mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Render a small JPEG preview for each stored document and show it at the card's leading edge, falling back to the kind icon when none exists. - Generate previews in the background on upload (new document-thumbnail pg-boss queue) plus a boot-time corpus backfill for existing documents; upload never blocks on or fails because of it. Images are downscaled via @napi-rs/canvas, a PDF's first page is rasterised then downscaled, every other type is skipped. Generation never throws. - Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table (migration 0236) so the list/detail queries that omit the original blob never drag the preview either. Same AES-256-GCM string codec + fail-closed decrypt as the content index; wired into the key-rotation walk. - Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail (auth + module gate + userId-scoped query + fail-closed decrypt), always image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an authed lazy <img>, onError falls back to the icon. - Add hasThumbnail to the document DTO so the card only fetches a preview that exists. Also drop the "search looks inside your documents" caption from the overview (the index-all prompt stays) and the per-card searchable marker; both were noise. Remove the now-unused i18n keys across all six locales.
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
|
|
|
import { RASTER_MAX_PAGES, rasterizePdf } from "../rasterize-pdf";
|
|
|
|
/**
|
|
* Rasterization renders a PDF's pages to bounded JPEG images. Pins: a text-layer
|
|
* PDF and a synthetic image-only (graphics-only, no text layer) PDF both render
|
|
* to JPEG page images; the page cap is honoured; malformed input degrades to
|
|
* `{ ok: false }` and never throws.
|
|
*
|
|
* The fixtures are built inline (a tiny hand-assembled PDF with a correct xref)
|
|
* so the test carries no binary blobs.
|
|
*/
|
|
function buildPdf(pageCount: number, withText: boolean): Buffer {
|
|
const pageNums: number[] = [];
|
|
const perPage: { pageNum: number; contentNum: number; content: string }[] =
|
|
[];
|
|
let n = 4;
|
|
for (let p = 0; p < pageCount; p++) {
|
|
const pageNum = n++;
|
|
const contentNum = n++;
|
|
pageNums.push(pageNum);
|
|
const content = withText
|
|
? `BT /F1 24 Tf 40 120 Td (Page ${p + 1} lab report glucose creatinine) Tj ET`
|
|
: `0 0 1 rg 40 40 120 120 re f`;
|
|
perPage.push({ pageNum, contentNum, content });
|
|
}
|
|
|
|
const bodies: string[] = [];
|
|
bodies[1] = `<< /Type /Catalog /Pages 2 0 R >>`;
|
|
bodies[2] = `<< /Type /Pages /Kids [${pageNums
|
|
.map((x) => `${x} 0 R`)
|
|
.join(" ")}] /Count ${pageCount} >>`;
|
|
bodies[3] = `<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`;
|
|
for (const { pageNum, contentNum, content } of perPage) {
|
|
bodies[pageNum] =
|
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentNum} 0 R >>`;
|
|
bodies[contentNum] =
|
|
`<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`;
|
|
}
|
|
|
|
const total = n - 1;
|
|
let out = "%PDF-1.4\n";
|
|
const offsets: number[] = [];
|
|
for (let i = 1; i <= total; 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 ${total + 1}\n0000000000 65535 f \n`;
|
|
for (let i = 1; i <= total; i++) {
|
|
out += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
|
|
}
|
|
out += `trailer\n<< /Size ${total + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
|
return Buffer.from(out, "latin1");
|
|
}
|
|
|
|
/** JPEG magic bytes. */
|
|
function isJpeg(base64: string): boolean {
|
|
const head = Buffer.from(base64, "base64").subarray(0, 2).toString("hex");
|
|
return head === "ffd8";
|
|
}
|
|
|
|
describe("rasterizePdf", () => {
|
|
it("renders a text-layer PDF's pages to JPEG images", async () => {
|
|
const result = await rasterizePdf(buildPdf(3, true));
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.images).toHaveLength(3);
|
|
for (const img of result.images) {
|
|
expect(img.mediaType).toBe("image/jpeg");
|
|
expect(isJpeg(img.dataBase64)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("renders a synthetic image-only (no text layer) PDF", async () => {
|
|
const result = await rasterizePdf(buildPdf(1, false));
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.images).toHaveLength(1);
|
|
expect(isJpeg(result.images[0]!.dataBase64)).toBe(true);
|
|
});
|
|
|
|
it("caps the number of rendered pages at RASTER_MAX_PAGES", async () => {
|
|
const result = await rasterizePdf(buildPdf(RASTER_MAX_PAGES + 4, true));
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.images).toHaveLength(RASTER_MAX_PAGES);
|
|
});
|
|
|
|
it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => {
|
|
const result = await rasterizePdf(buildPdf(5, true), 1);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.images).toHaveLength(1);
|
|
});
|
|
|
|
it("returns { ok: false } and never throws on malformed input", async () => {
|
|
await expect(
|
|
rasterizePdf(Buffer.from("this is not a pdf")),
|
|
).resolves.toEqual({ ok: false });
|
|
});
|
|
|
|
it("returns { ok: false } on an empty buffer", async () => {
|
|
await expect(rasterizePdf(Buffer.alloc(0))).resolves.toEqual({ ok: false });
|
|
});
|
|
});
|