来源:https://www.cnblogs.com/index-html/p/canvas_data_compress.html

前言

HTTP 支持 GZip 压缩,可节省不少传输资源。但遗憾的是,只有下载才有,上传并不支持。如果上传也能压缩,那就完美了。特别适合大量文本提交的场合,比如博客园,就是很好的例子。

虽然标准不支持「上传压缩」,但仍可以自己来实现。

Flash

首选方案当然是 Flash,毕竟它提供了压缩 API。除了 zip 格式,还支持 lzma 这种超级压缩。因为是原生接口,所以性能极高。而且对应的 swf 文件,也非常小。

JavaScript

Flash 逐渐淘汰,但取而代之的 HTML5,却没有提供压缩 API。只能自己用 JS 实现。

这虽然可行,但运行速度就慢多了,而且相应的 JS 也很大。如果代码有 50kb,而数据压缩后只小 10kb,那就不值了。除非量大,才有意义。 其他

能否不用 JS,而是利用某些接口,间接实现压缩?事实上,在 HTML5 刚出现时,就注意到了一个功能:canvas 导出图片。可以生成 JPG、PNG 等格式。

如果在思考的话,相信你也想到了。没错,就是 PNG —— 它是无损压缩的图片格式。我们把普通数据当成像素点,画到 canvas 上,然后导出成 PNG,不就是一个特殊的压缩包了吗!

下面开始探索。。。

编码

数据转像素,并不麻烦。1 个像素可以容纳 4 个字节:

R = bytes[0]
G = bytes[1]
B = bytes[2]
A = bytes[3]

事实上有现成的方法,可批量将数据填充成像素:

var img = new ImageData(bytes, w, h);
context.putImageData(img, 0, 0);

但是,图片的宽高如何设定? 尺寸

最简单的,就是用 1px 的高度。比如有 1000 个像素,则填在 1000 x 1 的图片里。

但如果有 10000 像素,就不可行了。因为 canvas 的尺寸,是有限制的。

不同的浏览器,最大尺寸不一样。有 4096 的,也有 32767 的。。。

以最大 4096 为例,如果每次都用这个宽度,显然不合理。

比如有 n = 4100 个像素,我们使用 4096 x 2 的尺寸:

| 1 | 2 | 3 | 4 | … | 4095 | 4096 | | 4097 | 4098 | 4099 | 4100 | …… 未利用 ……

第二行只用到 4 个,剩下的 4092 个都空着了。

但 4100 = 41 * 100。如果用这个尺寸,就不会有浪费。

所以,得对 n 分解因数:

n = w * h

这样就能将 n 个像素,正好填满 w x h 的图片。

但 n 是质数的话,就无解了。这时浪费就不可避免了,只是,怎样才能浪费最少?

于是就变成这样一个问题:

如何用 n + m 个点,拼成一个矩形。求矩形的 w 和 h。(n 已知,m 越小越好,0 < w <= MAX, 0 < h <= MAX)

考虑到 MAX 不大,穷举就可以。

我们遍历 h,计算相应的 w = ceil(n / h), 然后找出最接近 n 的 w * h。

var MAX = 4096;
var beg = Math.ceil(n / MAX);
var end = Math.ceil(Math.sqrt(n));

var minSize = 9e9;

var bestH = 0,          // 最终结果
    bestW = 0;

for (h = beg; h <= end; h++) {
    var w = Math.ceil(n / h);
    var size = w * h;

    if (size < minSize) {
        minSize = size;
        bestW = w;
        bestH = h;
    }
    if (size == n) {
        break;
    }
}

因为 w * h 和 h * w 是一样的,所以只需遍历到 sqrt(n) 就可以。

同样,也无需从 1 开始,从 n / MAX 即可。

这样,我们就能找到最适合的图片尺寸。

当然,连续的空白像素,最终压缩后会很小。这一步其实并不特别重要。

渲染

定下尺寸,我们就可以「渲染数据」了。

渲染看似简单,然而事实上却有个意想不到的坑 —— 同个像素写入后再读取,数据居然会有偏差!这里有个测试:

var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');

// 写入的数据
var bytes = [100, 101, 102, 103];

var buf = new Uint8ClampedArray(bytes);
var img = new ImageData(buf, 1, 1);
ctx.putImageData(img, 0, 0);

// 读取的数据
img = ctx.getImageData(0, 0, 1, 1);
console.log(img.data);

// 期望     [100, 101, 102, 103]
// 实际
// chrome  [99,  102, 102, 103]
// firefox [101, 101, 103, 103]
// ...

读取的值和写入的很接近,但并不相同。而且不同的浏览器,偏差还不一样!这究竟是怎么回事?

原来,浏览器为了提高渲染性能,有一个 Premultiplied Alpha 的机制。但是,这会牺牲一些精度!虽然视觉上并不明显,但用于数据存储,就有问题了。

如何禁用它?一番尝试都没成功。于是,只能从数据上琢磨。如果不使用 Alpha 通道,又会怎样?

// 写入的数据
var bytes = [100, 101, 102, 255];
...
console.log(img.data);  // [100, 101, 102, 255]

设置 A = 255,这样倒是避开了问题。

看来,只能从数据上着手,跳过 Alpha 通道:

// pixel 1
new_bytes[0] = bytes[0]     // R
new_bytes[1] = bytes[1]     // G
new_bytes[2] = bytes[2]     // B
new_bytes[3] = 255          // A

// pixel 2
new_bytes[4] = bytes[3]     // R
new_bytes[5] = bytes[4]     // G
new_bytes[6] = bytes[5]     // B
new_bytes[7] = 255          // A

...

这时,就不受 Premultiplied Alpha 的影响了。

出于简单,也可以 1 像素存 1 字节:

// pixel 1
new_bytes[0] = bytes[0]
new_bytes[1] = 255
new_bytes[2] = 255
new_bytes[3] = 255

// pixel 2
new_bytes[4] = bytes[1]
new_bytes[5] = 255
new_bytes[6] = 255
new_bytes[7] = 255

...

这样,整个图片最多只有 256 色。如果能导出成「索引型 PNG」的话,也是可以尝试的。

解码

最后,就是将图像导出成可传输的数据。如果 canvas 能直接导出成 blob,那是最好的,因为 blob 可通过 AJAX 上传。

canvas.toBlob(function(blob) {
    // ...
}, 'image/png')

不过,大多浏览器都不支持,只能导出 data uri 格式:

uri = canvas.toDataURL('image/png')  // data:image/png;base64,xxxx

然而 base64 会增加 13 的长度,这样压缩效果就大幅降低了。所以,我们还得解码成二进制:

base64 = uri.substr(uri.indexOf(',') + 1)
binary = atob(base64)

这时的 binary,就是最终想要的数据了吗?如果将 binary 通过 AJAX 提交的话,会发现实际传输字节,会比 binary.length 大!

原来 atob 函数返回的数据,仍是字符串型的,所以传输时会涉及到字集编码。因此我们还需再转换一次,变成真正的二进制类型:

var len = binary.length
var buf = new Uint8Array(len)

for (var i = 0; i < len; i++) {
    buf[i] = binary.charCodeAt(i)
}

这时的 buf,才能被 AJAX 原封不动的传输。

演示

综上所述,我们简单演示下:https://www.etherdream.com/FunnyScript/jszip/encode.html

<html>
<head>
  <title>PNG 压缩数据</title>
  <meta charset="utf-8" />
  <style>
    #txtContent {
      width: 800px;
      height: 300px;
    }
    #txtResult {
      width: 800px;
      height: 100px;
    }

    canvas {
      border: 2px solid #000;
    }
  </style>
</head>
<body>
  <div>
    <span>压缩文字:</span>
    <a href="javascript:load('t1.txt')">测试内容</a>
  </div>
  <div>
    <textarea id="txtContent"></textarea>
  </div>
  <div>
    <span>编码方式:</span>
    <select id="selEncType">
      <option value="1px_1byte">每像素 1 字节</option>
      <option value="1px_3byte">每像素 3 字节</option>
    </select>
    <button id="btnCompress">压缩</button>
  </div>
  <div>
    <textarea id="txtResult" readonly></textarea>
  </div>
  <script>
    function find_best_size(pixelCount) {
      // canvas max width or height
      var MAX_L = 4096;

      var sqrt = Math.ceil(Math.sqrt(pixelCount));
      if (sqrt > MAX_L) {
        return null;
      }

      var minL = Math.ceil(pixelCount / MAX_L);
      var minS = 1e9;
      var bestH = 0, bestW = 0;

      for (var h = minL; h <= sqrt; h++) {
        var w = Math.ceil(pixelCount / h);
        var size = w * h;
        if (size < minS) {
          minS = size;
          bestW = w;
          bestH = h;
        }
        if (size == pixelCount) {
          break;
        }
      }
      return {w: bestW, h: bestH};
    }


    function str_to_bytes(str) {
      var len = str.length;
      var buf = new Uint8Array(len);

      for (var i = 0; i < len; i++) {
        buf[i] = str.charCodeAt(i);
      }
      return buf;
    }


    // 每像素存 1 字节
    function encode_1px_1byte(bytes) {
      var count = bytes.length;
      var size = find_best_size(count);

      var buffer = new ArrayBuffer(size.w * size.h * 4);
      var u32Ptr = new Uint32Array(buffer);

      // R: bytes[i], G: FF, B: FF, A: FF

      for (var i = 0; i < count; i++) {
        u32Ptr[i] = bytes[i] | 0xFFFFFF00;  // 0xAABBGGRR
      }

      var u8Ptr = new Uint8ClampedArray(buffer);
      return new ImageData(u8Ptr, size.w, size.h);
    }


    // 每像素存 3 字节
    function encode_1px_3byte(bytes) {
      var count = Math.ceil(bytes.length / 3);
      var size = find_best_size(count);

      var buffer = new ArrayBuffer(size.w * size.h * 4);
      var u32Ptr = new Uint32Array(buffer);

      // R: bytes[i], G: bytes[i+1], B: bytes[i+2], A: FF

      // 数组越界返回 undefined,位运算时当做 0
      for (var i = 0, j = 0; i < count; i++, j += 3) {
        u32Ptr[i] =             // 0xAABBGGRR
          bytes[j + 0] <<  0 |  // R 
          bytes[j + 1] <<  8 |  // G
          bytes[j + 2] << 16 |  // B
          0xFF000000;           // A
      }

      var u8Ptr = new Uint8ClampedArray(buffer);
      return new ImageData(u8Ptr, size.w, size.h);
    }

    var ENCODE_TYPE = {
      '1px_3byte': encode_1px_3byte,
      '1px_1byte': encode_1px_1byte
    };


    function compress(bytes, type, callback) {
      var fn = ENCODE_TYPE[type];
      var img = fn(bytes);

      var canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      canvas.getContext('2d').putImageData(img, 0, 0);

      if (canvas.toBlob) {    // fast way
        canvas.toBlob(callback, 'image/png');
      } else {
        // canvas to base64
        var uri = canvas.toDataURL('image/png');
        var base64 = uri.substr(uri.indexOf(',') + 1);

        // base64 to binStr
        var binStr = atob(base64);

        // binStr to uint8[]
        var bytes = str_to_bytes(binStr);

        callback(bytes);
      }

      // for test
      document.body.appendChild(canvas);
    }



    // ------------------------------
    // 测试代码
    // ------------------------------
    btnCompress.onclick = function() {
      var text = txtContent.value;
      if (!text) {
        return;
      }

      var txt_bytes = utf8_to_bytes(text);
      var enc_type = selEncType.options[selEncType.selectedIndex].value;
      var tick = Date.now();

      compress(txt_bytes, enc_type, function(result) {
        // blob or Uint8Array
        var png_len = result.length || result.size;

        var log =
          '编码方式:' + enc_type + '\n' +
          '压缩前:' + txt_bytes.length + ' 字节\n' +
          '压缩后:' + png_len + ' 字节\n' +
          '压缩率:' + (png_len / txt_bytes.length * 100).toFixed(1) + '%\n' +
          '耗时:' + (Date.now() - tick) + 'ms';

        console.log(log);
        txtResult.value = log;
      });
    }

    function utf8_to_bytes(str) {
      var buf = [];
      var i = 0, j = 0;

      var esc = encodeURI(str);
      var n = esc.length;
      while (i < n) {
        var ch = esc.charCodeAt(i);
        if (ch == 37) {   // '%'
          var hex = esc.substr(i + 1, 2);
          ch = parseInt(hex, 16);
          i += 3;
        } else {
          i++;
        }
        buf[j++] = ch;
      }
      return buf;
    }

    function load(f) {
      var xhr = new XMLHttpRequest();
      xhr.open('GET', f, true);
      xhr.send();
      xhr.onload = function() {
        txtContent.value = xhr.responseText;
      };
    }


    if (!window.Uint8ClampedArray) {
      alert('当前浏览器不支持')
    }
  </script>
</body>
</html>

找一个大块的文本测试。例如 qq.com 首页 HTML,有 637,101 字节。

先使用「每像素 1 字节」的编码,各个浏览器生成的 PNG 大小: Chrome FireFox Safari 体积 289,460 203,276 478,994 比率 45.4% 31.9% 75.2%

其中火狐压缩率最高,减少了 23 的体积。生成的 PNG 看起来是这样的:

不过遗憾的是,所有浏览器生成的图片,都不是「256 色索引」的。

再测试「每像素 3 字节」,看看会不会有改善: Chrome FireFox Safari 体积 297,239 202,785 384,183 比率 46.7% 31.8% 60.3%

Safari 有了不少的进步,不过 Chrome 却更糟了。

FireFox 有略微的提升,压缩率仍是最高的。生成如下图片:

结论

由于 canvas 导出图片时,无法设置压缩等级,而默认的压缩率并不高。所以这种方式,最终效果并不理想。

同样的数据,相比 Flash 压缩,差距就很明显了: deflate 算法 lzma 算法 体积 133,660 108,015 比率 21.0% 17.0%

并且 Flash 生成的是通用格式,后端解压时,使用标准库即可;而 PNG 还得位图解码、像素处理等步骤,很麻烦。

所以,现实中还是优先使用 Flash,本文只是开脑洞而已。 用例

虽然是个然并卵的黑科技,不过实际还是有用到过,曾用在一个较大日志上传的场合(并且不能用 Flash)。

好在后端仅仅储存而已,并不分析。所以,可以让管理员将日志对应的 PNG 图片下回本地,在自己电脑上解析。

解压更容易,就是将像素还原回数据,这里有个简陋的 Demo:https://www.etherdream.com/FunnyScript/jszip/decode.html

<!doctype html>
<html>
<head>
  <title>PNG 数据解压</title>
  <meta charset="utf-8" />
  <style>
    #txtURL {
      width: 500px;
    }
    #txtResult {
      width: 600px;
      height: 300px;
    }
  </style>
</head>
<body>
  <div>
    图片 URL:<input id="txtURL" type="text" value="https://i.loli.net/2017/12/13/5a30dac33eca1.png">
    <button id="btnLoad">加载</button>
  </div>
  <div>
    <textarea id="txtResult"></textarea>
  </div>
  <div id="imgBox">
    图片预览:
  </div>
  <script>
    function uncompress(img) {
      var w = img.width;
      var h = img.height;

      var canvas = document.createElement('canvas');
      canvas.width = w;
      canvas.height = h;

      var ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0);

      var imgData = ctx.getImageData(0, 0, w, h);
      var buf = imgData.data.buffer;

      // FIX: remove padding
      return decode_1px_3byte(buf);
    }


    function decode_1px_3byte(buf) {
      var u32 = new Uint32Array(buf);
      var len = u32.length;
      var ret = new Uint8Array(len * 3);
      var p = 0;

      for (var i = 0; i < len; i++) {
        var rgba = u32[i];
        ret[p++] = rgba;
        ret[p++] = rgba >>  8 & 0xff;
        ret[p++] = rgba >> 16 & 0xff;
      }
      return ret;
    }


    btnLoad.onclick = function() {
      btnLoad.disabled = true;
      txtResult.value = 'loading...';

      var img = new Image();

      // debug
      imgBox.appendChild(img);

      img.onload = function() {
        btnLoad.disabled = false;

        var bytes = uncompress(img);
        var dec = new TextDecoder();
        txtResult.value = dec.decode(bytes);
      };

      img.onerror = function() {
        btnLoad.disabled = false;
        txtResult.value = 'load error!';
      };

      img.crossOrigin = true;
      img.referrerPolicy = 'no-referrer';
      img.src = txtURL.value;
    };
  </script>
</body>
</html>

这样,既减少了上传流量,也节省服务器存储空间。