Files
TREK/server/tests/unit/nest/llm-parse/image-input.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

259 lines
10 KiB
TypeScript

import {
capImage,
IMAGE_MAX_BYTES,
IMAGE_MAX_EDGE,
imageMimeType,
ImageTooLargeError,
readImageHeader,
renderPdfPages,
} from '../../../../src/nest/llm-parse/image-input';
import { Jimp } from 'jimp';
import { describe, it, expect } from 'vitest';
async function png(w: number, h: number): Promise<Buffer> {
const image = new Jimp({ width: w, height: h, color: 0xffffffff });
return Buffer.from(await image.getBuffer('image/png'));
}
/** A PNG that does not compress: every pixel from a fixed pseudo-random sequence. */
async function noisePng(w: number, h: number): Promise<Buffer> {
const image = new Jimp({ width: w, height: h, color: 0xffffffff });
let seed = 1;
for (let i = 0; i < image.bitmap.data.length; i++) {
seed = (seed * 1103515245 + 12345) % 2147483648;
image.bitmap.data[i] = i % 4 === 3 ? 255 : (seed >>> 16) & 0xff;
}
return Buffer.from(await image.getBuffer('image/png'));
}
/** The signature and IHDR of a PNG, and nothing after them. */
function pngHeader(w: number, h: number): Buffer {
const out = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(out, 0);
out.writeUInt32BE(13, 8);
out.write('IHDR', 12, 'latin1');
out.writeUInt32BE(w, 16);
out.writeUInt32BE(h, 20);
out.writeUInt8(8, 24);
out.writeUInt8(6, 25);
return out;
}
/** SOI, an APP0 segment to step over, and a baseline frame header. */
function jpegHeader(w: number, h: number): Buffer {
const app0 = Buffer.alloc(18);
app0.writeUInt16BE(0xffe0, 0);
app0.writeUInt16BE(16, 2);
app0.write('JFIF', 4, 'latin1');
const sof = Buffer.alloc(19);
sof.writeUInt16BE(0xffc0, 0);
sof.writeUInt16BE(17, 2);
sof.writeUInt8(8, 4);
sof.writeUInt16BE(h, 5);
sof.writeUInt16BE(w, 7);
sof.writeUInt8(3, 9);
return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof]);
}
/** A RIFF/WEBP container with the size field of one of the three chunk kinds. */
function webpHeader(chunk: 'VP8 ' | 'VP8L' | 'VP8X', w: number, h: number): Buffer {
const out = Buffer.alloc(30);
out.write('RIFF', 0, 'latin1');
out.writeUInt32LE(22, 4);
out.write('WEBP', 8, 'latin1');
out.write(chunk, 12, 'latin1');
out.writeUInt32LE(10, 16);
if (chunk === 'VP8 ') {
Buffer.from([0x9d, 0x01, 0x2a]).copy(out, 23);
out.writeUInt16LE(w, 26);
out.writeUInt16LE(h, 28);
} else if (chunk === 'VP8L') {
out.writeUInt8(0x2f, 20);
out.writeUInt32LE(((w - 1) | ((h - 1) << 14)) >>> 0, 21);
} else {
out.writeUIntLE(w - 1, 24, 3);
out.writeUIntLE(h - 1, 27, 3);
}
return out;
}
describe('imageMimeType', () => {
it('knows the photo formats a provider reads, whatever the case', () => {
expect(imageMimeType('a.jpg')).toBe('image/jpeg');
expect(imageMimeType('A.JPEG')).toBe('image/jpeg');
expect(imageMimeType('a.png')).toBe('image/png');
expect(imageMimeType('a.webp')).toBe('image/webp');
});
it('is null for everything else, HEIC included', () => {
for (const name of ['a.heic', 'a.pdf', 'a.txt', 'jpg', 'a.jpg.pdf']) expect(imageMimeType(name)).toBeNull();
});
});
describe('capImage', () => {
it('shrinks the long edge of a large photo to the cap, as a JPEG', async () => {
const out = await capImage(await png(IMAGE_MAX_EDGE * 2, 1000), 'image/png');
expect(out.mimeType).toBe('image/jpeg');
const back = await Jimp.read(out.data);
expect(back.width).toBe(IMAGE_MAX_EDGE);
expect(back.height).toBe(500);
});
it('caps a portrait photo by its height', async () => {
const back = await Jimp.read((await capImage(await png(800, IMAGE_MAX_EDGE * 2), 'image/png')).data);
expect(back.height).toBe(IMAGE_MAX_EDGE);
expect(back.width).toBe(400);
});
it('leaves a photo that is already small enough untouched', async () => {
const data = await png(400, 300);
expect(await capImage(data, 'image/png')).toEqual({ data, mimeType: 'image/png' });
});
it('passes bytes it cannot decode through unchanged', async () => {
const data = Buffer.from('RIFF....WEBP');
expect(await capImage(data, 'image/webp')).toEqual({ data, mimeType: 'image/webp' });
});
it('refuses a PNG whose header claims more than the pixel cap, before decoding a byte of it', async () => {
// 33 bytes that would inflate to 1.6 GB: the header alone says so.
await expect(capImage(pngHeader(20000, 20000), 'image/png')).rejects.toThrow(ImageTooLargeError);
await expect(capImage(pngHeader(20000, 20000), 'image/png')).rejects.toThrow(
'20000 x 20000 pixels, more than the 40 megapixels',
);
});
it('refuses a JPEG and a WebP over the pixel cap the same way', async () => {
await expect(capImage(jpegHeader(10000, 10000), 'image/jpeg')).rejects.toThrow(ImageTooLargeError);
await expect(capImage(webpHeader('VP8X', 8000, 6000), 'image/webp')).rejects.toThrow(ImageTooLargeError);
});
it('types a photo by its bytes, not its name', async () => {
const data = await png(400, 300);
expect(await capImage(data, 'image/jpeg')).toEqual({ data, mimeType: 'image/png' });
});
it('re-encodes a photo that fits the edge but is too heavy to send', async () => {
const heavy = await noisePng(1300, 1300);
expect(heavy.length).toBeGreaterThan(IMAGE_MAX_BYTES);
const out = await capImage(heavy, 'image/png');
expect(out.mimeType).toBe('image/jpeg');
expect(out.data.length).toBeLessThan(heavy.length);
const back = await Jimp.read(out.data);
expect([back.width, back.height]).toEqual([1300, 1300]);
}, 30_000);
it('sends a WebP as it came, since nothing here decodes one, and refuses it over the byte cap', async () => {
const small = webpHeader('VP8 ', 640, 480);
expect(await capImage(small, 'image/webp')).toEqual({ data: small, mimeType: 'image/webp' });
const heavy = Buffer.concat([small, Buffer.alloc(IMAGE_MAX_BYTES)]);
await expect(capImage(heavy, 'image/webp')).rejects.toThrow(/cannot be made smaller/);
});
});
describe('readImageHeader', () => {
it('reads the size of a PNG and a JPEG from their first bytes', async () => {
expect(readImageHeader(await png(400, 300))).toEqual({ mimeType: 'image/png', width: 400, height: 300 });
const jpg = Buffer.from(await new Jimp({ width: 320, height: 200, color: 0xffffffff }).getBuffer('image/jpeg'));
expect(readImageHeader(jpg)).toEqual({ mimeType: 'image/jpeg', width: 320, height: 200 });
expect(readImageHeader(jpegHeader(640, 480))).toEqual({ mimeType: 'image/jpeg', width: 640, height: 480 });
});
it('reads all three WebP flavours', () => {
expect(readImageHeader(webpHeader('VP8 ', 640, 480))).toEqual({ mimeType: 'image/webp', width: 640, height: 480 });
expect(readImageHeader(webpHeader('VP8L', 1024, 768))).toEqual({
mimeType: 'image/webp',
width: 1024,
height: 768,
});
expect(readImageHeader(webpHeader('VP8X', 4000, 3000))).toEqual({
mimeType: 'image/webp',
width: 4000,
height: 3000,
});
});
it('is null for bytes that are none of the three, or say no size', () => {
const noFrame = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
for (const data of [Buffer.from('not an image'), Buffer.from('RIFF....WEBP'), noFrame, pngHeader(0, 10)]) {
expect(readImageHeader(data)).toBeNull();
}
});
});
/**
* A PDF with no text layer: each page is one JPEG, as a scanner writes it. The
* page box, and the size the image claims to have, can differ from the JPEG's.
*/
async function scannedPdf(pages: number, box = { w: 300, h: 400 }, claimed = { w: 300, h: 400 }): Promise<Buffer> {
const jpg = Buffer.from(await new Jimp({ width: 300, height: 400, color: 0xddddddff }).getBuffer('image/jpeg'));
const objects: Buffer[] = [];
const kids = Array.from({ length: pages }, (_, i) => `${3 + i * 3} 0 R`).join(' ');
objects.push(Buffer.from('<< /Type /Catalog /Pages 2 0 R >>'));
objects.push(Buffer.from(`<< /Type /Pages /Kids [${kids}] /Count ${pages} >>`));
for (let i = 0; i < pages; i++) {
const page = 3 + i * 3;
const content = `q ${box.w} 0 0 ${box.h} 0 0 cm /Im0 Do Q`;
objects.push(
Buffer.from(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${box.w} ${box.h}] /Resources << /XObject << /Im0 ${page + 2} 0 R >> >> /Contents ${page + 1} 0 R >>`,
),
);
objects.push(Buffer.from(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`));
objects.push(
Buffer.concat([
Buffer.from(
`<< /Type /XObject /Subtype /Image /Width ${claimed.w} /Height ${claimed.h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpg.length} >>\nstream\n`,
),
jpg,
Buffer.from('\nendstream'),
]),
);
}
const parts: Buffer[] = [Buffer.from('%PDF-1.4\n')];
const offsets: number[] = [];
let length = parts[0].length;
objects.forEach((body, i) => {
offsets.push(length);
const obj = Buffer.concat([Buffer.from(`${i + 1} 0 obj\n`), body, Buffer.from('\nendobj\n')]);
parts.push(obj);
length += obj.length;
});
const rows = offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('');
parts.push(
Buffer.from(
`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${rows}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${length}\n%%EOF\n`,
),
);
return Buffer.concat(parts);
}
describe('renderPdfPages', () => {
it('draws the first two pages of a scan as images no larger than a capped photo', async () => {
const pages = await renderPdfPages(await scannedPdf(3));
expect(pages).toHaveLength(2);
for (const page of pages) {
const image = await Jimp.read(page.data);
expect(Math.max(image.width, image.height)).toBeLessThanOrEqual(IMAGE_MAX_EDGE);
}
}, 30_000);
it('draws a page by its long edge, so a tall strip never becomes a huge canvas', async () => {
// Scaled by its width alone, this page was a canvas of 1600 x 320000 pixels.
const [page] = await renderPdfPages(await scannedPdf(1, { w: 10, h: 2000 }));
const image = await Jimp.read(page.data);
expect(image.height).toBeLessThanOrEqual(IMAGE_MAX_EDGE);
expect(image.width).toBeLessThanOrEqual(10);
}, 30_000);
it('still draws a page whose embedded image claims more than the pixel cap, leaving that image out', async () => {
const pages = await renderPdfPages(await scannedPdf(1, { w: 300, h: 400 }, { w: 20000, h: 20000 }));
expect(pages).toHaveLength(1);
}, 30_000);
it('throws for bytes that are not a PDF, which the caller reports as unreadable', async () => {
await expect(renderPdfPages(Buffer.from('not a pdf'))).rejects.toThrow();
});
});