任务:使用尽可能少的资源,渲染有意义的文本。
- 可读字体可以有多小?
- 存储需要多少内存?
- 使用它需要多少代码?
让我们看看我们得到了什么。 扰流板

位图简介
计算机将位图显示为位图。 这与.bmp
格式无关,而与在内存中存储像素的方式有关。 要了解正在发生的事情,我们需要学习一些有关这种方式的知识。
层数
图像通常在彼此之上包含多个图层 。 通常,它们对应于RGB颜色空间的坐标。 一层代表红色 ,一层代表绿色 ,一层代表蓝色 。 如果图像格式支持透明度,则为其创建第四层,通常称为alpha 。 粗略地说,彩色图像是三个(如果有alpha通道,则为四个)黑白,一个位于另一个之上。
- RGB不是唯一的色彩空间。 JPEG格式例如使用YUV 。 但是在本文中,我们将不需要其余的色彩空间,因此我们不考虑它们。
可以通过两种方式在内存中表示一组图层。 要么将它们分开存储,要么将来自不同层的值进行交错。 在后一种情况下,图层称为channel ,这就是大多数现代格式的工作方式。
假设我们有一个包含三个图层的4x4绘图: R代表红色, G代表绿色, B代表每个像素的蓝色分量。 可以这样表示:
RRRR RRRR RRRR RRRR GGGG GGGG GGGG GGGG BBBB BBBB BBBB BBBB
所有三层都分别存储。 交替格式看起来有所不同:
RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB
- 每三个字符对应一个像素
- 三元组中的值按RGB顺序排列。 有时可以使用不同的顺序(例如BGR ),但这是最常见的顺序。
为简单起见,我以二维矩阵的形式排列像素,因为这样可以更清楚地看到图像中这个或那个三元组的位置。 但实际上,计算机内存不是二维的,而是一维的,因此4x4图片将按以下方式存储:
RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB
bpp
缩写bpp表示每个像素的位数或字节数(每个像素的位数/字节)。 您可能在3bpp
看到过24bpp
或3bpp
。 这两个特性意味着同一件事- 每个像素24 位或每个像素 3 个字节 。 由于一个字节中总是有8位,因此您可以根据所讨论的单位的值进行猜测。
内存表示
24bpp
,又名3bpp
用于存储鲜花的最常见格式。 这就是RGB顺序的单个像素如何看待各个位的电平。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 RRRRRRRRGGGGGGGGBBBBB BBB
- R的一个字节, G的一个字节, B的一个字节,总共三个字节。
- 它们每个都包含从0到255的值。
因此,如果给定像素具有以下颜色:
然后,在第一个字节255
存储255
,在第二个字节255
存储80
,在第三个字节255
存储100
。
通常,这些值以十六进制表示 。 说#ff5064
。 这更加方便和紧凑: R = 0xff
(即R=255
以十进制表示), G = 0x50
(= G=80
), B=0x64
(= B=100
)。
- 十六进制表示法具有一个有用的属性。 由于颜色的每个字节都由两个字符表示,因此每个字符正好编码半个字节或四位。 顺便说一下,4位称为nibble 。
线宽
当像素接一个又一个并且每个像素包含多个通道时,数据很容易混淆。 不知道一行的结束时间和下一行的开始时间,因此,要使用位图解释文件,您需要知道图像大小和bpp 。 在我们的例子中,图片的宽度为w = 4
像素,每个像素包含3个字节,因此该字符串使用12个字节(通常为w*bpp
)进行编码。
- 字符串不一定总是使用精确的
w*bpp
字节编码; 通常,将“隐藏”像素添加到其中以使图像宽度达到某个大小。 例如,当图片的像素大小等于2的幂时,缩放图片会更快,更方便。 因此,该文件可能包含(用户可访问)120x120像素的图像,但被存储为128x128的图像。 当屏幕上显示图像时,这些像素将被忽略。 但是,我们不需要了解它们。
一维表示中任何像素(x, y)
的坐标为(y * w + x) * bpp
。 通常,这很明显: y
是行号,每行包含w
像素,因此y * w
是所需行的开头, +x
将我们带到其中的所需x
。 而且由于坐标不是以字节为单位,而是以像素为单位,因此所有这些都乘以bpp
像素的大小(在这种情况下为字节)。 由于像素的大小非零,因此您需要从接收到的坐标开始精确地读取bpp
字节,我们将完整显示所需像素。
字体图集
实际上,现有的监视器并不整体显示一个像素,而是显示三个子像素-红色,蓝色和绿色。 如果以放大倍数查看监视器,将会看到类似以下内容:

我们对LCD感兴趣,因为最有可能是您从阅读此文本的监视器中获得的。 当然,有一些陷阱:
- 并非所有矩阵都使用此子像素顺序,有时使用BGR。
- 如果转动显示器(例如,以横向方向看手机),图案也会旋转,字体也会停止工作。
- 不同的矩阵方向和子像素的排列将需要对字体本身进行重新加工。
- 特别是,它不适用于使用PenTile布局的 AMOLED显示器 。 此类显示器最常用于移动设备中。
使用亚像素技巧提高分辨率称为亚像素渲染 。 例如,您可以在此处阅读有关其在排版中的使用的信息 。
对我们来说幸运的是,马特·萨尔诺夫(Matt Sarnov)已经想出了使用亚像素渲染来创建微型字体的方法。 手工地,他创造了这张小图片:

如果您非常仔细地看一下显示器,则其外观如下所示:

在这里,它以编程方式增加了12倍:

根据他的工作,我创建了一个字体图集,其中每个字符对应于1x5
像素的列。 字符顺序如下:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ

同一图集增加了12倍:

使用36个字符,可以精确地365
像素。 如果我们假设每个像素占用3个字节,那么我们需要36*5*3 = 540
个字节来存储整个图片( 原始图片中大约为。::有关alpha通道的一系列令人困惑的编辑,删除元数据等)。在翻译中,我省略了它,只使用了文件的最终版本 。 通过pngcrush和optipng传递的PNG文件花费更少:
# wc -c < font-crushed.png 390
但是,如果使用稍微不同的方法,则可以实现更小的尺寸
压缩方式
细心的读者会注意到地图集仅使用7种颜色:
#ffffff
#ff0000
#00ff00
#0000ff
#00ffff
#ff00ff
#ffff00
调色板
在这种情况下,创建调色板通常更容易。 然后,对于每个像素,您不能存储三个字节的颜色,而只能存储调色板中的颜色编号。 在我们的例子中,3位( 7 < 2^3
)足以从7种颜色中进行选择。 如果我们为每个像素分配一个三位的值,那么整个地图集将适合68个字节 。
- 精通数据压缩的读者可以回答说,通常存在“分数位”之类的东西,在我们的例子中, 每个像素2.875位就足够了。 这种密度可以使用称为算术编码的黑魔法来实现。 我们不会这样做,因为算术编码是一件很复杂的事情,而且68个字节已经有点了。
对齐方式
三位编码具有一个严重的缺点。 像素不能均匀分布在8位字节上,这很重要,因为字节是最小的可寻址存储区。 假设我们要保存三个像素:
ABC
如果每个位占用3位,则将需要2个字节来存储它们( -
表示未使用的位):
bit 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 pixel AAABBBCCC - - - - - - -
重要的是,像素C不仅会留下一堆空白; 它在两个字节之间被撕裂 。 当我们开始添加以下像素时,它们可以相对于字节边界任意定位。 最简单的解决方案是对每个像素使用半字节,因为8被完美地除以4,并允许您在每个字节中恰好放置两个像素。 但这会将地图集的大小增加了三分之一,从68个字节增加到90个字节 。
- 实际上,使用回文编码,间隔编码和其他压缩技术可以使文件更小。 像算术编码一样,我们将这些技术推迟到下一篇文章。
位缓冲器
幸运的是,使用3位值根本没有什么不可能。 您只需要监视当前正在写入或读取的字节中的哪个位置。 以下简单类将3位数据流转换为字节数组。
- 出于可读性原因,代码是用JS编写的,但是相同的方法可以推广到其他语言。
- 从低字节到高字节的使用顺序( 小端 )
class BitBuffer { constructor(bytes) { this.data = new Uint8Array(bytes); this.offset = 0; } write(value) { for (let i = 0; i < 3; ) { // bits remaining const remaining = 3 - i; // bit offset in the byte ie remainder of dividing by 8 const bit_offset = this.offset & 7; // byte offset for a given bit offset, ie divide by 8 const byte_offset = this.offset >> 3; // max number of bits we can write to the current byte const wrote = Math.min(remaining, 8 - bit_offset); // mask with the correct bit-width const mask = ~(0xff << wrote); // shift the bits we want to the start of the byte and mask off the rest const write_bits = value & mask; // destination mask to zero all the bits we're changing first const dest_mask = ~(mask << bit_offset); value >>= wrote; // write it this.data[byte_offset] = (this.data[byte_offset] & dest_mask) | (write_bits << bit_offset); // advance this.offset += wrote; i += wrote; } } to_string() { return Array.from(this.data, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join(''); } };
让我们下载并编码图集文件:
const PNG = require('png-js'); const fs = require('fs'); // this is our palette of colors const Palette = [ [0xff, 0xff, 0xff], [0xff, 0x00, 0x00], [0x00, 0xff, 0x00], [0x00, 0x00, 0xff], [0x00, 0xff, 0xff], [0xff, 0x00, 0xff], [0xff, 0xff, 0x00] ]; // given a color represented as [R, G, B], find the index in palette where that color is function find_palette_index(color) { const [sR, sG, sB] = color; for (let i = 0; i < Palette.length; i++) { const [aR, aG, aB] = Palette[i]; if (sR === aR && sG === aG && sB === aB) { return i; } } return -1; } // build the bit buffer representation function build(cb) { const data = fs.readFileSync('subpixels.png'); const image = new PNG(data); image.decode(function(pixels) { // we need 3 bits per pixel, so w*h*3 gives us the # of bits for our buffer // however BitBuffer can only allocate bytes, dividing this by 8 (bits for a byte) // gives us the # of bytes, but that division can result in 67.5 ... Math.ceil // just rounds up to 68. this will give the right amount of storage for any // size atlas. let result = new BitBuffer(Math.ceil((image.width * image.height * 3) / 8)); for (let y = 0; y < image.height; y++) { for (let x = 0; x < image.width; x++) { // 1D index as described above const index = (y * image.width + x) * 4; // extract the RGB pixel value, ignore A (alpha) const color = Array.from(pixels.slice(index, index + 3)); // write out 3-bit palette index to the bit buffer result.write(find_palette_index(color)); } } cb(result); }); } build((result) => console.log(result.to_string()));
正如预期的那样,地图集可容纳68个字节 ,比PNG文件小6倍。
( 注:作者有点不屑一顾:他没有保存调色板和图像大小,根据我的估计,这将需要23个字节(具有固定的调色板大小,并将图像大小增加到91个字节 ))
现在,将图像转换为字符串,以便将其粘贴到源代码中。 本质上, to_string
方法to_string
此操作:它以十六进制数表示每个字节的内容。
305000000c0328d6d4b24cb46d516d4ddab669926a0ddab651db76150060009c0285 e6a0752db59054655bd7b569d26a4ddba053892a003060400d232850b40a6b61ad00
但是结果字符串仍然很长,因为我们将自己限制为16个字符的字母。 您可以将其替换为base64 ,其中的字符数是原来的四倍。
to_string() { return Buffer.from(this.data).toString('base64'); }
在base64中,地图集如下所示:
MFAAAAwDKNbUsky0bVFtTdq2aZJqDdq2Udt2FQBgAJwCheagdS21kFRlW9e1adJqTdugU4kqADBgQA0jKFC0CmthrQA=
该行可以硬编码到JS模块,并用于光栅化文本。
栅格化
为了节省内存,一次只能解码一个字母。
const Alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const Atlas = Uint8Array.from(Buffer.from('MFAAAAwDKNbUsky0bVFtTdq2aZJqDdq2Udt2FQBgAJwCheagdS21kFRlW9e1adJqTdugU4kqADBgQA0jKFC0CmthrQA=', 'base64')); const Palette = [ [0xff, 0xff, 0xff], [0xff, 0x00, 0x00], [0x00, 0xff, 0x00], [0x00, 0x00, 0xff], [0x00, 0xff, 0xff], [0xff, 0x00, 0xff], [0xff, 0xff, 0x00] ]; // at the given bit offset |offset| read a 3-bit value from the Atlas read = (offset) => { let value = 0; for (let i = 0; i < 3; ) { const bit_offset = offset & 7; const read = Math.min(3 - i, 8 - bit_offset); const read_bits = (Atlas[offset >> 3] >> bit_offset) & (~(0xff << read)); value |= read_bits << i; offset += read; i += read; } return value; }; // for a given glyph |g| unpack the palette indices for the 5 vertical pixels unpack = (g) => { return (new Uint8Array(5)).map((_, i) => read(Alphabet.length*3*i + Alphabet.indexOf(g)*3)); }; // for given glyph |g| decode the 1x5 vertical RGB strip decode = (g) => { const rgb = new Uint8Array(5*3); unpack(g).forEach((value, index) => rgb.set(Palette[value], index*3)); return rgb; }
decode
功能将字符作为输入,并返回源图像中的相应列。 这里令人印象深刻的是,解码单个字符仅需5个字节的内存,再加上〜1.875个字节即可读取所需的数组片段,即 平均每封信6.875 。 如果您添加68个字节来存储数组并添加36个字节来存储字母,那么理论上可以用128个字节的RAM渲染文本。
- 如果您用C或汇编器重写代码,则可以这样做。 在JS开销的背景下,这可以节省匹配项。
剩下的只是将这些列收集为一个整体并返回带有文本的图片。
print = (t) => { const c = t.toUpperCase().replace(/[^\w\d ]/g, ''); const w = c.length * 2 - 1, h = 5, bpp = 3; // * 2 for whitespace const b = new Uint8Array(w * h * bpp); [...c].forEach((g, i) => { if (g !== ' ') for (let y = 0; y < h; y++) { // copy each 1x1 pixel row to the the bitmap b.set(decode(g).slice(y * bpp, y * bpp + bpp), (y * w + i * 2) * bpp); } }); return {w: w, h: h, data: b}; };
这将是最小的字体。
const fs = require('fs'); const result = print("Breaking the physical limits of fonts"); fs.writeFileSync(`${result.w}x${result.h}.bin`, result.data);
添加一点imagemagick以获得可读格式的图像:
# convert -size 73x5 -depth 8 rgb:73x5.bin done.png
这是最终结果:

它也增加了12倍:

是从校准不良的监视器宏拍摄的:

最后,最好在显示器上:
