希卡文字是游戏《塞尔达传说旷野之息》中一种虚构的文字,在塞尔达游戏中全部的希卡族的建筑上都能找到上面的符号的影子,一直觉得这些只是装饰性的符号,直到看到塞学家的分析才恍然大悟,原来这些都是文字呀!不愧是老任,塞尔达天下第一!javascript
希卡文能够与英文字符作相互映射,转换器实现的就是两种文字的相互转换,支持将英文字符转换成希卡文和希卡文图片内容解析成英文:css
工具的演示地址在这:kinglisky.github.io/zelda-wordshtml
github.io 打不开的同窗戳这里:nlush.com/zelda-words…前端
仓库地址:github.com/kinglisky/z…vue
虚构世界的文字每每是基于现实文字创造的,希卡文字与英文字母是一一对应的,映射以下:java
知道了映射关系咱们只须要将一个个字母转换成对应的希卡文就行了,先来准备下希卡文的文字素材,这里推荐一篇文章:从虚构世界的文字提及。node
做者十分贴心的实现了一套希卡文字体,咱们可从网站中扒拉下字体文件:git
3type.cn/css/fonts/s…github
拿到字体文件其实咱们配置下 @font-face
就能够直接使用了web
@font-face {
font-family: "SheikahGlyphs";
src: url("https://3type.cn/css/fonts/sheikahglyphs-regular-webfont.woff2") format("woff2");
}
.sheikah-word {
font-family: SheikahGlyphs;
}
复制代码
<span class="sheikah-word">abc</span>
复制代码
不过考虑到后面咱们须要固定文字的格子与间距的大小,咱们换一种用法将字体转成 svg 图标来使用。
使用使用上述工具咱们获得字体文件的单个字符的 svg 文件了,而后导入到 iconfont 中生成字体图标:
<script src="//at.alicdn.com/t/font_2375469_s4wmtifuqro.js"></script>
复制代码
而后咱们封装一个简单的文件图标组件:
<template>
<svg class="word-icon" aria-hidden="true" :style="iconStyle" >
<use v-if="iconName" :xlink:href="iconName" />
</svg>
</template>
<script> import { computed } from 'vue'; export default { name: 'WordIcon', props: { // 图标名称 name: { type: String, required: true, }, width: { type: Number, default: '', }, height: { type: Number, default: '', }, color: { type: String, default: '', }, opacity: { type: String, default: '', }, }, setup: (props) => { const iconName = computed(() => props.name ? `#icon-${props.name}` : ''); const iconStyle = computed(() => ({ color: props.color, opacity: props.opacity, width: `${props.width}px`, height: `${props.height}px`, })); return { iconName, iconStyle, }; }, }; </script>
<style> .word-icon { overflow: hidden; width: 1em; height: 1em; padding: 0; margin: 0; fill: currentColor; } </style>
复制代码
英文字符的翻译的面板能够简单的实现,使用换行符 \n
拆分文字分组,替换不支持的字符为空字符:
<template>
<section class="words-panel" ref="container" >
<div class="words-panel__groups" v-for="(words, index) in wordGroups" :key="index" >
<WordIcon class="words-panel__icon" v-for="(word, idx) in words" :key="idx" :name="word" :width="size" :height="size" >
{{ word }}
</WordIcon>
</div>
</section>
</template>
<script> import { computed, ref } from 'vue'; import WordIcon from './icon.vue'; const WORDS = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '.', '!', '?', '-', ]; export default { name: 'WordsPanel', components: { WordIcon, }, data() { return { words: 'hello world', size: 60, }; }, setup: (props) => { const container = ref(null); const wordGroups = computed(() => { return props.words .toLowerCase() .split('\n') .map(words => words.split('').map(v => WORDS.includes(v) ? v : '')); }); return { container, wordGroups, }; }, }; </script>
复制代码
最后一步就是将希卡文字导出了,由于咱们并无涉及复杂的 DOM 结构与样式,这里咱们直接偷懒使用前端 DOM 出图的方式将目标的文字面板直接导出一张图片,这块现成的库不少,如 html2canvas 和 dom-to-image ,这里咱们使用 dom-to-image 来出图,这个库十分的小巧使用也十分简单。
这里简单讲下 DOM 出图的原理,DOM 出图主要是利用了 SVG 元素的 foreignObject 标签,咱们能够在 foreignObject 标签下塞入自定义 html 片断而后将整个 svg 做为一张图片 drawImage 到 canvas 上实现出图:
(async function () {
const svg =
`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <foreignObject x="0" y="0" width="200" height="200"> <div xmlns="http://www.w3.org/1999/xhtml">OUTPUT</div> </foreignObject> </svg>`;
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + svg;
const loadImage = (url) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = url;
});
};
const image = await loadImage(dataUrl);
const canvas = document.createElement('canvas');
canvas.width = 120;
canvas.height = 60;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
console.log(canvas.toDataURL());
})();
复制代码
dom-to-image 内部处理与上面的流程相似,处理咱们的字体图标时会生成相似于以下的 SVG 结构:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="60">
<svg>
<use xlink:href="#icon-a" />
</svg>
</foreignObject>
</svg>
复制代码
图标中使用的特殊的 use 标签,use 所引用的内容存在于全局,因此在出图时咱们须要处理下这部分的 symbol 引用。
处理成以下的结构:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="60">
<svg>
<symbol id="icon-a" viewBox="0 0 1024 1024">
<path d="xxx"></path>
</symbol>
<use xlink:href="#icon-a" />
</svg>
</foreignObject>
</svg>
复制代码
最终的图片导出咱们能够这样处理:
import domtoimage from 'dom-to-image';
// fix 节点中 svg 图标依赖
function fixSvgIconNode(node) {
if (node instanceof SVGElement) {
const useNodes = Array.from(node.querySelectorAll('use') || []);
useNodes.forEach((use) => {
const id = use.getAttribute('xlink:href');
// 将 svg 图片中依赖的 <symbol> 节点塞到当前 svg 节点下
if (id && !node.querySelector(id)) {
const symbolNode = document.querySelector(id);
if (symbolNode) {
node.insertBefore(
symbolNode.cloneNode(true),
node.children[0]
);
}
}
});
}
return true;
}
export default function exportImage (node) {
return domtoimage.toPng(node, { filter: fixSvgIconNode })
.then(dataUrl => {
console.log(dataUrl);
});
}
复制代码
自此英文到希卡文的转换就完成了,重点的咱们看下如何实现希卡文卡片内容的翻译。
咱们最终产出的内容是一张图片,咱们须要考虑如何图片的内容“翻译”出来,这里的翻译我打了个引号,可能咱们并不须要真正的解析翻译出图片的内容,或者咱们能够考虑一种投巧的方式将本来的文字信息隐藏在最终图片中?
咱们先来试试投巧的方式,将本来的文字信息隐藏图片中。
将目标信息隐藏在图片中而不影响图片视觉展现的技术能够称为图片隐写术,若是隐藏的目标对象是一张图片的话则能够称之为盲水印,盲水印经常使用于图片版权保护,图片的泄密追踪等。
咱们先来试试一种最简单的图片隐写手段:LSB(Least Significant Bit)最低有效位。
咱们都知道一张图片的每一个像素都是由 RGB 通道的颜色混合而成,而 RGB 某个通道的上色值 +1 或 -1 咱们在肉眼上是没法区分,就拿 rgb(0, 0, 0)
和 rgb(1, 0, 0)
你在肉眼上能区分吗?
显然不行,因此咱们能够在 RGB 某个通道上对色值进行增减 1 使其变为奇数(对应 1) 或者 偶数(对应 0),咱们只需将隐藏的信息转成二进制就能够映射到某个颜色通道的奇偶数值就能够实现信息的隐藏了。解析的过程也很简单,读取目标通道上的色值,奇数为 1 偶数为 0 反转出 01 的二进制数据再还原成原始数据就行了。
上面希卡文对应的信息是 hello world
,咱们如今试着将它隐藏在上面的图片的中。
首先将 hello world
转换成二进制,咱们固然能够将每一个字母转成对应的 ASCII 码而后转成对应的 8 位二进制数,不过既然是隐藏信息对文本进行编码咱们何不用一些现成的工具(其实就是偷懒啦),二维码就是一个很不错的载体。
咱们能够生成一张 hello world
对应的黑白二维码:
下面就是将二维码的信息隐藏到希卡文结果的图片中,由于是黑白的二维码图片,咱们能够很简单将黑色像素的值归为 0(偶数位)白色像素的值归为 1(奇数位),但由于二维码图片和希卡文图片的尺寸并不一致,咱们不方便将两张图片的像素位一一对应,我能够先将二维码的尺寸调整成和希卡文图片一致。
图片准备好了,咱们先来实现隐藏和解析水印的方法:
// 写入二维码水印
function writeMetaInfo(baseImageData, qrcodeImageData) {
const { width, height, data } = qrcodeImageData;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
// 选用 r 通道来隐藏信息
const r = (x + y * width) * 4;
const v = data[r];
// 二维码白色部分(背景)标识为 1,黑色部分(内容)标识为 0
const bit = v === 255 ? 1 : 0;
// 若是当前 R 通道色值奇偶性和二维码对应像素不一致则进行加减一使其奇偶性一致
if (baseImageData.data[r] % 2 !== bit) {
baseImageData.data[r] += bit ? 1 : -1;
}
}
}
return baseImageData;
}
// 读取二维码水印
function readMetaInfo(imageData) {
const { width, height, data } = imageData;
const qrcodeImageData = new ImageData(width, height);
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
// 读取 r 通道息
const r = (x + y * width) * 4;
// 奇数颜色为白色 255,偶数颜色为黑色 0
const v = data[r] % 2 === 0 ? 0 : 255;
qrcodeImageData.data[r] = v;
qrcodeImageData.data[r + 1] = v;
qrcodeImageData.data[r + 2] = v;
qrcodeImageData.data[r + 3] = 255;
}
}
return qrcodeImageData;
}
复制代码
完整的示例在这里 ,下面是隐藏二维码后的希卡文图片,是否是肉眼看不到什么变化?
对应的希卡片解析出的二维码以下,虽然带有一些噪点信息,但不影响二维码的识别。
最先的一版希卡图片识别就是用图片的最低有效位来隐藏信息的,完成的时候兴高采烈准备分享到微信让小可爱看下,等等!隐约记得微信会压缩图片,要不发微信再下载下来试试?
苍天呐!果真经过微信分享后图片会通过一些压缩处理(微信会把 PNG 图片都处理成 JPG 图片),致使咱们隐藏在图片中奇偶位信息丢失,试着解析了下微信的分享压缩事后的图片最终得出图片以下:
最低有效位的实现简单,但隐藏信息抗干扰能力却不好,图片的压缩很容形成奇偶位信息的丢失。咱们须要考虑如何提升隐藏信息抗干扰能力,最低有效是将信息隐藏在某个像素通道上的,若是咱们能够把隐藏信息的范围扩大呢?好比说二维码是用一个个黑白的色块标识数据比特位。咱们是否能经过一个个色块来隐藏信息呢?看个例子:
上面的色块影藏的什么信息呢?
[100, 200]
[01100100, 11001000]
复制代码
其实就是用了 16 个黑白色块表示表示了数字 100 和 200,黑色表示 0 白色表示 1,解析也十分简单,上面的图片拆分红 16 个色块,检查每一个色块,黑色的读取为 0 白色的读取为 1。上面使用黑白色来映射 01 咱们换个规则,好比用 rgb(0, 0 , 0)
表示 0 rgb(2, 2, 2)
表示 1。生成的图片长什么样呢?
肉眼是否是很难看出色差,但咱们确实是把信息影藏进图片里了,解析规则也很简单,拆分色块读取颜色,rgb(0, 0 , 0)
为 0,大于 rgb(0, 0, 0)
的为 1,这样必定程度上只要图片不是压缩的过度,咱们仍是能解析出原始信息的,固然提升两个颜色间的差值对比也不失为一种方法(肉眼不可见的范围内尽可能拉高)。
简单的实现以下:
// 统一成 8 位
function paddingLfet(bits) {
return ('00000000' + bits).slice(-8);
}
function write(data) {
const bits = data.reduce((s, it) => s + paddingLfet(it.toString(2)), '');
const size = 100;
const width = size * bits.length;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0000000';
ctx.fillRect(0, 0, width, size);
for (let i = 0; i < bits.length; i++) {
if (Number(bits[i])) {
ctx.fillStyle = '#020202';
ctx.fillRect(i * size, 0, size, size);
}
}
return canvas.toDataURL();
}
async function read(url) {
const image = await loadImage(url);
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
const size = 100;
const bits = [];
for (let i = 0; i < 16; i++) {
const imageData = ctx.getImageData(i * size, 0, size, size);
const r = imageData.data[0];
const g = imageData.data[1];
const b = imageData.data[2];
bits.push(r + g + b === 0 ? 0 : 1);
}
return bits;
}
复制代码
这种方法稳是挺稳的,但能隐藏的信息太少了,咱们用来隐藏大量的文字信息并不实用,却是能够隐藏一些关键的信息,后面咱们会用这种方式去记录希卡文卡片的格子大小,用于图片的解析。
图片的隐藏水印是一门高深的学问,以上只是些朴素实现,实际生产中一版是利用傅里叶变换生成图片的频域图,而后将水印信息隐藏在频域图中再作傅里叶逆变换还原成正常的图片,这样生成的图片有很好的抗干扰能力,不过这块超纲了(啃不动),摸清楚了再来试试。
接上面的问题,既然隐藏文字信息的“投巧”方案走不通,那咱们就试着真正去解析图片的内容吧~
识别图片的文字能想到技术就是 OCR,也扒拉到了现成的工具 tesseractjs,不过想要实现一套希卡文字的识别则须要训练生成希卡文字的 raineddata
才行,这里咱们试着用一种朴素的方式来实现(主要是太菜玩不转😂)。
对于生成的希卡图片,咱们已经知道符号和英文字母的映射关系,并且它们都是由同一套字体生成,文字按照一样的格子大小排布在图片中,空格子表示空字符串,若是咱们能把文字内容拆分一个个格子,再与已知字符图片进行匹配,挑出最接近图片字符图片不就现实了文字内容的识别码?这里最核心的内容其实就是如何实现两张类似图片的识别。
咱们先来确认两个关键信息:
对于一张希卡图片咱们总得知道它的格子大小才能作拆分吧?其实在生成的图片中咱们已经偷偷把这些信息藏进去了,我贴张图片你们就懂了:
还记得上面使用色块隐藏信息的方法吗?生成的希卡图片时咱们偷偷在第一行藏了些隐藏信息,不过使用的颜色与背景色十分接近肉眼很难区分罢了。
图片的首行咱们塞了三个关键信息:文字排列方式(0 or 1 标识)、文字格子的大小,图片的宽度(几个格子大小),每一个信息二进制为 8 bit 长度,总长度 24 位。解析时咱们拿到图片咱们只须要截取图片第一行(高度随意 2 ~ 4 像素足以)拆分均等的 24 份,第一份的颜色用于标识 0(由于前八位表示文字排列只有 01 两种状况,01 换成 8 位二进制前 7 位都是 0),剩下的 23 个色块颜色与第一块相同的标识为 0 不用则标识为 1,简单暴力。
经过上面的方法咱们能够拿到最关键的信息图片格子大小,咱们能够按照格子大小将图片拆分红一个个均等的格子:
最终咱们能够获得这样的一个格子,如今咱们须要作的就是从字典图片中匹配出这个符号,字典图片是咱们提早准备好的,就是下面这张:
图片的格子大小为 100,从左到右分别是:abcdefghijklmnopqrstuvwxyz0123456789.-!?
,咱们同样能够按顺序拆分每一个字母对应的符号图片。
剩下的就是比较两张图片类似性,从字典图片中找出最类似的图片,类似图片的识别其实原理很简单,以前有很详细的整理过一篇文章这里就很少赘述了。
下面全部涉及的类似图片检查的内容都在这了 类似图片识别的朴素实现,有兴趣的同窗能够看看。
姑且概述类似图片比较的原理,咱们无法很直接去比较两张图片是否类似,若是图片两张都是以二进制方式表示呢?若是两张图片都能变成下面同等长度的二进制字符串,咱们比较两张图片类似性,只须要判断这两字符的同个位置上有差别个数(汉明距离),差别越小,图片越类似。
00101101
00111001
复制代码
处理步骤是:
工具在这,上面是统一将图片缩小成 64x64 大小的样子,8x8 大小的图片指纹以下:
咱们须要作的是生成 40 个英文字符对应的图片指纹(8x8 大小),而后将解析的图片拆分的格子也按相同的流程生成对应的图片指纹(8x8 大小),接下来只要从字典中匹配出汉明距离最小的指纹,也就匹配出了原始的英文字符了,而后按照文字排列的方式将文本按顺序输出就行了。
具体代码实现的代码比较多就不往这里贴了,有兴趣的同窗能够点这里看代码实现。
这个仓库其实建了好久了,那时塞尔达玩的正入迷,想搞了希卡文字卡片生成器玩玩的,而后咕咕咕的放了两年,新年开工摸鱼整理仓库时发现的,恰好想试试 vite 和 vue3,因而摸鱼写了个小工具,翻了翻仓库发现有太多被本身搁置的东西了,但愿你对这个世界还感好奇吧~
最后给特别的你~