eligrey / eligrey/FileSaver.js

Saving multiple files

Open
#558 6 comments 10 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
22k
Forks
4.3k
PR merge metrics
No merged PRs in 30d

Description

Hi everyone.

I know this topics comes up some times, Just want to share a solution to saving everything as one zip file

Background
------

I got a bit tired of jszip memory management and lack of development and there was no good streaming solution for the browser unless you shipped the hole node Stream to browsers as well.

- Also thought jsZip was too large and had to many unnecessary features. in that it could read/write a bunch of different formats (base64, blobs, typedArrays, string) and much more
- zip.js was to complicated but had nicer abstract reading/writing methods. But couldn't handle unknown sizes (like a [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/ReadableStream))

None of those fell into my taste so i rolled my own. i just wanted to be able to create a zip file not reading them, so that I could save multiple files in one go as a zip. (i was not interested in reading zips or compressing multiple files since minimizing, sharing or uploading was not the design goal)
. The hole goal was to reduces the numbers of save dialogs into one.

So i ripped out the inflate, deflate and parsing of existing zips.

I maybe want to incorporate this into [StreamSaver](https://github.com/jimmywarting/StreamSaver.js) eventually.

zip.js ( less then 200 lines of code 🙂)

```js
class Crc32 {
constructor () {
this.crc = -1
}

append (data) {
var crc = this.crc | 0; var table = this.table
for (var offset = 0, len = data.length | 0; offset < len; offset++) {
crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF]
}
this.crc = crc
}

get () {
return ~this.crc
}
}
Crc32.prototype.table = (() => {
var i; var j; var t; var table = []
for (i = 0; i < 256; i++) {
t = i
for (j = 0; j < 8; j++) {
t = (t & 1)
? (t >>> 1) ^ 0xEDB88320
: t >>> 1
}
table[i] = t
}
return table
})()

const getDataHelper = byteLength => {
var uint8 = new Uint8Array(byteLength)
return {
array: uint8,
view: new DataView(uint8.buffer)
}
}

const pump = zipObj => zipObj.reader.read().then(chunk => {
if (chunk.done) return zipObj.writeFooter()
const outputData = chunk.value
zipObj.crc.append(outputData)
zipObj.uncompressedLength += outputData.length
zipObj.compressedLength += outputData.length
zipObj.ctrl.enqueue(outputData)
})

/**
* [createWriter description]
* @param {Object} underlyingSource [description]
* @return {Boolean} [description]
*/
function createWriter (underlyingSource) {
const files = Object.create(null)
const filenames = []
const encoder = new TextEncoder()
let offset = 0
let activeZipIndex = 0
let ctrl
let activeZipObject, closed

function next () {
activeZipIndex++
activeZipObject = files[filenames[activeZipIndex]]
if (activeZipObject) processNextChunk()
else if (closed) closeZip()
}

var zipWriter = {
enqueue (fileLike) {
if (closed) throw new TypeError('Cannot enqueue a chunk into a readable stream that is closed or has been requested to be closed')

let name = fileLike.name.trim()
const date = new Date(typeof fileLike.lastModified === 'undefined' ? Date.now() : fileLike.lastModified)

if (fileLike.directory && !name.endsWith('/')) name += '/'
if (files[name]) throw new Error('File already exists.')

const nameBuf = encoder.encode(name)
filenames.push(name)

const zipObject = files[name] = {
level: 0,
ctrl,
directory: !!fileLike.directory,
nameBuf,
comment: encoder.encode(fileLike.comment || ''),
compressedLength: 0,
uncompressedLength: 0,
writeHeader () {
var header = getDataHelper(26)
var data = getDataHelper(30 + nameBuf.length)

zipObject.offset = offset
zipObject.header = header
if (zipObject.level !== 0 && !zipObject.directory) {
header.view.setUint16(4, 0x0800)
}
header.view.setUint32(0, 0x14000808)
header.view.setUint16(6, (((date.getHours() << 6) | date.getMinutes()) << 5) | date.getSeconds() / 2, true)
header.view.setUint16(8, ((((date.getFullYear() - 1980) << 4) | (date.getMonth() + 1)) << 5) | date.getDate(), true)
header.view.setUint16(22, nameBuf.length, true)
data.view.setUint32(0, 0x504b0304)
data.array.set(header.array, 4)
data.array.set(nameBuf, 30)
offset += data.array.length
ctrl.enqueue(data.array)
},
writeFooter () {
var footer = getDataHelper(16)
footer.view.setUint32(0, 0x504b0708)

if (zipObject.crc) {
zipObject.header.view.setUint32(10, zipObject.crc.get(), true)
zipObject.header.view.setUint32(14, zipObject.compressedLength, true)
zipObject.header.view.setUint32(18, zipObject.uncompressedLength, true)
footer.view.setUint32(4, zipObject.crc.get(), true)
footer.view.setUint32(8, zipObject.compressedLength, true)
footer.view.setUint32(12, zipObject.uncompressedLength, true)
}

ctrl.enqueue(footer.array)
offset += zipObject.compressedLength + 16
next()
},
fileLike
}

if (!activeZipObject) {
activeZipObject = zipObject
processNextChunk()
}
},
close () {
if (closed) throw new TypeError('Cannot close a readable stream that has already been requested to be closed')
if (!activeZipObject) closeZip()
closed = true
}
}

function closeZip () {
var length = 0
var index = 0
var indexFilename, file
for (indexFilename = 0; indexFilename < filenames.length; indexFilename++) {
file = files[filenames[indexFilename]]
length += 46 + file.nameBuf.length + file.comment.length
}
const data = getDataHelper(length + 22)
for (indexFilename = 0; indexFilename < filenames.length; indexFilename++) {
file = files[filenames[indexFilename]]
data.view.setUint32(index, 0x504b0102)
data.view.setUint16(index + 4, 0x1400)
data.array.set(file.header.array, index + 6)
data.view.setUint16(index + 32, file.comment.length, true)
if (file.directory) {
data.view.setUint8(index + 38, 0x10)
}
data.view.setUint32(index + 42, file.offset, true)
data.array.set(file.nameBuf, index + 46)
data.array.set(file.comment, index + 46 + file.nameBuf.length)
index += 46 + file.nameBuf.length + file.comment.length
}
data.view.setUint32(index, 0x504b0506)
data.view.setUint16(index + 8, filenames.length, true)
data.view.setUint16(index + 10, filenames.length, true)
data.view.setUint32(index + 12, length, true)
data.view.setUint32(index + 16, offset, true)
ctrl.enqueue(data.array)
ctrl.close()
}

function processNextChunk () {
if (!activeZipObject) return
if (activeZipObject.directory) return activeZipObject.writeFooter(activeZipObject.writeHeader())
if (activeZipObject.reader) return pump(activeZipObject)
if (activeZipObject.fileLike.stream) {
activeZipObject.crc = new Crc32()
activeZipObject.reader = activeZipObject.fileLike.stream().getReader()
activeZipObject.writeHeader()
} else next()
}
return new ReadableStream({
start: c => {
ctrl = c
underlyingSource.start && Promise.resolve(underlyingSource.start(zipWriter))
},
pull () {
return processNextChunk() || (
underlyingSource.pull &&
Promise.resolve(underlyingSource.pull(zipWriter))
)
}
})
}

window.ZIP = createWriter
```

Questions?
---

This zip version operates on ReadableStream there are three ways of getting them atm
```js
new ReadableStream()
new Response().body
new TextEncoderStream()
```
but there will soon be a forth one as well
```js
new Blob().stream() // workaround new Response(blob).body
```

I didn't choose to operate on WritableStream, since FF haven't included it yet. it might seem weird to write a zip file using only ReadableStream but think of it as a zip writer that reads multiple ReadableStreams to accumulate one zip file 🤪

How to use
---

If you have ever worked with ReadableStreams sometime this is quite easy to understand and learn and how to use, cuz the zip constructor (api) is the same as ReadableStreams, supporting tee, start, enqueue, pull, close and all the other things that you can get with ReadableStream.

Example of creating a zip with two files

```js
const file1 = new File(['content of file one'], 'some folder/filename.txt', {
lastModified: new Date('2000-01-01')
})
const file2 = new File(['content of file two'], 'some folder/filename two.txt', {
lastModified: new Date('2000-01-01')
})

const readableStream = new ZIP({
start (ctrl) {
ctrl.enqueue(file1)
ctrl.enqueue(file2)
ctrl.close()
}
})
```

**Now, how do i save this stream with fileSaver?**
use the Response to turn it into a blob
```js
new Response(readableStream).blob().then(blob => {
saveAs(blob, 'archive.zip')
})
```

The stream is a "object stream" and is therefore based on enqueuing objects instead of typed arrays.
the objects takes something that is similar to a [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object, meaning it must have: this properties:
```js
// adding a file
ctrl.enqueue({
name: 'string',
lastModified: Date || datetime, // optional
stream() {
return new ReadableStream()
}
})

// adding a folder
ctrl.enqueue({
name: 'string',
lastModified: Date || datetime, // optional
directory: true
})
```

So my zip version dose not handle Files or Blobs as per say, and it dosen't even use the FileReader...

so you could add something from a remote place also

```js
const res = await fetch(image)

ctrl.enqueue({
name: 'cat.jpg',
stream() {
return res.body
}
})
```

or try to be somewhat creative with TextEncoderStream

you can also use the pull as a factory function, so that it dose one thing at the time.

```js
new ZIP({
pull (ctrl) { // will only be called when zip.js requires more data
ctrl.enqueue(file)
if (noMore) ctrl.close()
}
})

// likewise, stream() is also only called one at the time
// it dosen't call multiple streams simultaneously and buffer up lots of memory
// as it has to write the things in the order you added them
```

One last thing
---

Add a `blob.stream()` polyfill
```js
Blob.prototype.stream = Blob.prototype.stream || function () {
return new Response(this).body
}
```

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.