Compare commits

...
Author SHA1 Message Date
CC WorkerandClaude Sonnet 4.6 8e8a345e61 fix: incremental PDF page rendering for exam setup backdrop
app-ci-deploy / test-build-deploy (push) Has been cancelled
Rendering a 36-page AQA exam PDF sequentially created 36 large canvas
elements, causing memory pressure and keeping pdfStatus='loading' for
60–120s in headless Chrome. Two changes:

1. pdfLoader.ts: reuse a single canvas (reduces peak memory from ~120MB
   to ~4MB) and fire onPageReady callback after each page so callers can
   stream pages to the canvas as they render.

2. ExamTemplateSetupPage.tsx: use the callback to add each PDF page
   shape to the tldraw canvas the moment it renders, making the first
   page visible within a few seconds rather than after all pages load.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 03:35:09 +00:00
CC WorkerandClaude Sonnet 4.6 2de3e29179 fix: serve .mjs files as application/javascript for pdfjs module worker
app-ci-deploy / test-build-deploy (push) Has been cancelled
nginx:alpine mime.types only covers .js, not .mjs. The pdfjs-dist v4
worker is output as pdf.worker-*.mjs; without the correct MIME type the
browser refuses to execute it as a module worker and pdfjs throws
'Network Error', blocking the PDF backdrop from rendering.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 03:21:44 +00:00
CC WorkerandClaude Sonnet 4.6 29390d30ca Merge S4-9b: PDF backdrop on ExamCanvas from source-pdf endpoint
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 <[email protected]>
2026-06-07 02:59:17 +00:00
3 changed files with 28 additions and 3 deletions
+3
View File
@@ -19,6 +19,9 @@ FROM nginx:alpine
# Copy built files # Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
# Create a simple nginx configuration # Create a simple nginx configuration
RUN echo 'server { \ RUN echo 'server { \
listen 3000; \ listen 3000; \
+15 -1
View File
@@ -159,7 +159,21 @@ const ExamTemplateSetupInner: React.FC = () => {
setPdfError(null) setPdfError(null)
try { try {
const bytes = await examRepository.getTemplateSourcePdf(templateId) const bytes = await examRepository.getTemplateSourcePdf(templateId)
pages = await loadPdfPageImages(bytes) pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
const newPage = partialPages[partialPages.length - 1]
const allGeometries = pageGeometryFromImages(partialPages)
pageGeometriesRef.current = allGeometries
const ed = editorRef.current
if (ed) {
const geometry = allGeometries[partialPages.length - 1]
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
try { ed.sendToBack([shapeId as any]) } catch { /* */ }
}
}
setPdfStatus('ready')
})
setPdfStatus(pages.length ? 'ready' : 'missing') setPdfStatus(pages.length ? 'ready' : 'missing')
} catch (pdfErr) { } catch (pdfErr) {
const pdfMsg = apiMessage(pdfErr).message const pdfMsg = apiMessage(pdfErr).message
+10 -2
View File
@@ -12,20 +12,27 @@ export interface PdfPageImage {
height: number height: number
} }
export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAGE_WIDTH): Promise<PdfPageImage[]> { export async function loadPdfPageImages(
pdfBytes: ArrayBuffer,
targetWidth = PAGE_WIDTH,
onPageReady?: (pages: PdfPageImage[]) => void,
): Promise<PdfPageImage[]> {
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
const pages: PdfPageImage[] = [] const pages: PdfPageImage[] = []
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
// for a typical 36-page exam paper.
const canvas = document.createElement("canvas")
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber) const page = await pdf.getPage(pageNumber)
const baseViewport = page.getViewport({ scale: 1 }) const baseViewport = page.getViewport({ scale: 1 })
const scale = targetWidth / baseViewport.width const scale = targetWidth / baseViewport.width
const viewport = page.getViewport({ scale }) const viewport = page.getViewport({ scale })
const canvas = document.createElement("canvas")
canvas.width = Math.ceil(viewport.width) canvas.width = Math.ceil(viewport.width)
canvas.height = Math.ceil(viewport.height) canvas.height = Math.ceil(viewport.height)
const context = canvas.getContext("2d") const context = canvas.getContext("2d")
if (!context) throw new Error("Unable to create PDF render canvas") if (!context) throw new Error("Unable to create PDF render canvas")
context.clearRect(0, 0, canvas.width, canvas.height)
await page.render({ canvasContext: context, viewport }).promise await page.render({ canvasContext: context, viewport }).promise
pages.push({ pages.push({
pageNumber, pageNumber,
@@ -33,6 +40,7 @@ export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAG
width: canvas.width, width: canvas.width,
height: canvas.height, height: canvas.height,
}) })
onPageReady?.([...pages])
} }
return pages return pages