mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
feat(pdf): add bulk PDF operations (watermark/compress/rotate/etc.) to batch converter
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user