From b7e64c79b471628dbcba311b1f9d1e100cafd9d9 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Wed, 10 Jun 2026 16:48:57 -0500 Subject: [PATCH] Add imaging/document primitives: PNG codec, rasterizer, GIF, PDF - Pure-JS PNG decode (grey/RGB/palette/alpha) + RGBA encode, CRC-checked; decode verified byte-identical with ImageMagick - Software rasterizer: shapes, arrows, blur, highlight, magnify, tooltip, number badges, cursor, bitmap text (vendored public-domain font8x8), crop/resize, focused-view rendering - GIF89a encoder with LZW (cross-validated pixel-for-pixel against ImageMagick decode) + NETSCAPE looping - Minimal PDF 1.4 writer: pages, fonts, rects, images, outlines, valid xref; rendering validated under Ghostscript - 12 imaging workflow tests (35 total) Co-Authored-By: Claude Fable 5 --- core/font8x8.js | 141 +++++++++++ core/gif.js | 154 ++++++++++++ core/pdf.js | 214 +++++++++++++++++ core/png.js | 160 +++++++++++++ core/raster.js | 412 +++++++++++++++++++++++++++++++++ tests/unit/gifdecode.js | 102 ++++++++ tests/unit/imaging.test.js | 230 ++++++++++++++++++ vendor/font8x8/font8x8_basic.h | 152 ++++++++++++ 8 files changed, 1565 insertions(+) create mode 100644 core/font8x8.js create mode 100644 core/gif.js create mode 100644 core/pdf.js create mode 100644 core/png.js create mode 100644 core/raster.js create mode 100644 tests/unit/gifdecode.js create mode 100644 tests/unit/imaging.test.js create mode 100644 vendor/font8x8/font8x8_basic.h diff --git a/core/font8x8.js b/core/font8x8.js new file mode 100644 index 0000000..7c75f73 --- /dev/null +++ b/core/font8x8.js @@ -0,0 +1,141 @@ +'use strict'; + +/** + * 8x8 monochrome bitmap font (ASCII 0-127), public domain. + * Generated from vendor/font8x8/font8x8_basic.h + * (Daniel Hepper / Marcel Sondaar / IBM public-domain VGA fonts). + * Each glyph is 8 bytes; bit n of byte y is pixel (x=n, y). + */ + +const FONT8X8 = [ + [0,0,0,0,0,0,0,0], // 0x0 + [0,0,0,0,0,0,0,0], // 0x1 + [0,0,0,0,0,0,0,0], // 0x2 + [0,0,0,0,0,0,0,0], // 0x3 + [0,0,0,0,0,0,0,0], // 0x4 + [0,0,0,0,0,0,0,0], // 0x5 + [0,0,0,0,0,0,0,0], // 0x6 + [0,0,0,0,0,0,0,0], // 0x7 + [0,0,0,0,0,0,0,0], // 0x8 + [0,0,0,0,0,0,0,0], // 0x9 + [0,0,0,0,0,0,0,0], // 0xa + [0,0,0,0,0,0,0,0], // 0xb + [0,0,0,0,0,0,0,0], // 0xc + [0,0,0,0,0,0,0,0], // 0xd + [0,0,0,0,0,0,0,0], // 0xe + [0,0,0,0,0,0,0,0], // 0xf + [0,0,0,0,0,0,0,0], // 0x10 + [0,0,0,0,0,0,0,0], // 0x11 + [0,0,0,0,0,0,0,0], // 0x12 + [0,0,0,0,0,0,0,0], // 0x13 + [0,0,0,0,0,0,0,0], // 0x14 + [0,0,0,0,0,0,0,0], // 0x15 + [0,0,0,0,0,0,0,0], // 0x16 + [0,0,0,0,0,0,0,0], // 0x17 + [0,0,0,0,0,0,0,0], // 0x18 + [0,0,0,0,0,0,0,0], // 0x19 + [0,0,0,0,0,0,0,0], // 0x1a + [0,0,0,0,0,0,0,0], // 0x1b + [0,0,0,0,0,0,0,0], // 0x1c + [0,0,0,0,0,0,0,0], // 0x1d + [0,0,0,0,0,0,0,0], // 0x1e + [0,0,0,0,0,0,0,0], // 0x1f + [0,0,0,0,0,0,0,0], // " " + [24,60,60,24,24,0,24,0], // "!" + [54,54,0,0,0,0,0,0], // "\"" + [54,54,127,54,127,54,54,0], // "#" + [12,62,3,30,48,31,12,0], // "$" + [0,99,51,24,12,102,99,0], // "%" + [28,54,28,110,59,51,110,0], // "&" + [6,6,3,0,0,0,0,0], // "'" + [24,12,6,6,6,12,24,0], // "(" + [6,12,24,24,24,12,6,0], // ")" + [0,102,60,255,60,102,0,0], // "*" + [0,12,12,63,12,12,0,0], // "+" + [0,0,0,0,0,12,12,6], // "," + [0,0,0,63,0,0,0,0], // "-" + [0,0,0,0,0,12,12,0], // "." + [96,48,24,12,6,3,1,0], // "/" + [62,99,115,123,111,103,62,0], // "0" + [12,14,12,12,12,12,63,0], // "1" + [30,51,48,28,6,51,63,0], // "2" + [30,51,48,28,48,51,30,0], // "3" + [56,60,54,51,127,48,120,0], // "4" + [63,3,31,48,48,51,30,0], // "5" + [28,6,3,31,51,51,30,0], // "6" + [63,51,48,24,12,12,12,0], // "7" + [30,51,51,30,51,51,30,0], // "8" + [30,51,51,62,48,24,14,0], // "9" + [0,12,12,0,0,12,12,0], // ":" + [0,12,12,0,0,12,12,6], // ";" + [24,12,6,3,6,12,24,0], // "<" + [0,0,63,0,0,63,0,0], // "=" + [6,12,24,48,24,12,6,0], // ">" + [30,51,48,24,12,0,12,0], // "?" + [62,99,123,123,123,3,30,0], // "@" + [12,30,51,51,63,51,51,0], // "A" + [63,102,102,62,102,102,63,0], // "B" + [60,102,3,3,3,102,60,0], // "C" + [31,54,102,102,102,54,31,0], // "D" + [127,70,22,30,22,70,127,0], // "E" + [127,70,22,30,22,6,15,0], // "F" + [60,102,3,3,115,102,124,0], // "G" + [51,51,51,63,51,51,51,0], // "H" + [30,12,12,12,12,12,30,0], // "I" + [120,48,48,48,51,51,30,0], // "J" + [103,102,54,30,54,102,103,0], // "K" + [15,6,6,6,70,102,127,0], // "L" + [99,119,127,127,107,99,99,0], // "M" + [99,103,111,123,115,99,99,0], // "N" + [28,54,99,99,99,54,28,0], // "O" + [63,102,102,62,6,6,15,0], // "P" + [30,51,51,51,59,30,56,0], // "Q" + [63,102,102,62,54,102,103,0], // "R" + [30,51,7,14,56,51,30,0], // "S" + [63,45,12,12,12,12,30,0], // "T" + [51,51,51,51,51,51,63,0], // "U" + [51,51,51,51,51,30,12,0], // "V" + [99,99,99,107,127,119,99,0], // "W" + [99,99,54,28,28,54,99,0], // "X" + [51,51,51,30,12,12,30,0], // "Y" + [127,99,49,24,76,102,127,0], // "Z" + [30,6,6,6,6,6,30,0], // "[" + [3,6,12,24,48,96,64,0], // "\\" + [30,24,24,24,24,24,30,0], // "]" + [8,28,54,99,0,0,0,0], // "^" + [0,0,0,0,0,0,0,255], // "_" + [12,12,24,0,0,0,0,0], // "`" + [0,0,30,48,62,51,110,0], // "a" + [7,6,6,62,102,102,59,0], // "b" + [0,0,30,51,3,51,30,0], // "c" + [56,48,48,62,51,51,110,0], // "d" + [0,0,30,51,63,3,30,0], // "e" + [28,54,6,15,6,6,15,0], // "f" + [0,0,110,51,51,62,48,31], // "g" + [7,6,54,110,102,102,103,0], // "h" + [12,0,14,12,12,12,30,0], // "i" + [48,0,48,48,48,51,51,30], // "j" + [7,6,102,54,30,54,103,0], // "k" + [14,12,12,12,12,12,30,0], // "l" + [0,0,51,127,127,107,99,0], // "m" + [0,0,31,51,51,51,51,0], // "n" + [0,0,30,51,51,51,30,0], // "o" + [0,0,59,102,102,62,6,15], // "p" + [0,0,110,51,51,62,48,120], // "q" + [0,0,59,110,102,6,15,0], // "r" + [0,0,62,3,30,48,31,0], // "s" + [8,12,62,12,12,44,24,0], // "t" + [0,0,51,51,51,51,110,0], // "u" + [0,0,51,51,51,30,12,0], // "v" + [0,0,99,107,127,127,54,0], // "w" + [0,0,99,54,28,54,99,0], // "x" + [0,0,51,51,51,62,48,31], // "y" + [0,0,63,25,12,38,63,0], // "z" + [56,12,12,7,12,12,56,0], // "{" + [24,24,24,0,24,24,24,0], // "|" + [7,12,12,56,12,12,7,0], // "}" + [110,59,0,0,0,0,0,0], // "~" + [0,0,0,0,0,0,0,0] // 0x7f +]; + +module.exports = { FONT8X8 }; diff --git a/core/gif.js b/core/gif.js new file mode 100644 index 0000000..d3ec60c --- /dev/null +++ b/core/gif.js @@ -0,0 +1,154 @@ +'use strict'; + +/** + * GIF89a encoder (pure JS). Uses a fixed 6x7x6 RGB palette (252 colors), + * full-frame LZW-compressed frames, and a NETSCAPE looping extension. + * Good enough for screenshot slideshows; deterministic output. + */ + +const R_LEVELS = 6, G_LEVELS = 7, B_LEVELS = 6; + +function buildPalette() { + const palette = Buffer.alloc(256 * 3); + let i = 0; + for (let r = 0; r < R_LEVELS; r++) { + for (let g = 0; g < G_LEVELS; g++) { + for (let b = 0; b < B_LEVELS; b++) { + palette[i * 3] = Math.round((r * 255) / (R_LEVELS - 1)); + palette[i * 3 + 1] = Math.round((g * 255) / (G_LEVELS - 1)); + palette[i * 3 + 2] = Math.round((b * 255) / (B_LEVELS - 1)); + i++; + } + } + } + return palette; // remaining entries stay black +} + +const PALETTE = buildPalette(); + +function quantizeIndex(r, g, b) { + const ri = Math.round((r / 255) * (R_LEVELS - 1)); + const gi = Math.round((g / 255) * (G_LEVELS - 1)); + const bi = Math.round((b / 255) * (B_LEVELS - 1)); + return ri * G_LEVELS * B_LEVELS + gi * B_LEVELS + bi; +} + +/** Map an RGBA image to palette indices. */ +function toIndices(img) { + const n = img.width * img.height; + const out = Buffer.alloc(n); + for (let i = 0; i < n; i++) { + const p = i * 4; + out[i] = quantizeIndex(img.data[p], img.data[p + 1], img.data[p + 2]); + } + return out; +} + +/** GIF LZW compression of palette indices, minCodeSize 8. */ +function lzwEncode(indices) { + const MIN_CODE = 8; + const CLEAR = 1 << MIN_CODE; // 256 + const EOI = CLEAR + 1; // 257 + const MAX_CODE = 4096; + + const bytes = []; + let bitBuf = 0, bitCnt = 0; + let codeSize = MIN_CODE + 1; + const emit = (code) => { + bitBuf |= code << bitCnt; + bitCnt += codeSize; + while (bitCnt >= 8) { + bytes.push(bitBuf & 0xff); + bitBuf >>>= 8; + bitCnt -= 8; + } + }; + + let dict = new Map(); + let next = EOI + 1; + const reset = () => { dict = new Map(); next = EOI + 1; codeSize = MIN_CODE + 1; }; + + emit(CLEAR); + let prefix = -1; + for (let i = 0; i < indices.length; i++) { + const c = indices[i]; + if (prefix < 0) { prefix = c; continue; } + const key = prefix * 256 + c; + const found = dict.get(key); + if (found !== undefined) { + prefix = found; + } else { + emit(prefix); + dict.set(key, next); + next++; + // The decoder builds its table one entry behind the encoder, so the + // width change happens at (1<= MAX_CODE) { + emit(CLEAR); + reset(); + } + prefix = c; + } + } + if (prefix >= 0) emit(prefix); + emit(EOI); + if (bitCnt > 0) bytes.push(bitBuf & 0xff); + + // Pack into <=255-byte sub-blocks + const out = [Buffer.from([MIN_CODE])]; + for (let i = 0; i < bytes.length; i += 255) { + const blockData = bytes.slice(i, i + 255); + out.push(Buffer.from([blockData.length]), Buffer.from(blockData)); + } + out.push(Buffer.from([0])); + return Buffer.concat(out); +} + +/** + * Encode frames (RGBA images, all same size) into an animated GIF. + * delayCs is per-frame delay in centiseconds; loop 0 = forever. + */ +function encodeGif(frames, { delayCs = 150, loop = 0 } = {}) { + if (!frames.length) throw new Error('gif: no frames'); + const { width, height } = frames[0]; + const parts = []; + + parts.push(Buffer.from('GIF89a', 'latin1')); + const lsd = Buffer.alloc(7); + lsd.writeUInt16LE(width, 0); + lsd.writeUInt16LE(height, 2); + lsd[4] = 0xf7; // GCT present, 8-bit color, 256 entries + lsd[5] = 0; // background color + lsd[6] = 0; // aspect + parts.push(lsd, PALETTE); + + // NETSCAPE2.0 looping extension + parts.push(Buffer.from([0x21, 0xff, 0x0b])); + parts.push(Buffer.from('NETSCAPE2.0', 'latin1')); + parts.push(Buffer.from([0x03, 0x01, loop & 0xff, (loop >> 8) & 0xff, 0x00])); + + for (const frame of frames) { + if (frame.width !== width || frame.height !== height) { + throw new Error('gif: all frames must share dimensions'); + } + const gce = Buffer.alloc(8); + gce[0] = 0x21; gce[1] = 0xf9; gce[2] = 4; + gce[3] = 0x04; // disposal: do not dispose + gce.writeUInt16LE(Math.max(2, Math.round(delayCs)), 4); + gce[6] = 0; gce[7] = 0; + parts.push(gce); + + const desc = Buffer.alloc(10); + desc[0] = 0x2c; + desc.writeUInt16LE(0, 1); desc.writeUInt16LE(0, 3); + desc.writeUInt16LE(width, 5); desc.writeUInt16LE(height, 7); + desc[9] = 0; // no local color table + parts.push(desc, lzwEncode(toIndices(frame))); + } + + parts.push(Buffer.from([0x3b])); // trailer + return Buffer.concat(parts); +} + +module.exports = { encodeGif, PALETTE, quantizeIndex, toIndices, lzwEncode }; diff --git a/core/pdf.js b/core/pdf.js new file mode 100644 index 0000000..efcca80 --- /dev/null +++ b/core/pdf.js @@ -0,0 +1,214 @@ +'use strict'; + +const zlib = require('node:zlib'); + +/** + * Minimal PDF 1.4 writer: pages, Helvetica/Helvetica-Bold/Courier text, + * rects/lines, deflated content streams, RGB images as XObjects, and a + * simple outline (bookmarks). Coordinates passed in are top-left based in + * points; converted to PDF's bottom-left space internally. + */ + +const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' }; +// Approximate average glyph width factors (per 1pt font size) for wrapping. +const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 }; + +function esc(text) { + return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); +} + +function toLatin1(text) { + let out = ''; + for (const ch of String(text)) { + const code = ch.codePointAt(0); + out += code <= 0xff ? ch : '?'; + } + return out; +} + +function col(c) { + return `${(c[0] / 255).toFixed(3)} ${(c[1] / 255).toFixed(3)} ${(c[2] / 255).toFixed(3)}`; +} + +class PdfBuilder { + constructor({ pageWidth = 595.28, pageHeight = 841.89 } = {}) { + this.pageWidth = pageWidth; + this.pageHeight = pageHeight; + this.pages = []; + this.images = []; // { name, width, height, data (deflated RGB), smask? } + this.imageCache = new Map(); + this.bookmarks = []; // { title, pageIndex, y } + } + + addPage() { + this.pages.push({ ops: [] }); + return this.pages.length - 1; + } + + get currentPage() { + if (!this.pages.length) this.addPage(); + return this.pages[this.pages.length - 1]; + } + + textWidth(text, size, font = 'F1') { + return String(text).length * size * (FONT_WIDTH_FACTOR[font] || 0.51); + } + + /** Greedy word wrap to maxWidth points. */ + wrapText(text, size, maxWidth, font = 'F1') { + const lines = []; + for (const para of String(text).split('\n')) { + const words = para.split(/\s+/).filter(Boolean); + if (!words.length) { lines.push(''); continue; } + let line = ''; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (this.textWidth(candidate, size, font) <= maxWidth || !line) line = candidate; + else { lines.push(line); line = word; } + } + lines.push(line); + } + return lines; + } + + text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) { + const y = this.pageHeight - yTop - size; + this.currentPage.ops.push( + `BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET` + ); + } + + rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) { + const y = this.pageHeight - yTop - h; + const ops = []; + if (fill) ops.push(`${col(fill)} rg`); + if (stroke) ops.push(`${col(stroke)} RG ${lineWidth} w`); + ops.push(`${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re`); + ops.push(fill && stroke ? 'B' : fill ? 'f' : 'S'); + this.currentPage.ops.push(ops.join(' ')); + } + + line(x0, y0t, x1, y1t, { color = [0, 0, 0], width = 1 } = {}) { + const y0 = this.pageHeight - y0t, y1 = this.pageHeight - y1t; + this.currentPage.ops.push( + `${col(color)} RG ${width} w ${x0.toFixed(2)} ${y0.toFixed(2)} m ${x1.toFixed(2)} ${y1.toFixed(2)} l S` + ); + } + + /** Draw an RGBA raster image; alpha is dropped (composited upstream). */ + image(img, x, yTop, w, h) { + let name = this.imageCache.get(img); + if (!name) { + name = `Im${this.images.length + 1}`; + const rgb = Buffer.alloc(img.width * img.height * 3); + for (let i = 0, n = img.width * img.height; i < n; i++) { + rgb[i * 3] = img.data[i * 4]; + rgb[i * 3 + 1] = img.data[i * 4 + 1]; + rgb[i * 3 + 2] = img.data[i * 4 + 2]; + } + this.images.push({ name, width: img.width, height: img.height, data: zlib.deflateSync(rgb) }); + this.imageCache.set(img, name); + } + const y = this.pageHeight - yTop - h; + this.currentPage.ops.push( + `q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm /${name} Do Q` + ); + return name; + } + + bookmark(title, pageIndex = this.pages.length - 1) { + this.bookmarks.push({ title: toLatin1(title), pageIndex }); + } + + build() { + if (!this.pages.length) this.addPage(); + const objects = []; // 1-based; objects[i] = body string|Buffer after header + const addObj = (body) => { objects.push(body); return objects.length; }; + + // Reserve ids: 1 catalog, 2 pages tree (filled later) + addObj(null); // 1: catalog placeholder + addObj(null); // 2: pages placeholder + + const fontIds = {}; + for (const [res, base] of Object.entries(FONTS)) { + fontIds[res] = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /${base} /Encoding /WinAnsiEncoding >>`); + } + const imageIds = {}; + for (const img of this.images) { + imageIds[img.name] = addObj({ + dict: `<< /Type /XObject /Subtype /Image /Width ${img.width} /Height ${img.height} ` + + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length ${img.data.length} >>`, + stream: img.data, + }); + } + + const fontRes = Object.entries(fontIds).map(([r, id]) => `/${r} ${id} 0 R`).join(' '); + const imgRes = this.images.map((img) => `/${img.name} ${imageIds[img.name]} 0 R`).join(' '); + const resources = `<< /Font << ${fontRes} >> ${this.images.length ? `/XObject << ${imgRes} >>` : ''} >>`; + + const pageIds = []; + for (const page of this.pages) { + const content = zlib.deflateSync(Buffer.from(page.ops.join('\n'), 'latin1')); + const contentId = addObj({ + dict: `<< /Filter /FlateDecode /Length ${content.length} >>`, + stream: content, + }); + pageIds.push(addObj( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] ` + + `/Resources ${resources} /Contents ${contentId} 0 R >>` + )); + } + + let outlinesRef = ''; + if (this.bookmarks.length) { + const outlineRootId = objects.length + 1; + const itemIds = this.bookmarks.map((_, i) => outlineRootId + 1 + i); + addObj(`<< /Type /Outlines /First ${itemIds[0]} 0 R /Last ${itemIds[itemIds.length - 1]} 0 R /Count ${itemIds.length} >>`); + this.bookmarks.forEach((bm, i) => { + const parts = [ + `/Title (${esc(bm.title)})`, + `/Parent ${outlineRootId} 0 R`, + `/Dest [${pageIds[bm.pageIndex]} 0 R /Fit]`, + ]; + if (i > 0) parts.push(`/Prev ${itemIds[i - 1]} 0 R`); + if (i < itemIds.length - 1) parts.push(`/Next ${itemIds[i + 1]} 0 R`); + addObj(`<< ${parts.join(' ')} >>`); + }); + outlinesRef = `/Outlines ${outlineRootId} 0 R /PageMode /UseOutlines`; + } + + objects[0] = `<< /Type /Catalog /Pages 2 0 R ${outlinesRef} >>`; + objects[1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`; + + // Serialize with xref + const chunks = [Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'latin1')]; + let offset = chunks[0].length; + const offsets = [0]; + objects.forEach((body, idx) => { + offsets.push(offset); + let objBuf; + if (body && typeof body === 'object' && body.stream) { + objBuf = Buffer.concat([ + Buffer.from(`${idx + 1} 0 obj\n${body.dict}\nstream\n`, 'latin1'), + body.stream, + Buffer.from('\nendstream\nendobj\n', 'latin1'), + ]); + } else { + objBuf = Buffer.from(`${idx + 1} 0 obj\n${body}\nendobj\n`, 'latin1'); + } + chunks.push(objBuf); + offset += objBuf.length; + }); + + const xrefStart = offset; + let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (let i = 1; i <= objects.length; i++) { + xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`; + } + xref += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`; + chunks.push(Buffer.from(xref, 'latin1')); + return Buffer.concat(chunks); + } +} + +module.exports = { PdfBuilder, FONTS }; diff --git a/core/png.js b/core/png.js new file mode 100644 index 0000000..a44b48f --- /dev/null +++ b/core/png.js @@ -0,0 +1,160 @@ +'use strict'; + +const zlib = require('node:zlib'); +const { crc32 } = require('./zip'); + +/** + * Pure-JS PNG codec. Decodes 8-bit greyscale/RGB/palette/grey+alpha/RGBA + * (non-interlaced) into RGBA; encodes RGBA. Enough for screenshots and + * export rasterization without native dependencies. + */ + +const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const MAX_DIM = 32768; + +function decodePng(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 57 || !buffer.subarray(0, 8).equals(SIGNATURE)) { + throw new Error('png: bad signature'); + } + let pos = 8; + let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0; + let palette = null, trns = null; + const idat = []; + + while (pos + 8 <= buffer.length) { + const len = buffer.readUInt32BE(pos); + const type = buffer.toString('latin1', pos + 4, pos + 8); + const dataStart = pos + 8; + if (len > buffer.length - dataStart) throw new Error('png: truncated chunk'); + const data = buffer.subarray(dataStart, dataStart + len); + const expectCrc = buffer.readUInt32BE(dataStart + len); + if (crc32(buffer.subarray(pos + 4, dataStart + len)) !== expectCrc) { + throw new Error(`png: CRC mismatch in ${type}`); + } + if (type === 'IHDR') { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + interlace = data[12]; + if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) { + throw new Error('png: unreasonable dimensions'); + } + if (bitDepth !== 8) throw new Error(`png: unsupported bit depth ${bitDepth}`); + if (![0, 2, 3, 4, 6].includes(colorType)) throw new Error(`png: bad color type ${colorType}`); + if (interlace !== 0) throw new Error('png: interlaced images not supported'); + } else if (type === 'PLTE') { + palette = data; + } else if (type === 'tRNS') { + trns = data; + } else if (type === 'IDAT') { + idat.push(data); + } else if (type === 'IEND') { + break; + } + pos = dataStart + len + 4; + } + if (!width) throw new Error('png: missing IHDR'); + if (idat.length === 0) throw new Error('png: missing IDAT'); + + const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType]; + const bpp = channels; // bytes per pixel at 8-bit depth + const stride = width * bpp; + const raw = zlib.inflateSync(Buffer.concat(idat)); + if (raw.length < (stride + 1) * height) throw new Error('png: scanline data too short'); + + // Unfilter + const lines = Buffer.alloc(stride * height); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + const src = (stride + 1) * y + 1; + const dst = stride * y; + for (let x = 0; x < stride; x++) { + const rawByte = raw[src + x]; + const left = x >= bpp ? lines[dst + x - bpp] : 0; + const up = y > 0 ? lines[dst + x - stride] : 0; + const upLeft = y > 0 && x >= bpp ? lines[dst + x - stride - bpp] : 0; + let val; + switch (filter) { + case 0: val = rawByte; break; + case 1: val = rawByte + left; break; + case 2: val = rawByte + up; break; + case 3: val = rawByte + ((left + up) >> 1); break; + case 4: { + const p = left + up - upLeft; + const pa = Math.abs(p - left), pb = Math.abs(p - up), pc = Math.abs(p - upLeft); + val = rawByte + (pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft); + break; + } + default: throw new Error(`png: bad filter ${filter}`); + } + lines[dst + x] = val & 0xff; + } + } + + // Expand to RGBA + const out = Buffer.alloc(width * height * 4); + for (let i = 0, p = 0; i < width * height; i++, p += 4) { + const s = i * bpp; + switch (colorType) { + case 0: + out[p] = out[p + 1] = out[p + 2] = lines[s]; + out[p + 3] = 255; + break; + case 2: + out[p] = lines[s]; out[p + 1] = lines[s + 1]; out[p + 2] = lines[s + 2]; + out[p + 3] = 255; + break; + case 3: { + const idx = lines[s]; + if (!palette || idx * 3 + 2 >= palette.length) throw new Error('png: palette index out of range'); + out[p] = palette[idx * 3]; out[p + 1] = palette[idx * 3 + 1]; out[p + 2] = palette[idx * 3 + 2]; + out[p + 3] = trns && idx < trns.length ? trns[idx] : 255; + break; + } + case 4: + out[p] = out[p + 1] = out[p + 2] = lines[s]; + out[p + 3] = lines[s + 1]; + break; + case 6: + out[p] = lines[s]; out[p + 1] = lines[s + 1]; out[p + 2] = lines[s + 2]; + out[p + 3] = lines[s + 3]; + break; + } + } + return { width, height, data: out }; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const typeBuf = Buffer.from(type, 'latin1'); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data]))); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +/** Encode an RGBA image { width, height, data } to PNG bytes. */ +function encodePng(img) { + const { width, height, data } = img; + if (!width || !height || data.length !== width * height * 4) { + throw new Error('png: encode expects RGBA data of width*height*4'); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + // compression/filter/interlace = 0 + + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (stride + 1)] = 0; // filter: none + data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + const idat = zlib.deflateSync(raw, { level: 6 }); + return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]); +} + +module.exports = { decodePng, encodePng }; diff --git a/core/raster.js b/core/raster.js new file mode 100644 index 0000000..1317e20 --- /dev/null +++ b/core/raster.js @@ -0,0 +1,412 @@ +'use strict'; + +const { FONT8X8 } = require('./font8x8'); + +/** + * Software rasterizer for annotation rendering in exports. Operates on + * RGBA images ({ width, height, data: Buffer }). The same normalized + * annotation scene graph drawn by the editor canvas is burned into pixels + * here, so exports match the editor. + * + * Stroke widths are normalized to a 1000px-wide reference image and scaled, + * font sizes are fractions of image height — both resolution-independent. + */ + +function createImage(width, height, color = [255, 255, 255, 255]) { + const data = Buffer.alloc(width * height * 4); + for (let p = 0; p < data.length; p += 4) { + data[p] = color[0]; data[p + 1] = color[1]; data[p + 2] = color[2]; data[p + 3] = color[3]; + } + return { width, height, data }; +} + +function cloneImage(img) { + return { width: img.width, height: img.height, data: Buffer.from(img.data) }; +} + +function parseColor(str, fallback = [0, 0, 0, 255]) { + if (Array.isArray(str)) return str; + if (typeof str !== 'string' || str === 'transparent' || str === 'none' || str === '') return str === undefined ? fallback : [0, 0, 0, 0]; + const m = /^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(str.trim()); + if (!m) return fallback; + const v = parseInt(m[1], 16); + return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, m[2] ? parseInt(m[2], 16) : 255]; +} + +function blendPixel(img, x, y, color) { + if (x < 0 || y < 0 || x >= img.width || y >= img.height) return; + const a = color[3] / 255; + if (a <= 0) return; + const p = (y * img.width + x) * 4; + const d = img.data; + if (a >= 1) { + d[p] = color[0]; d[p + 1] = color[1]; d[p + 2] = color[2]; d[p + 3] = 255; + return; + } + d[p] = Math.round(color[0] * a + d[p] * (1 - a)); + d[p + 1] = Math.round(color[1] * a + d[p + 1] * (1 - a)); + d[p + 2] = Math.round(color[2] * a + d[p + 2] * (1 - a)); + d[p + 3] = Math.max(d[p + 3], Math.round(255 * a)); +} + +function fillRect(img, x, y, w, h, color) { + const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y)); + const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h)); + for (let yy = y0; yy < y1; yy++) { + for (let xx = x0; xx < x1; xx++) blendPixel(img, xx, yy, color); + } +} + +function strokeRect(img, x, y, w, h, color, t) { + fillRect(img, x - t / 2, y - t / 2, w + t, t, color); // top + fillRect(img, x - t / 2, y + h - t / 2, w + t, t, color); // bottom + fillRect(img, x - t / 2, y + t / 2, t, h - t, color); // left + fillRect(img, x + w - t / 2, y + t / 2, t, h - t, color); // right +} + +function ovalCoverage(cx, cy, rx, ry, px, py) { + const dx = (px - cx) / rx, dy = (py - cy) / ry; + return dx * dx + dy * dy; +} + +function fillOval(img, x, y, w, h, color) { + const cx = x + w / 2, cy = y + h / 2, rx = Math.max(1, w / 2), ry = Math.max(1, h / 2); + const y0 = Math.max(0, Math.floor(y)), y1 = Math.min(img.height, Math.ceil(y + h)); + const x0 = Math.max(0, Math.floor(x)), x1 = Math.min(img.width, Math.ceil(x + w)); + for (let yy = y0; yy < y1; yy++) { + for (let xx = x0; xx < x1; xx++) { + if (ovalCoverage(cx, cy, rx, ry, xx + 0.5, yy + 0.5) <= 1) blendPixel(img, xx, yy, color); + } + } +} + +function strokeOval(img, x, y, w, h, color, t) { + const cx = x + w / 2, cy = y + h / 2; + const rxO = Math.max(1, w / 2 + t / 2), ryO = Math.max(1, h / 2 + t / 2); + const rxI = Math.max(0.5, w / 2 - t / 2), ryI = Math.max(0.5, h / 2 - t / 2); + const y0 = Math.max(0, Math.floor(cy - ryO)), y1 = Math.min(img.height, Math.ceil(cy + ryO)); + const x0 = Math.max(0, Math.floor(cx - rxO)), x1 = Math.min(img.width, Math.ceil(cx + rxO)); + for (let yy = y0; yy < y1; yy++) { + for (let xx = x0; xx < x1; xx++) { + const px = xx + 0.5, py = yy + 0.5; + if (ovalCoverage(cx, cy, rxO, ryO, px, py) <= 1 && ovalCoverage(cx, cy, rxI, ryI, px, py) > 1) { + blendPixel(img, xx, yy, color); + } + } + } +} + +function drawLine(img, x0, y0, x1, y1, color, t) { + const half = Math.max(0.5, t / 2); + const minX = Math.max(0, Math.floor(Math.min(x0, x1) - half - 1)); + const maxX = Math.min(img.width, Math.ceil(Math.max(x0, x1) + half + 1)); + const minY = Math.max(0, Math.floor(Math.min(y0, y1) - half - 1)); + const maxY = Math.min(img.height, Math.ceil(Math.max(y0, y1) + half + 1)); + const dx = x1 - x0, dy = y1 - y0; + const lenSq = dx * dx + dy * dy || 1; + for (let yy = minY; yy < maxY; yy++) { + for (let xx = minX; xx < maxX; xx++) { + const px = xx + 0.5, py = yy + 0.5; + let u = ((px - x0) * dx + (py - y0) * dy) / lenSq; + u = Math.max(0, Math.min(1, u)); + const ex = x0 + u * dx - px, ey = y0 + u * dy - py; + if (ex * ex + ey * ey <= half * half) blendPixel(img, xx, yy, color); + } + } +} + +/** Scanline polygon fill (even-odd). points: [[x,y],...] */ +function fillPolygon(img, points, color) { + const ys = points.map((p) => p[1]); + const y0 = Math.max(0, Math.floor(Math.min(...ys))); + const y1 = Math.min(img.height, Math.ceil(Math.max(...ys))); + for (let yy = y0; yy < y1; yy++) { + const scanY = yy + 0.5; + const xs = []; + for (let i = 0; i < points.length; i++) { + const [ax, ay] = points[i]; + const [bx, by] = points[(i + 1) % points.length]; + if ((ay <= scanY && by > scanY) || (by <= scanY && ay > scanY)) { + xs.push(ax + ((scanY - ay) / (by - ay)) * (bx - ax)); + } + } + xs.sort((a, b) => a - b); + for (let k = 0; k + 1 < xs.length; k += 2) { + const xa = Math.max(0, Math.round(xs[k])); + const xb = Math.min(img.width, Math.round(xs[k + 1])); + for (let xx = xa; xx < xb; xx++) blendPixel(img, xx, yy, color); + } + } +} + +function drawArrow(img, x0, y0, x1, y1, color, t) { + const dx = x1 - x0, dy = y1 - y0; + const len = Math.hypot(dx, dy) || 1; + const headLen = Math.min(len * 0.4, Math.max(10, t * 4)); + const ux = dx / len, uy = dy / len; + const bx = x1 - ux * headLen, by = y1 - uy * headLen; + drawLine(img, x0, y0, bx, by, color, t); + const wing = headLen * 0.5; + fillPolygon(img, [ + [x1, y1], + [bx - uy * wing, by + ux * wing], + [bx + uy * wing, by - ux * wing], + ], color); +} + +function boxBlur(img, x, y, w, h, radius) { + const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y)); + const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h)); + if (x1 <= x0 || y1 <= y0) return; + const r = Math.max(1, Math.round(radius)); + // Two passes of box blur approximates gaussian well enough for redaction. + for (let pass = 0; pass < 2; pass++) { + const src = Buffer.from(img.data); + for (let yy = y0; yy < y1; yy++) { + for (let xx = x0; xx < x1; xx++) { + let rs = 0, gs = 0, bs = 0, n = 0; + for (let oy = -r; oy <= r; oy += Math.max(1, Math.floor(r / 3))) { + for (let ox = -r; ox <= r; ox += Math.max(1, Math.floor(r / 3))) { + const sx = Math.min(x1 - 1, Math.max(x0, xx + ox)); + const sy = Math.min(y1 - 1, Math.max(y0, yy + oy)); + const p = (sy * img.width + sx) * 4; + rs += src[p]; gs += src[p + 1]; bs += src[p + 2]; n++; + } + } + const p = (yy * img.width + xx) * 4; + img.data[p] = Math.round(rs / n); + img.data[p + 1] = Math.round(gs / n); + img.data[p + 2] = Math.round(bs / n); + } + } + } +} + +/** Nearest-neighbour scaled copy of a region (used by magnify). */ +function magnifyRegion(img, x, y, w, h, zoom, borderColor, t) { + const src = cloneImage(img); + const cx = x + w / 2, cy = y + h / 2; + const sw = w / zoom, sh = h / zoom; + const sx0 = cx - sw / 2, sy0 = cy - sh / 2; + const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y)); + const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h)); + for (let yy = y0; yy < y1; yy++) { + for (let xx = x0; xx < x1; xx++) { + // Only inside the oval lens + if (ovalCoverage(cx, cy, w / 2, h / 2, xx + 0.5, yy + 0.5) > 1) continue; + const sx = Math.min(img.width - 1, Math.max(0, Math.round(sx0 + ((xx - x) / w) * sw))); + const sy = Math.min(img.height - 1, Math.max(0, Math.round(sy0 + ((yy - y) / h) * sh))); + const sp = (sy * src.width + sx) * 4; + const dp = (yy * img.width + xx) * 4; + img.data[dp] = src.data[sp]; img.data[dp + 1] = src.data[sp + 1]; + img.data[dp + 2] = src.data[sp + 2]; img.data[dp + 3] = 255; + } + } + strokeOval(img, x, y, w, h, borderColor, t); +} + +// ---- text ----------------------------------------------------------------- + +function glyphFor(ch) { + const code = ch.codePointAt(0); + return FONT8X8[code >= 0 && code < 128 ? code : 63]; // '?' fallback +} + +/** Width/height of text at a pixel size (8x8 glyphs, integer scaled). */ +function measureText(text, sizePx) { + const scale = Math.max(1, Math.round(sizePx / 8)); + const lines = String(text).split('\n'); + const w = Math.max(...lines.map((l) => l.length)) * 8 * scale; + return { width: w, height: lines.length * 10 * scale, scale, lineHeight: 10 * scale }; +} + +function drawText(img, x, y, text, sizePx, color) { + const { scale, lineHeight } = measureText(text, sizePx); + const lines = String(text).split('\n'); + let ty = Math.round(y); + for (const line of lines) { + let tx = Math.round(x); + for (const ch of line) { + const glyph = glyphFor(ch); + for (let gy = 0; gy < 8; gy++) { + const row = glyph[gy]; + for (let gx = 0; gx < 8; gx++) { + if (!(row & (1 << gx))) continue; + fillRect(img, tx + gx * scale, ty + gy * scale, scale, scale, color); + } + } + tx += 8 * scale; + } + ty += lineHeight; + } +} + +function drawTextCentered(img, cx, cy, text, sizePx, color) { + const m = measureText(text, sizePx); + drawText(img, cx - m.width / 2, cy - m.height / 2 + m.scale, text, sizePx, color); +} + +function drawCursorIcon(img, x, y, sizePx, color) { + const s = sizePx; + fillPolygon(img, [ + [x, y], [x, y + s], [x + s * 0.28, y + s * 0.75], + [x + s * 0.45, y + s * 1.05], [x + s * 0.58, y + s * 0.98], + [x + s * 0.42, y + s * 0.68], [x + s * 0.72, y + s * 0.68], + ], [255, 255, 255, 255]); + // dark outline by drawing slightly smaller inner arrow + fillPolygon(img, [ + [x + 2, y + 4], [x + 2, y + s - 3], [x + s * 0.26, y + s * 0.7], + [x + s * 0.42, y + s * 0.98], [x + s * 0.52, y + s * 0.93], + [x + s * 0.37, y + s * 0.63], [x + s * 0.62, y + s * 0.63], + ], color); +} + +// ---- composition ---------------------------------------------------------- + +function crop(img, x, y, w, h) { + const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y)); + const cw = Math.min(img.width - x0, Math.round(w)), ch = Math.min(img.height - y0, Math.round(h)); + if (cw <= 0 || ch <= 0) throw new Error('crop: empty region'); + const out = createImage(cw, ch); + for (let yy = 0; yy < ch; yy++) { + img.data.copy(out.data, yy * cw * 4, ((y0 + yy) * img.width + x0) * 4, ((y0 + yy) * img.width + x0 + cw) * 4); + } + return out; +} + +/** Bilinear resize. */ +function resize(img, w, h) { + const out = createImage(w, h); + for (let yy = 0; yy < h; yy++) { + const sy = ((yy + 0.5) * img.height) / h - 0.5; + const y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(img.height - 1, y0 + 1); + const fy = sy - y0; + for (let xx = 0; xx < w; xx++) { + const sx = ((xx + 0.5) * img.width) / w - 0.5; + const x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(img.width - 1, x0 + 1); + const fx = sx - x0; + const dp = (yy * w + xx) * 4; + for (let c = 0; c < 4; c++) { + const p00 = img.data[(y0 * img.width + x0) * 4 + c]; + const p01 = img.data[(y0 * img.width + x1) * 4 + c]; + const p10 = img.data[(y1 * img.width + x0) * 4 + c]; + const p11 = img.data[(y1 * img.width + x1) * 4 + c]; + out.data[dp + c] = Math.round( + p00 * (1 - fx) * (1 - fy) + p01 * fx * (1 - fy) + p10 * (1 - fx) * fy + p11 * fx * fy + ); + } + } + } + return out; +} + +function drawImage(dst, src, dx, dy) { + for (let yy = 0; yy < src.height; yy++) { + for (let xx = 0; xx < src.width; xx++) { + const sp = (yy * src.width + xx) * 4; + blendPixel(dst, dx + xx, dy + yy, [src.data[sp], src.data[sp + 1], src.data[sp + 2], src.data[sp + 3]]); + } + } +} + +// ---- annotation rendering --------------------------------------------------- + +const DRAW_ORDER = { blur: 0, highlight: 1, magnify: 2, rect: 3, oval: 3, line: 3, arrow: 3, cursor: 4, number: 5, text: 6, tooltip: 7 }; + +/** + * Burn annotations into a copy of the base image. Annotation coords are + * fractions of the image; returns a new image. + */ +function renderAnnotations(baseImg, annotations = []) { + const img = cloneImage(baseImg); + const W = img.width, H = img.height; + const px = (frac, total) => frac * total; + const strokePx = (sw) => Math.max(1, Math.round((sw || 3) * W / 1000)); + const fontPx = (style) => Math.max(8, Math.round((style.fontSize || 0.022) * H)); + + const ordered = [...annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3)); + + for (const ann of ordered) { + const x = px(ann.x, W), y = px(ann.y, H), w = px(ann.w, W), h = px(ann.h, H); + const style = ann.style || {}; + const stroke = parseColor(style.stroke, [229, 72, 77, 255]); + const fill = parseColor(style.fill, [0, 0, 0, 0]); + const textColor = parseColor(style.textColor, [255, 255, 255, 255]); + const t = strokePx(style.strokeWidth); + + switch (ann.type) { + case 'rect': + if (fill[3] > 0) fillRect(img, x, y, w, h, fill); + strokeRect(img, x, y, w, h, stroke, t); + break; + case 'oval': + if (fill[3] > 0) fillOval(img, x, y, w, h, fill); + strokeOval(img, x, y, w, h, stroke, t); + break; + case 'line': + drawLine(img, x, y, x + w, y + h, stroke, t); + break; + case 'arrow': + drawArrow(img, x, y, x + w, y + h, stroke, t); + break; + case 'blur': + boxBlur(img, x, y, w, h, ann.radius || 8); + break; + case 'highlight': + fillRect(img, x, y, w, h, [255, 235, 59, 105]); + break; + case 'magnify': + magnifyRegion(img, x, y, w, h, ann.zoom || 2, stroke, t); + break; + case 'text': { + drawText(img, x, y, ann.text || '', fontPx(style), stroke[3] > 0 ? stroke : [0, 0, 0, 255]); + break; + } + case 'tooltip': { + const bg = parseColor(style.fill === 'transparent' || !style.fill ? '#1F2937' : style.fill); + fillRect(img, x, y, w, h, bg); + strokeRect(img, x, y, w, h, parseColor(style.stroke, [17, 24, 39, 255]), Math.max(1, Math.round(t / 2))); + const tail = style.tail || 'bottom'; + const ts = Math.max(6, Math.min(w, h) * 0.25); + if (tail === 'bottom') fillPolygon(img, [[x + w / 2 - ts, y + h], [x + w / 2 + ts, y + h], [x + w / 2, y + h + ts * 1.4]], bg); + if (tail === 'top') fillPolygon(img, [[x + w / 2 - ts, y], [x + w / 2 + ts, y], [x + w / 2, y - ts * 1.4]], bg); + if (tail === 'left') fillPolygon(img, [[x, y + h / 2 - ts], [x, y + h / 2 + ts], [x - ts * 1.4, y + h / 2]], bg); + if (tail === 'right') fillPolygon(img, [[x + w, y + h / 2 - ts], [x + w, y + h / 2 + ts], [x + w + ts * 1.4, y + h / 2]], bg); + drawTextCentered(img, x + w / 2, y + h / 2, ann.text || '', fontPx(style), textColor); + break; + } + case 'number': { + const r = Math.max(8, Math.min(w, h) / 2); + const cx = x + w / 2, cy = y + h / 2; + fillOval(img, cx - r, cy - r, r * 2, r * 2, stroke); + drawTextCentered(img, cx, cy, String(ann.value ?? '?'), Math.max(8, r), textColor); + break; + } + case 'cursor': + drawCursorIcon(img, x, y, Math.max(12, Math.min(w, h)), [17, 24, 39, 255]); + break; + default: + break; + } + } + return img; +} + +/** Apply focused view (zoom/pan crop, then scale back to original size). */ +function applyFocusedView(img, fv) { + if (!fv || !fv.enabled || !(fv.zoom > 1)) return img; + const vw = img.width / fv.zoom, vh = img.height / fv.zoom; + const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2); + const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2); + const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh); + return resize(region, img.width, img.height); +} + +module.exports = { + createImage, cloneImage, parseColor, blendPixel, + fillRect, strokeRect, fillOval, strokeOval, drawLine, drawArrow, + fillPolygon, boxBlur, magnifyRegion, + measureText, drawText, drawTextCentered, drawCursorIcon, + crop, resize, drawImage, + renderAnnotations, applyFocusedView, +}; diff --git a/tests/unit/gifdecode.js b/tests/unit/gifdecode.js new file mode 100644 index 0000000..a4b7a3b --- /dev/null +++ b/tests/unit/gifdecode.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Minimal GIF decoder used only by tests to verify the encoder end-to-end: + * parses header/palette/frames and decompresses LZW back to RGB pixels. + */ + +function decodeGif(buf) { + if (buf.toString('latin1', 0, 6) !== 'GIF89a') throw new Error('not GIF89a'); + const width = buf.readUInt16LE(6); + const height = buf.readUInt16LE(8); + const packed = buf[10]; + const gctSize = packed & 0x80 ? 2 << (packed & 0x07) : 0; + let pos = 13; + const palette = buf.subarray(pos, pos + gctSize * 3); + pos += gctSize * 3; + + const frames = []; + let loops = null; + while (pos < buf.length) { + const block = buf[pos++]; + if (block === 0x3b) break; // trailer + if (block === 0x21) { // extension + const label = buf[pos++]; + if (label === 0xff) { + const size = buf[pos]; + const app = buf.toString('latin1', pos + 1, pos + 1 + size); + pos += 1 + size; + const sub = []; + while (buf[pos] !== 0) { sub.push(buf.subarray(pos + 1, pos + 1 + buf[pos])); pos += 1 + buf[pos]; } + pos++; + if (app.startsWith('NETSCAPE')) loops = Buffer.concat(sub).readUInt16LE(1); + } else { + while (buf[pos] !== 0) pos += 1 + buf[pos]; + pos++; + } + } else if (block === 0x2c) { // image descriptor + const fw = buf.readUInt16LE(pos + 4); + const fh = buf.readUInt16LE(pos + 6); + const lpacked = buf[pos + 8]; + pos += 9; + if (lpacked & 0x80) pos += (2 << (lpacked & 0x07)) * 3; + const minCode = buf[pos++]; + const chunks = []; + while (buf[pos] !== 0) { chunks.push(buf.subarray(pos + 1, pos + 1 + buf[pos])); pos += 1 + buf[pos]; } + pos++; + const indices = lzwDecode(Buffer.concat(chunks), minCode, fw * fh); + frames.push({ width: fw, height: fh, indices }); + } else { + throw new Error(`unknown block 0x${block.toString(16)} at ${pos - 1}`); + } + } + return { width, height, palette, frames, loops }; +} + +function lzwDecode(data, minCode, expectedPixels) { + const CLEAR = 1 << minCode; + const EOI = CLEAR + 1; + let codeSize = minCode + 1; + let dict = []; + const resetDict = () => { + dict = []; + for (let i = 0; i < CLEAR; i++) dict[i] = [i]; + dict[CLEAR] = null; dict[EOI] = null; + codeSize = minCode + 1; + }; + resetDict(); + + const out = []; + let bitPos = 0; + let prev = null; + const readCode = () => { + let code = 0; + for (let i = 0; i < codeSize; i++) { + const byte = data[bitPos >> 3]; + if (byte === undefined) return -1; + code |= ((byte >> (bitPos & 7)) & 1) << i; + bitPos++; + } + return code; + }; + + for (;;) { + const code = readCode(); + if (code < 0 || code === EOI) break; + if (code === CLEAR) { resetDict(); prev = null; continue; } + let entry; + if (dict[code]) entry = dict[code]; + else if (code === dict.length && prev) entry = [...prev, prev[0]]; + else throw new Error(`bad LZW code ${code} (dict ${dict.length})`); + out.push(...entry); + if (prev) { + dict.push([...prev, entry[0]]); + if (dict.length === (1 << codeSize) && codeSize < 12) codeSize++; + } + prev = entry; + if (out.length >= expectedPixels) break; + } + return out; +} + +module.exports = { decodeGif }; diff --git a/tests/unit/imaging.test.js b/tests/unit/imaging.test.js new file mode 100644 index 0000000..d5c27c0 --- /dev/null +++ b/tests/unit/imaging.test.js @@ -0,0 +1,230 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const { execFileSync } = require('node:child_process'); + +const { decodePng, encodePng } = require('../../core/png'); +const raster = require('../../core/raster'); +const { encodeGif } = require('../../core/gif'); +const { decodeGif } = require('./gifdecode'); +const { PdfBuilder } = require('../../core/pdf'); +const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers'); + +function px(img, x, y) { + const p = (y * img.width + x) * 4; + return [...img.data.subarray(p, p + 4)]; +} + +function hasTool(cmd) { + try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; } +} + +test('PNG decoder reads an externally-encoded PNG correctly', () => { + const img = decodePng(TINY_PNG); // produced by a real-world encoder + assert.equal(img.width, 1); + assert.equal(img.height, 1); + // ImageMagick reads this file as srgba(255,0,0,0.498); we must agree. + assert.deepEqual([...img.data], [255, 0, 0, 127]); +}); + +test('PNG encode -> decode round-trips pixels exactly', () => { + const src = raster.createImage(13, 7, [10, 20, 30, 255]); + raster.fillRect(src, 3, 2, 5, 3, [200, 100, 50, 255]); + const decoded = decodePng(encodePng(src)); + assert.equal(decoded.width, 13); + assert.equal(decoded.height, 7); + assert.deepEqual(decoded.data, src.data); +}); + +test('our PNG output is valid for external tools (ImageMagick)', { skip: !hasTool('identify') }, (t) => { + const dir = makeTmpDir('pngcheck'); + t.after(() => rmrf(dir)); + const img = raster.createImage(40, 20, [255, 0, 0, 255]); + const file = path.join(dir, 'check.png'); + fs.writeFileSync(file, encodePng(img)); + const out = execFileSync('identify', ['-format', '%w %h %m', file]).toString(); + assert.equal(out.trim(), '40 20 PNG'); +}); + +test('annotations are burned into pixels: rect, highlight, blur, number', () => { + // 200x100 white image; verify actual pixel changes per annotation. + const base = raster.createImage(200, 100, [255, 255, 255, 255]); + // Distinct dark region so blur produces a measurable smear. + raster.fillRect(base, 100, 0, 4, 100, [0, 0, 0, 255]); + + const out = raster.renderAnnotations(base, [ + { id: 'a1', type: 'rect', x: 0.05, y: 0.1, w: 0.2, h: 0.4, style: { stroke: '#FF0000', strokeWidth: 10, fill: 'transparent' } }, + { id: 'a2', type: 'highlight', x: 0.0, y: 0.8, w: 0.1, h: 0.2, style: {} }, + { id: 'a3', type: 'blur', x: 0.45, y: 0.0, w: 0.15, h: 1.0, radius: 6, style: {} }, + { id: 'a4', type: 'number', value: 4, x: 0.8, y: 0.5, w: 0.15, h: 0.3, style: { stroke: '#0000FF' } }, + ]); + + // Original image untouched (renderAnnotations works on a copy). + assert.deepEqual(px(base, 10, 10), [255, 255, 255, 255]); + + // Rect stroke: border pixel red, interior still white. + assert.deepEqual(px(out, 10, 10), [255, 0, 0, 255]); + assert.deepEqual(px(out, 25, 30), [255, 255, 255, 255]); + + // Highlight: white blended toward yellow (R stays high, B drops). + const hl = px(out, 5, 90); + assert.ok(hl[2] < 200 && hl[0] > 240, `highlight should yellow the pixel, got ${hl}`); + + // Blur: the hard black/white edge inside the blur region is now grey. + const edge = px(out, 99, 50); + assert.ok(edge[0] > 30 && edge[0] < 225, `blur should smear edge, got ${edge}`); + + // Number badge: just inside the left edge of the disc is the badge color + // (blue); dead center would hit the white glyph. + const badge = px(out, Math.round(0.815 * 200), Math.round(0.65 * 100)); + assert.ok(badge[2] > 200 && badge[0] < 80, `badge center should be blue, got ${badge}`); +}); + +test('text rendering puts glyph pixels where text is drawn', () => { + const img = raster.createImage(120, 40, [255, 255, 255, 255]); + raster.drawText(img, 4, 4, 'OK', 16, [0, 0, 0, 255]); + let dark = 0; + for (let i = 0; i < img.data.length; i += 4) if (img.data[i] < 100) dark++; + assert.ok(dark > 30, `expected glyph pixels, found ${dark}`); + // Region far from the text stays untouched. + assert.deepEqual(px(img, 110, 35), [255, 255, 255, 255]); +}); + +test('crop and focused view produce correct geometry without mutating input', () => { + const img = raster.createImage(100, 50, [10, 10, 10, 255]); + raster.fillRect(img, 60, 20, 10, 10, [250, 250, 250, 255]); + + const cropped = raster.crop(img, 50, 10, 40, 30); + assert.equal(cropped.width, 40); + assert.equal(cropped.height, 30); + assert.deepEqual(px(cropped, 15, 15), [250, 250, 250, 255]); // 60+5,20+5 relative + + // focused view: zoom 2 around the bright square keeps output size + const fv = raster.applyFocusedView(img, { enabled: true, zoom: 2, panX: 0.65, panY: 0.5 }); + assert.equal(fv.width, 100); + assert.equal(fv.height, 50); + // the bright square now covers a larger area: count bright pixels + const bright = (im) => { + let n = 0; + for (let i = 0; i < im.data.length; i += 4) if (im.data[i] > 200) n++; + return n; + }; + assert.ok(bright(fv) > bright(img) * 2.5, 'zoomed view should enlarge the bright region'); + assert.equal(img.width, 100, 'input image unchanged'); +}); + +test('GIF encoder produces decodable frames with correct pixels and looping', () => { + const f1 = raster.createImage(31, 17, [255, 0, 0, 255]); + const f2 = raster.createImage(31, 17, [0, 0, 255, 255]); + raster.fillRect(f2, 0, 0, 10, 17, [0, 255, 0, 255]); + + const gif = encodeGif([f1, f2], { delayCs: 50, loop: 0 }); + const decoded = decodeGif(gif); + assert.equal(decoded.width, 31); + assert.equal(decoded.height, 17); + assert.equal(decoded.frames.length, 2); + assert.equal(decoded.loops, 0); + + const colorAt = (frame, x, y) => { + const idx = frame.indices[y * frame.width + x]; + return [decoded.palette[idx * 3], decoded.palette[idx * 3 + 1], decoded.palette[idx * 3 + 2]]; + }; + // Quantization tolerance: channels within 26 of target. + const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) <= 26); + assert.ok(near(colorAt(decoded.frames[0], 15, 8), [255, 0, 0]), 'frame1 red'); + assert.ok(near(colorAt(decoded.frames[1], 25, 8), [0, 0, 255]), 'frame2 blue'); + assert.ok(near(colorAt(decoded.frames[1], 5, 8), [0, 255, 0]), 'frame2 left green'); + assert.equal(decoded.frames[0].indices.length, 31 * 17, 'every pixel decoded'); +}); + +test('GIF with a complex frame (forces LZW code growth) still round-trips', () => { + // Noise image exercises dictionary growth + reset paths in the encoder. + const img = raster.createImage(120, 80); + let seed = 42; + const rand = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + for (let i = 0; i < img.data.length; i += 4) { + img.data[i] = Math.floor(rand() * 256); + img.data[i + 1] = Math.floor(rand() * 256); + img.data[i + 2] = Math.floor(rand() * 256); + img.data[i + 3] = 255; + } + const decoded = decodeGif(encodeGif([img], { delayCs: 10 })); + assert.equal(decoded.frames[0].indices.length, 120 * 80); +}); + +test('GIF output is valid for external tools (ImageMagick)', { skip: !hasTool('identify') }, (t) => { + const dir = makeTmpDir('gifcheck'); + t.after(() => rmrf(dir)); + const frames = [raster.createImage(20, 10, [255, 0, 0, 255]), raster.createImage(20, 10, [0, 255, 0, 255])]; + const file = path.join(dir, 'anim.gif'); + fs.writeFileSync(file, encodeGif(frames, { delayCs: 100 })); + const out = execFileSync('identify', ['-format', '%w %h %m\n', file]).toString().trim().split('\n'); + assert.equal(out.length, 2, 'two frames detected'); + assert.equal(out[0], '20 10 GIF'); +}); + +test('PDF builder emits a structurally valid document with working xref', () => { + const pdf = new PdfBuilder(); + pdf.addPage(); + pdf.bookmark('Intro', 0); + pdf.text('Hello StepForge', 50, 50, { size: 16, font: 'F2' }); + pdf.rect(50, 80, 100, 40, { fill: [220, 220, 250], stroke: [0, 0, 0] }); + const img = raster.createImage(8, 8, [0, 128, 255, 255]); + pdf.image(img, 50, 140, 120, 60); + pdf.addPage(); + pdf.bookmark('Second', 1); + pdf.text('Page two', 50, 50, {}); + const buf = pdf.build(); + + assert.equal(buf.subarray(0, 8).toString('latin1'), '%PDF-1.4'); + assert.ok(buf.subarray(buf.length - 7).toString('latin1').includes('%%EOF')); + + // xref offsets must point at the right objects. + const text = buf.toString('latin1'); + const xrefAt = Number(/startxref\n(\d+)\n%%EOF/.exec(text)[1]); + assert.equal(text.slice(xrefAt, xrefAt + 4), 'xref'); + const lines = text.slice(xrefAt).split('\n'); + const count = Number(lines[1].split(' ')[1]); + for (let i = 1; i < count; i++) { + const offset = Number(lines[2 + i].split(' ')[0]); + assert.match(text.slice(offset, offset + 20), new RegExp(`^${i} 0 obj`), `object ${i} offset valid`); + } + + // Page tree declares 2 pages; both content streams inflate. + assert.match(text, /\/Type \/Pages \/Kids \[[^\]]+\] \/Count 2/); + const streams = text.match(/(? { + const dir = makeTmpDir('pdfcheck'); + t.after(() => rmrf(dir)); + const pdf = new PdfBuilder(); + pdf.addPage(); + pdf.text('Validation page', 60, 60, { size: 14 }); + const img = raster.createImage(16, 16, [255, 0, 0, 255]); + pdf.image(img, 60, 100, 200, 100); + pdf.addPage(); + pdf.text('Second page', 60, 60, {}); + const file = path.join(dir, 'check.pdf'); + fs.writeFileSync(file, pdf.build()); + // gs exits non-zero / prints errors on malformed PDFs; -o /dev/null renders all pages. + const out = execFileSync('gs', ['-dBATCH', '-dNOPAUSE', '-sDEVICE=nullpage', file], { stdio: 'pipe' }).toString(); + assert.match(out, /Processing pages 1 through 2/); + assert.doesNotMatch(out, /error/i); +}); + +test('wrapText breaks long paragraphs to the given width', () => { + const pdf = new PdfBuilder(); + const lines = pdf.wrapText('alpha beta gamma delta epsilon zeta eta theta', 12, 100); + assert.ok(lines.length >= 3, `expected several lines, got ${lines.length}`); + for (const line of lines) { + assert.ok(pdf.textWidth(line, 12) <= 100 || !line.includes(' '), `line too wide: "${line}"`); + } + // Round-trip: joining gives back all words in order. + assert.equal(lines.join(' ').replace(/\s+/g, ' '), 'alpha beta gamma delta epsilon zeta eta theta'); +}); diff --git a/vendor/font8x8/font8x8_basic.h b/vendor/font8x8/font8x8_basic.h new file mode 100644 index 0000000..125cf16 --- /dev/null +++ b/vendor/font8x8/font8x8_basic.h @@ -0,0 +1,152 @@ +/** + * 8x8 monochrome bitmap fonts for rendering + * Author: Daniel Hepper + * + * License: Public Domain + * + * Based on: + * // Summary: font8x8.h + * // 8x8 monochrome bitmap fonts for rendering + * // + * // Author: + * // Marcel Sondaar + * // International Business Machines (public domain VGA fonts) + * // + * // License: + * // Public Domain + * + * Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm + **/ + +// Constant: font8x8_basic +// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin) +char font8x8_basic[128][8] = { + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019 + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space) + { 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!) + { 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (") + { 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#) + { 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($) + { 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%) + { 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&) + { 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (') + { 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (() + { 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ()) + { 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*) + { 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,) + { 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.) + { 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/) + { 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0) + { 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1) + { 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2) + { 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3) + { 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4) + { 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5) + { 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6) + { 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7) + { 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8) + { 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9) + { 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:) + { 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;) + { 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<) + { 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=) + { 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>) + { 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?) + { 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@) + { 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A) + { 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B) + { 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C) + { 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D) + { 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E) + { 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F) + { 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G) + { 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H) + { 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I) + { 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J) + { 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K) + { 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L) + { 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M) + { 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N) + { 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O) + { 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P) + { 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q) + { 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R) + { 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S) + { 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T) + { 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U) + { 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V) + { 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W) + { 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X) + { 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y) + { 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z) + { 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([) + { 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\) + { 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (]) + { 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_) + { 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`) + { 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a) + { 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b) + { 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c) + { 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d) + { 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e) + { 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f) + { 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g) + { 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h) + { 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i) + { 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j) + { 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k) + { 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l) + { 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m) + { 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n) + { 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o) + { 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p) + { 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q) + { 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r) + { 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s) + { 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t) + { 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u) + { 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v) + { 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w) + { 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x) + { 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y) + { 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z) + { 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({) + { 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|) + { 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (}) + { 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~) + { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F +};