zip.ts

examples/sogs/zip.ts · raw · open the example · all examples · site home

Exports: ZipEntryZipArchive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// A minimal ZIP reader for bundled SOG files (.sog, or Spark's SOGS .zip):
// the end-of-central-directory record, the central directory and each
// entry's local header; entries stored or deflated (inflated by the
// browser's DecompressionStream). Zip64 archives are rejected.

export interface ZipEntry {
  name: string;
  method: number;
  compressedSize: number;
  size: number;
  headerOffset: number;
}

const EOCD = 0x06054b50;
const CENTRAL = 0x02014b50;
const LOCAL = 0x04034b50;

export class ZipArchive {
  readonly bytes: Uint8Array<ArrayBuffer>;
  readonly entries = new Map<string, ZipEntry>();

  constructor(bytes: Uint8Array<ArrayBuffer>) {
    this.bytes = bytes;
    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);

    let end = -1;
    for (let at = bytes.byteLength - 22; at >= Math.max(0, bytes.byteLength - 22 - 65535); at -= 1) {
      if (view.getUint32(at, true) === EOCD) {
        end = at;
        break;
      }
    }
    if (end < 0) {
      throw new Error("ZIP: no end of central directory");
    }

    const total = view.getUint16(end + 10, true);
    let at = view.getUint32(end + 16, true);
    if (total === 0xffff || at === 0xffffffff) {
      throw new Error("ZIP: Zip64 archives are not supported");
    }

    const decoder = new TextDecoder();
    for (let i = 0; i < total; i += 1) {
      if (view.getUint32(at, true) !== CENTRAL) {
        throw new Error("ZIP: bad central directory");
      }
      const nameLength = view.getUint16(at + 28, true);
      const name = decoder.decode(bytes.subarray(at + 46, at + 46 + nameLength));
      this.entries.set(name, {
        name,
        method: view.getUint16(at + 10, true),
        compressedSize: view.getUint32(at + 20, true),
        size: view.getUint32(at + 24, true),
        headerOffset: view.getUint32(at + 42, true),
      });
      at += 46 + nameLength + view.getUint16(at + 30, true) + view.getUint16(at + 32, true);
    }
  }

  static isZip(bytes: Uint8Array) {
    return bytes.byteLength >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 3 && bytes[3] === 4;
  }

  // The bytes of entry `name`
  async read(name: string): Promise<Uint8Array<ArrayBuffer>> {
    const entry = this.entries.get(name);
    if (!entry) {
      throw new Error(`ZIP: no entry ${name}`);
    }
    const view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
    const at = entry.headerOffset;
    if (view.getUint32(at, true) !== LOCAL) {
      throw new Error(`ZIP: bad local header for ${name}`);
    }
    const start = at + 30 + view.getUint16(at + 26, true) + view.getUint16(at + 28, true);
    const data = this.bytes.subarray(start, start + entry.compressedSize);
    if (entry.method === 0) {
      return data.slice();
    }
    if (entry.method !== 8) {
      throw new Error(`ZIP: ${name} uses compression method ${entry.method}`);
    }
    const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
    return new Uint8Array(await new Response(stream).arrayBuffer());
  }
}