pdf.js
This commit is contained in:
449
main.ts
449
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<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" },
|
||||
"/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<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() {
|
||||
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<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} 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<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.
|
||||
// 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<typeof setTimeout> | 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();
|
||||
|
||||
Reference in New Issue
Block a user