feat(scan): support iX1600 duplex scanning on Linux
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { readFile, stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { AgentConfig, AgentHeartbeat, NextScanJobResponse, ScanResult } from "./types.js"
|
||||
|
||||
@@ -16,10 +16,11 @@ export class FedeoApi {
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
private async request<T>(path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<T> {
|
||||
const response = await fetch(this.url(path), {
|
||||
...init,
|
||||
headers: this.headers(init.headers),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -51,6 +52,9 @@ export class FedeoApi {
|
||||
}
|
||||
|
||||
async uploadScan(jobId: string, result: ScanResult) {
|
||||
if ((await stat(result.path)).size > 100 * 1024 * 1024) {
|
||||
throw new Error("Die Scan-Datei überschreitet das Upload-Limit von 100 MiB. Bitte den Stapel aufteilen oder die Auflösung reduzieren.")
|
||||
}
|
||||
const form = new FormData()
|
||||
const fileBuffer = await readFile(result.path)
|
||||
const file = new File([fileBuffer], result.filename || basename(result.path), {
|
||||
@@ -62,6 +66,6 @@ export class FedeoApi {
|
||||
return this.request(`/instance-agent/scan-jobs/${jobId}/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}, 120_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type CommandResult = {
|
||||
|
||||
export const commandExists = (command: string) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const child = spawn("sh", ["-lc", `command -v ${command}`])
|
||||
const child = spawn("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "--", command])
|
||||
child.on("error", () => resolve(false))
|
||||
child.on("close", (code) => resolve(code === 0))
|
||||
})
|
||||
@@ -16,33 +16,50 @@ export const commandExists = (command: string) =>
|
||||
export const runCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeoutMs?: number } = {}
|
||||
options: { timeoutMs?: number; cwd?: string } = {}
|
||||
) =>
|
||||
new Promise<CommandResult>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options.cwd,
|
||||
})
|
||||
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
|
||||
let timedOut = false
|
||||
let killTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||
killTimeout = setTimeout(() => child.kill("SIGKILL"), 2000)
|
||||
}, options.timeoutMs)
|
||||
: null
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)))
|
||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)))
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) => {
|
||||
child.on("error", (error) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (killTimeout) clearTimeout(killTimeout)
|
||||
reject(error)
|
||||
})
|
||||
child.on("close", (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (killTimeout) clearTimeout(killTimeout)
|
||||
if (timedOut) {
|
||||
reject(new Error(`${command} wurde nach ${options.timeoutMs} ms beendet`))
|
||||
return
|
||||
}
|
||||
if (signal) {
|
||||
reject(new Error(`${command} wurde durch ${signal} beendet`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
code: code ?? 0,
|
||||
code: code ?? 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ export const loadConfig = (): AgentConfig => {
|
||||
scanResolution: numberFromEnv(process.env.FEDEO_SCAN_RESOLUTION, 300),
|
||||
scanMode: optional(process.env.FEDEO_SCAN_MODE) || "Color",
|
||||
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
|
||||
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
|
||||
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
|
||||
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
|
||||
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { FedeoApi } from "./api.js"
|
||||
import { loadConfig } from "./config.js"
|
||||
import { log } from "./logger.js"
|
||||
import { listPrinters } from "./print/cups.js"
|
||||
import { hasSane, listScanners, runScan } from "./scan/sane.js"
|
||||
import { AgentHeartbeat } from "./types.js"
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -25,11 +28,14 @@ const main = async () => {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const scannerNames = await listScanners()
|
||||
const printerNames = await listPrinters()
|
||||
const scannerNames = await listScanners().catch((error) => {
|
||||
log.warn("Scanner-Erkennung fehlgeschlagen", stringifyError(error))
|
||||
return []
|
||||
})
|
||||
const printerNames = await listPrinters().catch(() => [])
|
||||
const scanAvailable = await hasSane()
|
||||
|
||||
const heartbeat = await api.heartbeat({
|
||||
const heartbeatPayload: AgentHeartbeat = {
|
||||
capabilities: {
|
||||
scan: scanAvailable,
|
||||
print: printerNames.length > 0,
|
||||
@@ -44,7 +50,8 @@ const main = async () => {
|
||||
node: process.version,
|
||||
uptimeSeconds: Math.round(os.uptime()),
|
||||
},
|
||||
})
|
||||
}
|
||||
const heartbeat = await api.heartbeat(heartbeatPayload)
|
||||
|
||||
if (heartbeat.pendingScanJobs > 0) {
|
||||
log.info(`${heartbeat.pendingScanJobs} Scan-Auftrag/Aufträge warten`)
|
||||
@@ -62,14 +69,44 @@ const main = async () => {
|
||||
scannerName: next.job.scannerName || config.scannerName || "default",
|
||||
})
|
||||
|
||||
// Keep online status fresh during long scans without probing an occupied USB scanner.
|
||||
let heartbeatRunning = false
|
||||
const heartbeatTimer = setInterval(async () => {
|
||||
if (heartbeatRunning) return
|
||||
heartbeatRunning = true
|
||||
try {
|
||||
await api.heartbeat(heartbeatPayload)
|
||||
} catch (error) {
|
||||
log.warn("Heartbeat während Scan fehlgeschlagen", stringifyError(error))
|
||||
} finally {
|
||||
heartbeatRunning = false
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
try {
|
||||
await api.updateScanJobStatus(next.job.id, "running")
|
||||
const scanResult = await runScan(config, next.job)
|
||||
await api.uploadScan(next.job.id, scanResult)
|
||||
const jobId = next.job.id
|
||||
const reportProgress = async (message: string) => {
|
||||
await api.updateScanJobStatus(jobId, "running", message).catch((error) => {
|
||||
log.warn("Scan-Status konnte nicht übertragen werden", stringifyError(error))
|
||||
})
|
||||
}
|
||||
const scanResult = await runScan(config, next.job, reportProgress)
|
||||
await reportProgress(`${scanResult.pageCount} Seiten gescannt. ${scanResult.warnings.join(" ")}`.trim())
|
||||
try {
|
||||
await api.uploadScan(jobId, scanResult)
|
||||
} catch (error) {
|
||||
throw new Error(`${stringifyError(error)} Scan-Datei bleibt zur Wiederherstellung unter ${scanResult.path}.`)
|
||||
}
|
||||
|
||||
await rm(path.dirname(scanResult.path), { recursive: true, force: true }).catch((error) => {
|
||||
log.warn("Scan gespeichert; temporäre Dateien konnten nicht entfernt werden", stringifyError(error))
|
||||
})
|
||||
|
||||
log.info("Scan-Auftrag abgeschlossen", {
|
||||
jobId: next.job.id,
|
||||
file: scanResult.filename,
|
||||
pages: scanResult.pageCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = stringifyError(error)
|
||||
@@ -79,6 +116,8 @@ const main = async () => {
|
||||
})
|
||||
|
||||
await api.updateScanJobStatus(next.job.id, "failed", message)
|
||||
} finally {
|
||||
clearInterval(heartbeatTimer)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Agent-Schleife fehlgeschlagen", stringifyError(error))
|
||||
|
||||
@@ -1,66 +1,45 @@
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { AgentConfig, ScanResult } from "../types.js"
|
||||
import { commandExists, runCommand } from "../commands.js"
|
||||
import { runCommand } from "../commands.js"
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
const agentRoot = path.resolve(path.dirname(currentFile), "../..")
|
||||
const postprocessScript = path.join(agentRoot, "scripts/opencv_postprocess.py")
|
||||
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
|
||||
|
||||
const extensionMimeTypes: Record<string, string> = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
|
||||
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
|
||||
try {
|
||||
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
|
||||
if (result.code === 0) return
|
||||
} catch {
|
||||
// Report missing runtimes before the scanner consumes any paper.
|
||||
}
|
||||
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.")
|
||||
}
|
||||
|
||||
const ensureOutputExtension = (filename: string, format: AgentConfig["scanFormat"]) => {
|
||||
const ext = path.extname(filename)
|
||||
if (ext) return filename
|
||||
return `${filename}.${format}`
|
||||
}
|
||||
|
||||
export const hasOpenCvPostprocessRuntime = async (config: AgentConfig) => {
|
||||
if (!await commandExists(config.postprocessPython)) return false
|
||||
|
||||
const result = await runCommand(config.postprocessPython, [
|
||||
"-c",
|
||||
"import cv2, PIL, numpy",
|
||||
], { timeoutMs: 10_000 })
|
||||
|
||||
return result.code === 0
|
||||
}
|
||||
|
||||
export const postprocessScan = async (
|
||||
export const createScanDocument = async (
|
||||
config: AgentConfig,
|
||||
inputPath: string,
|
||||
outputFilename: string,
|
||||
outputFormat: AgentConfig["scanFormat"],
|
||||
pages: string[],
|
||||
outputPath: string,
|
||||
resolution: number,
|
||||
postprocess: boolean,
|
||||
profile: AgentConfig["postprocessProfile"]
|
||||
): Promise<ScanResult> => {
|
||||
const filename = ensureOutputExtension(outputFilename, outputFormat)
|
||||
const outputPath = path.join(config.workDir, filename)
|
||||
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
|
||||
if (postprocess) args.push("--postprocess")
|
||||
if (config.postprocessStrict) args.push("--strict")
|
||||
args.push("--inputs", ...pages)
|
||||
|
||||
const result = await runCommand(config.postprocessPython, [
|
||||
postprocessScript,
|
||||
"--input",
|
||||
inputPath,
|
||||
"--output",
|
||||
outputPath,
|
||||
"--profile",
|
||||
profile,
|
||||
], { timeoutMs: 5 * 60 * 1000 })
|
||||
const result = await runCommand(config.postprocessPython, args, {
|
||||
timeoutMs: config.scanTimeoutSeconds * 1000,
|
||||
})
|
||||
if (result.code !== 0) throw new Error(result.stderr || "Scan-Datei konnte nicht erstellt werden")
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `OpenCV-Nachbearbeitung wurde mit Code ${result.code} beendet`)
|
||||
}
|
||||
|
||||
const extension = path.extname(outputPath).toLowerCase()
|
||||
const details = JSON.parse(result.stdout) as { pageCount: number; warnings: string[] }
|
||||
return {
|
||||
path: outputPath,
|
||||
filename,
|
||||
mimeType: extensionMimeTypes[extension] || "application/octet-stream",
|
||||
filename: path.basename(outputPath),
|
||||
mimeType: outputPath.endsWith(".pdf") ? "application/pdf" : outputPath.endsWith(".png") ? "image/png" : "image/tiff",
|
||||
...details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AgentConfig = {
|
||||
scanResolution: number
|
||||
scanMode: string
|
||||
scanSource?: string
|
||||
scanTimeoutSeconds: number
|
||||
scanPostprocess: boolean
|
||||
postprocessProfile: "document" | "receipt" | "raw"
|
||||
postprocessPython: string
|
||||
@@ -45,4 +46,6 @@ export type ScanResult = {
|
||||
path: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
pageCount: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user