Mode-3: crop thumbnails on question bank cards
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 <[email protected]>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// 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');
|
||||
}
|
||||
Reference in New Issue
Block a user