😽

ブラウザ内でバイナリを圧縮してコードやlocalStorageに埋め込む

2022/04/28に公開3

JS で wasm のダウンロードや TypedArry を通じた操作をやってると、コード内や localStorage にバイナリを埋め込みたいときがあります。

考え方

  • JS の内部エンコーディングは UTF16 と決められているので、UTF16で表現可能な範囲を1文字として、バイナリをインライン化すればサイズが小さくて済むはず
  • Chrome は CompressionStearm でブラウザ内で deflate できるので、あれば圧縮する
    • https://chromestatus.com/feature/5855937971617792
    • Chrome ではない場合、deflate 処理は飛ばしてそのまま。localStorage の読み書きなら途中でブラウザ自体のサポート増える/消えるなどしない限り一貫性は取れる
    • 今回はやってないが、インラインJSに埋め込む場合、50kb を超えたあたりで pako を dynamic import して使ってフォールバックしてもよい

やってみた

const encoder = new TextEncoder();
const decoder = new TextDecoder();

const compress_supported = !!globalThis.DecompressionStream;

async function deflate(str: string): Promise<ArrayBuffer> {
  const buf = encoder.encode(str);
  if (compress_supported) {
    const stream = new Response(buf).body!.pipeThrough(new CompressionStream("deflate"));
    const buf2 = await new Response(stream as any).arrayBuffer();
    return buf2;
  } else {
    return buf.buffer;
  }
}

async function inflate(buffer: ArrayBuffer): Promise<string> {
  if (compress_supported) {
    const decompressedStream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream("deflate"));
    const buf = await new Response(decompressedStream as any).arrayBuffer();
    return decoder.decode(buf);
  } else {
    return decoder.decode(buffer);
  }
}

const encode = async (raw: string) => {
    const buf = await deflate(raw);
    const u8 = new Uint8Array(buf.byteLength % 2 ? buf.byteLength + 1 : buf.byteLength);
    u8.set(new Uint8Array(buf));
    const u16 = new Uint16Array(u8.buffer);
    const u16str = Array.from(u16).map(s => String.fromCharCode(s))));
    return u16str.join('');
));

const decode = async (encoded: string) => {
    const u16c = Array.from(encoded).map((c) => c.charCodeAt(0));
    const u8c = new Uint8Array(new Uint16Array(u16c).buffer);
    const last = u8c.at(-1);
    try {
        return await inflate(
            last === 0 ? u8c.slice(0,-1) : u8c
        );
    } catch (err) {
        return await inflate(u8c);
    }
}

使う

// usage

const input = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
`;

(async () => {
    console.time('all');
    console.time('encode');
    const encoded = await encode(input);
    console.timeEnd('encode');
    console.time('decode');
    const decoded = await decode(encoded);
    console.timeEnd('decode');
    console.timeEnd('all');
})();

結果

encode 結果は 1340byte => 143byte。143byte と小さいのは、lolem ipsum を3回コピペしてるので、同じ部分が符号化されて deflate が効いているのがわかります。

encode された内容はこんな感じ。

鱸凭燋㄃봈誻聗ꭧ湈ꚾ∀ັ鈳▐룰ꃼ剬⹃፻�鮏긎㺐⊽텫⦁ꨆ坬洤鎓鈴攀㋩뒓렻㑈X쾋ᦪ뗆닇⒴뉙茷ਛٽ�栎ꕆ⍻酐펇྆㜃膩⪍銌ᗪ霏ꚉ蛓䞋⌒洓勰⚨逽郗奌㽌튐ᡣ⅌蚼㴦Ќ涕學郆ꇡപﮃ⿠饮ᡇ辏ᚧ䇯⇇鰧⑤ꄯ﷘搮䭨㨐⢍汼罸뻆豢㐌猔믉뉤ᆵ郞쥐囜⮊₩幍ⴺ탟靽萤鏌ⵖً肭뾘窹⻝�빹糳裏�ퟦ嫯

URLに使うのは安全ではありませんが、JS内で取り回す文字列としては問題ないです。

ただ、青空文庫から引っこ抜いてきたデータを突っ込んでみたら、charCode が 65536 を超えて動きませんでした。バイナリなら問題ないですが日本語はもうちょっと工夫が必要そうです。codePoint を使うとなんとかなるかも。

Discussion

Hajime HoshiHajime Hoshi

バイナリなら問題ないですが日本語はもうちょっと工夫が必要そうです。

よく分からなかったのですが、任意のバイナリに対応しているわけではない、ということでしょうか?

mizchimizchi

書くのサボったんですが、コードポイントが u16 を超えてると、UInt16Array への変換処理で溢れて壊れました

nujarumnujarum

これは・・・
Uint8ArrayUint16Array のところがエンディアン依存・・・ですよね?

今回の用途は localStorage とのことなので、同一環境での enc/dec だと存じますが、
encode された文字列を異なる環境間で同等に扱うには、上位/下位 bit の扱いを統一する等の改変が必要そうですね。
(Little Endian 方式に統一すれば、slice(0, -1) 等の条件分岐は不要になる?かも・・・)

console.log([...new Uint8Array(Uint16Array.of(0x100).buffer)])
// » [0, 1] (LE) or [1, 0] (BE)