From 44624cd4bfabcbdca8da84a950b15a0968269237 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 10:46:20 +0530 Subject: [PATCH] feat(pdf): add form field detection, fill, and flatten Adds pdfGetFormFields (lists AcroForm fields with name/type/value) and pdfFillForm (fills text fields by name, optionally flattens) to PDFOperations.js, dispatched via 'formFields'/'fillForm' in executeOperation. pdfFillForm skips unknown/non-text fields per-field (logs + continues) rather than failing the whole batch, matching the partial-success precedent set by pdfExtractImages. Wires a "Fill Form" entry into the PDF editor dialog: selecting a PDF fetches its fields via a new get-pdf-form-fields/pdf-form-fields IPC round trip and renders one text input per field, plus a flatten checkbox, following the same structure as the crop/pageNumbers dialogs. Amit Haridas --- src/index.html | 46 +++++++++ src/main.js | 18 ++++ src/main/PDFOperations.js | 70 ++++++++++++++ src/renderer.js | 108 +++++++++++++++++++++ tests/main/PDFOperations.test.js | 155 +++++++++++++++++++++++++++++++ 5 files changed, 397 insertions(+) diff --git a/src/index.html b/src/index.html index f4954cb..2ff738b 100644 --- a/src/index.html +++ b/src/index.html @@ -2111,6 +2111,52 @@ + + + diff --git a/src/main.js b/src/main.js index a9c8117..4930191 100644 --- a/src/main.js +++ b/src/main.js @@ -1454,6 +1454,13 @@ function createMenu() { { type: 'separator', }, + { + label: 'Fill Form...', + click: () => showPDFEditorDialog('fillForm'), + }, + { + type: 'separator', + }, { label: 'Security', submenu: [ @@ -4699,6 +4706,17 @@ ipcMain.on('get-pdf-page-count', async (event, filePath) => { }); } }); +ipcMain.on('get-pdf-form-fields', async (event, filePath) => { + try { + const result = await PDFOperations.pdfGetFormFields({ inputPath: filePath }); + event.reply('pdf-form-fields', result); + } catch (error) { + event.reply('pdf-form-fields', { + success: false, + error: error.message, + }); + } +}); // IPC Handler for folder selection (for PDF operations) ipcMain.on('select-pdf-folder', (event, inputId) => { diff --git a/src/main/PDFOperations.js b/src/main/PDFOperations.js index 8fb974a..74697d7 100644 --- a/src/main/PDFOperations.js +++ b/src/main/PDFOperations.js @@ -589,6 +589,70 @@ async function pdfExtractImages(data) { } } +async function pdfGetFormFields(data) { + try { + const pdfBytes = fs.readFileSync(data.inputPath); + const pdf = await PDFDocument.load(pdfBytes); + const form = pdf.getForm(); + + const fields = form.getFields().map((field) => { + let value; + try { + if (typeof field.getText === 'function') { + value = field.getText(); + } else if (typeof field.isChecked === 'function') { + value = field.isChecked(); + } else if (typeof field.getSelected === 'function') { + value = field.getSelected(); + } + } catch { + // Some field types throw when read in an unexpected state; leave value undefined. + value = undefined; + } + return { name: field.getName(), type: field.constructor.name, value }; + }); + + return { success: true, fields }; + } catch (error) { + return { success: false, error: error.message }; + } +} + +async function pdfFillForm(data) { + try { + const pdfBytes = fs.readFileSync(data.inputPath); + const pdf = await PDFDocument.load(pdfBytes); + const form = pdf.getForm(); + + const values = data.values || {}; + let filledCount = 0; + + for (const [name, value] of Object.entries(values)) { + try { + const field = form.getTextField(name); + field.setText(value !== null && value !== undefined ? String(value) : ''); + filledCount++; + } catch (fieldError) { + // Batch-of-independent-fields: a field that doesn't exist or isn't a text + // field shouldn't fail the whole fill — skip it and keep going (same + // partial-success precedent as pdfExtractImages). + console.warn(`pdfFillForm: skipping field "${name}": ${fieldError.message}`); + } + } + + if (data.flatten) { + form.flatten(); + } + + const filledPdfBytes = await pdf.save(); + fs.writeFileSync(data.outputPath, filledPdfBytes); + + return { success: true, message: `Successfully filled ${filledCount} form field(s)` }; + } catch (error) { + return { success: false, error: error.message }; + } +} + function executeOperation(operation, data) { switch (operation) { case 'merge': @@ -619,6 +683,10 @@ function executeOperation(operation, data) { return pdfCrop(data); case 'extractImages': return pdfExtractImages(data); + case 'formFields': + return pdfGetFormFields(data); + case 'fillForm': + return pdfFillForm(data); default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` }); } @@ -647,6 +715,8 @@ module.exports = { pdfAddPageNumbers, pdfCrop, pdfExtractImages, + pdfGetFormFields, + pdfFillForm, executeOperation, getPageCount, }; diff --git a/src/renderer.js b/src/renderer.js index ad69490..e408e3d 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -3894,6 +3894,23 @@ function showPDFEditorDialog(operation, openedFilePath = null) { if (extractImagesInput) extractImagesInput.value = openedFilePath; } break; + case 'fillForm': { + sectionId = 'pdf-fill-form-section'; + titleText = 'Fill Form'; + const fieldsList = document.getElementById('fill-form-fields-list'); + if (fieldsList) { + fieldsList.innerHTML = + 'Select a PDF with fillable fields to list them here.'; + } + if (openedFilePath) { + const fillFormInput = document.getElementById('fill-form-input-path'); + if (fillFormInput) { + fillFormInput.value = openedFilePath; + setTimeout(() => loadFillFormFields(openedFilePath), 50); + } + } + break; + } } title.textContent = titleText; document.getElementById(sectionId).classList.remove('hidden'); @@ -4112,6 +4129,16 @@ document.addEventListener('DOMContentLoaded', () => { inputId: 'extract-images-output-folder', folder: true, }, + { + id: 'browse-fill-form-input', + inputId: 'fill-form-input-path', + saveDialog: false, + }, + { + id: 'browse-fill-form-output', + inputId: 'fill-form-output-path', + saveDialog: true, + }, ]; browseButtons.forEach((button) => { const btn = document.getElementById(button.id); @@ -4140,6 +4167,9 @@ document.addEventListener('DOMContentLoaded', () => { if (file) { document.getElementById(button.inputId).value = file.path; onPDFFileSelected(button.inputId, file.path); + if (button.inputId === 'fill-form-input-path') { + loadFillFormFields(file.path); + } } }; input.click(); @@ -4231,6 +4261,10 @@ document.addEventListener('DOMContentLoaded', () => { checkbox: 'crop-overwrite', section: 'crop-saveas-section', }, + { + checkbox: 'fill-form-overwrite', + section: 'fill-form-saveas-section', + }, ]; overwriteCheckboxes.forEach((item) => { const checkbox = document.getElementById(item.checkbox); @@ -4285,6 +4319,59 @@ ipcRenderer.on('pdf-page-count', (event, { count, error }) => { document.getElementById('current-page-order').classList.remove('hidden'); document.getElementById('reorder-pages').value = currentOrder; }); + +// Fill Form: request the AcroForm text fields for a selected PDF, then render +// one text input per field so the user can supply values before submitting. +function loadFillFormFields(filePath) { + const container = document.getElementById('fill-form-fields-list'); + if (!container) return; + container.innerHTML = ''; + const loading = document.createElement('small'); + loading.textContent = 'Loading form fields...'; + container.appendChild(loading); + ipcRenderer.send('get-pdf-form-fields', filePath); +} +ipcRenderer.on('pdf-form-fields', (event, result) => { + const container = document.getElementById('fill-form-fields-list'); + if (!container) return; + container.innerHTML = ''; + + if (!result.success) { + const msg = document.createElement('small'); + msg.textContent = `Error reading form fields: ${result.error || 'unknown error'}`; + container.appendChild(msg); + return; + } + + const textFields = (result.fields || []).filter((field) => field.type === 'PDFTextField'); + + if (textFields.length === 0) { + const msg = document.createElement('small'); + msg.textContent = 'No fillable text fields were found in this PDF.'; + container.appendChild(msg); + return; + } + + textFields.forEach((field) => { + const row = document.createElement('div'); + row.className = 'fill-form-field-row'; + + const label = document.createElement('label'); + label.textContent = field.name; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'fill-form-field-input'; + input.dataset.fieldName = field.name; + if (field.value) { + input.value = field.value; + } + + label.appendChild(input); + row.appendChild(label); + container.appendChild(row); + }); +}); function getPDFStatusElement() { return document.getElementById('pdf-status-message'); } @@ -4573,6 +4660,27 @@ function processPDFOperation() { return; } break; + case 'fillForm': + operationData.inputPath = document.getElementById('fill-form-input-path').value.trim(); + operationData.overwrite = document.getElementById('fill-form-overwrite').checked; + operationData.outputPath = operationData.overwrite + ? operationData.inputPath + : document.getElementById('fill-form-output-path').value.trim(); + operationData.flatten = document.getElementById('fill-form-flatten').checked; + operationData.values = {}; + document + .querySelectorAll('#fill-form-fields-list .fill-form-field-input') + .forEach((input) => { + operationData.values[input.dataset.fieldName] = input.value; + }); + if (!operationData.inputPath || !operationData.outputPath) { + showPDFValidationMessage( + 'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'), + '#fill-form-input-path' + ); + return; + } + break; } clearPDFStatus(); // Show progress diff --git a/tests/main/PDFOperations.test.js b/tests/main/PDFOperations.test.js index 289a686..e52ad73 100644 --- a/tests/main/PDFOperations.test.js +++ b/tests/main/PDFOperations.test.js @@ -230,3 +230,158 @@ describe('PDFOperations - Task 15 new operations', () => { }); }); }); + +describe('PDFOperations - Task 16 form field fill/flatten', () => { + let tmpDir, plainInputPath; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfops_form_')); + plainInputPath = path.join(tmpDir, 'plain.pdf'); + + const doc = await PDFDocument.create(); + doc.addPage([600, 800]); + fs.writeFileSync(plainInputPath, await doc.save()); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // Builds a fixture PDF with a real AcroForm text field via pdf-lib's + // form.createTextField() API, mirroring pdf-lib's documented form-creation flow. + async function buildFormPdf(fileName, initialValue = 'John Doe') { + const doc = await PDFDocument.create(); + const page = doc.addPage([600, 800]); + const form = doc.getForm(); + const nameField = form.createTextField('name'); + nameField.setText(initialValue); + nameField.addToPage(page, { x: 50, y: 700, width: 200, height: 20 }); + + const filePath = path.join(tmpDir, fileName); + fs.writeFileSync(filePath, await doc.save()); + return filePath; + } + + describe('pdfGetFormFields', () => { + it('lists text fields with name, type, and current value', async () => { + const formPath = await buildFormPdf('form.pdf', 'John Doe'); + + const result = await PDFOperations.pdfGetFormFields({ inputPath: formPath }); + + expect(result.success).toBe(true); + expect(result.fields).toEqual([{ name: 'name', type: 'PDFTextField', value: 'John Doe' }]); + }); + + it('returns an empty fields array for a PDF with no AcroForm', async () => { + const result = await PDFOperations.pdfGetFormFields({ inputPath: plainInputPath }); + + expect(result.success).toBe(true); + expect(result.fields).toEqual([]); + }); + + it('returns failure for a nonexistent file', async () => { + const result = await PDFOperations.pdfGetFormFields({ + inputPath: path.join(tmpDir, 'missing.pdf'), + }); + + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); + }); + + describe('pdfFillForm', () => { + it('fills a text field with the given value', async () => { + const formPath = await buildFormPdf('form.pdf', ''); + const outputPath = path.join(tmpDir, 'filled.pdf'); + + const result = await PDFOperations.pdfFillForm({ + inputPath: formPath, + outputPath, + values: { name: 'Jane Smith' }, + }); + + expect(result.success).toBe(true); + expect(fs.existsSync(outputPath)).toBe(true); + + const filled = await PDFDocument.load(fs.readFileSync(outputPath)); + expect(filled.getForm().getTextField('name').getText()).toBe('Jane Smith'); + }); + + it('flattens the form when flatten is true, removing editable fields', async () => { + const formPath = await buildFormPdf('form.pdf', ''); + const outputPath = path.join(tmpDir, 'flattened.pdf'); + + const result = await PDFOperations.pdfFillForm({ + inputPath: formPath, + outputPath, + values: { name: 'Jane Smith' }, + flatten: true, + }); + + expect(result.success).toBe(true); + const flattened = await PDFDocument.load(fs.readFileSync(outputPath)); + expect(flattened.getForm().getFields().length).toBe(0); + }); + + it('does not flatten when flatten is false/omitted', async () => { + const formPath = await buildFormPdf('form.pdf', ''); + const outputPath = path.join(tmpDir, 'not-flattened.pdf'); + + const result = await PDFOperations.pdfFillForm({ + inputPath: formPath, + outputPath, + values: { name: 'Jane Smith' }, + }); + + expect(result.success).toBe(true); + const notFlattened = await PDFDocument.load(fs.readFileSync(outputPath)); + expect(notFlattened.getForm().getFields().length).toBe(1); + }); + + it('skips a value for a field that does not exist, continuing with the rest', async () => { + const formPath = await buildFormPdf('form.pdf', ''); + const outputPath = path.join(tmpDir, 'filled-partial.pdf'); + + const result = await PDFOperations.pdfFillForm({ + inputPath: formPath, + outputPath, + values: { name: 'Jane Smith', doesNotExist: 'whatever' }, + }); + + expect(result.success).toBe(true); + const filled = await PDFDocument.load(fs.readFileSync(outputPath)); + expect(filled.getForm().getTextField('name').getText()).toBe('Jane Smith'); + }); + + it('returns failure for a nonexistent input file', async () => { + const result = await PDFOperations.pdfFillForm({ + inputPath: path.join(tmpDir, 'missing.pdf'), + outputPath: path.join(tmpDir, 'out.pdf'), + values: { name: 'X' }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBeTruthy(); + }); + }); + + describe('executeOperation dispatch', () => { + it('dispatches formFields', async () => { + const formPath = await buildFormPdf('form.pdf', 'John Doe'); + const result = await PDFOperations.executeOperation('formFields', { inputPath: formPath }); + expect(result.success).toBe(true); + expect(result.fields.length).toBe(1); + }); + + it('dispatches fillForm', async () => { + const formPath = await buildFormPdf('form.pdf', ''); + const outputPath = path.join(tmpDir, 'dispatch-filled.pdf'); + const result = await PDFOperations.executeOperation('fillForm', { + inputPath: formPath, + outputPath, + values: { name: 'Dispatch Test' }, + }); + expect(result.success).toBe(true); + }); + }); +});