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