@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/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @larkup/tool-doc-editor
3
+ *
4
+ * Document Editor / Form Filling tool for the Larkup platform.
5
+ * Enables AI-powered form filling and document editing in a Canvas-style UI.
6
+ *
7
+ * Supports: PDF, DOCX, PPTX, TXT
8
+ *
9
+ * Uses:
10
+ * - pdf-lib (MIT) for native PDF form field operations
11
+ * - mammoth (MIT) for DOCX reading
12
+ * - @larkup/sandbox for heavy editing (python-docx, python-pptx, pypdf)
13
+ */
14
+
15
+ export const TOOL_META = {
16
+ id: 'doc-editor',
17
+ name: 'Document Editor',
18
+ version: '0.1.0',
19
+ } as const;
20
+
21
+ // Public API
22
+ export {
23
+ createSession,
24
+ getSession,
25
+ getAllSessions,
26
+ deleteSession,
27
+ restoreSession,
28
+ applyFieldEdits,
29
+ applyContentEdits,
30
+ applySignature,
31
+ exportDocument,
32
+ } from './editor.js';
33
+
34
+ export {
35
+ parseDocument,
36
+ parsePDF,
37
+ parseDOCX,
38
+ parsePPTX,
39
+ parseTXT,
40
+ detectDocumentType,
41
+ enrichPDFWithText,
42
+ } from './parsers.js';
43
+
44
+ export type {
45
+ DocumentType,
46
+ DocumentField,
47
+ DocumentPage,
48
+ ParsedDocument,
49
+ FieldEdit,
50
+ ContentEdit,
51
+ EditorSession,
52
+ EditResult,
53
+ } from './types.js';
package/src/parsers.ts ADDED
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Document parsers for PDF, DOCX, PPTX, and TXT files.
3
+ */
4
+
5
+ import { PDFDocument, PDFTextField, PDFCheckBox, PDFDropdown, PDFRadioGroup } from 'pdf-lib';
6
+ import mammoth from 'mammoth';
7
+ import { randomUUID } from 'node:crypto';
8
+ import type { DocumentType, DocumentField, DocumentPage, ParsedDocument } from './types.js';
9
+
10
+ /* ------------------------------------------------------------------ */
11
+ /* Detect document type from MIME / extension */
12
+ /* ------------------------------------------------------------------ */
13
+
14
+ const MIME_MAP: Record<string, DocumentType> = {
15
+ 'application/pdf': 'pdf',
16
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
17
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
18
+ 'text/plain': 'txt',
19
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
20
+ };
21
+
22
+ const EXT_MAP: Record<string, DocumentType> = {
23
+ '.pdf': 'pdf',
24
+ '.docx': 'docx',
25
+ '.pptx': 'pptx',
26
+ '.txt': 'txt',
27
+ '.xlsx': 'xlsx',
28
+ };
29
+
30
+ export function detectDocumentType(fileName: string, mimeType?: string): DocumentType | null {
31
+ if (mimeType && MIME_MAP[mimeType]) return MIME_MAP[mimeType];
32
+ const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
33
+ return EXT_MAP[ext] ?? null;
34
+ }
35
+
36
+ /* ------------------------------------------------------------------ */
37
+ /* PDF Parser — using pdf-lib (MIT) */
38
+ /* ------------------------------------------------------------------ */
39
+
40
+ export async function parsePDF(buffer: Buffer, fileName: string): Promise<ParsedDocument> {
41
+ const sessionId = randomUUID();
42
+ const pdfDoc = await PDFDocument.load(buffer, { ignoreEncryption: true });
43
+
44
+ const pageCount = pdfDoc.getPageCount();
45
+ const fields: DocumentField[] = [];
46
+ const pages: DocumentPage[] = [];
47
+
48
+ // Extract form fields (AcroForm)
49
+ const form = pdfDoc.getForm();
50
+ const formFields = form.getFields();
51
+
52
+ for (const field of formFields) {
53
+ const name = field.getName();
54
+ const widgets = field.acroField.getWidgets();
55
+ let pageIndex = 0;
56
+ let bbox: DocumentField['bbox'] | undefined;
57
+
58
+ // Try to determine which page the field is on
59
+ if (widgets.length > 0) {
60
+ const widget = widgets[0];
61
+ const rect = widget.getRectangle();
62
+ for (let i = 0; i < pageCount; i++) {
63
+ const page = pdfDoc.getPage(i);
64
+ const annots = page.node.lookupMaybe(
65
+ page.node.get(page.node.normalizedEntries().Annots as any) as any,
66
+ // @ts-expect-error — accessing internal for page detection
67
+ page.node.context.lookupMaybe,
68
+ );
69
+ // Simplified page detection — place on page 0 if uncertain
70
+ pageIndex = 0;
71
+ }
72
+
73
+ if (rect) {
74
+ const page = pdfDoc.getPage(pageIndex);
75
+ const { width: pw, height: ph } = page.getSize();
76
+ bbox = {
77
+ x: (rect.x / pw) * 100,
78
+ y: (1 - (rect.y + rect.height) / ph) * 100,
79
+ width: (rect.width / pw) * 100,
80
+ height: (rect.height / ph) * 100,
81
+ };
82
+ }
83
+ }
84
+
85
+ let fieldType: DocumentField['type'] = 'text';
86
+ let value = '';
87
+ let options: string[] | undefined;
88
+
89
+ if (field instanceof PDFTextField) {
90
+ fieldType = field.isMultiline() ? 'textarea' : 'text';
91
+ value = field.getText() ?? '';
92
+ } else if (field instanceof PDFCheckBox) {
93
+ fieldType = 'checkbox';
94
+ value = field.isChecked() ? 'true' : 'false';
95
+ } else if (field instanceof PDFDropdown) {
96
+ fieldType = 'select';
97
+ const selected = field.getSelected();
98
+ value = selected.length > 0 ? selected[0] : '';
99
+ options = field.getOptions();
100
+ } else if (field instanceof PDFRadioGroup) {
101
+ fieldType = 'radio';
102
+ value = field.getSelected() ?? '';
103
+ options = field.getOptions();
104
+ }
105
+
106
+ fields.push({
107
+ id: `pdf_field_${fields.length}`,
108
+ name,
109
+ type: fieldType,
110
+ value,
111
+ pageIndex,
112
+ bbox,
113
+ options,
114
+ });
115
+ }
116
+
117
+ // Extract text per page (basic extraction from PDF operators)
118
+ // pdf-lib doesn't have built-in text extraction, so we use the raw text
119
+ // For rich text extraction, the sandbox pypdf is used
120
+ for (let i = 0; i < pageCount; i++) {
121
+ const page = pdfDoc.getPage(i);
122
+ const { width, height } = page.getSize();
123
+ const pageFields = fields.filter((f) => f.pageIndex === i);
124
+
125
+ pages.push({
126
+ index: i,
127
+ text: '', // Will be populated by sandbox if needed
128
+ fields: pageFields,
129
+ dimensions: { width, height },
130
+ });
131
+ }
132
+
133
+ // Get metadata
134
+ const metadata: Record<string, string> = {};
135
+ const title = pdfDoc.getTitle();
136
+ const author = pdfDoc.getAuthor();
137
+ const subject = pdfDoc.getSubject();
138
+ const creator = pdfDoc.getCreator();
139
+
140
+ if (title) metadata['title'] = title;
141
+ if (author) metadata['author'] = author;
142
+ if (subject) metadata['subject'] = subject;
143
+ if (creator) metadata['creator'] = creator;
144
+
145
+ return {
146
+ sessionId,
147
+ fileName,
148
+ type: 'pdf',
149
+ mimeType: 'application/pdf',
150
+ fileSize: buffer.length,
151
+ pages,
152
+ fields,
153
+ rawText: '', // Populated separately via pdf-parse or sandbox
154
+ metadata,
155
+ totalPages: pageCount,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Extract raw text from PDF using pdf-parse (already available in web app).
161
+ * This is called separately since pdf-parse is in the web app, not this package.
162
+ */
163
+ export function enrichPDFWithText(parsed: ParsedDocument, extractedText: string): ParsedDocument {
164
+ // Split text by page breaks (pdf-parse uses \n\n\n between pages typically)
165
+ const pageTexts = extractedText.split(/\n{3,}/);
166
+
167
+ const updatedPages = parsed.pages.map((page, i) => ({
168
+ ...page,
169
+ text: pageTexts[i]?.trim() ?? '',
170
+ }));
171
+
172
+ return {
173
+ ...parsed,
174
+ pages: updatedPages,
175
+ rawText: extractedText,
176
+ };
177
+ }
178
+
179
+ /* ------------------------------------------------------------------ */
180
+ /* DOCX Parser — using mammoth (MIT) */
181
+ /* ------------------------------------------------------------------ */
182
+
183
+ export async function parseDOCX(buffer: Buffer, fileName: string): Promise<ParsedDocument> {
184
+ const sessionId = randomUUID();
185
+
186
+ // Extract HTML (for preview rendering)
187
+ const htmlResult = await mammoth.convertToHtml({ buffer });
188
+ const html = htmlResult.value;
189
+
190
+ // Extract raw text
191
+ const textResult = await mammoth.extractRawText({ buffer });
192
+ const rawText = textResult.value;
193
+
194
+ // Detect form-like fields from the text (common patterns)
195
+ const fields = detectFormFieldsFromText(rawText, 0);
196
+
197
+ // DOCX is treated as a single "page" for simplicity
198
+ const pages: DocumentPage[] = [
199
+ {
200
+ index: 0,
201
+ text: rawText,
202
+ html,
203
+ fields,
204
+ },
205
+ ];
206
+
207
+ return {
208
+ sessionId,
209
+ fileName,
210
+ type: 'docx',
211
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
212
+ fileSize: buffer.length,
213
+ pages,
214
+ fields,
215
+ rawText,
216
+ metadata: {},
217
+ totalPages: 1,
218
+ };
219
+ }
220
+
221
+ /* ------------------------------------------------------------------ */
222
+ /* PPTX Parser — uses sandbox python-pptx for real parsing */
223
+ /* Fallback: JSZip to extract basic slide text */
224
+ /* ------------------------------------------------------------------ */
225
+
226
+ export async function parsePPTX(buffer: Buffer, fileName: string): Promise<ParsedDocument> {
227
+ const sessionId = randomUUID();
228
+
229
+ // Basic extraction using JSZip (already in web app deps)
230
+ // For richer parsing, the sandbox python-pptx script is used
231
+ try {
232
+ const JSZip = (await import('jszip')).default;
233
+ const zip = await JSZip.loadAsync(buffer);
234
+
235
+ const pages: DocumentPage[] = [];
236
+ const slideFiles = Object.keys(zip.files)
237
+ .filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f))
238
+ .sort();
239
+
240
+ for (let i = 0; i < slideFiles.length; i++) {
241
+ const slideXml = await zip.files[slideFiles[i]].async('text');
242
+ // Extract text from XML (simplified)
243
+ const text = slideXml
244
+ .replace(/<[^>]+>/g, ' ')
245
+ .replace(/\s+/g, ' ')
246
+ .trim();
247
+
248
+ pages.push({
249
+ index: i,
250
+ text,
251
+ fields: [],
252
+ });
253
+ }
254
+
255
+ const fields = pages.flatMap((p) => detectFormFieldsFromText(p.text, p.index));
256
+
257
+ return {
258
+ sessionId,
259
+ fileName,
260
+ type: 'pptx',
261
+ mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
262
+ fileSize: buffer.length,
263
+ pages,
264
+ fields,
265
+ rawText: pages.map((p) => p.text).join('\n\n'),
266
+ metadata: {},
267
+ totalPages: pages.length,
268
+ };
269
+ } catch {
270
+ return {
271
+ sessionId,
272
+ fileName,
273
+ type: 'pptx',
274
+ mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
275
+ fileSize: buffer.length,
276
+ pages: [],
277
+ fields: [],
278
+ rawText: '',
279
+ metadata: {},
280
+ totalPages: 0,
281
+ };
282
+ }
283
+ }
284
+
285
+ /* ------------------------------------------------------------------ */
286
+ /* TXT Parser — native passthrough */
287
+ /* ------------------------------------------------------------------ */
288
+
289
+ export async function parseTXT(buffer: Buffer, fileName: string): Promise<ParsedDocument> {
290
+ const sessionId = randomUUID();
291
+ const rawText = buffer.toString('utf-8');
292
+
293
+ const fields = detectFormFieldsFromText(rawText, 0);
294
+
295
+ return {
296
+ sessionId,
297
+ fileName,
298
+ type: 'txt',
299
+ mimeType: 'text/plain',
300
+ fileSize: buffer.length,
301
+ pages: [
302
+ {
303
+ index: 0,
304
+ text: rawText,
305
+ fields,
306
+ },
307
+ ],
308
+ fields,
309
+ rawText,
310
+ metadata: {},
311
+ totalPages: 1,
312
+ };
313
+ }
314
+
315
+ /* ------------------------------------------------------------------ */
316
+ /* Unified parser */
317
+ /* ------------------------------------------------------------------ */
318
+
319
+ export async function parseDocument(
320
+ buffer: Buffer,
321
+ fileName: string,
322
+ mimeType?: string,
323
+ ): Promise<ParsedDocument> {
324
+ const type = detectDocumentType(fileName, mimeType);
325
+ if (!type) {
326
+ throw new Error(`Unsupported file type: ${fileName}`);
327
+ }
328
+
329
+ switch (type) {
330
+ case 'pdf':
331
+ return parsePDF(buffer, fileName);
332
+ case 'docx':
333
+ return parseDOCX(buffer, fileName);
334
+ case 'pptx':
335
+ return parsePPTX(buffer, fileName);
336
+ case 'txt':
337
+ return parseTXT(buffer, fileName);
338
+ default:
339
+ // Return a basic parsed document for unsupported types so they can be "viewed" without crashing
340
+ return {
341
+ sessionId: randomUUID(),
342
+ fileName,
343
+ type: (type as any) || 'unknown',
344
+ mimeType: mimeType || 'application/octet-stream',
345
+ fileSize: buffer.length,
346
+ pages: [],
347
+ fields: [],
348
+ rawText: `Preview not available for this file type.\nFile: ${fileName}\nSize: ${(
349
+ buffer.length / 1024
350
+ ).toFixed(1)} KB`,
351
+ metadata: {},
352
+ totalPages: 0,
353
+ };
354
+ }
355
+ }
356
+
357
+ /* ------------------------------------------------------------------ */
358
+ /* Heuristic form field detection from unstructured text */
359
+ /* ------------------------------------------------------------------ */
360
+
361
+ /**
362
+ * Detects form-like patterns in text:
363
+ * - "Name: ___________"
364
+ * - "Date: "
365
+ * - "Email: "
366
+ * - "[ ]" checkboxes
367
+ * - Lines ending with ":" followed by blank/underscores
368
+ */
369
+ function detectFormFieldsFromText(text: string, pageIndex: number): DocumentField[] {
370
+ const fields: DocumentField[] = [];
371
+ const lines = text.split('\n');
372
+
373
+ const labelPatterns = [
374
+ // "Label: ___" or "Label: " (blank after colon)
375
+ /^(.+?):\s*[_\s]{3,}$/,
376
+ // "Label: " at end of line
377
+ /^(.+?):\s*$/,
378
+ // "[ ] Label" — checkbox
379
+ /^\[[\sx]\]\s+(.+)$/i,
380
+ ];
381
+
382
+ for (const line of lines) {
383
+ const trimmed = line.trim();
384
+ if (!trimmed || trimmed.length < 3) continue;
385
+
386
+ // Check for checkbox pattern
387
+ const checkMatch = trimmed.match(/^\[([\sx])\]\s+(.+)$/i);
388
+ if (checkMatch) {
389
+ fields.push({
390
+ id: `detected_${fields.length}`,
391
+ name: checkMatch[2].trim(),
392
+ type: 'checkbox',
393
+ value: checkMatch[1].toLowerCase() === 'x' ? 'true' : 'false',
394
+ pageIndex,
395
+ });
396
+ continue;
397
+ }
398
+
399
+ // Check for label: value patterns
400
+ const labelMatch = trimmed.match(/^(.+?):\s*([_\s]*)$/);
401
+ if (labelMatch && labelMatch[1].length < 60) {
402
+ const label = labelMatch[1].trim();
403
+ // Detect field type from label name
404
+ const lowerLabel = label.toLowerCase();
405
+ let fieldType: DocumentField['type'] = 'text';
406
+
407
+ if (lowerLabel.includes('date') || lowerLabel.includes('dob')) {
408
+ fieldType = 'date';
409
+ } else if (
410
+ lowerLabel.includes('description') ||
411
+ lowerLabel.includes('comments') ||
412
+ lowerLabel.includes('notes') ||
413
+ lowerLabel.includes('address')
414
+ ) {
415
+ fieldType = 'textarea';
416
+ } else if (
417
+ lowerLabel.includes('email') ||
418
+ lowerLabel.includes('phone') ||
419
+ lowerLabel.includes('number') ||
420
+ lowerLabel.includes('amount') ||
421
+ lowerLabel.includes('zip') ||
422
+ lowerLabel.includes('code')
423
+ ) {
424
+ fieldType = lowerLabel.includes('email') ? 'text' : 'text';
425
+ }
426
+
427
+ fields.push({
428
+ id: `detected_${fields.length}`,
429
+ name: label,
430
+ type: fieldType,
431
+ value: '',
432
+ pageIndex,
433
+ placeholder: `Enter ${label.toLowerCase()}`,
434
+ });
435
+ }
436
+ }
437
+
438
+ return fields;
439
+ }