原文:https://www.igorkromin.net/index.php/2018/09/06/hijacking-html-canvas-and-png-images-to-store-arbitrary-text-data/

我最近遇到了一个有趣的Web应用程序项目,该项目有一个特殊的要求:既要提供保存/加载功能,有不能依赖于cookie、本地存储或服务器端存储(没有帐户,也不用登录)。对于这个保存功能,我的实现方法是,首先获取数据,将其序列化为JSON,然后动态创建一个带有数据URL下载属性集的新链接元素,并在该链接上触发单击事件。虽然这种方式在桌面浏览器上运行状况良好,但是在移动版Safari浏览器上,却惨遭失败。

之所以出现这种情况,问题在于移动版Safari浏览器会忽略link元素中的download属性。这样的话,就会导致序列化的JSON数据将显示在浏览器窗口中,而无法将其存储到用户的设备上。同时,我们还没有办法禁用它。

对于上述问题,我们的解决方案是:向用户显示存储数据的内容,并将其保存到设备中。很明显,就这里来说,图像是一个显而易见的选择。虽然它无法创建相同的保存/加载体验,但是已经足够接近了。

我们曾经尝试过二维码,结果发现,虽然它们非常容易生成,但解码的时候,事情就没有那么简单了,并且需要包含一些非常臃肿的库,所以,我们很快就将其放弃了。

接下来的挑战是,如何在PNG中存储任意文本数据。实际上,这并不是一个新想法,并且之前已经做过,但是我不想建立一个完全通用的存储容器,倒是很乐意施加一些约束,从而使我的工作更轻松一些。

约束/需求


  1. 生成的图像必须易于保存,并且应具有预设的尺寸。
  2. 需要保存/加载的数据的大小约为几十千字节。
  3. 需要将数据存储为JSON。
  4. 不必关心任何特定图像格式的保存/加载细节。

听起来很简单,对吧?实际上,这里有几个坑。但首先让我们看看一般性的方法。

图像实际上就是一个2D像素阵列。每个像素对应一个3个字节的元组,每个字节对应于RGB中的一个颜色分量。其中,每个颜色分量的取值范围为0到255。这种组织方法有助于自然地存储字节/字符数组。例如,单个像素可用于存储一个ASCII字符数组['F', 'T', 'W'],这实际上就是将ASCII码编码为颜色强度,具体如图所示...

这样,我们就会得到一个相当灰暗无聊的像素,但它存储了我们想要的数据。利用这种方式,我们可以对一个整个句子进行类似的处理,例如“The quick brown fox jumps over the lazy dog”进行相应的编码处理后,得到如下所示的序列......

84  104 101
32  113 117
105 99  107
32  98  114
111 119 110
32  102 111
120 32  106
117 109 112
115 32  111
118 101 114
32  116 104
101 32  108
97  122 121
32  100 111
103

这实际上就是得到了15个像素,具体如下所示......

注意,最后一个3元组中只有一个字符代码,因此,需要用两个零值进行填充,从而得到一个像素。

上面介绍的是一种基本的方法。但是,它无法满足我们的所有需求:

  1. 虽然存储和生成由1行像素组成的图像是最容易实现的,但是,要点击这一行像素却并不容易,所以,必须使用足够大的方形图像。使用图像的预设最大尺寸(256x256像素)可以很好地实现这一目的,但需要跟踪实际编码的数据的大小。这里的编码大小实际上就是正方形的长度,所以,必须将其存储在生成的图像中。如果让第一个像素采用单一颜色的话,我们可以得到一个最大尺寸为255x255像素的正方形——第一行像素被用来存储这个表示大小的值,因为它是一个正方形,因此,图像中的最后一列也会被“没收”。待编码的字节/字符数组的大小也必须以某种方式保存下来,这需要一个字节以上的存储空间,为此,我们可以借助于第一行像素中的剩余部分来处理这个问题(实际上,我并没有通过这种方法来处理这个问题,这个将在后面介绍)。
  2. 由于可用像素数据的最大尺寸为255x255像素,因此,我可以使用65025像素。反过来,这可以转换为195075字节(190kB)的文本数据。这远远高于我的实际需求。
  3. 借助于TextEncoder,可以将序列化的JSON数据转换为字节数组(即JavaScript中的Uint8Array)。
  4. 借助于屏外画布,可以随意操作像素数据,然后将其转换为相应格式的图像数据URL。

将对象转换为字节数组


现在,我们已经找到了一个通用方法,并为字节数组找到了相应的容器。下一步的工作,就是将对象转换为可以存储在字节数组中的形式。这其实非常简单,只需借助JSON.stringify()TextEncoder.encode(),我们就能够得到一个Uint8Array。之后,还可以计算出存储这些数据所需的方形图像的大小。

var strData = JSON.stringify(myObjData);
var uint8array = (new TextEncoder('utf-8')).encode(strData);
var dataSize = Math.ceil(Math.sqrt(uint8array.length / 3));

将字节数组转换为图像数据


接下来,我们需要读取字节数组中的数据,并将其转换为可与画布一起使用的ImageData对象。在这里,我遇到了第一个坎——ImageData需要的是Uint8ClampedArray,我们这里的却是Uint8Array。从根本上说,由于我的数据在某种意义上已被TextEncoder转换所“钳制”,所以我并不需要太担心这个问题。

由于我们需要无损格式来存储图像数据,因此,这里选择将PNG作为输出格式。这也意味着,我们不是将数据存储为RGB格式,而是将其存储为RGBA格式。也就是说,每个像素还有一个额外的Alpha通道,因此,需要用到额外的字节。但是经过一番尝试后发现,当alpha通道设置为零时,会遇到一个RGB受损方面的问题。

这给我带来了很大的麻烦,我不得不鼓捣代码,将3元组字节数组转换为4元组数组,并将第4个(alpha)分量设置为完全不透明(即255)。这对于以后的解码来说是一个优势,因为可以轻松跳过所有零填充数据。它不是最有效的代码,但是,至少能够达到预期目的。

var paddedData = new Uint8ClampedArray(dataSize * dataSize * 4);
var idx = 0;
for (var i = 0; i < uint8array.length; i += 3) {
  var subArray = uint8array.subarray(i, i + 3);
  paddedData.set(subArray, idx);
  paddedData.set([255], idx + 3);
  idx += 4;
}

作为附带的好处,现在得到了正确类型的Uint8ClampedArray字节数组,所以,我们终于可以构造ImageData对象了。

var imageData = new ImageData(paddedData, dataSize, dataSize);

绘制图像


有了ImageData对象,接下来就可以创建一个画布,以便绘制JSON编码的图像数据。首先,我们需要将画布创建为“off screen”类型,并检索其上下文,并将背景设置为纯色(实际上,这里的实际颜色无关紧要)。

var imgSize = 256;
var canvas = document.createElement('canvas');
canvas.width = canvas.height = imgSize;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#AA0000';
ctx.fillRect(0, 0, imgSize, imgSize);

然后,我们就可以“绘制”表示方形图像大小的像素了。实际上,这里的方形图形就是我们编码后的数据。

ctx.fillStyle = 'rgb(' + dataSize +',0,0)';
ctx.fillRect(0, 0, 1, 1);

下面,我们来渲染图像数据……

ctx.putImageData(imageData, 0, 1);

保存图像


现在,我们可以将图像从画布保存到文件系统(对于移动版Safari浏览器来说,就是显示到一个新选项卡中),这需要借助于一些jQuery代码...

$('body').append('<a id="hiddenLink" href="' + canvas.toDataURL() +
  '" style="display:none;" download="image.png">Download</a>');
var link = $('#hiddenLink')[0];
link.click();
link.remove();

最终结果是这样的:

当然,下一步是解码图像并从中取出原始JSON,这些步骤我们将在下一篇文章中加以详细介绍,请读者耐心等待。祝阅读愉快!

源链接

Hacking more

...