@juspay/neurolink 9.95.1 → 9.95.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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +352 -352
- package/dist/cli/factories/commandFactory.d.ts +10 -0
- package/dist/cli/factories/commandFactory.js +30 -0
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/core/baseProvider.js +1 -0
- package/dist/core/constants.d.ts +5 -0
- package/dist/core/constants.js +19 -0
- package/dist/core/modules/MessageBuilder.js +2 -0
- package/dist/lib/core/baseProvider.js +1 -0
- package/dist/lib/core/constants.d.ts +5 -0
- package/dist/lib/core/constants.js +19 -0
- package/dist/lib/core/modules/MessageBuilder.js +2 -0
- package/dist/lib/neurolink.js +8 -4
- package/dist/lib/types/file.d.ts +48 -1
- package/dist/lib/types/generate.d.ts +14 -0
- package/dist/lib/types/stream.d.ts +7 -0
- package/dist/lib/utils/errorHandling.d.ts +10 -0
- package/dist/lib/utils/errorHandling.js +28 -0
- package/dist/lib/utils/fileDetector.js +35 -0
- package/dist/lib/utils/messageBuilder.js +53 -5
- package/dist/lib/utils/multimodalOptionsBuilder.d.ts +4 -0
- package/dist/lib/utils/multimodalOptionsBuilder.js +1 -0
- package/dist/lib/utils/pdfProcessor.d.ts +39 -1
- package/dist/lib/utils/pdfProcessor.js +267 -48
- package/dist/neurolink.js +8 -4
- package/dist/types/file.d.ts +48 -1
- package/dist/types/generate.d.ts +14 -0
- package/dist/types/stream.d.ts +7 -0
- package/dist/utils/errorHandling.d.ts +10 -0
- package/dist/utils/errorHandling.js +28 -0
- package/dist/utils/fileDetector.js +35 -0
- package/dist/utils/messageBuilder.js +53 -5
- package/dist/utils/multimodalOptionsBuilder.d.ts +4 -0
- package/dist/utils/multimodalOptionsBuilder.js +1 -0
- package/dist/utils/pdfProcessor.d.ts +39 -1
- package/dist/utils/pdfProcessor.js +267 -48
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The conversion uses pdf-to-img package (MuPDF-based) for high-quality conversion.
|
|
9
9
|
*/
|
|
10
|
-
import type { FileProcessingResult, PDFProcessorOptions, PDFProviderConfig, PDFImageConversionOptions, PDFImageConversionResult } from "../types/index.js";
|
|
10
|
+
import type { FileProcessingResult, PDFProcessorOptions, PDFProviderConfig, PDFImageConversionOptions, PDFImageConversionResult, PDFImagePage } from "../types/index.js";
|
|
11
11
|
export declare class PDFProcessor {
|
|
12
12
|
private static readonly PDF_SIGNATURE;
|
|
13
13
|
static process(content: Buffer, options?: PDFProcessorOptions): Promise<FileProcessingResult>;
|
|
@@ -20,7 +20,31 @@ export declare class PDFProcessor {
|
|
|
20
20
|
static getProviderConfig(provider: string): PDFProviderConfig | null;
|
|
21
21
|
private static isValidPDF;
|
|
22
22
|
private static extractBasicMetadata;
|
|
23
|
+
/**
|
|
24
|
+
* Accurate page count via pdf-parse (pdfjs) (#287). The header regex in
|
|
25
|
+
* `extractBasicMetadata` only sees plaintext `/Type /Page` markers and misses
|
|
26
|
+
* compressed/object-stream PDFs (and miscounts when a page dict spans a chunk
|
|
27
|
+
* boundary). This parses the document properly, bounded by a timeout so a
|
|
28
|
+
* pathological PDF can't block; returns null on timeout/failure so the caller
|
|
29
|
+
* falls back to the regex estimate.
|
|
30
|
+
*/
|
|
31
|
+
private static getAccuratePageCount;
|
|
23
32
|
static estimateTokens(pageCount: number, mode?: "text-only" | "visual"): number;
|
|
33
|
+
/**
|
|
34
|
+
* Estimate the largest page's rendered pixel count from the PDF's MediaBox
|
|
35
|
+
* entries, WITHOUT rendering (#260). Parses `/MediaBox [llx lly urx ury]`
|
|
36
|
+
* (dimensions in points); rendered pixels ≈ (width·scale)·(height·scale).
|
|
37
|
+
* Returns 0 when no plaintext MediaBox is found (e.g. compressed object
|
|
38
|
+
* streams) — the caller then skips downscaling, matching prior behavior.
|
|
39
|
+
*
|
|
40
|
+
* A crafted/malformed MediaBox can have finite but astronomically large
|
|
41
|
+
* width/height; `width * height * scale * scale` can then overflow past
|
|
42
|
+
* `Number.MAX_VALUE` to `Infinity`. Clamp the product to
|
|
43
|
+
* `Number.MAX_SAFE_INTEGER` so it stays finite — a large-but-finite pixel
|
|
44
|
+
* estimate still triggers downscaling, whereas `Infinity` would collapse
|
|
45
|
+
* the computed downscale factor to 0 (see `convertToImages`).
|
|
46
|
+
*/
|
|
47
|
+
private static largestPagePixels;
|
|
24
48
|
/**
|
|
25
49
|
* Convert a PDF buffer to an array of base64 PNG images
|
|
26
50
|
*
|
|
@@ -46,6 +70,20 @@ export declare class PDFProcessor {
|
|
|
46
70
|
* ```
|
|
47
71
|
*/
|
|
48
72
|
static convertToImages(pdfBuffer: Buffer, options?: PDFImageConversionOptions): Promise<PDFImageConversionResult>;
|
|
73
|
+
/**
|
|
74
|
+
* Shared input validation for the image-conversion paths. Returns the PDF
|
|
75
|
+
* size in MB (needed by the memory-estimate log). Throws on any invalid input
|
|
76
|
+
* with the same messages the batch path has always used.
|
|
77
|
+
*/
|
|
78
|
+
private static validateImageConversionInput;
|
|
79
|
+
/**
|
|
80
|
+
* Streaming variant of {@link convertToImages} (#302): yields each page's
|
|
81
|
+
* base64 PNG as soon as it renders instead of buffering the whole document,
|
|
82
|
+
* and reports progress via `options.onProgress`. A page that fails to render
|
|
83
|
+
* is yielded with `error` set (per-page isolation, #294) rather than aborting
|
|
84
|
+
* the stream. `convertToImages` is the batch wrapper over this contract.
|
|
85
|
+
*/
|
|
86
|
+
static convertToImagesStream(pdfBuffer: Buffer, options?: PDFImageConversionOptions): AsyncGenerator<PDFImagePage, void, void>;
|
|
49
87
|
/**
|
|
50
88
|
* Convert a PDF file path to an array of base64 PNG images
|
|
51
89
|
*
|
|
@@ -150,6 +150,13 @@ export class PDFProcessor {
|
|
|
150
150
|
throw new Error(`PDF size ${sizeMB.toFixed(2)}MB exceeds ${config.maxSizeMB}MB limit for ${provider}`);
|
|
151
151
|
}
|
|
152
152
|
const metadata = PDFProcessor.extractBasicMetadata(content);
|
|
153
|
+
// #287: prefer an accurate page count (pdf-parse/pdfjs) over the unreliable
|
|
154
|
+
// header regex for both limit enforcement and returned metadata; fall back
|
|
155
|
+
// to the regex estimate when the accurate probe times out or fails.
|
|
156
|
+
const accuratePages = await PDFProcessor.getAccuratePageCount(content);
|
|
157
|
+
if (accuratePages !== null) {
|
|
158
|
+
metadata.estimatedPages = accuratePages;
|
|
159
|
+
}
|
|
153
160
|
if (metadata.estimatedPages && metadata.estimatedPages > config.maxPages) {
|
|
154
161
|
const enforceLimits = options?.enforceLimits !== false;
|
|
155
162
|
if (enforceLimits) {
|
|
@@ -185,6 +192,10 @@ export class PDFProcessor {
|
|
|
185
192
|
...metadata,
|
|
186
193
|
provider,
|
|
187
194
|
apiType: config.apiType,
|
|
195
|
+
// #349: surface the provider's citations requirement so downstream
|
|
196
|
+
// provider adapters (e.g. Bedrock Converse document blocks) can act on
|
|
197
|
+
// it, instead of the config field being read nowhere.
|
|
198
|
+
requiresCitations: config.requiresCitations,
|
|
188
199
|
},
|
|
189
200
|
};
|
|
190
201
|
}
|
|
@@ -220,6 +231,35 @@ export class PDFProcessor {
|
|
|
220
231
|
filename: undefined,
|
|
221
232
|
};
|
|
222
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Accurate page count via pdf-parse (pdfjs) (#287). The header regex in
|
|
236
|
+
* `extractBasicMetadata` only sees plaintext `/Type /Page` markers and misses
|
|
237
|
+
* compressed/object-stream PDFs (and miscounts when a page dict spans a chunk
|
|
238
|
+
* boundary). This parses the document properly, bounded by a timeout so a
|
|
239
|
+
* pathological PDF can't block; returns null on timeout/failure so the caller
|
|
240
|
+
* falls back to the regex estimate.
|
|
241
|
+
*/
|
|
242
|
+
static async getAccuratePageCount(buffer) {
|
|
243
|
+
try {
|
|
244
|
+
const { PDFParse } = await import("pdf-parse");
|
|
245
|
+
const pdf = new PDFParse({ data: new Uint8Array(buffer) });
|
|
246
|
+
try {
|
|
247
|
+
const info = await Promise.race([
|
|
248
|
+
pdf.getInfo(),
|
|
249
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS)),
|
|
250
|
+
]);
|
|
251
|
+
const total = info.total;
|
|
252
|
+
return typeof total === "number" && total > 0 ? total : null;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await pdf.destroy?.();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
logger.debug(`[PDF] Accurate page count unavailable; using regex estimate: ${error instanceof Error ? error.message : String(error)}`);
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
223
263
|
static estimateTokens(pageCount, mode = "visual") {
|
|
224
264
|
if (mode === "text-only") {
|
|
225
265
|
return Math.ceil((pageCount / 3) * 1000);
|
|
@@ -231,6 +271,40 @@ export class PDFProcessor {
|
|
|
231
271
|
// ============================================================================
|
|
232
272
|
// PDF → Image Conversion (for providers without native PDF support)
|
|
233
273
|
// ============================================================================
|
|
274
|
+
/**
|
|
275
|
+
* Estimate the largest page's rendered pixel count from the PDF's MediaBox
|
|
276
|
+
* entries, WITHOUT rendering (#260). Parses `/MediaBox [llx lly urx ury]`
|
|
277
|
+
* (dimensions in points); rendered pixels ≈ (width·scale)·(height·scale).
|
|
278
|
+
* Returns 0 when no plaintext MediaBox is found (e.g. compressed object
|
|
279
|
+
* streams) — the caller then skips downscaling, matching prior behavior.
|
|
280
|
+
*
|
|
281
|
+
* A crafted/malformed MediaBox can have finite but astronomically large
|
|
282
|
+
* width/height; `width * height * scale * scale` can then overflow past
|
|
283
|
+
* `Number.MAX_VALUE` to `Infinity`. Clamp the product to
|
|
284
|
+
* `Number.MAX_SAFE_INTEGER` so it stays finite — a large-but-finite pixel
|
|
285
|
+
* estimate still triggers downscaling, whereas `Infinity` would collapse
|
|
286
|
+
* the computed downscale factor to 0 (see `convertToImages`).
|
|
287
|
+
*/
|
|
288
|
+
static largestPagePixels(pdfBuffer, scale) {
|
|
289
|
+
const text = pdfBuffer.toString("latin1");
|
|
290
|
+
const re = /\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/g;
|
|
291
|
+
let max = 0;
|
|
292
|
+
let match;
|
|
293
|
+
while ((match = re.exec(text)) !== null) {
|
|
294
|
+
const width = Math.abs(parseFloat(match[3]) - parseFloat(match[1]));
|
|
295
|
+
const height = Math.abs(parseFloat(match[4]) - parseFloat(match[2]));
|
|
296
|
+
if (Number.isFinite(width) && Number.isFinite(height)) {
|
|
297
|
+
const pixels = width * height * scale * scale;
|
|
298
|
+
const boundedPixels = Number.isFinite(pixels)
|
|
299
|
+
? Math.min(pixels, Number.MAX_SAFE_INTEGER)
|
|
300
|
+
: Number.MAX_SAFE_INTEGER;
|
|
301
|
+
if (boundedPixels > max) {
|
|
302
|
+
max = boundedPixels;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return max;
|
|
307
|
+
}
|
|
234
308
|
/**
|
|
235
309
|
* Convert a PDF buffer to an array of base64 PNG images
|
|
236
310
|
*
|
|
@@ -257,43 +331,32 @@ export class PDFProcessor {
|
|
|
257
331
|
*/
|
|
258
332
|
static async convertToImages(pdfBuffer, options) {
|
|
259
333
|
const startTime = Date.now();
|
|
260
|
-
const { scale =
|
|
334
|
+
const { scale = PDF_LIMITS.DEFAULT_SCALE, maxPages = PDF_LIMITS.DEFAULT_MAX_PAGES, format = "png", maxCanvasPixels = PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS, password, onProgress, } = options || {};
|
|
261
335
|
const images = [];
|
|
262
336
|
const warnings = [];
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
// 0b. Validate the render scale. A non-finite or non-positive scale yields a
|
|
271
|
-
// degenerate viewport (blank/zero-dimension render), and an excessive scale
|
|
272
|
-
// can allocate hundreds of MB per page — reject both with a clear message.
|
|
273
|
-
if (!Number.isFinite(scale) || scale <= 0 || scale > PDF_LIMITS.MAX_SCALE) {
|
|
274
|
-
throw new Error(`Invalid scale: ${scale}. Scale must be a finite number greater than 0 and at most ${PDF_LIMITS.MAX_SCALE}.`);
|
|
275
|
-
}
|
|
276
|
-
// 1. Validate buffer is not empty or too small
|
|
277
|
-
if (!pdfBuffer || pdfBuffer.length < 5) {
|
|
278
|
-
throw new Error("Invalid PDF: Buffer is too small or empty. " +
|
|
279
|
-
"A valid PDF must be at least 5 bytes (PDF header).");
|
|
280
|
-
}
|
|
281
|
-
// 2. Validate PDF magic bytes (%PDF-)
|
|
282
|
-
if (!PDFProcessor.isValidPDF(pdfBuffer)) {
|
|
283
|
-
throw new Error("Invalid PDF: File must start with %PDF- header. " +
|
|
284
|
-
"The provided buffer does not appear to be a valid PDF file.");
|
|
285
|
-
}
|
|
286
|
-
// 3. Validate maximum buffer size to prevent memory exhaustion
|
|
287
|
-
const sizeMB = pdfBuffer.length / (1024 * 1024);
|
|
288
|
-
if (sizeMB > PDF_LIMITS.MAX_SIZE_MB) {
|
|
289
|
-
throw new Error(`PDF too large for image conversion: ${sizeMB.toFixed(2)}MB exceeds ${PDF_LIMITS.MAX_SIZE_MB}MB limit. ` +
|
|
290
|
-
"Consider splitting the PDF or using a provider with native PDF support.");
|
|
291
|
-
}
|
|
337
|
+
const pageErrors = [];
|
|
338
|
+
// Validation shared with convertToImagesStream (#302).
|
|
339
|
+
const sizeMB = PDFProcessor.validateImageConversionInput(pdfBuffer, {
|
|
340
|
+
format,
|
|
341
|
+
scale,
|
|
342
|
+
maxCanvasPixels,
|
|
343
|
+
});
|
|
292
344
|
logger.debug("[PDF→Image] ✅ PDF validation passed", {
|
|
293
345
|
bufferSize: pdfBuffer.length,
|
|
294
346
|
sizeMB: sizeMB.toFixed(2),
|
|
295
347
|
maxPages,
|
|
296
348
|
});
|
|
349
|
+
// #297: surface an up-front memory estimate so an operator can spot a
|
|
350
|
+
// conversion that will allocate a lot before it runs. Uses the cheap regex
|
|
351
|
+
// page estimate (the accurate pdf-parse count runs in process(), not here).
|
|
352
|
+
const estimatedPages = Math.max(1, PDFProcessor.extractBasicMetadata(pdfBuffer).estimatedPages ?? 1);
|
|
353
|
+
const estimatedMemoryMB = PDFProcessor.estimateConversionMemoryUsage(pdfBuffer.length, estimatedPages, scale);
|
|
354
|
+
logger.info("[PDF→Image] Estimated memory usage before conversion", {
|
|
355
|
+
scale,
|
|
356
|
+
estimatedPages,
|
|
357
|
+
estimatedMemoryMB,
|
|
358
|
+
sizeMB: Number(sizeMB.toFixed(2)),
|
|
359
|
+
});
|
|
297
360
|
try {
|
|
298
361
|
// Dynamic import to avoid loading MuPDF binaries until needed
|
|
299
362
|
const pdfToImgModule = await import("pdf-to-img");
|
|
@@ -303,32 +366,87 @@ export class PDFProcessor {
|
|
|
303
366
|
scale,
|
|
304
367
|
maxPages: maxPages || "all",
|
|
305
368
|
});
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
369
|
+
// #260: pre-flight page-size check WITHOUT rendering. pdf-to-img applies
|
|
370
|
+
// `scale` uniformly with no per-page hook and no pixel guard, so a very
|
|
371
|
+
// large page (e.g. an architectural drawing with a huge MediaBox) can
|
|
372
|
+
// allocate gigabytes of canvas. Read the largest MediaBox from the PDF
|
|
373
|
+
// bytes and, if that page at the requested scale would exceed
|
|
374
|
+
// maxCanvasPixels, downscale the whole render uniformly to stay under it.
|
|
375
|
+
let effectiveScale = scale;
|
|
376
|
+
const largestPixels = PDFProcessor.largestPagePixels(pdfBuffer, scale);
|
|
377
|
+
if (largestPixels > maxCanvasPixels) {
|
|
378
|
+
const downscale = Math.sqrt(maxCanvasPixels / largestPixels);
|
|
379
|
+
// Floor the result: an astronomically large (but now finite, see
|
|
380
|
+
// `largestPagePixels`) pixel estimate would otherwise push `downscale`
|
|
381
|
+
// — and therefore `effectiveScale` — toward 0, handing `pdf-to-img` a
|
|
382
|
+
// degenerate viewport instead of a small-but-renderable page.
|
|
383
|
+
effectiveScale = Math.max(PDF_LIMITS.MIN_EFFECTIVE_SCALE, scale * downscale);
|
|
384
|
+
// Recompute the ratio actually applied (may differ from `downscale`
|
|
385
|
+
// when the floor above kicks in) so the logged estimate stays honest.
|
|
386
|
+
const actualDownscale = effectiveScale / scale;
|
|
387
|
+
const beforeMB = (largestPixels * 4) / (1024 * 1024);
|
|
388
|
+
const afterMB = (largestPixels * actualDownscale * actualDownscale * 4) /
|
|
389
|
+
(1024 * 1024);
|
|
390
|
+
const msg = `Downscaled render (scale ${scale} → ${effectiveScale.toFixed(3)}): ` +
|
|
391
|
+
`the largest page would allocate ~${beforeMB.toFixed(0)}MB, above the ` +
|
|
392
|
+
`maxCanvasPixels ceiling; reduced to ~${afterMB.toFixed(0)}MB per page.`;
|
|
393
|
+
logger.warn(`[PDF→Image] ⚠️ ${msg}`);
|
|
394
|
+
warnings.push(msg);
|
|
395
|
+
}
|
|
396
|
+
// Create PDF document (password forwarded for encrypted PDFs, #258).
|
|
397
|
+
// pdf-to-img resolves `.length` (numPages) synchronously here and exposes
|
|
398
|
+
// `.getPage(n)`, so we drive an indexed loop rather than the async
|
|
399
|
+
// iterator — that lets one bad page be isolated instead of aborting all.
|
|
400
|
+
const document = await pdf(pdfBuffer, {
|
|
401
|
+
scale: effectiveScale,
|
|
402
|
+
...(password ? { password } : {}),
|
|
403
|
+
});
|
|
404
|
+
const totalPages = document.length;
|
|
405
|
+
// #294: convert page-by-page with per-page isolation. A single page whose
|
|
406
|
+
// canvas render throws (e.g. a degenerate MediaBox) no longer discards
|
|
407
|
+
// every already-converted page — it is recorded in `errors` and the rest
|
|
408
|
+
// continue.
|
|
409
|
+
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
|
410
|
+
if (maxPages !== undefined && pageNum - 1 >= maxPages) {
|
|
411
|
+
warnings.push(`Stopped at page ${pageNum - 1} (maxPages limit: ${maxPages})`);
|
|
314
412
|
break;
|
|
315
413
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
414
|
+
try {
|
|
415
|
+
const page = await document.getPage(pageNum);
|
|
416
|
+
const base64Image = page.toString("base64");
|
|
417
|
+
images.push(base64Image);
|
|
418
|
+
logger.debug(`[PDF→Image] Converted page ${pageNum}`, {
|
|
419
|
+
imageSizeBytes: page.length,
|
|
420
|
+
base64Length: base64Image.length,
|
|
421
|
+
});
|
|
422
|
+
// #302: report progress after each successfully converted page.
|
|
423
|
+
if (onProgress) {
|
|
424
|
+
await onProgress({
|
|
425
|
+
pagesConverted: images.length,
|
|
426
|
+
totalPages,
|
|
427
|
+
elapsedMs: Date.now() - startTime,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (pageError) {
|
|
432
|
+
const msg = pageError instanceof Error ? pageError.message : String(pageError);
|
|
433
|
+
logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render: ${msg}`);
|
|
434
|
+
pageErrors.push({ page: pageNum, error: msg });
|
|
435
|
+
}
|
|
324
436
|
}
|
|
325
|
-
//
|
|
437
|
+
// Empty PDF (0 pages) or every page failed → treat as a conversion
|
|
438
|
+
// failure (kept inside the try so the password/format mapping below still
|
|
439
|
+
// applies), otherwise return the pages that did convert.
|
|
326
440
|
if (images.length === 0) {
|
|
441
|
+
if (pageErrors.length > 0) {
|
|
442
|
+
throw new Error(`All ${pageErrors.length} page(s) failed to render. First error: ${pageErrors[0].error}`);
|
|
443
|
+
}
|
|
327
444
|
throw new Error("PDF has 0 pages. Cannot convert empty PDF to images.");
|
|
328
445
|
}
|
|
329
446
|
const conversionTimeMs = Date.now() - startTime;
|
|
330
447
|
logger.info("[PDF→Image] ✅ PDF conversion completed", {
|
|
331
448
|
pageCount: images.length,
|
|
449
|
+
failedPages: pageErrors.length,
|
|
332
450
|
conversionTimeMs,
|
|
333
451
|
totalImageBytes: images.reduce((sum, img) => sum + img.length, 0),
|
|
334
452
|
});
|
|
@@ -337,6 +455,7 @@ export class PDFProcessor {
|
|
|
337
455
|
pageCount: images.length,
|
|
338
456
|
conversionTimeMs,
|
|
339
457
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
458
|
+
errors: pageErrors.length > 0 ? pageErrors : undefined,
|
|
340
459
|
};
|
|
341
460
|
}
|
|
342
461
|
catch (error) {
|
|
@@ -346,11 +465,111 @@ export class PDFProcessor {
|
|
|
346
465
|
error: errorMessage,
|
|
347
466
|
conversionTimeMs,
|
|
348
467
|
});
|
|
468
|
+
// #258: map pdfjs's PasswordException to an actionable typed error so a
|
|
469
|
+
// caller learns to supply (or correct) the password instead of seeing a
|
|
470
|
+
// generic "conversion failed". pdfjs code 1 = NEED_PASSWORD, 2 = INCORRECT.
|
|
471
|
+
const pdfErr = error;
|
|
472
|
+
if (pdfErr?.name === "PasswordException" ||
|
|
473
|
+
/password/i.test(errorMessage)) {
|
|
474
|
+
const incorrect = pdfErr.code === 2 || /incorrect|invalid/i.test(errorMessage);
|
|
475
|
+
throw incorrect
|
|
476
|
+
? ErrorFactory.pdfIncorrectPassword()
|
|
477
|
+
: ErrorFactory.pdfPasswordRequired();
|
|
478
|
+
}
|
|
349
479
|
throw new Error(`PDF to image conversion failed: ${errorMessage}`, {
|
|
350
480
|
cause: error,
|
|
351
481
|
});
|
|
352
482
|
}
|
|
353
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Shared input validation for the image-conversion paths. Returns the PDF
|
|
486
|
+
* size in MB (needed by the memory-estimate log). Throws on any invalid input
|
|
487
|
+
* with the same messages the batch path has always used.
|
|
488
|
+
*/
|
|
489
|
+
static validateImageConversionInput(pdfBuffer, opts) {
|
|
490
|
+
if (opts.format !== "png") {
|
|
491
|
+
throw new Error(`Invalid format: "${opts.format}". Only "png" format is currently supported.`);
|
|
492
|
+
}
|
|
493
|
+
if (!Number.isFinite(opts.scale) ||
|
|
494
|
+
opts.scale < PDF_LIMITS.MIN_SCALE ||
|
|
495
|
+
opts.scale > PDF_LIMITS.MAX_SCALE) {
|
|
496
|
+
throw new Error(`Invalid scale: ${opts.scale}. Scale must be a finite number between ${PDF_LIMITS.MIN_SCALE} and ${PDF_LIMITS.MAX_SCALE}.`);
|
|
497
|
+
}
|
|
498
|
+
if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
|
|
499
|
+
throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
|
|
500
|
+
}
|
|
501
|
+
if (!pdfBuffer || pdfBuffer.length < 5) {
|
|
502
|
+
throw new Error("Invalid PDF: Buffer is too small or empty. " +
|
|
503
|
+
"A valid PDF must be at least 5 bytes (PDF header).");
|
|
504
|
+
}
|
|
505
|
+
if (!PDFProcessor.isValidPDF(pdfBuffer)) {
|
|
506
|
+
throw new Error("Invalid PDF: File must start with %PDF- header. " +
|
|
507
|
+
"The provided buffer does not appear to be a valid PDF file.");
|
|
508
|
+
}
|
|
509
|
+
const sizeMB = pdfBuffer.length / (1024 * 1024);
|
|
510
|
+
if (sizeMB > PDF_LIMITS.MAX_SIZE_MB) {
|
|
511
|
+
throw new Error(`PDF too large for image conversion: ${sizeMB.toFixed(2)}MB exceeds ${PDF_LIMITS.MAX_SIZE_MB}MB limit. ` +
|
|
512
|
+
"Consider splitting the PDF or using a provider with native PDF support.");
|
|
513
|
+
}
|
|
514
|
+
return sizeMB;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Streaming variant of {@link convertToImages} (#302): yields each page's
|
|
518
|
+
* base64 PNG as soon as it renders instead of buffering the whole document,
|
|
519
|
+
* and reports progress via `options.onProgress`. A page that fails to render
|
|
520
|
+
* is yielded with `error` set (per-page isolation, #294) rather than aborting
|
|
521
|
+
* the stream. `convertToImages` is the batch wrapper over this contract.
|
|
522
|
+
*/
|
|
523
|
+
static async *convertToImagesStream(pdfBuffer, options) {
|
|
524
|
+
const startTime = Date.now();
|
|
525
|
+
const { scale = PDF_LIMITS.DEFAULT_SCALE, maxPages = PDF_LIMITS.DEFAULT_MAX_PAGES, format = "png", maxCanvasPixels = PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS, password, onProgress, } = options || {};
|
|
526
|
+
PDFProcessor.validateImageConversionInput(pdfBuffer, {
|
|
527
|
+
format,
|
|
528
|
+
scale,
|
|
529
|
+
maxCanvasPixels,
|
|
530
|
+
});
|
|
531
|
+
const pdfToImgModule = await import("pdf-to-img");
|
|
532
|
+
const pdf = pdfToImgModule.pdf;
|
|
533
|
+
// #260: uniform downscale so the largest page stays under maxCanvasPixels.
|
|
534
|
+
let effectiveScale = scale;
|
|
535
|
+
const largestPixels = PDFProcessor.largestPagePixels(pdfBuffer, scale);
|
|
536
|
+
if (largestPixels > maxCanvasPixels) {
|
|
537
|
+
effectiveScale = scale * Math.sqrt(maxCanvasPixels / largestPixels);
|
|
538
|
+
}
|
|
539
|
+
const document = await pdf(pdfBuffer, {
|
|
540
|
+
scale: effectiveScale,
|
|
541
|
+
...(password ? { password } : {}),
|
|
542
|
+
});
|
|
543
|
+
const totalPages = document.length;
|
|
544
|
+
let converted = 0;
|
|
545
|
+
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
|
546
|
+
if (maxPages !== undefined && pageNum - 1 >= maxPages) {
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
const page = await document.getPage(pageNum);
|
|
551
|
+
const base64Image = page.toString("base64");
|
|
552
|
+
converted++;
|
|
553
|
+
if (onProgress) {
|
|
554
|
+
await onProgress({
|
|
555
|
+
pagesConverted: converted,
|
|
556
|
+
totalPages,
|
|
557
|
+
elapsedMs: Date.now() - startTime,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
yield {
|
|
561
|
+
pageIndex: pageNum,
|
|
562
|
+
image: base64Image,
|
|
563
|
+
imageSizeBytes: page.length,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
catch (pageError) {
|
|
567
|
+
const msg = pageError instanceof Error ? pageError.message : String(pageError);
|
|
568
|
+
logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render (stream): ${msg}`);
|
|
569
|
+
yield { pageIndex: pageNum, image: "", imageSizeBytes: 0, error: msg };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
354
573
|
/**
|
|
355
574
|
* Convert a PDF file path to an array of base64 PNG images
|
|
356
575
|
*
|
|
@@ -386,7 +605,7 @@ export class PDFProcessor {
|
|
|
386
605
|
* @param scale - Scale factor
|
|
387
606
|
* @returns Estimated memory usage in MB
|
|
388
607
|
*/
|
|
389
|
-
static estimateConversionMemoryUsage(pdfSizeBytes, pageCount, scale =
|
|
608
|
+
static estimateConversionMemoryUsage(pdfSizeBytes, pageCount, scale = PDF_LIMITS.DEFAULT_SCALE) {
|
|
390
609
|
// Rough estimation:
|
|
391
610
|
// - Each page at scale 2 produces ~1-3MB PNG
|
|
392
611
|
// - MuPDF needs ~2x PDF size for processing
|
package/dist/neurolink.js
CHANGED
|
@@ -3899,11 +3899,15 @@ Current user's request: ${currentInput}`;
|
|
|
3899
3899
|
evaluationDomain: options.evaluationDomain,
|
|
3900
3900
|
toolUsageContext: options.toolUsageContext,
|
|
3901
3901
|
input: options.input,
|
|
3902
|
-
//
|
|
3903
|
-
// TextGenerationOptions — the message builder reads them
|
|
3904
|
-
// formatting
|
|
3905
|
-
//
|
|
3902
|
+
// Multimodal file-processing options must survive the reconstruction
|
|
3903
|
+
// into TextGenerationOptions — the message builder reads them downstream
|
|
3904
|
+
// (csv encoding/formatting/sanitization/parse timeout, PDF
|
|
3905
|
+
// password/decryption #258). Omitting them here silently dropped e.g.
|
|
3906
|
+
// every csvOptions field (incl. the CLI --csv-* flags) or
|
|
3907
|
+
// pdfOptions.password so the CLI --pdf-password / SDK pdfOptions never
|
|
3908
|
+
// reached convertToImages.
|
|
3906
3909
|
csvOptions: options.csvOptions,
|
|
3910
|
+
pdfOptions: options.pdfOptions,
|
|
3907
3911
|
region: options.region,
|
|
3908
3912
|
tts: options.tts,
|
|
3909
3913
|
stt: options.stt,
|
package/dist/types/file.d.ts
CHANGED
|
@@ -95,6 +95,8 @@ export type FileProcessingResult = {
|
|
|
95
95
|
estimatedPages?: number | null;
|
|
96
96
|
provider?: string;
|
|
97
97
|
apiType?: PDFAPIType;
|
|
98
|
+
/** Provider's citations requirement for visual PDF analysis (#349). */
|
|
99
|
+
requiresCitations?: boolean | "auto";
|
|
98
100
|
officeFormat?: OfficeDocumentType;
|
|
99
101
|
pageCount?: number;
|
|
100
102
|
slideCount?: number;
|
|
@@ -210,6 +212,14 @@ export type PDFProviderConfig = {
|
|
|
210
212
|
maxSizeMB: number;
|
|
211
213
|
maxPages: number;
|
|
212
214
|
supportsNative: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Whether this provider needs source citations enabled for visual PDF
|
|
217
|
+
* analysis (#349). `"auto"` = enable when the request requires visual
|
|
218
|
+
* grounding (currently Bedrock's Converse document blocks); `false` = the
|
|
219
|
+
* provider handles PDFs without an explicit citations flag. Surfaced on
|
|
220
|
+
* `FileProcessingResult.metadata.requiresCitations` so downstream provider
|
|
221
|
+
* adapters can act on it instead of the value being dead config.
|
|
222
|
+
*/
|
|
213
223
|
requiresCitations: boolean | "auto";
|
|
214
224
|
apiType: PDFAPIType;
|
|
215
225
|
};
|
|
@@ -226,6 +236,8 @@ export type PDFProcessorOptions = {
|
|
|
226
236
|
* Set to false to bypass limit enforcement (logs warning instead)
|
|
227
237
|
*/
|
|
228
238
|
enforceLimits?: boolean;
|
|
239
|
+
/** Password for an encrypted PDF (used on the image-conversion path) (#258). */
|
|
240
|
+
password?: string;
|
|
229
241
|
};
|
|
230
242
|
/**
|
|
231
243
|
* Audio provider configuration for transcription services
|
|
@@ -395,10 +407,40 @@ export type PDFImageConversionOptions = {
|
|
|
395
407
|
maxPages?: number;
|
|
396
408
|
/** Output format (default: png). Only PNG is currently implemented by PDFProcessor. */
|
|
397
409
|
format?: "png";
|
|
410
|
+
/**
|
|
411
|
+
* Per-page pixel ceiling (#260). Any page whose width×height×scale² would
|
|
412
|
+
* exceed this is uniformly downscaled to stay under it, preventing a huge
|
|
413
|
+
* page from allocating gigabytes of canvas. Default: PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS.
|
|
414
|
+
*/
|
|
415
|
+
maxCanvasPixels?: number;
|
|
416
|
+
/** Password for an encrypted PDF (passed to the underlying renderer) (#258). */
|
|
417
|
+
password?: string;
|
|
418
|
+
/** Per-page progress callback invoked as each page is rendered (#302). */
|
|
419
|
+
onProgress?: (progress: PDFImageConversionProgress) => void | Promise<void>;
|
|
420
|
+
};
|
|
421
|
+
/** Progress reported per page during streaming conversion (#302). */
|
|
422
|
+
export type PDFImageConversionProgress = {
|
|
423
|
+
/** Number of pages successfully converted so far. */
|
|
424
|
+
pagesConverted: number;
|
|
425
|
+
/** Total pages in the document (known up-front from the renderer). */
|
|
426
|
+
totalPages: number;
|
|
427
|
+
/** Elapsed time since conversion started, in milliseconds. */
|
|
428
|
+
elapsedMs: number;
|
|
429
|
+
};
|
|
430
|
+
/** A single streamed page result (#302). `error` is set when that page failed. */
|
|
431
|
+
export type PDFImagePage = {
|
|
432
|
+
/** 1-based page index. */
|
|
433
|
+
pageIndex: number;
|
|
434
|
+
/** Base64-encoded PNG for the page (empty string when `error` is set). */
|
|
435
|
+
image: string;
|
|
436
|
+
/** Byte size of the rendered PNG (0 when `error` is set). */
|
|
437
|
+
imageSizeBytes: number;
|
|
438
|
+
/** Populated when this page failed to render (#294). */
|
|
439
|
+
error?: string;
|
|
398
440
|
};
|
|
399
441
|
/** Result of PDF to image conversion. */
|
|
400
442
|
export type PDFImageConversionResult = {
|
|
401
|
-
/** Array of base64-encoded PNG images (one per page) */
|
|
443
|
+
/** Array of base64-encoded PNG images (one per successfully converted page) */
|
|
402
444
|
images: string[];
|
|
403
445
|
/** Number of pages converted */
|
|
404
446
|
pageCount: number;
|
|
@@ -406,6 +448,11 @@ export type PDFImageConversionResult = {
|
|
|
406
448
|
conversionTimeMs: number;
|
|
407
449
|
/** Any warnings during conversion */
|
|
408
450
|
warnings?: string[];
|
|
451
|
+
/** Per-page failures — present only when some pages failed to render (#294). */
|
|
452
|
+
errors?: Array<{
|
|
453
|
+
page: number;
|
|
454
|
+
error: string;
|
|
455
|
+
}>;
|
|
409
456
|
};
|
|
410
457
|
/** Options for filename sanitization. */
|
|
411
458
|
export type SanitizeFileNameOptions = {
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -125,6 +125,13 @@ export type GenerateOptions = {
|
|
|
125
125
|
music?: MusicOptions;
|
|
126
126
|
};
|
|
127
127
|
csvOptions?: CSVProcessorOptions;
|
|
128
|
+
/** PDF processing options (#258). */
|
|
129
|
+
pdfOptions?: {
|
|
130
|
+
/** Password for an encrypted PDF (image-conversion fallback path). */
|
|
131
|
+
password?: string;
|
|
132
|
+
/** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
|
|
133
|
+
maxCanvasPixels?: number;
|
|
134
|
+
};
|
|
128
135
|
videoOptions?: {
|
|
129
136
|
frames?: number;
|
|
130
137
|
quality?: number;
|
|
@@ -1131,6 +1138,13 @@ export type TextGenerationOptions = {
|
|
|
1131
1138
|
expectedOutcome?: string;
|
|
1132
1139
|
evaluationCriteria?: string[];
|
|
1133
1140
|
csvOptions?: CSVProcessorOptions;
|
|
1141
|
+
/** PDF processing options (#258). */
|
|
1142
|
+
pdfOptions?: {
|
|
1143
|
+
/** Password for an encrypted PDF (image-conversion fallback path). */
|
|
1144
|
+
password?: string;
|
|
1145
|
+
/** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
|
|
1146
|
+
maxCanvasPixels?: number;
|
|
1147
|
+
};
|
|
1134
1148
|
enableSummarization?: boolean;
|
|
1135
1149
|
/**
|
|
1136
1150
|
* Skip injecting tool schemas into the system prompt.
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -207,6 +207,13 @@ export type StreamOptions = {
|
|
|
207
207
|
};
|
|
208
208
|
};
|
|
209
209
|
csvOptions?: CSVProcessorOptions;
|
|
210
|
+
/** PDF processing options (#258). */
|
|
211
|
+
pdfOptions?: {
|
|
212
|
+
/** Password for an encrypted PDF (image-conversion fallback path). */
|
|
213
|
+
password?: string;
|
|
214
|
+
/** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
|
|
215
|
+
maxCanvasPixels?: number;
|
|
216
|
+
};
|
|
210
217
|
videoOptions?: {
|
|
211
218
|
frames?: number;
|
|
212
219
|
quality?: number;
|
|
@@ -39,6 +39,8 @@ export declare const ERROR_CODES: {
|
|
|
39
39
|
readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
|
|
40
40
|
readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
|
|
41
41
|
readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
|
|
42
|
+
readonly PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED";
|
|
43
|
+
readonly PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD";
|
|
42
44
|
readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
|
|
43
45
|
readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
|
|
44
46
|
readonly RATE_LIMITER_RESET: "RATE_LIMITER_RESET";
|
|
@@ -199,6 +201,14 @@ export declare class ErrorFactory {
|
|
|
199
201
|
* Create a PDF page limit exceeded error
|
|
200
202
|
*/
|
|
201
203
|
static pdfPageLimitExceeded(estimatedPages: number, maxPages: number, provider: string): NeuroLinkError;
|
|
204
|
+
/**
|
|
205
|
+
* The PDF is encrypted and no password was supplied (#258).
|
|
206
|
+
*/
|
|
207
|
+
static pdfPasswordRequired(): NeuroLinkError;
|
|
208
|
+
/**
|
|
209
|
+
* A password was supplied for an encrypted PDF but it was incorrect (#258).
|
|
210
|
+
*/
|
|
211
|
+
static pdfIncorrectPassword(): NeuroLinkError;
|
|
202
212
|
/**
|
|
203
213
|
* Create an image too large error
|
|
204
214
|
*/
|