Files
SnowLuma/packages/protocol/tests/highway/utils.test.ts
DrSmoothl 9d1e39f25f fix(highway): VP8X WebP canvas dims are little-endian, not big-endian (#112)
detectImageFormat read the VP8X (extended WebP) canvas width/height with a
big-endian readBE24, but the WebP spec stores them as 24-bit LITTLE-endian
(Minus-One). A 1920×1080 image was reported as 8324865×3605505, so the
fileInfo sent to highway was garbage and QQ cropped the thumbnail. VP8
(readLE16) and VP8L (readLE32) were already correct.

- Replace readBE24 with readLE24 in the VP8X branch; readBE24 had no other
  caller, so it's deleted (no dead code left).
- Add the previously-absent detectImageFormat regression suite (12 cases):
  PNG/GIF/BMP/VP8/VP8L/VP8X/JPEG + JPEG DHT-skip + truncated/unknown
  fallbacks. The VP8X cases pin little-endian byte order, including the
  exact bytes from the issue and an asymmetric 256×1 case that a BE misread
  would blow up to 16711681.

Verified TDD red→green: the 3 VP8X cases failed before the one-helper swap,
all 12 pass after.
2026-06-20 20:19:10 +08:00

115 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { detectImageFormat } from '@snowluma/protocol/highway/utils';
// Regression coverage for detectImageFormat (was previously untested). The
// header-parsing is all hand-rolled byte math, so each format's offset AND
// endianness is pinned here. The VP8X cases exist because that branch read the
// canvas width/height big-endian when the WebP spec stores them 24-bit
// little-endian (issue #112: a 1920×1080 image came back as 8324865×3605505,
// cropping the QQ thumbnail). VP8 / VP8L were already little-endian.
// ── little-endian byte writers (match how the formats store dimensions) ──
function le16(v: number): number[] {
return [v & 0xff, (v >> 8) & 0xff];
}
function le24(v: number): number[] {
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff];
}
function le32(v: number): number[] {
return [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff];
}
function be16(v: number): number[] {
return [(v >> 8) & 0xff, v & 0xff];
}
function be32(v: number): number[] {
return [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
}
function ascii(s: string): number[] {
return [...s].map((c) => c.charCodeAt(0));
}
/** Build a buffer from segments placed at explicit offsets; gaps are 0. */
function buf(len: number, ...segs: Array<[number, number[]]>): Uint8Array {
const b = new Uint8Array(len);
for (const [off, bytes] of segs) b.set(bytes, off);
return b;
}
describe('detectImageFormat', () => {
it('PNG — IHDR width/height (big-endian @16/@20) → 1001', () => {
const b = buf(24, [0, [0x89, 0x50, 0x4e, 0x47]], [16, be32(800)], [20, be32(600)]);
expect(detectImageFormat(b)).toEqual({ format: 1001, width: 800, height: 600 });
});
it('GIF — logical screen width/height (little-endian @6/@8) → 2000', () => {
const b = buf(10, [0, ascii('GIF89a')], [6, le16(320)], [8, le16(240)]);
expect(detectImageFormat(b)).toEqual({ format: 2000, width: 320, height: 240 });
// GIF87a header variant is also accepted.
const b87 = buf(10, [0, ascii('GIF87a')], [6, le16(48)], [8, le16(16)]);
expect(detectImageFormat(b87)).toEqual({ format: 2000, width: 48, height: 16 });
});
it('BMP — DIB width/height (little-endian @18/@22) → 1005', () => {
const b = buf(26, [0, ascii('BM')], [18, le32(1024)], [22, le32(768)]);
expect(detectImageFormat(b)).toEqual({ format: 1005, width: 1024, height: 768 });
});
it('WebP VP8 (lossy) — little-endian @26/@28 → 1002', () => {
const b = buf(32, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8 ')], [26, le16(640)], [28, le16(480)]);
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 640, height: 480 });
});
it('WebP VP8L (lossless) — 14-bit fields packed in LE32 @21 → 1002', () => {
// width-1 in bits[0..13], height-1 in bits[14..27].
const bits = (100 - 1) | ((200 - 1) << 14);
const b = buf(32, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8L')], [21, le32(bits >>> 0)]);
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 100, height: 200 });
});
it('WebP VP8X (extended) — canvas width/height are 24-bit LITTLE-endian +1 @24/@27 → 1002 (issue #112)', () => {
const b = buf(34, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8X')], [24, le24(1920 - 1)], [27, le24(1080 - 1)]);
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 1920, height: 1080 });
});
it('WebP VP8X — asymmetric dims pin byte order (BE misread would give huge/swapped values)', () => {
// 256×1: width-1=255 → LE bytes [ff,00,00]; a BE read of those = 16711680.
// height-1=0 → [00,00,00]. Distinct w≠h also catches an offset swap.
const b = buf(34, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8X')], [24, le24(256 - 1)], [27, le24(1 - 1)]);
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 256, height: 1 });
});
it('WebP VP8X — exact bytes from issue #112 decode to 1920×1080, not 8324865×3605505', () => {
// The reported broken values came from these literal canvas bytes.
const b = buf(34, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8X')],
[24, [0x7f, 0x07, 0x00]], [27, [0x37, 0x04, 0x00]]);
expect(detectImageFormat(b)).toEqual({ format: 1002, width: 1920, height: 1080 });
});
it('JPEG — dimensions from the SOF0 segment (big-endian, height before width) → 1000', () => {
// FFD8 SOI, then SOF0 (FFC0) segment: len(BE16)=17, precision, height@+5, width@+7.
// Buffer must hold the whole declared 17-byte segment (offset 2..20) or the
// parser's bounds-check bails before reading — hence length 24, not 20.
const b = buf(24,
[0, [0xff, 0xd8]],
[2, [0xff, 0xc0]], [4, be16(17)], [6, [8]], [7, be16(720)], [9, be16(1280)]);
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 1280, height: 720 });
});
it('JPEG — skips a DHT (0xFFC4) segment before SOF0 (must not misread Huffman bytes)', () => {
// SOI, DHT(FFC4) len=4 (2 payload bytes), then SOF0 with real 640×480.
const b = buf(28,
[0, [0xff, 0xd8]],
[2, [0xff, 0xc4]], [4, be16(4)], [6, [0xde, 0xad]],
[8, [0xff, 0xc0]], [10, be16(17)], [12, [8]], [13, be16(480)], [15, be16(640)]);
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 640, height: 480 });
});
it('unknown bytes fall back to {1000, 0, 0} without throwing', () => {
expect(detectImageFormat(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual({ format: 1000, width: 0, height: 0 });
});
it('a truncated WebP header (<30 bytes) does not crash and falls through', () => {
const b = buf(20, [0, ascii('RIFF')], [8, ascii('WEBP')], [12, ascii('VP8X')]);
expect(detectImageFormat(b)).toEqual({ format: 1000, width: 0, height: 0 });
});
});