mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Refs #1124. The thumbnail job opened no wide event, so every annotation it made went nowhere: one instance ran it hundreds of times, left sixteen PDFs without a preview, and the log never said so. Each run is now a `job.document_thumbnail` background event. A run that should have produced a preview and did not is raised to warn and names the reason; a type that never has a preview stays at info.
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
import { Buffer } from "node:buffer";
|
||
|
||
import { createCanvas } from "@napi-rs/canvas";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
|
||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||
|
||
// Partial mock of the native canvas module: `loadImage` delegates to the real
|
||
// decoder by default (so every real-fixture test below is unaffected) and can
|
||
// be overridden per-test via `loadImageMock` to simulate a decoded image whose
|
||
// dimensions the header sniff could not have known.
|
||
const { loadImageMock } = vi.hoisted(() => ({ loadImageMock: vi.fn() }));
|
||
vi.mock("@napi-rs/canvas", async (importActual) => {
|
||
const actual = await importActual<typeof import("@napi-rs/canvas")>();
|
||
return {
|
||
...actual,
|
||
loadImage: (source: Buffer | Uint8Array) =>
|
||
loadImageMock(source) ?? actual.loadImage(source),
|
||
};
|
||
});
|
||
|
||
import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail";
|
||
|
||
/**
|
||
* Preview-thumbnail generation downscales an image (or a PDF's first page) to a
|
||
* small JPEG, bounded to `THUMB_LONG_EDGE` on the long edge, and NEVER throws:
|
||
* an unsupported MIME or malformed bytes resolve to `{ ok: false }` so the card
|
||
* falls back to its kind icon. The re-encode always emits a fresh JPEG (EXIF/GPS
|
||
* stripped by construction).
|
||
*/
|
||
|
||
/** A solid-colour source image at the given size, encoded to the given type. */
|
||
function sourceImage(
|
||
width: number,
|
||
height: number,
|
||
mime: "image/jpeg" | "image/png" | "image/webp",
|
||
): Buffer {
|
||
const canvas = createCanvas(width, height);
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.fillStyle = "#3366cc";
|
||
ctx.fillRect(0, 0, width, height);
|
||
if (mime === "image/png") return canvas.toBuffer("image/png");
|
||
if (mime === "image/webp") return canvas.toBuffer("image/webp");
|
||
return canvas.toBuffer("image/jpeg", 90);
|
||
}
|
||
|
||
/** JPEG magic bytes. */
|
||
function isJpeg(buf: Buffer): boolean {
|
||
return buf.subarray(0, 2).toString("hex") === "ffd8";
|
||
}
|
||
|
||
describe("generateThumbnail", () => {
|
||
it("downscales a large JPEG to a bounded JPEG preview", async () => {
|
||
const result = await generateThumbnail(
|
||
sourceImage(1200, 800, "image/jpeg"),
|
||
"image/jpeg",
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(isJpeg(result.thumbnail.jpeg)).toBe(true);
|
||
expect(Math.max(result.thumbnail.width, result.thumbnail.height)).toBe(
|
||
THUMB_LONG_EDGE,
|
||
);
|
||
// Landscape source keeps its aspect ratio (320 × 213).
|
||
expect(result.thumbnail.width).toBe(THUMB_LONG_EDGE);
|
||
expect(result.thumbnail.height).toBe(213);
|
||
});
|
||
|
||
it("renders a PNG source to a JPEG preview", async () => {
|
||
const result = await generateThumbnail(
|
||
sourceImage(640, 640, "image/png"),
|
||
"image/png",
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(isJpeg(result.thumbnail.jpeg)).toBe(true);
|
||
expect(result.thumbnail.width).toBe(THUMB_LONG_EDGE);
|
||
expect(result.thumbnail.height).toBe(THUMB_LONG_EDGE);
|
||
});
|
||
|
||
it("never upscales a small source", async () => {
|
||
const result = await generateThumbnail(
|
||
sourceImage(100, 60, "image/jpeg"),
|
||
"image/jpeg",
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(result.thumbnail.width).toBe(100);
|
||
expect(result.thumbnail.height).toBe(60);
|
||
});
|
||
|
||
it("renders a PDF's first page to a JPEG preview", async () => {
|
||
// A tiny hand-assembled one-page graphics PDF (no text layer needed).
|
||
const content = "0 0 1 rg 20 20 160 160 re f";
|
||
const bodies: string[] = [];
|
||
bodies[1] = "<< /Type /Catalog /Pages 2 0 R >>";
|
||
bodies[2] = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
|
||
bodies[3] =
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>";
|
||
bodies[4] = `<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`;
|
||
let out = "%PDF-1.4\n";
|
||
const offsets: number[] = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
offsets[i] = Buffer.byteLength(out, "latin1");
|
||
out += `${i} 0 obj\n${bodies[i]}\nendobj\n`;
|
||
}
|
||
const xrefOffset = Buffer.byteLength(out, "latin1");
|
||
out += "xref\n0 5\n0000000000 65535 f \n";
|
||
for (let i = 1; i <= 4; i++) {
|
||
out += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
|
||
}
|
||
out += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||
|
||
const result = await generateThumbnail(
|
||
Buffer.from(out, "latin1"),
|
||
"application/pdf",
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
if (!result.ok) return;
|
||
expect(isJpeg(result.thumbnail.jpeg)).toBe(true);
|
||
expect(
|
||
Math.max(result.thumbnail.width, result.thumbnail.height),
|
||
).toBeLessThanOrEqual(THUMB_LONG_EDGE);
|
||
});
|
||
|
||
it("returns { ok: false } for an unsupported MIME type", async () => {
|
||
await expect(
|
||
generateThumbnail(Buffer.from("plain text"), "text/plain"),
|
||
).resolves.toEqual({ ok: false, reason: "unsupported-type" });
|
||
});
|
||
|
||
it("returns { ok: false } and never throws on malformed image bytes", async () => {
|
||
await expect(
|
||
generateThumbnail(Buffer.from("not a real image"), "image/png"),
|
||
).resolves.toEqual({ ok: false, reason: "error" });
|
||
});
|
||
|
||
it("returns { ok: false } on a malformed PDF", async () => {
|
||
await expect(
|
||
generateThumbnail(Buffer.from("not a pdf"), "application/pdf"),
|
||
).resolves.toEqual({ ok: false, reason: "raster-failed" });
|
||
});
|
||
|
||
it("refuses a decompression bomb before decoding (pixel cap)", async () => {
|
||
// A valid PNG signature + IHDR declaring 30000×30000 = 900 MP. The header
|
||
// sniff rejects it BEFORE `loadImage` would allocate a multi-GB bitmap, so
|
||
// the (otherwise incomplete) byte string never reaches the decoder.
|
||
const bomb = Buffer.alloc(24);
|
||
bomb.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
|
||
bomb.writeUInt32BE(13, 8); // IHDR length
|
||
bomb.write("IHDR", 12, "ascii");
|
||
bomb.writeUInt32BE(30_000, 16); // width
|
||
bomb.writeUInt32BE(30_000, 20); // height
|
||
await expect(generateThumbnail(bomb, "image/png")).resolves.toEqual({
|
||
ok: false,
|
||
reason: "pixel-cap",
|
||
});
|
||
});
|
||
|
||
it("refuses an oversize image the header sniff missed (post-decode cap)", async () => {
|
||
// Bytes the JPEG sniffer cannot read (no SOI) → the pre-decode guard is
|
||
// skipped and the code falls through to `loadImage`. The decoder is forced
|
||
// to return 30000×30000 = 900 MP, so only the post-decode backstop can
|
||
// catch it. Without that backstop this would OOM the serial worker.
|
||
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
|
||
await expect(
|
||
generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"),
|
||
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
|
||
});
|
||
});
|