autoform-mcp-server 1.3.0 → 1.5.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/index.js CHANGED
@@ -6,21 +6,25 @@ import { handleDetectFields, detectFieldsSchema } from './tools/detectFields.js'
6
6
  import { handleFillPdf, fillPdfSchema } from './tools/fillPdf.js';
7
7
  import { handleFillAtCoordinates, fillAtCoordinatesSchema, handleGetPdfInfo, getPdfInfoSchema } from './tools/fillAtCoordinates.js';
8
8
  import { handleFillBatchAtCoordinates, fillBatchAtCoordinatesSchema } from './tools/fillBatchAtCoordinates.js';
9
+ import { handleFillBatchAcroForm, fillBatchAcroFormSchema } from './tools/fillBatchAcroForm.js';
10
+ import { handleAnalyzeStaticPdf, analyzeStaticPdfSchema } from './tools/analyzeStaticPdf.js';
9
11
  import { handleSaveCoordinatesAsTemplate, saveCoordinatesAsTemplateSchema } from './tools/saveCoordinatesAsTemplate.js';
10
12
  import { handleGenerateBatch, generateBatchSchema } from './tools/generateBatch.js';
11
13
  import { handleListTemplates, listTemplatesSchema } from './tools/manageTemplates.js';
12
14
  import { handleImportTemplate, importTemplateSchema } from './tools/importTemplate.js';
13
- const server = new Server({ name: 'autoform', version: '1.2.0' }, { capabilities: { tools: {} } });
15
+ const server = new Server({ name: 'autoform', version: '1.5.0' }, { capabilities: { tools: {} } });
14
16
  // Register all tools
15
17
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
16
18
  tools: [
17
19
  // Info & detection
18
20
  getPdfInfoSchema,
19
21
  detectFieldsSchema,
22
+ analyzeStaticPdfSchema,
20
23
  // Single fill
21
24
  fillPdfSchema,
22
25
  fillAtCoordinatesSchema,
23
- // Batch fill
26
+ // Batch fill (preferred for multiple documents)
27
+ fillBatchAcroFormSchema,
24
28
  fillBatchAtCoordinatesSchema,
25
29
  generateBatchSchema,
26
30
  // Template management
@@ -38,12 +42,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
38
42
  return await handleGetPdfInfo(args);
39
43
  case 'autoform_detect_fields':
40
44
  return await handleDetectFields(args);
45
+ case 'autoform_analyze_static_pdf':
46
+ return await handleAnalyzeStaticPdf(args);
41
47
  case 'autoform_fill_pdf':
42
48
  return await handleFillPdf(args);
43
49
  case 'autoform_fill_at_coordinates':
44
50
  return await handleFillAtCoordinates(args);
45
51
  case 'autoform_fill_batch_at_coordinates':
46
52
  return await handleFillBatchAtCoordinates(args);
53
+ case 'autoform_fill_batch_acroform':
54
+ return await handleFillBatchAcroForm(args);
47
55
  case 'autoform_save_coordinates_as_template':
48
56
  return await handleSaveCoordinatesAsTemplate(args);
49
57
  case 'autoform_generate_batch':
@@ -66,4 +74,4 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
66
74
  // Start server
67
75
  const transport = new StdioServerTransport();
68
76
  await server.connect(transport);
69
- console.error('[AutoForm MCP] Server v1.2.0 started — 9 tools registered');
77
+ console.error('[AutoForm MCP] Server v1.5.0 started — 11 tools registered');
@@ -11,6 +11,16 @@ export declare class PdfService {
11
11
  }>;
12
12
  /** Fill AcroForm fields and save */
13
13
  static fillAcroForm(pdfPath: string, values: Record<string, string>, outputPath: string): Promise<number>;
14
+ /**
15
+ * Batch fill AcroForm: generate N PDFs from the same base PDF, one per data row.
16
+ * If merge=true, all outputs are concatenated into a single PDF and temp files are cleaned.
17
+ * Optionally accepts a fieldMap to translate logical data keys → technical AcroForm field names.
18
+ */
19
+ static batchFillAcroForm(pdfPath: string, dataRows: Array<Record<string, string>>, outputDir: string, baseName: string, merge?: boolean, fieldMap?: Record<string, string>): Promise<{
20
+ outputPaths: string[];
21
+ mergedPath?: string;
22
+ errors: string[];
23
+ }>;
14
24
  /** Fill PDF using template coordinates (drawText), same logic as web app's pdfGenerator */
15
25
  static fillWithTemplate(pdfPath: string, fields: FieldBox[], values: Record<string, string>, outputPath: string): Promise<number>;
16
26
  /**
@@ -98,6 +98,86 @@ export class PdfService {
98
98
  fs.writeFileSync(outputPath, savedBytes);
99
99
  return filled;
100
100
  }
101
+ /**
102
+ * Batch fill AcroForm: generate N PDFs from the same base PDF, one per data row.
103
+ * If merge=true, all outputs are concatenated into a single PDF and temp files are cleaned.
104
+ * Optionally accepts a fieldMap to translate logical data keys → technical AcroForm field names.
105
+ */
106
+ static async batchFillAcroForm(pdfPath, dataRows, outputDir, baseName, merge = false, fieldMap) {
107
+ const outputPaths = [];
108
+ const errors = [];
109
+ const timestamp = new Date().toISOString().slice(0, 10);
110
+ fs.mkdirSync(outputDir, { recursive: true });
111
+ const workingDir = merge
112
+ ? fs.mkdtempSync(path.join(outputDir, '.tmp_batch_'))
113
+ : outputDir;
114
+ // Load the base PDF once
115
+ const bytes = fs.readFileSync(pdfPath);
116
+ for (let i = 0; i < dataRows.length; i++) {
117
+ try {
118
+ const row = dataRows[i];
119
+ // Translate logical keys to technical field names if a map is provided
120
+ const resolvedValues = {};
121
+ for (const [key, value] of Object.entries(row)) {
122
+ const technicalName = fieldMap?.[key] ?? key;
123
+ resolvedValues[technicalName] = String(value ?? '');
124
+ }
125
+ const num = String(i + 1).padStart(3, '0');
126
+ const outPath = path.join(workingDir, `${baseName}_${num}_${timestamp}.pdf`);
127
+ // Re-load each iteration to get a fresh form instance
128
+ const pdfDoc = await PDFDocument.load(bytes);
129
+ const form = pdfDoc.getForm();
130
+ for (const [name, value] of Object.entries(resolvedValues)) {
131
+ try {
132
+ const field = form.getField(name);
133
+ if (field instanceof PDFTextField) {
134
+ field.setText(value);
135
+ }
136
+ else if (field instanceof PDFCheckBox) {
137
+ if (['true', '1', 'yes', 'si', 'sí'].includes(value.toLowerCase()))
138
+ field.check();
139
+ else
140
+ field.uncheck();
141
+ }
142
+ else if (field instanceof PDFDropdown) {
143
+ field.select(value);
144
+ }
145
+ else if (field instanceof PDFRadioGroup) {
146
+ field.select(value);
147
+ }
148
+ }
149
+ catch {
150
+ // Field not found — skip silently
151
+ }
152
+ }
153
+ form.flatten();
154
+ const savedBytes = await pdfDoc.save();
155
+ fs.writeFileSync(outPath, savedBytes);
156
+ outputPaths.push(outPath);
157
+ }
158
+ catch (err) {
159
+ errors.push(`Fila ${i + 1}: ${err instanceof Error ? err.message : String(err)}`);
160
+ }
161
+ }
162
+ let mergedPath;
163
+ if (merge && outputPaths.length > 0) {
164
+ mergedPath = path.join(outputDir, `${baseName}_merged_${timestamp}.pdf`);
165
+ await this.mergePdfs(outputPaths, mergedPath);
166
+ // Clean up temp files and dir
167
+ for (const p of outputPaths) {
168
+ try {
169
+ fs.unlinkSync(p);
170
+ }
171
+ catch { /* ignore */ }
172
+ }
173
+ try {
174
+ fs.rmdirSync(workingDir);
175
+ }
176
+ catch { /* ignore */ }
177
+ return { outputPaths: [], mergedPath, errors };
178
+ }
179
+ return { outputPaths, mergedPath, errors };
180
+ }
101
181
  /** Fill PDF using template coordinates (drawText), same logic as web app's pdfGenerator */
102
182
  static async fillWithTemplate(pdfPath, fields, values, outputPath) {
103
183
  const bytes = fs.readFileSync(pdfPath);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Position of a text element in PDF coordinates (origin: bottom-left).
3
+ */
4
+ export interface TextItem {
5
+ text: string;
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ fontSize: number;
11
+ page: number;
12
+ }
13
+ /**
14
+ * Horizontal line detected as a drawing operator.
15
+ */
16
+ export interface HorizontalLine {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ page: number;
21
+ }
22
+ /**
23
+ * A page's extracted information.
24
+ */
25
+ export interface PageTextInfo {
26
+ page: number;
27
+ width: number;
28
+ height: number;
29
+ textItems: TextItem[];
30
+ totalCharacters: number;
31
+ }
32
+ /**
33
+ * Classifies a PDF based on its extractable content.
34
+ */
35
+ export type PdfContentType = 'acroform' | 'text_vectorial' | 'image_only' | 'mixed';
36
+ /**
37
+ * Extracts text content from a PDF using pdfjs-dist in Node.js-compatible mode.
38
+ * No native dependencies, works entirely in pure JavaScript.
39
+ */
40
+ export declare class PdfTextExtractor {
41
+ /** Extract all text items from all pages of a PDF */
42
+ static extractAllText(pdfPath: string): Promise<PageTextInfo[]>;
43
+ /**
44
+ * Classify a PDF based on extractable text density.
45
+ * Thresholds are empirical; can be tuned.
46
+ */
47
+ static classifyContent(pages: PageTextInfo[]): PdfContentType;
48
+ /**
49
+ * For a given point (x, y) on a page, find the text items within a certain distance.
50
+ * Distance is computed in PDF points (not pixels).
51
+ * Returns items sorted by distance (closest first).
52
+ */
53
+ static findNearbyText(pageInfo: PageTextInfo, targetX: number, targetY: number, maxDistance?: number): Array<TextItem & {
54
+ distance: number;
55
+ relativePosition: 'left' | 'right' | 'above' | 'below' | 'inside';
56
+ }>;
57
+ /**
58
+ * Find the most likely label for a field at a given position.
59
+ * Prefers text that:
60
+ * - Is to the LEFT of the field (same Y level, typical "Label: ___")
61
+ * - Is ABOVE the field (stacked layouts)
62
+ * - Ends with ':' (explicit label)
63
+ * - Is short (labels are usually 1-3 words)
64
+ */
65
+ static inferLabelForField(pageInfo: PageTextInfo, fieldX: number, fieldY: number, fieldWidth: number, fieldHeight: number): {
66
+ label: string | null;
67
+ confidence: 'high' | 'medium' | 'low';
68
+ nearbyText: string[];
69
+ };
70
+ /**
71
+ * Detect probable field locations in a static PDF (no AcroForm).
72
+ * Heuristics:
73
+ * 1. Text ending in ':' followed by horizontal space → likely label + input area
74
+ * 2. Text that looks like a label (short, ends with ':') positioned above a gap
75
+ */
76
+ static detectStaticFieldCandidates(pages: PageTextInfo[]): Array<{
77
+ page: number;
78
+ inferredLabel: string;
79
+ hintFromText: string;
80
+ suggestedX: number;
81
+ suggestedY: number;
82
+ suggestedWidth: number;
83
+ suggestedHeight: number;
84
+ confidence: 'high' | 'medium' | 'low';
85
+ reason: string;
86
+ }>;
87
+ }
@@ -0,0 +1,224 @@
1
+ import * as fs from 'fs';
2
+ // Use legacy build — works in Node without DOM/canvas
3
+ // @ts-ignore — pdfjs-dist types are incomplete for this path
4
+ import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
5
+ /**
6
+ * Extracts text content from a PDF using pdfjs-dist in Node.js-compatible mode.
7
+ * No native dependencies, works entirely in pure JavaScript.
8
+ */
9
+ export class PdfTextExtractor {
10
+ /** Extract all text items from all pages of a PDF */
11
+ static async extractAllText(pdfPath) {
12
+ const bytes = fs.readFileSync(pdfPath);
13
+ const pdf = await pdfjsLib.getDocument({
14
+ data: new Uint8Array(bytes),
15
+ useSystemFonts: true,
16
+ // Silence pdfjs warnings on stderr when running in Node
17
+ verbosity: 0
18
+ }).promise;
19
+ const pages = [];
20
+ for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
21
+ const page = await pdf.getPage(pageNum);
22
+ const viewport = page.getViewport({ scale: 1 });
23
+ const textContent = await page.getTextContent();
24
+ const items = [];
25
+ let totalChars = 0;
26
+ for (const raw of textContent.items) {
27
+ const text = String(raw.str ?? '').trim();
28
+ if (text === '')
29
+ continue;
30
+ // transform = [scaleX, skewX, skewY, scaleY, translateX, translateY]
31
+ const [, , , scaleY, tx, ty] = raw.transform;
32
+ const fontSize = Math.abs(scaleY);
33
+ // pdfjs gives us the baseline; approximate the bounding box
34
+ // Width is already computed by pdfjs; height ≈ fontSize
35
+ const width = Math.abs(raw.width ?? 0);
36
+ const height = Math.abs(raw.height ?? fontSize);
37
+ items.push({
38
+ text,
39
+ x: tx,
40
+ y: ty, // already in PDF coordinate system (bottom-left origin)
41
+ width,
42
+ height,
43
+ fontSize,
44
+ page: pageNum
45
+ });
46
+ totalChars += text.length;
47
+ }
48
+ pages.push({
49
+ page: pageNum,
50
+ width: viewport.width,
51
+ height: viewport.height,
52
+ textItems: items,
53
+ totalCharacters: totalChars
54
+ });
55
+ }
56
+ return pages;
57
+ }
58
+ /**
59
+ * Classify a PDF based on extractable text density.
60
+ * Thresholds are empirical; can be tuned.
61
+ */
62
+ static classifyContent(pages) {
63
+ if (pages.length === 0)
64
+ return 'image_only';
65
+ const totalChars = pages.reduce((sum, p) => sum + p.totalCharacters, 0);
66
+ const totalItems = pages.reduce((sum, p) => sum + p.textItems.length, 0);
67
+ const avgCharsPerPage = totalChars / pages.length;
68
+ // Empty or near-empty: scanned image
69
+ if (totalChars < 10 || totalItems < 3)
70
+ return 'image_only';
71
+ // Low density: possibly mixed (image + some labels)
72
+ if (avgCharsPerPage < 50)
73
+ return 'mixed';
74
+ return 'text_vectorial';
75
+ }
76
+ /**
77
+ * For a given point (x, y) on a page, find the text items within a certain distance.
78
+ * Distance is computed in PDF points (not pixels).
79
+ * Returns items sorted by distance (closest first).
80
+ */
81
+ static findNearbyText(pageInfo, targetX, targetY, maxDistance = 100) {
82
+ const results = [];
83
+ for (const item of pageInfo.textItems) {
84
+ // Use the center of the text item
85
+ const itemCenterX = item.x + item.width / 2;
86
+ const itemCenterY = item.y + item.height / 2;
87
+ const dx = targetX - itemCenterX;
88
+ const dy = targetY - itemCenterY;
89
+ const distance = Math.sqrt(dx * dx + dy * dy);
90
+ if (distance > maxDistance)
91
+ continue;
92
+ // Determine relative position
93
+ let relativePosition;
94
+ const absDx = Math.abs(dx);
95
+ const absDy = Math.abs(dy);
96
+ if (absDx < item.width / 2 && absDy < item.height / 2) {
97
+ relativePosition = 'inside';
98
+ }
99
+ else if (absDx > absDy) {
100
+ relativePosition = dx > 0 ? 'left' : 'right';
101
+ }
102
+ else {
103
+ relativePosition = dy > 0 ? 'below' : 'above';
104
+ }
105
+ results.push({ ...item, distance, relativePosition });
106
+ }
107
+ return results.sort((a, b) => a.distance - b.distance);
108
+ }
109
+ /**
110
+ * Find the most likely label for a field at a given position.
111
+ * Prefers text that:
112
+ * - Is to the LEFT of the field (same Y level, typical "Label: ___")
113
+ * - Is ABOVE the field (stacked layouts)
114
+ * - Ends with ':' (explicit label)
115
+ * - Is short (labels are usually 1-3 words)
116
+ */
117
+ static inferLabelForField(pageInfo, fieldX, fieldY, fieldWidth, fieldHeight) {
118
+ // Target point: slightly to the LEFT of the field's center Y
119
+ const targetX = fieldX; // left edge of field
120
+ const targetY = fieldY + fieldHeight / 2;
121
+ const nearby = this.findNearbyText(pageInfo, targetX, targetY, 150);
122
+ if (nearby.length === 0) {
123
+ return { label: null, confidence: 'low', nearbyText: [] };
124
+ }
125
+ // Score each candidate
126
+ const scored = nearby.map(item => {
127
+ let score = 0;
128
+ // Prefer text to the LEFT
129
+ if (item.relativePosition === 'left')
130
+ score += 50;
131
+ // Secondary: above the field
132
+ else if (item.relativePosition === 'above')
133
+ score += 30;
134
+ // Penalize right/below/inside
135
+ else
136
+ score += 10;
137
+ // Closer is better (inversely proportional)
138
+ score += Math.max(0, 100 - item.distance);
139
+ // Ends with ':' → almost certainly a label
140
+ if (item.text.trim().endsWith(':'))
141
+ score += 40;
142
+ // Short text is more label-like (1-4 words)
143
+ const wordCount = item.text.trim().split(/\s+/).length;
144
+ if (wordCount >= 1 && wordCount <= 4)
145
+ score += 20;
146
+ else if (wordCount > 10)
147
+ score -= 20;
148
+ // Penalize very generic text
149
+ if (/^(página|page|de|the|and|or)$/i.test(item.text.trim()))
150
+ score -= 30;
151
+ return { item, score };
152
+ });
153
+ scored.sort((a, b) => b.score - a.score);
154
+ const best = scored[0];
155
+ const cleanLabel = best.item.text.trim().replace(/:\s*$/, '');
156
+ // Confidence based on score
157
+ let confidence;
158
+ if (best.score >= 100)
159
+ confidence = 'high';
160
+ else if (best.score >= 60)
161
+ confidence = 'medium';
162
+ else
163
+ confidence = 'low';
164
+ // Return top 5 nearby text items for context
165
+ const nearbyText = scored.slice(0, 5).map(s => s.item.text.trim());
166
+ return { label: cleanLabel, confidence, nearbyText };
167
+ }
168
+ /**
169
+ * Detect probable field locations in a static PDF (no AcroForm).
170
+ * Heuristics:
171
+ * 1. Text ending in ':' followed by horizontal space → likely label + input area
172
+ * 2. Text that looks like a label (short, ends with ':') positioned above a gap
173
+ */
174
+ static detectStaticFieldCandidates(pages) {
175
+ const candidates = [];
176
+ for (const pageInfo of pages) {
177
+ // Find all "label-like" text items (end with ':' or are short and capitalized)
178
+ const labels = pageInfo.textItems.filter(item => {
179
+ const t = item.text.trim();
180
+ if (t.length === 0 || t.length > 40)
181
+ return false;
182
+ return t.endsWith(':') || /^[A-ZÁÉÍÓÚÑ][\wáéíóúñ\s]*$/.test(t);
183
+ });
184
+ for (const label of labels) {
185
+ const labelText = label.text.trim().replace(/:\s*$/, '');
186
+ if (labelText.length < 2)
187
+ continue;
188
+ // Assume the input goes to the RIGHT of the label
189
+ // (most common "Label: ___" pattern)
190
+ const fieldX = label.x + label.width + 5;
191
+ const fieldY = label.y;
192
+ const fieldWidth = Math.max(150, (pageInfo.width - fieldX - 20) * 0.4);
193
+ const fieldHeight = label.fontSize * 1.5;
194
+ // Only suggest if there's space to the right
195
+ if (fieldX + fieldWidth > pageInfo.width)
196
+ continue;
197
+ // Check: is there text already occupying that space?
198
+ const occupied = pageInfo.textItems.some(other => {
199
+ if (other === label)
200
+ return false;
201
+ const otherCenterX = other.x + other.width / 2;
202
+ const otherCenterY = other.y + other.height / 2;
203
+ return (otherCenterX >= fieldX &&
204
+ otherCenterX <= fieldX + fieldWidth &&
205
+ Math.abs(otherCenterY - (fieldY + fieldHeight / 2)) < fieldHeight);
206
+ });
207
+ if (occupied)
208
+ continue;
209
+ candidates.push({
210
+ page: pageInfo.page,
211
+ inferredLabel: labelText,
212
+ hintFromText: label.text.trim(),
213
+ suggestedX: Math.round(fieldX),
214
+ suggestedY: Math.round(fieldY),
215
+ suggestedWidth: Math.round(fieldWidth),
216
+ suggestedHeight: Math.round(fieldHeight),
217
+ confidence: labelText.endsWith(':') ? 'high' : 'medium',
218
+ reason: `Label text "${label.text.trim()}" detected at (${Math.round(label.x)}, ${Math.round(label.y)}); suggested input area to its right.`
219
+ });
220
+ }
221
+ }
222
+ return candidates;
223
+ }
224
+ }
@@ -0,0 +1,27 @@
1
+ export declare const analyzeStaticPdfSchema: {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: "object";
6
+ properties: {
7
+ pdf_path: {
8
+ type: string;
9
+ description: string;
10
+ };
11
+ };
12
+ required: string[];
13
+ };
14
+ };
15
+ export declare function handleAnalyzeStaticPdf(args: any): Promise<{
16
+ content: {
17
+ type: "text";
18
+ text: string;
19
+ }[];
20
+ isError: boolean;
21
+ } | {
22
+ content: {
23
+ type: "text";
24
+ text: string;
25
+ }[];
26
+ isError?: undefined;
27
+ }>;
@@ -0,0 +1,157 @@
1
+ import { PdfTextExtractor } from '../services/pdfTextExtractor.js';
2
+ export const analyzeStaticPdfSchema = {
3
+ name: 'autoform_analyze_static_pdf',
4
+ description: `Analiza un PDF estatico (sin AcroForm) para sugerir donde colocar los campos automaticamente. Extrae texto con posiciones y usa heuristicas para detectar labels y areas de input probables.
5
+
6
+ CUANDO USAR:
7
+ - El PDF no tiene AcroForm (autoform_detect_fields devolvio has_acroform=false).
8
+ - Es un certificado, diploma, constancia, formulario diseñado en Canva/Word/Figma.
9
+ - Quieres sugerencias automaticas de donde van los campos sin tener que calcular coordenadas visualmente.
10
+
11
+ QUE DEVUELVE:
12
+ - Clasificacion del PDF: "text_vectorial" (texto extraible), "mixed" (texto + imagenes), "image_only" (escaneado).
13
+ - Para text_vectorial: "suggested_fields" con labels inferidos y coordenadas sugeridas.
14
+ - Para image_only: "suggested_approaches" con opciones claras para el usuario (NO rechaza el PDF).
15
+ - Texto completo con posiciones por pagina ("all_text_items").
16
+
17
+ ⚠️ CASO PDF-IMAGEN (pdf_type: "image_only"):
18
+ Si recibes este tipo, NO intentes procesar el PDF con los otros tools. En su lugar, PRESENTA AL USUARIO las suggested_approaches de forma clara y amigable, y espera su eleccion. Las opciones tipicas son:
19
+ 1. Adjuntar el PDF al chat de Claude Desktop (Claude puede verlo visualmente)
20
+ 2. Usar la app web de AutoForm para definir campos manualmente
21
+ 3. Proporcionar coordenadas manualmente si ya las conoces
22
+
23
+ ⚠️ CASO text_vectorial:
24
+ - suggested_fields son SUGERENCIAS heuristicas (asumen layout "Label: ___" a la derecha).
25
+ - Para layouts atipicos (certificados con campo DEBAJO del label, diplomas con layout centrado, etc.) las sugerencias pueden estar en posiciones incorrectas.
26
+ - USA all_text_items para ver TODOS los textos del PDF con sus posiciones exactas y razona sobre el layout real antes de decidir las coordenadas finales.
27
+ - Los campos "CERTIFICADO", "Firma 1/2" que se detectan como sugerencias probablemente NO son campos editables — son etiquetas del certificado. Descarta sugerencias que claramente son texto decorativo.
28
+ - Cuando no estes seguro, PREGUNTA al usuario donde va cada dato en lugar de adivinar.
29
+
30
+ ⚠️ CASO mixed:
31
+ Hay algo de texto pero poca densidad. Puede haber campos que NO se detectaron automaticamente porque el label es una imagen. Usa suggested_fields como punto de partida pero verifica visualmente si faltan campos.`,
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ pdf_path: {
36
+ type: 'string',
37
+ description: 'Ruta absoluta al PDF estatico a analizar'
38
+ }
39
+ },
40
+ required: ['pdf_path']
41
+ }
42
+ };
43
+ export async function handleAnalyzeStaticPdf(args) {
44
+ const { pdf_path } = args;
45
+ let pages;
46
+ try {
47
+ pages = await PdfTextExtractor.extractAllText(pdf_path);
48
+ }
49
+ catch (err) {
50
+ return {
51
+ content: [{
52
+ type: 'text',
53
+ text: JSON.stringify({
54
+ error: true,
55
+ message: `No se pudo leer el PDF: ${err instanceof Error ? err.message : String(err)}`
56
+ }, null, 2)
57
+ }],
58
+ isError: true
59
+ };
60
+ }
61
+ if (pages.length === 0) {
62
+ return {
63
+ content: [{
64
+ type: 'text',
65
+ text: JSON.stringify({
66
+ error: true,
67
+ message: 'El PDF no contiene paginas procesables.'
68
+ }, null, 2)
69
+ }],
70
+ isError: true
71
+ };
72
+ }
73
+ const contentType = PdfTextExtractor.classifyContent(pages);
74
+ // Case: image-only PDF — return actionable options, never reject
75
+ if (contentType === 'image_only') {
76
+ return {
77
+ content: [{
78
+ type: 'text',
79
+ text: JSON.stringify({
80
+ pdf_type: 'image_only',
81
+ pdf_info: {
82
+ pages: pages.length,
83
+ dimensions_pt: {
84
+ width: Math.round(pages[0].width),
85
+ height: Math.round(pages[0].height)
86
+ }
87
+ },
88
+ reason: 'Este PDF no contiene texto vectorial extraible. Probablemente es una imagen escaneada, un PDF aplanado, o un diseño donde todos los textos fueron convertidos a curvas.',
89
+ message_for_user: 'Este PDF es tipo imagen — no puedo extraer texto automaticamente para detectar los campos. Sin embargo, tenemos varias opciones para procesarlo. Elige la que prefieras:',
90
+ suggested_approaches: [
91
+ {
92
+ id: 'attach_to_chat',
93
+ title: 'Adjuntar el PDF directamente al chat',
94
+ description: 'Arrastra el PDF al chat de Claude Desktop. Podre verlo visualmente como imagen y estimar las coordenadas de los campos con buena precision. Luego usaremos autoform_fill_at_coordinates o autoform_fill_batch_at_coordinates para llenar los datos.',
95
+ effort: 'bajo',
96
+ precision: 'media-alta',
97
+ recommended: true
98
+ },
99
+ {
100
+ id: 'web_app',
101
+ title: 'Usar la app web de AutoForm',
102
+ description: 'Abre la aplicacion web de AutoForm en tu navegador, carga el PDF, dibuja los campos visualmente donde correspondan, exporta como JSON y luego usa autoform_import_template aqui. Es la opcion mas precisa para PDFs complejos.',
103
+ effort: 'medio',
104
+ precision: 'alta'
105
+ },
106
+ {
107
+ id: 'manual_coordinates',
108
+ title: 'Proporcionar coordenadas manualmente',
109
+ description: 'Si ya conoces las coordenadas exactas de los campos (por ejemplo porque trabajaste con el PDF antes), puedes pasarlas directamente a autoform_fill_at_coordinates o autoform_fill_batch_at_coordinates.',
110
+ effort: 'alto',
111
+ precision: 'exacta'
112
+ }
113
+ ],
114
+ next_instruction: 'Presenta estas opciones al usuario de forma clara (no copies el JSON literal, reformulalo conversacionalmente). Espera su eleccion antes de continuar. NO intentes procesar el PDF con otros tools hasta tener instrucciones del usuario.'
115
+ }, null, 2)
116
+ }]
117
+ };
118
+ }
119
+ // Case: text_vectorial or mixed — extract field candidates
120
+ const suggestedFields = PdfTextExtractor.detectStaticFieldCandidates(pages);
121
+ const allTextItems = pages.flatMap(p => p.textItems.map(item => ({
122
+ text: item.text,
123
+ page: item.page,
124
+ x: Math.round(item.x),
125
+ y: Math.round(item.y),
126
+ fontSize: Math.round(item.fontSize * 10) / 10
127
+ })));
128
+ const summary = suggestedFields.length > 0
129
+ ? `Se detectaron ${suggestedFields.length} campos candidatos automaticamente:\n${suggestedFields.map(f => `- "${f.inferredLabel}" en pag ${f.page}, pos (${f.suggestedX}, ${f.suggestedY}), ${f.suggestedWidth}x${f.suggestedHeight} [${f.confidence}]`).join('\n')}`
130
+ : 'No se detectaron campos candidatos automaticamente. El PDF tiene texto pero no hay patrones claros de "Label: ___" reconocibles.';
131
+ return {
132
+ content: [{
133
+ type: 'text',
134
+ text: JSON.stringify({
135
+ pdf_type: contentType,
136
+ pdf_info: {
137
+ pages: pages.length,
138
+ dimensions_pt: pages.map(p => ({
139
+ page: p.page,
140
+ width: Math.round(p.width),
141
+ height: Math.round(p.height)
142
+ }))
143
+ },
144
+ total_text_items: allTextItems.length,
145
+ suggested_fields: suggestedFields,
146
+ all_text_items: allTextItems.slice(0, 100), // Limit to avoid huge responses
147
+ text_items_truncated: allTextItems.length > 100,
148
+ summary,
149
+ guidance: contentType === 'mixed'
150
+ ? 'El PDF tiene densidad baja de texto — puede tener elementos visuales (titulos como imagen, logos) que no se extraen. Usa los suggested_fields como punto de partida pero verifica si faltan campos.'
151
+ : suggestedFields.length > 0
152
+ ? 'Usa los suggested_fields directamente con autoform_fill_at_coordinates (un documento) o autoform_fill_batch_at_coordinates (multiples documentos).'
153
+ : 'No hay campos obvios detectables. Pregunta al usuario donde quiere colocar los datos o pidele que abra el PDF en la app web de AutoForm.'
154
+ }, null, 2)
155
+ }]
156
+ };
157
+ }
@@ -1,11 +1,35 @@
1
1
  import { PdfService } from '../services/pdfService.js';
2
+ import { PdfTextExtractor } from '../services/pdfTextExtractor.js';
2
3
  export const detectFieldsSchema = {
3
4
  name: 'autoform_detect_fields',
4
- description: `Detecta campos de formulario AcroForm en un PDF interactivo (creado con Adobe Acrobat, LibreOffice, etc).
5
+ description: `Detecta campos de formulario AcroForm en un PDF interactivo Y correlaciona cada campo con su label visible extraido del texto del PDF.
5
6
 
6
- CUANDO USAR: El PDF es un formulario donde puedes hacer click y escribir. Si el PDF es una imagen/diseño estatico (certificado, diploma), esta tool dira "sin campos" — en ese caso usa autoform_get_pdf_info + analisis visual.
7
+ CUANDO USAR:
8
+ - El PDF es un formulario donde puedes hacer click y escribir (AcroForm).
9
+ - Quieres saber que campos tiene + como se llaman visualmente.
7
10
 
8
- DESPUES DE DETECTAR: Usa autoform_fill_pdf para llenar los campos encontrados.`,
11
+ QUE DEVUELVE:
12
+ - Lista de campos AcroForm con nombre tecnico, tipo, pagina y coordenadas.
13
+ - Para CADA campo, un "inferred_label" calculado automaticamente correlacionando la posicion del campo con el texto circundante del PDF.
14
+ - "nearby_text": hasta 5 textos cercanos al campo, ordenados por relevancia.
15
+ - "confidence": high/medium/low segun que tan claro es el label correlacionado.
16
+
17
+ ⚠️ COMO USAR EL RESULTADO:
18
+ - Si confidence es "high": confia en inferred_label y usalo directamente.
19
+ - Si confidence es "medium": el label es probable pero verifica con nearby_text si tiene sentido.
20
+ - Si confidence es "low": NO asumas — pregunta al usuario o mira el PDF visualmente si es posible.
21
+ - NUNCA inventes el significado de un campo por su posicion o numero.
22
+
23
+ SI EL PDF NO TIENE ACROFORM: esta tool devolvera has_acroform=false. En ese caso usa autoform_analyze_static_pdf.
24
+
25
+ FLUJO RECOMENDADO PARA UN DOCUMENTO:
26
+ 1. autoform_detect_fields → obtienes campos + inferred_labels
27
+ 2. Usa autoform_fill_pdf con field_values={nombre_tecnico: valor}
28
+ O usa autoform_fill_pdf con field_map si prefieres claves logicas
29
+
30
+ FLUJO RECOMENDADO PARA MULTIPLES DOCUMENTOS:
31
+ 1. autoform_detect_fields → obtienes campos + inferred_labels
32
+ 2. Usa autoform_fill_batch_acroform con data_rows y field_map si las claves son logicas.`,
9
33
  inputSchema: {
10
34
  type: 'object',
11
35
  properties: {
@@ -20,21 +44,85 @@ DESPUES DE DETECTAR: Usa autoform_fill_pdf para llenar los campos encontrados.`,
20
44
  export async function handleDetectFields(args) {
21
45
  const { pdf_path } = args;
22
46
  const { fields, hasAcroform } = await PdfService.detectFields(pdf_path);
23
- let summary;
24
- if (fields.length === 0) {
25
- summary = `El PDF "${pdf_path}" no contiene campos AcroForm.\n\nPara usar este PDF, defina los campos manualmente en la app web de AutoForm y exporte la plantilla como JSON. Luego impórtela con autoform_import_template.`;
47
+ // If no AcroForm, recommend the static analysis tool
48
+ if (!hasAcroform || fields.length === 0) {
49
+ return {
50
+ content: [{
51
+ type: 'text',
52
+ text: JSON.stringify({
53
+ has_acroform: false,
54
+ total_fields: 0,
55
+ fields: [],
56
+ message: `El PDF no contiene campos AcroForm interactivos. Use autoform_analyze_static_pdf para analizar PDFs estaticos (certificados, diplomas, formularios diseñados en Canva/Word).`,
57
+ next_action: 'autoform_analyze_static_pdf'
58
+ }, null, 2)
59
+ }]
60
+ };
61
+ }
62
+ // Extract text from the PDF to correlate with AcroForm fields
63
+ let pagesText;
64
+ try {
65
+ pagesText = await PdfTextExtractor.extractAllText(pdf_path);
66
+ }
67
+ catch (err) {
68
+ // If extraction fails, return fields without label inference
69
+ return {
70
+ content: [{
71
+ type: 'text',
72
+ text: JSON.stringify({
73
+ has_acroform: true,
74
+ total_fields: fields.length,
75
+ fields,
76
+ text_extraction_error: err instanceof Error ? err.message : String(err),
77
+ warning: 'No se pudo extraer texto del PDF para inferir labels. Los nombres tecnicos se devuelven sin correlacion visual.'
78
+ }, null, 2)
79
+ }]
80
+ };
81
+ }
82
+ // Enrich each field with inferred label and nearby text
83
+ const enrichedFields = fields.map(field => {
84
+ const pageInfo = pagesText.find(p => p.page === field.page);
85
+ if (!pageInfo) {
86
+ return { ...field, inferred_label: null, confidence: 'low', nearby_text: [] };
87
+ }
88
+ const { label, confidence, nearbyText } = PdfTextExtractor.inferLabelForField(pageInfo, field.x, field.y, field.width, field.height);
89
+ return {
90
+ ...field,
91
+ inferred_label: label,
92
+ confidence,
93
+ nearby_text: nearbyText
94
+ };
95
+ });
96
+ // Build a summary that emphasizes inferred labels
97
+ const labelSummary = enrichedFields
98
+ .map(f => {
99
+ const labelDisplay = f.inferred_label
100
+ ? `"${f.inferred_label}" (${f.confidence})`
101
+ : '[sin label inferido]';
102
+ return `- ${f.name}: ${labelDisplay} — pag ${f.page}, pos (${Math.round(f.x)}, ${Math.round(f.y)}), ${Math.round(f.width)}x${Math.round(f.height)}`;
103
+ })
104
+ .join('\n');
105
+ const highConfidenceCount = enrichedFields.filter(f => f.confidence === 'high').length;
106
+ const lowConfidenceCount = enrichedFields.filter(f => f.confidence === 'low').length;
107
+ let guidance;
108
+ if (lowConfidenceCount === 0) {
109
+ guidance = 'Todos los campos tienen labels correlacionados con alta confianza. Puedes proceder a llenar usando los inferred_label directamente.';
110
+ }
111
+ else if (lowConfidenceCount < enrichedFields.length) {
112
+ guidance = `${highConfidenceCount} campos tienen labels claros, pero ${lowConfidenceCount} tienen baja confianza. Verifica los nearby_text antes de llenar.`;
26
113
  }
27
114
  else {
28
- summary = `Encontrados ${fields.length} campos AcroForm:\n\n${fields.map(f => `- ${f.name} (${f.type}) Pag ${f.page}, pos: ${Math.round(f.x)},${Math.round(f.y)} ${Math.round(f.width)}x${Math.round(f.height)}${f.value ? ` = "${f.value}"` : ''}`).join('\n')}`;
115
+ guidance = 'Los labels inferidos tienen baja confianza. Revisa el nearby_text de cada campo o pregunta al usuario que represente cada uno antes de llenar.';
29
116
  }
30
117
  return {
31
118
  content: [{
32
119
  type: 'text',
33
120
  text: JSON.stringify({
34
- has_acroform: hasAcroform,
121
+ has_acroform: true,
35
122
  total_fields: fields.length,
36
- fields,
37
- summary
123
+ fields: enrichedFields,
124
+ summary: labelSummary,
125
+ guidance
38
126
  }, null, 2)
39
127
  }]
40
128
  };
@@ -4,9 +4,11 @@ import { PdfService } from '../services/pdfService.js';
4
4
  const OUTPUT_DIR = path.join(os.homedir(), '.autoform-mcp', 'output');
5
5
  export const fillAtCoordinatesSchema = {
6
6
  name: 'autoform_fill_at_coordinates',
7
- description: `Escribe texto en posiciones exactas de un PDF usando coordenadas en puntos PDF (origen: esquina inferior-izquierda).
7
+ description: `Escribe texto en posiciones exactas de UN PDF usando coordenadas en puntos PDF (origen: esquina inferior-izquierda).
8
8
  Ideal cuando Claude analiza visualmente un PDF y determina dónde colocar el texto.
9
9
 
10
+ ⚠️ ESTA TOOL ES PARA UN UNICO DOCUMENTO. Si necesitas generar multiples PDFs con los mismos campos pero datos diferentes, USA autoform_fill_batch_at_coordinates (mas eficiente, una sola llamada).
11
+
10
12
  IMPORTANTE — Codificación de texto:
11
13
  - El texto se pasa EXACTAMENTE como lo envías. NO simplifiques ni modifiques caracteres.
12
14
  - Acentos españoles (á é í ó ú ñ Ñ ü) están soportados completamente.
@@ -0,0 +1,52 @@
1
+ export declare const fillBatchAcroFormSchema: {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: "object";
6
+ properties: {
7
+ pdf_path: {
8
+ type: string;
9
+ description: string;
10
+ };
11
+ data_rows: {
12
+ type: string;
13
+ description: string;
14
+ items: {
15
+ type: string;
16
+ additionalProperties: {
17
+ type: string;
18
+ };
19
+ };
20
+ };
21
+ field_map: {
22
+ type: string;
23
+ description: string;
24
+ additionalProperties: {
25
+ type: string;
26
+ };
27
+ };
28
+ merge_into_single: {
29
+ type: string;
30
+ description: string;
31
+ };
32
+ output_dir: {
33
+ type: string;
34
+ description: string;
35
+ };
36
+ };
37
+ required: string[];
38
+ };
39
+ };
40
+ export declare function handleFillBatchAcroForm(args: any): Promise<{
41
+ content: {
42
+ type: "text";
43
+ text: string;
44
+ }[];
45
+ isError: boolean;
46
+ } | {
47
+ content: {
48
+ type: "text";
49
+ text: string;
50
+ }[];
51
+ isError?: undefined;
52
+ }>;
@@ -0,0 +1,96 @@
1
+ import * as path from 'path';
2
+ import * as os from 'os';
3
+ import { PdfService } from '../services/pdfService.js';
4
+ const OUTPUT_DIR = path.join(os.homedir(), '.autoform-mcp', 'output');
5
+ export const fillBatchAcroFormSchema = {
6
+ name: 'autoform_fill_batch_acroform',
7
+ description: `Genera MULTIPLES PDFs a partir de un PDF con campos AcroForm (formulario interactivo) + array de datos. Una sola llamada genera N documentos.
8
+
9
+ CUANDO USAR: El PDF base tiene campos AcroForm (detectados con autoform_detect_fields) y el usuario quiere llenarlos con datos de varias personas/filas. Escalable a cientos de documentos en una sola llamada.
10
+
11
+ NO USAR SI: El PDF es estatico (sin AcroForm) → usa autoform_fill_batch_at_coordinates.
12
+ NO USAR SI: Solo necesitas llenar UN documento → usa autoform_fill_pdf.
13
+
14
+ IMPORTANTE sobre nombres de campos:
15
+ - Los campos AcroForm tienen nombres TECNICOS (ej: "Text1", "fld_nombre", "field_001") que NO siempre coinciden con los labels visibles del PDF.
16
+ - DESPUES de llamar autoform_detect_fields, DEBES MIRAR EL PDF VISUALMENTE para correlacionar cada campo tecnico con su label visible segun sus coordenadas.
17
+ - Si las claves de tus data_rows NO coinciden con los nombres tecnicos, usa el parametro field_map para traducir: { "nombre_logico": "nombre_tecnico" }
18
+ - Ejemplo: si el campo tecnico es "Text1" pero visualmente corresponde al label "Nombre:", pasa field_map={"nombre": "Text1"} y data_rows=[{"nombre": "Juan"}]
19
+
20
+ MERGE: Si merge_into_single=true, se genera SOLO el PDF unificado (los individuales son temporales y se borran automaticamente).
21
+
22
+ CODIFICACION: Los datos se escriben EXACTAMENTE como los envias. Acentos espanoles (a e i o u n N u con tildes) estan completamente soportados. NO simplifiques caracteres.`,
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ pdf_path: {
27
+ type: 'string',
28
+ description: 'Ruta absoluta al PDF con AcroForm'
29
+ },
30
+ data_rows: {
31
+ type: 'array',
32
+ description: 'Array de objetos, cada objeto es un documento. Claves = nombre del campo (tecnico o logico si usas field_map), valores = texto a escribir.',
33
+ items: {
34
+ type: 'object',
35
+ additionalProperties: { type: 'string' }
36
+ }
37
+ },
38
+ field_map: {
39
+ type: 'object',
40
+ description: '(Opcional) Mapeo de nombres logicos a nombres tecnicos de AcroForm. Formato: {"nombre_logico": "nombre_tecnico"}. Usalo cuando las claves de data_rows son mas legibles que los nombres tecnicos reales del PDF.',
41
+ additionalProperties: { type: 'string' }
42
+ },
43
+ merge_into_single: {
44
+ type: 'boolean',
45
+ description: '(Opcional) Si true, une todos los PDFs en uno solo y elimina los individuales. Default: false.'
46
+ },
47
+ output_dir: {
48
+ type: 'string',
49
+ description: '(Opcional) Directorio de salida. Default: ~/.autoform-mcp/output/'
50
+ }
51
+ },
52
+ required: ['pdf_path', 'data_rows']
53
+ }
54
+ };
55
+ export async function handleFillBatchAcroForm(args) {
56
+ const { pdf_path, output_dir, merge_into_single = false } = args;
57
+ let data_rows = args.data_rows;
58
+ if (typeof data_rows === 'string') {
59
+ try {
60
+ data_rows = JSON.parse(data_rows);
61
+ }
62
+ catch { /* keep */ }
63
+ }
64
+ let field_map = args.field_map;
65
+ if (typeof field_map === 'string') {
66
+ try {
67
+ field_map = JSON.parse(field_map);
68
+ }
69
+ catch { /* keep */ }
70
+ }
71
+ if (!data_rows || !Array.isArray(data_rows) || data_rows.length === 0) {
72
+ return {
73
+ content: [{ type: 'text', text: JSON.stringify({ error: true, message: 'data_rows es requerido (array de objetos con datos)' }, null, 2) }],
74
+ isError: true
75
+ };
76
+ }
77
+ const outDir = output_dir || OUTPUT_DIR;
78
+ const baseName = path.basename(pdf_path, '.pdf').replace(/[^a-zA-Z0-9_\-]/g, '_');
79
+ const result = await PdfService.batchFillAcroForm(pdf_path, data_rows, outDir, baseName, merge_into_single, field_map);
80
+ return {
81
+ content: [{
82
+ type: 'text',
83
+ text: JSON.stringify({
84
+ documents_generated: merge_into_single ? data_rows.length - result.errors.length : result.outputPaths.length,
85
+ output_paths: result.outputPaths,
86
+ merged_path: result.mergedPath,
87
+ errors: result.errors,
88
+ message: result.mergedPath
89
+ ? `Generados ${data_rows.length - result.errors.length} documentos unidos en: ${result.mergedPath}`
90
+ : result.outputPaths.length > 0
91
+ ? `Generados ${result.outputPaths.length} documentos en ${outDir}`
92
+ : `Error: ${result.errors.join(', ')}`
93
+ }, null, 2)
94
+ }]
95
+ };
96
+ }
@@ -5,11 +5,16 @@ import { TemplateStore } from '../services/templateStore.js';
5
5
  const OUTPUT_DIR = path.join(os.homedir(), '.autoform-mcp', 'output');
6
6
  export const fillPdfSchema = {
7
7
  name: 'autoform_fill_pdf',
8
- description: `Llena UN PDF con valores. Detecta automaticamente si tiene AcroForm o usa un template guardado.
8
+ description: `Llena UN SOLO PDF con valores. Detecta automaticamente si tiene AcroForm o usa un template guardado.
9
9
 
10
- CUANDO USAR: Quieres llenar UN SOLO documento y ya tienes AcroForm o un template guardado.
11
- NO USAR SI: El PDF es estatico sin template usa autoform_fill_at_coordinates.
12
- NO USAR SI: Quieres generar MULTIPLES documentos usa autoform_generate_batch o autoform_fill_batch_at_coordinates.`,
10
+ ⚠️ ESTA TOOL ES PARA UN UNICO DOCUMENTO. Si necesitas generar 2 o mas documentos, USA UNA TOOL BATCH (mas eficiente, menos llamadas):
11
+ - PDF con AcroForm + multiples filasautoform_fill_batch_acroform
12
+ - PDF estatico + multiples filas → autoform_fill_batch_at_coordinates
13
+ - Template guardado + multiples filas → autoform_generate_batch
14
+
15
+ CUANDO USAR: Solo cuando el usuario pide llenar UN documento especifico.
16
+ NO USAR PARA BATCH: Nunca llames esta tool en loop para generar multiples PDFs — usa las tools batch que existen para eso.
17
+ NO USAR SI: El PDF es estatico sin template → usa autoform_fill_at_coordinates.`,
13
18
  inputSchema: {
14
19
  type: 'object',
15
20
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoform-mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "MCP server for bulk PDF form filling. Detect fields, fill templates, and generate hundreds of PDFs from data — directly from Claude.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -41,10 +41,11 @@
41
41
  "dependencies": {
42
42
  "@modelcontextprotocol/sdk": "^1.12.1",
43
43
  "jszip": "^3.10.1",
44
- "pdf-lib": "^1.17.1"
44
+ "pdf-lib": "^1.17.1",
45
+ "pdfjs-dist": "^4.10.38"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^20.0.0",
48
49
  "typescript": "^5.2.2"
49
50
  }
50
- }
51
+ }