mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 20:49:40 +02:00
Add comprehensive test suite (174 tests) covering encoding, schema, image, database CRUD, and PNG round-trip. Fix critical bugs: - PNG compression: replace non-functional zlibCompress with pako.deflate - PNG import: add CRC validation, support all filter types (Sub/Up/Avg/Paeth) - Input validation: validate records against schema before insert - Index overflow: dynamic dataStartRing prevents index/data ring overlap - Image expansion: expand before writes instead of after to prevent OOB - update() read bug: search index from end to find latest entry, not deleted one - String encoding: enforce 511-byte max length - Index ring count: use 6 bits (2 pixels) instead of 3 bits for >7 ring support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
7.5 KiB
TypeScript
298 lines
7.5 KiB
TypeScript
/**
|
|
* Encoding/Decoding between bits, colors, and pixel data
|
|
*/
|
|
|
|
import type { ColorIndex, RGB, BitStream, SerializedRecord } from './types.js';
|
|
import { COLORS, BITS_PER_PIXEL } from './constants.js';
|
|
import pako from 'pako';
|
|
|
|
// =============================================================================
|
|
// COLOR ↔ BITS CONVERSION
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Convert 3 bits to a color index
|
|
*/
|
|
export function bitsToColor(b0: number, b1: number, b2: number): ColorIndex {
|
|
return ((b0 << 2) | (b1 << 1) | b2) as ColorIndex;
|
|
}
|
|
|
|
/**
|
|
* Convert a color index to 3 bits
|
|
*/
|
|
export function colorToBits(color: ColorIndex): [number, number, number] {
|
|
return [(color >> 2) & 1, (color >> 1) & 1, color & 1];
|
|
}
|
|
|
|
/**
|
|
* Convert a color index to RGB values
|
|
*/
|
|
export function colorToRGB(color: ColorIndex): RGB {
|
|
return COLORS[color].rgb;
|
|
}
|
|
|
|
/**
|
|
* Find the closest color index for given RGB values
|
|
* Uses simple threshold: value >= 128 = 1, else 0
|
|
*/
|
|
export function rgbToColor(r: number, g: number, b: number): ColorIndex {
|
|
const bit0 = r >= 128 ? 1 : 0;
|
|
const bit1 = g >= 128 ? 1 : 0;
|
|
const bit2 = b >= 128 ? 1 : 0;
|
|
return bitsToColor(bit0, bit1, bit2);
|
|
}
|
|
|
|
// =============================================================================
|
|
// BIT STREAM
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a new bit stream for writing
|
|
*/
|
|
export function createBitStream(): BitStream {
|
|
return { bits: [], position: 0 };
|
|
}
|
|
|
|
/**
|
|
* Write bits to a stream
|
|
*/
|
|
export function writeBits(stream: BitStream, value: number, count: number): void {
|
|
for (let i = count - 1; i >= 0; i--) {
|
|
stream.bits.push((value >> i) & 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read bits from a stream
|
|
*/
|
|
export function readBits(stream: BitStream, count: number): number {
|
|
let value = 0;
|
|
for (let i = 0; i < count; i++) {
|
|
if (stream.position >= stream.bits.length) {
|
|
throw new Error('Unexpected end of bit stream');
|
|
}
|
|
value = (value << 1) | stream.bits[stream.position++];
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Peek at bits without consuming them
|
|
*/
|
|
export function peekBits(stream: BitStream, count: number): number {
|
|
const originalPosition = stream.position;
|
|
const value = readBits(stream, count);
|
|
stream.position = originalPosition;
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Check if stream has more bits
|
|
*/
|
|
export function hasMoreBits(stream: BitStream, count = 1): boolean {
|
|
return stream.position + count <= stream.bits.length;
|
|
}
|
|
|
|
// =============================================================================
|
|
// BITS ↔ PIXELS CONVERSION
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Convert a bit array to color indices (pixels)
|
|
* Pads with zeros if necessary to align to 3-bit boundaries
|
|
*/
|
|
export function bitsToPixels(bits: number[]): ColorIndex[] {
|
|
const pixels: ColorIndex[] = [];
|
|
const paddedBits = [...bits];
|
|
|
|
// Pad to multiple of 3
|
|
while (paddedBits.length % BITS_PER_PIXEL !== 0) {
|
|
paddedBits.push(0);
|
|
}
|
|
|
|
for (let i = 0; i < paddedBits.length; i += BITS_PER_PIXEL) {
|
|
const color = bitsToColor(paddedBits[i], paddedBits[i + 1], paddedBits[i + 2]);
|
|
pixels.push(color);
|
|
}
|
|
|
|
return pixels;
|
|
}
|
|
|
|
/**
|
|
* Convert color indices (pixels) to bits
|
|
*/
|
|
export function pixelsToBits(pixels: ColorIndex[]): number[] {
|
|
const bits: number[] = [];
|
|
for (const pixel of pixels) {
|
|
const [b0, b1, b2] = colorToBits(pixel);
|
|
bits.push(b0, b1, b2);
|
|
}
|
|
return bits;
|
|
}
|
|
|
|
/**
|
|
* Convert a bit stream to a serialized record
|
|
*/
|
|
export function streamToRecord(stream: BitStream): SerializedRecord {
|
|
return {
|
|
pixels: bitsToPixels(stream.bits),
|
|
bitLength: stream.bits.length,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// VALUE ENCODING
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Encode an integer with variable bit length
|
|
*/
|
|
export function encodeInt(stream: BitStream, value: number, bitLength: number): void {
|
|
if (value < 0) {
|
|
throw new Error('Negative integers not supported');
|
|
}
|
|
if (value >= 2 ** bitLength) {
|
|
throw new Error(`Value ${value} too large for ${bitLength} bits`);
|
|
}
|
|
writeBits(stream, value, bitLength);
|
|
}
|
|
|
|
/**
|
|
* Decode an integer with variable bit length
|
|
*/
|
|
export function decodeInt(stream: BitStream, bitLength: number): number {
|
|
return readBits(stream, bitLength);
|
|
}
|
|
|
|
/**
|
|
* Encode a boolean (1 bit)
|
|
*/
|
|
export function encodeBool(stream: BitStream, value: boolean): void {
|
|
writeBits(stream, value ? 1 : 0, 1);
|
|
}
|
|
|
|
/**
|
|
* Decode a boolean
|
|
*/
|
|
export function decodeBool(stream: BitStream): boolean {
|
|
return readBits(stream, 1) === 1;
|
|
}
|
|
|
|
/**
|
|
* Encode a string as UTF-8 bytes
|
|
* Format: [length:9bit][...bytes]
|
|
*/
|
|
export function encodeString(stream: BitStream, value: string, compress = false): void {
|
|
const bytes = new TextEncoder().encode(value);
|
|
|
|
// 9-bit length field can hold max 511 bytes
|
|
if (bytes.length > 511) {
|
|
throw new Error(`String too long: ${bytes.length} bytes (max 511)`);
|
|
}
|
|
|
|
if (compress && bytes.length > 20) {
|
|
const compressed = pako.deflate(bytes);
|
|
if (compressed.length < bytes.length) {
|
|
// Use compressed version
|
|
writeBits(stream, 1, 1); // compression flag
|
|
writeBits(stream, compressed.length, 9);
|
|
for (const byte of compressed) {
|
|
writeBits(stream, byte, 8);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Uncompressed
|
|
writeBits(stream, 0, 1); // compression flag
|
|
writeBits(stream, bytes.length, 9);
|
|
for (const byte of bytes) {
|
|
writeBits(stream, byte, 8);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode a string
|
|
*/
|
|
export function decodeString(stream: BitStream): string {
|
|
const isCompressed = readBits(stream, 1) === 1;
|
|
const length = readBits(stream, 9);
|
|
const bytes = new Uint8Array(length);
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
bytes[i] = readBits(stream, 8);
|
|
}
|
|
|
|
if (isCompressed) {
|
|
const decompressed = pako.inflate(bytes);
|
|
return new TextDecoder().decode(decompressed);
|
|
}
|
|
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
|
|
/**
|
|
* Encode a timestamp (days since epoch, 24 bits)
|
|
*/
|
|
export function encodeTimestamp(stream: BitStream, date: Date | null): void {
|
|
if (date === null) {
|
|
writeBits(stream, 0, 24); // 0 = null/no date
|
|
return;
|
|
}
|
|
const daysSinceEpoch = Math.floor(date.getTime() / (1000 * 60 * 60 * 24));
|
|
writeBits(stream, daysSinceEpoch, 24);
|
|
}
|
|
|
|
/**
|
|
* Decode a timestamp
|
|
*/
|
|
export function decodeTimestamp(stream: BitStream): Date | null {
|
|
const daysSinceEpoch = readBits(stream, 24);
|
|
if (daysSinceEpoch === 0) return null;
|
|
return new Date(daysSinceEpoch * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
/**
|
|
* Encode an array of integers
|
|
* Format: [count:8bit][...values]
|
|
*/
|
|
export function encodeIntArray(stream: BitStream, values: number[], itemBitLength: number): void {
|
|
writeBits(stream, values.length, 8);
|
|
for (const value of values) {
|
|
encodeInt(stream, value, itemBitLength);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode an array of integers
|
|
*/
|
|
export function decodeIntArray(stream: BitStream, itemBitLength: number): number[] {
|
|
const count = readBits(stream, 8);
|
|
const values: number[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
values.push(decodeInt(stream, itemBitLength));
|
|
}
|
|
return values;
|
|
}
|
|
|
|
/**
|
|
* Encode an array of strings
|
|
*/
|
|
export function encodeStringArray(stream: BitStream, values: string[], compress = false): void {
|
|
writeBits(stream, values.length, 8);
|
|
for (const value of values) {
|
|
encodeString(stream, value, compress);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode an array of strings
|
|
*/
|
|
export function decodeStringArray(stream: BitStream): string[] {
|
|
const count = readBits(stream, 8);
|
|
const values: string[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
values.push(decodeString(stream));
|
|
}
|
|
return values;
|
|
}
|