import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { createHash } from "node:crypto"; import { createReadStream, promises as fs } from "node:fs"; import { join, dirname } from "node:path"; import { STORAGE_DIR } from "@/lib/env"; const run = promisify(execFile); const BIN = (name: string) => process.env.POPPLER_BIN ? join(process.env.POPPLER_BIN, name) : name; const PATH_ENV = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH ?? ""}` }; export async function sha256File(path: string): Promise { return new Promise((res, rej) => { const h = createHash("sha256"); createReadStream(path).on("data", (d) => h.update(d)).on("end", () => res(h.digest("hex"))).on("error", rej); }); } export type PdfInfo = { pages: number | null; title: string | null; author: string | null; subject: string | null; keywords: string | null; creator: string | null; producer: string | null; creationDate: string | null; modDate: string | null; pdfVersion: string | null; encrypted: boolean; pageSize: string | null; raw: Record }; export async function pdfInfo(path: string): Promise { const { stdout } = await run(BIN("pdfinfo"), [path], { env: PATH_ENV, maxBuffer: 1 << 20 }); const raw: Record = {}; for (const line of stdout.split("\n")) { const i = line.indexOf(":"); if (i > 0) raw[line.slice(0, i).trim()] = line.slice(i + 1).trim(); } const g = (k: string) => raw[k] ?? null; return { pages: raw.Pages ? Number(raw.Pages) : null, title: g("Title"), author: g("Author"), subject: g("Subject"), keywords: g("Keywords"), creator: g("Creator"), producer: g("Producer"), creationDate: g("CreationDate"), modDate: g("ModDate"), pdfVersion: g("PDF version"), encrypted: (raw.Encrypted ?? "no").toLowerCase() !== "no", pageSize: g("Page size"), raw }; } /** Integrity: the file must parse, report ≥ 1 page, not be encrypted, and the first page must yield text (or be flagged scanned). */ export async function pdfIntegrity(path: string): Promise<{ ok: boolean; reason?: string; info?: PdfInfo }> { try { const info = await pdfInfo(path); if (!info.pages || info.pages < 1) return { ok: false, reason: "no pages", info }; if (info.encrypted) return { ok: false, reason: "encrypted", info }; return { ok: true, info }; } catch (e) { return { ok: false, reason: (e as Error).message.slice(0, 200) }; } } export async function pdfText(path: string, opts: { first?: number; last?: number } = {}): Promise { const args = ["-layout", "-enc", "UTF-8"]; if (opts.first) args.push("-f", String(opts.first)); if (opts.last) args.push("-l", String(opts.last)); args.push(path, "-"); const { stdout } = await run(BIN("pdftotext"), args, { env: PATH_ENV, maxBuffer: 64 << 20 }); return stdout; } /** Full text split per page (form-feed separated by pdftotext). */ export async function pdfTextPages(path: string): Promise { const t = await pdfText(path); return t.split("\f").map((p) => p.replace(/[ \t]+\n/g, "\n").trim()); } export async function pdfThumbnail(path: string, outPngBase: string, dpi = 50): Promise { await fs.mkdir(dirname(outPngBase), { recursive: true }); await run(BIN("pdftoppm"), ["-png", "-r", String(dpi), "-f", "1", "-l", "1", "-singlefile", path, outPngBase], { env: PATH_ENV }); return `${outPngBase}.png`; } export function storagePath(key: string) { return join(/*turbopackIgnore: true*/ STORAGE_DIR, key); } export async function ensureStorage() { for (const d of ["files", "previews", "inbox", "tmp"]) await fs.mkdir(join(/*turbopackIgnore: true*/ STORAGE_DIR, d), { recursive: true }); } /** Content-addressed storage key: files/<2 hex>/.pdf — the same manuscript is never stored twice. */ export function manuscriptKey(sha: string, ext = "pdf") { return `files/${sha.slice(0, 2)}/${sha}.${ext}`; } export async function storeFile(srcPath: string, sha: string, ext = "pdf"): Promise { const key = manuscriptKey(sha, ext); const dest = storagePath(key); await fs.mkdir(dirname(dest), { recursive: true }); try { await fs.access(dest); } catch { await fs.copyFile(srcPath, dest); await fs.chmod(dest, 0o640); } return key; }