@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,380 @@
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
+
13
+ import type { FieldEdit, ContentEdit } from './types.js';
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* PDF — Fill form fields using pypdf */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ export function generateFillPDFScript(edits: FieldEdit[]): string {
20
+ const editsJson = JSON.stringify(edits);
21
+
22
+ return `
23
+ import json
24
+ import sys
25
+ from pypdf import PdfReader, PdfWriter
26
+
27
+ # Load the PDF
28
+ reader = PdfReader("/sandbox/input/document.pdf")
29
+ writer = PdfWriter()
30
+
31
+ # Copy all pages
32
+ for page in reader.pages:
33
+ writer.add_page(page)
34
+
35
+ # Copy existing form fields
36
+ if reader.is_form:
37
+ writer.clone_reader_document_root(reader)
38
+
39
+ # Parse edits
40
+ edits = json.loads('''${editsJson}''')
41
+
42
+ # Build field value map (field_name -> value)
43
+ field_map = {}
44
+ for edit in edits:
45
+ field_map[edit["fieldId"]] = edit["value"]
46
+
47
+ # Apply form field fills
48
+ if reader.is_form:
49
+ fields = reader.get_fields()
50
+ update_dict = {}
51
+ for field_name, field_obj in (fields or {}).items():
52
+ for edit_id, value in field_map.items():
53
+ # Match by name or by detected ID pattern
54
+ if field_name == edit_id or edit_id.endswith(field_name):
55
+ update_dict[field_name] = value
56
+ break
57
+
58
+ if update_dict:
59
+ writer.update_page_form_field_values(writer.pages[0], update_dict)
60
+
61
+ # Save
62
+ with open("/sandbox/output/document.pdf", "wb") as f:
63
+ writer.write(f)
64
+
65
+ print(f"SUCCESS: Updated {len(field_map)} fields")
66
+ `;
67
+ }
68
+
69
+ /* ------------------------------------------------------------------ */
70
+ /* PDF — Extract full text using pypdf */
71
+ /* ------------------------------------------------------------------ */
72
+
73
+ export function generateExtractPDFTextScript(): string {
74
+ return `
75
+ import json
76
+ from pypdf import PdfReader
77
+
78
+ reader = PdfReader("/sandbox/input/document.pdf")
79
+ pages = []
80
+ fields = []
81
+
82
+ for i, page in enumerate(reader.pages):
83
+ text = page.extract_text() or ""
84
+ pages.append({
85
+ "index": i,
86
+ "text": text,
87
+ })
88
+
89
+ # Smart signature detection
90
+ lines = text.split('\\n')
91
+ for line in lines:
92
+ lower_line = line.lower()
93
+ if "signature:" in lower_line or "sign here" in lower_line or "date:" in lower_line or "_______" in lower_line:
94
+ if "signature" in lower_line or "sign" in lower_line:
95
+ fields.append({
96
+ "name": "detected_signature_" + str(i),
97
+ "type": "signature_placeholder",
98
+ "value": "",
99
+ "pageIndex": i,
100
+ "context": line.strip()
101
+ })
102
+
103
+ # Extract form fields
104
+ if reader.is_form:
105
+ form_fields = reader.get_fields() or {}
106
+ for name, field in form_fields.items():
107
+ field_type = str(field.get("/FT", ""))
108
+ value = str(field.get("/V", ""))
109
+ fields.append({
110
+ "name": name,
111
+ "type": field_type,
112
+ "value": value,
113
+ "pageIndex": 0 # We might not know the exact page from get_fields easily, default to 0
114
+ })
115
+
116
+ result = {
117
+ "pages": pages,
118
+ "fields": fields,
119
+ "totalPages": len(reader.pages),
120
+ "metadata": dict(reader.metadata or {}),
121
+ }
122
+
123
+ print("__RESULT__:" + json.dumps(result, default=str))
124
+ `;
125
+ }
126
+
127
+ /* ------------------------------------------------------------------ */
128
+ /* DOCX — Edit content using python-docx */
129
+ /* ------------------------------------------------------------------ */
130
+
131
+ export function generateEditDOCXScript(edits: ContentEdit[]): string {
132
+ const editsJson = JSON.stringify(edits);
133
+
134
+ return `
135
+ import json
136
+ from docx import Document
137
+
138
+ doc = Document("/sandbox/input/document.docx")
139
+ edits = json.loads('''${editsJson}''')
140
+
141
+ updated = 0
142
+ for edit in edits:
143
+ edit_type = edit.get("type", "replace_text")
144
+
145
+ if edit_type == "replace_text":
146
+ search = edit.get("search", "")
147
+ replacement = edit.get("text", "")
148
+
149
+ for para in doc.paragraphs:
150
+ if search in para.text:
151
+ # Preserve formatting by replacing in runs
152
+ full_text = ""
153
+ for run in para.runs:
154
+ full_text += run.text
155
+
156
+ if search in full_text:
157
+ # Simple replacement — replace in runs
158
+ remaining = search
159
+ for run in para.runs:
160
+ if remaining and remaining in run.text:
161
+ run.text = run.text.replace(remaining, replacement, 1)
162
+ remaining = ""
163
+ updated += 1
164
+ break
165
+ elif remaining and run.text and remaining.startswith(run.text):
166
+ remaining = remaining[len(run.text):]
167
+ run.text = ""
168
+
169
+ # Also check tables
170
+ for table in doc.tables:
171
+ for row in table.rows:
172
+ for cell in row.cells:
173
+ for para in cell.paragraphs:
174
+ if search in para.text:
175
+ for run in para.runs:
176
+ if search in run.text:
177
+ run.text = run.text.replace(search, replacement, 1)
178
+ updated += 1
179
+
180
+ elif edit_type == "insert_text":
181
+ text = edit.get("text", "")
182
+ position = edit.get("position", len(doc.paragraphs))
183
+ if position < len(doc.paragraphs):
184
+ para = doc.paragraphs[position]
185
+ para.insert_paragraph_before(text)
186
+ else:
187
+ doc.add_paragraph(text)
188
+ updated += 1
189
+
190
+ doc.save("/sandbox/output/document.docx")
191
+ print(f"SUCCESS: Applied {updated} edits")
192
+ `;
193
+ }
194
+
195
+ /* ------------------------------------------------------------------ */
196
+ /* DOCX — Fill form fields (content controls / placeholder text) */
197
+ /* ------------------------------------------------------------------ */
198
+
199
+ export function generateFillDOCXScript(edits: FieldEdit[]): string {
200
+ const editsJson = JSON.stringify(edits);
201
+
202
+ return `
203
+ import json
204
+ from docx import Document
205
+
206
+ doc = Document("/sandbox/input/document.docx")
207
+ edits = json.loads('''${editsJson}''')
208
+
209
+ # Build lookup
210
+ field_map = {edit["fieldId"]: edit["value"] for edit in edits}
211
+
212
+ updated = 0
213
+
214
+ # Strategy 1: Look for common form patterns like "Label: ___" or "Label: "
215
+ for para in doc.paragraphs:
216
+ for field_id, value in field_map.items():
217
+ # The field_id contains the label name from detection
218
+ label = field_id.replace("detected_", "").strip()
219
+
220
+ # Check if paragraph contains "Label:" pattern
221
+ text = para.text
222
+ for run in para.runs:
223
+ if "___" in run.text or (run.text.strip().endswith(":") and not value):
224
+ # Replace underscores with value
225
+ run.text = run.text.replace("___", value).rstrip("_")
226
+ updated += 1
227
+
228
+ # Strategy 2: Check tables (common in forms)
229
+ for table in doc.tables:
230
+ for row in table.rows:
231
+ cells = row.cells
232
+ for i, cell in enumerate(cells):
233
+ cell_text = cell.text.strip().rstrip(":")
234
+ # If this cell's text matches a field name, fill the next cell
235
+ for field_id, value in field_map.items():
236
+ if cell_text.lower() == field_id.lower() and i + 1 < len(cells):
237
+ next_cell = cells[i + 1]
238
+ for para in next_cell.paragraphs:
239
+ if para.runs:
240
+ para.runs[0].text = value
241
+ else:
242
+ para.text = value
243
+ updated += 1
244
+
245
+ doc.save("/sandbox/output/document.docx")
246
+ print(f"SUCCESS: Filled {updated} fields")
247
+ `;
248
+ }
249
+
250
+ /* ------------------------------------------------------------------ */
251
+ /* PPTX — Extract text from slides */
252
+ /* ------------------------------------------------------------------ */
253
+
254
+ export function generateExtractPPTXScript(): string {
255
+ return `
256
+ import json
257
+ from pptx import Presentation
258
+ from pptx.util import Inches, Pt
259
+
260
+ prs = Presentation("/sandbox/input/document.pptx")
261
+ slides = []
262
+
263
+ for i, slide in enumerate(prs.slides):
264
+ texts = []
265
+ fields = []
266
+
267
+ for shape in slide.shapes:
268
+ if shape.has_text_frame:
269
+ for para in shape.text_frame.paragraphs:
270
+ text = para.text.strip()
271
+ if text:
272
+ texts.append(text)
273
+
274
+ # Detect form-like patterns
275
+ if ":" in text and text.endswith(":"):
276
+ fields.append({
277
+ "name": text.rstrip(":").strip(),
278
+ "type": "text",
279
+ "value": "",
280
+ "slideIndex": i,
281
+ })
282
+ elif "___" in text:
283
+ label = text.split("___")[0].strip().rstrip(":")
284
+ if label:
285
+ fields.append({
286
+ "name": label,
287
+ "type": "text",
288
+ "value": "",
289
+ "slideIndex": i,
290
+ })
291
+
292
+ slides.append({
293
+ "index": i,
294
+ "text": "\\n".join(texts),
295
+ "fields": fields,
296
+ })
297
+
298
+ result = {
299
+ "slides": slides,
300
+ "totalSlides": len(prs.slides),
301
+ }
302
+
303
+ print("__RESULT__:" + json.dumps(result, default=str))
304
+ `;
305
+ }
306
+
307
+ /* ------------------------------------------------------------------ */
308
+ /* PPTX — Edit slide text */
309
+ /* ------------------------------------------------------------------ */
310
+
311
+ export function generateEditPPTXScript(edits: ContentEdit[]): string {
312
+ const editsJson = JSON.stringify(edits);
313
+
314
+ return `
315
+ import json
316
+ from pptx import Presentation
317
+
318
+ prs = Presentation("/sandbox/input/document.pptx")
319
+ edits = json.loads('''${editsJson}''')
320
+
321
+ updated = 0
322
+ for edit in edits:
323
+ slide_index = edit.get("pageIndex", 0)
324
+ if slide_index >= len(prs.slides):
325
+ continue
326
+
327
+ slide = prs.slides[slide_index]
328
+ edit_type = edit.get("type", "replace_text")
329
+
330
+ if edit_type == "replace_text":
331
+ search = edit.get("search", "")
332
+ replacement = edit.get("text", "")
333
+
334
+ for shape in slide.shapes:
335
+ if shape.has_text_frame:
336
+ for para in shape.text_frame.paragraphs:
337
+ for run in para.runs:
338
+ if search in run.text:
339
+ run.text = run.text.replace(search, replacement)
340
+ updated += 1
341
+
342
+ prs.save("/sandbox/output/document.pptx")
343
+ print(f"SUCCESS: Applied {updated} edits")
344
+ `;
345
+ }
346
+
347
+ /* ------------------------------------------------------------------ */
348
+ /* PPTX — Fill form fields in slides */
349
+ /* ------------------------------------------------------------------ */
350
+
351
+ export function generateFillPPTXScript(edits: FieldEdit[]): string {
352
+ const editsJson = JSON.stringify(edits);
353
+
354
+ return `
355
+ import json
356
+ from pptx import Presentation
357
+
358
+ prs = Presentation("/sandbox/input/document.pptx")
359
+ edits = json.loads('''${editsJson}''')
360
+
361
+ field_map = {edit["fieldId"]: edit["value"] for edit in edits}
362
+ updated = 0
363
+
364
+ for slide in prs.slides:
365
+ for shape in slide.shapes:
366
+ if shape.has_text_frame:
367
+ for para in shape.text_frame.paragraphs:
368
+ full_text = para.text
369
+ for field_id, value in field_map.items():
370
+ # Replace "___" blanks after field labels
371
+ if "___" in full_text:
372
+ for run in para.runs:
373
+ if "___" in run.text:
374
+ run.text = run.text.replace("___", value, 1)
375
+ updated += 1
376
+
377
+ prs.save("/sandbox/output/document.pptx")
378
+ print(f"SUCCESS: Filled {updated} fields")
379
+ `;
380
+ }
package/src/types.ts ADDED
@@ -0,0 +1,170 @@
1
+ /* ------------------------------------------------------------------ */
2
+ /* Types for the Document Editor / Form Filling tool */
3
+ /* ------------------------------------------------------------------ */
4
+
5
+ /** Supported document types */
6
+ export type DocumentType = 'pdf' | 'docx' | 'pptx' | 'txt' | 'xlsx';
7
+
8
+ /** A single editable field detected in a document */
9
+ export interface DocumentField {
10
+ /** Unique field identifier within the document */
11
+ id: string;
12
+ /** Human-readable field name / label */
13
+ name: string;
14
+ /** Field type */
15
+ type:
16
+ | 'text'
17
+ | 'textarea'
18
+ | 'checkbox'
19
+ | 'radio'
20
+ | 'date'
21
+ | 'number'
22
+ | 'select'
23
+ | 'signature_placeholder';
24
+ /** Current value (empty if unfilled) */
25
+ value: string;
26
+ /** Page/slide index (0-based) */
27
+ pageIndex: number;
28
+ /** Whether the field is required (if detectable) */
29
+ required?: boolean;
30
+ /** Placeholder or tooltip text */
31
+ placeholder?: string;
32
+ /** Options for select/radio fields */
33
+ options?: string[];
34
+ /** Bounding box for overlay positioning (PDF) — percentages of page */
35
+ bbox?: { x: number; y: number; width: number; height: number };
36
+ /** Context around the field (for smart detection) */
37
+ context?: string;
38
+ }
39
+
40
+ /** A single page or slide of a parsed document */
41
+ export interface DocumentPage {
42
+ /** 0-based page/slide index */
43
+ index: number;
44
+ /** Extracted text content */
45
+ text: string;
46
+ /** HTML rendering (for DOCX via mammoth) */
47
+ html?: string;
48
+ /** Fields on this page */
49
+ fields: DocumentField[];
50
+ /** Page dimensions in points (PDF) */
51
+ dimensions?: { width: number; height: number };
52
+ }
53
+
54
+ /** Result of parsing a document */
55
+ export interface ParsedDocument {
56
+ /** Session ID for tracking edits */
57
+ sessionId: string;
58
+ /** Original filename */
59
+ fileName: string;
60
+ /** Detected document type */
61
+ type: DocumentType;
62
+ /** MIME type */
63
+ mimeType: string;
64
+ /** File size in bytes */
65
+ fileSize: number;
66
+ /** Pages/slides */
67
+ pages: DocumentPage[];
68
+ /** All fields across all pages (flat) */
69
+ fields: DocumentField[];
70
+ /** Full raw text extraction */
71
+ rawText: string;
72
+ /** Document metadata (author, title, dates, etc.) */
73
+ metadata: Record<string, string>;
74
+ /** Total page/slide count */
75
+ totalPages: number;
76
+ }
77
+
78
+ /** An edit operation to apply to a document */
79
+ export interface FieldEdit {
80
+ /** Field ID to update */
81
+ fieldId: string;
82
+ /** New value */
83
+ value: string;
84
+ /** Type of edit */
85
+ type: 'fill' | 'replace' | 'append' | 'clear';
86
+ }
87
+
88
+ /** A content-level edit (not field-based) */
89
+ export interface ContentEdit {
90
+ /** Page/slide index */
91
+ pageIndex: number;
92
+ /** Type of content edit */
93
+ type: 'replace_text' | 'insert_text' | 'delete_text';
94
+ /** Search text (for replace) */
95
+ search?: string;
96
+ /** Replacement or insertion text */
97
+ text: string;
98
+ /** Position for insert (paragraph index) */
99
+ position?: number;
100
+ }
101
+
102
+ /** Signature details for applying to a document */
103
+ export interface SignatureData {
104
+ /** The mode of the signature */
105
+ mode: 'type' | 'draw' | 'upload';
106
+ /** Base64 image of the signature (for draw/upload) */
107
+ imagePayload?: string;
108
+ /** Typed text for the signature */
109
+ text?: string;
110
+ /** Font family for typed text */
111
+ font?: string;
112
+ /** Color (hex) */
113
+ color?: string;
114
+ /** Optional date to include */
115
+ date?: string;
116
+ /** Optional extra text */
117
+ extraText?: string;
118
+ /** Page index (0-based) where the signature should be placed */
119
+ pageIndex: number;
120
+ /** Approximate X coordinate (if known) */
121
+ x?: number;
122
+ /** Approximate Y coordinate (if known) */
123
+ y?: number;
124
+ /** Signature scale multiplier */
125
+ scale?: number;
126
+ /** Optional base64 to use instead of current session base64 (for undo/replace) */
127
+ base64Override?: string;
128
+ /** The text context detected near the signature line (e.g. "Datum, Unterschrift des Empfängers") */
129
+ detectedContext?: string;
130
+ /** User-provided note about placement (e.g. "bottom right corner") */
131
+ placementNote?: string;
132
+ }
133
+
134
+ /** Active editing session */
135
+ export interface EditorSession {
136
+ /** Unique session ID */
137
+ id: string;
138
+ /** Original filename */
139
+ fileName: string;
140
+ /** Document type */
141
+ type: DocumentType;
142
+ /** MIME type */
143
+ mimeType: string;
144
+ /** Original file as base64 */
145
+ originalFileBase64: string;
146
+ /** Current (modified) file as base64 */
147
+ currentFileBase64: string;
148
+ /** Parsed document state */
149
+ parsed: ParsedDocument;
150
+ /** Applied edits history */
151
+ edits: FieldEdit[];
152
+ /** Created timestamp */
153
+ createdAt: string;
154
+ /** Last modified timestamp */
155
+ updatedAt: string;
156
+ }
157
+
158
+ /** Result of applying edits */
159
+ export interface EditResult {
160
+ /** Whether the edit succeeded */
161
+ success: boolean;
162
+ /** Updated parsed document */
163
+ parsed: ParsedDocument;
164
+ /** Updated file as base64 */
165
+ fileBase64: string;
166
+ /** Error message if failed */
167
+ error?: string;
168
+ /** Fields that were updated */
169
+ updatedFields: string[];
170
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://hub.larkup.de/schemas/tool-manifest.v1.json",
3
+ "id": "doc-editor",
4
+ "name": "Document Editor",
5
+ "description": "AI-powered form filling and document editing with Canvas-style live preview.",
6
+ "longDescription": "Open PDF, Word, PowerPoint, and text files in a split-view Canvas. AI fills forms using your knowledge base, edits content on request, and previews changes live. Supports PDF form fields (AcroForm), DOCX paragraphs/tables, PPTX slide text, and plain text editing. Uses pdf-lib (native) for PDF and the Docker sandbox for DOCX/PPTX via python-docx/python-pptx.",
7
+ "category": "utility",
8
+ "version": "0.1.0",
9
+ "pricing": "free",
10
+ "emoji": "📝",
11
+ "icon": "FileEdit",
12
+ "packageName": "@larkup/tool-doc-editor",
13
+ "installSize": "~5 MB",
14
+ "systemDeps": ["docker"],
15
+ "author": "Larkup",
16
+ "capabilities": ["document-editing", "form-filling", "document-preview"],
17
+ "tags": ["pdf", "docx", "pptx", "form", "canvas", "editor", "fill"],
18
+ "downloads": 0,
19
+ "repositoryUrl": "https://github.com/Larkup-AI/larkup-rag",
20
+ "license": "Apache-2.0",
21
+ "updatedAt": "2026-07-20"
22
+ }