This commit is contained in:
2026-07-15 17:55:56 -05:00
parent 2925f8aa35
commit 741b38b4f6
7 changed files with 737 additions and 221 deletions

View File

@@ -4,7 +4,9 @@
"exports": "./main.ts", "exports": "./main.ts",
"imports": { "imports": {
"@std/assert": "jsr:@std/assert@^1", "@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": { "tasks": {
"dev": "deno desktop --hmr --backend cef --allow-read --allow-write --allow-env main.ts", "dev": "deno desktop --hmr --backend cef --allow-read --allow-write --allow-env main.ts",

86
deno.lock generated
View File

@@ -4,7 +4,9 @@
"jsr:@std/assert@1": "1.0.19", "jsr:@std/assert@1": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/internal@^1.0.14": "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": { "jsr": {
"@std/assert@1.0.19": { "@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": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@std/assert@1", "jsr:@std/assert@1",
"jsr:@std/path@1" "jsr:@std/path@1",
"npm:pdfjs-dist@^6.1.200"
] ]
} }
} }

425
main.ts
View File

@@ -14,10 +14,17 @@ import {
import { cacheOrGetPdf } from "./src/pdf_cache.ts"; import { cacheOrGetPdf } from "./src/pdf_cache.ts";
import INDEX_HTML from "./web/index.html" with { type: "text" }; import INDEX_HTML from "./web/index.html" with { type: "text" };
import APP_JS from "./web/app.js" 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 STYLES_CSS from "./web/styles.css" with { type: "text" };
import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with { import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with {
type: "json", 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]"; const PREFIX = "[BizMatch QC]";
@@ -110,14 +117,12 @@ async function loadDocumentsFromFile(
`Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`, `Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`,
); );
} }
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(raw); parsed = JSON.parse(raw);
} catch (error) { } catch (error) {
throw new Error(`Invalid JSON in "${jsonPath}": ${errorMessage(error)}`); throw new Error(`Invalid JSON in "${jsonPath}": ${errorMessage(error)}`);
} }
try { try {
return { documents: validateDocuments(parsed), source: "file" }; return { documents: validateDocuments(parsed), source: "file" };
} catch (error) { } catch (error) {
@@ -219,34 +224,101 @@ function json(data: unknown, status = 200) {
}); });
} }
function staticFile(pathname: string): Response { // ---- PDF token system ----
const files: Record<string, { body: string; type: string }> = { // A token stores a resolved cache path so that Range requests
"/": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, // (which PDF.js makes many of) hit the local disk cache directly
"/index.html": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, // without re-stat'ing or re-validating against NAS for each one.
"/app.js": { body: APP_JS, type: "text/javascript; charset=utf-8" },
"/styles.css": { body: STYLES_CSS, type: "text/css; charset=utf-8" }, interface PdfToken {
}; cachePath: string;
const file = files[pathname]; sourcePath: string;
if (!file) return new Response("Not found", { status: 404 }); fileSize: number;
return new Response(file.body, { stale: boolean;
headers: { "content-type": file.type, "cache-control": "no-store" }, createdAt: number;
}
const pdfTokens = new Map<string, PdfToken>();
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<Response> {
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() { // ------- HTTP Server -------
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)}`,
);
}
}
Deno.serve(async (request) => { Deno.serve(async (request) => {
const url = new URL(request.url); 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 { try {
const body = await request.json() as { width?: number; height?: number }; const body = await request.json() as { index?: number };
if (typeof body.width === "number" && typeof body.height === "number") { const index = body.index;
settings.windowWidth = Math.max(1100, Math.round(body.width)); const doc = documents[index!];
settings.windowHeight = Math.max(700, Math.round(body.height));
await saveAppSettings(settings);
console.log(
`${PREFIX} Window size saved (fallback): ${settings.windowWidth}x${settings.windowHeight}`,
);
}
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) { if (!Number.isInteger(index) || !doc) {
return json({ error: "Unknown document index." }, 404); return json({ error: "Unknown document index." }, 404);
} }
try {
const sourcePath = resolvePdfPath(settings.pdfBaseDirectory, doc); 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 cacheResult = await cacheOrGetPdf(sourcePath);
const cacheLookupMs = Math.round(performance.now() - startTotal);
let fileSize: number; let fileSize: number;
try { try {
fileSize = (await Deno.stat(cacheResult.path)).size; fileSize = (await Deno.stat(cacheResult.path)).size;
} catch { } 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, {
if (rangeHeader) { cachePath: cacheResult.path,
const match = rangeHeader.match(/^bytes=(\d+)-(\d*)$/); sourcePath,
if (match) { fileSize,
const rangeStart = parseInt(match[1], 10); stale: cacheResult.stale,
const rangeEnd = match[2] ? parseInt(match[2], 10) : fileSize - 1; createdAt: Date.now(),
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 }); const elapsed = Math.round(performance.now() - startTime);
await file.seek(rangeStart, Deno.SeekMode.Start); const status = cacheResult.stale ? "refresh" : "hit";
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( console.log(
`${PREFIX} PDF served (range ${rangeStart}-${rangeEnd}/${fileSize}): ${elapsed}ms, cache_lookup=${cacheLookupMs}ms`, `${PREFIX} PDF disk cache ${status} in ${elapsed}ms, token created`,
); );
return new Response(buf.slice(0, bytesRead), { return json({
status: 206, url: `/api/pdf/content/${token}`,
headers: { size: fileSize,
"content-type": "application/pdf", cacheStatus: status,
"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);
console.log(
`${PREFIX} PDF served (full ${fileSize}B): ${elapsed}ms, cache_lookup=${cacheLookupMs}ms`,
);
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" } : {}),
},
}); });
} catch (error) { } catch (error) {
const message = errorMessage(error); const message = errorMessage(error);
console.error( console.error(`${PREFIX} PDF prepare failed: ${message}`);
`${PREFIX} PDF open failed for "${doc.file_name}": ${message}`,
);
return json({ error: message }, 404); 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<string, { body: string; type: string }> = {
"/": { 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} Settings path: ${resolveSettingsPath()}`);
console.log(`${PREFIX} PDF cache directory: ${resolveCacheDir()}`); console.log(`${PREFIX} PDF cache directory: ${resolveCacheDir()}`);
console.log( console.log(
@@ -416,52 +499,77 @@ console.log(
}`, }`,
); );
console.log(`${PREFIX} Documents loaded: ${documents.length}`); console.log(`${PREFIX} Documents loaded: ${documents.length}`);
console.log(`${PREFIX} PDF.js version: 6.1.200`);
if (loadError) console.error(`${PREFIX} ${loadError}`); if (loadError) console.error(`${PREFIX} ${loadError}`);
// ------- Window management -------
const clamped = clampWindowSize(settings); const clamped = clampWindowSize(settings);
console.log( 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 Desktop API: use BrowserWindow constructor with width/height,
// Deno 2.9 type checker does not include desktop type declarations. // then verify with getSize() and apply setSize() if needed.
// The runtime `deno desktop` command also types this as a generic
// BrowserWindow<WindowBindings> 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.
type _Win = { type _Win = {
readonly width: number; getSize(): [number, number];
readonly height: number; setSize(w: number, h: number): void;
onresize: ((event: Event) => void) | null; addEventListener(
onclose: ((event: Event) => void) | null; type: string,
listener: (event: CustomEvent) => void,
): void;
show(): void; show(): void;
}; };
const _WinCtor = (Deno as unknown as { const _WinCtor = (Deno as unknown as {
BrowserWindow: new ( BrowserWindow: new (
opts?: { title?: string; width?: number; height?: number }, opts?: { title?: string; width?: number; height?: number },
) => _Win; ) => _Win;
}).BrowserWindow; }).BrowserWindow;
const win = new _WinCtor({ const win = new _WinCtor({
title: "BizMatch QC", title: "BizMatch QC",
width: clamped.windowWidth, width: clamped.windowWidth,
height: clamped.windowHeight, height: clamped.windowHeight,
}); });
const [actualW, actualH] = win.getSize();
console.log( 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<typeof setTimeout> | undefined; let resizeTimer: ReturnType<typeof setTimeout> | undefined;
try { try {
win.onresize = () => { win.addEventListener(
"resize",
((e: CustomEvent) => {
if (resizeTimer !== undefined) clearTimeout(resizeTimer); if (resizeTimer !== undefined) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { // Store immediately but debounce writes
const w = win.width; const w = e.detail?.width ?? 0;
const h = win.height; const h = e.detail?.height ?? 0;
if (w > 0 && h > 0) { if (w >= 1100 && h >= 700) {
settings.windowWidth = w; settings.windowWidth = w;
settings.windowHeight = h; settings.windowHeight = h;
console.log(`${PREFIX} Window resized: ${w}x${h}`); }
resizeTimer = setTimeout(() => {
if (settings.windowWidth >= 1100 && settings.windowHeight >= 700) {
console.log(
`${PREFIX} Window resized: ${settings.windowWidth}x${settings.windowHeight}`,
);
saveAppSettings(settings).catch((err) => saveAppSettings(settings).catch((err) =>
console.error( console.error(
`${PREFIX} Failed to save window size: ${errorMessage(err)}`, `${PREFIX} Failed to save window size: ${errorMessage(err)}`,
@@ -469,23 +577,36 @@ try {
); );
} }
}, 500); }, 500);
}; }) as (e: Event) => void,
);
win.onclose = () => { win.addEventListener(
"close",
(() => {
if (resizeTimer !== undefined) clearTimeout(resizeTimer); if (resizeTimer !== undefined) clearTimeout(resizeTimer);
const w = win.width; const [fw, fh] = win.getSize();
const h = win.height; if (fw >= 1100 && fh >= 700) {
if (w > 0 && h > 0) { settings.windowWidth = fw;
settings.windowWidth = w; settings.windowHeight = fh;
settings.windowHeight = h;
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 on close: ${settings.windowWidth}x${settings.windowHeight}`,
);
} catch (err) {
console.error(
`${PREFIX} Failed to save settings on close: ${errorMessage(err)}`,
);
}
}) as (e: Event) => void,
);
} catch (err) { } catch (err) {
console.warn( console.warn(
`${PREFIX} Native window-event binding unavailable: ${errorMessage(err)}`, `${PREFIX} Native window-event binding unavailable: ${errorMessage(err)}`,
); );
console.warn(`${PREFIX} Falling back to web-page resize tracking.`);
} }
win.show(); win.show();

View File

@@ -1,12 +1,12 @@
let state; let state;
let groups = []; let groups = [];
let selectedIndex = -1; let selectedIndex = -1;
let currentPdfUrl = ""; let pdfViewer = null;
const people = document.querySelector("#people"); const people = document.querySelector("#people");
const fields = document.querySelector("#fields"); const fields = document.querySelector("#fields");
const pdf = document.querySelector("#pdf");
const viewer = document.querySelector(".viewer"); const viewer = document.querySelector(".viewer");
const pdfContainer = document.querySelector("#pdfViewer");
const pdfMessage = document.querySelector("#pdfMessage"); const pdfMessage = document.querySelector("#pdfMessage");
const status = document.querySelector("#status"); const status = document.querySelector("#status");
const search = document.querySelector("#search"); const search = document.querySelector("#search");
@@ -23,6 +23,7 @@ const fieldDefs = [
null, null,
["Address", "address"], ["Address", "address"],
["State", "state"], ["State", "state"],
null,
["Businesses from Notes", "notes_business_raw"], ["Businesses from Notes", "notes_business_raw"],
["Types of Businesses", "types_of_business_raw"], ["Types of Businesses", "types_of_business_raw"],
["Background Experience", "background_experience"], ["Background Experience", "background_experience"],
@@ -111,30 +112,34 @@ function renderList() {
} }
async function loadPdf(index) { async function loadPdf(index) {
if (currentPdfUrl) { if (pdfViewer) {
URL.revokeObjectURL(currentPdfUrl); pdfViewer.destroy();
currentPdfUrl = "";
} }
pdf.removeAttribute("src"); pdfViewer = new PdfViewer(pdfContainer);
viewer.classList.remove("loaded"); viewer.classList.remove("loaded");
pdfMessage.hidden = false; pdfMessage.hidden = false;
pdfMessage.textContent = "Loading PDF\u2026"; pdfMessage.textContent = "Loading PDF\u2026";
const startTime = performance.now();
try { try {
const response = await fetch(`/api/pdf?index=${index}`, { const response = await fetch("/api/pdf/prepare", {
cache: "no-store", method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ index }),
}); });
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
throw new Error( throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
body.error || `PDF request failed with HTTP ${response.status}.`,
);
} }
const blob = await response.blob(); const data = await response.json();
currentPdfUrl = URL.createObjectURL(blob); const prepareMs = Math.round(performance.now() - startTime);
pdf.src = currentPdfUrl; console.log(
`[BizMatch QC] PDF prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
);
viewer.classList.add("loaded"); viewer.classList.add("loaded");
pdfMessage.hidden = true; pdfMessage.hidden = true;
await pdfViewer.load(data.url, data.size);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
pdfMessage.textContent = `Cannot open PDF: ${message}`; pdfMessage.textContent = `Cannot open PDF: ${message}`;
@@ -222,7 +227,6 @@ saveSettings.onclick = async (event) => {
event.preventDefault(); event.preventDefault();
saveSettings.disabled = true; saveSettings.disabled = true;
settingsError.hidden = true; settingsError.hidden = true;
try { try {
const response = await fetch("/api/config", { const response = await fetch("/api/config", {
method: "POST", 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() { async function load() {
try { try {
const response = await fetch("/api/state", { cache: "no-store" }); const response = await fetch("/api/state", { cache: "no-store" });

View File

@@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>BizMatch QC</title> <title>BizMatch QC</title>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
<script type="module">
import * as pdfjsLib from "/pdfjs/pdf.min.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/pdf.worker.min.mjs";
globalThis.pdfjsLib = pdfjsLib;
</script>
<script type="module" src="/pdf_viewer.js"></script>
</head> </head>
<body> <body>
<header> <header>
@@ -22,7 +28,7 @@
<div id="fields"></div> <div id="fields"></div>
</section> </section>
<section class="viewer"> <section class="viewer">
<iframe id="pdf" title="PDF document"></iframe> <div id="pdfViewer"></div>
<div id="pdfMessage">Select a document</div> <div id="pdfMessage">Select a document</div>
</section> </section>
</main> </main>

287
web/pdf_viewer.js Normal file
View File

@@ -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 = `
<div class="pdf-v-loading" hidden>Loading PDF\u2026</div>
<div class="pdf-v-error" hidden></div>
<div class="pdf-v-pages"></div>
`;
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;

View File

@@ -3,7 +3,7 @@
} }
body { body {
margin: 0; margin: 0;
font: 14px system-ui,sans-serif; font: 14px system-ui, sans-serif;
color: #202124; color: #202124;
} }
header { header {
@@ -29,7 +29,7 @@ header span {
main { main {
height: calc(100vh - 52px); height: calc(100vh - 52px);
display: grid; display: grid;
grid-template-columns: 330px 430px minmax(500px,1fr); grid-template-columns: 330px 430px minmax(500px, 1fr);
} }
aside, aside,
.details { .details {
@@ -77,12 +77,14 @@ aside,
.viewer { .viewer {
position: relative; position: relative;
background: #555; background: #555;
overflow: hidden;
} }
.viewer iframe { .viewer #pdfViewer {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 0; overflow-y: auto;
background: white; overflow-x: hidden;
background: #525659;
} }
.viewer #pdfMessage { .viewer #pdfMessage {
position: absolute; position: absolute;
@@ -91,12 +93,13 @@ aside,
place-items: center; place-items: center;
color: white; color: white;
pointer-events: none; pointer-events: none;
z-index: 2;
} }
.viewer.loaded #pdfMessage { .viewer.loaded #pdfMessage {
display: none; display: none;
} }
dialog { dialog {
width: min(720px,90vw); width: min(720px, 90vw);
} }
dialog label { dialog label {
display: block; display: block;
@@ -185,3 +188,39 @@ hr.field-sep {
width: auto; width: auto;
margin-top: 0; 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%;
}