126 lines
6.9 KiB
TypeScript
126 lines
6.9 KiB
TypeScript
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<string, unknown>, key: string) => {
|
|
const value = settings[key]
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
|
}
|
|
|
|
const booleanSetting = (settings: Record<string, unknown>, 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<void> = async () => {}
|
|
): Promise<ScanResult> => {
|
|
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}.`)
|
|
}
|
|
}
|