diff --git a/AGENTS.md b/AGENTS.md index 672ee9e..8f9e1ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: ## Project notes - The published package entry point and OpenCode tool registration live in `src/index.ts`; the reader, safety checks, format registry, and media extraction are split across `src/`. +- `src/registry.ts` resolves `officeparser` through named, default, and top-level exports because OpenCode's Bun loader can leave the named `OfficeParser` binding undefined even for version 7.5.1. - Run `npm run check && npm test` for the local typecheck and executable fixture suite. The DOCX, ODT, and PPTX fixtures are generated as real ZIP archives by `test/fixtures.ts`. ## Maintaining this file diff --git a/src/registry.ts b/src/registry.ts index d9cafd4..8e7128f 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,13 +1,62 @@ -import { OfficeParser } from "officeparser"; +import * as officeParser from "officeparser"; import type { OfficeParserConfig, SupportedFileType } from "officeparser"; import type { RichDocumentFormat } from "./types.ts"; -function officeFormat(parserType: SupportedFileType): RichDocumentFormat { +type ParseOffice = RichDocumentFormat["parse"]; + +interface OfficeParserModuleValue { + OfficeParser?: unknown; + default?: unknown; + parseOffice?: unknown; +} + +interface ResolvedParser { + parseOffice: ParseOffice; + receiver: unknown; +} + +function moduleValue(value: unknown): OfficeParserModuleValue | undefined { + if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined; + return value as OfficeParserModuleValue; +} + +// Bun's CJS interop can leave the named OfficeParser binding undefined while +// retaining the default class and top-level parseOffice export. +function resolveOfficeParser(value: unknown, seen = new Set()): ResolvedParser | undefined { + const candidate = moduleValue(value); + if (!candidate) return undefined; + + const reference = value as object; + if (seen.has(reference)) return undefined; + seen.add(reference); + + for (const nested of [candidate.OfficeParser, candidate.default]) { + const resolved = resolveOfficeParser(nested, seen); + if (resolved) return resolved; + } + + if (typeof candidate.parseOffice === "function") { + return { parseOffice: candidate.parseOffice as ParseOffice, receiver: value }; + } + return undefined; +} + +function parserFor(module: unknown): ResolvedParser { + const parser = resolveOfficeParser(module); + if (!parser) throw new TypeError("officeparser does not expose a parseOffice function"); + return parser; +} + +export function createOfficeFormat( + parserType: SupportedFileType, + parserModule: unknown = officeParser, +): RichDocumentFormat { return { extension: `.${parserType}`, parserType, parse(input: Uint8Array, config: OfficeParserConfig) { - return OfficeParser.parseOffice(input, { ...config, fileType: parserType }); + const parser = parserFor(parserModule); + return parser.parseOffice.call(parser.receiver, input, { ...config, fileType: parserType }); }, }; } @@ -20,7 +69,7 @@ function officeFormat(parserType: SupportedFileType): RichDocumentFormat { export const formatRegistry: ReadonlyMap = new Map( ["docx", "odt", "pptx"].map((format) => { const parserType = format as SupportedFileType; - return [`.${format}`, officeFormat(parserType)] as const; + return [`.${format}`, createOfficeFormat(parserType)] as const; }), ); diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 37f1dec..3c73c5b 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -2,18 +2,23 @@ import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import { mkdir, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; -import type { OfficeParserAST } from "officeparser"; +import type { OfficeParserAST, OfficeParserConfig } from "officeparser"; import { RichDocumentReaderPlugin } from "../src/index.ts"; import { READER_LIMITS } from "../src/limits.ts"; import { readRichDocument } from "../src/reader.ts"; +import { createOfficeFormat } from "../src/registry.ts"; import { createFixtures } from "./fixtures.ts"; let fixtures: Awaited>; const extractedDirectories = new Set(); function context() { + return projectContext(fixtures.root); +} + +function projectContext(projectRoot: string) { const abort = new AbortController(); - return { directory: fixtures.root, worktree: fixtures.root, abort: abort.signal }; + return { directory: projectRoot, worktree: projectRoot, abort: abort.signal }; } function rememberExtraction(result: { metadata?: { media?: Array<{ temporaryPath: string }> } }) { @@ -40,6 +45,49 @@ describe("plugin registration", () => { }); describe("read_rich_document", () => { + it("resolves named, default, and top-level officeparser exports", async () => { + const ast = {} as OfficeParserAST; + const calls: OfficeParserConfig[] = []; + const parseOffice = async (_input: Uint8Array, config: OfficeParserConfig) => { + calls.push(config); + return ast; + }; + const namedParser = { parseOffice }; + const defaultParser = Object.assign(function OfficeParser() {}, { parseOffice }); + + for (const parserModule of [ + { OfficeParser: namedParser }, + { default: defaultParser }, + { parseOffice }, + { OfficeParser: undefined, default: defaultParser, parseOffice }, + ]) { + const format = createOfficeFormat("docx", parserModule); + assert.equal(await format.parse(new Uint8Array(), {}), ast); + } + + assert.deepEqual(calls.map(({ fileType }) => fileType), ["docx", "docx", "docx", "docx"]); + }); + + it("reads the supplied pesticide records DOCX with content and metadata", async (t) => { + const projectRoot = process.env.OPENCODE_RICH_DOCUMENT_SMOKE_ROOT; + if (!projectRoot) { + t.skip("set OPENCODE_RICH_DOCUMENT_SMOKE_ROOT to run the external supplied-document smoke test"); + return; + } + + const sourcePath = "docs/maint-div/Pesticide Application Records.docx"; + assert.equal((await stat(join(projectRoot, sourcePath))).isFile(), true); + + const result = await readRichDocument({ path: sourcePath }, projectContext(projectRoot)); + + assert.match(result.output, /Pesticide Application Records/); + assert.match(result.output, /Applicators Name/); + assert.ok(result.metadata); + assert.equal(result.metadata.format, "docx"); + assert.equal(result.metadata.sourcePath, sourcePath); + assert.deepEqual(result.metadata.media, []); + }); + it("extracts DOCX structure, media, and section association without default attachments", async () => { const result = await readRichDocument({ path: "structure.docx" }, context()); rememberExtraction(result);