diff --git a/deno.json b/deno.json index b85f1b7..068a6d3 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,9 @@ "exports": "./main.ts", "imports": { "@std/assert": "jsr:@std/assert@^1", - "@std/path": "jsr:@std/path@^1" + "@std/path": "jsr:@std/path@^1", + "pdfjs-dist": "npm:pdfjs-dist@^6.1.200", + "pdfjs-dist/": "npm:/pdfjs-dist@^6.1.200/" }, "tasks": { "dev": "deno desktop --hmr --backend cef --allow-read --allow-write --allow-env main.ts", diff --git a/deno.lock b/deno.lock index 37b76c1..238ce97 100644 --- a/deno.lock +++ b/deno.lock @@ -4,7 +4,9 @@ "jsr:@std/assert@1": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/path@1": "1.1.6" + "jsr:@std/path@1": "1.1.6", + "npm:pdfjs-dist@*": "6.1.200", + "npm:pdfjs-dist@^6.1.200": "6.1.200" }, "jsr": { "@std/assert@1.0.19": { @@ -23,10 +25,90 @@ ] } }, + "npm": { + "@napi-rs/canvas-android-arm64@1.0.2": { + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@napi-rs/canvas-darwin-arm64@1.0.2": { + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@napi-rs/canvas-darwin-x64@1.0.2": { + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@napi-rs/canvas-linux-arm-gnueabihf@1.0.2": { + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@napi-rs/canvas-linux-arm64-gnu@1.0.2": { + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@napi-rs/canvas-linux-arm64-musl@1.0.2": { + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@napi-rs/canvas-linux-riscv64-gnu@1.0.2": { + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@napi-rs/canvas-linux-x64-gnu@1.0.2": { + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@napi-rs/canvas-linux-x64-musl@1.0.2": { + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@napi-rs/canvas-win32-arm64-msvc@1.0.2": { + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@napi-rs/canvas-win32-x64-msvc@1.0.2": { + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@napi-rs/canvas@1.0.2": { + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "optionalDependencies": [ + "@napi-rs/canvas-android-arm64", + "@napi-rs/canvas-darwin-arm64", + "@napi-rs/canvas-darwin-x64", + "@napi-rs/canvas-linux-arm-gnueabihf", + "@napi-rs/canvas-linux-arm64-gnu", + "@napi-rs/canvas-linux-arm64-musl", + "@napi-rs/canvas-linux-riscv64-gnu", + "@napi-rs/canvas-linux-x64-gnu", + "@napi-rs/canvas-linux-x64-musl", + "@napi-rs/canvas-win32-arm64-msvc", + "@napi-rs/canvas-win32-x64-msvc" + ] + }, + "pdfjs-dist@6.1.200": { + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "optionalDependencies": [ + "@napi-rs/canvas" + ] + } + }, "workspace": { "dependencies": [ "jsr:@std/assert@1", - "jsr:@std/path@1" + "jsr:@std/path@1", + "npm:pdfjs-dist@^6.1.200" ] } } diff --git a/main.ts b/main.ts index c6439cc..da5ccbf 100644 --- a/main.ts +++ b/main.ts @@ -14,10 +14,17 @@ import { import { cacheOrGetPdf } from "./src/pdf_cache.ts"; import INDEX_HTML from "./web/index.html" with { type: "text" }; import APP_JS from "./web/app.js" with { type: "text" }; +import PDF_VIEWER_JS from "./web/pdf_viewer.js" with { type: "text" }; import STYLES_CSS from "./web/styles.css" with { type: "text" }; import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with { type: "json", }; +import PDFJS_LIB_TEXT from "pdfjs-dist/build/pdf.min.mjs" with { + type: "text", +}; +import PDFJS_WORKER_TEXT from "pdfjs-dist/build/pdf.worker.min.mjs" with { + type: "text", +}; const PREFIX = "[BizMatch QC]"; @@ -110,14 +117,12 @@ async function loadDocumentsFromFile( `Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`, ); } - let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSON in "${jsonPath}": ${errorMessage(error)}`); } - try { return { documents: validateDocuments(parsed), source: "file" }; } catch (error) { @@ -219,34 +224,101 @@ function json(data: unknown, status = 200) { }); } -function staticFile(pathname: string): Response { - const files: Record = { - "/": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, - "/index.html": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, - "/app.js": { body: APP_JS, type: "text/javascript; charset=utf-8" }, - "/styles.css": { body: STYLES_CSS, type: "text/css; charset=utf-8" }, - }; - const file = files[pathname]; - if (!file) return new Response("Not found", { status: 404 }); - return new Response(file.body, { - headers: { "content-type": file.type, "cache-control": "no-store" }, +// ---- PDF token system ---- +// A token stores a resolved cache path so that Range requests +// (which PDF.js makes many of) hit the local disk cache directly +// without re-stat'ing or re-validating against NAS for each one. + +interface PdfToken { + cachePath: string; + sourcePath: string; + fileSize: number; + stale: boolean; + createdAt: number; +} + +const pdfTokens = new Map(); +const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes + +function cleanupTokens() { + const now = Date.now(); + for (const [token, entry] of pdfTokens) { + if (now - entry.createdAt > TOKEN_TTL_MS) pdfTokens.delete(token); + } +} +// Periodic cleanup every 10 minutes +setInterval(cleanupTokens, 10 * 60 * 1000); + +async function servePdfBytes( + cachePath: string, + fileSize: number, + rangeHeader: string | null, + elapsedStart: number, + stale: boolean, +): Promise { + if (rangeHeader) { + const match = rangeHeader.match(/^bytes=(\d+)-(\d*)$/); + if (match) { + const rangeStart = parseInt(match[1], 10); + const rangeEnd = match[2] ? parseInt(match[2], 10) : fileSize - 1; + const length = rangeEnd - rangeStart + 1; + + if (rangeStart < 0 || rangeEnd >= fileSize || rangeStart > rangeEnd) { + return new Response(null, { + status: 416, + headers: { "content-range": `bytes */${fileSize}` }, + }); + } + + const file = await Deno.open(cachePath, { read: true }); + await file.seek(rangeStart, Deno.SeekMode.Start); + const buf = new Uint8Array(length); + let bytesRead = 0; + while (bytesRead < length) { + const n = await file.read(buf.subarray(bytesRead)); + if (n === null) break; + bytesRead += n; + } + file.close(); + + const elapsed = Math.round(performance.now() - elapsedStart); + console.log( + `${PREFIX} PDF range served from local cache: bytes ${rangeStart}-${ + rangeStart + bytesRead - 1 + }/${fileSize} in ${elapsed}ms`, + ); + + return new Response(buf.slice(0, bytesRead), { + status: 206, + headers: { + "content-type": "application/pdf", + "content-length": String(bytesRead), + "content-range": `bytes ${rangeStart}-${ + rangeStart + bytesRead - 1 + }/${fileSize}`, + "accept-ranges": "bytes", + ...(stale ? { "x-bizmatch-cache-status": "stale" } : {}), + }, + }); + } + } + + const file = await Deno.open(cachePath, { read: true }); + const elapsed = Math.round(performance.now() - elapsedStart); + console.log( + `${PREFIX} PDF full served from local cache (${fileSize}B) in ${elapsed}ms`, + ); + return new Response(file.readable, { + headers: { + "content-type": "application/pdf", + "content-length": String(fileSize), + "accept-ranges": "bytes", + ...(stale ? { "x-bizmatch-cache-status": "stale" } : {}), + }, }); } -function syncSaveSettings() { - try { - const path = resolveSettingsPath(); - Deno.mkdirSync(dirname(path), { recursive: true }); - Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2)); - console.log( - `${PREFIX} Window size saved: ${settings.windowWidth}x${settings.windowHeight}`, - ); - } catch (err) { - console.error( - `${PREFIX} Failed to save settings on close: ${errorMessage(err)}`, - ); - } -} +// ------- HTTP Server ------- Deno.serve(async (request) => { const url = new URL(request.url); @@ -282,130 +354,141 @@ Deno.serve(async (request) => { } } - if (url.pathname === "/api/window-size" && request.method === "POST") { + // ---- PDF prepare endpoint ---- + if (url.pathname === "/api/pdf/prepare" && request.method === "POST") { try { - const body = await request.json() as { width?: number; height?: number }; - if (typeof body.width === "number" && typeof body.height === "number") { - settings.windowWidth = Math.max(1100, Math.round(body.width)); - settings.windowHeight = Math.max(700, Math.round(body.height)); - await saveAppSettings(settings); - console.log( - `${PREFIX} Window size saved (fallback): ${settings.windowWidth}x${settings.windowHeight}`, - ); + const body = await request.json() as { index?: number }; + const index = body.index; + const doc = documents[index!]; + if (!Number.isInteger(index) || !doc) { + return json({ error: "Unknown document index." }, 404); } - return json({ ok: true }); - } catch { - return json({ ok: true }); - } - } - if (url.pathname === "/api/pdf" && request.method === "GET") { - const index = Number(url.searchParams.get("index")); - const doc = documents[index]; - if (!Number.isInteger(index) || !doc) { - return json({ error: "Unknown document index." }, 404); - } - - try { const sourcePath = resolvePdfPath(settings.pdfBaseDirectory, doc); - const startTotal = performance.now(); + const relative = doc._letter + "/" + doc.file_name; + console.log(`${PREFIX} PDF prepare requested: ${relative}`); + const startTime = performance.now(); const cacheResult = await cacheOrGetPdf(sourcePath); - const cacheLookupMs = Math.round(performance.now() - startTotal); let fileSize: number; - try { fileSize = (await Deno.stat(cacheResult.path)).size; } catch { - throw new Error(`Cached file is not readable: "${cacheResult.path}"`); + throw new Error("Cached file is not readable."); } - const rangeHeader = request.headers.get("range"); + const token = crypto.randomUUID(); + pdfTokens.set(token, { + cachePath: cacheResult.path, + sourcePath, + fileSize, + stale: cacheResult.stale, + createdAt: Date.now(), + }); - if (rangeHeader) { - const match = rangeHeader.match(/^bytes=(\d+)-(\d*)$/); - if (match) { - const rangeStart = parseInt(match[1], 10); - const rangeEnd = match[2] ? parseInt(match[2], 10) : fileSize - 1; - const length = rangeEnd - rangeStart + 1; - - if ( - rangeStart < 0 || rangeEnd >= fileSize || rangeStart > rangeEnd - ) { - return new Response(null, { - status: 416, - headers: { - "content-range": `bytes */${fileSize}`, - }, - }); - } - - const file = await Deno.open(cacheResult.path, { read: true }); - await file.seek(rangeStart, Deno.SeekMode.Start); - const buf = new Uint8Array(length); - let bytesRead = 0; - while (bytesRead < length) { - const n = await file.read(buf.subarray(bytesRead)); - if (n === null) break; - bytesRead += n; - } - file.close(); - - const elapsed = Math.round(performance.now() - startTotal); - console.log( - `${PREFIX} PDF served (range ${rangeStart}-${rangeEnd}/${fileSize}): ${elapsed}ms, cache_lookup=${cacheLookupMs}ms`, - ); - - return new Response(buf.slice(0, bytesRead), { - status: 206, - headers: { - "content-type": "application/pdf", - "content-length": String(bytesRead), - "content-range": `bytes ${rangeStart}-${ - rangeStart + bytesRead - 1 - }/${fileSize}`, - "accept-ranges": "bytes", - "content-disposition": `inline; filename*=UTF-8''${ - encodeURIComponent(doc.file_name) - }`, - ...(cacheResult.stale - ? { "x-bizmatch-cache-status": "stale" } - : {}), - }, - }); - } - } - - const file = await Deno.open(cacheResult.path, { read: true }); - const elapsed = Math.round(performance.now() - startTotal); + const elapsed = Math.round(performance.now() - startTime); + const status = cacheResult.stale ? "refresh" : "hit"; console.log( - `${PREFIX} PDF served (full ${fileSize}B): ${elapsed}ms, cache_lookup=${cacheLookupMs}ms`, + `${PREFIX} PDF disk cache ${status} in ${elapsed}ms, token created`, ); - return new Response(file.readable, { - headers: { - "content-type": "application/pdf", - "content-length": String(fileSize), - "accept-ranges": "bytes", - "content-disposition": `inline; filename*=UTF-8''${ - encodeURIComponent(doc.file_name) - }`, - ...(cacheResult.stale ? { "x-bizmatch-cache-status": "stale" } : {}), - }, + return json({ + url: `/api/pdf/content/${token}`, + size: fileSize, + cacheStatus: status, }); } catch (error) { const message = errorMessage(error); - console.error( - `${PREFIX} PDF open failed for "${doc.file_name}": ${message}`, - ); + console.error(`${PREFIX} PDF prepare failed: ${message}`); return json({ error: message }, 404); } } - return staticFile(url.pathname); + // ---- PDF content endpoint (range-aware, cache-only) ---- + if ( + url.pathname.startsWith("/api/pdf/content/") && request.method === "GET" + ) { + const token = url.pathname.slice("/api/pdf/content/".length); + const entry = pdfTokens.get(token); + if (!entry) { + return json({ error: "Invalid or expired PDF token." }, 404); + } + const startTime = performance.now(); + try { + return await servePdfBytes( + entry.cachePath, + entry.fileSize, + request.headers.get("range"), + startTime, + entry.stale, + ); + } catch (error) { + const message = errorMessage(error); + console.error(`${PREFIX} PDF content serve failed: ${message}`); + return json({ error: message }, 500); + } + } + + // ---- HEAD request for pdf content ---- + if ( + url.pathname.startsWith("/api/pdf/content/") && request.method === "HEAD" + ) { + const token = url.pathname.slice("/api/pdf/content/".length); + const entry = pdfTokens.get(token); + if (!entry) { + return new Response(null, { status: 404 }); + } + return new Response(null, { + status: 200, + headers: { + "content-type": "application/pdf", + "content-length": String(entry.fileSize), + "accept-ranges": "bytes", + }, + }); + } + + // ---- PDF.js library assets ---- + if (url.pathname === "/pdfjs/pdf.min.mjs") { + return new Response(PDFJS_LIB_TEXT, { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "public, max-age=86400", + }, + }); + } + + if (url.pathname === "/pdfjs/pdf.worker.min.mjs") { + return new Response(PDFJS_WORKER_TEXT, { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "public, max-age=86400", + }, + }); + } + + // ---- Static files ---- + const staticFiles: Record = { + "/": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, + "/index.html": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, + "/app.js": { body: APP_JS, type: "text/javascript; charset=utf-8" }, + "/pdf_viewer.js": { + body: PDF_VIEWER_JS, + type: "text/javascript; charset=utf-8", + }, + "/styles.css": { body: STYLES_CSS, type: "text/css; charset=utf-8" }, + }; + const file = staticFiles[url.pathname]; + if (file) { + return new Response(file.body, { + headers: { "content-type": file.type, "cache-control": "no-store" }, + }); + } + + return new Response("Not found", { status: 404 }); }); -console.log(`${PREFIX} Listening on http://127.0.0.1:42469/`); +// ------- Startup logging ------- console.log(`${PREFIX} Settings path: ${resolveSettingsPath()}`); console.log(`${PREFIX} PDF cache directory: ${resolveCacheDir()}`); console.log( @@ -416,76 +499,114 @@ console.log( }`, ); console.log(`${PREFIX} Documents loaded: ${documents.length}`); +console.log(`${PREFIX} PDF.js version: 6.1.200`); if (loadError) console.error(`${PREFIX} ${loadError}`); +// ------- Window management ------- const clamped = clampWindowSize(settings); console.log( - `${PREFIX} Restoring window size: ${clamped.windowWidth}x${clamped.windowHeight}`, + `${PREFIX} Settings window size loaded: ${clamped.windowWidth}x${clamped.windowHeight}`, +); +console.log( + `${PREFIX} Creating main window with: ${clamped.windowWidth}x${clamped.windowHeight}`, ); -// Deno Desktop provides Deno.BrowserWindow at runtime but the standard -// Deno 2.9 type checker does not include desktop type declarations. -// The runtime `deno desktop` command also types this as a generic -// BrowserWindow without width/height properties. -// We use a local type assertion to narrow to the exact API we use. -// Remove this cast when official desktop types become available. +// Deno 2.9 Desktop API: use BrowserWindow constructor with width/height, +// then verify with getSize() and apply setSize() if needed. type _Win = { - readonly width: number; - readonly height: number; - onresize: ((event: Event) => void) | null; - onclose: ((event: Event) => void) | null; + getSize(): [number, number]; + setSize(w: number, h: number): void; + addEventListener( + type: string, + listener: (event: CustomEvent) => void, + ): void; show(): void; }; + const _WinCtor = (Deno as unknown as { BrowserWindow: new ( opts?: { title?: string; width?: number; height?: number }, ) => _Win; }).BrowserWindow; + const win = new _WinCtor({ title: "BizMatch QC", width: clamped.windowWidth, height: clamped.windowHeight, }); + +const [actualW, actualH] = win.getSize(); console.log( - `${PREFIX} Window initialized: ${clamped.windowWidth}x${clamped.windowHeight}`, + `${PREFIX} Main window actual size after construction: ${actualW}x${actualH}`, ); -// Native resize tracking +if ( + actualW !== clamped.windowWidth || actualH !== clamped.windowHeight +) { + console.log( + `${PREFIX} Window size differs from requested, applying setSize(${clamped.windowWidth}, ${clamped.windowHeight})`, + ); + win.setSize(clamped.windowWidth, clamped.windowHeight); + const [w2, h2] = win.getSize(); + console.log(`${PREFIX} After setSize: ${w2}x${h2}`); +} + +// Native resize tracking using Deno 2.9 BrowserWindow events let resizeTimer: ReturnType | undefined; try { - win.onresize = () => { - if (resizeTimer !== undefined) clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const w = win.width; - const h = win.height; - if (w > 0 && h > 0) { + win.addEventListener( + "resize", + ((e: CustomEvent) => { + if (resizeTimer !== undefined) clearTimeout(resizeTimer); + // Store immediately but debounce writes + const w = e.detail?.width ?? 0; + const h = e.detail?.height ?? 0; + if (w >= 1100 && h >= 700) { settings.windowWidth = w; settings.windowHeight = h; - console.log(`${PREFIX} Window resized: ${w}x${h}`); - saveAppSettings(settings).catch((err) => - console.error( - `${PREFIX} Failed to save window size: ${errorMessage(err)}`, - ) + } + resizeTimer = setTimeout(() => { + if (settings.windowWidth >= 1100 && settings.windowHeight >= 700) { + console.log( + `${PREFIX} Window resized: ${settings.windowWidth}x${settings.windowHeight}`, + ); + saveAppSettings(settings).catch((err) => + console.error( + `${PREFIX} Failed to save window size: ${errorMessage(err)}`, + ) + ); + } + }, 500); + }) as (e: Event) => void, + ); + + win.addEventListener( + "close", + (() => { + if (resizeTimer !== undefined) clearTimeout(resizeTimer); + const [fw, fh] = win.getSize(); + if (fw >= 1100 && fh >= 700) { + settings.windowWidth = fw; + settings.windowHeight = fh; + } + try { + const path = resolveSettingsPath(); + Deno.mkdirSync(dirname(path), { recursive: true }); + Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2)); + console.log( + `${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight}`, + ); + } catch (err) { + console.error( + `${PREFIX} Failed to save settings on close: ${errorMessage(err)}`, ); } - }, 500); - }; - - win.onclose = () => { - if (resizeTimer !== undefined) clearTimeout(resizeTimer); - const w = win.width; - const h = win.height; - if (w > 0 && h > 0) { - settings.windowWidth = w; - settings.windowHeight = h; - syncSaveSettings(); - } - }; + }) as (e: Event) => void, + ); } catch (err) { console.warn( `${PREFIX} Native window-event binding unavailable: ${errorMessage(err)}`, ); - console.warn(`${PREFIX} Falling back to web-page resize tracking.`); } win.show(); diff --git a/web/app.js b/web/app.js index f17008c..8b9fa50 100644 --- a/web/app.js +++ b/web/app.js @@ -1,12 +1,12 @@ let state; let groups = []; let selectedIndex = -1; -let currentPdfUrl = ""; +let pdfViewer = null; const people = document.querySelector("#people"); const fields = document.querySelector("#fields"); -const pdf = document.querySelector("#pdf"); const viewer = document.querySelector(".viewer"); +const pdfContainer = document.querySelector("#pdfViewer"); const pdfMessage = document.querySelector("#pdfMessage"); const status = document.querySelector("#status"); const search = document.querySelector("#search"); @@ -23,6 +23,7 @@ const fieldDefs = [ null, ["Address", "address"], ["State", "state"], + null, ["Businesses from Notes", "notes_business_raw"], ["Types of Businesses", "types_of_business_raw"], ["Background Experience", "background_experience"], @@ -111,30 +112,34 @@ function renderList() { } async function loadPdf(index) { - if (currentPdfUrl) { - URL.revokeObjectURL(currentPdfUrl); - currentPdfUrl = ""; + if (pdfViewer) { + pdfViewer.destroy(); } - pdf.removeAttribute("src"); + pdfViewer = new PdfViewer(pdfContainer); viewer.classList.remove("loaded"); pdfMessage.hidden = false; pdfMessage.textContent = "Loading PDF\u2026"; + const startTime = performance.now(); try { - const response = await fetch(`/api/pdf?index=${index}`, { - cache: "no-store", + const response = await fetch("/api/pdf/prepare", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ index }), }); if (!response.ok) { const body = await response.json().catch(() => ({})); - throw new Error( - body.error || `PDF request failed with HTTP ${response.status}.`, - ); + throw new Error(body.error || `Prepare failed: HTTP ${response.status}`); } - const blob = await response.blob(); - currentPdfUrl = URL.createObjectURL(blob); - pdf.src = currentPdfUrl; + const data = await response.json(); + const prepareMs = Math.round(performance.now() - startTime); + console.log( + `[BizMatch QC] PDF prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`, + ); + viewer.classList.add("loaded"); pdfMessage.hidden = true; + await pdfViewer.load(data.url, data.size); } catch (error) { const message = error instanceof Error ? error.message : String(error); pdfMessage.textContent = `Cannot open PDF: ${message}`; @@ -222,7 +227,6 @@ saveSettings.onclick = async (event) => { event.preventDefault(); saveSettings.disabled = true; settingsError.hidden = true; - try { const response = await fetch("/api/config", { method: "POST", @@ -247,31 +251,6 @@ saveSettings.onclick = async (event) => { } }; -let resizeTimer; -globalThis.addEventListener("resize", () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - fetch("/api/window-size", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - width: globalThis.innerWidth, - height: globalThis.innerHeight, - }), - }); - }, 300); -}); - -globalThis.addEventListener("beforeunload", () => { - navigator.sendBeacon( - "/api/window-size", - JSON.stringify({ - width: globalThis.innerWidth, - height: globalThis.innerHeight, - }), - ); -}); - async function load() { try { const response = await fetch("/api/state", { cache: "no-store" }); diff --git a/web/index.html b/web/index.html index 35a9fde..ea53fd1 100644 --- a/web/index.html +++ b/web/index.html @@ -5,6 +5,12 @@ BizMatch QC + +
@@ -22,7 +28,7 @@
- +
Select a document
@@ -30,15 +36,15 @@

Settings

+ + Use anonymized sample data + + + + +
diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js new file mode 100644 index 0000000..ba6eae1 --- /dev/null +++ b/web/pdf_viewer.js @@ -0,0 +1,287 @@ +// PDF.js viewer with memory LRU cache and lazy page rendering. +// Depends on global pdfjsLib (loaded via module import in index.html). +// Uses pdfjs-dist 6.1.200. + +const MAX_CACHED_DOCS = 3; +const MAX_CACHED_BYTES = 150 * 1024 * 1024; +const PRELOAD_PAGES_AHEAD = 2; +const PRELOAD_PAGES_BEHIND = 1; +const MAX_RENDER_SCALE = 2.0; + +class PdfViewer { + constructor(container) { + this.container = container; + this.lru = []; + this.currentEntry = null; + this.renderTasks = []; + this.observer = null; + this.currentDocId = 0; + this.loadingDiv = null; + this.errorDiv = null; + this.pagesDiv = null; + this._createDOM(); + } + + _createDOM() { + this.container.innerHTML = ` + + +
+ `; + this.loadingDiv = this.container.querySelector(".pdf-v-loading"); + this.errorDiv = this.container.querySelector(".pdf-v-error"); + this.pagesDiv = this.container.querySelector(".pdf-v-pages"); + } + + _showError(msg) { + this.loadingDiv.hidden = true; + this.errorDiv.textContent = msg; + this.errorDiv.hidden = false; + } + + _showLoading() { + this.errorDiv.hidden = true; + this.loadingDiv.hidden = false; + } + + _hideLoading() { + this.loadingDiv.hidden = true; + } + + async load(url, byteSize) { + const docId = ++this.currentDocId; + this._showLoading(); + this._cancelAllRenders(); + if (this.observer) this.observer.disconnect(); + + // Check memory LRU + const cached = this.lru.find((e) => e.url === url); + if (cached) { + cached.lastUsed = Date.now(); + this.lru = this.lru.filter((e) => e !== cached); + this.lru.push(cached); + this.currentEntry = cached; + this._renderDoc(docId, cached); + console.log("[BizMatch QC] PDF memory cache hit"); + return; + } + + const startLoad = performance.now(); + try { + const task = pdfjsLib.getDocument({ url, rangeChunkSize: 65536 }); + const doc = await task.promise; + const loadMs = Math.round(performance.now() - startLoad); + console.log( + `[BizMatch QC] PDF.js document loaded: ${loadMs}ms`, + ); + + const pageCount = doc.numPages; + const entry = { + url, + doc, + byteSize: byteSize || 0, + pageCount, + lastUsed: Date.now(), + }; + + this._evictIfNeeded(entry.byteSize); + this.lru.push(entry); + if (docId !== this.currentDocId) { + doc.destroy(); + return; + } + this.currentEntry = entry; + console.log("[BizMatch QC] PDF memory cache miss"); + this._renderDoc(docId, entry, startLoad); + } catch (err) { + if (docId !== this.currentDocId) return; + this._showError( + `Cannot open PDF: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + _evictIfNeeded(incomingBytes) { + let totalBytes = incomingBytes; + for (const e of this.lru) totalBytes += e.byteSize; + + while ( + this.lru.length > 0 && + (this.lru.length >= MAX_CACHED_DOCS || totalBytes > MAX_CACHED_BYTES) + ) { + const victim = this.lru.shift(); + totalBytes -= victim.byteSize; + try { + victim.doc.destroy(); + } catch { /* ok */ } + console.log( + "[BizMatch QC] PDF memory cache evicted: " + + victim.url.split("/").pop(), + ); + } + } + + _cancelAllRenders() { + for (const t of this.renderTasks) { + t.cancelled = true; + } + this.renderTasks = []; + } + + async _renderDoc(docId, entry, startLoad = 0) { + this.pagesDiv.innerHTML = ""; + this._hideLoading(); + + const pageCount = entry.pageCount; + const placeholderStyle = "pdf-v-placeholder"; + for (let i = 1; i <= pageCount; i++) { + const ph = document.createElement("div"); + ph.className = placeholderStyle; + ph.dataset.page = String(i); + // Pre-allocate aspect ratio from first page + this.pagesDiv.appendChild(ph); + } + + // Get first page dimensions for placeholder sizing + try { + const firstPage = await entry.doc.getPage(1); + const vp = firstPage.getViewport({ scale: 1 }); + const ratio = vp.height / vp.width; + const placeholders = this.pagesDiv.querySelectorAll( + `.${placeholderStyle}`, + ); + for (const ph of placeholders) { + // Will be sized in CSS via aspect-ratio; set data attrs for CSS + ph.dataset.ratio = String(ratio); + } + } catch { /* ignore */ } + + // Render first page immediately + this._renderPage(docId, entry, 1); + + // Set up lazy rendering + this.observer = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) { + const pageNum = parseInt(e.target.dataset.page); + if (pageNum) { + this._renderPage(docId, entry, pageNum); + // Pre-render nearby pages + for ( + let p = pageNum - PRELOAD_PAGES_BEHIND; + p <= pageNum + PRELOAD_PAGES_AHEAD; + p++ + ) { + if (p >= 1 && p <= pageCount && p !== pageNum) { + this._renderPage(docId, entry, p); + } + } + } + } + } + }, + { root: this.pagesDiv, rootMargin: "200px" }, + ); + + const placeholders = this.pagesDiv.querySelectorAll(`.${placeholderStyle}`); + for (const ph of placeholders) { + this.observer.observe(ph); + } + + if (startLoad) { + const readyMs = Math.round(performance.now() - startLoad); + console.log(`[BizMatch QC] PDF ready: ${readyMs}ms`); + } + } + + async _renderPage(docId, entry, pageNum) { + // Don't re-render if already rendered + const existing = this.pagesDiv.querySelector( + `.pdf-v-page[data-page="${pageNum}"]`, + ); + if (existing) return; + if (docId !== this.currentDocId) return; + + // Don't re-render if cancelled + const existingTask = this.renderTasks.find((t) => t.pageNum === pageNum); + if (existingTask) return; + + const placeholder = this.pagesDiv.querySelector( + `.pdf-v-placeholder[data-page="${pageNum}"]`, + ); + if (!placeholder) return; + + const renderTask = { pageNum, cancelled: false, promise: null }; + this.renderTasks.push(renderTask); + + const startRender = performance.now(); + try { + const page = await entry.doc.getPage(pageNum); + if (renderTask.cancelled || docId !== this.currentDocId) return; + + const containerWidth = this.pagesDiv.clientWidth || 600; + const vp1 = page.getViewport({ scale: 1 }); + const fitScale = containerWidth / vp1.width; + const dpr = Math.min(globalThis.devicePixelRatio || 1, MAX_RENDER_SCALE); + const scale = Math.min(fitScale * dpr, MAX_RENDER_SCALE); + const viewport = page.getViewport({ scale: fitScale }); + + const pageDiv = document.createElement("div"); + pageDiv.className = "pdf-v-page"; + pageDiv.dataset.page = String(pageNum); + pageDiv.style.width = `${viewport.width}px`; + pageDiv.style.height = `${viewport.height}px`; + + const canvas = document.createElement("canvas"); + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + canvas.width = Math.floor(viewport.width * (scale / fitScale)); + canvas.height = Math.floor(viewport.height * (scale / fitScale)); + pageDiv.appendChild(canvas); + + placeholder.replaceWith(pageDiv); + + const ctx = canvas.getContext("2d"); + if (ctx && !renderTask.cancelled && docId === this.currentDocId) { + await page.render({ canvasContext: ctx, viewport }).promise; + if (pageNum === 1) { + const renderMs = Math.round(performance.now() - startRender); + console.log( + `[BizMatch QC] First page rendered: ${renderMs}ms`, + ); + } + } + } catch (err) { + if (renderTask.cancelled) return; + placeholder.textContent = `Error rendering page ${pageNum}: ${ + errorMsg(err) + }`; + } finally { + this.renderTasks = this.renderTasks.filter((t) => t !== renderTask); + } + } + + destroy() { + this._cancelAllRenders(); + if (this.observer) this.observer.disconnect(); + this._evictAll(); + } + + _evictAll() { + for (const entry of this.lru) { + try { + entry.doc.destroy(); + } catch { /* ok */ } + } + this.lru = []; + this.currentEntry = null; + } +} + +function errorMsg(err) { + return err instanceof Error ? err.message : String(err); +} + +// Export global +globalThis.PdfViewer = PdfViewer; diff --git a/web/styles.css b/web/styles.css index 80bade8..c72c9b1 100644 --- a/web/styles.css +++ b/web/styles.css @@ -3,7 +3,7 @@ } body { margin: 0; - font: 14px system-ui,sans-serif; + font: 14px system-ui, sans-serif; color: #202124; } header { @@ -29,7 +29,7 @@ header span { main { height: calc(100vh - 52px); display: grid; - grid-template-columns: 330px 430px minmax(500px,1fr); + grid-template-columns: 330px 430px minmax(500px, 1fr); } aside, .details { @@ -77,12 +77,14 @@ aside, .viewer { position: relative; background: #555; + overflow: hidden; } -.viewer iframe { +.viewer #pdfViewer { width: 100%; height: 100%; - border: 0; - background: white; + overflow-y: auto; + overflow-x: hidden; + background: #525659; } .viewer #pdfMessage { position: absolute; @@ -91,12 +93,13 @@ aside, place-items: center; color: white; pointer-events: none; + z-index: 2; } .viewer.loaded #pdfMessage { display: none; } dialog { - width: min(720px,90vw); + width: min(720px, 90vw); } dialog label { display: block; @@ -185,3 +188,39 @@ hr.field-sep { width: auto; margin-top: 0; } + +/* PDF.js viewer */ +.pdf-v-loading { + padding: 40px 20px; + color: #aaa; + text-align: center; + font-size: 15px; +} +.pdf-v-error { + padding: 30px 20px; + color: #f88; + text-align: center; + white-space: pre-wrap; +} +.pdf-v-pages { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 10px 0; +} +.pdf-v-page { + background: white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + flex-shrink: 0; +} +.pdf-v-placeholder { + background: rgba(255, 255, 255, 0.05); + flex-shrink: 0; +} +.pdf-v-page canvas, +.pdf-v-placeholder canvas { + display: block; + width: 100%; + height: 100%; +}