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
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 2334ab30ed
commit 44624cd4bf
5 changed files with 397 additions and 0 deletions
+46
View File
@@ -2111,6 +2111,52 @@
</div>
</div>
<!-- Fill Form Section -->
<div id="pdf-fill-form-section" class="pdf-operation-section hidden">
<div class="export-section">
<label>PDF File:</label>
<div class="folder-input-group">
<input
type="text"
id="fill-form-input-path"
placeholder="Select PDF file..."
readonly
/>
<button id="browse-fill-form-input">Browse</button>
</div>
</div>
<div class="export-section">
<label>Form Fields:</label>
<div id="fill-form-fields-list" class="fill-form-fields-list">
<small>Select a PDF with fillable fields to list them here.</small>
</div>
</div>
<div class="export-section">
<label class="checkbox-inline"
><input type="checkbox" id="fill-form-flatten" /> Flatten after fill (makes
fields non-editable)</label
>
</div>
<div class="export-section">
<label class="checkbox-inline"
><input type="checkbox" id="fill-form-overwrite" /> Overwrite original
file</label
>
</div>
<div class="export-section" id="fill-form-saveas-section">
<label>Save As:</label>
<div class="folder-input-group">
<input
type="text"
id="fill-form-output-path"
placeholder="Select save location..."
readonly
/>
<button id="browse-fill-form-output">Save As</button>
</div>
</div>
</div>
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
<!-- Progress indicator -->
+18
View File
@@ -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) => {
+70
View File
@@ -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,
};
+108
View File
@@ -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 =
'<small>Select a PDF with fillable fields to list them here.</small>';
}
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