Render a preview per bank question, client-side, from the source PDF cropped to the question's bounds — makes the bank visually browsable like the spike cockpit. bankCrops.ts: renders each template's PDF once (cached) via the existing pdf.js loader (already at the 780px coordinate space the bounds live in), then crops the page image to (bounds.x, bounds.y - pageTop, w, h) → a small PNG data-URL; decoded page images are cached too. QuestionBankPage: a lazy <QuestionCrop> at the top of each card (spinner → image, or "no preview" when a question has no bounds/PDF). No API/infra change. tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
69 lines
2.8 KiB
TypeScript
69 lines
2.8 KiB
TypeScript
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
|
|
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
|
|
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
|
|
// once (cached) and reuses it for all of that template's questions. No API/infra change.
|
|
import { examRepository } from '../../services/exam/examRepository';
|
|
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
|
|
|
|
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
|
|
const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
|
|
|
function templateRender(templateId: string): Promise<PdfPageImage[]> {
|
|
let p = renderCache.get(templateId);
|
|
if (!p) {
|
|
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
|
|
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
|
|
renderCache.set(templateId, p);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
let p = imageCache.get(src);
|
|
if (!p) {
|
|
p = new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error('image decode failed'));
|
|
img.src = src;
|
|
});
|
|
imageCache.set(src, p);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
export interface CropInput {
|
|
templateId: string;
|
|
page: number | null;
|
|
bounds: Record<string, number> | null;
|
|
}
|
|
|
|
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
|
|
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
|
|
const { templateId, page, bounds } = input;
|
|
if (!bounds || !page) return null;
|
|
const pages = await templateRender(templateId);
|
|
const pageImg = pages.find((pg) => pg.pageNumber === page);
|
|
if (!pageImg) return null;
|
|
|
|
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
|
|
let yTop = 0;
|
|
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
|
|
|
|
const sx = Math.max(0, Math.round(bounds.x ?? 0));
|
|
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
|
|
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
|
|
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
|
|
if (sw <= 1 || sh <= 1) return null;
|
|
|
|
const img = await loadImage(pageImg.src);
|
|
const scale = Math.min(1, maxWidth / sw);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.max(1, Math.round(sw * scale));
|
|
canvas.height = Math.max(1, Math.round(sh * scale));
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return null;
|
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
|
return canvas.toDataURL('image/png');
|
|
}
|