Files
FEDEO/backend/src/utils/documentText.ts
flfeders 23d49f7186
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s
feat(documents): suggest inbox folder and type with AI
2026-10-01 18:11:41 +02:00

333 lines
10 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import zlib from "node:zlib";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { FastifyInstance } from "fastify";
import { eq } from "drizzle-orm";
import { files } from "../../db/schema";
const execFileAsync = promisify(execFile);
type ExtractionMethod = "text" | "ocr" | "none";
type ExtractedDocumentText = {
text: string | null;
method: ExtractionMethod;
};
type ExtractionOptions = { maxPages?: number; timeoutMs?: number };
type ExtractionContext = { maxPages?: number; deadline?: number };
function normalizeExtractedText(text: string) {
return text
.replace(/\u0000/g, "")
.replace(/\r/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function decodePdfString(raw: string) {
let out = "";
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (ch !== "\\") {
out += ch;
continue;
}
const next = raw[i + 1];
if (!next) break;
if (next === "n") {
out += "\n";
i += 1;
continue;
}
if (next === "r") {
out += "\r";
i += 1;
continue;
}
if (next === "t") {
out += "\t";
i += 1;
continue;
}
if (next === "b") {
out += "\b";
i += 1;
continue;
}
if (next === "f") {
out += "\f";
i += 1;
continue;
}
if (next === "(" || next === ")" || next === "\\") {
out += next;
i += 1;
continue;
}
if (/[0-7]/.test(next)) {
let oct = next;
let advance = 1;
for (let j = 2; j <= 3; j += 1) {
const c = raw[i + j];
if (!c || !/[0-7]/.test(c)) break;
oct += c;
advance += 1;
}
out += String.fromCharCode(parseInt(oct, 8));
i += advance;
continue;
}
out += next;
i += 1;
}
return out;
}
function extractTextFromTjOperator(segment: string) {
const parts = segment.match(/\((?:\\.|[^\\)])*\)/g);
if (!parts) return "";
return parts
.map((part) => decodePdfString(part.slice(1, -1)))
.join("");
}
function extractTextStreamsFromPdf(pdfBuffer: Buffer) {
const pdfLatin = pdfBuffer.toString("latin1");
const texts: string[] = [];
let cursor = 0;
while (true) {
const streamPos = pdfLatin.indexOf("stream", cursor);
if (streamPos < 0) break;
let dataStart = streamPos + 6;
if (pdfLatin[dataStart] === "\r" && pdfLatin[dataStart + 1] === "\n") {
dataStart += 2;
} else if (pdfLatin[dataStart] === "\n") {
dataStart += 1;
}
const streamEnd = pdfLatin.indexOf("endstream", dataStart);
if (streamEnd < 0) break;
const sliceEnd = streamEnd > dataStart && pdfBuffer[streamEnd - 1] === 0x0d
? streamEnd - 1
: streamEnd;
const compressed = pdfBuffer.subarray(dataStart, sliceEnd);
try {
texts.push(zlib.inflateSync(compressed).toString("latin1"));
} catch {
// Ignore non-Flate streams.
}
cursor = streamEnd + 9;
}
return texts;
}
function extractTextFromPdfBufferFallback(pdfBuffer: Buffer) {
const streams = extractTextStreamsFromPdf(pdfBuffer);
const extracted: string[] = [];
for (const stream of streams) {
const operators = stream.match(/\[(?:.|\r|\n)*?\]TJ|\((?:\\.|[^\\)])*\)Tj/g);
if (!operators) continue;
for (const operator of operators) {
const text = extractTextFromTjOperator(operator)
.replace(/[ \t]+/g, " ")
.trim();
if (text) {
extracted.push(text);
}
}
}
return normalizeExtractedText(extracted.join("\n"));
}
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, timeout });
} catch (err: any) {
if (err?.code === "ENOENT") {
return null;
}
throw err;
}
}
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, 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))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.map((file) => path.join(outputDir, file));
}
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`);
try {
await fs.access(quickLookFile);
return [quickLookFile];
} catch {
return null;
}
}
async function getAvailableTesseractLanguages(context: ExtractionContext) {
const result = await runCommand("tesseract", ["--list-langs"], context);
if (!result) return [];
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("List of available languages"));
}
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, context);
if (!pagePaths?.length) return null;
return await runOcrForImages(pagePaths, context);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
export async function extractDocumentText(
fileBuffer: Buffer,
mimeType?: 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" };
}
if (!isPdf) {
return { text: null, method: "none" };
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedeo-pdf-"));
const pdfPath = path.join(tmpDir, "document.pdf");
try {
await fs.writeFile(pdfPath, fileBuffer);
const cliText = await extractPdfTextWithPoppler(pdfPath, context);
if (cliText) {
return { text: cliText, method: "text" };
}
const ocrText = await runOcrForPdf(pdfPath, context);
if (ocrText) {
return { text: ocrText, method: "ocr" };
}
// 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" };
}
return { text: null, method: "none" };
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
export async function storeExtractedTextForFile(
server: FastifyInstance,
fileId: string,
fileBuffer: Buffer,
mimeType?: string | null,
fileName?: string | null
) {
const result = await extractDocumentText(fileBuffer, mimeType, fileName);
await server.db
.update(files)
.set({ extractedText: result.text })
.where(eq(files.id, fileId));
return result;
}