feat(scan): support iX1600 duplex scanning on Linux

This commit is contained in:
2026-09-17 18:22:43 +02:00
parent cfef07b96d
commit dd37622bb6
24 changed files with 1411 additions and 215 deletions

View File

@@ -1,149 +1,118 @@
import { mkdirSync } from "node:fs"
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 { hasOpenCvPostprocessRuntime, postprocessScan } from "./postprocess.js"
import { log } from "../logger.js"
import { checkScanRuntime, createScanDocument } from "./postprocess.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]
const stringSetting = (settings: Record<string, unknown>, 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]
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
}
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])
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): Promise<ScanResult> => {
if (!await hasSane()) {
throw new Error("scanimage ist nicht installiert oder nicht im PATH")
}
mkdirSync(config.workDir, { recursive: true })
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") as AgentConfig["scanFormat"] | undefined || config.scanFormat
const resolution = numberSetting(settings, "resolution") || config.scanResolution
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 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 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")
await checkScanRuntime(config, postprocess)
const args = [
"--format",
scanFormat,
"--resolution",
String(resolution),
"--mode",
mode,
"--output-file",
scanOutputPath,
]
// 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}`)
if (source) args.push("--source", source)
if (scannerName) args.push("--device-name", scannerName)
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,
})
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)
// 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.")
}
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",
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)
}
}