feat(documents): suggest inbox folder and type with AI
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user