├── done.png ├── font.png ├── done-cap.png ├── subtext1.png ├── done-other.png ├── done-scale.png ├── font-scale.png ├── subpixels.png ├── subtext1p.png ├── subtext1z.png ├── font-crushed.png ├── index.js └── README.md /done.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/done.png -------------------------------------------------------------------------------- /font.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/font.png -------------------------------------------------------------------------------- /done-cap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/done-cap.png -------------------------------------------------------------------------------- /subtext1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/subtext1.png -------------------------------------------------------------------------------- /done-other.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/done-other.png -------------------------------------------------------------------------------- /done-scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/done-scale.png -------------------------------------------------------------------------------- /font-scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/font-scale.png -------------------------------------------------------------------------------- /subpixels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/subpixels.png -------------------------------------------------------------------------------- /subtext1p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/subtext1p.png -------------------------------------------------------------------------------- /subtext1z.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/subtext1z.png -------------------------------------------------------------------------------- /font-crushed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphitemaster/breaking_the_physical_limits_of_fonts/HEAD/font-crushed.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 2 | const Atlas = Uint8Array.from(Buffer.from('MFAAAAwDKNbUsky0bVFtTdq2aZJqDdq2Udt2FQBgAJwCheagdS21kFRlW9e1adJqTdugU4kqADBgQA0jKFC0CmthrQA=', 'base64')); 3 | const Palette = [ 4 | [0xff, 0xff, 0xff], 5 | [0xff, 0x00, 0x00], 6 | [0x00, 0xff, 0x00], 7 | [0x00, 0x00, 0xff], 8 | [0x00, 0xff, 0xff], 9 | [0xff, 0x00, 0xff], 10 | [0xff, 0xff, 0x00] 11 | ]; 12 | 13 | read = (offset) => { 14 | let value = 0; 15 | for (let i = 0; i < 3; ) { 16 | const bit_offset = offset & 7; 17 | const read = Math.min(3 - i, 8 - bit_offset); 18 | const read_bits = (Atlas[offset >> 3] >> bit_offset) & (~(0xff << read)); 19 | value |= read_bits << i; 20 | offset += read; 21 | i += read; 22 | } 23 | return value; 24 | }; 25 | 26 | unpack = (g) => { 27 | return (new Uint8Array(5)).map((_, i) => 28 | read(Alphabet.length*3*i + Alphabet.indexOf(g)*3)); 29 | }; 30 | 31 | decode = (g) => { 32 | const rgb = new Uint8Array(5*3); 33 | unpack(g).forEach((value, index) => 34 | rgb.set(Palette[value], index*3)); 35 | return rgb; 36 | } 37 | 38 | print = (t) => { 39 | const c = t.toUpperCase().replace(/[^\w\d ]/g, ''); 40 | const w = c.length * 2 - 1, h = 5, bpp = 3; 41 | const b = new Uint8Array(w*h*bpp); 42 | let x = 0; 43 | [...c].forEach((g, i) => { 44 | if (g !== ' ') for (let y = 0; y < 5; y++) { 45 | b.set(decode(g).slice(y*3, y*3+3), (y * w + i*2) * bpp); 46 | } 47 | }); 48 | return {w: w, h: h, data: b}; 49 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Breaking the physical limits of fonts 2 | 3 | The challenge: in the fewest resources possible, render meaningful text. 4 | 5 | * How small can a font really go? 6 | * How many bytes of memory would you need (to store it and run it?) 7 | * How much code would it take to express it? 8 | 9 | Lets see just how far we can take this! 10 | 11 | ## Crash course in bitmaps 12 | 13 | Computers typically represent images as bitmaps. The term bitmap is not to be confused with the `.bmp` file extension which is something quite different. It's just a way to store pixels in memory. 14 | 15 | 16 | ### Planes 17 | Images often contain multiple _planes_. Each plane refers to one "layer" of the image. Most images contain three planes in the [RGB color space](https://en.wikipedia.org/wiki/RGB_color_space). One for _red_, _green_ and _blue_. However images with transparency often contain another plane called _alpha_. You can think of a colored image as actually three (or four for transparency) grayscale images in one. 18 | 19 | * There are other color spaces than RGB. JPEGs use [YUV](https://en.wikipedia.org/wiki/YUV) for instance. We're going to focus on RGB since it's what most people are familiar with. 20 | 21 | There's two ways to represent this in memory. You can store each plane separately, or you can interleave the planes. When the planes are interleaved, we use the term _channels_ instead. Interleaving is the most common method today. 22 | 23 | Imagine we had a 4x4 image, we could represent the three planes like this, where _R_ is red, _G_ is green and _B_ is blue, respectively. 24 | 25 | ``` 26 | R R R R 27 | R R R R 28 | R R R R 29 | R R R R 30 | 31 | G G G G 32 | G G G G 33 | G G G G 34 | G G G G 35 | 36 | B B B B 37 | B B B B 38 | B B B B 39 | B B B B 40 | ``` 41 | 42 | This would be storing of the planes separately. 43 | 44 | Where as doing something like this would be interleaving. 45 | 46 | ``` 47 | RGB RGB RGB RGB 48 | RGB RGB RGB RGB 49 | RGB RGB RGB RGB 50 | RGB RGB RGB RGB 51 | ``` 52 | 53 | * Each cluster of characters is **exactly** one pixel 54 | * The order I've decided to put them in is called _RGB_ order, there's other orders like _BGR_ where blue comes first. The most common order is _RGB_ however. 55 | 56 | I've taken some creative freedoms of laying out the pixels in a 2D fashion so it's more obvious how it maps, however computers don't actually have 2D memory, their memory is 1D. So the above 4x4 example would actually be: 57 | 58 | ``` 59 | RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB RGB 60 | ``` 61 | 62 | ### BPP 63 | 64 | The term _bpp_ is used to refer to either how many "bytes" or "bits" are used "per-pixel". You may have seen `24bpp` or `3bpp` used before, these are the same thing. One means **24 _bits_ per pixel**, the other means **3 _bytes_ per pixel**. There's **always 8 bits in a byte**. Since bit is the smaller unit, it will always be the larger number which is how you can tell if bits or bytes are being used as the unit. 65 | 66 | ### Representation 67 | 68 | The most common format you see today is 24-bit color i.e. `24bpp`, or `3bpp`. Here's a nice way of showing what that looks like at the bit level for **one pixel** in _RGB_ order. 69 | 70 | ``` 71 | bit 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 72 | pixel R R R R R R R R G G G G G G G G B B B B B B B B 73 | ``` 74 | * There's one byte for _R_, one for _G_ and one for _B_. 75 | * A byte can hold a value from 0 to 255. 76 | 77 | So if we have a single pixel with the following color: 78 | * `R 255` 79 | * `G 80` 80 | * `B 100` 81 | 82 | Then the first byte stores the value `255`, second stores the value `80` and the last stores the value `100`. 83 | 84 | You may have seen _hex_, _hexadecimal_ or [_base-16_](https://en.wikipedia.org/wiki/Hexadecimal) representations of color before. Like `#ff5064`. This is a much nicer way of thinking about color. Each two characters refer to each channel of color. In this case; _R_ is `0xff`, which is `255` in decimal. _G_ is `0x50`, which is `80` in decimal and _B_ is `0x64`, which is `100` in decimal. 85 | 86 | * One nice property of hexadecimal representation is that each byte of a color is represented by two characters, that means each character expresses **exactly 4 bits**. Every 4 bits is called a [nibble](https://en.wikipedia.org/wiki/Nibble). 87 | 88 | 89 | ### Stride 90 | When the pixels are layed out flat like that and each one has multiple channels. It can get really confusing because we can't tell when the next row of pixels in our image starts. That's why we need to know the dimensions of the image and the _bpp_ to make sense of it. 91 | The term _stride_ is used to refer to the amount of bytes there are in one scan-line (row of pixels). In our example we have a 4x4 image, each pixel is **3bpp**, that means we have a stride of `4*3` bytes, or generally, `w*bpp` bytes. 92 | 93 | * It's not always true that an image has a stride of `w*bpp`, sometimes images have "hidden" padding pixels that are meant to keep the image's dimensions a power of some size. As an example, working with power of two's is far easier and faster when scaling images. So you may have an image that is `120x120` pixels (that you can see) but the actual representation is `128x128` and those "padding" pixels are skipped over when previewing the image. We won't worry about that here. 94 | 95 | This is a very simple mapping, for any pixel `(x, y)` the 1D location of it is `(y * w + x) * bpp`. Think about the math of this for a moment, `y` is the row of the pixel, we know each row has `w` pixels, so `y * w` moves us through the rows, `x` moves us along that row to the specific `x` coordinate on that row. However, that only works when we have one byte per pixel, since we interleaved _R_, _G_ and _B_, we need to actually move in units of what ever `bpp` is, so multiplying the whole thing by `3`, or `bpp` in the general case gives us the 1D location of the pixel. More specifically, it gives us the location of the first channel for that pixel. Reading exactly `bpp` bytes from that location gives us a whole pixel. 96 | 97 | ## The font atlas 98 | The way displays actually display pixels is through the use of three sub-pixels, one red, one green and another blue. If you zoomed in real close to a single pixel what you'd see is something that resembles this depending on your display. 99 | 100 | ![](subpixels.png) 101 | * Taken from https://en.wikipedia.org/wiki/Subpixel_rendering#/media/File:Pixel_geometry_01_Pengo.jpg 102 | 103 | The one we're interested in exploiting is LCD as that's likely the display technology you're reading this on. 104 | 105 | There's some caveats of course 106 | 107 | * Not all displays have this subpixel pattern, some might put blue before red resulting in a BGR pattern. 108 | * If your display is rotated (phone or tablet) this pattern will be rotated too and this font will stop working. 109 | * Different subpixel patterns and orientations actually require different subpixel rendering of fonts themselves. 110 | * This will not work on [AMOLED displays](https://en.wikipedia.org/wiki/AMOLED) since they use [pentile](https://en.wikipedia.org/wiki/PenTile_matrix_family) subpixel patterns. These displays are by far the most common displays on mobile devices too. 111 | 112 | This method of exploiting subpixels for additional rendering resolution is called [subpixel rendering](https://en.wikipedia.org/wiki/Subpixel_rendering). 113 | 114 | For more information on subpixel font rendering, check out [this excellent resource](https://www.grc.com/ctwhat.htm). 115 | 116 | Fortunately for us, someone has already had this idea and built something called [millitext](http://www.msarnoff.org/millitext/). Their work is listed here. 117 | 118 | They built the following tiny image by hand. 119 | 120 | ![](subtext1.png) 121 | 122 | Which if you look at your monitor close enough looks like. 123 | 124 | ![](subtext1p.png) 125 | 126 | Here it is scaled up 12x. 127 | 128 | ![](subtext1z.png) 129 | 130 | That brings us to this really tiny image which is actually a font atlas built off his work, each `1x5` pixels represents a character. The atlas was created by hand and layed out like so. 131 | ``` 132 | 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ 133 | ``` 134 | 135 | ![](font.png) 136 | 137 | That might be difficult to see, so I've scaled it up 12x 138 | 139 | ![](font-scale.png) 140 | 141 | Which works out to exactly `36x5` pixels in size. The PNG is also `4bpp`, since it also has an alpha channel, but we will be ignoring that. Assuming we store each pixel as `RGB`, we'd need exactly `36*5*3` bytes, or `540` bytes to represent this as a bitmap. That's actually surprisingly good already, since the PNG itself is actually: 142 | ``` 143 | # wc -c < font.png 144 | 15118 145 | ``` 146 | **27x larger!** 147 | * This command tells us how many "bytes" are in a file on a Linux PC. 148 | * This is 14 KiB! 149 | 150 | ~~PNG is not well suited for storing things like this since it's already too small. In fact a [BMP](https://en.wikipedia.org/wiki/BMP_file_format) can do a much better job, look:~~ 151 | ``` 152 | # wc -c < font.bmp 153 | 858 154 | ``` 155 | 156 | * Edit: It turns out our pesky PNG file contains a ton of metadata that can be stripped making it far smaller, we also have an unnecessary alpha channel. Running the PNG through tools like [pngcrush](https://pmt.sourceforge.io/pngcrush/) and [optipng](http://optipng.sourceforge.net/) really push things to the limits. 157 | 158 | ``` 159 | # wc -c < font-crushed.png 160 | 390 161 | ``` 162 | 163 | We can still take this further ourselves with a slightly different approach. 164 | 165 | # Compressing 166 | 167 | The acute of you may have noticed something interesting about the atlas, there's only seven colors in it, these colors in particular: 168 | 169 | 0. `#ffffff` 170 | 1. `#ff0000` 171 | 2. `#00ff00` 172 | 3. `#0000ff` 173 | 4. `#00ffff` 174 | 5. `#ff00ff` 175 | 6. `#ffff00` 176 | 177 | ## Palette 178 | When we only have a few colors like this, it's often easier to create a palette and refer to colors in the palette instead of each pixel being the color value itself. Assuming we use the palette above, then each pixel only ever needs to be represented by a single value in the range 0-6. 179 | 180 | * 1-bit can represent 2 possible values (0, 1) 181 | * 2-bit can represent 4 possible values (0, 1, 2, 3) 182 | * 3-bit can represent 8 possible values (0, 1, 2, 3, 4, 5, 6, 7) 183 | 184 | If we represented each pixel as a 3-bit quantity, where the value of that pixel referred to our palette, we would only need **68 bytes** to represent the entire atlas. 185 | 186 | * The data compression folks out there might point out that you can have such a thing as a "fractional bit", the perfect size we actually need here is **2.875 bits**. This is often accomplished through something called ~~[entropy coding](https://en.wikipedia.org/wiki/Entropy_encoding)~~ [arithmetic coding](https://en.wikipedia.org/wiki/Arithmetic_coding). However, this is quite complicated and **68 bytes** is already incredibly small. 187 | 188 | ## Alignment 189 | There's an ugly problem with 3-bit encoding though. It does not divide evenly into a byte. A byte is the smallest addressable unit computers can actually deal with. Imagine we have these three pixels: 190 | ``` 191 | A B C 192 | ``` 193 | 194 | If each one takes 3-bits, then two bytes would look like this in memory, where `-` denotes an unused bit. 195 | ``` 196 | bit 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 197 | pixel A A A B B B C C C - - - - - - - 198 | ``` 199 | 200 | See that, the **C** pixel takes up one bit of the next byte, it's _split_ across the bytes and in fact, as we start adding more pixels, they can be split up and straddle anywhere! Here we just have 1-bit inside another byte, but in practice a pixel can end up 2-bits inside another byte, 3-bits inside another byte, etc. 201 | 202 | An easy solution to this problem would be to use a nibble per pixel, since 4 divides evenly into 8, this would keep everything _aligned_ on a byte, allowing exactly 2 pixels per byte, but it also takes our atlas up from **68 bytes** to **90 bytes**, which is **1.3x** larger. 203 | 204 | * We can actually go far smaller if we exploit the fact that the top-half of some characters are the same as their bottom half (mirroring). Similarly, the use of range coding and other very specific compression techniques can probably get this down far more. We'll leave that for a later writeup. 205 | 206 | ## Bit buffer 207 | Fortunately it's still possible to work with 3-bit quantities, it just requires keeping track of which bit in a byte you are at when encoding and decoding. 208 | 209 | Included here is a simple class which writes 3-bit quantities into a byte array. 210 | 211 | * To keep this as accessible as possible for readers, the code will be written in JS but can be extended to other languages. 212 | * All code will assume [Little Endian](https://en.wikipedia.org/wiki/Endianness) byte order since that's the most common. 213 | 214 | ```js 215 | class BitBuffer { 216 | constructor(bytes) { 217 | this.data = new Uint8Array(bytes); 218 | this.offset = 0; 219 | } 220 | write(value) { 221 | for (let i = 0; i < 3; ) { 222 | // bits remaining 223 | const remaining = 3 - i; 224 | 225 | // bit offset in the byte i.e remainder of dividing by 8 226 | const bit_offset = this.offset & 7; 227 | 228 | // byte offset for a given bit offset, i.e divide by 8 229 | const byte_offset = this.offset >> 3; 230 | 231 | // max number of bits we can write to the current byte 232 | const wrote = Math.min(remaining, 8 - bit_offset); 233 | 234 | // mask with the correct bit-width 235 | const mask = ~(0xff << wrote); 236 | 237 | // shift the bits we want to the start of the byte and mask off the rest 238 | const write_bits = value & mask; 239 | 240 | // destination mask to zero all the bits we're changing first 241 | const dest_mask = ~(mask << bit_offset); 242 | value >>= wrote; 243 | 244 | // write it 245 | this.data[byte_offset] = (this.data[byte_offset] & dest_mask) | (write_bits << bit_offset); 246 | 247 | // advance 248 | this.offset += wrote; 249 | i += wrote; 250 | } 251 | } 252 | to_string() { 253 | return Array.from(this.data, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join(''); 254 | } 255 | }; 256 | ``` 257 | 258 | So, lets load in that atlas PNG, ignore the alpha and encode it into our bit buffer, we'll use [png-js](https://www.npmjs.com/package/pngjs) for this. 259 | 260 | ```js 261 | const PNG = require('png-js'); 262 | const fs = require('fs'); 263 | 264 | // this is our palette of colors 265 | const Palette = [ 266 | [0xff, 0xff, 0xff], 267 | [0xff, 0x00, 0x00], 268 | [0x00, 0xff, 0x00], 269 | [0x00, 0x00, 0xff], 270 | [0x00, 0xff, 0xff], 271 | [0xff, 0x00, 0xff], 272 | [0xff, 0xff, 0x00] 273 | ]; 274 | 275 | // given a color represented as [R, G, B], find the index in palette where that color is 276 | function find_palette_index(color) { 277 | const [sR, sG, sB] = color; 278 | for (let i = 0; i < Palette.length; i++) { 279 | const [aR, aG, aB] = Palette[i]; 280 | if (sR === aR && sG === aG && sB === aB) { 281 | return i; 282 | } 283 | } 284 | return -1; 285 | } 286 | 287 | // build the bit buffer representation 288 | function build(cb) { 289 | const data = fs.readFileSync('subpixels.png'); 290 | const image = new PNG(data); 291 | image.decode(function(pixels) { 292 | // we need 3 bits per pixel, so w*h*3 gives us the # of bits for our buffer 293 | // however BitBuffer can only allocate bytes, dividing this by 8 (bits for a byte) 294 | // gives us the # of bytes, but that division can result in 67.5 ... Math.ceil 295 | // just rounds up to 68. this will give the right amount of storage for any 296 | // size atlas. 297 | let result = new BitBuffer(Math.ceil((image.width * image.height * 3) / 8)); 298 | for (let y = 0; y < image.height; y++) { 299 | for (let x = 0; x < image.width; x++) { 300 | // 1D index as described above 301 | const index = (y * image.width + x) * 4; 302 | // extract the RGB pixel value, ignore A (alpha) 303 | const color = Array.from(pixels.slice(index, index + 3)); 304 | // write out 3-bit palette index to the bit buffer 305 | result.write(find_palette_index(color)); 306 | } 307 | } 308 | cb(result); 309 | }); 310 | } 311 | 312 | build((result) => console.log(result.to_string())); 313 | ``` 314 | 315 | After all that work, we now have a single bit buffer containing our atlas in exactly **68 bytes**. 316 | 317 | ~~To put that in perspective, here's the original PNG~~ 318 | ``` 319 | # wc -c < font.png 320 | 15118 321 | ``` 322 | 323 | ~~We're **222x** smaller!~~ 324 | 325 | ~~That's not a mistake. We've compressed something down to **0.45%** it's original size!~~ 326 | 327 | * Edit: With the appropriate crushing of the original PNG. We're **~6x** smaller now. 328 | 329 | Now lets convert the representation to a string so we can embed it into our source code. That's essentially what the `to_string` method does. It reads off the contents of each byte into a single base-16 number. 330 | ``` 331 | 305000000c0328d6d4b24cb46d516d4ddab669926a0ddab651db76150060009c0285 332 | e6a0752db59054655bd7b569d26a4ddba053892a003060400d232850b40a6b61ad00 333 | ``` 334 | 335 | However, this is still quite long to embed. Fortunately this is because we've limited ourselves to base-16 which has an alphabet of 16 characters. A better encoding method for this is actually [base-64](https://en.wikipedia.org/wiki/Base64), which gives us 4x more characters, so lets change `to_string` to use that. 336 | ```js 337 | to_string() { 338 | return Buffer.from(this.data).toString('base64'); 339 | } 340 | ``` 341 | 342 | Which then gives us: 343 | ``` 344 | MFAAAAwDKNbUsky0bVFtTdq2aZJqDdq2Udt2FQBgAJwCheagdS21kFRlW9e1adJqTdugU4kqADBgQA0jKFC0CmthrQA= 345 | ``` 346 | 347 | Meaning we can now embed this single string in our JS and begin rasterizing text. 348 | 349 | # Rasterizing text 350 | 351 | We want to only decode one glyph at a time to reduce the memory usage, that can be done quite trivially 352 | 353 | ```js 354 | const Alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 355 | const Atlas = Uint8Array.from(Buffer.from('MFAAAAwDKNbUsky0bVFtTdq2aZJqDdq2Udt2FQBgAJwCheagdS21kFRlW9e1adJqTdugU4kqADBgQA0jKFC0CmthrQA=', 'base64')); 356 | const Palette = [ 357 | [0xff, 0xff, 0xff], 358 | [0xff, 0x00, 0x00], 359 | [0x00, 0xff, 0x00], 360 | [0x00, 0x00, 0xff], 361 | [0x00, 0xff, 0xff], 362 | [0xff, 0x00, 0xff], 363 | [0xff, 0xff, 0x00] 364 | ]; 365 | 366 | // at the given bit offset |offset| read a 3-bit value from the Atlas 367 | read = (offset) => { 368 | let value = 0; 369 | for (let i = 0; i < 3; ) { 370 | const bit_offset = offset & 7; 371 | const read = Math.min(3 - i, 8 - bit_offset); 372 | const read_bits = (Atlas[offset >> 3] >> bit_offset) & (~(0xff << read)); 373 | value |= read_bits << i; 374 | offset += read; 375 | i += read; 376 | } 377 | return value; 378 | }; 379 | 380 | // for a given glyph |g| unpack the palette indices for the 5 vertical pixels 381 | unpack = (g) => { 382 | return (new Uint8Array(5)).map((_, i) => 383 | read(Alphabet.length*3*i + Alphabet.indexOf(g)*3)); 384 | }; 385 | 386 | // for given glyph |g| decode the 1x5 vertical RGB strip 387 | decode = (g) => { 388 | const rgb = new Uint8Array(5*3); 389 | unpack(g).forEach((value, index) => 390 | rgb.set(Palette[value], index*3)); 391 | return rgb; 392 | } 393 | ``` 394 | 395 | The `decode` function here gives us our original `1x5` strip for the given glyph `g`. What's most impressive is we only need **5 bytes** of memory to decode a single character into memory. Similarly, we only need **~1.875 bytes** of memory to read such a character to begin with. Giving us an average working set of **6.875 bytes**. When you include the **68 bytes** used to represent the atlas and **36 bytes** to represent the alphabet string, we're capable of drawing text with less than **128 bytes** of RAM _theoretically_. 396 | 397 | * Writing this in assembler or C would allow you to actually see these savings 398 | 399 | Now all that's left is a way to compose these vertical strips into an image for the purposes of drawing some text. 400 | 401 | ```js 402 | print = (t) => { 403 | const c = t.toUpperCase().replace(/[^\w\d ]/g, ''); 404 | const w = c.length * 2 - 1, h = 5, bpp = 3; // * 2 for whitespace 405 | const b = new Uint8Array(w * h * bpp); 406 | [...c].forEach((g, i) => { 407 | if (g !== ' ') for (let y = 0; y < h; y++) { 408 | // copy each 1x1 pixel row to the the bitmap 409 | b.set(decode(g).slice(y * bpp, y * bpp + bpp), (y * w + i * 2) * bpp); 410 | } 411 | }); 412 | return {w: w, h: h, data: b}; 413 | }; 414 | ``` 415 | 416 | With that we've broken the physical limits of fonts. 417 | 418 | ```js 419 | const fs = require('fs'); 420 | const result = print("Breaking the physical limits of fonts"); 421 | fs.writeFileSync(`${result.w}x${result.h}.bin`, result.data); 422 | ``` 423 | 424 | Use some [imagemagick](https://imagemagick.org/index.php) to get 425 | a readable image in a format that you can actually preview. 426 | ``` 427 | # convert -size 73x5 -depth 8 rgb:73x5.bin done.png 428 | ``` 429 | 430 | Here's the final result 431 | 432 | ![](done.png) 433 | 434 | Here it is scaled up 12x 435 | 436 | ![](done-scale.png) 437 | 438 | Here it is on a poorly calibrated display. 439 | ![](done-cap.png) 440 | 441 | Here's a much cleaner image on a properly calibrated display. 442 | ![](done-other.png) --------------------------------------------------------------------------------