@larkup/tool-doc-editor 0.2.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/dist/editor.js ADDED
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Document editor — orchestrates parsing, editing, and export.
3
+ *
4
+ * Uses:
5
+ * - pdf-lib (MIT) for native PDF form filling
6
+ * - Sandbox scripts for DOCX/PPTX editing via python-docx/python-pptx
7
+ * - In-memory session management
8
+ */
9
+ import { PDFDocument, PDFTextField, PDFCheckBox, PDFDropdown, PDFRadioGroup, rgb, StandardFonts, } from 'pdf-lib';
10
+ import { SandboxManager } from '@larkup/sandbox';
11
+ import { parseDocument, enrichPDFWithText } from './parsers.js';
12
+ import { generateFillDOCXScript, generateFillPPTXScript, generateEditDOCXScript, generateEditPPTXScript, generateExtractPDFTextScript, } from './sandbox-scripts.js';
13
+ const sessions = new Map();
14
+ export function getSession(sessionId) {
15
+ return sessions.get(sessionId);
16
+ }
17
+ export function getAllSessions() {
18
+ return Array.from(sessions.values()).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
19
+ }
20
+ export function deleteSession(sessionId) {
21
+ return sessions.delete(sessionId);
22
+ }
23
+ export function restoreSession(session) {
24
+ sessions.set(session.id, session);
25
+ }
26
+ /* ------------------------------------------------------------------ */
27
+ /* Create session — parse and store document */
28
+ /* ------------------------------------------------------------------ */
29
+ export async function createSession(buffer, fileName, mimeType) {
30
+ // Parse the document
31
+ let parsed = await parseDocument(buffer, fileName, mimeType);
32
+ // For PDFs, try to extract text via sandbox for richer content
33
+ // Skip heavy extraction for long PDFs (e.g. > 5 pages) to just "view" them quickly
34
+ if (parsed.type === 'pdf' && parsed.rawText === '' && parsed.totalPages <= 5) {
35
+ try {
36
+ const sandboxResult = await runSandboxScript(generateExtractPDFTextScript(), buffer, 'document.pdf');
37
+ if (sandboxResult.exitCode === 0) {
38
+ const resultMatch = sandboxResult.stdout.match(/__RESULT__:(.+)/);
39
+ if (resultMatch) {
40
+ const extracted = JSON.parse(resultMatch[1]);
41
+ const pageTexts = (extracted.pages || [])
42
+ .map((p) => p.text)
43
+ .join('\n\n\n');
44
+ parsed = enrichPDFWithText(parsed, pageTexts);
45
+ // Enrich fields from sandbox pypdf extraction
46
+ if (extracted.fields && extracted.fields.length > 0) {
47
+ for (const f of extracted.fields) {
48
+ const exists = parsed.fields.some((pf) => pf.name === f.name);
49
+ if (!exists) {
50
+ parsed.fields.push({
51
+ id: `sandbox_field_${parsed.fields.length}`,
52
+ name: f.name,
53
+ type: f.type === '/Tx' ? 'text' : 'text',
54
+ value: f.value || '',
55
+ pageIndex: 0,
56
+ });
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ catch {
64
+ // Sandbox not available — continue with basic extraction
65
+ }
66
+ }
67
+ const fileBase64 = buffer.toString('base64');
68
+ const session = {
69
+ id: parsed.sessionId,
70
+ fileName,
71
+ type: parsed.type,
72
+ mimeType: parsed.mimeType,
73
+ originalFileBase64: fileBase64,
74
+ currentFileBase64: fileBase64,
75
+ parsed,
76
+ edits: [],
77
+ createdAt: new Date().toISOString(),
78
+ updatedAt: new Date().toISOString(),
79
+ };
80
+ sessions.set(session.id, session);
81
+ return session;
82
+ }
83
+ /* ------------------------------------------------------------------ */
84
+ /* Apply field edits */
85
+ /* ------------------------------------------------------------------ */
86
+ export async function applyFieldEdits(sessionId, edits) {
87
+ const session = sessions.get(sessionId);
88
+ if (!session) {
89
+ console.error(`[doc-editor] Session not found: ${sessionId}. Available sessions:`, Array.from(sessions.keys()));
90
+ return {
91
+ success: false,
92
+ parsed: null,
93
+ fileBase64: '',
94
+ error: 'Session not found',
95
+ updatedFields: [],
96
+ };
97
+ }
98
+ const buffer = Buffer.from(session.currentFileBase64, 'base64');
99
+ try {
100
+ let resultBuffer;
101
+ let updatedFields = [];
102
+ // Map field IDs to actual field names for native PDF filling and sandbox scripts.
103
+ // The LLM sends field IDs like "pdf_field_0"; we resolve them to the actual PDF field names.
104
+ // If the ID doesn't match any field, try matching by name directly (case-insensitive).
105
+ const resolvedEdits = edits.map((edit) => {
106
+ // Primary: look up by ID
107
+ let field = session.parsed.fields.find((f) => f.id === edit.fieldId);
108
+ if (!field) {
109
+ // Fallback: match by name (case-insensitive, trimmed)
110
+ const needle = edit.fieldId.trim().toLowerCase();
111
+ field = session.parsed.fields.find((f) => f.name.trim().toLowerCase() === needle);
112
+ }
113
+ if (!field) {
114
+ // Fallback: match by partial name containment
115
+ const needle = edit.fieldId.trim().toLowerCase();
116
+ field = session.parsed.fields.find((f) => f.name.trim().toLowerCase().includes(needle) ||
117
+ needle.includes(f.name.trim().toLowerCase()));
118
+ }
119
+ const resolvedName = field ? field.name : edit.fieldId;
120
+ console.log(`[doc-editor] Resolving fieldId "${edit.fieldId}" → "${resolvedName}" (matched: ${!!field})`);
121
+ return {
122
+ ...edit,
123
+ fieldId: resolvedName,
124
+ };
125
+ });
126
+ switch (session.type) {
127
+ case 'pdf': {
128
+ // Use pdf-lib natively for PDF form filling (no sandbox needed!)
129
+ const fillResult = await fillPDFNatively(buffer, resolvedEdits);
130
+ resultBuffer = fillResult.buffer;
131
+ updatedFields = fillResult.matchedFields;
132
+ if (fillResult.matchedFields.length === 0) {
133
+ console.warn(`[doc-editor] WARNING: fillPDFNatively matched 0 fields. Edits:`, resolvedEdits.map((e) => e.fieldId));
134
+ }
135
+ break;
136
+ }
137
+ case 'docx': {
138
+ // Use sandbox python-docx
139
+ const script = generateFillDOCXScript(resolvedEdits);
140
+ const result = await runSandboxScript(script, buffer, 'document.docx');
141
+ if (result.exitCode !== 0) {
142
+ return {
143
+ success: false,
144
+ parsed: session.parsed,
145
+ fileBase64: session.currentFileBase64,
146
+ error: result.stderr || 'DOCX edit failed',
147
+ updatedFields: [],
148
+ };
149
+ }
150
+ // Get the modified file from sandbox artifacts
151
+ const artifact = result.artifacts.find((a) => a.name === 'document.docx');
152
+ if (!artifact) {
153
+ return {
154
+ success: false,
155
+ parsed: session.parsed,
156
+ fileBase64: session.currentFileBase64,
157
+ error: 'Modified file not returned from sandbox',
158
+ updatedFields: [],
159
+ };
160
+ }
161
+ resultBuffer = Buffer.from(artifact.data, 'base64');
162
+ updatedFields = edits.map((e) => e.fieldId);
163
+ break;
164
+ }
165
+ case 'pptx': {
166
+ // Use sandbox python-pptx
167
+ const script = generateFillPPTXScript(resolvedEdits);
168
+ const result = await runSandboxScript(script, buffer, 'document.pptx');
169
+ if (result.exitCode !== 0) {
170
+ return {
171
+ success: false,
172
+ parsed: session.parsed,
173
+ fileBase64: session.currentFileBase64,
174
+ error: result.stderr || 'PPTX edit failed',
175
+ updatedFields: [],
176
+ };
177
+ }
178
+ const artifact = result.artifacts.find((a) => a.name === 'document.pptx');
179
+ if (!artifact) {
180
+ return {
181
+ success: false,
182
+ parsed: session.parsed,
183
+ fileBase64: session.currentFileBase64,
184
+ error: 'Modified file not returned from sandbox',
185
+ updatedFields: [],
186
+ };
187
+ }
188
+ resultBuffer = Buffer.from(artifact.data, 'base64');
189
+ updatedFields = edits.map((e) => e.fieldId);
190
+ break;
191
+ }
192
+ case 'txt': {
193
+ // Direct text editing — apply replacements
194
+ let text = buffer.toString('utf-8');
195
+ for (let i = 0; i < edits.length; i++) {
196
+ const edit = edits[i];
197
+ const resolvedEdit = resolvedEdits[i];
198
+ if (edit.type === 'fill' || edit.type === 'replace') {
199
+ // Replace "Label: ___" with "Label: value"
200
+ const pattern = new RegExp(`(${escapeRegExp(resolvedEdit.fieldId)}:\\s*)[_\\s]*`, 'i');
201
+ text = text.replace(pattern, `$1${edit.value}`);
202
+ updatedFields.push(edit.fieldId);
203
+ }
204
+ }
205
+ resultBuffer = Buffer.from(text, 'utf-8');
206
+ break;
207
+ }
208
+ default:
209
+ return {
210
+ success: false,
211
+ parsed: session.parsed,
212
+ fileBase64: session.currentFileBase64,
213
+ error: `Editing not supported for type: ${session.type}`,
214
+ updatedFields: [],
215
+ };
216
+ }
217
+ // Re-parse the modified document
218
+ const newParsed = await parseDocument(resultBuffer, session.fileName, session.mimeType);
219
+ newParsed.sessionId = sessionId;
220
+ // Update session
221
+ const newBase64 = resultBuffer.toString('base64');
222
+ session.currentFileBase64 = newBase64;
223
+ session.parsed = newParsed;
224
+ session.edits.push(...edits);
225
+ session.updatedAt = new Date().toISOString();
226
+ return {
227
+ success: true,
228
+ parsed: newParsed,
229
+ fileBase64: newBase64,
230
+ updatedFields,
231
+ };
232
+ }
233
+ catch (err) {
234
+ console.error('[doc-editor] applyFieldEdits error:', err);
235
+ return {
236
+ success: false,
237
+ parsed: session.parsed,
238
+ fileBase64: session.currentFileBase64,
239
+ error: err.message ?? 'Edit failed',
240
+ updatedFields: [],
241
+ };
242
+ }
243
+ }
244
+ /* ------------------------------------------------------------------ */
245
+ /* Apply content edits (text-level) */
246
+ /* ------------------------------------------------------------------ */
247
+ export async function applyContentEdits(sessionId, edits) {
248
+ const session = sessions.get(sessionId);
249
+ if (!session) {
250
+ return {
251
+ success: false,
252
+ parsed: null,
253
+ fileBase64: '',
254
+ error: 'Session not found',
255
+ updatedFields: [],
256
+ };
257
+ }
258
+ const buffer = Buffer.from(session.currentFileBase64, 'base64');
259
+ try {
260
+ let resultBuffer;
261
+ switch (session.type) {
262
+ case 'docx': {
263
+ const script = generateEditDOCXScript(edits);
264
+ const result = await runSandboxScript(script, buffer, 'document.docx');
265
+ if (result.exitCode !== 0) {
266
+ return {
267
+ success: false,
268
+ parsed: session.parsed,
269
+ fileBase64: session.currentFileBase64,
270
+ error: result.stderr || 'DOCX edit failed',
271
+ updatedFields: [],
272
+ };
273
+ }
274
+ const artifact = result.artifacts.find((a) => a.name === 'document.docx');
275
+ resultBuffer = artifact ? Buffer.from(artifact.data, 'base64') : buffer;
276
+ break;
277
+ }
278
+ case 'pptx': {
279
+ const script = generateEditPPTXScript(edits);
280
+ const result = await runSandboxScript(script, buffer, 'document.pptx');
281
+ if (result.exitCode !== 0) {
282
+ return {
283
+ success: false,
284
+ parsed: session.parsed,
285
+ fileBase64: session.currentFileBase64,
286
+ error: result.stderr || 'PPTX edit failed',
287
+ updatedFields: [],
288
+ };
289
+ }
290
+ const artifact = result.artifacts.find((a) => a.name === 'document.pptx');
291
+ resultBuffer = artifact ? Buffer.from(artifact.data, 'base64') : buffer;
292
+ break;
293
+ }
294
+ case 'txt': {
295
+ let text = buffer.toString('utf-8');
296
+ for (const edit of edits) {
297
+ if (edit.type === 'replace_text' && edit.search) {
298
+ text = text.replace(edit.search, edit.text);
299
+ }
300
+ else if (edit.type === 'insert_text') {
301
+ const lines = text.split('\n');
302
+ const pos = edit.position ?? lines.length;
303
+ lines.splice(pos, 0, edit.text);
304
+ text = lines.join('\n');
305
+ }
306
+ }
307
+ resultBuffer = Buffer.from(text, 'utf-8');
308
+ break;
309
+ }
310
+ default:
311
+ return {
312
+ success: false,
313
+ parsed: session.parsed,
314
+ fileBase64: session.currentFileBase64,
315
+ error: `Content editing not supported for type: ${session.type}`,
316
+ updatedFields: [],
317
+ };
318
+ }
319
+ const newParsed = await parseDocument(resultBuffer, session.fileName, session.mimeType);
320
+ newParsed.sessionId = sessionId;
321
+ const newBase64 = resultBuffer.toString('base64');
322
+ session.currentFileBase64 = newBase64;
323
+ session.parsed = newParsed;
324
+ session.updatedAt = new Date().toISOString();
325
+ return {
326
+ success: true,
327
+ parsed: newParsed,
328
+ fileBase64: newBase64,
329
+ updatedFields: [],
330
+ };
331
+ }
332
+ catch (err) {
333
+ return {
334
+ success: false,
335
+ parsed: session.parsed,
336
+ fileBase64: session.currentFileBase64,
337
+ error: err.message ?? 'Content edit failed',
338
+ updatedFields: [],
339
+ };
340
+ }
341
+ }
342
+ /* ------------------------------------------------------------------ */
343
+ /* Export the current document */
344
+ /* ------------------------------------------------------------------ */
345
+ export function exportDocument(sessionId) {
346
+ const session = sessions.get(sessionId);
347
+ if (!session)
348
+ return null;
349
+ return {
350
+ buffer: Buffer.from(session.currentFileBase64, 'base64'),
351
+ fileName: session.fileName,
352
+ mimeType: session.mimeType,
353
+ };
354
+ }
355
+ /* ------------------------------------------------------------------ */
356
+ /* Native PDF form filling using pdf-lib (no sandbox needed) */
357
+ /* ------------------------------------------------------------------ */
358
+ async function fillPDFNatively(buffer, edits) {
359
+ const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true });
360
+ const form = pdfDoc.getForm();
361
+ const allFields = form.getFields();
362
+ const matchedFields = [];
363
+ const allFieldNames = allFields.map((f) => f.getName());
364
+ console.log(`[doc-editor] fillPDFNatively: ${allFields.length} PDF fields: [${allFieldNames.join(', ')}]`);
365
+ console.log(`[doc-editor] fillPDFNatively: ${edits.length} edits to apply`);
366
+ for (const edit of edits) {
367
+ let matched = false;
368
+ // Find the matching field by name
369
+ for (const field of allFields) {
370
+ const fieldName = field.getName();
371
+ const editId = edit.fieldId;
372
+ // Match by exact name, case-insensitive name, or containment
373
+ const isMatch = fieldName === editId ||
374
+ fieldName.toLowerCase() === editId.toLowerCase() ||
375
+ editId.toLowerCase().endsWith(fieldName.toLowerCase()) ||
376
+ fieldName.toLowerCase().includes(editId.toLowerCase()) ||
377
+ editId.toLowerCase().includes(fieldName.toLowerCase());
378
+ if (isMatch) {
379
+ if (edit.type === 'clear') {
380
+ if (field instanceof PDFTextField) {
381
+ field.setText('');
382
+ }
383
+ matched = true;
384
+ matchedFields.push(fieldName);
385
+ console.log(`[doc-editor] Cleared field "${fieldName}"`);
386
+ break;
387
+ }
388
+ if (field instanceof PDFTextField) {
389
+ field.setText(edit.value);
390
+ matched = true;
391
+ matchedFields.push(fieldName);
392
+ console.log(`[doc-editor] Filled text field "${fieldName}" with "${edit.value.substring(0, 50)}${edit.value.length > 50 ? '...' : ''}"`);
393
+ }
394
+ else if (field instanceof PDFCheckBox) {
395
+ if (edit.value === 'true' || edit.value === 'yes' || edit.value === '1') {
396
+ field.check();
397
+ }
398
+ else {
399
+ field.uncheck();
400
+ }
401
+ matched = true;
402
+ matchedFields.push(fieldName);
403
+ }
404
+ else if (field instanceof PDFDropdown) {
405
+ try {
406
+ field.select(edit.value);
407
+ matched = true;
408
+ matchedFields.push(fieldName);
409
+ }
410
+ catch {
411
+ // Value might not be in options
412
+ }
413
+ }
414
+ else if (field instanceof PDFRadioGroup) {
415
+ try {
416
+ field.select(edit.value);
417
+ matched = true;
418
+ matchedFields.push(fieldName);
419
+ }
420
+ catch {
421
+ // Value might not be in options
422
+ }
423
+ }
424
+ break;
425
+ }
426
+ }
427
+ if (!matched) {
428
+ console.warn(`[doc-editor] No matching PDF field found for edit "${edit.fieldId}"`);
429
+ }
430
+ }
431
+ console.log(`[doc-editor] fillPDFNatively: matched ${matchedFields.length}/${edits.length} fields`);
432
+ // Very Important: update field appearances so the changes are visible in all PDF viewers!
433
+ try {
434
+ const defaultFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
435
+ form.updateFieldAppearances(defaultFont);
436
+ }
437
+ catch (e) {
438
+ console.error('Failed to update field appearances, ignoring', e);
439
+ }
440
+ const modifiedBytes = await pdfDoc.save();
441
+ return { buffer: Buffer.from(modifiedBytes), matchedFields };
442
+ }
443
+ /* ------------------------------------------------------------------ */
444
+ /* Apply signature using pdf-lib (no sandbox needed) */
445
+ /* ------------------------------------------------------------------ */
446
+ export async function applySignature(sessionId, data) {
447
+ const session = sessions.get(sessionId);
448
+ if (!session) {
449
+ return { success: false, error: 'Session not found' };
450
+ }
451
+ try {
452
+ // ALWAYS re-sign from the original (unsigned) document to replace, not stack.
453
+ // Only use base64Override if explicitly provided from the frontend for undo flows.
454
+ const sourceBase64 = data.base64Override || session.originalFileBase64;
455
+ const buffer = Buffer.from(sourceBase64, 'base64');
456
+ if (session.mimeType === 'application/pdf') {
457
+ const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true });
458
+ const pages = pdfDoc.getPages();
459
+ const pageIndex = Math.min(Math.max(0, data.pageIndex), pages.length - 1);
460
+ const page = pages[pageIndex];
461
+ const { width, height } = page.getSize();
462
+ // --- Smart Placement: try to find the detected context text position ---
463
+ let resolvedX = null;
464
+ let resolvedY = null;
465
+ if (data.detectedContext) {
466
+ try {
467
+ // Use sandbox PyPDF to find text coordinates on this page
468
+ const coords = await findTextPositionOnPage(buffer, pageIndex, data.detectedContext);
469
+ if (coords) {
470
+ // Place signature right below the detected text line
471
+ resolvedX = coords.x;
472
+ // coords.y is the bottom of the text in PDF coordinate system
473
+ // Place signature just below (subtract a small offset for spacing)
474
+ resolvedY = coords.y - 10;
475
+ }
476
+ }
477
+ catch {
478
+ // Fallback to UI-provided coordinates
479
+ }
480
+ }
481
+ // Fall back to user-specified percentages
482
+ if (resolvedX === null || resolvedY === null) {
483
+ const xPct = data.x ?? 50;
484
+ const yPct = data.y ?? 50;
485
+ resolvedX = (xPct / 100) * width;
486
+ // Invert Y: UI treats y=0 as top, PDF treats y=0 as bottom
487
+ resolvedY = height - (yPct / 100) * height;
488
+ }
489
+ const x = resolvedX;
490
+ const y = resolvedY;
491
+ if (data.mode === 'type' && data.text) {
492
+ const font = await pdfDoc.embedFont(StandardFonts.HelveticaOblique);
493
+ let color = rgb(0, 0, 0);
494
+ if (data.color === 'blue')
495
+ color = rgb(0, 0, 1);
496
+ if (data.color === 'red')
497
+ color = rgb(1, 0, 0);
498
+ const size = 24 * (data.scale ?? 1);
499
+ page.drawText(data.text, {
500
+ x,
501
+ y: y - size, // Adjust for PDF drawing from baseline instead of top-left
502
+ size,
503
+ font,
504
+ color,
505
+ });
506
+ }
507
+ else if ((data.mode === 'draw' || data.mode === 'upload') && data.imagePayload) {
508
+ const isPng = data.imagePayload.startsWith('data:image/png');
509
+ const isJpeg = data.imagePayload.startsWith('data:image/jpeg');
510
+ // Strip data URL prefix
511
+ const base64Data = data.imagePayload.replace(/^data:image\/\w+;base64,/, '');
512
+ const imageBytes = Buffer.from(base64Data, 'base64');
513
+ let embeddedImage;
514
+ if (isJpeg) {
515
+ embeddedImage = await pdfDoc.embedJpg(imageBytes);
516
+ }
517
+ else {
518
+ embeddedImage = await pdfDoc.embedPng(imageBytes); // default PNG
519
+ }
520
+ const intrinsicHeight = embeddedImage.height;
521
+ let drawHeight = intrinsicHeight;
522
+ let drawWidth = embeddedImage.width;
523
+ // Match frontend's max-h-[60px] behavior
524
+ if (intrinsicHeight > 60) {
525
+ const ratio = 60 / intrinsicHeight;
526
+ drawHeight = 60;
527
+ drawWidth = drawWidth * ratio;
528
+ }
529
+ // Apply user scale
530
+ drawHeight *= data.scale ?? 1;
531
+ drawWidth *= data.scale ?? 1;
532
+ page.drawImage(embeddedImage, {
533
+ x,
534
+ y: y - drawHeight, // Top-left alignment based on bottom-left drawing
535
+ width: drawWidth,
536
+ height: drawHeight,
537
+ });
538
+ }
539
+ if (data.date) {
540
+ const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
541
+ page.drawText(`Date: ${data.date}`, {
542
+ x,
543
+ y: y - 20,
544
+ size: 12,
545
+ font,
546
+ color: rgb(0, 0, 0),
547
+ });
548
+ }
549
+ if (data.extraText) {
550
+ const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
551
+ page.drawText(data.extraText, {
552
+ x,
553
+ y: y - 40,
554
+ size: 12,
555
+ font,
556
+ color: rgb(0, 0, 0),
557
+ });
558
+ }
559
+ const modifiedBytes = await pdfDoc.save();
560
+ const newBase64 = Buffer.from(modifiedBytes).toString('base64');
561
+ // Re-parse the document so the canvas preview updates properly
562
+ const newBuffer = Buffer.from(modifiedBytes);
563
+ const newParsed = await parseDocument(newBuffer, session.fileName, session.mimeType);
564
+ newParsed.sessionId = sessionId;
565
+ session.currentFileBase64 = newBase64;
566
+ session.parsed = newParsed;
567
+ session.updatedAt = new Date().toISOString();
568
+ sessions.set(sessionId, session);
569
+ return {
570
+ success: true,
571
+ parsed: newParsed,
572
+ fileBase64: newBase64,
573
+ updatedFields: [],
574
+ };
575
+ }
576
+ else {
577
+ return { success: false, error: 'Signatures only supported for PDF natively' };
578
+ }
579
+ }
580
+ catch (err) {
581
+ return { success: false, error: err.message || 'Failed to apply signature' };
582
+ }
583
+ }
584
+ /* ------------------------------------------------------------------ */
585
+ /* Find text position on a PDF page using sandbox PyPDF */
586
+ /* ------------------------------------------------------------------ */
587
+ async function findTextPositionOnPage(pdfBuffer, pageIndex, searchText) {
588
+ try {
589
+ const code = `
590
+ import json, sys
591
+ try:
592
+ import pypdf
593
+ reader = pypdf.PdfReader("document.pdf")
594
+ page = reader.pages[${pageIndex}]
595
+
596
+ search = ${JSON.stringify(searchText)}.lower()
597
+
598
+ # Extract text with visitor to get positions
599
+ positions = []
600
+ def visitor(text, cm, tm, font_dict, font_size):
601
+ if text and text.strip():
602
+ x = tm[4]
603
+ y = tm[5]
604
+ positions.append({"text": text.strip(), "x": float(x), "y": float(y)})
605
+
606
+ page.extract_text(visitor_text=visitor)
607
+
608
+ # Find the best match
609
+ best = None
610
+ best_score = 0
611
+ for pos in positions:
612
+ t = pos["text"].lower()
613
+ # Check if search text is contained in this text segment or vice versa
614
+ if search in t or t in search:
615
+ score = len(t)
616
+ if score > best_score:
617
+ best_score = score
618
+ best = pos
619
+ # Also check word overlap
620
+ search_words = set(search.split())
621
+ text_words = set(t.split())
622
+ overlap = len(search_words & text_words)
623
+ if overlap > best_score:
624
+ best_score = overlap
625
+ best = pos
626
+
627
+ if best:
628
+ print("__RESULT__:" + json.dumps({"x": best["x"], "y": best["y"]}))
629
+ else:
630
+ print("__RESULT__:null")
631
+ except Exception as e:
632
+ print("__RESULT__:null")
633
+ print(f"Error: {e}", file=sys.stderr)
634
+ `;
635
+ const sandboxManager = new SandboxManager({ backend: 'docker' });
636
+ const result = await sandboxManager.execute({
637
+ code,
638
+ language: 'python',
639
+ files: [
640
+ {
641
+ name: 'document.pdf',
642
+ content: pdfBuffer.toString('base64'),
643
+ isBase64: true,
644
+ },
645
+ ],
646
+ timeout: 15_000,
647
+ });
648
+ if (result.exitCode === 0) {
649
+ const match = result.stdout.match(/__RESULT__:(.+)/);
650
+ if (match) {
651
+ const parsed = JSON.parse(match[1]);
652
+ if (parsed && typeof parsed.x === 'number' && typeof parsed.y === 'number') {
653
+ return { x: parsed.x, y: parsed.y };
654
+ }
655
+ }
656
+ }
657
+ }
658
+ catch {
659
+ // Sandbox not available — fall back to UI coordinates
660
+ }
661
+ return null;
662
+ }
663
+ /* ------------------------------------------------------------------ */
664
+ /* Sandbox execution helper */
665
+ /* ------------------------------------------------------------------ */
666
+ async function runSandboxScript(code, fileBuffer, inputFileName) {
667
+ const sandboxManager = new SandboxManager({ backend: 'docker' });
668
+ const result = await sandboxManager.execute({
669
+ code,
670
+ language: 'python',
671
+ files: [
672
+ {
673
+ name: inputFileName,
674
+ content: fileBuffer.toString('base64'),
675
+ isBase64: true,
676
+ },
677
+ ],
678
+ timeout: 30_000,
679
+ });
680
+ return {
681
+ stdout: result.stdout,
682
+ stderr: result.stderr,
683
+ exitCode: result.exitCode,
684
+ artifacts: result.artifacts.map((a) => ({
685
+ name: a.name,
686
+ mimeType: a.mimeType,
687
+ data: a.data,
688
+ })),
689
+ };
690
+ }
691
+ /* ------------------------------------------------------------------ */
692
+ /* Helpers */
693
+ /* ------------------------------------------------------------------ */
694
+ function escapeRegExp(string) {
695
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
696
+ }