Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 53 additions & 4 deletions src/registry.ts
Original file line number Diff line number Diff line change
@@ -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<object>()): 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 });
},
};
}
Expand All @@ -20,7 +69,7 @@ function officeFormat(parserType: SupportedFileType): RichDocumentFormat {
export const formatRegistry: ReadonlyMap<string, RichDocumentFormat> = new Map(
["docx", "odt", "pptx"].map((format) => {
const parserType = format as SupportedFileType;
return [`.${format}`, officeFormat(parserType)] as const;
return [`.${format}`, createOfficeFormat(parserType)] as const;
}),
);

Expand Down
52 changes: 50 additions & 2 deletions test/read-rich-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createFixtures>>;
const extractedDirectories = new Set<string>();

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 }> } }) {
Expand All @@ -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);
Expand Down
Loading