mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Remove mammoth dependency and implement lightweight DOCX text extraction using fflate + DOMParser. Extracts text from body, headers/footers, and footnotes/endnotes by parsing w:p/w:t elements. Make readDocument() synchronous. Add PDF.js type definitions to eliminate unsafe any access.
225 lines
No EOL
9 KiB
TypeScript
225 lines
No EOL
9 KiB
TypeScript
import { loadPdfJs } from 'obsidian';
|
|
import { unzipSync, strFromU8 } from 'fflate';
|
|
import type { IPageText } from '../Types/SearchTypes';
|
|
import { Exception } from './Exception';
|
|
|
|
// Minimal structural types for the slice of PDF.js we use. Obsidian's `loadPdfJs()`
|
|
// is typed as `Promise<any>` and we don't bundle pdfjs-dist, so we describe just the
|
|
// shape we touch to keep this file free of unsafe-`any` access.
|
|
interface PdfTextItem { str?: string; }
|
|
interface PdfTextContent { items: PdfTextItem[]; }
|
|
interface PdfPage {
|
|
getTextContent(): Promise<PdfTextContent>;
|
|
cleanup?(): void;
|
|
}
|
|
interface PdfDocument {
|
|
numPages: number;
|
|
getPage(pageNumber: number): Promise<PdfPage>;
|
|
destroy?(): Promise<void>;
|
|
}
|
|
interface PdfJs {
|
|
getDocument(src: { data: Uint8Array }): { promise: Promise<PdfDocument> };
|
|
}
|
|
|
|
// Uses Obsidian's own bundled PDF.js via loadPdfJs() rather than shipping our own
|
|
export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
|
|
try {
|
|
const pdfjs = (await loadPdfJs()) as PdfJs;
|
|
const pdf = await pdfjs.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
|
|
|
|
const pageTexts: IPageText[] = [];
|
|
for (let i = 1; i <= pdf.numPages; i++) {
|
|
const page = await pdf.getPage(i);
|
|
const content = await page.getTextContent();
|
|
const text = content.items
|
|
.map((item) => item.str ?? '')
|
|
.join(' ');
|
|
pageTexts.push({ text, pageNumber: i });
|
|
page.cleanup?.();
|
|
}
|
|
await pdf.destroy?.();
|
|
|
|
return pageTexts;
|
|
} catch (error) {
|
|
/** PDF.js error types (from underlying pdfjs-dist library):
|
|
* - InvalidPDFException: Invalid or corrupted PDF structure
|
|
* - PasswordException: PDF requires a password
|
|
* - FormatError: PDF format error
|
|
* - UnexpectedResponseException: Unexpected server response
|
|
* - AbortException: Operation was aborted
|
|
* - UnknownErrorException: Unknown error occurred **/
|
|
|
|
if (error instanceof Error && error.name === 'PasswordException') {
|
|
return [{ text: "PDF is password protected!", pageNumber: 1 }] as IPageText[];
|
|
}
|
|
return [{ text: `Failed to read PDF: ${Exception.messageFrom(error)}`, pageNumber: 1 }] as IPageText[];
|
|
}
|
|
}
|
|
|
|
// Handles document formats: DOCX, PPTX, XLSX, ODT, ODP, ODS.
|
|
//
|
|
// Every format is a ZIP-of-XML container we read with fflate + the platform DOMParser,
|
|
// so no heavy parser is bundled. We only extract plain text (no OCR, no images) — the
|
|
// LLM can read files directly and use its own vision engine when it needs to.
|
|
export function readDocument(arrayBuffer: ArrayBuffer, extension: string): IPageText[] {
|
|
try {
|
|
let text: string;
|
|
switch (extension.toLowerCase()) {
|
|
case 'docx':
|
|
text = extractDocx(arrayBuffer);
|
|
break;
|
|
case 'xlsx':
|
|
text = extractXlsx(arrayBuffer);
|
|
break;
|
|
case 'pptx':
|
|
text = extractPptx(arrayBuffer);
|
|
break;
|
|
case 'odt':
|
|
case 'odp':
|
|
case 'ods':
|
|
text = extractOdf(arrayBuffer);
|
|
break;
|
|
default:
|
|
throw Exception.new(`Unsupported document type: .${extension}`);
|
|
}
|
|
|
|
// These formats expose no page structure, so we return a single page
|
|
return [{ text, pageNumber: 1 }] as IPageText[];
|
|
} catch (error) {
|
|
return [{ text: `Failed to read document: ${Exception.messageFrom(error)}`, pageNumber: 1 }] as IPageText[];
|
|
}
|
|
}
|
|
|
|
// --- Shared helpers for the ZIP-of-XML formats (docx / xlsx / pptx / odf) ---
|
|
|
|
// Throws on legacy OLE2 binaries (.xls/.ppt/.doc) — they aren't ZIPs. Callers wrap
|
|
// this so the failure surfaces as a clean "failed to read" message.
|
|
function unzip(arrayBuffer: ArrayBuffer): Record<string, Uint8Array> {
|
|
return unzipSync(new Uint8Array(arrayBuffer));
|
|
}
|
|
|
|
function parseXml(bytes: Uint8Array): Document {
|
|
return new DOMParser().parseFromString(strFromU8(bytes), 'application/xml');
|
|
}
|
|
|
|
/** Numeric sort key from a path like "ppt/slides/slide12.xml" -> 12. */
|
|
function ordinal(path: string): number {
|
|
const match = path.match(/(\d+)\.xml$/);
|
|
return match ? Number(match[1]) : 0;
|
|
}
|
|
|
|
// --- DOCX: body in word/document.xml; <w:p> = paragraph, <w:t> = text run.
|
|
// Headers/footers and foot/endnotes live in sibling parts that share the same
|
|
// <w:p>/<w:t> shape, so they run through the same paragraph extractor. ---
|
|
function extractDocx(arrayBuffer: ArrayBuffer): string {
|
|
const files = unzip(arrayBuffer);
|
|
|
|
// Joined text of every <w:p> paragraph in a parsed part. Runs are joined with
|
|
// '' (not a space) because Word splits a single word across multiple <w:t> runs
|
|
// for spell-check / formatting — a space here would shred words like "Hel"+"lo".
|
|
// Empty paragraphs (incl. the separator entries in foot/endnotes) are dropped.
|
|
const paragraphs = (bytes: Uint8Array): string =>
|
|
Array.from(parseXml(bytes).getElementsByTagName('w:p'))
|
|
.map((p) =>
|
|
Array.from(p.getElementsByTagName('w:t'))
|
|
.map((t) => t.textContent ?? '')
|
|
.join('')
|
|
)
|
|
.filter((line) => line.length > 0)
|
|
.join('\n');
|
|
|
|
// Body first, then headers/footers (grouped + numerically ordered), then notes.
|
|
const headerFooter = Object.keys(files)
|
|
.filter((name) => /^word\/(header|footer)\d+\.xml$/.test(name))
|
|
.sort();
|
|
|
|
const parts: string[] = [];
|
|
if (files['word/document.xml']) parts.push(paragraphs(files['word/document.xml']));
|
|
for (const name of headerFooter) parts.push(paragraphs(files[name]));
|
|
if (files['word/footnotes.xml']) parts.push(paragraphs(files['word/footnotes.xml']));
|
|
if (files['word/endnotes.xml']) parts.push(paragraphs(files['word/endnotes.xml']));
|
|
|
|
return parts.filter((p) => p.length > 0).join('\n\n');
|
|
}
|
|
|
|
// --- XLSX: values live as indices into a shared-strings table ---
|
|
function extractXlsx(arrayBuffer: ArrayBuffer): string {
|
|
const files = unzip(arrayBuffer);
|
|
|
|
// Resolve the shared-strings table (cells of type "s" point into this).
|
|
let shared: string[] = [];
|
|
if (files['xl/sharedStrings.xml']) {
|
|
const dom = parseXml(files['xl/sharedStrings.xml']);
|
|
shared = Array.from(dom.getElementsByTagName('si')).map((si) =>
|
|
Array.from(si.getElementsByTagName('t'))
|
|
.map((t) => t.textContent ?? '')
|
|
.join('')
|
|
);
|
|
}
|
|
|
|
const sheets = Object.keys(files)
|
|
.filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/.test(name))
|
|
.sort((a, b) => ordinal(a) - ordinal(b));
|
|
|
|
const rows: string[] = [];
|
|
for (const name of sheets) {
|
|
const dom = parseXml(files[name]);
|
|
for (const row of Array.from(dom.getElementsByTagName('row'))) {
|
|
const cells = Array.from(row.getElementsByTagName('c')).map((cell) => {
|
|
const type = cell.getAttribute('t');
|
|
if (type === 'inlineStr') {
|
|
// Text stored directly in the cell: <c t="inlineStr"><is><t>…</t></is></c>
|
|
return Array.from(cell.getElementsByTagName('t'))
|
|
.map((t) => t.textContent ?? '')
|
|
.join('');
|
|
}
|
|
const value = cell.getElementsByTagName('v')[0];
|
|
if (!value) return '';
|
|
return type === 's'
|
|
? shared[Number(value.textContent)] ?? ''
|
|
: value.textContent ?? '';
|
|
});
|
|
rows.push(cells.join('\t'));
|
|
}
|
|
}
|
|
return rows.join('\n');
|
|
}
|
|
|
|
// --- PPTX: one XML per slide; sort numerically so output is in slide order ---
|
|
function extractPptx(arrayBuffer: ArrayBuffer): string {
|
|
const files = unzip(arrayBuffer);
|
|
|
|
const slides = Object.keys(files)
|
|
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
|
.sort((a, b) => ordinal(a) - ordinal(b));
|
|
|
|
return slides
|
|
.map((name) => {
|
|
const dom = parseXml(files[name]);
|
|
return Array.from(dom.getElementsByTagName('a:t'))
|
|
.map((t) => t.textContent ?? '')
|
|
.join(' ');
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
|
|
// --- ODF (odt / ods / odp): everything lives in a single content.xml ---
|
|
function extractOdf(arrayBuffer: ArrayBuffer): string {
|
|
const files = unzip(arrayBuffer);
|
|
if (!files['content.xml']) return '';
|
|
|
|
const dom = parseXml(files['content.xml']);
|
|
|
|
// Single document-order pass over paragraphs (text:p) and headings (text:h).
|
|
// getElementsByTagName("*") preserves order, keeping body text, spreadsheet cells,
|
|
// and slide text in the sequence they appear.
|
|
const out: string[] = [];
|
|
const all = dom.getElementsByTagName('*');
|
|
for (let i = 0; i < all.length; i++) {
|
|
const tag = all[i].tagName; // prefixed, e.g. "text:p"
|
|
if (tag === 'text:p' || tag === 'text:h') {
|
|
out.push(all[i].textContent ?? '');
|
|
}
|
|
}
|
|
return out.join('\n');
|
|
} |