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 <noreply@anthropic.com>
This commit is contained in:
+141
@@ -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 };
|
||||||
+154
@@ -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<<codeSize)+1, not (1<<codeSize).
|
||||||
|
if (next === (1 << codeSize) + 1 && codeSize < 12) codeSize++;
|
||||||
|
if (next >= 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 };
|
||||||
+214
@@ -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 };
|
||||||
+160
@@ -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 };
|
||||||
+412
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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(/(?<!end)stream\n/g) || [];
|
||||||
|
assert.equal(streams.length, 3, '2 page content streams + 1 image stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF renders correctly under Ghostscript', { skip: !hasTool('gs') }, (t) => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
Vendored
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 8x8 monochrome bitmap fonts for rendering
|
||||||
|
* Author: Daniel Hepper <daniel@hepper.net>
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user