diff --git a/src/pages/exam/QuestionBankPage.tsx b/src/pages/exam/QuestionBankPage.tsx index 10f6bd8..eb85e74 100644 --- a/src/pages/exam/QuestionBankPage.tsx +++ b/src/pages/exam/QuestionBankPage.tsx @@ -11,6 +11,27 @@ import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; import { examRepository } from '../../services/exam/examRepository'; import type { BankQuestion, BankResponse } from '../../types/exam.types'; +import { cropQuestion } from './bankCrops'; + +const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => { + const [src, setSrc] = useState(null); + const [done, setDone] = useState(false); + useEffect(() => { + let alive = true; + cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds }) + .then((d) => { if (alive) { setSrc(d); setDone(true); } }) + .catch(() => { if (alive) setDone(true); }); + return () => { alive = false; }; + }, [q.template_id, q.page, q.bounds]); + if (src) { + return ; + } + return ( + + {done ? 'no preview' : } + + ); +}; const QuestionBankPage: React.FC = () => { const navigate = useNavigate(); @@ -133,6 +154,7 @@ const QuestionBankPage: React.FC = () => { return ( toggle(q.id)}> + Q{q.label ?? '?'} diff --git a/src/pages/exam/bankCrops.ts b/src/pages/exam/bankCrops.ts new file mode 100644 index 0000000..20a65a8 --- /dev/null +++ b/src/pages/exam/bankCrops.ts @@ -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>(); +const imageCache = new Map>(); + +function templateRender(templateId: string): Promise { + 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 { + let p = imageCache.get(src); + if (!p) { + p = new Promise((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 | 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 { + 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'); +}