feat(pdf): add bulk PDF operations (watermark/compress/rotate/etc.) to batch converter

This commit is contained in:
2026-08-23 19:31:33 +05:30
parent bc47316746
commit 8a95144bf3
7 changed files with 1777 additions and 1 deletions
+42
View File
@@ -9,6 +9,7 @@ const ImageOperations = require('./main/ImageOperations');
const AudioOperations = require('./main/AudioOperations');
const VideoOperations = require('./main/VideoOperations');
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
const { runPDFBatchOperation } = require('./main/PDFBatchOperations');
const GitOperations = require('./main/GitOperations');
const PdfFontHeader = require('./main/PdfFontHeader');
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
@@ -5054,6 +5055,47 @@ ipcMain.on(
}
);
// ========================================
// BATCH PDF OPERATIONS — apply one PDFOperations.executeOperation() operation
// (watermark, compress, rotate, split, ... — every per-file non-interactive op;
// see PDF_BATCH_OUTPUT_SPEC in src/main/PDFBatchOperations.js for exclusions) to
// every .pdf in a folder, mirroring the runMediaBatchOperation() batch-folder
// pattern above: collect matching files, loop the operation over them with
// per-file progress, then a completion dialog with completed/failed counts.
// ========================================
ipcMain.on(
'batch-pdf-operation',
async (event, { operation, data, inputFolder, outputFolder, includeSubfolders }) => {
if (!conversionLimiter()) {
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
return;
}
await runPDFBatchOperation({
operation,
inputFolder,
outputFolder,
includeSubfolders,
data,
maxFileSize: MAX_FILE_SIZE,
sanitizeError: sanitizeErrorMessage,
onProgress: (progress) => mainWindow.webContents.send('batch-progress', progress),
onComplete: (result) => {
mainWindow.webContents.send('pdf-batch-complete', result);
if (!result.success) return;
const allSucceeded = result.failed === 0;
dialog.showMessageBox(mainWindow, {
type: allSucceeded ? 'info' : 'warning',
title: allSucceeded ? 'Batch PDF Operation Complete' : 'Batch PDF Operation Finished',
message: 'Batch PDF operation finished!',
detail: `Completed: ${result.completed}/${result.total} files${result.failed > 0 ? ` (${result.failed} failed)` : ''}\nOutput: ${result.outputFolder}`,
buttons: ['OK'],
});
},
});
}
);
// IPC Handler for folder selection (for batch image operations)
ipcMain.on('select-image-folder', (event, inputId) => {
const folder = dialog.showOpenDialogSync(mainWindow, {
+177
View File
@@ -0,0 +1,177 @@
/**
* PDFBatchOperations
*
* Applies one PDFOperations.executeOperation() operation (watermark, compress,
* rotate, split, ...) to every .pdf file in an input folder — the PDF sibling of
* runMediaBatchOperation() in main.js (Task 12's image/audio/video batch mode):
* collect matching files via collectFilesByExtension (which generalizes the
* inline collectFiles() of the 'universal-convert-batch' handler), loop the
* operation over them, and report per-file progress plus a final
* completed/failed summary.
*
* Pulled out as its own Electron-free module (same precedent as
* collectFilesByExtension) so the batch loop is unit-testable against real
* pdf-lib fixtures; main.js injects the IPC-facing callbacks:
* onProgress -> mainWindow.webContents.send('batch-progress', ...)
* onComplete -> mainWindow.webContents.send('pdf-batch-complete', ...) + dialog
*
* Not every executeOperation op fits the "apply the same operation to every
* file" batch model. Excluded (enforced by absence from PDF_BATCH_OUTPUT_SPEC,
* which doubles as the defensive backstop for renderer-supplied op names):
* - merge / reorder / fillForm — consume per-file knowledge the batch flow
* cannot supply (merge takes many inputs in one op; reorder needs each
* file's full page order; fillForm's field values differ per file).
* - formFields — a read-only query returning data, not a transform.
* - encrypt / decrypt / permissions — pdf-lib 1.17.1 (bundled) silently
* ignores encryption options in save() and cannot decrypt on load, so a
* batch run would either write unprotected files while reporting success
* or deterministically fail every file.
*
* @module PDFBatchOperations
*/
const fs = require('fs');
const path = require('path');
const PDFOperations = require('./PDFOperations');
const { collectFilesByExtension } = require('./collectFilesByExtension');
// How to derive each output file's path (or directory) from the source file,
// mirroring BATCH_OUTPUT_SPEC in main.js:
// { ext: 'original' } -> <outputFolder>/<relativeDir>/<baseName>.pdf via outputPath
// { ext: 'txt' } -> <outputFolder>/<relativeDir>/<baseName>.txt via outputPath
// { folder: true } -> split writes its `<baseName>_part_N.pdf` files into
// the mirrored output folder via outputFolder
// { dir: true } -> extractImages writes images into a per-PDF
// <outputFolder>/<relativeDir>/<baseName>/ via outputDir
const PDF_BATCH_OUTPUT_SPEC = {
split: { folder: true },
compress: { ext: 'original' },
rotate: { ext: 'original' },
delete: { ext: 'original' },
watermark: { ext: 'original' },
extractText: { ext: 'txt' },
pageNumbers: { ext: 'original' },
crop: { ext: 'original' },
extractImages: { dir: true },
};
/**
* Runs a single PDFOperations operation over every .pdf under `inputFolder`.
*
* @param {object} args
* @param {string} args.operation - executeOperation op name (must be batchable).
* @param {string} args.inputFolder - Folder to scan for .pdf files.
* @param {string} args.outputFolder - Destination folder (created if missing);
* the input folder's relative structure is mirrored beneath it.
* @param {boolean} [args.includeSubfolders=true] - Recurse into subdirectories.
* @param {object} [args.data={}] - Shared operation options forwarded to
* executeOperation for every file (same option shapes as the single-file PDF
* editor dialog; inputPath/outputPath are added per file here).
* @param {number} [args.maxFileSize] - Skip (count as failed) files larger than
* this many bytes, mirroring the batch-convert handler's file-size guard.
* @param {Function} [args.onProgress] - Called with { completed, failed, total,
* currentFile } before each file and once (currentFile: null) at the end —
* the existing 'batch-progress' payload shape.
* @param {Function} args.onComplete - Called exactly once with the outcome:
* { success: false, error } for early failures, otherwise
* { success: true, completed, failed, total, outputFolder }.
* @param {Function} [args.sanitizeError] - Sanitizer for error messages that
* could leak absolute paths (main.js passes sanitizeErrorMessage).
* @returns {Promise<void>}
*/
async function runPDFBatchOperation({
operation,
inputFolder,
outputFolder,
includeSubfolders,
data = {},
maxFileSize,
onProgress = () => {},
onComplete,
sanitizeError = (message) => message,
}) {
const spec = PDF_BATCH_OUTPUT_SPEC[operation];
if (!spec) {
onComplete({
success: false,
error: `Batch mode is not supported for the "${operation}" operation.`,
});
return;
}
if (!inputFolder || !fs.existsSync(inputFolder)) {
onComplete({ success: false, error: 'Input folder does not exist.' });
return;
}
try {
fs.mkdirSync(outputFolder, { recursive: true });
} catch (error) {
onComplete({
success: false,
error: sanitizeError(`Failed to create output folder: ${error.message}`),
});
return;
}
const files = collectFilesByExtension(inputFolder, ['.pdf'], includeSubfolders !== false);
if (files.length === 0) {
onComplete({ success: false, error: 'No matching files found in the selected folder.' });
return;
}
const total = files.length;
let completed = 0;
let failed = 0;
for (const filePath of files) {
onProgress({
completed,
failed,
total,
currentFile: path.basename(filePath),
});
try {
if (maxFileSize && fs.statSync(filePath).size > maxFileSize) {
failed++;
continue;
}
const baseName = path.basename(filePath, path.extname(filePath));
const relativeDir = path.dirname(path.relative(inputFolder, filePath));
const targetDir = relativeDir === '.' ? outputFolder : path.join(outputFolder, relativeDir);
fs.mkdirSync(targetDir, { recursive: true });
const fileData = { ...data, inputPath: filePath };
if (spec.dir) {
fileData.outputDir = path.join(targetDir, baseName);
} else if (spec.folder) {
fileData.outputFolder = targetDir;
} else {
const ext = spec.ext === 'original' ? 'pdf' : spec.ext;
fileData.outputPath = path.join(targetDir, `${baseName}.${ext}`);
}
// PDFOperations ops report failures via { success: false } rather than by
// throwing (each op catches internally), so the result flag — not just
// the promise — decides the per-file outcome.
const result = await PDFOperations.executeOperation(operation, fileData);
if (result && result.success) {
completed++;
} else {
failed++;
}
} catch {
// stat/mkdir can throw (file vanished mid-scan, output path became a
// file, ...): count the file as failed and keep the batch going, matching
// how executeOperation's own failures are handled.
failed++;
}
}
onProgress({ completed, failed, total, currentFile: null });
onComplete({ success: true, completed, failed, total, outputFolder });
}
module.exports = { runPDFBatchOperation, PDF_BATCH_OUTPUT_SPEC };
+2
View File
@@ -87,6 +87,7 @@ const ALLOWED_SEND_CHANNELS = [
'process-pdf-operation',
'get-pdf-page-count',
'select-pdf-folder',
'batch-pdf-operation',
// ASCII generator (separate window)
'open-ascii-generator',
@@ -235,6 +236,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pdf-page-count',
'pdf-operation-complete',
'pdf-operation-error',
'pdf-batch-complete',
// ASCII Art Generator
'show-ascii-generator-window',
+12 -1
View File
@@ -12,6 +12,7 @@ const hljs = require('highlight.js');
const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands');
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
const { showPdfBatchDialog } = require('./renderer/pdf-batch-dialog');
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
@@ -2772,6 +2773,16 @@ ipcRenderer.on('show-universal-converter-dialog', () => {
// Batch converter menu items - open universal converter with batch mode and correct tool
ipcRenderer.on('show-batch-converter', (event, type) => {
if (type === 'pdf') {
// Task 22: the PDF batch menu item now also offers bulk PDF operations
// (watermark/compress/rotate/...). Format conversion stays the default and
// is delegated to the pre-existing universal-converter batch flow.
showPdfBatchDialog({ onConvertFormat: () => openUniversalConverterBatch('pdf') });
return;
}
openUniversalConverterBatch(type);
});
function openUniversalConverterBatch(type) {
showUniversalConverterDialog();
// Map batch type to the appropriate tool
const toolMap = {
@@ -2792,7 +2803,7 @@ ipcRenderer.on('show-batch-converter', (event, type) => {
batchToggle.checked = true;
batchToggle.dispatchEvent(new Event('change'));
}
});
}
ipcRenderer.on('conversion-status', (event, status) => {
document.getElementById('converter-status').textContent = status;
});
+715
View File
@@ -0,0 +1,715 @@
/**
* PDF Batch Dialog
*
* Batch wrapper for the single-file PDF operations (Tasks 15-16 backend via
* PDFOperations.executeOperation): pick one operation (watermark, compress,
* rotate, split, ...) plus that operation's shared option fields — the same
* fields and option shapes the single-file PDF editor dialog in
* renderer.js/index.html already sends — and apply it to every .pdf in an
* input folder. Mirrors the batch-folder construction pattern of the Image/
* Audio/Video Tools dialog (src/renderer/media-operations-dialog.js, Task 12):
* a `.modal`-based dialog driven by ModalManager, folder pickers via the
* existing generic `select-folder` / `folder-selected` IPC channels filtered by
* a unique `type` per picker, and Process firing a fire-and-forget
* `batch-pdf-operation` whose progress/completion arrive via `batch-progress`
* / `pdf-batch-complete` events sent by the `batch-pdf-operation` handler in
* main.js (which delegates to src/main/PDFBatchOperations.js).
*
* The dialog is reached from the Tools > Batch PDF Conversion... menu item
* (`show-batch-converter` with type 'pdf'). Its top selector keeps that menu
* item's existing behavior as the default: "Convert Format" delegates to the
* pre-existing universal-converter batch flow via the `onConvertFormat`
* callback renderer.js passes in; "Bulk PDF Operation" reveals this dialog's
* per-file operation controls.
*
* Only per-file, non-interactive operations are offered (see
* PDF_BATCH_OUTPUT_SPEC in src/main/PDFBatchOperations.js for the exclusions —
* the renderer list and that spec intentionally agree).
*
* @module pdf-batch-dialog
*/
const { ipcRenderer } = require('electron');
// Field names are chosen to exactly match the `data` shape each backend
// operation destructures — see src/main/PDFOperations.js — mirroring the
// single-file PDF editor dialog's collection code in renderer.js. Defaults and
// option lists mirror the corresponding index.html sections.
const BATCH_OPERATIONS = {
watermark: {
label: 'Add Watermark',
fields: [
{ name: 'text', label: 'Watermark Text', type: 'text' },
{
name: 'pages',
label: 'Apply to',
type: 'select',
options: [
{ value: 'all', label: 'All Pages' },
{ value: 'custom', label: 'Custom Pages' },
],
default: 'all',
},
{
name: 'customPages',
label: 'Custom Pages (e.g. 1-5, 7)',
type: 'text',
showIf: { field: 'pages', equals: 'custom' },
},
{
name: 'position',
label: 'Position',
type: 'select',
options: [
'center',
'diagonal',
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
],
default: 'center',
},
{ name: 'fontSize', label: 'Font Size', type: 'number', min: 8, max: 144, default: 48 },
// 0-100 in the UI; divided by 100 before sending, like the single-file dialog.
{ name: 'opacity', label: 'Opacity (0-100)', type: 'number', min: 0, max: 100, default: 30 },
{ name: 'color', label: 'Color', type: 'color', default: '#000000' },
],
},
split: {
label: 'Split',
fields: [
{
name: 'splitMode',
label: 'Split Mode',
type: 'select',
options: [
{ value: 'pages', label: 'By Page Range' },
{ value: 'interval', label: 'Every N Pages' },
{ value: 'size', label: 'By File Size' },
],
default: 'pages',
},
{
name: 'pageRanges',
label: 'Page Ranges (e.g. 1-5, 6-10)',
type: 'text',
showIf: { field: 'splitMode', equals: 'pages' },
},
{
name: 'interval',
label: 'Pages per Split File',
type: 'number',
min: 1,
default: 5,
showIf: { field: 'splitMode', equals: 'interval' },
},
],
},
compress: { label: 'Compress', fields: [] },
rotate: {
label: 'Rotate',
fields: [
{
name: 'angle',
label: 'Rotation Angle',
type: 'select',
options: [
{ value: '90', label: '90° Clockwise' },
{ value: '180', label: '180°' },
{ value: '270', label: '270° Clockwise (90° Counter-clockwise)' },
],
default: '90',
},
{ name: 'pages', label: 'Pages (e.g. 1-3, 5; empty = all)', type: 'text', optional: true },
],
},
delete: {
label: 'Delete Pages',
fields: [{ name: 'pages', label: 'Pages to Delete (e.g. 1-3, 5)', type: 'text' }],
},
extractText: { label: 'Extract Text', fields: [] },
pageNumbers: {
label: 'Add Page Numbers',
fields: [
{
name: 'position',
label: 'Position',
type: 'select',
options: [
'bottom-center',
'bottom-left',
'bottom-right',
'top-center',
'top-left',
'top-right',
],
default: 'bottom-center',
},
{ name: 'startNumber', label: 'Start Number', type: 'number', min: 1, default: 1 },
],
},
crop: {
label: 'Crop Margins',
fields: [
{ name: 'margins.top', label: 'Top Margin', type: 'number', min: 0, default: 0, float: true },
{
name: 'margins.bottom',
label: 'Bottom Margin',
type: 'number',
min: 0,
default: 0,
float: true,
},
{
name: 'margins.left',
label: 'Left Margin',
type: 'number',
min: 0,
default: 0,
float: true,
},
{
name: 'margins.right',
label: 'Right Margin',
type: 'number',
min: 0,
default: 0,
float: true,
},
],
},
extractImages: { label: 'Extract Images', fields: [] },
};
const INPUT_FOLDER_FIELD = { name: 'inputFolder', label: 'Input Folder' };
const OUTPUT_FOLDER_FIELD = { name: 'outputFolder', label: 'Output Folder' };
const SUBFOLDERS_FIELD = { name: 'includeSubfolders', label: 'Include subfolders' };
const INPUT_FOLDER_PICK_TYPE = 'pdf-batch-input-dir';
const OUTPUT_FOLDER_PICK_TYPE = 'pdf-batch-output-dir';
let modalEl = null;
let modalManager = null;
let els = null;
let onConvertFormatCallback = null;
// True between sending a batch-pdf-operation and its pdf-batch-complete; guards
// against firing a second overlapping run (the Process button is also disabled,
// but the batch-type switch re-labels it, so the flag is the real guard).
let runInFlight = false;
function fieldElId(name) {
return `pdf-batch-field-${name}`;
}
function buildDialogDom() {
modalEl = document.createElement('div');
modalEl.id = 'pdf-batch-dialog';
modalEl.className = 'modal hidden';
modalEl.setAttribute('role', 'dialog');
modalEl.setAttribute('aria-modal', 'true');
modalEl.setAttribute('aria-labelledby', 'pdf-batch-title');
modalEl.innerHTML = `
<div class="modal-backdrop" data-close></div>
<div class="modal-content large">
<div class="modal-header">
<h3 id="pdf-batch-title">Batch PDF Tools</h3>
<button class="modal-close" id="pdf-batch-close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="export-section">
<label for="pdf-batch-type">Batch Type:</label>
<select id="pdf-batch-type">
<option value="convert">Convert Format (existing batch converter)</option>
<option value="operation">Bulk PDF Operation</option>
</select>
</div>
<small id="pdf-batch-convert-hint">
Format conversion (PDF to DOCX/HTML/...) uses the existing batch converter.
Choose "Bulk PDF Operation" to watermark, compress, or otherwise process many PDFs at once.
</small>
<div id="pdf-batch-operation-panel" class="hidden">
<div class="export-section">
<label for="pdf-batch-operation">Operation:</label>
<select id="pdf-batch-operation"></select>
</div>
<div id="pdf-batch-operation-fields"></div>
</div>
<div id="pdf-batch-status" class="info-message hidden" aria-live="polite"></div>
<div id="pdf-batch-progress" class="batch-progress hidden">
<div class="progress-bar">
<div class="progress-fill" id="pdf-batch-progress-fill"></div>
</div>
<div class="progress-text">
<span id="pdf-batch-progress-text">Processing...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button id="pdf-batch-cancel" class="btn btn-secondary" data-close>Cancel</button>
<button id="pdf-batch-process" class="btn btn-primary">Open Batch Converter...</button>
</div>
</div>
`;
document.body.appendChild(modalEl);
els = {
typeSelect: modalEl.querySelector('#pdf-batch-type'),
convertHint: modalEl.querySelector('#pdf-batch-convert-hint'),
operationPanel: modalEl.querySelector('#pdf-batch-operation-panel'),
operationSelect: modalEl.querySelector('#pdf-batch-operation'),
fieldsContainer: modalEl.querySelector('#pdf-batch-operation-fields'),
status: modalEl.querySelector('#pdf-batch-status'),
progress: modalEl.querySelector('#pdf-batch-progress'),
progressFill: modalEl.querySelector('#pdf-batch-progress-fill'),
progressText: modalEl.querySelector('#pdf-batch-progress-text'),
processBtn: modalEl.querySelector('#pdf-batch-process'),
};
els.typeSelect.addEventListener('change', updateBatchTypeUI);
els.operationSelect.addEventListener('change', renderOperationFields);
els.processBtn.addEventListener('click', handleProcess);
modalEl.querySelector('#pdf-batch-cancel').addEventListener('click', hideDialog);
modalManager = new window.ModalManager(modalEl);
// Generic output-folder picker reply (shared with the batch converter's
// input/output folder pickers) — filter by our own `type` so we only react
// to requests this dialog made.
ipcRenderer.on('folder-selected', (event, { type, path: folderPath }) => {
if (!folderPath) return;
const fieldName =
type === INPUT_FOLDER_PICK_TYPE
? INPUT_FOLDER_FIELD.name
: type === OUTPUT_FOLDER_PICK_TYPE
? OUTPUT_FOLDER_FIELD.name
: null;
if (!fieldName) return;
const input = document.getElementById(fieldElId(fieldName));
if (input) input.value = folderPath;
});
// Batch operation progress/completion (main.js: batch-pdf-operation handler,
// delegating to runPDFBatchOperation() in src/main/PDFBatchOperations.js).
ipcRenderer.on('batch-progress', (event, { completed, failed, total, currentFile }) => {
if (!els.progress || els.progress.classList.contains('hidden')) return;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
els.progressFill.style.width = `${pct}%`;
els.progressText.textContent = currentFile
? `Processing ${completed + 1}/${total}: ${currentFile}${failed ? ` (${failed} failed so far)` : ''}`
: `Processed ${completed}/${total}${failed ? ` (${failed} failed)` : ''}`;
});
ipcRenderer.on('pdf-batch-complete', (event, { success, completed, failed, total, error }) => {
runInFlight = false;
hideProgress();
if (success) {
showStatus(
`Batch complete: ${completed}/${total} file(s) processed${failed ? ` (${failed} failed)` : ''}.`,
failed > 0 ? 'warning' : 'success'
);
} else {
showStatus(`Error: ${error || 'Batch operation failed.'}`, 'warning');
}
});
}
function ensureDialog() {
if (!modalEl) {
buildDialogDom();
}
}
function clearStatus() {
els.status.textContent = '';
els.status.classList.remove('info-message', 'warning-message', 'success-message');
els.status.classList.add('hidden');
}
function showStatus(message, type = 'info') {
els.status.textContent = message;
els.status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message');
els.status.classList.add(`${type}-message`);
}
function showProgress() {
els.progress.classList.remove('hidden');
els.progressText.textContent = 'Scanning folder...';
els.progressFill.style.width = '0%';
els.processBtn.disabled = true;
}
function hideProgress() {
els.progress.classList.add('hidden');
els.progressFill.style.width = '0%';
els.processBtn.disabled = false;
}
function optionValue(option) {
return typeof option === 'string' ? option : option.value;
}
function optionLabel(option) {
return typeof option === 'string' ? option : option.label;
}
function createLabeledWrapper(field) {
const wrapper = document.createElement('div');
wrapper.className = 'export-section';
wrapper.id = `${fieldElId(field.name)}-wrapper`;
const label = document.createElement('label');
label.setAttribute('for', fieldElId(field.name));
label.textContent = `${field.label}:`;
wrapper.appendChild(label);
return wrapper;
}
function renderTextField(field) {
const wrapper = createLabeledWrapper(field);
const input = document.createElement('input');
input.type = 'text';
input.id = fieldElId(field.name);
input.placeholder = field.placeholder || '';
wrapper.appendChild(input);
return wrapper;
}
function renderNumberField(field) {
const wrapper = createLabeledWrapper(field);
const input = document.createElement('input');
input.type = 'number';
input.id = fieldElId(field.name);
if (field.min !== undefined) input.min = field.min;
if (field.max !== undefined) input.max = field.max;
if (field.default !== undefined) input.value = field.default;
wrapper.appendChild(input);
return wrapper;
}
function renderSelectField(field) {
const wrapper = createLabeledWrapper(field);
const select = document.createElement('select');
select.id = fieldElId(field.name);
field.options.forEach((opt) => {
const option = document.createElement('option');
option.value = optionValue(opt);
option.textContent = optionLabel(opt);
if (optionValue(opt) === field.default) option.selected = true;
select.appendChild(option);
});
// Attached to the select itself (not via a bubbling container listener) so
// conditional fields update regardless of how the change event was fired.
select.addEventListener('change', updateConditionalFields);
wrapper.appendChild(select);
return wrapper;
}
function renderColorField(field) {
const wrapper = createLabeledWrapper(field);
const input = document.createElement('input');
input.type = 'color';
input.id = fieldElId(field.name);
input.value = field.default || '#000000';
wrapper.appendChild(input);
return wrapper;
}
function createFolderPickerField(field, pickType) {
const wrapper = createLabeledWrapper(field);
const group = document.createElement('div');
group.className = 'folder-input-group';
const input = document.createElement('input');
input.type = 'text';
input.id = fieldElId(field.name);
input.placeholder = 'Choose...';
input.readOnly = true;
group.appendChild(input);
const browseBtn = document.createElement('button');
browseBtn.type = 'button';
browseBtn.textContent = 'Browse Folder';
browseBtn.addEventListener('click', () => {
ipcRenderer.send('select-folder', pickType);
});
group.appendChild(browseBtn);
wrapper.appendChild(group);
return wrapper;
}
function renderCheckboxField(field) {
const wrapper = document.createElement('div');
wrapper.className = 'export-section';
wrapper.id = `${fieldElId(field.name)}-wrapper`;
const label = document.createElement('label');
label.setAttribute('for', fieldElId(field.name));
const input = document.createElement('input');
input.type = 'checkbox';
input.id = fieldElId(field.name);
input.checked = true;
input.style.marginRight = '0.5em';
label.appendChild(input);
label.appendChild(document.createTextNode(field.label));
wrapper.appendChild(label);
return wrapper;
}
function fieldIsVisible(field, values) {
if (!field.showIf) return true;
return values[field.showIf.field] === field.showIf.equals;
}
// Toggles the hidden class of conditionally-shown fields (watermark custom
// pages, split ranges/interval) based on the current select values.
function updateConditionalFields() {
const opConfig = BATCH_OPERATIONS[els.operationSelect.value];
if (!opConfig) return;
const values = {};
opConfig.fields.forEach((field) => {
const input = document.getElementById(fieldElId(field.name));
if (input) values[field.name] = input.value;
});
opConfig.fields.forEach((field) => {
if (!field.showIf) return;
const wrapper = document.getElementById(`${fieldElId(field.name)}-wrapper`);
if (wrapper) wrapper.classList.toggle('hidden', !fieldIsVisible(field, values));
});
}
function renderOperationFields() {
const opConfig = BATCH_OPERATIONS[els.operationSelect.value];
els.fieldsContainer.innerHTML = '';
els.fieldsContainer.appendChild(
createFolderPickerField(INPUT_FOLDER_FIELD, INPUT_FOLDER_PICK_TYPE)
);
els.fieldsContainer.appendChild(renderCheckboxField(SUBFOLDERS_FIELD));
opConfig.fields.forEach((field) => {
let fieldEl;
switch (field.type) {
case 'text':
fieldEl = renderTextField(field);
break;
case 'number':
fieldEl = renderNumberField(field);
break;
case 'select':
fieldEl = renderSelectField(field);
break;
case 'color':
fieldEl = renderColorField(field);
break;
default:
return;
}
els.fieldsContainer.appendChild(fieldEl);
});
els.fieldsContainer.appendChild(
createFolderPickerField(OUTPUT_FOLDER_FIELD, OUTPUT_FOLDER_PICK_TYPE)
);
updateConditionalFields();
}
function updateBatchTypeUI() {
const isConvert = els.typeSelect.value === 'convert';
els.convertHint.classList.toggle('hidden', !isConvert);
els.operationPanel.classList.toggle('hidden', isConvert);
els.processBtn.textContent = isConvert ? 'Open Batch Converter...' : 'Process';
// Progress state is left alone here: clearing it mid-run would re-enable the
// Process button while a batch is still in flight (runInFlight guards that).
clearStatus();
}
// Reads the shared option fields into the exact `data` shape the backend
// operation destructures, dropping conditionally-hidden fields and applying
// per-op conversions (opacity /100, margins grouping) exactly like the
// single-file PDF editor dialog does.
function collectOperationData(opKey) {
const opConfig = BATCH_OPERATIONS[opKey];
const raw = {};
for (const field of opConfig.fields) {
const input = document.getElementById(fieldElId(field.name));
if (!input) continue;
if (field.type === 'number') {
const num = field.float ? parseFloat(input.value) : parseInt(input.value, 10);
raw[field.name] = Number.isFinite(num) ? num : null;
} else {
raw[field.name] = input.value.trim();
}
}
const visible = (name) =>
fieldIsVisible(
opConfig.fields.find((f) => f.name === name),
raw
);
let data;
switch (opKey) {
case 'watermark':
data = {
text: raw.text,
fontSize: raw.fontSize,
opacity: raw.opacity !== null ? raw.opacity / 100 : null,
position: raw.position,
color: raw.color,
pages: raw.pages,
};
if (raw.pages === 'custom' && visible('customPages')) {
data.customPages = raw.customPages;
}
break;
case 'split':
data = { splitMode: raw.splitMode };
if (raw.splitMode === 'pages' && visible('pageRanges')) data.pageRanges = raw.pageRanges;
if (raw.splitMode === 'interval' && visible('interval')) data.interval = raw.interval;
break;
case 'rotate':
data = { angle: parseInt(raw.angle, 10), pages: raw.pages };
break;
case 'delete':
data = { pages: raw.pages };
break;
case 'pageNumbers':
data = { position: raw.position, startNumber: raw.startNumber };
break;
case 'crop':
data = {
margins: {
top: raw['margins.top'],
bottom: raw['margins.bottom'],
left: raw['margins.left'],
right: raw['margins.right'],
},
};
break;
default:
data = {};
}
return { data };
}
// Per-operation required-field checks, mirroring the single-file dialog's
// validation messages.
function validateOperationData(opKey, data) {
switch (opKey) {
case 'watermark':
if (!data.text) return 'Enter watermark text.';
if (!data.fontSize) return 'Enter a font size.';
if (data.pages === 'custom' && !data.customPages) {
return 'Enter the custom pages to watermark.';
}
return null;
case 'split':
if (data.splitMode === 'pages' && !data.pageRanges) {
return 'Enter page ranges (e.g. 1-5, 6-10).';
}
if (data.splitMode === 'interval' && !data.interval) {
return 'Enter the number of pages per split file.';
}
return null;
case 'delete':
if (!data.pages) return 'Enter the pages to delete (e.g. 1-3, 5).';
return null;
default:
return null;
}
}
function handleConvertProcess() {
hideDialog();
if (typeof onConvertFormatCallback === 'function') {
onConvertFormatCallback();
}
}
function handleBulkProcess() {
if (runInFlight) return;
const inputFolder = document.getElementById(fieldElId(INPUT_FOLDER_FIELD.name))?.value;
const outputFolder = document.getElementById(fieldElId(OUTPUT_FOLDER_FIELD.name))?.value;
const includeSubfolders =
document.getElementById(fieldElId(SUBFOLDERS_FIELD.name))?.checked !== false;
if (!inputFolder) {
showStatus('Select an input folder.', 'warning');
return;
}
if (!outputFolder) {
showStatus('Select an output folder.', 'warning');
return;
}
const opKey = els.operationSelect.value;
const { data } = collectOperationData(opKey);
const error = validateOperationData(opKey, data);
if (error) {
showStatus(error, 'warning');
return;
}
clearStatus();
showProgress();
runInFlight = true;
ipcRenderer.send('batch-pdf-operation', {
operation: opKey,
inputFolder,
outputFolder,
includeSubfolders,
data,
});
}
function handleProcess() {
if (els.typeSelect.value === 'convert') {
handleConvertProcess();
} else {
handleBulkProcess();
}
}
function hideDialog() {
if (modalManager) modalManager.close();
clearStatus();
hideProgress();
}
/**
* Opens the Batch PDF Tools dialog.
*
* @param {object} [options]
* @param {Function} [options.onConvertFormat] - Invoked when the user keeps the
* default "Convert Format" batch type; renderer.js passes the pre-existing
* universal-converter batch flow that the Tools > Batch PDF Conversion...
* menu item used to open directly.
*/
function showPdfBatchDialog(options = {}) {
ensureDialog();
onConvertFormatCallback = options.onConvertFormat || null;
els.operationSelect.innerHTML = '';
Object.entries(BATCH_OPERATIONS).forEach(([key, op]) => {
const option = document.createElement('option');
option.value = key;
option.textContent = op.label;
els.operationSelect.appendChild(option);
});
els.typeSelect.value = 'convert';
runInFlight = false;
clearStatus();
hideProgress();
updateBatchTypeUI();
renderOperationFields();
modalManager.open();
}
module.exports = { showPdfBatchDialog, BATCH_OPERATIONS };