原文:
- 2018/09/06 Hijacking HTML canvas and PNG images to store arbitrary text data
- 2018/09/20 Retrieving data from hijacked PNG images using HTML canvas and Javascript
需求
实现数据的保存与加载。
前提是没有以下支持:
- Cookie
- LocalStorage
- Server Side Storage
设计
- 使用图片
- 支持几十 KB 数据
- 不需要考虑特定图片格式的处理细节
实现
关于保存为图片的方案选择
排除方案 1
数据 -> JSON -> 动态生成 Data URLs 与 下载链接。
移动端 Safari 上无效:不认 <a>
标记的 download
属性。
排除方案 2
二维码
编码容易,解码麻烦,需要一个很大的库。
选择方案:Canvas
每三个 ASCII 组成一个 RGB 像素,不足部分用 0 填充。
问题:图片太小的话,不便于点击保存(?原话:this is not easy to tap to save)
方案:预设大小:256 * 256
第一行保留(最后一列顺带着保留了,因为是数据方阵的设计)用来记录元数据,比如数据规模。
可用空间:255 * 255,65025 => 190KB
JSON 字符串 ---TextEncoder---> 字节数组
> (new TextEncoder('utf-8')).encode('Hello World')
Uint8Array(11) [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]
测试数据:
$ curl -X POST -qd "name=胡昂&location=中华人民共和国&job=软件开发工程师" https://httpbin.org/post | jq --tab
主要逻辑:
function createElementFromHTML(html) {
var emptyElement = document.createElement("empty");
emptyElement.innerHTML = html.trim();
return emptyElement.firstChild;
}
var exportedImage = null;
var importedData = null;
function exportData(data) {
// JSON 序列化
var strData = JSON.stringify(data);
// TextEncoder 编码成数字
var uint8array = new TextEncoder("utf-8").encode(strData);
// 计算需要多大方阵存储全部数据
var dataSize = Math.ceil(Math.sqrt(uint8array.length / 3));
// 用 255 填充 RGBA 中的 alpha 通道,设置完全不透明
// 否则 alpha = 0,即透明图片)会遇到 RGB 损坏的问题
// https://stackoverflow.com/questions/22384423/canvas-corrupts-rgb-when-alpha-0
var paddedData = new Uint8ClampedArray(dataSize * dataSize * 4);
var idx = 0;
// 每三个数字后加一个 255
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;
}
// 生成图像数据
var imageData = new ImageData(paddedData, dataSize, dataSize);
// 创建一个屏外(off screen)画布
var imgSize = 256;
var canvas = document.createElement("canvas");
canvas.width = canvas.height = imgSize;
// 获取画布上下文
var ctx = canvas.getContext("2d");
// 画布设置:256 * 256 的黑布
ctx.fillStyle = "#AA0000"; // 颜色没关系
ctx.fillRect(0, 0, imgSize, imgSize);
// 用一个像素的 R 值记录数据规模
ctx.fillStyle = "rgb(" + dataSize + ", 0, 0)";
ctx.fillRect(0, 0, 1, 1);
// 画布填充内容
ctx.putImageData(imageData, 0, 1);
var body = document.getElementsByTagName("body")[0];
exportedImage = canvas.toDataURL();
var downloadedLink = createElementFromHTML('<a id="hiddenLink" href="' + exportedImage + '" style="display:;" download="image.png">Download</a>');
body.appendChild(downloadedLink);
// MDN 中 Element 没有这个方法,可能不通用
// downloadedLink.click();
var e = document.createEvent("MouseEvents");
e.initEvent("click", true, true);
// downloadedLink.dispatchEvent(e); // 无效
var downloadedLink = document.getElementById("hiddenLink");
downloadedLink.dispatchEvent(e);
body.removeChild(downloadedLink);
}
// exportData 的反向操作
function importData(imgSrc, callback) {
var img = new Image();
img.onload = function () {
// 先把图片填充到画布上
var imgSize = img.width;
var canvas = document.createElement("canvas");
canvas.width = canvas.height = imgSize;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
// 获取数据规模
var headerData = ctx.getImageData(0, 0, 1, 1);
var dataSize = headerData.data[0];
// 获取数据
var imageData = ctx.getImageData(0, 1, dataSize, dataSize);
var paddedData = imageData.data;
// 抛弃每一个像素数据中的第四位(RGBA 中的 alpha 通道)
var uint8array = new Uint8Array((paddedData.length / 4) * 3);
var idx = 0;
for (var i = 0; i < paddedData.length - 1; i += 4) {
var subArray = paddedData.subarray(i, i + 3);
uint8array.set(subArray, idx);
idx += 3;
}
// 将最后为 0 的填充数据去掉
var includeBytes = uint8array.length;
for (var i = uint8array.length - 1; i > 0; i--) {
if (uint8array[i] === 0) {
includeBytes--;
} else {
break;
}
}
var data = uint8array.subarray(0, includeBytes);
var strData = new TextDecoder("utf-8").decode(data);
try {
importedData = JSON.parse(strData);
if (callback) {
callback();
}
} catch (error) {
if (callback) {
callback(error);
}
}
};
// 可以设置 src 属性为普通 URL 或 Data URL
img.src = imgSrc;
}