From aa2f35e46730bb13d2293e58f1ee774b5e312d7d Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 03:56:09 +0100 Subject: [PATCH] feat(exam): render template PDFs behind setup canvas --- src/AppRoutes.admin.test.tsx | 5 +- .../exam/setup/ExamTemplateSetupPage.tsx | 69 +++++++++++++++++-- src/pages/exam/setup/examCanvasShapes.tsx | 30 +++++++- src/pages/exam/setup/pdfLoader.ts | 39 +++++++++++ src/services/exam/examRepository.ts | 9 +++ src/utils/exam-canvas/model.test.ts | 20 +++++- src/utils/exam-canvas/model.ts | 41 ++++++++--- 7 files changed, 192 insertions(+), 21 deletions(-) create mode 100644 src/pages/exam/setup/pdfLoader.ts diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index a4346e5..627c57f 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () =>
Public Not Found< vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () =>
Public Home
})); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single Player
})); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
})); -vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
})); +vi.mock('./pages/exam', () => ({ + ExamDashboardPage: () =>
Exam Marker
, + ExamTemplateSetupPage: () =>
Exam Template Setup
, +})); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
})); diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx index e9ad39b..d92e680 100644 --- a/src/pages/exam/setup/ExamTemplateSetupPage.tsx +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -13,8 +13,9 @@ import { ErrorBoundary } from '../../../components/ErrorBoundary' import { logger } from '../../../debugConfig' import { examRepository } from '../../../services/exam/examRepository' import type { ExamTemplateDetail } from '../../../types/exam.types' -import { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' -import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' +import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' +import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' +import { loadPdfPageImages, PdfPageImage } from './pdfLoader' const TOOLS = [ { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const }, @@ -28,6 +29,18 @@ const TOOLS = [ { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const }, ] +const PAGE_START_X = 260 +const PDF_PAGE_IDS_PREFIX = 'pdf-page-' + +function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] { + let y = 0 + return pages.map((page) => { + const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height } + y += page.height + return geometry + }) +} + function apiMessage(err: unknown): { message: string; conflict: boolean } { if (axios.isAxiosError(err)) { const detail = (err.response?.data as { detail?: string } | undefined)?.detail @@ -88,6 +101,26 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { }))) } +function syncPdfPages(editor: Editor, pages: PdfPageImage[]) { + const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id) + if (existing.length) editor.deleteShapes(existing) + if (!pages.length) return + const geometries = pageGeometryFromImages(pages) + editor.createShapes(geometries.map((geometry) => { + const page = pages[geometry.pageNumber - 1] + return { + id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber), + type: PDF_PAGE_SHAPE_TYPE, + x: geometry.x, + y: geometry.y, + isLocked: true, + props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber }, + } as any + })) + const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber)) + try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ } +} + function seedGuide(editor: Editor) { const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) if (current.length) return @@ -104,6 +137,7 @@ const ExamTemplateSetupInner: React.FC = () => { const navigate = useNavigate() const theme = useTheme() const editorRef = useRef(null) + const pageGeometriesRef = useRef([]) const [template, setTemplate] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -111,6 +145,8 @@ const ExamTemplateSetupInner: React.FC = () => { const [error, setError] = useState(null) const [conflict, setConflict] = useState(null) const [activeTool, setActiveTool] = useState('select') + const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') + const [pdfError, setPdfError] = useState(null) const load = useCallback(async () => { if (!templateId) return @@ -118,8 +154,26 @@ const ExamTemplateSetupInner: React.FC = () => { try { const detail = await examRepository.getTemplate(templateId) setTemplate(detail) + let pages: PdfPageImage[] = [] + setPdfStatus('loading') + setPdfError(null) + try { + const bytes = await examRepository.getTemplateSourcePdf(templateId) + pages = await loadPdfPageImages(bytes) + setPdfStatus(pages.length ? 'ready' : 'missing') + } catch (pdfErr) { + const pdfMsg = apiMessage(pdfErr).message + setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error') + setPdfError(pdfMsg) + logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg }) + } + const geometries = pageGeometryFromImages(pages) + pageGeometriesRef.current = geometries const editor = editorRef.current - if (editor) loadShapes(editor, shapesFromTemplate(detail)) + if (editor) { + syncPdfPages(editor, pages) + loadShapes(editor, shapesFromTemplate(detail, geometries)) + } setDirty(false) } catch (e) { const msg = apiMessage(e).message @@ -139,10 +193,10 @@ const ExamTemplateSetupInner: React.FC = () => { try { ensureDomainIds(editor) const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] - const payload = serializeCanvasShapes(template, shapes) + const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current) const saved = await examRepository.replaceTemplate(templateId, payload) setTemplate(saved) - loadShapes(editor, shapesFromTemplate(saved)) + loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current)) setDirty(false) } catch (e) { const msg = apiMessage(e) @@ -186,7 +240,7 @@ const ExamTemplateSetupInner: React.FC = () => { editorRef.current = editor editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) editor.store.listen(() => setDirty(true), { scope: 'document' }) - if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor) + if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor) }} /> @@ -211,6 +265,9 @@ const ExamTemplateSetupInner: React.FC = () => { 1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API. + + PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} + {loading && } diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx index bdd6596..50da7be 100644 --- a/src/pages/exam/setup/examCanvasShapes.tsx +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -3,6 +3,8 @@ import React from 'react' import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' +export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page' + export const SHAPE_TYPES = { boundary: 'exam-boundary', part: 'exam-part', @@ -14,6 +16,11 @@ export const SHAPE_TYPES = { furniture: 'exam-region-furniture', } as const +export type ExamPdfPageTLShape = TLBaseBoxShape & { + type: typeof PDF_PAGE_SHAPE_TYPE + props: { w: number; h: number; src: string; pageNumber: number } +} + export type ExamCanvasTLShape = TLBaseBoxShape & { type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] props: { @@ -68,7 +75,22 @@ function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { } const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) } -const ind = (s: ExamCanvasTLShape) => +const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => + +class PdfPageUtil extends BaseBoxShapeUtil { + static override type = PDF_PAGE_SHAPE_TYPE + static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number } + override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } } + override canEdit() { return false } + override component(shape: ExamPdfPageTLShape) { + return ( + + {'PDF + + ) + } + override indicator(shape: ExamPdfPageTLShape) { return ind(shape) } +} class BoundaryUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } class PartUtil extends BaseBoxShapeUtil { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } } @@ -82,9 +104,13 @@ class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.m class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture } -export const examCanvasShapeUtils = [BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const +export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const +export function isPdfPageShape(type: string): boolean { + return type === PDF_PAGE_SHAPE_TYPE +} + export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null diff --git a/src/pages/exam/setup/pdfLoader.ts b/src/pages/exam/setup/pdfLoader.ts new file mode 100644 index 0000000..3f7baa8 --- /dev/null +++ b/src/pages/exam/setup/pdfLoader.ts @@ -0,0 +1,39 @@ +import * as pdfjsLib from "pdfjs-dist" +import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url" + +import { PAGE_WIDTH } from "../../../utils/exam-canvas/model" + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc + +export interface PdfPageImage { + pageNumber: number + src: string + width: number + height: number +} + +export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAGE_WIDTH): Promise { + const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise + const pages: PdfPageImage[] = [] + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber) + const baseViewport = page.getViewport({ scale: 1 }) + const scale = targetWidth / baseViewport.width + const viewport = page.getViewport({ scale }) + const canvas = document.createElement("canvas") + canvas.width = Math.ceil(viewport.width) + canvas.height = Math.ceil(viewport.height) + const context = canvas.getContext("2d") + if (!context) throw new Error("Unable to create PDF render canvas") + await page.render({ canvasContext: context, viewport }).promise + pages.push({ + pageNumber, + src: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + }) + } + + return pages +} diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index e4c0913..a42cdde 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -127,6 +127,15 @@ export const examRepository = { return res.data; }, + async getTemplateSourcePdf(templateId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { + headers, + responseType: 'arraybuffer', + }); + return res.data; + }, + async createTemplate(payload: CreateTemplatePayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/templates`, payload, { headers }); diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts index 2142dc9..52b40b7 100644 --- a/src/utils/exam-canvas/model.test.ts +++ b/src/utils/exam-canvas/model.test.ts @@ -1,7 +1,6 @@ - import { describe, expect, it } from 'vitest' import type { ExamTemplateDetail } from '../../types/exam.types' -import { isUuid, serializeCanvasShapes, shapesFromTemplate } from './model' +import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model' const template: ExamTemplateDetail = { id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, @@ -30,6 +29,23 @@ describe('exam setup canvas serialization', () => { expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true) }) + it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => { + const pages = [ + { pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 }, + { pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 }, + ] + expect(pageForY(1050, pages)).toBe(2) + const payload = serializeCanvasShapes(template, [ + { id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' }, + { id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' }, + { id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' }, + { id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 }, + ], pages) + expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2) + expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true) + expect(payload.response_areas[0].page).toBe(2) + }) + it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { const shapes = shapesFromTemplate({ ...template, diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts index 00ea3ae..22bdb54 100644 --- a/src/utils/exam-canvas/model.ts +++ b/src/utils/exam-canvas/model.ts @@ -3,6 +3,9 @@ import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exa export const PAGE_HEIGHT = 1100 export const PAGE_WIDTH = 780 +export const PAGE_GAP = 0 + +export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number } export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind @@ -25,10 +28,28 @@ export interface ExamCanvasShapeModel { questionId?: string | null } -export function pageForY(y: number): number { +export function pageForY(y: number, pages?: CanvasPageGeometry[]): number { + if (pages?.length) { + const hit = pages.find((page) => y >= page.y && y <= page.y + page.h) + if (hit) return hit.pageNumber + const nearest = pages.reduce((best, page) => { + const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h))) + return dy < best.dy ? { page, dy } : best + }, { page: pages[0], dy: Number.POSITIVE_INFINITY }) + return nearest.page.pageNumber + } return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1) } +export function pageTop(page: number, pages?: CanvasPageGeometry[]): number { + const hit = pages?.find((p) => p.pageNumber === page) + return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP)) +} + +function pageForShape(shape: Pick, pages?: CanvasPageGeometry[]): number { + return pageForY(shape.y + shape.h / 2, pages) +} + export function isUuid(value: string | null | undefined): value is string { return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } @@ -63,10 +84,10 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s return cy >= minY && cy <= maxY } -export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[]): TemplateReplacePayload { +export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload { const orderedBoundaries = shapes .filter((s) => s.kind === 'boundary') - .sort((a, b) => (pageForY(a.y) - pageForY(b.y)) || (a.y - b.y)) + .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y)) const parts = shapes.filter((s) => s.kind === 'part') const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') @@ -84,7 +105,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} }) bands.push({ questionId, top, bottom }) for (const b of [top, bottom]) { - boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForY(b.y) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true }) + boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true }) } } @@ -93,23 +114,23 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() partQuestionIds.set(part.id, qid) - questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForY(part.y) }) + questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) }) }) const response_areas: TemplateReplacePayload['response_areas'] = [] for (const region of regions) { const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) - const fallbackPart = parts.find((part) => pageForY(part.y) === pageForY(region.y)) ?? parts[0] + const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0] const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined if (!questionId) continue const kind = region.kind as ExamCanvasRegionKind - response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForY(region.y), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null }) + response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null }) } return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries } } -export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] { +export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] { const shapes: ExamCanvasShapeModel[] = [] const questions = new Map(detail.questions.map((q) => [q.id, q])) for (const b of detail.boundaries ?? []) { @@ -121,9 +142,9 @@ export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeM shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id }) } for (const r of detail.response_areas ?? []) { - const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 } + const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 } const q = questions.get(r.question_id) - shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id }) + shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id }) } return shapes }