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