import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises" import path from "node:path" import { AgentConfig, ScanJob, ScanResult } from "../types.js" import { commandExists, runCommand } from "../commands.js" import { checkScanRuntime, createScanDocument } from "./postprocess.js" const stringSetting = (settings: Record, key: string) => { const value = settings[key] return typeof value === "string" && value.trim() ? value.trim() : undefined } const booleanSetting = (settings: Record, 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 } export const hasSane = () => commandExists("scanimage") export const listScanners = async () => { if (!await hasSane()) return [] // SANE also probes network backends; these can take more than ten seconds // even when the USB scanner is already available. const result = await runCommand("scanimage", ["-L"], { timeoutMs: 30_000 }) if (result.code !== 0) return [] return result.stdout.split(/\r?\n/) .map((line) => line.trim().match(/^device `([^']+)'/)?.[1]) .filter((device): device is string => Boolean(device)) } export const runScan = async ( config: AgentConfig, job: ScanJob, reportProgress: (message: string) => Promise = async () => {} ): Promise => { if (!await hasSane()) throw new Error("scanimage ist nicht installiert oder nicht im PATH") const settings = job.settings || {} const format = stringSetting(settings, "format") || config.scanFormat if (!["pdf", "png", "tiff"].includes(format)) throw new Error("Scan-Format muss PDF, PNG oder TIFF sein") const resolution = Number(settings.resolution ?? config.scanResolution) if (!Number.isFinite(resolution) || resolution < 75 || resolution > 1200) { throw new Error("Die Scan-Auflösung muss zwischen 75 und 1200 dpi liegen") } const mode = stringSetting(settings, "mode") || config.scanMode const source = stringSetting(settings, "source") || config.scanSource const scannerName = job.scannerName || config.scannerName const paperSize = stringSetting(settings, "paperSize") || "auto" if (!["auto", "A4", "Letter"].includes(paperSize)) throw new Error("Unbekanntes Papierformat") const batch = Boolean(source && /adf|document feeder/i.test(source)) if (batch && format !== "pdf") throw new Error("Für Einzug- und Duplex-Scans bitte PDF als Format wählen") const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess) const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil") const processing = { backgroundCleanup: booleanSetting(settings, "backgroundCleanup", config.scanBackgroundCleanup), ocr: format === "pdf" && booleanSetting(settings, "ocr", config.scanOcr), ocrLanguage: stringSetting(settings, "ocrLanguage") || config.scanOcrLanguage, } await checkScanRuntime(config, postprocess, processing) // Each physical scan has its own directory. A filename cannot escape it or overwrite another job. await mkdir(config.workDir, { recursive: true, mode: 0o700 }) const jobDir = await mkdtemp(path.join(path.resolve(config.workDir), "scan-")) const requested = path.basename((job.requestedFilename || "scan").replace(/\\/g, "/")) const stem = requested.replace(/\.[^.]*$/, "").replace(/[\x00-\x1f]/g, "").trim() || "scan" const outputPath = path.join(jobDir, `${stem}.${format}`) try { // Device/source come first: changing source can change the remaining SANE options. const args: string[] = [] if (scannerName) args.push("--device-name", scannerName) if (source) args.push("--source", source) args.push("--mode", mode, "--resolution", String(resolution), "--format=png") if (paperSize !== "auto") { const [width, height] = paperSize === "A4" ? [210, 297] : [215.9, 279.4] if (scannerName?.startsWith("fujitsu:")) { args.push("--page-width", String(width), "--page-height", String(height)) } args.push("-l", "0", "-t", "0", "-x", String(width), "-y", String(height)) } if (batch) { // Relative pattern avoids interpreting '%' in a user-configured work directory. args.push("--batch=page-%06d.png", "--batch-start=1", "--batch-increment=1") } else { args.push("--output-file", "page-000001.png") } await reportProgress(batch ? "Dokumente werden eingezogen und gescannt..." : "Seite wird gescannt...") const result = await runCommand("scanimage", args, { cwd: jobDir, timeoutMs: config.scanTimeoutSeconds * 1000, }) // SANE_STATUS_NO_DOCS (7) is the expected end of an unlimited ADF batch. // Never turn paper jams, I/O errors or canceled scans into a successful partial upload. if (result.code !== 0 && !(batch && result.code === 7)) { throw new Error(result.stderr.trim() || `scanimage wurde mit Code ${result.code} beendet`) } const names = (await readdir(jobDir)) .filter((name) => /^page-\d+\.png$/.test(name)) .sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0])) const pages: string[] = [] for (const [index, name] of names.entries()) { const page = path.join(jobDir, name) // Some scanimage versions leave a zero-byte file for the final NO_DOCS attempt. if ((await stat(page)).size === 0) { if (batch && result.code === 7 && index === names.length - 1) continue throw new Error(`Unvollständige Scan-Seite: ${name}`) } if (Number(name.match(/\d+/)![0]) !== index + 1) throw new Error("Im Scan fehlen Seiten") pages.push(page) } if (!pages.length) throw new Error("Keine Seiten gescannt. Bitte Papier in den Einzug legen.") if (source?.toLowerCase().includes("duplex") && pages.length % 2 !== 0) { throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.") } await reportProgress(`${pages.length} Seiten gescannt. ${processing.ocr ? "Durchsuchbares PDF mit OCR wird erstellt..." : "Datei wird erstellt..."}`) return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"], processing) } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`) } }