Merge S4-9b: PDF backdrop on ExamCanvas from source-pdf endpoint
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled

Renders template source PDFs as locked image shapes behind the exam
setup regions. Adds page geometry abstraction so shape coordinates
map to real PDF page dimensions rather than fixed PAGE_HEIGHT math.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
CC Worker 2026-06-07 02:59:17 +00:00
commit 29390d30ca
7 changed files with 192 additions and 21 deletions

View File

@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
vi.mock('./pages/exam', () => ({
ExamDashboardPage: () => <div>Exam Marker</div>,
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));

View File

@ -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<Editor | null>(null)
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -111,6 +145,8 @@ const ExamTemplateSetupInner: React.FC = () => {
const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(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)
}}
/>
</Box>
@ -211,6 +265,9 @@ const ExamTemplateSetupInner: React.FC = () => {
<Typography variant="body2" color="text.secondary">
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.
</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
</Typography>
</Paper>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}

View File

@ -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) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
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 (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
</HTMLContainer>
)
}
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
}
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { 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<ExamCanvasTLShape> { 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<ExamCanvasTLShape> { 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

View File

@ -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<PdfPageImage[]> {
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
}

View File

@ -127,6 +127,15 @@ export const examRepository = {
return res.data;
},
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
headers,
responseType: 'arraybuffer',
});
return res.data;
},
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });

View File

@ -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,

View File

@ -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<ExamCanvasShapeModel, 'y' | 'h'>, 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
}