@echofiles/echo-pdf 0.4.2 → 0.5.0

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.
@@ -6,11 +6,11 @@ const ok = (id, result) => new Response(JSON.stringify({
6
6
  id: id ?? null,
7
7
  result,
8
8
  }), { headers: { "Content-Type": "application/json" } });
9
- const err = (id, code, message, data) => new Response(JSON.stringify({
9
+ const err = (id, code, message, data, httpStatus = 400) => new Response(JSON.stringify({
10
10
  jsonrpc: "2.0",
11
11
  id: id ?? null,
12
12
  error: data ? { code, message, data } : { code, message },
13
- }), { status: 400, headers: { "Content-Type": "application/json" } });
13
+ }), { status: httpStatus, headers: { "Content-Type": "application/json" } });
14
14
  const asObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
15
15
  const resolvePublicBaseUrl = (request, configured) => typeof configured === "string" && configured.length > 0 ? configured : request.url;
16
16
  const prepareMcpToolArgs = (toolName, args) => {
@@ -32,10 +32,7 @@ export const handleMcpRequest = async (request, env, config, fileStore) => {
32
32
  contextName: "MCP",
33
33
  });
34
34
  if (!auth.ok) {
35
- return new Response(JSON.stringify({ error: auth.message, code: auth.code }), {
36
- status: auth.status,
37
- headers: { "Content-Type": "application/json" },
38
- });
35
+ return err(null, -32001, auth.message, { status: auth.status, code: auth.code }, 200);
39
36
  }
40
37
  let body;
41
38
  try {
@@ -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
+ };
@@ -69,6 +69,8 @@ export const loadEchoPdfConfig = (env) => {
69
69
  const providerOverride = env.ECHO_PDF_DEFAULT_PROVIDER;
70
70
  const modelOverride = env.ECHO_PDF_DEFAULT_MODEL;
71
71
  const publicBaseUrlOverride = env.ECHO_PDF_PUBLIC_BASE_URL;
72
+ const computeAuthHeaderOverride = env.ECHO_PDF_COMPUTE_AUTH_HEADER;
73
+ const computeAuthEnvOverride = env.ECHO_PDF_COMPUTE_AUTH_ENV;
72
74
  const fileGetAuthHeaderOverride = env.ECHO_PDF_FILE_GET_AUTH_HEADER;
73
75
  const fileGetAuthEnvOverride = env.ECHO_PDF_FILE_GET_AUTH_ENV;
74
76
  const fileGetCacheTtlOverride = env.ECHO_PDF_FILE_GET_CACHE_TTL_SECONDS;
@@ -79,6 +81,14 @@ export const loadEchoPdfConfig = (env) => {
79
81
  publicBaseUrl: typeof publicBaseUrlOverride === "string" && publicBaseUrlOverride.trim().length > 0
80
82
  ? publicBaseUrlOverride.trim()
81
83
  : resolved.service.publicBaseUrl,
84
+ computeAuth: {
85
+ authHeader: typeof computeAuthHeaderOverride === "string" && computeAuthHeaderOverride.trim().length > 0
86
+ ? computeAuthHeaderOverride.trim()
87
+ : resolved.service.computeAuth?.authHeader,
88
+ authEnv: typeof computeAuthEnvOverride === "string" && computeAuthEnvOverride.trim().length > 0
89
+ ? computeAuthEnvOverride.trim()
90
+ : resolved.service.computeAuth?.authEnv,
91
+ },
82
92
  fileGet: {
83
93
  authHeader: typeof fileGetAuthHeaderOverride === "string" && fileGetAuthHeaderOverride.trim().length > 0
84
94
  ? fileGetAuthHeaderOverride.trim()
@@ -20,6 +20,10 @@ export interface EchoPdfConfig {
20
20
  readonly service: {
21
21
  readonly name: string;
22
22
  readonly publicBaseUrl?: string;
23
+ readonly computeAuth?: {
24
+ readonly authHeader?: string;
25
+ readonly authEnv?: string;
26
+ };
23
27
  readonly fileGet?: {
24
28
  readonly authHeader?: string;
25
29
  readonly authEnv?: string;
@@ -10,3 +10,11 @@ export declare const visionRecognize: (input: {
10
10
  imageDataUrl: string;
11
11
  runtimeApiKeys?: Record<string, string>;
12
12
  }) => Promise<string>;
13
+ export declare const generateText: (input: {
14
+ config: EchoPdfConfig;
15
+ env: Env;
16
+ providerAlias: string;
17
+ model: string;
18
+ prompt: string;
19
+ runtimeApiKeys?: Record<string, string>;
20
+ }) => Promise<string>;
@@ -132,3 +132,42 @@ export const visionRecognize = async (input) => {
132
132
  }
133
133
  return "";
134
134
  };
135
+ export const generateText = async (input) => {
136
+ const provider = getProvider(input.config, input.providerAlias);
137
+ const url = resolveEndpoint(provider, "chatCompletionsPath");
138
+ const response = await withTimeout(url, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/json",
142
+ ...toAuthHeader(input.config, input.providerAlias, provider, input.env, input.runtimeApiKeys),
143
+ ...(provider.headers ?? {}),
144
+ },
145
+ body: JSON.stringify({
146
+ model: input.model,
147
+ messages: [
148
+ {
149
+ role: "user",
150
+ content: input.prompt,
151
+ },
152
+ ],
153
+ }),
154
+ }, provider.timeoutMs ?? 30000);
155
+ if (!response.ok) {
156
+ throw new Error(`Text generation request failed: HTTP ${response.status} url=${url} detail=${await responseDetail(response)}`);
157
+ }
158
+ const payload = await response.json();
159
+ const message = payload.choices?.[0]?.message;
160
+ if (!message)
161
+ return "";
162
+ const content = message.content;
163
+ if (typeof content === "string")
164
+ return content;
165
+ if (Array.isArray(content)) {
166
+ return content
167
+ .map((part) => part)
168
+ .filter((part) => part.type === "text" && typeof part.text === "string")
169
+ .map((part) => part.text ?? "")
170
+ .join("");
171
+ }
172
+ return "";
173
+ };
package/dist/worker.js CHANGED
@@ -111,6 +111,14 @@ const operationArgsFromRequest = (request) => {
111
111
  args.prompt = request.prompt;
112
112
  return args;
113
113
  };
114
+ const checkComputeAuth = (request, env, config) => checkHeaderAuth(request, env, {
115
+ authHeader: config.service.computeAuth?.authHeader,
116
+ authEnv: config.service.computeAuth?.authEnv,
117
+ allowMissingSecret: false,
118
+ misconfiguredCode: "COMPUTE_AUTH_MISCONFIGURED",
119
+ unauthorizedCode: "UNAUTHORIZED",
120
+ contextName: "compute endpoint",
121
+ });
114
122
  export default {
115
123
  async fetch(request, env, ctx) {
116
124
  const url = new URL(request.url);
@@ -149,6 +157,9 @@ export default {
149
157
  return json({ tools: listToolSchemas() });
150
158
  }
151
159
  if (request.method === "POST" && url.pathname === "/tools/call") {
160
+ const auth = checkComputeAuth(request, env, config);
161
+ if (!auth.ok)
162
+ return json({ error: auth.message, code: auth.code }, auth.status);
152
163
  const body = await readJson(request);
153
164
  const name = typeof body.name === "string" ? body.name : "";
154
165
  if (!name)
@@ -180,6 +191,9 @@ export default {
180
191
  }
181
192
  }
182
193
  if (request.method === "POST" && url.pathname === "/providers/models") {
194
+ const auth = checkComputeAuth(request, env, config);
195
+ if (!auth.ok)
196
+ return json({ error: auth.message, code: auth.code }, auth.status);
183
197
  const body = await readJson(request);
184
198
  const provider = resolveProviderAlias(config, typeof body.provider === "string" ? body.provider : undefined);
185
199
  const runtimeKeys = typeof body.providerApiKeys === "object" && body.providerApiKeys !== null
@@ -194,6 +208,9 @@ export default {
194
208
  }
195
209
  }
196
210
  if (request.method === "POST" && url.pathname === "/api/agent/run") {
211
+ const auth = checkComputeAuth(request, env, config);
212
+ if (!auth.ok)
213
+ return json({ error: auth.message, code: auth.code }, auth.status);
197
214
  const body = await readJson(request);
198
215
  if (Object.hasOwn(body, "operation") && !isValidOperation(body.operation)) {
199
216
  return json({ error: "Invalid operation. Must be one of: extract_pages, ocr_pages, tables_to_latex" }, 400);
@@ -213,6 +230,9 @@ export default {
213
230
  }
214
231
  }
215
232
  if (request.method === "POST" && url.pathname === "/api/agent/stream") {
233
+ const auth = checkComputeAuth(request, env, config);
234
+ if (!auth.ok)
235
+ return json({ error: auth.message, code: auth.code }, auth.status);
216
236
  const body = await readJson(request);
217
237
  if (Object.hasOwn(body, "operation") && !isValidOperation(body.operation)) {
218
238
  return json({ error: "Invalid operation. Must be one of: extract_pages, ocr_pages, tables_to_latex" }, 400);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@echofiles/echo-pdf",
3
- "description": "MCP-first PDF agent on Cloudflare Workers with CLI and web demo.",
4
- "version": "0.4.2",
3
+ "description": "Local-first PDF document component core with CLI, workspace artifacts, and reusable page primitives.",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/core/index.d.ts",
21
21
  "import": "./dist/core/index.js"
22
22
  },
23
+ "./local": {
24
+ "types": "./dist/local/index.d.ts",
25
+ "import": "./dist/local/index.js"
26
+ },
23
27
  "./worker": {
24
28
  "types": "./dist/worker.d.ts",
25
29
  "import": "./dist/worker.js"
@@ -38,6 +42,13 @@
38
42
  "check:runtime": "bash ./scripts/check-runtime.sh",
39
43
  "dev": "wrangler dev",
40
44
  "deploy": "wrangler deploy",
45
+ "document:dev": "ECHO_PDF_SOURCE_DEV=1 bun ./bin/echo-pdf.js",
46
+ "eval": "node ./eval/run-local.mjs",
47
+ "eval:smoke": "node ./eval/run-local.mjs --suite smoke",
48
+ "eval:core": "node ./eval/run-local.mjs --suite core",
49
+ "eval:stress": "node ./eval/run-local.mjs --suite stress",
50
+ "eval:known-bad": "node ./eval/run-local.mjs --suite known-bad",
51
+ "eval:fetch-public-samples": "node ./eval/fetch-public-samples.mjs",
41
52
  "typecheck": "npm run check:runtime && tsc --noEmit",
42
53
  "test:unit": "npm run check:runtime && vitest run tests/unit",
43
54
  "test:import-smoke": "npm run check:runtime && npm run build && vitest run tests/integration/npm-pack-import.integration.test.ts tests/integration/ts-nodenext-consumer.integration.test.ts",