@echofiles/echo-pdf 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -11
- package/bin/echo-pdf.js +176 -8
- package/bin/lib/http.js +26 -1
- package/dist/agent-defaults.d.ts +3 -0
- package/dist/agent-defaults.js +18 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +36 -0
- package/dist/core/index.d.ts +50 -0
- package/dist/core/index.js +7 -0
- package/dist/file-ops.d.ts +11 -0
- package/dist/file-ops.js +36 -0
- package/dist/file-store-do.d.ts +36 -0
- package/dist/file-store-do.js +298 -0
- package/dist/file-utils.d.ts +6 -0
- package/dist/file-utils.js +36 -0
- package/dist/http-error.d.ts +9 -0
- package/dist/http-error.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local/index.d.ts +135 -0
- package/dist/local/index.js +555 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.js +124 -0
- package/dist/node/pdfium-local.d.ts +8 -0
- package/dist/node/pdfium-local.js +147 -0
- package/dist/node/semantic-local.d.ts +16 -0
- package/dist/node/semantic-local.js +113 -0
- package/dist/pdf-agent.d.ts +18 -0
- package/dist/pdf-agent.js +217 -0
- package/dist/pdf-config.d.ts +4 -0
- package/dist/pdf-config.js +140 -0
- package/dist/pdf-storage.d.ts +8 -0
- package/dist/pdf-storage.js +86 -0
- package/dist/pdf-types.d.ts +83 -0
- package/dist/pdf-types.js +1 -0
- package/dist/pdfium-engine.d.ts +9 -0
- package/dist/pdfium-engine.js +180 -0
- package/dist/provider-client.d.ts +20 -0
- package/dist/provider-client.js +173 -0
- package/dist/provider-keys.d.ts +10 -0
- package/dist/provider-keys.js +27 -0
- package/dist/r2-file-store.d.ts +20 -0
- package/dist/r2-file-store.js +176 -0
- package/dist/response-schema.d.ts +15 -0
- package/dist/response-schema.js +159 -0
- package/dist/tool-registry.d.ts +16 -0
- package/dist/tool-registry.js +175 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +386 -0
- package/package.json +34 -5
- package/wrangler.toml +1 -1
- package/src/agent-defaults.ts +0 -25
- package/src/file-ops.ts +0 -50
- package/src/file-store-do.ts +0 -349
- package/src/file-utils.ts +0 -43
- package/src/http-error.ts +0 -21
- package/src/index.ts +0 -415
- package/src/mcp-server.ts +0 -171
- package/src/pdf-agent.ts +0 -252
- package/src/pdf-config.ts +0 -143
- package/src/pdf-storage.ts +0 -109
- package/src/pdf-types.ts +0 -85
- package/src/pdfium-engine.ts +0 -207
- package/src/provider-client.ts +0 -176
- package/src/provider-keys.ts +0 -44
- package/src/r2-file-store.ts +0 -195
- package/src/response-schema.ts +0 -182
- package/src/tool-registry.ts +0 -203
- package/src/types.ts +0 -40
- package/src/wasm.d.ts +0 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { checkHeaderAuth } from "./auth.js";
|
|
2
|
+
import { buildMcpContent, buildToolOutputEnvelope } from "./response-schema.js";
|
|
3
|
+
import { callTool, listToolSchemas } from "./tool-registry.js";
|
|
4
|
+
const ok = (id, result) => new Response(JSON.stringify({
|
|
5
|
+
jsonrpc: "2.0",
|
|
6
|
+
id: id ?? null,
|
|
7
|
+
result,
|
|
8
|
+
}), { headers: { "Content-Type": "application/json" } });
|
|
9
|
+
const err = (id, code, message, data, httpStatus = 400) => new Response(JSON.stringify({
|
|
10
|
+
jsonrpc: "2.0",
|
|
11
|
+
id: id ?? null,
|
|
12
|
+
error: data ? { code, message, data } : { code, message },
|
|
13
|
+
}), { status: httpStatus, headers: { "Content-Type": "application/json" } });
|
|
14
|
+
const asObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
|
|
15
|
+
const resolvePublicBaseUrl = (request, configured) => typeof configured === "string" && configured.length > 0 ? configured : request.url;
|
|
16
|
+
const prepareMcpToolArgs = (toolName, args) => {
|
|
17
|
+
if (toolName === "pdf_extract_pages") {
|
|
18
|
+
const mode = typeof args.returnMode === "string" ? args.returnMode : "";
|
|
19
|
+
if (!mode) {
|
|
20
|
+
return { ...args, returnMode: "url" };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return args;
|
|
24
|
+
};
|
|
25
|
+
export const handleMcpRequest = async (request, env, config, fileStore) => {
|
|
26
|
+
const auth = checkHeaderAuth(request, env, {
|
|
27
|
+
authHeader: config.mcp.authHeader,
|
|
28
|
+
authEnv: config.mcp.authEnv,
|
|
29
|
+
allowMissingSecret: env.ECHO_PDF_ALLOW_MISSING_AUTH_SECRET === "1",
|
|
30
|
+
misconfiguredCode: "AUTH_MISCONFIGURED",
|
|
31
|
+
unauthorizedCode: "UNAUTHORIZED",
|
|
32
|
+
contextName: "MCP",
|
|
33
|
+
});
|
|
34
|
+
if (!auth.ok) {
|
|
35
|
+
return err(null, -32001, auth.message, { status: auth.status, code: auth.code }, 200);
|
|
36
|
+
}
|
|
37
|
+
let body;
|
|
38
|
+
try {
|
|
39
|
+
body = (await request.json());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return err(null, -32700, "Parse error");
|
|
43
|
+
}
|
|
44
|
+
if (typeof body !== "object" || body === null) {
|
|
45
|
+
return err(null, -32600, "Invalid Request");
|
|
46
|
+
}
|
|
47
|
+
if (body.jsonrpc !== "2.0") {
|
|
48
|
+
return err(body.id ?? null, -32600, "Invalid Request: jsonrpc must be '2.0'");
|
|
49
|
+
}
|
|
50
|
+
const method = body.method ?? "";
|
|
51
|
+
const id = body.id ?? null;
|
|
52
|
+
if (typeof method !== "string" || method.length === 0) {
|
|
53
|
+
return err(id, -32600, "Invalid Request: method is required");
|
|
54
|
+
}
|
|
55
|
+
if (method.startsWith("notifications/")) {
|
|
56
|
+
return new Response(null, { status: 204 });
|
|
57
|
+
}
|
|
58
|
+
const params = asObj(body.params);
|
|
59
|
+
if (method === "initialize") {
|
|
60
|
+
return ok(id, {
|
|
61
|
+
protocolVersion: "2024-11-05",
|
|
62
|
+
serverInfo: {
|
|
63
|
+
name: config.mcp.serverName,
|
|
64
|
+
version: config.mcp.version,
|
|
65
|
+
},
|
|
66
|
+
capabilities: {
|
|
67
|
+
tools: {},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (method === "tools/list") {
|
|
72
|
+
return ok(id, { tools: listToolSchemas().map((tool) => ({
|
|
73
|
+
name: tool.name,
|
|
74
|
+
description: tool.description,
|
|
75
|
+
inputSchema: tool.inputSchema,
|
|
76
|
+
})) });
|
|
77
|
+
}
|
|
78
|
+
if (method !== "tools/call") {
|
|
79
|
+
return err(id, -32601, `Unsupported method: ${method}`);
|
|
80
|
+
}
|
|
81
|
+
const toolName = typeof params.name === "string" ? params.name : "";
|
|
82
|
+
const args = prepareMcpToolArgs(toolName, asObj(params.arguments));
|
|
83
|
+
if (!toolName) {
|
|
84
|
+
return err(id, -32602, "Invalid params: name is required", {
|
|
85
|
+
code: "INVALID_PARAMS",
|
|
86
|
+
status: 400,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const result = await callTool(toolName, args, {
|
|
91
|
+
config,
|
|
92
|
+
env,
|
|
93
|
+
fileStore,
|
|
94
|
+
});
|
|
95
|
+
const envelope = buildToolOutputEnvelope(result, resolvePublicBaseUrl(request, config.service.publicBaseUrl));
|
|
96
|
+
return ok(id, { content: buildMcpContent(envelope) });
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
const status = error?.status;
|
|
101
|
+
const stableStatus = typeof status === "number" && Number.isFinite(status) ? status : 500;
|
|
102
|
+
const code = error?.code;
|
|
103
|
+
const details = error?.details;
|
|
104
|
+
if (message.startsWith("Unknown tool:")) {
|
|
105
|
+
return err(id, -32601, message, {
|
|
106
|
+
code: typeof code === "string" ? code : "TOOL_NOT_FOUND",
|
|
107
|
+
status: 404,
|
|
108
|
+
details,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (stableStatus >= 400 && stableStatus < 500) {
|
|
112
|
+
return err(id, -32602, message, {
|
|
113
|
+
code: typeof code === "string" ? code : "INVALID_PARAMS",
|
|
114
|
+
status: stableStatus,
|
|
115
|
+
details,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return err(id, -32000, message, {
|
|
119
|
+
code: typeof code === "string" ? code : "INTERNAL_ERROR",
|
|
120
|
+
status: stableStatus,
|
|
121
|
+
details,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EchoPdfConfig } from "../pdf-types.js";
|
|
2
|
+
export declare const getLocalPdfPageCount: (config: EchoPdfConfig, bytes: Uint8Array) => Promise<number>;
|
|
3
|
+
export declare const renderLocalPdfPageToPng: (config: EchoPdfConfig, bytes: Uint8Array, pageIndex: number, scale?: number) => Promise<{
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
png: Uint8Array;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const extractLocalPdfPageText: (config: EchoPdfConfig, bytes: Uint8Array, pageIndex: number) => Promise<string>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/// <reference path="./compat.d.ts" />
|
|
2
|
+
import { encode as encodePng } from "@cf-wasm/png";
|
|
3
|
+
import { init } from "@embedpdf/pdfium";
|
|
4
|
+
let moduleInstance = null;
|
|
5
|
+
let libraryInitialized = false;
|
|
6
|
+
const isNodeRuntime = () => typeof process !== "undefined" && Boolean(process.versions?.node);
|
|
7
|
+
const ensureWasmFunctionShim = () => {
|
|
8
|
+
const wasmApi = WebAssembly;
|
|
9
|
+
if (typeof wasmApi.Function === "function")
|
|
10
|
+
return;
|
|
11
|
+
wasmApi.Function = (_sig, fn) => fn;
|
|
12
|
+
};
|
|
13
|
+
const readLocalPdfiumWasm = async () => {
|
|
14
|
+
const [{ readFile }, { createRequire }] = await Promise.all([
|
|
15
|
+
import("node:fs/promises"),
|
|
16
|
+
import("node:module"),
|
|
17
|
+
]);
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const bytes = await readFile(require.resolve("@embedpdf/pdfium/pdfium.wasm"));
|
|
20
|
+
return new Uint8Array(bytes).slice().buffer;
|
|
21
|
+
};
|
|
22
|
+
const ensureLocalPdfium = async (_config) => {
|
|
23
|
+
if (!isNodeRuntime()) {
|
|
24
|
+
throw new Error("local document APIs require a Node-compatible runtime");
|
|
25
|
+
}
|
|
26
|
+
ensureWasmFunctionShim();
|
|
27
|
+
if (!moduleInstance) {
|
|
28
|
+
moduleInstance = await init({ wasmBinary: await readLocalPdfiumWasm() });
|
|
29
|
+
}
|
|
30
|
+
if (!libraryInitialized) {
|
|
31
|
+
moduleInstance.FPDF_InitLibrary();
|
|
32
|
+
libraryInitialized = true;
|
|
33
|
+
}
|
|
34
|
+
return moduleInstance;
|
|
35
|
+
};
|
|
36
|
+
const makeDoc = (pdfium, bytes) => {
|
|
37
|
+
const memPtr = pdfium.pdfium.wasmExports.malloc(bytes.length);
|
|
38
|
+
pdfium.pdfium.HEAPU8.set(bytes, memPtr);
|
|
39
|
+
const doc = pdfium.FPDF_LoadMemDocument(memPtr, bytes.length, "");
|
|
40
|
+
if (!doc) {
|
|
41
|
+
pdfium.pdfium.wasmExports.free(memPtr);
|
|
42
|
+
throw new Error("Failed to load PDF document");
|
|
43
|
+
}
|
|
44
|
+
return { doc, memPtr };
|
|
45
|
+
};
|
|
46
|
+
const closeDoc = (pdfium, doc, memPtr) => {
|
|
47
|
+
pdfium.FPDF_CloseDocument(doc);
|
|
48
|
+
pdfium.pdfium.wasmExports.free(memPtr);
|
|
49
|
+
};
|
|
50
|
+
const decodeUtf16Le = (buf) => {
|
|
51
|
+
const view = new Uint16Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 2));
|
|
52
|
+
const chars = [];
|
|
53
|
+
for (const code of view) {
|
|
54
|
+
if (code === 0)
|
|
55
|
+
break;
|
|
56
|
+
chars.push(code);
|
|
57
|
+
}
|
|
58
|
+
return String.fromCharCode(...chars);
|
|
59
|
+
};
|
|
60
|
+
const bgraToRgba = (bgra) => {
|
|
61
|
+
const rgba = new Uint8Array(bgra.length);
|
|
62
|
+
for (let i = 0; i < bgra.length; i += 4) {
|
|
63
|
+
rgba[i] = bgra[i + 2] ?? 0;
|
|
64
|
+
rgba[i + 1] = bgra[i + 1] ?? 0;
|
|
65
|
+
rgba[i + 2] = bgra[i] ?? 0;
|
|
66
|
+
rgba[i + 3] = bgra[i + 3] ?? 255;
|
|
67
|
+
}
|
|
68
|
+
return rgba;
|
|
69
|
+
};
|
|
70
|
+
export const getLocalPdfPageCount = async (config, bytes) => {
|
|
71
|
+
const pdfium = await ensureLocalPdfium(config);
|
|
72
|
+
const { doc, memPtr } = makeDoc(pdfium, bytes);
|
|
73
|
+
try {
|
|
74
|
+
return pdfium.FPDF_GetPageCount(doc);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
closeDoc(pdfium, doc, memPtr);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
export const renderLocalPdfPageToPng = async (config, bytes, pageIndex, scale = config.service.defaultRenderScale) => {
|
|
81
|
+
const pdfium = await ensureLocalPdfium(config);
|
|
82
|
+
const { doc, memPtr } = makeDoc(pdfium, bytes);
|
|
83
|
+
let page = 0;
|
|
84
|
+
let bitmap = 0;
|
|
85
|
+
try {
|
|
86
|
+
page = pdfium.FPDF_LoadPage(doc, pageIndex);
|
|
87
|
+
if (!page) {
|
|
88
|
+
throw new Error(`Failed to load page ${pageIndex}`);
|
|
89
|
+
}
|
|
90
|
+
const width = Math.max(1, Math.round(pdfium.FPDF_GetPageWidthF(page) * scale));
|
|
91
|
+
const height = Math.max(1, Math.round(pdfium.FPDF_GetPageHeightF(page) * scale));
|
|
92
|
+
bitmap = pdfium.FPDFBitmap_Create(width, height, 1);
|
|
93
|
+
if (!bitmap) {
|
|
94
|
+
throw new Error("Failed to create bitmap");
|
|
95
|
+
}
|
|
96
|
+
pdfium.FPDFBitmap_FillRect(bitmap, 0, 0, width, height, 0xffffffff);
|
|
97
|
+
pdfium.FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, 0);
|
|
98
|
+
const stride = pdfium.FPDFBitmap_GetStride(bitmap);
|
|
99
|
+
const bufferPtr = pdfium.FPDFBitmap_GetBuffer(bitmap);
|
|
100
|
+
const heap = pdfium.pdfium.HEAPU8;
|
|
101
|
+
const bgra = heap.slice(bufferPtr, bufferPtr + stride * height);
|
|
102
|
+
const rgba = bgraToRgba(bgra);
|
|
103
|
+
const png = encodePng(rgba, width, height);
|
|
104
|
+
return { width, height, png };
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (bitmap)
|
|
108
|
+
pdfium.FPDFBitmap_Destroy(bitmap);
|
|
109
|
+
if (page)
|
|
110
|
+
pdfium.FPDF_ClosePage(page);
|
|
111
|
+
closeDoc(pdfium, doc, memPtr);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
export const extractLocalPdfPageText = async (config, bytes, pageIndex) => {
|
|
115
|
+
const pdfium = await ensureLocalPdfium(config);
|
|
116
|
+
const { doc, memPtr } = makeDoc(pdfium, bytes);
|
|
117
|
+
let page = 0;
|
|
118
|
+
let textPage = 0;
|
|
119
|
+
let outPtr = 0;
|
|
120
|
+
try {
|
|
121
|
+
page = pdfium.FPDF_LoadPage(doc, pageIndex);
|
|
122
|
+
if (!page) {
|
|
123
|
+
throw new Error(`Failed to load page ${pageIndex}`);
|
|
124
|
+
}
|
|
125
|
+
textPage = pdfium.FPDFText_LoadPage(page);
|
|
126
|
+
if (!textPage)
|
|
127
|
+
return "";
|
|
128
|
+
const chars = pdfium.FPDFText_CountChars(textPage);
|
|
129
|
+
if (chars <= 0)
|
|
130
|
+
return "";
|
|
131
|
+
const bytesLen = (chars + 1) * 2;
|
|
132
|
+
outPtr = pdfium.pdfium.wasmExports.malloc(bytesLen);
|
|
133
|
+
pdfium.FPDFText_GetText(textPage, 0, chars, outPtr);
|
|
134
|
+
const heap = pdfium.pdfium.HEAPU8;
|
|
135
|
+
const raw = heap.slice(outPtr, outPtr + bytesLen);
|
|
136
|
+
return decodeUtf16Le(raw).trim();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (outPtr)
|
|
140
|
+
pdfium.pdfium.wasmExports.free(outPtr);
|
|
141
|
+
if (textPage)
|
|
142
|
+
pdfium.FPDFText_ClosePage(textPage);
|
|
143
|
+
if (page)
|
|
144
|
+
pdfium.FPDF_ClosePage(page);
|
|
145
|
+
closeDoc(pdfium, doc, memPtr);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface SemanticPageInput {
|
|
2
|
+
readonly pageNumber: number;
|
|
3
|
+
readonly text: string;
|
|
4
|
+
readonly artifactPath: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SemanticSectionNode {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly type: "section";
|
|
9
|
+
readonly title: string;
|
|
10
|
+
readonly level: number;
|
|
11
|
+
readonly pageNumber: number;
|
|
12
|
+
readonly pageArtifactPath: string;
|
|
13
|
+
readonly excerpt: string;
|
|
14
|
+
readonly children: ReadonlyArray<SemanticSectionNode>;
|
|
15
|
+
}
|
|
16
|
+
export declare const buildSemanticSectionTree: (pages: ReadonlyArray<SemanticPageInput>) => ReadonlyArray<SemanticSectionNode>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const normalizeLine = (value) => value.replace(/\s+/g, " ").trim();
|
|
2
|
+
const excerptFor = (value) => normalizeLine(value).slice(0, 160);
|
|
3
|
+
const hasTocSuffix = (value) => /(?:\.{2,}|\s{2,}|\t)\d+$/.test(value);
|
|
4
|
+
const hasTrailingPageNumber = (value) => /\s\d+$/.test(value);
|
|
5
|
+
const isContentsHeading = (value) => {
|
|
6
|
+
const normalized = normalizeLine(value).toLowerCase();
|
|
7
|
+
return normalized === "contents" || normalized === "table of contents" || normalized === "目录";
|
|
8
|
+
};
|
|
9
|
+
const detectHeading = (line) => {
|
|
10
|
+
const normalized = normalizeLine(line);
|
|
11
|
+
if (!normalized || normalized.length > 120)
|
|
12
|
+
return null;
|
|
13
|
+
if (hasTocSuffix(normalized))
|
|
14
|
+
return null;
|
|
15
|
+
const numbered = normalized.match(/^(\d+(?:\.\d+){0,3})\s+(.+)$/);
|
|
16
|
+
if (numbered) {
|
|
17
|
+
const numberPath = numbered[1] || "";
|
|
18
|
+
const topLevelNumber = Number.parseInt(numberPath.split(".")[0] || "", 10);
|
|
19
|
+
const title = normalizeLine(numbered[2] || "");
|
|
20
|
+
const level = numberPath.split(".").length;
|
|
21
|
+
if (!title)
|
|
22
|
+
return null;
|
|
23
|
+
if (title.length < 2)
|
|
24
|
+
return null;
|
|
25
|
+
if (hasTrailingPageNumber(normalized))
|
|
26
|
+
return null;
|
|
27
|
+
if (!/^[A-Za-z\u4E00-\u9FFF第((]/.test(title))
|
|
28
|
+
return null;
|
|
29
|
+
if (/^(GHz|MHz|Kbps|Mbps|Hz|kHz|mA|V|W)\b/i.test(title))
|
|
30
|
+
return null;
|
|
31
|
+
if (/[。;;::]$/.test(title))
|
|
32
|
+
return null;
|
|
33
|
+
if (Number.isFinite(topLevelNumber) && topLevelNumber > 20)
|
|
34
|
+
return null;
|
|
35
|
+
if (/^[A-Z]+\d+$/.test(title))
|
|
36
|
+
return null;
|
|
37
|
+
if (level === 1 && title.length > 40)
|
|
38
|
+
return null;
|
|
39
|
+
if (level === 1 && /[,,×—]/.test(title))
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
title: `${numberPath} ${title}`.trim(),
|
|
43
|
+
level,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const chinese = normalized.match(/^(第[0-9一二三四五六七八九十百]+)(章|节|部分)\s+(.+)$/);
|
|
47
|
+
if (chinese) {
|
|
48
|
+
const suffix = chinese[2] || "";
|
|
49
|
+
return {
|
|
50
|
+
title: normalized,
|
|
51
|
+
level: suffix === "节" ? 2 : 1,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const english = normalized.match(/^(Chapter|Section|Part|Appendix)\b[:\s-]*(.+)?$/i);
|
|
55
|
+
if (english) {
|
|
56
|
+
return {
|
|
57
|
+
title: normalized,
|
|
58
|
+
level: /section/i.test(english[1] || "") ? 2 : 1,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
const toReadonlyTree = (node) => ({
|
|
64
|
+
...node,
|
|
65
|
+
children: node.children.map(toReadonlyTree),
|
|
66
|
+
});
|
|
67
|
+
export const buildSemanticSectionTree = (pages) => {
|
|
68
|
+
const rootChildren = [];
|
|
69
|
+
const stack = [];
|
|
70
|
+
const emittedKeys = new Set();
|
|
71
|
+
let nextId = 1;
|
|
72
|
+
for (const page of pages) {
|
|
73
|
+
const lines = page.text
|
|
74
|
+
.split(/\r?\n/)
|
|
75
|
+
.map(normalizeLine)
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (lines.length === 0)
|
|
78
|
+
continue;
|
|
79
|
+
const contentsPage = isContentsHeading(lines[0] || "");
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const heading = detectHeading(line);
|
|
82
|
+
if (!heading || contentsPage)
|
|
83
|
+
continue;
|
|
84
|
+
const emittedKey = `${heading.level}:${heading.title}`;
|
|
85
|
+
if (emittedKeys.has(emittedKey))
|
|
86
|
+
continue;
|
|
87
|
+
const node = {
|
|
88
|
+
id: `section-${nextId}`,
|
|
89
|
+
type: "section",
|
|
90
|
+
title: heading.title,
|
|
91
|
+
level: heading.level,
|
|
92
|
+
pageNumber: page.pageNumber,
|
|
93
|
+
pageArtifactPath: page.artifactPath,
|
|
94
|
+
excerpt: excerptFor(line),
|
|
95
|
+
children: [],
|
|
96
|
+
};
|
|
97
|
+
nextId += 1;
|
|
98
|
+
emittedKeys.add(emittedKey);
|
|
99
|
+
while (stack.length > 0 && (stack[stack.length - 1]?.level || 0) >= heading.level) {
|
|
100
|
+
stack.pop();
|
|
101
|
+
}
|
|
102
|
+
const parent = stack[stack.length - 1];
|
|
103
|
+
if (parent) {
|
|
104
|
+
parent.children.push(node);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
rootChildren.push(node);
|
|
108
|
+
}
|
|
109
|
+
stack.push(node);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return rootChildren.map(toReadonlyTree);
|
|
113
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Env, FileStore } from "./types.js";
|
|
2
|
+
import type { AgentTraceEvent, EchoPdfConfig, PdfOperationRequest } from "./pdf-types.js";
|
|
3
|
+
interface RuntimeOptions {
|
|
4
|
+
readonly trace?: (event: AgentTraceEvent) => void;
|
|
5
|
+
readonly fileStore: FileStore;
|
|
6
|
+
}
|
|
7
|
+
export declare const ingestPdfFromPayload: (config: EchoPdfConfig, input: {
|
|
8
|
+
readonly fileId?: string;
|
|
9
|
+
readonly url?: string;
|
|
10
|
+
readonly base64?: string;
|
|
11
|
+
readonly filename?: string;
|
|
12
|
+
}, opts: RuntimeOptions) => Promise<{
|
|
13
|
+
id: string;
|
|
14
|
+
filename: string;
|
|
15
|
+
bytes: Uint8Array;
|
|
16
|
+
}>;
|
|
17
|
+
export declare const runPdfAgent: (config: EchoPdfConfig, env: Env, request: PdfOperationRequest, opts: RuntimeOptions) => Promise<unknown>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { resolveModelForProvider, resolveProviderAlias } from "./agent-defaults.js";
|
|
2
|
+
import { fromBase64, normalizeReturnMode, toDataUrl } from "./file-utils.js";
|
|
3
|
+
import { badRequest, notFound, unprocessable } from "./http-error.js";
|
|
4
|
+
import { extractPdfPageText, getPdfPageCount, renderPdfPageToPng, toBytes } from "./pdfium-engine.js";
|
|
5
|
+
import { visionRecognize } from "./provider-client.js";
|
|
6
|
+
const traceStep = (opts, phase, name, payload, level) => {
|
|
7
|
+
if (!opts.trace)
|
|
8
|
+
return;
|
|
9
|
+
opts.trace({ kind: "step", phase, name, payload, level });
|
|
10
|
+
};
|
|
11
|
+
const ensurePages = (pages, pageCount, maxPages) => {
|
|
12
|
+
if (pages.length === 0)
|
|
13
|
+
throw badRequest("PAGES_REQUIRED", "At least one page is required");
|
|
14
|
+
if (pages.length > maxPages) {
|
|
15
|
+
throw badRequest("TOO_MANY_PAGES", `Page count exceeds maxPagesPerRequest (${maxPages})`, {
|
|
16
|
+
maxPagesPerRequest: maxPages,
|
|
17
|
+
providedPages: pages.length,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
for (const page of pages) {
|
|
21
|
+
if (!Number.isInteger(page) || page < 1 || page > pageCount) {
|
|
22
|
+
throw badRequest("PAGE_OUT_OF_RANGE", `Page ${page} out of range 1..${pageCount}`, {
|
|
23
|
+
page,
|
|
24
|
+
min: 1,
|
|
25
|
+
max: pageCount,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(pages)].sort((a, b) => a - b);
|
|
30
|
+
};
|
|
31
|
+
export const ingestPdfFromPayload = async (config, input, opts) => {
|
|
32
|
+
if (input.fileId) {
|
|
33
|
+
const existing = await opts.fileStore.get(input.fileId);
|
|
34
|
+
if (!existing) {
|
|
35
|
+
throw notFound("FILE_NOT_FOUND", `File not found: ${input.fileId}`, { fileId: input.fileId });
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
id: existing.id,
|
|
39
|
+
filename: existing.filename,
|
|
40
|
+
bytes: existing.bytes,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
let bytes = null;
|
|
44
|
+
let filename = input.filename ?? "document.pdf";
|
|
45
|
+
if (input.url) {
|
|
46
|
+
traceStep(opts, "start", "file.fetch.url", { url: input.url });
|
|
47
|
+
try {
|
|
48
|
+
bytes = await toBytes(input.url);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw badRequest("URL_FETCH_FAILED", `Unable to fetch PDF from url: ${error instanceof Error ? error.message : String(error)}`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(input.url);
|
|
55
|
+
filename = decodeURIComponent(u.pathname.split("/").pop() || filename);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// ignore URL parse failure
|
|
59
|
+
}
|
|
60
|
+
traceStep(opts, "end", "file.fetch.url", { sizeBytes: bytes.byteLength });
|
|
61
|
+
}
|
|
62
|
+
else if (input.base64) {
|
|
63
|
+
traceStep(opts, "start", "file.decode.base64");
|
|
64
|
+
bytes = fromBase64(input.base64);
|
|
65
|
+
traceStep(opts, "end", "file.decode.base64", { sizeBytes: bytes.byteLength });
|
|
66
|
+
}
|
|
67
|
+
if (!bytes) {
|
|
68
|
+
throw badRequest("MISSING_FILE_INPUT", "Missing file input. Provide fileId, url or base64");
|
|
69
|
+
}
|
|
70
|
+
if (bytes.byteLength > config.service.maxPdfBytes) {
|
|
71
|
+
throw badRequest("PDF_TOO_LARGE", `PDF exceeds max size (${config.service.maxPdfBytes} bytes)`, {
|
|
72
|
+
maxPdfBytes: config.service.maxPdfBytes,
|
|
73
|
+
sizeBytes: bytes.byteLength,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const meta = await opts.fileStore.put({
|
|
77
|
+
filename,
|
|
78
|
+
mimeType: "application/pdf",
|
|
79
|
+
bytes,
|
|
80
|
+
});
|
|
81
|
+
traceStep(opts, "end", "file.stored", { fileId: meta.id, sizeBytes: meta.sizeBytes });
|
|
82
|
+
return {
|
|
83
|
+
id: meta.id,
|
|
84
|
+
filename: meta.filename,
|
|
85
|
+
bytes,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
const resolveReturnMode = (value) => normalizeReturnMode(value);
|
|
89
|
+
const stripCodeFences = (value) => {
|
|
90
|
+
const text = value.trim();
|
|
91
|
+
const fenced = text.match(/^```[a-zA-Z0-9_-]*\n([\s\S]*?)\n```$/);
|
|
92
|
+
return typeof fenced?.[1] === "string" ? fenced[1].trim() : text;
|
|
93
|
+
};
|
|
94
|
+
const extractTabularLatex = (value) => {
|
|
95
|
+
const text = stripCodeFences(value);
|
|
96
|
+
const blocks = text.match(/\\begin\{tabular\}[\s\S]*?\\end\{tabular\}/g);
|
|
97
|
+
if (!blocks || blocks.length === 0)
|
|
98
|
+
return "";
|
|
99
|
+
return blocks.map((b) => b.trim()).join("\n\n");
|
|
100
|
+
};
|
|
101
|
+
export const runPdfAgent = async (config, env, request, opts) => {
|
|
102
|
+
traceStep(opts, "start", "pdf.operation", { operation: request.operation });
|
|
103
|
+
const file = await ingestPdfFromPayload(config, request, opts);
|
|
104
|
+
const pageCount = await getPdfPageCount(config, file.bytes);
|
|
105
|
+
traceStep(opts, "log", "pdf.meta", { fileId: file.id, pageCount });
|
|
106
|
+
const pages = ensurePages(request.pages, pageCount, config.service.maxPagesPerRequest);
|
|
107
|
+
const scale = request.renderScale ?? config.service.defaultRenderScale;
|
|
108
|
+
const returnMode = resolveReturnMode(request.returnMode);
|
|
109
|
+
if (request.operation === "extract_pages") {
|
|
110
|
+
const images = [];
|
|
111
|
+
for (const page of pages) {
|
|
112
|
+
traceStep(opts, "start", "render.page", { page });
|
|
113
|
+
const rendered = await renderPdfPageToPng(config, file.bytes, page - 1, scale);
|
|
114
|
+
if (returnMode === "file_id") {
|
|
115
|
+
const stored = await opts.fileStore.put({
|
|
116
|
+
filename: `${file.filename}-p${page}.png`,
|
|
117
|
+
mimeType: "image/png",
|
|
118
|
+
bytes: rendered.png,
|
|
119
|
+
});
|
|
120
|
+
images.push({ page, mimeType: "image/png", fileId: stored.id });
|
|
121
|
+
}
|
|
122
|
+
else if (returnMode === "url") {
|
|
123
|
+
const stored = await opts.fileStore.put({
|
|
124
|
+
filename: `${file.filename}-p${page}.png`,
|
|
125
|
+
mimeType: "image/png",
|
|
126
|
+
bytes: rendered.png,
|
|
127
|
+
});
|
|
128
|
+
images.push({
|
|
129
|
+
page,
|
|
130
|
+
mimeType: "image/png",
|
|
131
|
+
fileId: stored.id,
|
|
132
|
+
url: `/api/files/get?fileId=${encodeURIComponent(stored.id)}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
images.push({
|
|
137
|
+
page,
|
|
138
|
+
mimeType: "image/png",
|
|
139
|
+
data: toDataUrl(rendered.png, "image/png"),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
traceStep(opts, "end", "render.page", { page, width: rendered.width, height: rendered.height });
|
|
143
|
+
}
|
|
144
|
+
const result = { fileId: file.id, pageCount, returnMode, images };
|
|
145
|
+
traceStep(opts, "end", "pdf.operation", { operation: request.operation });
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
const providerAlias = resolveProviderAlias(config, request.provider);
|
|
149
|
+
const model = resolveModelForProvider(config, providerAlias, request.model);
|
|
150
|
+
if (!model) {
|
|
151
|
+
throw badRequest("MODEL_REQUIRED", "model is required for OCR or table extraction; set agent.defaultModel");
|
|
152
|
+
}
|
|
153
|
+
if (request.operation === "ocr_pages") {
|
|
154
|
+
const results = [];
|
|
155
|
+
for (const page of pages) {
|
|
156
|
+
traceStep(opts, "start", "ocr.page", { page });
|
|
157
|
+
const rendered = await renderPdfPageToPng(config, file.bytes, page - 1, scale);
|
|
158
|
+
const imageDataUrl = toDataUrl(rendered.png, "image/png");
|
|
159
|
+
const fallbackText = await extractPdfPageText(config, file.bytes, page - 1);
|
|
160
|
+
const prompt = request.prompt?.trim() || config.agent.ocrPrompt;
|
|
161
|
+
const llmText = await visionRecognize({
|
|
162
|
+
config,
|
|
163
|
+
env,
|
|
164
|
+
providerAlias,
|
|
165
|
+
model,
|
|
166
|
+
prompt,
|
|
167
|
+
imageDataUrl,
|
|
168
|
+
runtimeApiKeys: request.providerApiKeys,
|
|
169
|
+
});
|
|
170
|
+
const text = stripCodeFences(llmText || fallbackText || "");
|
|
171
|
+
results.push({ page, text });
|
|
172
|
+
traceStep(opts, "end", "ocr.page", { page, chars: text.length });
|
|
173
|
+
}
|
|
174
|
+
const result = {
|
|
175
|
+
fileId: file.id,
|
|
176
|
+
pageCount,
|
|
177
|
+
provider: providerAlias,
|
|
178
|
+
model,
|
|
179
|
+
pages: results,
|
|
180
|
+
};
|
|
181
|
+
traceStep(opts, "end", "pdf.operation", { operation: request.operation });
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
const tables = [];
|
|
185
|
+
for (const page of pages) {
|
|
186
|
+
traceStep(opts, "start", "table.page", { page });
|
|
187
|
+
const rendered = await renderPdfPageToPng(config, file.bytes, page - 1, scale);
|
|
188
|
+
const imageDataUrl = toDataUrl(rendered.png, "image/png");
|
|
189
|
+
const prompt = request.prompt?.trim() || config.agent.tablePrompt;
|
|
190
|
+
const rawLatex = await visionRecognize({
|
|
191
|
+
config,
|
|
192
|
+
env,
|
|
193
|
+
providerAlias,
|
|
194
|
+
model,
|
|
195
|
+
prompt,
|
|
196
|
+
imageDataUrl,
|
|
197
|
+
runtimeApiKeys: request.providerApiKeys,
|
|
198
|
+
});
|
|
199
|
+
const latex = extractTabularLatex(rawLatex);
|
|
200
|
+
if (!latex) {
|
|
201
|
+
throw unprocessable("TABLE_LATEX_MISSING", `table extraction did not return valid LaTeX tabular for page ${page}`, {
|
|
202
|
+
page,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
tables.push({ page, latex });
|
|
206
|
+
traceStep(opts, "end", "table.page", { page, chars: latex.length });
|
|
207
|
+
}
|
|
208
|
+
const result = {
|
|
209
|
+
fileId: file.id,
|
|
210
|
+
pageCount,
|
|
211
|
+
provider: providerAlias,
|
|
212
|
+
model,
|
|
213
|
+
pages: tables,
|
|
214
|
+
};
|
|
215
|
+
traceStep(opts, "end", "pdf.operation", { operation: request.operation });
|
|
216
|
+
return result;
|
|
217
|
+
};
|