zip
Read and patch ZIP archives with no dependencies, using the platform's own DEFLATE.
ZIP is the container behind OOXML (.docx / .xlsx / .pptx), EPUB, ODF and browser extensions. "Pull one file out of this archive" and "rewrite one file inside this archive" come up constantly, and a full ZIP library is a large dependency for that. Everything those two tasks need is the central directory plus DEFLATE — and DEFLATE now ships in every browser as DecompressionStream.
API
| Function | Description |
|---|---|
readZipEntries(bytes) | Parse the central directory into ZipEntry[]; [] when not a ZIP |
readZipEntry(bytes, nameOrEntry) | Decompress one entry; null when missing or unsupported |
zipHasEntry(bytes, name) | Whether an entry with that exact name exists |
rewriteZip(bytes, options) | Rebuild the archive with entries replaced and/or new ones appended |
createZip(files) | Build an archive from scratch, every entry STORED |
crc32(data) | IEEE CRC32, the checksum ZIP stores per entry |
inflateRaw(data) | Decompress raw DEFLATE bytes (no zlib/gzip wrapper) |
rewriteZip options
| Option | Description | Default |
|---|---|---|
filter | Which entries to decompress and pass to transform | every file |
transform | (data, entry) => Uint8Array | string | null; null leaves the entry alone | — |
inject | Brand-new entries to append: { name, data }[] | — |
ZipEntry
| Field | Description |
|---|---|
name | Path inside the archive, e.g. word/document.xml |
compression | ZIP_STORED (0) or ZIP_DEFLATE (8) |
crc, compressedSize, uncompressedSize | As recorded in the central directory |
modTime, modDate | MS-DOS packed timestamp, preserved on rewrite |
directory | Whether the name ends in / |
dataStart | Offset of the compressed bytes in the source |
Example
Read one file out of a .docx
import { readZipEntry } from 'ranuts';
const bytes = new Uint8Array(await file.arrayBuffer());
const xml = await readZipEntry(bytes, 'word/document.xml');
if (xml) {
const doc = new DOMParser().parseFromString(new TextDecoder().decode(xml), 'text/xml');
}List what is inside
import { readZipEntries } from 'ranuts';
for (const entry of readZipEntries(bytes)) {
if (entry.directory) continue;
console.log(entry.name, entry.uncompressedSize);
}Patch every XML part and add a file
import { rewriteZip } from 'ranuts';
const patched = await rewriteZip(bytes, {
filter: (entry) => entry.name.endsWith('.xml'),
transform: (data) => new TextDecoder().decode(data).replace(/
/g, ' '),
inject: [{ name: 'meta.json', data: JSON.stringify({ patched: true }) }],
});Extract embedded media as object URLs
import { readZipEntries, readZipEntry, getMime } from 'ranuts';
const media = {};
for (const entry of readZipEntries(bytes)) {
if (!entry.name.startsWith('word/media/')) continue;
const data = await readZipEntry(bytes, entry);
if (!data) continue;
const ext = entry.name.split('.').pop();
media[entry.name] = URL.createObjectURL(new Blob([data], { type: getMime(`.${ext}`) }));
}Build a container
import { createZip } from 'ranuts';
const zip = createZip([
{ name: 'mimetype', data: 'application/epub+zip' },
{ name: 'META-INF/container.xml', data: containerXml },
]);Notes
Reads STORED and DEFLATE. Other compression methods appear in
readZipEntriesbutreadZipEntryreturnsnullfor them rather than guessing.Rewrites are uncompressed. Replaced and injected entries are written STORED, so the output is larger than the input. Untouched entries keep their original compressed bytes copied verbatim. That is the right trade for patch-and-hand-off, the wrong one for archiving.
rewriteZipreturns the original array when nothing changed — including when a transform returns identical bytes. The no-op path costs nothing and the result can be compared with===.Sizes come from the central directory, never from local headers. Archives written by a streaming writer set general-purpose bit 3 and leave zeros in the local header, filling the real values into a data descriptor after the compressed bytes. Trusting local headers is the most common way a hand-rolled ZIP reader breaks on real files;
rewriteZipalso writes fresh local headers and clears that flag, so its output is readable by strict parsers.A failing transform keeps the entry. If
transformthrows, or the entry uses an unsupported method, the original content is copied through — a rewrite must never lose data it failed to understand.No ZIP64, no encryption, no multi-disk. Archives over 4 GiB or with more than 65535 entries are out of scope.
readZipEntriesreturns[]for anything it cannot parse rather than throwing, because the usual caller is inspecting a user-supplied file.inflateRawneedsDecompressionStream— available in all current browsers and in Node 18+. It throws where the API is missing.