openreader/compute-worker/src/compute/pdf/model.ts
Richard R a56aaa2be9 Refactor PDF parsing and rendering logic; migrate to compute-worker protocol
- Removed deprecated functions related to document parsing and blob storage in blobstore.ts.
- Introduced new PDF rendering logic in pdf-preview-renderer.ts and pdf-preview-pdfjs-runtime.ts.
- Updated previews-render.ts to utilize the new PDF rendering functions.
- Refactored user-whisper-align-job.ts to use the compute-worker client for alignment requests.
- Enhanced artifact.ts and operation.ts to validate parsed PDF artifacts and resolve current PDF parses.
- Updated snapshot.ts to align with new worker operation types.
- Adjusted runtime-config.ts to check for compute-worker availability.
- Modified types in parsed-pdf.ts and tts.ts to reflect changes in the compute-worker protocol.
- Added unit tests for PDF artifact validation and compute-worker client contract.
- Removed obsolete pdf-op-key.vitest.spec.ts test file.
2026-06-12 13:43:33 -06:00

147 lines
5.7 KiB
TypeScript

import path from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises';
import { DOCSTORE_DIR } from '../platform/docstore';
const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main';
const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL';
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model');
const MANIFEST_PATH = path.join(MODULE_DIR, 'assets', 'manifest.json');
export const MODEL_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx');
export const MODEL_DATA_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx.data');
export const MODEL_CONFIG_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.config.json');
export const MODEL_PREPROCESSOR_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.preprocessor_config.json');
const LICENSE_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.LICENSE.txt');
const STATIC_LICENSE_PATH = path.join(MODULE_DIR, 'assets', 'LICENSE.txt');
type ManifestEntry = {
path: string;
sha256?: string;
size?: number;
};
function loadManifest(): { files: ManifestEntry[] } {
const manifestText = readFileSync(MANIFEST_PATH, 'utf8');
const parsed = JSON.parse(manifestText) as { files?: ManifestEntry[] };
return { files: Array.isArray(parsed.files) ? parsed.files : [] };
}
const manifest = loadManifest();
let inflight: Promise<string> | null = null;
async function sha256Hex(filePath: string): Promise<string> {
const bytes = await readFile(filePath);
return createHash('sha256').update(bytes).digest('hex');
}
async function downloadToFile(url: string, outPath: string): Promise<void> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`);
}
const bytes = new Uint8Array(await res.arrayBuffer());
await writeFile(outPath, bytes);
}
function joinModelUrl(baseUrl: string, relativePath: string): string {
return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`;
}
function manifestEntry(filePath: string): { sha256: string; size: number } | null {
const found = manifest.files.find((entry) => entry.path === filePath);
if (!found || !found.sha256) return null;
return {
sha256: found.sha256.toLowerCase(),
size: Number(found.size),
};
}
async function verifyFile(pathToFile: string, manifestPath: string): Promise<boolean> {
const expected = manifestEntry(manifestPath);
if (!expected) return true;
const bytes = await readFile(pathToFile);
if (Number.isFinite(expected.size) && expected.size > 0 && bytes.byteLength !== expected.size) {
return false;
}
const actual = await sha256Hex(pathToFile);
return actual === expected.sha256;
}
async function ensureLicense(): Promise<void> {
await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH);
if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) {
throw new Error('PDF layout model license checksum verification failed');
}
}
async function ensureModelInternal(): Promise<string> {
try {
await access(MODEL_PATH);
await access(MODEL_DATA_PATH);
await access(MODEL_CONFIG_PATH);
await access(MODEL_PREPROCESSOR_PATH);
if (
await verifyFile(MODEL_PATH, 'model.onnx')
&& await verifyFile(MODEL_DATA_PATH, 'model.onnx.data')
&& await verifyFile(MODEL_CONFIG_PATH, 'config.json')
&& await verifyFile(MODEL_PREPROCESSOR_PATH, 'preprocessor_config.json')
) {
await ensureLicense();
return MODEL_PATH;
}
} catch {
// continue
}
await mkdir(MODEL_DIR, { recursive: true });
const modelTmpPath = `${MODEL_PATH}.tmp`;
const modelDataTmpPath = `${MODEL_DATA_PATH}.tmp`;
const configTmpPath = `${MODEL_CONFIG_PATH}.tmp`;
const preprocessorTmpPath = `${MODEL_PREPROCESSOR_PATH}.tmp`;
const modelBaseUrl = process.env[PDF_LAYOUT_MODEL_BASE_URL_ENV]?.trim()
|| DEFAULT_MODEL_BASE_URL;
const modelUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx');
const modelDataUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx.data');
const configUrl = joinModelUrl(modelBaseUrl, 'config.json');
const preprocessorUrl = joinModelUrl(modelBaseUrl, 'preprocessor_config.json');
await downloadToFile(modelUrl, modelTmpPath);
if (!(await verifyFile(modelTmpPath, 'model.onnx'))) {
await unlink(modelTmpPath).catch(() => undefined);
throw new Error('PDF layout model checksum verification failed');
}
await downloadToFile(modelDataUrl, modelDataTmpPath);
if (!(await verifyFile(modelDataTmpPath, 'model.onnx.data'))) {
await unlink(modelDataTmpPath).catch(() => undefined);
throw new Error('PDF layout model external data checksum verification failed');
}
await downloadToFile(configUrl, configTmpPath);
if (!(await verifyFile(configTmpPath, 'config.json'))) {
await unlink(configTmpPath).catch(() => undefined);
throw new Error('PDF layout model config checksum verification failed');
}
await downloadToFile(preprocessorUrl, preprocessorTmpPath);
if (!(await verifyFile(preprocessorTmpPath, 'preprocessor_config.json'))) {
await unlink(preprocessorTmpPath).catch(() => undefined);
throw new Error('PDF layout model preprocessor checksum verification failed');
}
await rename(modelTmpPath, MODEL_PATH);
await rename(modelDataTmpPath, MODEL_DATA_PATH);
await rename(configTmpPath, MODEL_CONFIG_PATH);
await rename(preprocessorTmpPath, MODEL_PREPROCESSOR_PATH);
await ensureLicense();
return MODEL_PATH;
}
export async function ensureModel(): Promise<string> {
if (inflight) return inflight;
inflight = ensureModelInternal().finally(() => {
inflight = null;
});
return inflight;
}