Replace unpdf and officeparser with lightweight document parsers

Switch PDF extraction to Obsidian's bundled PDF.js via loadPdfJs() instead of unpdf. Replace officeparser with mammoth for DOCX and custom fflate-based ZIP parsers for PPTX/XLSX/ODF formats. Eliminates heavy dependencies, removes all dynamic eval and .wasm references, and simplifies the esbuild plugin configuration.
This commit is contained in:
Andrew Beal 2026-06-28 14:06:56 +01:00
parent 9f6994e30d
commit 9457fa5b44
8 changed files with 382 additions and 745 deletions

View file

@ -1,18 +1,26 @@
import { extractText, getDocumentProxy } from 'unpdf';
import { loadPdfJs } from 'obsidian';
import { unzipSync, strFromU8 } from 'fflate';
import mammoth from 'mammoth/mammoth.browser.js';
import type { IPageText } from '../Types/SearchTypes';
import { Exception } from './Exception';
import { parseOffice } from 'officeparser';
// Handles PDF format
// Uses Obsidian's own bundled PDF.js via loadPdfJs() rather than shipping our own
export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
try {
const pdf = await getDocumentProxy(new Uint8Array(arrayBuffer), { isEvalSupported: false });
const pages = (await extractText(pdf, { mergePages: false })).text;
const pdfjs = await loadPdfJs();
const pdf = await pdfjs.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
const pageTexts: IPageText[] = pages.map((pageText, index) => ({
text: pageText,
pageNumber: index + 1
}));
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: { str?: string }) => item.str ?? '')
.join(' ');
pageTexts.push({ text, pageNumber: i });
page.cleanup?.();
}
await pdf.destroy?.();
return pageTexts;
} catch (error) {
@ -31,18 +39,137 @@ export async function readPDF(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
}
}
// Handles document formats: DOCX, PPTX, XLSX, ODT, ODP, ODS
export async function readDocument(arrayBuffer: ArrayBuffer): Promise<IPageText[]> {
// Handles document formats: DOCX, PPTX, XLSX, ODT, ODP, ODS.
//
// DOCX goes through mammoth (battle-tested: tables, lists, footnotes). The remaining
// formats are ZIP-of-XML containers 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 async function readDocument(arrayBuffer: ArrayBuffer, extension: string): Promise<IPageText[]> {
try {
const ast = await parseOffice(arrayBuffer, {
extractAttachments: true,
ocr: true,
ocrLanguage: "eng+esp"
});
let text: string;
switch (extension.toLowerCase()) {
case 'docx':
text = (await mammoth.extractRawText({ arrayBuffer })).value;
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}`);
}
// OfficeParser doesn't currently expose page data (page number etc)
return [{ text: (await ast.to('text')).value, pageNumber: 1 }] as IPageText[];
// 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 (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;
}
// --- 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');
}

View file

@ -410,8 +410,9 @@ This plugin is built on the shoulders of many excellent projects:
- Official SDKs: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript), [@google/genai](https://github.com/google/generative-ai-js), [openai](https://github.com/openai/openai-node)
**Document Processing**
- [unpdf](https://github.com/unjs/unpdf) - PDF parsing and text extraction
- [officeparser](https://github.com/nicktomlin/officeparser) - Office document parsing (DOCX, PPTX, XLSX, ODT, ODP, ODS)
- [mammoth](https://github.com/mwilliamson/mammoth.js) - DOCX text extraction
- [fflate](https://github.com/101arrowz/fflate) - Unzipping Office Open XML / OpenDocument files (PPTX, XLSX, ODT, ODP, ODS)
- PDF text extraction via Obsidian's bundled [PDF.js](https://mozilla.github.io/pdf.js/) (`loadPdfJs()`)
**UI Framework**
- [Svelte](https://svelte.dev) - Reactive UI components

View file

@ -48,7 +48,7 @@ export class InputService {
}
if (isDocumentFile(fileType)) {
const content = await readDocument(await file.arrayBuffer());
const content = await readDocument(await file.arrayBuffer(), fileType);
attachments.push(new Attachment(
file.name,
MimeType.TEXT_PLAIN,

View file

@ -98,7 +98,7 @@ export class VaultService {
if (isDocumentFile(fileExtension)) {
const arrayBuffer = await this.readBinaryData(file, allowAccessToPluginRoot);
if (arrayBuffer) {
return (await readDocument(arrayBuffer))[0].text;
return (await readDocument(arrayBuffer, fileExtension))[0].text;
}
}
@ -418,7 +418,7 @@ export class VaultService {
content = await readPDF(arrayBuffer);
} else if (isDocumentFile(fileExtension)) {
const arrayBuffer = await this.vault.readBinary(file);
content = await readDocument(arrayBuffer);
content = await readDocument(arrayBuffer, fileExtension);
} else {
content = [{ text: await this.vault.cachedRead(file), pageNumber: 1 }] as IPageText[];
}

View file

@ -41,26 +41,23 @@ function copyDir(src, dest) {
// Neutralises `new Function(...)` literals in the bundled output.
//
// All such literals come from dependencies, never from our own source:
// - unpdf/PDF.js: 3x feature-detection probes `new Function("")` wrapped in
// try/catch (a throw makes them return `false` = "eval not supported", the
// safe answer), plus 1x PostScript JIT compiler gated behind
// `isEvalSupported` (we pass `false`, so it is never entered).
// All such literals come from dependencies, never from our own source, and every
// one is dead code in our usage:
// - bluebird (transitive via mammoth): 7x promise-method JIT compilers, ALL
// gated behind `canEvaluate = typeof navigator == "undefined"`. Obsidian always
// defines `navigator`, so `canEvaluate` is false and bluebird takes its pure-JS
// fallbacks — the `new Function` branches are never entered. (call_get.js x2,
// join.js x3, promisify.js x2.)
// - diff2html/@profoundlogic/hogan: 2x template compilers reachable only via
// Hogan.compile(), which diff2html invokes only for runtime `rawTemplates`.
// We use diff2html's precompiled defaults, so these never run.
//
// Every occurrence is therefore dead code in our usage. We rewrite them to a
// throwing stub so the bundle contains no `new Function(` literal, which clears
// the Obsidian scanner's "Dynamic Code Execution" warning. The build FAILS if
// the count drifts from EXPECTED_NEW_FUNCTION_COUNT, so a dependency change can
// never silently reintroduce a live (or un-neutralised) dynamic-eval path.
// 9 = 5 PDF.js feature-detection probes (`new Function(""),!0}catch{return!1}`)
// + 2 PDF.js PostScript JIT compilers (gated behind `isEvalSupported`, which
// DocumentHelper passes as `false`) + 2 Hogan template compilers (reachable
// only via Hogan.compile(), unused with diff2html's precompiled templates).
// unpdf >=1.6 vendors PDF.js twice (main + worker/legacy), so the PDF.js probe
// and JIT sites each appear in duplicate; the Hogan pair is unchanged.
// We rewrite them to a throwing stub so the bundle contains no `new Function(`
// literal, which clears the Obsidian scanner's "Dynamic Code Execution" warning. The
// build FAILS if the count drifts from EXPECTED_NEW_FUNCTION_COUNT, so a dependency
// change can never silently reintroduce a live (or un-neutralised) dynamic-eval path.
//
// 9 = 7 bluebird (canEvaluate-gated) + 2 Hogan.
const EXPECTED_NEW_FUNCTION_COUNT = 9;
const NEW_FUNCTION_STUB = "VKBlockedDynamicFn";
function neutraliseDynamicEval(outfile) {
@ -94,98 +91,15 @@ function neutraliseDynamicEval(outfile) {
);
}
// Neutralises the `.wasm` filename literals in the bundled output.
//
// Every reference comes from unpdf's vendored PDF.js, which loads optional
// WebAssembly modules only when *rendering/decoding image streams*:
// - qcms_bg.wasm — colour management (ICC profile transforms)
// - jbig2.wasm — JBIG2 decoder (scanned bilevel images)
// - openjpeg.wasm — OpenJPEG decoder (JPEG2000 images)
// Our PDF code (Helpers/DocumentHelper.ts) only calls extractText for text and
// never renders, so none of these loaders is entered. Each is also gated behind
// private-field checks and resolves its file via a relative URL we do not ship,
// so even if reached it falls back to PDF.js's pure-JS path. (unpdf >=1.6
// vendors PDF.js twice, so qcms_bg.wasm appears in duplicate.)
//
// The literals are therefore dead, but their `.wasm` filenames trip Obsidian's
// "unrecognised .wasm" scanner (native binary that can't be statically
// reviewed). We rewrite each `*.wasm` filename to a non-`.wasm` name so no
// `.wasm` literal remains; the surrounding (unreachable) code stays intact. The
// build FAILS if an unexpected `.wasm` literal appears (or an expected one
// vanishes), so a dependency change can never silently reintroduce an
// undocumented .wasm reference.
//
// Keyed by literal filename → number of occurrences expected in the bundle.
const EXPECTED_WASM_REFS = {
"qcms_bg.wasm": 2,
"jbig2.wasm": 1,
"openjpeg.wasm": 1,
};
const WASM_REF_SUFFIX_FROM = ".wasm";
const WASM_REF_SUFFIX_TO = ".disabled-wasm";
function neutraliseWasmRef(outfile) {
if (!existsSync(outfile)) return;
let contents = readFileSync(outfile, "utf-8");
// Match the whole quoted/templated literal ending in `.wasm`, anchored on the
// quote/backtick. The negated class `[^'"`]` cannot overlap the delimiters, so
// this matches linearly — a `[\w.-]*\.wasm` form instead backtracks
// catastrophically on the megabytes of base64 in the dev inline sourcemap and
// hangs the watcher at 100% CPU.
const wasmLiterals = contents.match(/['"`][^'"`]*\.wasm['"`]/g) || [];
// Extract just the `<name>.wasm` basename from each literal: drop the quotes,
// and for template literals drop any `${...}` interpolation prefix (qcms refs
// look like `` `${...}qcms_bg.wasm` ``). The basename is the trailing run of
// filename characters ending in `.wasm`.
const found = {};
for (const lit of wasmLiterals) {
const inner = lit.slice(1, -1);
const name = (inner.match(/[\w.-]+\.wasm$/) || [inner])[0];
found[name] = (found[name] || 0) + 1;
}
// Any literal we don't recognise → fail loudly (new undocumented .wasm).
const unknown = Object.keys(found).filter((n) => !(n in EXPECTED_WASM_REFS));
if (unknown.length) {
throw new Error(
`neutraliseWasmRef: unexpected \`.wasm\` literal(s) in ${outfile}: ` +
`${unknown.join(", ")}. A dependency introduced a new .wasm reference — ` +
`re-audit that it is unreachable, then add it to EXPECTED_WASM_REFS.`,
);
}
// Each expected filename must be present with the expected count.
for (const [name, count] of Object.entries(EXPECTED_WASM_REFS)) {
if ((found[name] || 0) !== count) {
throw new Error(
`neutraliseWasmRef: expected ${count} \`${name}\` literal(s) in ` +
`${outfile} but found ${found[name] || 0}. The .wasm references ` +
`changed shape — re-audit before shipping, then update ` +
`EXPECTED_WASM_REFS.`,
);
}
}
// Rewrite only the `.wasm` extension on the literals we've vetted, so no
// `.wasm` string remains for the scanner to flag.
for (const name of Object.keys(EXPECTED_WASM_REFS)) {
contents = contents.split(name).join(
name.slice(0, -WASM_REF_SUFFIX_FROM.length) + WASM_REF_SUFFIX_TO,
);
}
writeFileSync(outfile, contents);
console.log(
`🛡️ Neutralised ${wasmLiterals.length} \`.wasm\` reference(s) ` +
`(${Object.keys(EXPECTED_WASM_REFS).join(", ")}) in ${outfile}`,
);
}
// Runs the dynamic-eval and .wasm neutralisation on every build and rebuild, for
// both dev and production, so the dev bundle matches what ships and the post-build
// step is exercised continuously rather than only at release time. Registered last
// so it transforms the finalised main.js.
// Runs the dynamic-eval neutralisation on every build and rebuild, for both dev and
// production, so the dev bundle matches what ships and the post-build step is
// exercised continuously rather than only at release time. Registered last so it
// transforms the finalised main.js.
const dynamicEvalPlugin = {
name: "neutralise-dynamic-eval",
setup(build) {
build.onEnd(() => {
neutraliseDynamicEval(build.initialOptions.outfile);
neutraliseWasmRef(build.initialOptions.outfile);
});
},
};
@ -253,64 +167,8 @@ const cssMergerPlugin = {
}
};
// officeparser's browser bundle is an IIFE (var officeParser = (()=> { ... })()) that doesn't
// set module.exports, so esbuild can't resolve its exports. This plugin intercepts the resolve
// and appends a CJS export line so imports work correctly.
//
// The browser bundle contains a dynamic-require shim (from its own esbuild bundling) that our
// esbuild would otherwise resolve against the `external` list, emitting require("fs") etc. in
// the final output. On Obsidian mobile there is no "fs" module, so we replace that shim with a
// stub that always throws, keeping the bundle self-contained.
//
// Every identifier in the shim is minified and churns between releases — not just the outer
// variable name (Au in v5, Vm in v6, Gs in v7.1.0, dl in v7.2.1) but also the closure params
// (r/t/e in v7.1.0 became n/e/t in v7.2.1). So the regex captures whatever identifiers appear
// rather than hardcoding any of them. If the shim's *shape* changes such that nothing matches,
// the build FAILS loudly — a silent no-op here previously let the stale v6 `Vm` regex match
// nothing on v7, and the hardcoded v7.1.0 param names broke against v7.2.1.
const officeParserPlugin = {
name: "officeparser-cjs-shim",
setup(build) {
build.onResolve({ filter: /^officeparser$/ }, () => ({
path: join(process.cwd(), 'node_modules', 'officeparser', 'dist', 'officeparser.browser.iife.js'),
namespace: 'officeparser-shim',
}));
build.onLoad({ filter: /.*/, namespace: 'officeparser-shim' }, async (args) => {
let contents = readFileSync(args.path, 'utf-8');
// Capture the (minified) outer var in group 1 and every other minified identifier in its
// own group, reusing the captures via backreferences so we match regardless of which letters
// esbuild chose this release. Group map: 1=outer var, 2=arrow param, 3/4=getter params,
// 5=function param.
const id = "[A-Za-z_$][\\w$]*";
const shimPattern = new RegExp(
`var (${id})=\\((${id})=>typeof require<"u"\\?require:typeof Proxy<"u"\\?` +
`new Proxy\\(\\2,\\{get:\\((${id}),(${id})\\)=>\\(typeof require<"u"\\?require:\\3\\)` +
`\\[\\4\\]\\}\\):\\2\\)\\(function\\((${id})\\)\\{if\\(typeof require<"u"\\)` +
`return require\\.apply\\(this,arguments\\);` +
`throw Error\\('Dynamic require of "'\\+\\5\\+'" is not supported'\\)\\}\\)`,
);
if (!shimPattern.test(contents)) {
throw new Error(
"officeparser-cjs-shim: dynamic-require shim not found in officeparser browser bundle. " +
"officeparser's bundling likely changed shape — re-audit the IIFE and update shimPattern " +
"before shipping, or require(\"fs\") may leak into the mobile bundle.",
);
}
contents = contents.replace(
shimPattern,
'var $1=(function(r){throw Error(\'Dynamic require of "\'+r+\'" is not supported\')})',
);
return {
contents: contents + '\nmodule.exports = officeParser;\n',
loader: 'js',
};
});
},
};
const buildOptions = {
plugins: [
officeParserPlugin,
esbuildSvelte({
compilerOptions: { css: "injected" },
preprocess: sveltePreprocess(),

8
global.d.ts vendored
View file

@ -12,3 +12,11 @@ declare module '*.png' {
const content: string;
export default content;
}
// mammoth ships type declarations for its default entry but not for the browser
// build we import (mammoth/mammoth.browser.js) for mobile safety. The browser build
// exposes the same public API, so we re-use mammoth's own types.
declare module 'mammoth/mammoth.browser.js' {
import mammoth from 'mammoth';
export = mammoth;
}

763
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -52,15 +52,15 @@
"diff": "^9.0.0",
"diff2html": "^3.4.56",
"express": "^5.2.1",
"fflate": "^0.8.3",
"fuzzysort": "^3.1.0",
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"lowlight": "^3.3.0",
"officeparser": "^7.2.2",
"mammoth": "^1.12.0",
"openai": "^6.45.0",
"path-browserify": "^1.0.1",
"regex-parser": "^2.3.1",
"unpdf": "^1.6.2",
"uuid": "^14.0.1",
"zod": "^4.4.3"
}