feat(documents): suggest inbox folder and type with AI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s

This commit is contained in:
2026-10-01 18:11:41 +02:00
parent 07adca417e
commit 23d49f7186
10 changed files with 597 additions and 109 deletions

View File

@@ -17,6 +17,9 @@ type ExtractedDocumentText = {
method: ExtractionMethod;
};
type ExtractionOptions = { maxPages?: number; timeoutMs?: number };
type ExtractionContext = { maxPages?: number; deadline?: number };
function normalizeExtractedText(text: string) {
return text
.replace(/\u0000/g, "")
@@ -167,9 +170,11 @@ function extractTextFromPdfBufferFallback(pdfBuffer: Buffer) {
return normalizeExtractedText(extracted.join("\n"));
}
async function runCommand(command: string, args: string[]) {
async function runCommand(command: string, args: string[], context: ExtractionContext = {}) {
const timeout = context.deadline ? context.deadline - Date.now() : undefined;
if (timeout !== undefined && timeout <= 0) throw new Error("Die Texterkennung hat zu lange gedauert.");
try {
return await execFileAsync(command, args, { maxBuffer: 50 * 1024 * 1024 });
return await execFileAsync(command, args, { maxBuffer: 50 * 1024 * 1024, timeout });
} catch (err: any) {
if (err?.code === "ENOENT") {
return null;
@@ -179,14 +184,16 @@ async function runCommand(command: string, args: string[]) {
}
}
async function extractPdfTextWithPoppler(pdfPath: string) {
const result = await runCommand("pdftotext", ["-layout", "-enc", "UTF-8", pdfPath, "-"]);
async function extractPdfTextWithPoppler(pdfPath: string, context: ExtractionContext) {
const pages = context.maxPages ? ["-f", "1", "-l", String(context.maxPages)] : [];
const result = await runCommand("pdftotext", ["-layout", "-enc", "UTF-8", ...pages, pdfPath, "-"], context);
if (!result) return null;
return normalizeExtractedText(result.stdout);
}
async function renderPdfPagesToPng(pdfPath: string, outputDir: string) {
const pdftoppmResult = await runCommand("pdftoppm", ["-png", "-r", "200", pdfPath, path.join(outputDir, "page")]);
async function renderPdfPagesToPng(pdfPath: string, outputDir: string, context: ExtractionContext) {
const pages = context.maxPages ? ["-f", "1", "-l", String(context.maxPages), "-scale-to", "2400"] : [];
const pdftoppmResult = await runCommand("pdftoppm", ["-png", "-r", "200", ...pages, pdfPath, path.join(outputDir, "page")], context);
if (pdftoppmResult) {
return (await fs.readdir(outputDir))
.filter((file) => /^page-\d+\.png$/.test(file))
@@ -194,7 +201,7 @@ async function renderPdfPagesToPng(pdfPath: string, outputDir: string) {
.map((file) => path.join(outputDir, file));
}
const qlmanageResult = await runCommand("qlmanage", ["-t", "-s", "2000", "-o", outputDir, pdfPath]);
const qlmanageResult = await runCommand("qlmanage", ["-t", "-s", "2000", "-o", outputDir, pdfPath], context);
if (!qlmanageResult) return null;
const quickLookFile = path.join(outputDir, `${path.basename(pdfPath)}.png`);
@@ -207,8 +214,8 @@ async function renderPdfPagesToPng(pdfPath: string, outputDir: string) {
}
}
async function getAvailableTesseractLanguages() {
const result = await runCommand("tesseract", ["--list-langs"]);
async function getAvailableTesseractLanguages(context: ExtractionContext) {
const result = await runCommand("tesseract", ["--list-langs"], context);
if (!result) return [];
return result.stdout
@@ -217,36 +224,28 @@ async function getAvailableTesseractLanguages() {
.filter((line) => line && !line.startsWith("List of available languages"));
}
async function runOcrForPdf(pdfPath: string) {
async function runOcrForImages(pagePaths: string[], context: ExtractionContext) {
const texts: string[] = [];
const configuredLanguages = (process.env.TESSERACT_LANGS || "deu+eng").split("+").map(lang => lang.trim()).filter(Boolean);
const availableLanguages = await getAvailableTesseractLanguages(context);
const selectedLanguages = configuredLanguages.filter(lang => availableLanguages.includes(lang));
const languages = selectedLanguages.length ? selectedLanguages.join("+") : "eng";
for (const pagePath of pagePaths) {
const result = await runCommand("tesseract", [pagePath, "stdout", "-l", languages], context);
if (!result) return null;
const pageText = normalizeExtractedText(result.stdout);
if (pageText) texts.push(pageText);
}
return normalizeExtractedText(texts.join("\n\n"));
}
async function runOcrForPdf(pdfPath: string, context: ExtractionContext) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-ocr-"));
try {
const pagePaths = await renderPdfPagesToPng(pdfPath, tmpDir);
const pagePaths = await renderPdfPagesToPng(pdfPath, tmpDir, context);
if (!pagePaths?.length) return null;
const texts: string[] = [];
const configuredLanguages = (process.env.TESSERACT_LANGS || "deu+eng")
.split("+")
.map((lang) => lang.trim())
.filter(Boolean);
const availableLanguages = await getAvailableTesseractLanguages();
const selectedLanguages = configuredLanguages.filter((lang) => availableLanguages.includes(lang));
const languages = selectedLanguages.length ? selectedLanguages.join("+") : "eng";
for (const pagePath of pagePaths) {
const result = await runCommand("tesseract", [
pagePath,
"stdout",
"-l",
languages,
]);
if (!result) return null;
const pageText = normalizeExtractedText(result.stdout);
if (pageText) texts.push(pageText);
}
return normalizeExtractedText(texts.join("\n\n"));
return await runOcrForImages(pagePaths, context);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
@@ -255,12 +254,29 @@ async function runOcrForPdf(pdfPath: string) {
export async function extractDocumentText(
fileBuffer: Buffer,
mimeType?: string | null,
fileName?: string | null
fileName?: string | null,
options: ExtractionOptions = {},
): Promise<ExtractedDocumentText> {
const context: ExtractionContext = { maxPages: options.maxPages, deadline: options.timeoutMs ? Date.now() + options.timeoutMs : undefined };
const normalizedMimeType = mimeType?.toLowerCase() || "";
const normalizedFileName = fileName?.toLowerCase() || "";
const isPdf = normalizedMimeType === "application/pdf" || normalizedFileName.endsWith(".pdf");
// Scanner image analysis is opt-in; normal file uploads keep their existing extraction behavior.
if (options.maxPages && ["image/jpeg", "image/png", "image/tiff"].includes(normalizedMimeType)) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-image-ocr-"));
try {
const { default: sharp } = await import("sharp");
const imagePath = path.join(tmpDir, "page.png");
await sharp(fileBuffer, { page: 0, pages: 1, limitInputPixels: 50_000_000 })
.rotate().resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true }).png().toFile(imagePath);
const text = await runOcrForImages([imagePath], context);
return { text: text || null, method: text ? "ocr" : "none" };
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
if (normalizedMimeType.startsWith("text/")) {
const text = normalizeExtractedText(fileBuffer.toString("utf-8"));
return { text: text || null, method: text ? "text" : "none" };
@@ -271,22 +287,23 @@ export async function extractDocumentText(
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-pdf-"));
const pdfPath = path.join(tmpDir, fileName || "document.pdf");
const pdfPath = path.join(tmpDir, "document.pdf");
try {
await fs.writeFile(pdfPath, fileBuffer);
const cliText = await extractPdfTextWithPoppler(pdfPath);
const cliText = await extractPdfTextWithPoppler(pdfPath, context);
if (cliText) {
return { text: cliText, method: "text" };
}
const ocrText = await runOcrForPdf(pdfPath);
const ocrText = await runOcrForPdf(pdfPath, context);
if (ocrText) {
return { text: ocrText, method: "ocr" };
}
const fallbackText = extractTextFromPdfBufferFallback(fileBuffer);
// The fallback cannot enforce page limits; bounded analyses must not silently read the rest.
const fallbackText = context.maxPages ? null : extractTextFromPdfBufferFallback(fileBuffer);
if (fallbackText) {
return { text: fallbackText, method: "text" };
}