import { mkdirSync } from "node:fs" import path from "node:path" import { AgentConfig, ScanJob, ScanResult } from "../types.js" import { commandExists, runCommand } from "../commands.js" import { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js" import { log } from "../logger.js" const mimeTypes = { pdf: "application/pdf", png: "image/png", tiff: "image/tiff", } const stringSetting = (settings: Record | undefined, key: string) => { const value = settings?.[key] return typeof value === "string" && value.trim() ? value.trim() : undefined } const numberSetting = (settings: Record | undefined, key: string) => { const value = settings?.[key] if (typeof value === "number" && Number.isFinite(value)) return value if (typeof value === "string" && value.trim()) { const parsed = Number(value) if (Number.isFinite(parsed)) return parsed } return undefined } const booleanSetting = (settings: Record | undefined, key: string, fallback: boolean) => { const value = settings?.[key] if (typeof value === "boolean") return value if (typeof value === "string") return ["1", "true", "yes", "ja", "on"].includes(value.trim().toLowerCase()) return fallback } const profileSetting = ( settings: Record | undefined, fallback: AgentConfig["postprocessProfile"] ): AgentConfig["postprocessProfile"] => { const value = settings?.postprocessProfile if (value === "document" || value === "receipt" || value === "raw") return value return fallback } const ensureFilenameExtension = (filename: string, format: AgentConfig["scanFormat"]) => { const ext = path.extname(filename) if (!ext) return `${filename}.${format}` const expectedExt = `.${format}` if (ext.toLowerCase() === expectedExt) return filename return `${filename.slice(0, -ext.length)}${expectedExt}` } const fallbackRawResult = (scanOutputPath: string, jobId: string): ScanResult => ({ path: scanOutputPath, filename: `${jobId}.raw.png`, mimeType: "image/png", }) export const hasSane = () => commandExists("scanimage") export const listScanners = async () => { if (!await hasSane()) return [] const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 }) if (result.code !== 0) return [] return result.stdout .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.startsWith("device `")) .map((line) => line.match(/device `([^']+)'/)?.[1]) .filter((device): device is string => Boolean(device)) } export const runScan = async (config: AgentConfig, job: ScanJob): Promise => { if (!await hasSane()) { throw new Error("scanimage ist nicht installiert oder nicht im PATH") } mkdirSync(config.workDir, { recursive: true }) const settings = job.settings || {} const format = stringSetting(settings, "format") as AgentConfig["scanFormat"] | undefined || config.scanFormat const resolution = numberSetting(settings, "resolution") || config.scanResolution const mode = stringSetting(settings, "mode") || config.scanMode const source = stringSetting(settings, "source") || config.scanSource const scannerName = job.scannerName || config.scannerName const filename = ensureFilenameExtension(job.requestedFilename || `${job.id}.${format}`, format) const outputPath = path.join(config.workDir, filename) const shouldPostprocess = booleanSetting(settings, "postprocess", config.scanPostprocess) const postprocessProfile = profileSetting(settings, config.postprocessProfile) const scanFormat = shouldPostprocess ? "png" : format const scanOutputPath = shouldPostprocess ? path.join(config.workDir, `${job.id}.raw.png`) : outputPath const args = [ "--format", scanFormat, "--resolution", String(resolution), "--mode", mode, "--output-file", scanOutputPath, ] if (source) args.push("--source", source) if (scannerName) args.push("--device-name", scannerName) const result = await runCommand("scanimage", args, { timeoutMs: 5 * 60 * 1000 }) if (result.code !== 0) { throw new Error(result.stderr || `scanimage wurde mit Code ${result.code} beendet`) } if (shouldPostprocess) { if (!await hasOpenCvPostprocessRuntime(config)) { const message = "OpenCV-Nachbearbeitung ist aktiviert, aber python3 mit cv2, Pillow und numpy ist nicht verfügbar" if (config.postprocessStrict) throw new Error(message) log.warn(`${message}. Rohscan wird ohne Korrektur hochgeladen.`, { jobId: job.id, python: config.postprocessPython, }) return fallbackRawResult(scanOutputPath, job.id) } try { return await postprocessScan(config, scanOutputPath, filename, format, postprocessProfile) } catch (error) { if (config.postprocessStrict) throw error log.warn("OpenCV-Nachbearbeitung fehlgeschlagen. Rohscan wird ohne Korrektur hochgeladen.", { jobId: job.id, error: error instanceof Error ? error.message : String(error), }) return fallbackRawResult(scanOutputPath, job.id) } } return { path: outputPath, filename, mimeType: mimeTypes[format] || "application/octet-stream", } }