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 (
+
+
+
+ )
+ }
+ 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
}