@juspay/neurolink 9.95.2 → 10.0.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.
- package/CHANGELOG.md +23 -0
- package/dist/browser/neurolink.min.js +380 -380
- package/dist/cli/commands/proxy.d.ts +26 -0
- package/dist/cli/commands/proxy.js +132 -13
- package/dist/cli/factories/commandFactory.d.ts +7 -0
- package/dist/cli/factories/commandFactory.js +29 -21
- package/dist/cli/utils/inputValidation.d.ts +1 -0
- package/dist/cli/utils/inputValidation.js +6 -1
- package/dist/core/constants.d.ts +3 -0
- package/dist/core/constants.js +10 -0
- package/dist/lib/core/constants.d.ts +3 -0
- package/dist/lib/core/constants.js +10 -0
- package/dist/lib/types/file.d.ts +38 -1
- package/dist/lib/utils/fileDetector.js +69 -6
- package/dist/lib/utils/imageCache.d.ts +10 -2
- package/dist/lib/utils/imageCache.js +12 -23
- package/dist/lib/utils/imageProcessor.d.ts +25 -0
- package/dist/lib/utils/imageProcessor.js +28 -1
- package/dist/lib/utils/logSanitize.d.ts +97 -0
- package/dist/lib/utils/logSanitize.js +163 -0
- package/dist/lib/utils/messageBuilder.js +36 -0
- package/dist/lib/utils/pdfProcessor.d.ts +30 -1
- package/dist/lib/utils/pdfProcessor.js +215 -52
- package/dist/types/file.d.ts +38 -1
- package/dist/utils/fileDetector.js +69 -6
- package/dist/utils/imageCache.d.ts +10 -2
- package/dist/utils/imageCache.js +12 -23
- package/dist/utils/imageProcessor.d.ts +25 -0
- package/dist/utils/imageProcessor.js +28 -1
- package/dist/utils/logSanitize.d.ts +97 -0
- package/dist/utils/logSanitize.js +163 -0
- package/dist/utils/messageBuilder.js +36 -0
- package/dist/utils/pdfProcessor.d.ts +30 -1
- package/dist/utils/pdfProcessor.js +215 -52
- package/package.json +1 -1
|
@@ -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,53 @@ 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
|
+
// Captured outside the try so the `finally` below can always clear it
|
|
247
|
+
// — otherwise a `getInfo()` that wins the race leaves this timer
|
|
248
|
+
// running until PAGE_COUNT_TIMEOUT_MS fires for nothing. `.unref()`
|
|
249
|
+
// so it can never itself hold the process open in the meantime.
|
|
250
|
+
let timeoutHandle;
|
|
251
|
+
try {
|
|
252
|
+
const info = await Promise.race([
|
|
253
|
+
pdf.getInfo(),
|
|
254
|
+
new Promise((_, reject) => {
|
|
255
|
+
timeoutHandle = setTimeout(() => reject(new Error("page-count timeout")), PDF_LIMITS.PAGE_COUNT_TIMEOUT_MS);
|
|
256
|
+
timeoutHandle.unref?.();
|
|
257
|
+
}),
|
|
258
|
+
]);
|
|
259
|
+
const total = info.total;
|
|
260
|
+
return typeof total === "number" && total > 0 ? total : null;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
if (timeoutHandle !== undefined) {
|
|
264
|
+
clearTimeout(timeoutHandle);
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await pdf.destroy?.();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// A throwing destroy() must not discard an otherwise-valid page
|
|
271
|
+
// count returned above (matches fileReferenceRegistry.ts's
|
|
272
|
+
// pdf.destroy() cleanup pattern) — swallow it.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
logger.debug(`[PDF] Accurate page count unavailable; using regex estimate: ${error instanceof Error ? error.message : String(error)}`);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
223
281
|
static estimateTokens(pageCount, mode = "visual") {
|
|
224
282
|
if (mode === "text-only") {
|
|
225
283
|
return Math.ceil((pageCount / 3) * 1000);
|
|
@@ -291,48 +349,32 @@ export class PDFProcessor {
|
|
|
291
349
|
*/
|
|
292
350
|
static async convertToImages(pdfBuffer, options) {
|
|
293
351
|
const startTime = Date.now();
|
|
294
|
-
const { scale =
|
|
352
|
+
const { scale = PDF_LIMITS.DEFAULT_SCALE, maxPages = PDF_LIMITS.DEFAULT_MAX_PAGES, format = "png", maxCanvasPixels = PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS, password, onProgress, } = options || {};
|
|
295
353
|
const images = [];
|
|
296
354
|
const warnings = [];
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
// 0b. Validate the render scale. A non-finite or non-positive scale yields a
|
|
305
|
-
// degenerate viewport (blank/zero-dimension render), and an excessive scale
|
|
306
|
-
// can allocate hundreds of MB per page — reject both with a clear message.
|
|
307
|
-
if (!Number.isFinite(scale) || scale <= 0 || scale > PDF_LIMITS.MAX_SCALE) {
|
|
308
|
-
throw new Error(`Invalid scale: ${scale}. Scale must be a finite number greater than 0 and at most ${PDF_LIMITS.MAX_SCALE}.`);
|
|
309
|
-
}
|
|
310
|
-
// 0c. Validate the per-page pixel ceiling (#260). A non-finite or
|
|
311
|
-
// non-positive value would disable the guard or yield a NaN downscale.
|
|
312
|
-
if (!Number.isFinite(maxCanvasPixels) || maxCanvasPixels <= 0) {
|
|
313
|
-
throw new Error(`Invalid maxCanvasPixels: ${maxCanvasPixels}. Must be a finite number greater than 0.`);
|
|
314
|
-
}
|
|
315
|
-
// 1. Validate buffer is not empty or too small
|
|
316
|
-
if (!pdfBuffer || pdfBuffer.length < 5) {
|
|
317
|
-
throw new Error("Invalid PDF: Buffer is too small or empty. " +
|
|
318
|
-
"A valid PDF must be at least 5 bytes (PDF header).");
|
|
319
|
-
}
|
|
320
|
-
// 2. Validate PDF magic bytes (%PDF-)
|
|
321
|
-
if (!PDFProcessor.isValidPDF(pdfBuffer)) {
|
|
322
|
-
throw new Error("Invalid PDF: File must start with %PDF- header. " +
|
|
323
|
-
"The provided buffer does not appear to be a valid PDF file.");
|
|
324
|
-
}
|
|
325
|
-
// 3. Validate maximum buffer size to prevent memory exhaustion
|
|
326
|
-
const sizeMB = pdfBuffer.length / (1024 * 1024);
|
|
327
|
-
if (sizeMB > PDF_LIMITS.MAX_SIZE_MB) {
|
|
328
|
-
throw new Error(`PDF too large for image conversion: ${sizeMB.toFixed(2)}MB exceeds ${PDF_LIMITS.MAX_SIZE_MB}MB limit. ` +
|
|
329
|
-
"Consider splitting the PDF or using a provider with native PDF support.");
|
|
330
|
-
}
|
|
355
|
+
const pageErrors = [];
|
|
356
|
+
// Validation shared with convertToImagesStream (#302).
|
|
357
|
+
const sizeMB = PDFProcessor.validateImageConversionInput(pdfBuffer, {
|
|
358
|
+
format,
|
|
359
|
+
scale,
|
|
360
|
+
maxCanvasPixels,
|
|
361
|
+
});
|
|
331
362
|
logger.debug("[PDF→Image] ✅ PDF validation passed", {
|
|
332
363
|
bufferSize: pdfBuffer.length,
|
|
333
364
|
sizeMB: sizeMB.toFixed(2),
|
|
334
365
|
maxPages,
|
|
335
366
|
});
|
|
367
|
+
// #297: surface an up-front memory estimate so an operator can spot a
|
|
368
|
+
// conversion that will allocate a lot before it runs. Uses the cheap regex
|
|
369
|
+
// page estimate (the accurate pdf-parse count runs in process(), not here).
|
|
370
|
+
const estimatedPages = Math.max(1, PDFProcessor.extractBasicMetadata(pdfBuffer).estimatedPages ?? 1);
|
|
371
|
+
const estimatedMemoryMB = PDFProcessor.estimateConversionMemoryUsage(pdfBuffer.length, estimatedPages, scale);
|
|
372
|
+
logger.info("[PDF→Image] Estimated memory usage before conversion", {
|
|
373
|
+
scale,
|
|
374
|
+
estimatedPages,
|
|
375
|
+
estimatedMemoryMB,
|
|
376
|
+
sizeMB: Number(sizeMB.toFixed(2)),
|
|
377
|
+
});
|
|
336
378
|
try {
|
|
337
379
|
// Dynamic import to avoid loading MuPDF binaries until needed
|
|
338
380
|
const pdfToImgModule = await import("pdf-to-img");
|
|
@@ -369,35 +411,60 @@ export class PDFProcessor {
|
|
|
369
411
|
logger.warn(`[PDF→Image] ⚠️ ${msg}`);
|
|
370
412
|
warnings.push(msg);
|
|
371
413
|
}
|
|
372
|
-
// Create PDF document
|
|
414
|
+
// Create PDF document (password forwarded for encrypted PDFs, #258).
|
|
415
|
+
// pdf-to-img resolves `.length` (numPages) synchronously here and exposes
|
|
416
|
+
// `.getPage(n)`, so we drive an indexed loop rather than the async
|
|
417
|
+
// iterator — that lets one bad page be isolated instead of aborting all.
|
|
373
418
|
const document = await pdf(pdfBuffer, {
|
|
374
419
|
scale: effectiveScale,
|
|
375
420
|
...(password ? { password } : {}),
|
|
376
421
|
});
|
|
377
|
-
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
422
|
+
const totalPages = document.length;
|
|
423
|
+
// #294: convert page-by-page with per-page isolation. A single page whose
|
|
424
|
+
// canvas render throws (e.g. a degenerate MediaBox) no longer discards
|
|
425
|
+
// every already-converted page — it is recorded in `errors` and the rest
|
|
426
|
+
// continue.
|
|
427
|
+
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
|
428
|
+
if (maxPages !== undefined && pageNum - 1 >= maxPages) {
|
|
429
|
+
warnings.push(`Stopped at page ${pageNum - 1} (maxPages limit: ${maxPages})`);
|
|
383
430
|
break;
|
|
384
431
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
432
|
+
try {
|
|
433
|
+
const page = await document.getPage(pageNum);
|
|
434
|
+
const base64Image = page.toString("base64");
|
|
435
|
+
images.push(base64Image);
|
|
436
|
+
logger.debug(`[PDF→Image] Converted page ${pageNum}`, {
|
|
437
|
+
imageSizeBytes: page.length,
|
|
438
|
+
base64Length: base64Image.length,
|
|
439
|
+
});
|
|
440
|
+
// #302: report progress after each successfully converted page.
|
|
441
|
+
if (onProgress) {
|
|
442
|
+
await onProgress({
|
|
443
|
+
pagesConverted: images.length,
|
|
444
|
+
totalPages,
|
|
445
|
+
elapsedMs: Date.now() - startTime,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (pageError) {
|
|
450
|
+
const msg = pageError instanceof Error ? pageError.message : String(pageError);
|
|
451
|
+
logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render: ${msg}`);
|
|
452
|
+
pageErrors.push({ page: pageNum, error: msg });
|
|
453
|
+
}
|
|
393
454
|
}
|
|
394
|
-
//
|
|
455
|
+
// Empty PDF (0 pages) or every page failed → treat as a conversion
|
|
456
|
+
// failure (kept inside the try so the password/format mapping below still
|
|
457
|
+
// applies), otherwise return the pages that did convert.
|
|
395
458
|
if (images.length === 0) {
|
|
459
|
+
if (pageErrors.length > 0) {
|
|
460
|
+
throw new Error(`All ${pageErrors.length} page(s) failed to render. First error: ${pageErrors[0].error}`);
|
|
461
|
+
}
|
|
396
462
|
throw new Error("PDF has 0 pages. Cannot convert empty PDF to images.");
|
|
397
463
|
}
|
|
398
464
|
const conversionTimeMs = Date.now() - startTime;
|
|
399
465
|
logger.info("[PDF→Image] ✅ PDF conversion completed", {
|
|
400
466
|
pageCount: images.length,
|
|
467
|
+
failedPages: pageErrors.length,
|
|
401
468
|
conversionTimeMs,
|
|
402
469
|
totalImageBytes: images.reduce((sum, img) => sum + img.length, 0),
|
|
403
470
|
});
|
|
@@ -406,6 +473,7 @@ export class PDFProcessor {
|
|
|
406
473
|
pageCount: images.length,
|
|
407
474
|
conversionTimeMs,
|
|
408
475
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
476
|
+
errors: pageErrors.length > 0 ? pageErrors : undefined,
|
|
409
477
|
};
|
|
410
478
|
}
|
|
411
479
|
catch (error) {
|
|
@@ -431,6 +499,101 @@ export class PDFProcessor {
|
|
|
431
499
|
});
|
|
432
500
|
}
|
|
433
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Shared input validation for the image-conversion paths. Returns the PDF
|
|
504
|
+
* size in MB (needed by the memory-estimate log). Throws on any invalid input
|
|
505
|
+
* with the same messages the batch path has always used.
|
|
506
|
+
*/
|
|
507
|
+
static validateImageConversionInput(pdfBuffer, opts) {
|
|
508
|
+
if (opts.format !== "png") {
|
|
509
|
+
throw new Error(`Invalid format: "${opts.format}". Only "png" format is currently supported.`);
|
|
510
|
+
}
|
|
511
|
+
if (!Number.isFinite(opts.scale) ||
|
|
512
|
+
opts.scale < PDF_LIMITS.MIN_SCALE ||
|
|
513
|
+
opts.scale > PDF_LIMITS.MAX_SCALE) {
|
|
514
|
+
throw new Error(`Invalid scale: ${opts.scale}. Scale must be a finite number between ${PDF_LIMITS.MIN_SCALE} and ${PDF_LIMITS.MAX_SCALE}.`);
|
|
515
|
+
}
|
|
516
|
+
if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
|
|
517
|
+
throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
|
|
518
|
+
}
|
|
519
|
+
if (!pdfBuffer || pdfBuffer.length < 5) {
|
|
520
|
+
throw new Error("Invalid PDF: Buffer is too small or empty. " +
|
|
521
|
+
"A valid PDF must be at least 5 bytes (PDF header).");
|
|
522
|
+
}
|
|
523
|
+
if (!PDFProcessor.isValidPDF(pdfBuffer)) {
|
|
524
|
+
throw new Error("Invalid PDF: File must start with %PDF- header. " +
|
|
525
|
+
"The provided buffer does not appear to be a valid PDF file.");
|
|
526
|
+
}
|
|
527
|
+
const sizeMB = pdfBuffer.length / (1024 * 1024);
|
|
528
|
+
if (sizeMB > PDF_LIMITS.MAX_SIZE_MB) {
|
|
529
|
+
throw new Error(`PDF too large for image conversion: ${sizeMB.toFixed(2)}MB exceeds ${PDF_LIMITS.MAX_SIZE_MB}MB limit. ` +
|
|
530
|
+
"Consider splitting the PDF or using a provider with native PDF support.");
|
|
531
|
+
}
|
|
532
|
+
return sizeMB;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Streaming variant of {@link convertToImages} (#302): yields each page's
|
|
536
|
+
* base64 PNG as soon as it renders instead of buffering the whole document,
|
|
537
|
+
* and reports progress via `options.onProgress`. A page that fails to render
|
|
538
|
+
* is yielded with `error` set (per-page isolation, #294) rather than aborting
|
|
539
|
+
* the stream.
|
|
540
|
+
*
|
|
541
|
+
* NOT a wrapper of/over {@link convertToImages}, despite the similar
|
|
542
|
+
* contract — the two are independent, parallel implementations (each does
|
|
543
|
+
* its own `pdf-to-img` import, downscale calculation, and page loop)
|
|
544
|
+
* rather than one delegating to the other. Keep behavior changes (page
|
|
545
|
+
* isolation, downscale, password handling) in sync across both by hand.
|
|
546
|
+
*/
|
|
547
|
+
static async *convertToImagesStream(pdfBuffer, options) {
|
|
548
|
+
const startTime = Date.now();
|
|
549
|
+
const { scale = PDF_LIMITS.DEFAULT_SCALE, maxPages = PDF_LIMITS.DEFAULT_MAX_PAGES, format = "png", maxCanvasPixels = PDF_LIMITS.DEFAULT_MAX_CANVAS_PIXELS, password, onProgress, } = options || {};
|
|
550
|
+
PDFProcessor.validateImageConversionInput(pdfBuffer, {
|
|
551
|
+
format,
|
|
552
|
+
scale,
|
|
553
|
+
maxCanvasPixels,
|
|
554
|
+
});
|
|
555
|
+
const pdfToImgModule = await import("pdf-to-img");
|
|
556
|
+
const pdf = pdfToImgModule.pdf;
|
|
557
|
+
// #260: uniform downscale so the largest page stays under maxCanvasPixels.
|
|
558
|
+
let effectiveScale = scale;
|
|
559
|
+
const largestPixels = PDFProcessor.largestPagePixels(pdfBuffer, scale);
|
|
560
|
+
if (largestPixels > maxCanvasPixels) {
|
|
561
|
+
effectiveScale = scale * Math.sqrt(maxCanvasPixels / largestPixels);
|
|
562
|
+
}
|
|
563
|
+
const document = await pdf(pdfBuffer, {
|
|
564
|
+
scale: effectiveScale,
|
|
565
|
+
...(password ? { password } : {}),
|
|
566
|
+
});
|
|
567
|
+
const totalPages = document.length;
|
|
568
|
+
let converted = 0;
|
|
569
|
+
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
|
570
|
+
if (maxPages !== undefined && pageNum - 1 >= maxPages) {
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const page = await document.getPage(pageNum);
|
|
575
|
+
const base64Image = page.toString("base64");
|
|
576
|
+
converted++;
|
|
577
|
+
if (onProgress) {
|
|
578
|
+
await onProgress({
|
|
579
|
+
pagesConverted: converted,
|
|
580
|
+
totalPages,
|
|
581
|
+
elapsedMs: Date.now() - startTime,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
yield {
|
|
585
|
+
pageIndex: pageNum,
|
|
586
|
+
image: base64Image,
|
|
587
|
+
imageSizeBytes: page.length,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
catch (pageError) {
|
|
591
|
+
const msg = pageError instanceof Error ? pageError.message : String(pageError);
|
|
592
|
+
logger.warn(`[PDF→Image] ⚠️ page ${pageNum} failed to render (stream): ${msg}`);
|
|
593
|
+
yield { pageIndex: pageNum, image: "", imageSizeBytes: 0, error: msg };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
434
597
|
/**
|
|
435
598
|
* Convert a PDF file path to an array of base64 PNG images
|
|
436
599
|
*
|
|
@@ -466,7 +629,7 @@ export class PDFProcessor {
|
|
|
466
629
|
* @param scale - Scale factor
|
|
467
630
|
* @returns Estimated memory usage in MB
|
|
468
631
|
*/
|
|
469
|
-
static estimateConversionMemoryUsage(pdfSizeBytes, pageCount, scale =
|
|
632
|
+
static estimateConversionMemoryUsage(pdfSizeBytes, pageCount, scale = PDF_LIMITS.DEFAULT_SCALE) {
|
|
470
633
|
// Rough estimation:
|
|
471
634
|
// - Each page at scale 2 produces ~1-3MB PNG
|
|
472
635
|
// - MuPDF needs ~2x PDF size for processing
|
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
|
};
|
|
@@ -405,10 +415,32 @@ export type PDFImageConversionOptions = {
|
|
|
405
415
|
maxCanvasPixels?: number;
|
|
406
416
|
/** Password for an encrypted PDF (passed to the underlying renderer) (#258). */
|
|
407
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;
|
|
408
440
|
};
|
|
409
441
|
/** Result of PDF to image conversion. */
|
|
410
442
|
export type PDFImageConversionResult = {
|
|
411
|
-
/** Array of base64-encoded PNG images (one per page) */
|
|
443
|
+
/** Array of base64-encoded PNG images (one per successfully converted page) */
|
|
412
444
|
images: string[];
|
|
413
445
|
/** Number of pages converted */
|
|
414
446
|
pageCount: number;
|
|
@@ -416,6 +448,11 @@ export type PDFImageConversionResult = {
|
|
|
416
448
|
conversionTimeMs: number;
|
|
417
449
|
/** Any warnings during conversion */
|
|
418
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
|
+
}>;
|
|
419
456
|
};
|
|
420
457
|
/** Options for filename sanitization. */
|
|
421
458
|
export type SanitizeFileNameOptions = {
|
|
@@ -25,7 +25,7 @@ import { CSVProcessor } from "./csvProcessor.js";
|
|
|
25
25
|
import { ImageProcessor } from "./imageProcessor.js";
|
|
26
26
|
import { logger } from "./logger.js";
|
|
27
27
|
import { withTimeout } from "./errorHandling.js";
|
|
28
|
-
import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
|
|
28
|
+
import { normalizeUrlForCache, redactUrlForError, sanitizeErrorCause, } from "./logSanitize.js";
|
|
29
29
|
import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
|
|
30
30
|
import { PDFProcessor } from "./pdfProcessor.js";
|
|
31
31
|
/**
|
|
@@ -54,22 +54,40 @@ const DEFAULT_RETRY_DELAY = 1000; // milliseconds
|
|
|
54
54
|
const URL_CONTENT_TYPE_TTL_MS = 60_000;
|
|
55
55
|
const URL_CONTENT_TYPE_CACHE_MAX_SIZE = 512;
|
|
56
56
|
const urlContentTypeCache = new Map();
|
|
57
|
+
/**
|
|
58
|
+
* Build the Map key for `urlContentTypeCache`. Delegates to the shared
|
|
59
|
+
* {@link normalizeUrlForCache} — this is a module-level, process-lifetime
|
|
60
|
+
* cache, so it must strip presigned-URL auth/signature query params (see
|
|
61
|
+
* `SENSITIVE_URL_QUERY_PARAM_DENYLIST`) the same way `ImageCache.normalizeUrl`
|
|
62
|
+
* does, folding a short hash of the stripped params into the key whenever any
|
|
63
|
+
* were present so two different presigned URLs for the same path don't
|
|
64
|
+
* collide and serve each other's cached content-type across auth contexts.
|
|
65
|
+
* Also strips tracking/analytics params first, so two URLs differing only by
|
|
66
|
+
* tracking noise (e.g. `utm_source`) still hit the same cache entry instead
|
|
67
|
+
* of missing each other. Falls back to the raw URL if it isn't a parseable
|
|
68
|
+
* absolute URL. Shared by both `getCachedUrlContentType` and
|
|
69
|
+
* `setCachedUrlContentType` so lookups and writes always agree on the key.
|
|
70
|
+
*/
|
|
71
|
+
function cacheKeyForUrl(url) {
|
|
72
|
+
return normalizeUrlForCache(url);
|
|
73
|
+
}
|
|
57
74
|
function getCachedUrlContentType(url, now) {
|
|
58
|
-
const
|
|
75
|
+
const key = cacheKeyForUrl(url);
|
|
76
|
+
const hit = urlContentTypeCache.get(key);
|
|
59
77
|
if (hit && hit.expiresAt > now) {
|
|
60
78
|
// Bump recency: Map iteration order follows insertion order, and the
|
|
61
79
|
// eviction below deletes the *first* key, so a plain `get` on a hot
|
|
62
80
|
// entry would leave it first in line for eviction despite being the
|
|
63
81
|
// most recently used. Re-inserting turns the size-bounded FIFO below
|
|
64
82
|
// into an actual LRU.
|
|
65
|
-
urlContentTypeCache.delete(
|
|
66
|
-
urlContentTypeCache.set(
|
|
83
|
+
urlContentTypeCache.delete(key);
|
|
84
|
+
urlContentTypeCache.set(key, hit);
|
|
67
85
|
return hit.contentType;
|
|
68
86
|
}
|
|
69
87
|
if (hit) {
|
|
70
88
|
// Entry exists but its TTL has passed — treat as a miss and evict it
|
|
71
89
|
// immediately rather than serving (or retaining) stale data.
|
|
72
|
-
urlContentTypeCache.delete(
|
|
90
|
+
urlContentTypeCache.delete(key);
|
|
73
91
|
}
|
|
74
92
|
return undefined;
|
|
75
93
|
}
|
|
@@ -89,7 +107,8 @@ function setCachedUrlContentType(url, contentType, now) {
|
|
|
89
107
|
if (!contentType) {
|
|
90
108
|
return;
|
|
91
109
|
}
|
|
92
|
-
|
|
110
|
+
const key = cacheKeyForUrl(url);
|
|
111
|
+
urlContentTypeCache.set(key, {
|
|
93
112
|
contentType,
|
|
94
113
|
expiresAt: now + URL_CONTENT_TYPE_TTL_MS,
|
|
95
114
|
});
|
|
@@ -1413,6 +1432,50 @@ export class FileDetector {
|
|
|
1413
1432
|
const timeout = options?.timeout || FileDetector.DEFAULT_NETWORK_TIMEOUT;
|
|
1414
1433
|
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1415
1434
|
const retryDelay = options?.retryDelay ?? DEFAULT_RETRY_DELAY;
|
|
1435
|
+
// #317: pre-flight HEAD to reject an oversized file BEFORE downloading any
|
|
1436
|
+
// body. content-length is advisory (chunked responses omit it), so a
|
|
1437
|
+
// missing/invalid header — or a server that refuses HEAD — falls through to
|
|
1438
|
+
// the streaming byte guard below; only a genuine oversize rejection stops
|
|
1439
|
+
// the GET from ever running.
|
|
1440
|
+
//
|
|
1441
|
+
// #323: skip the pre-flight entirely when this exact URL was recently seen
|
|
1442
|
+
// (its Content-Type is still cached, whether from a prior loadFromURL GET
|
|
1443
|
+
// or a MimeTypeStrategy HEAD) — issuing a fresh HEAD here would defeat the
|
|
1444
|
+
// whole point of that cache. The streaming byte guard in the GET below
|
|
1445
|
+
// still enforces maxSize even without a pre-flight, so this doesn't remove
|
|
1446
|
+
// the oversize protection — it only skips the redundant round-trip for a
|
|
1447
|
+
// URL we've already been talking to within the last 60s.
|
|
1448
|
+
if (getCachedUrlContentType(url, Date.now()) === undefined) {
|
|
1449
|
+
try {
|
|
1450
|
+
const head = await request(url, {
|
|
1451
|
+
dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
|
|
1452
|
+
method: "HEAD",
|
|
1453
|
+
headersTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
|
|
1454
|
+
bodyTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
|
|
1455
|
+
});
|
|
1456
|
+
// Drain/close the (empty) HEAD body so the connection can be reused.
|
|
1457
|
+
await head.body.dump();
|
|
1458
|
+
// Only trust `content-length` on a genuine 2xx response. A non-2xx
|
|
1459
|
+
// HEAD (redirect the dispatcher didn't follow, 403/404/405 "HEAD not
|
|
1460
|
+
// allowed", 5xx, …) can still carry a stale/irrelevant
|
|
1461
|
+
// `content-length` header — enforcing size off of that would reject
|
|
1462
|
+
// (or silently pass) based on the wrong body. Treat any non-2xx HEAD
|
|
1463
|
+
// as if the header were missing and fall through to the streaming
|
|
1464
|
+
// GET guard below, which enforces maxSize independently either way.
|
|
1465
|
+
if (head.statusCode >= 200 && head.statusCode < 300) {
|
|
1466
|
+
const declaredLength = Number(head.headers["content-length"]);
|
|
1467
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxSize) {
|
|
1468
|
+
throw new Error(`File too large: ${formatFileSize(declaredLength)} (max: ${formatFileSize(maxSize)})`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
catch (error) {
|
|
1473
|
+
if (error instanceof Error && /File too large/.test(error.message)) {
|
|
1474
|
+
throw error;
|
|
1475
|
+
}
|
|
1476
|
+
logger.debug(`[FileDetector] HEAD pre-flight skipped for ${redactUrlForError(url)}: ${sanitizeErrorCause(error).message}`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1416
1479
|
return withRetry(async () => {
|
|
1417
1480
|
try {
|
|
1418
1481
|
const response = await request(url, {
|
|
@@ -33,8 +33,16 @@ export declare class ImageCache {
|
|
|
33
33
|
*/
|
|
34
34
|
private parseConfigValue;
|
|
35
35
|
/**
|
|
36
|
-
* Normalize URL for consistent cache key generation
|
|
37
|
-
*
|
|
36
|
+
* Normalize URL for consistent cache key generation.
|
|
37
|
+
*
|
|
38
|
+
* Delegates to the shared {@link normalizeUrlForCache} — this normalized
|
|
39
|
+
* URL is used as the in-memory Map key (see `get`/`set` below), which lives
|
|
40
|
+
* for the process lifetime, so both tracking-param noise and presigned-URL
|
|
41
|
+
* auth/signature secrets must be stripped consistently with
|
|
42
|
+
* `fileDetector.cacheKeyForUrl`'s identical requirement. See
|
|
43
|
+
* `stripSensitiveUrlParamsForCacheKey`'s doc comment for why a naive
|
|
44
|
+
* strip-only approach (no hash suffix) would be a correctness bug, not just
|
|
45
|
+
* a hygiene one.
|
|
38
46
|
*/
|
|
39
47
|
private normalizeUrl;
|
|
40
48
|
/**
|
package/dist/utils/imageCache.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createHash } from "crypto";
|
|
16
16
|
import { logger } from "./logger.js";
|
|
17
|
-
import { redactUrlForError } from "./logSanitize.js";
|
|
17
|
+
import { normalizeUrlForCache, redactUrlForError } from "./logSanitize.js";
|
|
18
18
|
/**
|
|
19
19
|
* LRU Cache for downloaded images
|
|
20
20
|
*
|
|
@@ -85,30 +85,19 @@ export class ImageCache {
|
|
|
85
85
|
return value;
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
|
-
* Normalize URL for consistent cache key generation
|
|
89
|
-
*
|
|
88
|
+
* Normalize URL for consistent cache key generation.
|
|
89
|
+
*
|
|
90
|
+
* Delegates to the shared {@link normalizeUrlForCache} — this normalized
|
|
91
|
+
* URL is used as the in-memory Map key (see `get`/`set` below), which lives
|
|
92
|
+
* for the process lifetime, so both tracking-param noise and presigned-URL
|
|
93
|
+
* auth/signature secrets must be stripped consistently with
|
|
94
|
+
* `fileDetector.cacheKeyForUrl`'s identical requirement. See
|
|
95
|
+
* `stripSensitiveUrlParamsForCacheKey`'s doc comment for why a naive
|
|
96
|
+
* strip-only approach (no hash suffix) would be a correctness bug, not just
|
|
97
|
+
* a hygiene one.
|
|
90
98
|
*/
|
|
91
99
|
normalizeUrl(url) {
|
|
92
|
-
|
|
93
|
-
const parsed = new URL(url);
|
|
94
|
-
// Remove common tracking parameters that don't affect content
|
|
95
|
-
const trackingParams = [
|
|
96
|
-
"utm_source",
|
|
97
|
-
"utm_medium",
|
|
98
|
-
"utm_campaign",
|
|
99
|
-
"utm_term",
|
|
100
|
-
"utm_content",
|
|
101
|
-
"fbclid",
|
|
102
|
-
"gclid",
|
|
103
|
-
"_ga",
|
|
104
|
-
];
|
|
105
|
-
trackingParams.forEach((param) => parsed.searchParams.delete(param));
|
|
106
|
-
return parsed.toString();
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
// If URL parsing fails, use the original URL
|
|
110
|
-
return url;
|
|
111
|
-
}
|
|
100
|
+
return normalizeUrlForCache(url);
|
|
112
101
|
}
|
|
113
102
|
/**
|
|
114
103
|
* Generate content hash from image data
|
|
@@ -13,6 +13,31 @@ export declare const MIN_IMAGE_SIZE = 12;
|
|
|
13
13
|
* buffer that carries a format's magic bytes but is smaller than this is
|
|
14
14
|
* truncated/corrupt and would fail (or render blank) downstream — reject early
|
|
15
15
|
* with a clear message instead.
|
|
16
|
+
*
|
|
17
|
+
* Each threshold approximates the smallest byte count that format's
|
|
18
|
+
* container can structurally be while still holding the mandatory
|
|
19
|
+
* header/chunk skeleton for a minimal (e.g. 1×1 pixel) image — not an exact
|
|
20
|
+
* spec minimum, but close enough that anything smaller is unambiguously
|
|
21
|
+
* truncated:
|
|
22
|
+
* - **PNG (67)**: 8-byte signature + IHDR chunk (4 len + 4 type + 13 data +
|
|
23
|
+
* 4 CRC = 25) + a minimal IDAT chunk carrying one zlib-compressed
|
|
24
|
+
* scanline (~22 bytes) + IEND chunk (4 len + 4 type + 0 data + 4 CRC =
|
|
25
|
+
* 12) — matches the commonly-cited size of the smallest possible valid
|
|
26
|
+
* PNG (a 1×1 transparent pixel).
|
|
27
|
+
* - **JPEG (125)**: SOI + APP0/JFIF marker (18) + at least one DQT
|
|
28
|
+
* quantization table (~69 for one 8-bit luma table) + SOF0 + DHT + SOS
|
|
29
|
+
* headers + a minimal entropy-coded scan + EOI — the smallest legal
|
|
30
|
+
* baseline-JPEG skeleton for a 1×1 image.
|
|
31
|
+
* - **GIF (43)**: 6-byte GIF87a/89a header + 7-byte Logical Screen
|
|
32
|
+
* Descriptor + a 2-color (6-byte) color table + 10-byte Image
|
|
33
|
+
* Descriptor + minimal LZW-coded image sub-blocks + 1-byte trailer.
|
|
34
|
+
* - **WEBP (20)**: RIFF container header ("RIFF" + size + "WEBP" = 12
|
|
35
|
+
* bytes) + the first chunk's fourCC + size (8 bytes) — enough to confirm
|
|
36
|
+
* a well-formed RIFF/WEBP container and its first chunk, before any
|
|
37
|
+
* actual VP8/VP8L bitstream payload.
|
|
38
|
+
* - **AVIF (100)**: ISOBMFF `ftyp` box plus the mandatory `meta` box
|
|
39
|
+
* skeleton (`hdlr`/`pitm`/`iloc`/`iinf` sub-boxes) — roughly the minimum
|
|
40
|
+
* to hold AVIF's required box structure before any AV1 image item data.
|
|
16
41
|
*/
|
|
17
42
|
export declare const MIN_VALID_IMAGE_SIZE: Record<string, number>;
|
|
18
43
|
/**
|