150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
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<string, unknown> | undefined, key: string) => {
|
|
const value = settings?.[key]
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
|
}
|
|
|
|
const numberSetting = (settings: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<ScanResult> => {
|
|
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",
|
|
}
|
|
}
|