Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20960f97c0 | |||
| 068debee16 | |||
| a60d03a3a7 | |||
| ba16f5fe06 |
@@ -2,9 +2,12 @@
|
|||||||
"name": "bizmatch-qc-desktop",
|
"name": "bizmatch-qc-desktop",
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"exports": "./main.ts",
|
"exports": "./main.ts",
|
||||||
|
"unstable": ["raw-imports"],
|
||||||
"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
86
deno.lock
generated
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
355
main.ts
355
main.ts
@@ -19,6 +19,153 @@ 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/legacy/build/pdf.min.mjs" with {
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
import PDFJS_WORKER_TEXT from "pdfjs-dist/legacy/build/pdf.worker.min.mjs" with {
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
// PDF.js >= 5 needs these auxiliary assets at runtime (fetched by the worker):
|
||||||
|
// - wasm/: CCITT-G4 + JBIG2 (B/W scans), JPEG2000 and qcms (ICC) decoders
|
||||||
|
// - standard_fonts/: base-14 font substitutes for PDFs without embedded fonts
|
||||||
|
// - iccs/: default CMYK ICC profile
|
||||||
|
// Embedded via import attributes so the compiled desktop binary stays self-contained.
|
||||||
|
import WASM_JBIG2 from "pdfjs-dist/wasm/jbig2.wasm" with { type: "bytes" };
|
||||||
|
import WASM_OPENJPEG from "pdfjs-dist/wasm/openjpeg.wasm" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import WASM_QCMS from "pdfjs-dist/wasm/qcms_bg.wasm" with { type: "bytes" };
|
||||||
|
import JS_JBIG2_FALLBACK from "pdfjs-dist/wasm/jbig2_nowasm_fallback.js" with {
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
import JS_OPENJPEG_FALLBACK from "pdfjs-dist/wasm/openjpeg_nowasm_fallback.js" with {
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
import ICC_CGATS from "pdfjs-dist/iccs/CGATS001Compat-v2-micro.icc" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_DINGBATS from "pdfjs-dist/standard_fonts/FoxitDingbats.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_FIXED from "pdfjs-dist/standard_fonts/FoxitFixed.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_FIXED_B from "pdfjs-dist/standard_fonts/FoxitFixedBold.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_FIXED_BI from "pdfjs-dist/standard_fonts/FoxitFixedBoldItalic.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_FIXED_I from "pdfjs-dist/standard_fonts/FoxitFixedItalic.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_SERIF from "pdfjs-dist/standard_fonts/FoxitSerif.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_SERIF_B from "pdfjs-dist/standard_fonts/FoxitSerifBold.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_SERIF_BI from "pdfjs-dist/standard_fonts/FoxitSerifBoldItalic.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_SERIF_I from "pdfjs-dist/standard_fonts/FoxitSerifItalic.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_SYMBOL from "pdfjs-dist/standard_fonts/FoxitSymbol.pfb" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_LSANS from "pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_LSANS_B from "pdfjs-dist/standard_fonts/LiberationSans-Bold.ttf" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_LSANS_BI from "pdfjs-dist/standard_fonts/LiberationSans-BoldItalic.ttf" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
import FONT_LSANS_I from "pdfjs-dist/standard_fonts/LiberationSans-Italic.ttf" with {
|
||||||
|
type: "bytes",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PDFJS_ASSETS: Record<
|
||||||
|
string,
|
||||||
|
{ body: Uint8Array | string; type: string }
|
||||||
|
> = {
|
||||||
|
"/pdfjs/wasm/jbig2.wasm": { body: WASM_JBIG2, type: "application/wasm" },
|
||||||
|
"/pdfjs/wasm/openjpeg.wasm": {
|
||||||
|
body: WASM_OPENJPEG,
|
||||||
|
type: "application/wasm",
|
||||||
|
},
|
||||||
|
"/pdfjs/wasm/qcms_bg.wasm": { body: WASM_QCMS, type: "application/wasm" },
|
||||||
|
"/pdfjs/wasm/jbig2_nowasm_fallback.js": {
|
||||||
|
body: JS_JBIG2_FALLBACK,
|
||||||
|
type: "text/javascript; charset=utf-8",
|
||||||
|
},
|
||||||
|
"/pdfjs/wasm/openjpeg_nowasm_fallback.js": {
|
||||||
|
body: JS_OPENJPEG_FALLBACK,
|
||||||
|
type: "text/javascript; charset=utf-8",
|
||||||
|
},
|
||||||
|
"/pdfjs/iccs/CGATS001Compat-v2-micro.icc": {
|
||||||
|
body: ICC_CGATS,
|
||||||
|
type: "application/vnd.iccprofile",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitDingbats.pfb": {
|
||||||
|
body: FONT_DINGBATS,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitFixed.pfb": {
|
||||||
|
body: FONT_FIXED,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitFixedBold.pfb": {
|
||||||
|
body: FONT_FIXED_B,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitFixedBoldItalic.pfb": {
|
||||||
|
body: FONT_FIXED_BI,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitFixedItalic.pfb": {
|
||||||
|
body: FONT_FIXED_I,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitSerif.pfb": {
|
||||||
|
body: FONT_SERIF,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitSerifBold.pfb": {
|
||||||
|
body: FONT_SERIF_B,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitSerifBoldItalic.pfb": {
|
||||||
|
body: FONT_SERIF_BI,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitSerifItalic.pfb": {
|
||||||
|
body: FONT_SERIF_I,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/FoxitSymbol.pfb": {
|
||||||
|
body: FONT_SYMBOL,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/LiberationSans-Regular.ttf": {
|
||||||
|
body: FONT_LSANS,
|
||||||
|
type: "font/ttf",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/LiberationSans-Bold.ttf": {
|
||||||
|
body: FONT_LSANS_B,
|
||||||
|
type: "font/ttf",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/LiberationSans-BoldItalic.ttf": {
|
||||||
|
body: FONT_LSANS_BI,
|
||||||
|
type: "font/ttf",
|
||||||
|
},
|
||||||
|
"/pdfjs/standard_fonts/LiberationSans-Italic.ttf": {
|
||||||
|
body: FONT_LSANS_I,
|
||||||
|
type: "font/ttf",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const PREFIX = "[BizMatch QC]";
|
const PREFIX = "[BizMatch QC]";
|
||||||
|
|
||||||
@@ -314,9 +461,57 @@ async function servePdfBytes(
|
|||||||
|
|
||||||
// ------- HTTP Server -------
|
// ------- HTTP Server -------
|
||||||
|
|
||||||
|
// ---- Webview-reported window size (fallback tracking) ----
|
||||||
|
// Both getSize() and (apparently) the native resize events are unreliable
|
||||||
|
// in the experimental desktop backend, so the frontend reports its viewport
|
||||||
|
// size via POST /api/window-metrics. The first report arrives ~800ms after
|
||||||
|
// startup, i.e. after the enforced setSize, and is used to calibrate the
|
||||||
|
// per-session offset between the outer window size and the webview viewport
|
||||||
|
// (titlebar + borders). Later reports are translated back to outer-window
|
||||||
|
// coordinates with that offset. Calibrating per session means any error
|
||||||
|
// cancels out and the window cannot shrink a little on every restart.
|
||||||
|
let webviewMetricsOffset: { dw: number; dh: number } | null = null;
|
||||||
|
let sizeSeenFromWebview = false;
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/api/window-metrics" && request.method === "POST") {
|
||||||
|
try {
|
||||||
|
const body = await request.json() as {
|
||||||
|
innerWidth?: number;
|
||||||
|
innerHeight?: number;
|
||||||
|
};
|
||||||
|
const iw = Math.round(Number(body.innerWidth) || 0);
|
||||||
|
const ih = Math.round(Number(body.innerHeight) || 0);
|
||||||
|
if (iw > 200 && ih > 200) {
|
||||||
|
if (webviewMetricsOffset === null) {
|
||||||
|
// First report: window is still at the enforced startup size,
|
||||||
|
// so the difference to the viewport is the decoration size.
|
||||||
|
webviewMetricsOffset = {
|
||||||
|
dw: Math.min(Math.max(clamped.windowWidth - iw, 0), 200),
|
||||||
|
dh: Math.min(Math.max(clamped.windowHeight - ih, 0), 200),
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
`${PREFIX} Window metrics calibrated: viewport ${iw}x${ih}, decoration offset ${webviewMetricsOffset.dw}x${webviewMetricsOffset.dh}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const w = iw + webviewMetricsOffset.dw;
|
||||||
|
const h = ih + webviewMetricsOffset.dh;
|
||||||
|
if (w >= 1100 && h >= 700) {
|
||||||
|
settings.windowWidth = w;
|
||||||
|
settings.windowHeight = h;
|
||||||
|
sizeSeenFromWebview = true;
|
||||||
|
console.log(`${PREFIX} Window size (from webview): ${w}x${h}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return json({ ok: false }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/state" && request.method === "GET") {
|
if (url.pathname === "/api/state" && request.method === "GET") {
|
||||||
const configForClient = {
|
const configForClient = {
|
||||||
jsonPath: settings.jsonPath,
|
jsonPath: settings.jsonPath,
|
||||||
@@ -348,30 +543,6 @@ Deno.serve(async (request) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Client-side log forwarding ----
|
|
||||||
if (url.pathname === "/api/client-log" && request.method === "POST") {
|
|
||||||
try {
|
|
||||||
const body = await request.json() as {
|
|
||||||
level?: string;
|
|
||||||
message?: string;
|
|
||||||
data?: unknown;
|
|
||||||
};
|
|
||||||
const level = body.level || "info";
|
|
||||||
const prefix = `${PREFIX} Client`;
|
|
||||||
if (level === "error") {
|
|
||||||
console.error(
|
|
||||||
`${prefix} [ERROR] ${body.message || ""}`,
|
|
||||||
body.data || "",
|
|
||||||
);
|
|
||||||
} else if (level === "warn") {
|
|
||||||
console.warn(`${prefix} [WARN] ${body.message || ""}`, body.data || "");
|
|
||||||
} else {
|
|
||||||
console.log(`${prefix} ${body.message || ""}`, body.data || "");
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- PDF prepare endpoint ----
|
// ---- PDF prepare endpoint ----
|
||||||
if (url.pathname === "/api/pdf/prepare" && request.method === "POST") {
|
if (url.pathname === "/api/pdf/prepare" && request.method === "POST") {
|
||||||
try {
|
try {
|
||||||
@@ -392,29 +563,9 @@ Deno.serve(async (request) => {
|
|||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const cacheResult = await cacheOrGetPdf(sourcePath);
|
const cacheResult = await cacheOrGetPdf(sourcePath);
|
||||||
|
|
||||||
// Validate cached file
|
|
||||||
let fileSize: number;
|
let fileSize: number;
|
||||||
try {
|
try {
|
||||||
fileSize = (await Deno.stat(cacheResult.path)).size;
|
fileSize = (await Deno.stat(cacheResult.path)).size;
|
||||||
const f = await Deno.open(cacheResult.path, { read: true });
|
|
||||||
const head = new Uint8Array(5);
|
|
||||||
let n = 0;
|
|
||||||
while (n < 5) {
|
|
||||||
const r = await f.read(head.subarray(n));
|
|
||||||
if (r === null) break;
|
|
||||||
n += r;
|
|
||||||
}
|
|
||||||
f.close();
|
|
||||||
const header = new TextDecoder().decode(head.slice(0, n));
|
|
||||||
if (header !== "%PDF-") {
|
|
||||||
console.warn(
|
|
||||||
`${PREFIX} Cached PDF invalid header: "${header}", re-caching`,
|
|
||||||
);
|
|
||||||
await Deno.remove(cacheResult.path).catch(() => {});
|
|
||||||
const fresh = await cacheOrGetPdf(sourcePath);
|
|
||||||
fileSize = (await Deno.stat(fresh.path)).size;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("Cached file is not readable.");
|
throw new Error("Cached file is not readable.");
|
||||||
}
|
}
|
||||||
@@ -490,6 +641,36 @@ Deno.serve(async (request) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PDF.js auxiliary assets (wasm decoders, standard fonts, ICC) ----
|
||||||
|
const pdfjsAsset = PDFJS_ASSETS[url.pathname];
|
||||||
|
if (pdfjsAsset) {
|
||||||
|
return new Response(pdfjsAsset.body as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
"content-type": pdfjsAsset.type,
|
||||||
|
"cache-control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PDF.js library assets ----
|
||||||
|
if (url.pathname === "/pdfjs/legacy/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/legacy/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 ----
|
// ---- Static files ----
|
||||||
const staticFiles: Record<string, { body: string; type: string }> = {
|
const staticFiles: Record<string, { body: string; type: string }> = {
|
||||||
"/": { body: INDEX_HTML, type: "text/html; charset=utf-8" },
|
"/": { body: INDEX_HTML, type: "text/html; charset=utf-8" },
|
||||||
@@ -522,6 +703,7 @@ 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 -------
|
// ------- Window management -------
|
||||||
@@ -575,17 +757,50 @@ if (
|
|||||||
|
|
||||||
// Native resize tracking using Deno 2.9 BrowserWindow events
|
// Native resize tracking using Deno 2.9 BrowserWindow events
|
||||||
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let sizeSeenFromEvent = false;
|
||||||
|
let resizeEventLogsLeft = 3;
|
||||||
try {
|
try {
|
||||||
win.addEventListener(
|
win.addEventListener(
|
||||||
"resize",
|
"resize",
|
||||||
((e: CustomEvent) => {
|
((e: CustomEvent) => {
|
||||||
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
||||||
// Store immediately but debounce writes
|
// The payload shape of the experimental backend is not settled;
|
||||||
const w = e.detail?.width ?? 0;
|
// accept detail.{width,height}, detail.{w,h}, detail as [w, h],
|
||||||
const h = e.detail?.height ?? 0;
|
// and width/height directly on the event object.
|
||||||
|
const anyEvent = e as unknown as Record<string, unknown>;
|
||||||
|
const detail = anyEvent.detail as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| number[]
|
||||||
|
| undefined;
|
||||||
|
let w = 0;
|
||||||
|
let h = 0;
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
w = Number(detail[0]) || 0;
|
||||||
|
h = Number(detail[1]) || 0;
|
||||||
|
} else if (detail && typeof detail === "object") {
|
||||||
|
w = Number(detail.width ?? detail.w) || 0;
|
||||||
|
h = Number(detail.height ?? detail.h) || 0;
|
||||||
|
}
|
||||||
|
if (!w) w = Number(anyEvent.width) || 0;
|
||||||
|
if (!h) h = Number(anyEvent.height) || 0;
|
||||||
|
|
||||||
|
// Log the first few raw events so we can see what the backend
|
||||||
|
// actually delivers (helps debug the unreliable size reporting).
|
||||||
|
if (resizeEventLogsLeft > 0) {
|
||||||
|
resizeEventLogsLeft--;
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`${PREFIX} Native resize event: parsed=${w}x${h}, detail=${
|
||||||
|
JSON.stringify(detail)
|
||||||
|
}, event.width/height=${anyEvent.width}/${anyEvent.height}`,
|
||||||
|
);
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}
|
||||||
|
|
||||||
if (w >= 1100 && h >= 700) {
|
if (w >= 1100 && h >= 700) {
|
||||||
settings.windowWidth = w;
|
settings.windowWidth = w;
|
||||||
settings.windowHeight = h;
|
settings.windowHeight = h;
|
||||||
|
sizeSeenFromEvent = true;
|
||||||
}
|
}
|
||||||
resizeTimer = setTimeout(() => {
|
resizeTimer = setTimeout(() => {
|
||||||
if (settings.windowWidth >= 1100 && settings.windowHeight >= 700) {
|
if (settings.windowWidth >= 1100 && settings.windowHeight >= 700) {
|
||||||
@@ -606,17 +821,28 @@ try {
|
|||||||
"close",
|
"close",
|
||||||
(() => {
|
(() => {
|
||||||
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
||||||
const [fw, fh] = win.getSize();
|
// Prefer sizes reported by native resize events or by the webview.
|
||||||
if (fw >= 1100 && fh >= 700) {
|
// getSize() has been observed to return a stale value (the last
|
||||||
settings.windowWidth = fw;
|
// programmatically requested size) instead of the actual window
|
||||||
settings.windowHeight = fh;
|
// size, which caused the same size to be saved on every close.
|
||||||
|
if (!sizeSeenFromEvent && !sizeSeenFromWebview) {
|
||||||
|
try {
|
||||||
|
const [fw, fh] = win.getSize();
|
||||||
|
if (fw >= 1100 && fh >= 700) {
|
||||||
|
settings.windowWidth = fw;
|
||||||
|
settings.windowHeight = fh;
|
||||||
|
}
|
||||||
|
} catch { /* ok */ }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const path = resolveSettingsPath();
|
const path = resolveSettingsPath();
|
||||||
Deno.mkdirSync(dirname(path), { recursive: true });
|
Deno.mkdirSync(dirname(path), { recursive: true });
|
||||||
Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2));
|
Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2));
|
||||||
|
const source = sizeSeenFromEvent
|
||||||
|
? "resize events"
|
||||||
|
: (sizeSeenFromWebview ? "webview" : "getSize()");
|
||||||
console.log(
|
console.log(
|
||||||
`${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight}`,
|
`${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight} (source: ${source})`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -632,3 +858,28 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
win.show();
|
win.show();
|
||||||
|
|
||||||
|
// Workaround: with the experimental desktop backend the first BrowserWindow
|
||||||
|
// "adopts" the implicit startup window, which is created before user code
|
||||||
|
// runs. Sometimes the native window keeps its built-in default size even
|
||||||
|
// though getSize() already reports the requested values, so the early
|
||||||
|
// setSize-on-mismatch above is skipped. Enforce the persisted size
|
||||||
|
// unconditionally after show(), and verify once more shortly after.
|
||||||
|
try {
|
||||||
|
win.setSize(clamped.windowWidth, clamped.windowHeight);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const [w, h] = win.getSize();
|
||||||
|
if (w !== clamped.windowWidth || h !== clamped.windowHeight) {
|
||||||
|
console.log(
|
||||||
|
`${PREFIX} Window size drifted to ${w}x${h}, enforcing ${clamped.windowWidth}x${clamped.windowHeight}`,
|
||||||
|
);
|
||||||
|
win.setSize(clamped.windowWidth, clamped.windowHeight);
|
||||||
|
}
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}, 250);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`${PREFIX} Post-show size enforcement failed: ${errorMessage(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
31
src/data.ts
31
src/data.ts
@@ -24,6 +24,17 @@ export function validateDocuments(value: unknown): BuyerDocument[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalized person key from the extracted buyer name: lowercase,
|
||||||
|
// punctuation stripped, tokens sorted for order-insensitive comparison.
|
||||||
|
function personKey(docs: BuyerDocument[]): string | null {
|
||||||
|
const name = docs.map((d) => d.prospective_buyer).find((v) => text(v));
|
||||||
|
if (!name) return null;
|
||||||
|
const tokens = String(name).toLocaleLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
return tokens.length ? tokens.join(" ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
|
export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
|
||||||
const groups = new Map<string, BuyerDocument[]>();
|
const groups = new Map<string, BuyerDocument[]>();
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
@@ -33,6 +44,24 @@ export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
|
|||||||
groups.set(key, bucket);
|
groups.set(key, bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge filename-based buckets that share the same prospective_buyer
|
||||||
|
// (catches typos in scan filenames, e.g. "Zaboor" vs "Zahoor").
|
||||||
|
const byPerson = new Map<string, string>(); // personKey -> bucket key
|
||||||
|
for (const [key, docs] of [...groups.entries()]) {
|
||||||
|
const pKey = personKey(docs);
|
||||||
|
if (!pKey) continue;
|
||||||
|
const existingKey = byPerson.get(pKey);
|
||||||
|
if (existingKey === undefined) {
|
||||||
|
byPerson.set(pKey, key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const target = groups.get(existingKey);
|
||||||
|
if (target) {
|
||||||
|
target.push(...docs);
|
||||||
|
groups.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [...groups.entries()]
|
return [...groups.entries()]
|
||||||
.map(([key, docs]) => {
|
.map(([key, docs]) => {
|
||||||
docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||||
@@ -66,4 +95,4 @@ export function filterGroups(
|
|||||||
return groups.filter((group) =>
|
return groups.filter((group) =>
|
||||||
terms.every((term) => group.searchText.includes(term))
|
terms.every((term) => group.searchText.includes(term))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
189
web/app.js
189
web/app.js
@@ -1,20 +1,19 @@
|
|||||||
let state;
|
let state;
|
||||||
let groups = [];
|
let groups = [];
|
||||||
let selectedIndex = -1;
|
let selectedIndex = -1;
|
||||||
let currentPdfKey = null;
|
let loadedIndex = -1; // which document the PDF viewer currently shows/loads
|
||||||
|
let pdfViewer = null;
|
||||||
let loadRequestId = 0;
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const people = document.querySelector("#people");
|
const people = document.querySelector("#people");
|
||||||
const fields = document.querySelector("#fields");
|
const fields = document.querySelector("#fields");
|
||||||
const viewerSection = document.querySelector(".viewer");
|
const viewer = document.querySelector(".viewer");
|
||||||
const pdfFrame = document.querySelector("#pdfFrame");
|
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");
|
||||||
const errorBanner = document.querySelector("#errorBanner");
|
const errorBanner = document.querySelector("#errorBanner");
|
||||||
|
|
||||||
const pdfViewer = new NativePdfViewer(pdfFrame, viewerSection);
|
|
||||||
|
|
||||||
const fieldDefs = [
|
const fieldDefs = [
|
||||||
["Name / Company", "name_company"],
|
["Name / Company", "name_company"],
|
||||||
["Prospective Buyer", "prospective_buyer"],
|
["Prospective Buyer", "prospective_buyer"],
|
||||||
@@ -52,6 +51,19 @@ const esc = (value) =>
|
|||||||
"'": "'",
|
"'": "'",
|
||||||
})[char]);
|
})[char]);
|
||||||
|
|
||||||
|
// Normalized person key from the vision-extracted buyer name: lowercase,
|
||||||
|
// punctuation stripped, name tokens sorted so "Zahoor Bilal" and
|
||||||
|
// "Bilal Zahoor" compare equal.
|
||||||
|
function personKey(group) {
|
||||||
|
const name = group.docs.map((d) => d.prospective_buyer).find((v) =>
|
||||||
|
typeof v === "string" && v.trim()
|
||||||
|
);
|
||||||
|
if (!name) return null;
|
||||||
|
const tokens = name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||||
|
.trim().split(/\s+/).filter(Boolean).sort();
|
||||||
|
return tokens.length ? tokens.join(" ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildGroups(docs) {
|
function buildGroups(docs) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
docs.forEach((doc, index) => {
|
docs.forEach((doc, index) => {
|
||||||
@@ -72,7 +84,33 @@ function buildGroups(docs) {
|
|||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
map.set(key, group);
|
map.set(key, group);
|
||||||
});
|
});
|
||||||
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
||||||
|
// Second pass: merge filename-based groups that refer to the same person
|
||||||
|
// according to the extracted prospective_buyer. This catches typos in the
|
||||||
|
// scan filenames (e.g. "Zaboor, Bilal" vs "Zahoor, Bilal") which would
|
||||||
|
// otherwise show the same buyer twice. Groups without a prospective_buyer
|
||||||
|
// are never merged.
|
||||||
|
const byPerson = new Map();
|
||||||
|
const merged = [];
|
||||||
|
for (const group of map.values()) {
|
||||||
|
const pKey = personKey(group);
|
||||||
|
const target = pKey ? byPerson.get(pKey) : undefined;
|
||||||
|
if (target) {
|
||||||
|
target.docs.push(...group.docs);
|
||||||
|
target.text += " " + group.text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pKey) byPerson.set(pKey, group);
|
||||||
|
merged.push(group);
|
||||||
|
}
|
||||||
|
for (const group of merged) {
|
||||||
|
group.docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||||
|
const preferred = group.docs.find((d) =>
|
||||||
|
typeof d.prospective_buyer === "string" && d.prospective_buyer.trim()
|
||||||
|
);
|
||||||
|
if (preferred) group.displayName = preferred.prospective_buyer;
|
||||||
|
}
|
||||||
|
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleGroups() {
|
function visibleGroups() {
|
||||||
@@ -114,23 +152,75 @@ function renderList() {
|
|||||||
updateStatus(shown.length);
|
updateStatus(shown.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDocument(index, { force = false } = {}) {
|
async function loadPdf(index) {
|
||||||
const doc = state.documents[index];
|
const doc = state.documents[index];
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
||||||
const key = `${doc._letter}/${doc.file_name}`;
|
// Deduplicate: don't reload the document that is already shown.
|
||||||
|
// NOTE: must compare against loadedIndex, NOT selectedIndex --
|
||||||
// Deduplicate: ignore click on already-loaded document unless forced
|
// select() updates selectedIndex before calling loadPdf(), so a
|
||||||
if (!force && key === currentPdfKey) {
|
// selectedIndex comparison is always true and blocks every reload.
|
||||||
|
if (index === loadedIndex && pdfViewer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loadedIndex = index;
|
||||||
|
|
||||||
// Mark as loading this document
|
const previousZoom = pdfViewer ? pdfViewer.zoom : 1;
|
||||||
currentPdfKey = key;
|
if (pdfViewer) {
|
||||||
|
pdfViewer.destroy();
|
||||||
|
}
|
||||||
|
pdfViewer = new PdfViewer(pdfContainer, { initialZoom: previousZoom });
|
||||||
|
viewer.classList.remove("loaded");
|
||||||
|
pdfMessage.hidden = false;
|
||||||
|
pdfMessage.textContent = "Loading PDF\u2026";
|
||||||
|
|
||||||
|
const reqId = "pdf-" + (++loadRequestId);
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF ${reqId} selection: ${doc._letter}/${doc.file_name}`,
|
||||||
|
);
|
||||||
|
console.log(`[BizMatch QC] PDF ${reqId} prepare requested`);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/pdf/prepare", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ index, requestId: reqId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const prepareMs = Math.round(performance.now() - startTime);
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF ${reqId} prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index !== selectedIndex) {
|
||||||
|
// A newer selection happened while preparing, don't show stale result
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer.classList.add("loaded");
|
||||||
|
pdfMessage.hidden = true;
|
||||||
|
console.log(`[BizMatch QC] PDF ${reqId} viewer load started`);
|
||||||
|
await pdfViewer.load(data.url, data.size);
|
||||||
|
console.log(`[BizMatch QC] PDF ${reqId} rendering complete`);
|
||||||
|
} catch (error) {
|
||||||
|
if (index !== selectedIndex) return;
|
||||||
|
loadedIndex = -1; // allow retry: clicking the same document again reloads it
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
||||||
|
pdfMessage.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(index) {
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
renderList();
|
const doc = state.documents[index];
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
// Update detail panel
|
|
||||||
let rowIdx = 0;
|
let rowIdx = 0;
|
||||||
fields.innerHTML = `
|
fields.innerHTML = `
|
||||||
<h2>${esc(doc.name_from_filename)}</h2>
|
<h2>${esc(doc.name_from_filename)}</h2>
|
||||||
@@ -158,49 +248,13 @@ async function openDocument(index, { force = false } = {}) {
|
|||||||
}).join("")
|
}).join("")
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
void loadPdf(index);
|
||||||
pdfMessage.hidden = false;
|
renderList();
|
||||||
pdfMessage.textContent = "Loading PDF\u2026";
|
|
||||||
|
|
||||||
const reqId = "pdf-" + (++loadRequestId);
|
|
||||||
console.log(`[BizMatch QC] Native PDF prepare: ${reqId} (${key})`);
|
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/pdf/prepare", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ index, requestId: reqId }),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
const prepareMs = Math.round(performance.now() - startTime);
|
|
||||||
console.log(
|
|
||||||
`[BizMatch QC] Native PDF prepare: ${prepareMs}ms, cache ${data.cacheStatus}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (key !== currentPdfKey) {
|
|
||||||
// A newer document was selected while preparing
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await pdfViewer.load(data.url, data.size, key);
|
|
||||||
pdfMessage.hidden = true;
|
|
||||||
console.log(`[BizMatch QC] Native PDF loaded: ${reqId}`);
|
|
||||||
} catch (error) {
|
|
||||||
if (key !== currentPdfKey) return;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
|
||||||
pdfMessage.hidden = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
people.addEventListener("click", (event) => {
|
people.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest("[data-index]");
|
const button = event.target.closest("[data-index]");
|
||||||
if (button) openDocument(Number(button.dataset.index));
|
if (button) select(Number(button.dataset.index));
|
||||||
});
|
});
|
||||||
|
|
||||||
search.addEventListener("input", renderList);
|
search.addEventListener("input", renderList);
|
||||||
@@ -210,7 +264,7 @@ document.addEventListener("keydown", (event) => {
|
|||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||||
openDocument(
|
select(
|
||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
@@ -273,13 +327,36 @@ async function load() {
|
|||||||
throw new Error(`State request failed with HTTP ${response.status}.`);
|
throw new Error(`State request failed with HTTP ${response.status}.`);
|
||||||
}
|
}
|
||||||
state = await response.json();
|
state = await response.json();
|
||||||
|
loadedIndex = -1; // document set changed; index-based dedup is invalid now
|
||||||
groups = buildGroups(state.documents);
|
groups = buildGroups(state.documents);
|
||||||
showError(state.loadError || "");
|
showError(state.loadError || "");
|
||||||
renderList();
|
renderList();
|
||||||
if (state.documents.length) openDocument(0);
|
if (state.documents.length) select(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error instanceof Error ? error.message : String(error));
|
showError(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
|
|
||||||
|
// ---- Window size reporting ----
|
||||||
|
// The backend's native size APIs are unreliable (see main.ts), so the
|
||||||
|
// webview reports its own viewport size. The first report, sent shortly
|
||||||
|
// after startup, calibrates the decoration offset on the backend; later
|
||||||
|
// reports track user resizes.
|
||||||
|
let metricsTimer = null;
|
||||||
|
function reportWindowMetrics() {
|
||||||
|
fetch("/api/window-metrics", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
innerWidth: globalThis.innerWidth,
|
||||||
|
innerHeight: globalThis.innerHeight,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
globalThis.addEventListener("resize", () => {
|
||||||
|
clearTimeout(metricsTimer);
|
||||||
|
metricsTimer = setTimeout(reportWindowMetrics, 250);
|
||||||
|
});
|
||||||
|
setTimeout(reportWindowMetrics, 800); // calibration report
|
||||||
@@ -5,6 +5,17 @@
|
|||||||
<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">
|
||||||
|
console.log(
|
||||||
|
"[BizMatch QC] Map.getOrInsertComputed supported:",
|
||||||
|
typeof Map.prototype.getOrInsertComputed === "function",
|
||||||
|
);
|
||||||
|
console.log("[BizMatch QC] PDF.js frontend build: legacy");
|
||||||
|
import * as pdfjsLib from "/pdfjs/legacy/pdf.min.mjs";
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/legacy/pdf.worker.min.mjs";
|
||||||
|
globalThis.pdfjsLib = pdfjsLib;
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/pdf_viewer.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -22,9 +33,8 @@
|
|||||||
<div id="fields"></div>
|
<div id="fields"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="viewer">
|
<section class="viewer">
|
||||||
<iframe id="pdfFrame" class="native-pdf-frame"
|
<div id="pdfViewer"></div>
|
||||||
title="PDF preview"></iframe>
|
<div id="pdfMessage">Select a document</div>
|
||||||
<div id="pdfMessage" class="native-pdf-overlay">Select a document</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<dialog id="settingsDialog">
|
<dialog id="settingsDialog">
|
||||||
@@ -48,7 +58,6 @@
|
|||||||
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
|
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script type="module" src="/pdf_viewer.js"></script>
|
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,91 +1,385 @@
|
|||||||
// Native Chromium PDF viewer using a single persistent iframe.
|
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
|
||||||
|
// Depends on global pdfjsLib (loaded in index.html).
|
||||||
|
|
||||||
class NativePdfViewer {
|
const ZOOM_MIN = 0.25;
|
||||||
constructor(frame, container) {
|
const ZOOM_MAX = 5;
|
||||||
this.frame = frame;
|
const ZOOM_STEP = 1.2; // multiplicative
|
||||||
|
|
||||||
|
class PdfViewer {
|
||||||
|
constructor(container, options = {}) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
this.currentKey = null;
|
this.loadingTask = null;
|
||||||
|
this.doc = null;
|
||||||
|
this.numPages = 0;
|
||||||
|
this.renderTasks = new Set();
|
||||||
this.lastUrl = null;
|
this.lastUrl = null;
|
||||||
this.loadTimer = null;
|
this.lastSize = null;
|
||||||
this._onLoad = null;
|
// multiplier on fit-to-width (1 = fit width); can be seeded from the
|
||||||
|
// previous viewer instance so zoom survives switching documents
|
||||||
|
this.zoom = Math.min(
|
||||||
|
ZOOM_MAX,
|
||||||
|
Math.max(ZOOM_MIN, options.initialZoom ?? 1),
|
||||||
|
);
|
||||||
|
this._lastRenderWidth = 0;
|
||||||
|
this._resizeTimer = null;
|
||||||
|
this._zoomTimer = null;
|
||||||
|
this.resizeObserver = null;
|
||||||
this._createDOM();
|
this._createDOM();
|
||||||
|
this._updateZoomLabel();
|
||||||
|
this._observeResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
_createDOM() {
|
_createDOM() {
|
||||||
this.loadingDiv = document.createElement("div");
|
// The pages container is always visible (never hidden).
|
||||||
this.loadingDiv.className = "native-pdf-loading";
|
// Loading and error states are overlays on top.
|
||||||
this.loadingDiv.textContent = "Loading PDF\u2026";
|
this.container.innerHTML = `
|
||||||
this.container.appendChild(this.loadingDiv);
|
<div class="pdf-v-toolbar" hidden>
|
||||||
|
<button class="pdf-v-zoom-out" title="Verkleinern (Strg+Mausrad)">\u2212</button>
|
||||||
|
<button class="pdf-v-zoom-label" title="Auf Seitenbreite einpassen">100%</button>
|
||||||
|
<button class="pdf-v-zoom-in" title="Vergr\u00f6\u00dfern (Strg+Mausrad)">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-v-pages"></div>
|
||||||
|
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
|
||||||
|
<div class="pdf-v-overlay pdf-v-error" hidden>
|
||||||
|
<div class="pdf-v-error-msg"></div>
|
||||||
|
<button class="pdf-v-retry">Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.toolbar = this.container.querySelector(".pdf-v-toolbar");
|
||||||
|
this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label");
|
||||||
|
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
|
||||||
|
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
|
||||||
|
this.errorDiv = this.container.querySelector(".pdf-v-error");
|
||||||
|
this.errorMsg = this.container.querySelector(".pdf-v-error-msg");
|
||||||
|
this.retryBtn = this.container.querySelector(".pdf-v-retry");
|
||||||
|
|
||||||
this.errorDiv = document.createElement("div");
|
|
||||||
//this.errorDiv.className = "native-pdf-error";
|
|
||||||
|
|
||||||
this.errorMsg = document.createElement("div");
|
|
||||||
this.errorMsg.className = "native-pdf-error-msg";
|
|
||||||
this.errorDiv.appendChild(this.errorMsg);
|
|
||||||
|
|
||||||
this.retryBtn = document.createElement("button");
|
|
||||||
this.retryBtn.className = "native-pdf-retry";
|
|
||||||
this.retryBtn.textContent = "Retry";
|
|
||||||
this.retryBtn.addEventListener("click", () => {
|
this.retryBtn.addEventListener("click", () => {
|
||||||
if (this.lastUrl && this.currentKey) {
|
if (this.lastUrl != null) {
|
||||||
this._navigate(this.lastUrl, this.currentKey, true);
|
this._setState("loading");
|
||||||
|
this._loadInternal(this.lastUrl, this.lastSize);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.errorDiv.appendChild(this.retryBtn);
|
|
||||||
this.container.appendChild(this.errorDiv);
|
this.container.querySelector(".pdf-v-zoom-in").addEventListener(
|
||||||
|
"click",
|
||||||
|
() => this.setZoom(this.zoom * ZOOM_STEP),
|
||||||
|
);
|
||||||
|
this.container.querySelector(".pdf-v-zoom-out").addEventListener(
|
||||||
|
"click",
|
||||||
|
() => this.setZoom(this.zoom / ZOOM_STEP),
|
||||||
|
);
|
||||||
|
// Clicking the percentage resets to fit-width.
|
||||||
|
this.zoomLabel.addEventListener("click", () => this.setZoom(1));
|
||||||
|
|
||||||
|
// Ctrl + mouse wheel zoom, like in browsers / PDF readers.
|
||||||
|
this.pagesDiv.addEventListener("wheel", (e) => {
|
||||||
|
if (!e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
|
||||||
|
}, { passive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
_showState(state) {
|
// ---- Zoom ----
|
||||||
this.loadingDiv.classList.toggle("is-visible", state === "loading");
|
|
||||||
this.errorDiv.classList.toggle("is-visible", state === "error");
|
setZoom(zoom) {
|
||||||
|
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
|
||||||
|
if (Math.abs(clamped - this.zoom) < 0.001) return;
|
||||||
|
this.zoom = clamped;
|
||||||
|
this._updateZoomLabel();
|
||||||
|
if (!this.doc) return;
|
||||||
|
// Debounce so repeated +/+/+ or wheel ticks trigger one re-render.
|
||||||
|
clearTimeout(this._zoomTimer);
|
||||||
|
this._zoomTimer = setTimeout(() => this._rerender(), 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
load(url, _byteSize, key) {
|
_updateZoomLabel() {
|
||||||
this._navigate(url, key, false);
|
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_navigate(url, key, force) {
|
// ---- Resize ----
|
||||||
if (!force && key && key === this.currentKey) return;
|
|
||||||
|
|
||||||
|
_observeResize() {
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!this.doc) return;
|
||||||
|
const width = this._renderWidth();
|
||||||
|
if (Math.abs(width - this._lastRenderWidth) < 2) return;
|
||||||
|
clearTimeout(this._resizeTimer);
|
||||||
|
this._resizeTimer = setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF viewer resized, re-rendering at ${this._renderWidth()}px`,
|
||||||
|
);
|
||||||
|
this._rerender();
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
this.resizeObserver.observe(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderWidth() {
|
||||||
|
return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render all pages of the already-loaded document (zoom / resize),
|
||||||
|
// preserving the relative scroll position.
|
||||||
|
async _rerender() {
|
||||||
|
if (!this.doc) return;
|
||||||
const generation = ++this.generation;
|
const generation = ++this.generation;
|
||||||
this.currentKey = key;
|
|
||||||
|
for (const rt of this.renderTasks) {
|
||||||
|
try {
|
||||||
|
rt.cancel();
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}
|
||||||
|
this.renderTasks.clear();
|
||||||
|
|
||||||
|
const scrollRatio = this.pagesDiv.scrollHeight > 0
|
||||||
|
? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await this._renderAllPages(this.doc, generation, false);
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Loading ----
|
||||||
|
|
||||||
|
async load(url, byteSize) {
|
||||||
this.lastUrl = url;
|
this.lastUrl = url;
|
||||||
|
this.lastSize = byteSize;
|
||||||
|
this._setState("loading");
|
||||||
|
await this._loadInternal(url, byteSize);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear old error text, show loading badge
|
async _loadInternal(url, _byteSize) {
|
||||||
this.errorMsg.textContent = "";
|
const generation = ++this.generation;
|
||||||
this._showState("loading");
|
console.log(
|
||||||
|
`[BizMatch QC] PDF viewer load started: generation ${generation}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Shared hide function, idempotent per navigation
|
// Await cleanup of previous document (promises may be involved)
|
||||||
const hideLoading = () => {
|
await this._disposeCurrentDocument();
|
||||||
if (generation !== this.generation) return;
|
if (generation !== this.generation) return;
|
||||||
if (this.loadTimer) {
|
|
||||||
clearTimeout(this.loadTimer);
|
this.pagesDiv.innerHTML = "";
|
||||||
this.loadTimer = null;
|
|
||||||
|
const loadStart = performance.now();
|
||||||
|
try {
|
||||||
|
const task = pdfjsLib.getDocument({
|
||||||
|
url,
|
||||||
|
rangeChunkSize: 65536,
|
||||||
|
disableAutoFetch: false,
|
||||||
|
// PDF.js >= 5 decodes CCITT-G4/JBIG2 (B/W scans), JPEG2000 and ICC
|
||||||
|
// color via WASM modules fetched from wasmUrl. Without these URLs
|
||||||
|
// the decoders fail silently (ignoreErrors default) and scanned
|
||||||
|
// pages render as blank white canvases of the correct size.
|
||||||
|
wasmUrl: "/pdfjs/wasm/",
|
||||||
|
standardFontDataUrl: "/pdfjs/standard_fonts/",
|
||||||
|
iccUrl: "/pdfjs/iccs/",
|
||||||
|
});
|
||||||
|
this.loadingTask = task;
|
||||||
|
|
||||||
|
const doc = await task.promise;
|
||||||
|
if (generation !== this.generation) {
|
||||||
|
doc.destroy();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this._showState("loaded");
|
this.doc = doc;
|
||||||
};
|
this.numPages = doc.numPages;
|
||||||
|
|
||||||
// Optional early signal from iframe load event
|
const loadMs = Math.round(performance.now() - loadStart);
|
||||||
if (this._onLoad) this.frame.removeEventListener("load", this._onLoad);
|
console.log(
|
||||||
this._onLoad = hideLoading;
|
`[BizMatch QC] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`,
|
||||||
this.frame.addEventListener("load", hideLoading, { once: true });
|
);
|
||||||
|
|
||||||
// Fallback: hide loading after 800ms regardless of load event
|
this.toolbar.hidden = false;
|
||||||
if (this.loadTimer) clearTimeout(this.loadTimer);
|
this._updateZoomLabel();
|
||||||
this.loadTimer = setTimeout(hideLoading, 800);
|
|
||||||
|
|
||||||
this.frame.src = url;
|
await this._renderAllPages(doc, generation, true);
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
|
||||||
|
const totalMs = Math.round(performance.now() - loadStart);
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
// Distinguish cancellation from real errors
|
||||||
|
if (isCancellationError(err)) {
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF generation ${generation} cancelled`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = errorMsg(err);
|
||||||
|
console.error(`[BizMatch QC] PDF load failed:`, msg);
|
||||||
|
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
|
||||||
|
this._setState("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared render loop for initial load, zoom and resize.
|
||||||
|
// hideLoadingAfterFirstPage: true on initial load (removes the overlay
|
||||||
|
// as soon as page 1 is visible).
|
||||||
|
async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) {
|
||||||
|
this.pagesDiv.innerHTML = "";
|
||||||
|
|
||||||
|
const viewerWidth = this._renderWidth();
|
||||||
|
this._lastRenderWidth = viewerWidth;
|
||||||
|
console.log(`[BizMatch QC] PDF render width: ${viewerWidth}px`);
|
||||||
|
|
||||||
|
for (let p = 1; p <= this.numPages; p++) {
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
const pStart = performance.now();
|
||||||
|
await this._renderPage(doc, p, viewerWidth, generation);
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
if (p === 1 && hideLoadingAfterFirstPage) {
|
||||||
|
this._setState(null); // Remove loading overlay
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF page ${p} rendered in ${
|
||||||
|
Math.round(performance.now() - pStart)
|
||||||
|
}ms`,
|
||||||
|
);
|
||||||
|
await new Promise((r) => requestAnimationFrame(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setState(state) {
|
||||||
|
this.loadingDiv.hidden = true;
|
||||||
|
this.errorDiv.hidden = true;
|
||||||
|
if (state === "loading") this.loadingDiv.hidden = false;
|
||||||
|
if (state === "error") this.errorDiv.hidden = false;
|
||||||
|
this._viewerState = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getViewportWidth() {
|
||||||
|
const style = globalThis.getComputedStyle
|
||||||
|
? getComputedStyle(this.pagesDiv)
|
||||||
|
: null;
|
||||||
|
let padding = 24; // default guess
|
||||||
|
if (style) {
|
||||||
|
padding = (parseFloat(style.paddingLeft) || 0) +
|
||||||
|
(parseFloat(style.paddingRight) || 0);
|
||||||
|
}
|
||||||
|
const raw = Math.round(this.pagesDiv.clientWidth || 600);
|
||||||
|
return Math.max(raw - padding, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _disposeCurrentDocument() {
|
||||||
|
for (const rt of this.renderTasks) {
|
||||||
|
try {
|
||||||
|
rt.cancel();
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}
|
||||||
|
this.renderTasks.clear();
|
||||||
|
|
||||||
|
if (this.loadingTask) {
|
||||||
|
try {
|
||||||
|
await this.loadingTask.destroy();
|
||||||
|
} catch { /* ok */ }
|
||||||
|
this.loadingTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.doc) {
|
||||||
|
try {
|
||||||
|
await this.doc.destroy();
|
||||||
|
} catch { /* ok */ }
|
||||||
|
this.doc = null;
|
||||||
|
this.numPages = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _renderPage(doc, pageNum, viewerWidth, generation) {
|
||||||
|
const pageDiv = document.createElement("div");
|
||||||
|
pageDiv.className = "pdf-v-page";
|
||||||
|
pageDiv.dataset.page = String(pageNum);
|
||||||
|
pageDiv.dataset.renderState = "idle";
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
pageDiv.appendChild(canvas);
|
||||||
|
this.pagesDiv.appendChild(pageDiv);
|
||||||
|
|
||||||
|
pageDiv.dataset.renderState = "rendering";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await doc.getPage(pageNum);
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
|
||||||
|
const vp1 = page.getViewport({ scale: 1 });
|
||||||
|
const fitScale = viewerWidth / vp1.width;
|
||||||
|
const viewport = page.getViewport({ scale: fitScale });
|
||||||
|
|
||||||
|
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||||
|
canvas.width = Math.floor(viewport.width * dpr);
|
||||||
|
canvas.height = Math.floor(viewport.height * dpr);
|
||||||
|
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
|
||||||
|
|
||||||
|
const renderTask = page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport,
|
||||||
|
transform,
|
||||||
|
});
|
||||||
|
this.renderTasks.add(renderTask);
|
||||||
|
|
||||||
|
await renderTask.promise;
|
||||||
|
this.renderTasks.delete(renderTask);
|
||||||
|
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
pageDiv.dataset.renderState = "rendered";
|
||||||
|
} catch (err) {
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
if (isCancellationError(err)) return;
|
||||||
|
pageDiv.dataset.renderState = "error";
|
||||||
|
console.error(
|
||||||
|
`[BizMatch QC] Failed to render PDF page ${pageNum}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
if (pageNum === 1) {
|
||||||
|
this.errorMsg.textContent = `Unable to open PDF: ${errorMsg(err)}`;
|
||||||
|
this._setState("error");
|
||||||
|
} else {
|
||||||
|
pageDiv.textContent = `Error rendering page ${pageNum}`;
|
||||||
|
pageDiv.style.padding = "20px";
|
||||||
|
pageDiv.style.color = "#f88";
|
||||||
|
pageDiv.style.textAlign = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.loadTimer) clearTimeout(this.loadTimer);
|
|
||||||
if (this._onLoad) this.frame.removeEventListener("load", this._onLoad);
|
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
this.frame.src = "";
|
clearTimeout(this._resizeTimer);
|
||||||
this._showState("loading");
|
clearTimeout(this._zoomTimer);
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
}
|
||||||
|
this._disposeCurrentDocument().catch(() => {});
|
||||||
|
this.pagesDiv.innerHTML = "";
|
||||||
|
this.toolbar.hidden = true;
|
||||||
|
this._setState("loading");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.NativePdfViewer = NativePdfViewer;
|
function isCancellationError(err) {
|
||||||
|
if (!(err instanceof Error)) return false;
|
||||||
|
const msg = err.message || "";
|
||||||
|
return msg.includes("cancelled") ||
|
||||||
|
msg.includes("canceled") ||
|
||||||
|
msg.includes("destroyed") ||
|
||||||
|
msg.includes("Worker was destroyed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMsg(err) {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.PdfViewer = PdfViewer;
|
||||||
175
web/styles.css
175
web/styles.css
@@ -79,15 +79,15 @@ aside,
|
|||||||
background: #555;
|
background: #555;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.native-pdf-frame {
|
.viewer #pdfViewer {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0;
|
overflow-y: auto;
|
||||||
display: block;
|
overflow-x: hidden;
|
||||||
|
background: #525659;
|
||||||
}
|
}
|
||||||
.native-pdf-overlay {
|
.viewer #pdfMessage {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -95,77 +95,9 @@ aside,
|
|||||||
color: white;
|
color: white;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
white-space: pre-wrap;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
.native-pdf-loading {
|
.viewer.loaded #pdfMessage {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.native-pdf-loading.is-visible {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.72);
|
|
||||||
color: #ddd;
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.native-pdf-loading.is-visible::before {
|
|
||||||
content: "";
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pdf-spin 0.7s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
@keyframes pdf-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.native-pdf-error {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
.native-pdf-error.is-visible {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(50, 50, 50, 0.9);
|
|
||||||
text-align: center;
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
.native-pdf-error-msg {
|
|
||||||
color: #f88;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.native-pdf-retry {
|
|
||||||
padding: 8px 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #888;
|
|
||||||
background: #444;
|
|
||||||
color: #eee;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.native-pdf-retry:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
}
|
||||||
dialog {
|
dialog {
|
||||||
width: min(720px, 90vw);
|
width: min(720px, 90vw);
|
||||||
@@ -195,6 +127,7 @@ dialog input {
|
|||||||
.muted {
|
.muted {
|
||||||
color: #777;
|
color: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background: #fff1f1;
|
background: #fff1f1;
|
||||||
@@ -256,3 +189,95 @@ hr.field-sep {
|
|||||||
width: auto;
|
width: auto;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PDF.js viewer */
|
||||||
|
.pdf-v-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.pdf-v-loading {
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #aaa;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.pdf-v-error {
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pdf-v-error-msg {
|
||||||
|
color: #f88;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.pdf-v-retry {
|
||||||
|
padding: 8px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #888;
|
||||||
|
background: #444;
|
||||||
|
color: #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pdf-v-retry:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
.pdf-v-pages {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.pdf-v-page {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pdf-v-page canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom toolbar */
|
||||||
|
.pdf-v-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(35, 35, 38, 0.88);
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 4px;
|
||||||
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.pdf-v-toolbar button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 5px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pdf-v-toolbar button:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
.pdf-v-zoom-label {
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #ccc !important;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user