Files
mnote/wolai-frontend/convex/_utils/attachmentExtract.ts
T
2026-01-24 12:32:51 +08:00

251 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import JSZip from "jszip";
const MAX_TEXT_CHARS = 120_000;
function clampText(input: string, maxChars = MAX_TEXT_CHARS): string {
const trimmed = String(input || "").replace(/\s+\n/g, "\n").trim();
if (!trimmed) return "";
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars)}…`;
}
function decodeXmlEntities(input: string): string {
return input
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const code = Number.parseInt(hex, 16);
if (!Number.isFinite(code)) return "";
return String.fromCodePoint(code);
})
.replace(/&#(\d+);/g, (_, dec) => {
const code = Number.parseInt(dec, 10);
if (!Number.isFinite(code)) return "";
return String.fromCodePoint(code);
});
}
function getExtension(fileName: string | null | undefined): string {
const raw = String(fileName ?? "").trim().toLowerCase();
const idx = raw.lastIndexOf(".");
if (idx === -1) return "";
return raw.slice(idx + 1).replace(/[^a-z0-9]+/g, "");
}
function detectKind(args: { mimeType?: string | null; fileName?: string | null }): "pdf" | "docx" | "pptx" | "xlsx" | null {
const mime = String(args.mimeType ?? "").toLowerCase().trim();
if (mime === "application/pdf") return "pdf";
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx";
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return "pptx";
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx";
const ext = getExtension(args.fileName ?? null);
if (ext === "pdf") return "pdf";
if (ext === "docx") return "docx";
if (ext === "pptx") return "pptx";
if (ext === "xlsx") return "xlsx";
return null;
}
function extractTextFromXmlByTag(xml: string, tagName: string): string {
const out: string[] = [];
const re = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) out.push(clean);
}
return out.join(" ").trim();
}
function extractDocxText(documentXml: string): string {
const paras = documentXml.split(/<w:p[\s>]/g);
const out: string[] = [];
for (const p of paras) {
const line: string[] = [];
const re = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(p))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) line.push(clean);
}
const joined = line.join("").trim();
if (joined) out.push(joined);
}
return out.join("\n").trim();
}
function extractPptxText(slideXmls: string[]): string {
const out: string[] = [];
for (const xml of slideXmls) {
const line = extractTextFromXmlByTag(xml, "a:t");
if (line) out.push(line);
}
return out.join("\n\n").trim();
}
function extractXlsxText(args: { sharedStringsXml: string | null; sheetXmls: string[] }): string {
const sharedStrings: string[] = [];
if (args.sharedStringsXml) {
const re = /<t[^>]*>([\s\S]*?)<\/t>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(args.sharedStringsXml))) {
const raw = m[1] ?? "";
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
if (clean) sharedStrings.push(clean);
}
}
const out: string[] = [];
for (const xml of args.sheetXmls) {
const rows = xml.split(/<row[\s>]/g);
for (const r of rows) {
const cells: string[] = [];
// t="s" => sharedStrings indext="inlineStr" => <is><t>...;默认 => <v>number
const cellRe = /<c\b[^>]*?(?:t="([^"]+)")?[^>]*>([\s\S]*?)<\/c>/g;
let cm: RegExpExecArray | null;
while ((cm = cellRe.exec(r))) {
const t = (cm[1] ?? "").trim();
const body = cm[2] ?? "";
if (t === "inlineStr") {
const inline = extractTextFromXmlByTag(body, "t");
if (inline) cells.push(inline);
continue;
}
const vMatch = /<v>([\s\S]*?)<\/v>/.exec(body);
if (!vMatch) continue;
const rawV = decodeXmlEntities(String(vMatch[1] ?? "")).trim();
if (!rawV) continue;
if (t === "s") {
const idx = Number.parseInt(rawV, 10);
const s = Number.isFinite(idx) ? (sharedStrings[idx] ?? "") : "";
if (s) cells.push(s);
} else {
cells.push(rawV);
}
}
const line = cells.join(" ").replace(/\s+/g, " ").trim();
if (line) out.push(line);
}
}
return out.join("\n").trim();
}
export type AttachmentExtractOk = {
ok: true;
strategy: string;
text: string;
meta?: Record<string, unknown>;
};
export type AttachmentExtractResult =
| AttachmentExtractOk
| { ok: false; strategy: string; reason: string; meta?: Record<string, unknown> };
export async function extractTextFromAttachment(args: {
mimeType: string | null;
fileName: string | null;
bytes: ArrayBuffer;
}): Promise<AttachmentExtractResult> {
const kind = detectKind({ mimeType: args.mimeType, fileName: args.fileName });
if (!kind) {
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
}
if (kind === "pdf") {
try {
const mod = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(args.bytes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadingTask = (mod as any).getDocument({ data });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pdf = await (loadingTask as any).promise;
const out: string[] = [];
const pages = Number(pdf?.numPages ?? 0) || 0;
for (let i = 1; i <= pages; i += 1) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = await (pdf as any).getPage(i);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = await (page as any).getTextContent();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = Array.isArray((content as any)?.items) ? (content as any).items : [];
const line = items
.map((it: any) => (typeof it?.str === "string" ? it.str : ""))
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (line) out.push(line);
}
const text = clampText(out.join("\n\n"));
if (!text) {
return { ok: false, strategy: "pdfjs", reason: "未提取到可用文本", meta: { pages } };
}
return { ok: true, strategy: "pdfjs", text, meta: { pages } };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, strategy: "pdfjs", reason: message };
}
}
try {
const zip = await JSZip.loadAsync(args.bytes);
if (kind === "docx") {
const file = zip.file("word/document.xml");
const xml = file ? await file.async("string") : "";
const text = clampText(extractDocxText(xml));
if (!text) return { ok: false, strategy: "docx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "docx.xml", text };
}
if (kind === "pptx") {
const slideFiles = Object.keys(zip.files)
.filter((p) => /^ppt\/slides\/slide\d+\.xml$/i.test(p))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const slideXmls: string[] = [];
for (const p of slideFiles) {
const f = zip.file(p);
if (!f) continue;
// eslint-disable-next-line no-await-in-loop
slideXmls.push(await f.async("string"));
}
const text = clampText(extractPptxText(slideXmls));
if (!text) return { ok: false, strategy: "pptx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "pptx.xml", text, meta: { slides: slideXmls.length } };
}
if (kind === "xlsx") {
const sharedStringsXml = await (async () => {
const f = zip.file("xl/sharedStrings.xml");
if (!f) return null;
return await f.async("string");
})();
const sheetFiles = Object.keys(zip.files)
.filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(p))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const sheetXmls: string[] = [];
for (const p of sheetFiles) {
const f = zip.file(p);
if (!f) continue;
// eslint-disable-next-line no-await-in-loop
sheetXmls.push(await f.async("string"));
}
const text = clampText(extractXlsxText({ sharedStringsXml, sheetXmls }));
if (!text) return { ok: false, strategy: "xlsx.xml", reason: "未提取到可用文本" };
return { ok: true, strategy: "xlsx.xml", text, meta: { sheets: sheetXmls.length } };
}
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, strategy: "zip", reason: message };
}
}