Files
markdown-converter/src/main/PDFOperations.js
T
amitwh 363de75375 fix(pdf): guard pdfSplit against non-positive interval infinite loop
The interval split mode looped for (i = 0; i < totalPages; i += interval),
which spins forever when interval <= 0. Both the single-file dialog and the
batch dialog can reach it (the batch dialog's validateOperationData only
checks truthiness, so -1 passes). Guard at the source in the main process:
reject non-positive or non-integer intervals before the loop, protecting
both paths and any future caller.

Amit Haridas
2026-08-23 19:31:33 +05:30

761 lines
24 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
// pdf-lib 1.17.1 cannot encrypt: SaveOptions has no userPassword/ownerPassword/
// permissions fields, so save() silently ignores them and writes an unprotected
// file, and PDFDocument.load() cannot open password-protected input (verified
// empirically in Task 22's review). Rather than trusting a pinned version
// string, probe the installed library once at module load: save a tiny
// in-memory document with a userPassword and check the raw bytes for an
// /Encrypt dictionary (which an unencrypted document never contains). A library
// that supports encryption passes the probe and the password ops re-enable
// automatically. Probe errors fail closed (treated as unsupported).
const pdfEncryptionSupported = (async () => {
try {
const probeDoc = await PDFDocument.create();
const probeBytes = await probeDoc.save({ userPassword: 'encryption-capability-probe' });
return Buffer.from(probeBytes).includes('/Encrypt');
} catch {
return false;
}
})();
// Returned by the password ops when the probe reports no encryption support
// (Task 27): fail honestly instead of silently writing an unprotected file.
const PDF_ENCRYPTION_UNAVAILABLE_MESSAGE =
'Password protection is not available in this build (pdf-lib lacks encryption support).';
function parsePageRanges(rangeString, totalPages) {
const pages = [];
const ranges = rangeString.split(',').map((r) => r.trim());
for (const range of ranges) {
if (range.includes('-')) {
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let i = start; i <= end && i <= totalPages; i++) {
if (i > 0 && !pages.includes(i - 1)) {
pages.push(i - 1);
}
}
} else {
const page = parseInt(range);
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
pages.push(page - 1);
}
}
}
return pages.sort((a, b) => a - b);
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 };
}
async function pdfMerge(data) {
try {
const mergedPdf = await PDFDocument.create();
for (const filePath of data.inputFiles) {
const pdfBytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
const pdfBytes = await mergedPdf.save();
fs.writeFileSync(data.outputPath, pdfBytes);
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfSplit(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
const splits = [];
if (data.splitMode === 'pages') {
const ranges = data.pageRanges.split(',').map((r) => r.trim());
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
const pages = [];
if (range.includes('-')) {
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let p = start; p <= end && p <= totalPages; p++) {
pages.push(p - 1);
}
} else {
const page = parseInt(range);
if (page > 0 && page <= totalPages) {
pages.push(page - 1);
}
}
if (pages.length > 0) {
splits.push({ pages, name: `part_${i + 1}` });
}
}
} else if (data.splitMode === 'interval') {
const interval = data.interval;
if (!Number.isInteger(interval) || interval <= 0) {
return { success: false, message: 'Split interval must be a positive integer.' };
}
for (let i = 0; i < totalPages; i += interval) {
const pages = [];
for (let j = i; j < i + interval && j < totalPages; j++) {
pages.push(j);
}
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
}
} else if (data.splitMode === 'size') {
const chunkSize = Math.max(1, Math.floor(totalPages / 5));
for (let i = 0; i < totalPages; i += chunkSize) {
const pages = [];
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
pages.push(j);
}
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
}
}
const baseName = path.basename(data.inputPath, '.pdf');
for (const split of splits) {
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, split.pages);
copiedPages.forEach((page) => newPdf.addPage(page));
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
const newPdfBytes = await newPdf.save();
fs.writeFileSync(outputPath, newPdfBytes);
}
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfCompress(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const compressedPdfBytes = await pdf.save({
useObjectStreams: true,
addDefaultPage: false,
objectsPerTick: 50,
});
fs.writeFileSync(data.outputPath, compressedPdfBytes);
const originalSize = fs.statSync(data.inputPath).size;
const compressedSize = fs.statSync(data.outputPath).size;
const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
return {
success: true,
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
};
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfRotate(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
let pagesToRotate = [];
if (data.pages && data.pages.trim()) {
pagesToRotate = parsePageRanges(data.pages, totalPages);
} else {
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
}
pagesToRotate.forEach((pageIndex) => {
const page = pdf.getPage(pageIndex);
page.setRotation(degrees(data.angle));
});
const rotatedPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
return {
success: true,
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
};
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfDeletePages(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
const pagesToDelete = parsePageRanges(data.pages, totalPages);
pagesToDelete
.sort((a, b) => b - a)
.forEach((pageIndex) => {
pdf.removePage(pageIndex);
});
const newPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, newPdfBytes);
return {
success: true,
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
};
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfReorder(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
if (newOrder.length !== totalPages) {
return { success: false, error: `New order must include all ${totalPages} pages` };
}
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, newOrder);
copiedPages.forEach((page) => newPdf.addPage(page));
const reorderedPdfBytes = await newPdf.save();
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
return { success: true, message: 'Successfully reordered PDF pages' };
} catch (error) {
return { success: false, error: error.message };
}
}
// Shared corner/center coordinate mapping used by pdfWatermark and pdfAddPageNumbers.
function resolvePosition(position, width, height, margin = 50) {
switch (position) {
case 'center':
return { x: width / 2, y: height / 2 };
case 'diagonal':
return { x: width / 2, y: height / 2 };
case 'top-left':
return { x: margin, y: height - margin };
case 'top-center':
return { x: width / 2, y: height - margin };
case 'top-right':
return { x: width - margin, y: height - margin };
case 'bottom-left':
return { x: margin, y: margin };
case 'bottom-center':
return { x: width / 2, y: margin };
case 'bottom-right':
return { x: width - margin, y: margin };
default:
return { x: width / 2, y: height / 2 };
}
}
async function pdfWatermark(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
let pagesToWatermark = [];
if (data.pages === 'all') {
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
} else if (data.pages === 'custom' && data.customPages) {
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
}
const font = await pdf.embedFont(StandardFonts.Helvetica);
const color = hexToRgb(data.color);
for (const pageIndex of pagesToWatermark) {
const page = pdf.getPage(pageIndex);
const { width, height } = page.getSize();
const { x, y } = resolvePosition(data.position, width, height, 50);
const rotation = data.position === 'diagonal' ? 45 : 0;
page.drawText(data.text, {
x,
y,
size: data.fontSize,
font,
color: rgb(color.r, color.g, color.b),
opacity: data.opacity,
rotate: degrees(rotation),
});
}
const watermarkedPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
return {
success: true,
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
};
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfEncrypt(data) {
if (!(await pdfEncryptionSupported)) {
return { success: false, message: PDF_ENCRYPTION_UNAVAILABLE_MESSAGE };
}
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const encryptedPdfBytes = await pdf.save({
userPassword: data.userPassword,
ownerPassword: data.ownerPassword || data.userPassword,
permissions: {
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
modifying: data.permissions.modifying,
copying: data.permissions.copying,
annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly,
},
});
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
return { success: true, message: 'Successfully added password protection to PDF' };
} catch (error) {
if (error.message.includes('encrypt') || error.message.includes('password')) {
return {
success: false,
error:
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
};
}
return { success: false, error: error.message };
}
}
async function pdfDecrypt(data) {
if (!(await pdfEncryptionSupported)) {
return { success: false, message: PDF_ENCRYPTION_UNAVAILABLE_MESSAGE };
}
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
const decryptedPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
return { success: true, message: 'Successfully removed password protection from PDF' };
} catch (error) {
if (error.message.includes('password') || error.message.includes('encrypted')) {
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
}
return { success: false, error: error.message };
}
}
async function pdfSetPermissions(data) {
if (!(await pdfEncryptionSupported)) {
return { success: false, message: PDF_ENCRYPTION_UNAVAILABLE_MESSAGE };
}
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
const newPdfBytes = await pdf.save({
ownerPassword: data.ownerPassword,
permissions: {
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
modifying: data.permissions.modifying,
copying: data.permissions.copying,
annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly,
},
});
fs.writeFileSync(data.outputPath, newPdfBytes);
return { success: true, message: 'Successfully updated PDF permissions' };
} catch (error) {
if (error.message.includes('encrypt') || error.message.includes('permission')) {
return {
success: false,
error:
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
};
}
return { success: false, error: error.message };
}
}
// pdf-lib has no text-extraction API, so this loads pdfjs-dist's Node-friendly
// "legacy" build (the standard build assumes DOM globals like DOMMatrix).
// pdfjs-dist v5.x ships ESM-only, so it must be loaded via dynamic import()
// even from this CommonJS module.
async function loadPdfjs() {
return import('pdfjs-dist/legacy/build/pdf.mjs');
}
// Points pdfjs-dist at its bundled standard font metrics so it doesn't warn
// (and degrade text-extraction fidelity) when a PDF uses a standard font.
function getStandardFontDataUrl() {
return (
path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep
);
}
async function pdfExtractText(data) {
try {
const pdfjsLib = await loadPdfjs();
const fileData = new Uint8Array(fs.readFileSync(data.inputPath));
const pdf = await pdfjsLib.getDocument({
data: fileData,
standardFontDataUrl: getStandardFontDataUrl(),
}).promise;
let text = '';
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const content = await page.getTextContent();
const pageText = content.items.map((item) => item.str).join(' ');
text += pageText + '\n';
}
const trimmedText = text.trim();
const result = { success: true, text: trimmedText };
// outputPath is optional: when provided (e.g. from the PDF editor UI),
// also save the extracted text to disk and report where it went.
if (data.outputPath) {
fs.writeFileSync(data.outputPath, trimmedText, 'utf8');
result.message = `Successfully extracted text to ${data.outputPath}`;
}
return result;
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfAddPageNumbers(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const position = data.position || 'bottom-center';
const fontSize = data.fontSize || 12;
const startNumber = data.startNumber && data.startNumber > 0 ? data.startNumber : 1;
for (let i = 0; i < totalPages; i++) {
const page = pdf.getPage(i);
const { width, height } = page.getSize();
const { x, y } = resolvePosition(position, width, height, 30);
const label = String(startNumber + i);
const textWidth = font.widthOfTextAtSize(label, fontSize);
let drawX = x;
if (position.includes('center')) {
drawX = x - textWidth / 2;
} else if (position.includes('right')) {
drawX = x - textWidth;
}
page.drawText(label, {
x: drawX,
y,
size: fontSize,
font,
color: rgb(0, 0, 0),
});
}
const newPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, newPdfBytes);
return { success: true, message: `Successfully added page numbers to ${totalPages} page(s)` };
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfCrop(data) {
try {
const pdfBytes = fs.readFileSync(data.inputPath);
const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount();
const margins = data.margins || {};
const top = margins.top || 0;
const bottom = margins.bottom || 0;
const left = margins.left || 0;
const right = margins.right || 0;
for (let i = 0; i < totalPages; i++) {
const page = pdf.getPage(i);
const mediaBox = page.getMediaBox();
const newWidth = mediaBox.width - left - right;
const newHeight = mediaBox.height - top - bottom;
if (newWidth <= 0 || newHeight <= 0) {
return { success: false, error: `Crop margins are too large for page ${i + 1}` };
}
page.setCropBox(mediaBox.x + left, mediaBox.y + bottom, newWidth, newHeight);
}
const croppedPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, croppedPdfBytes);
return { success: true, message: `Successfully cropped ${totalPages} page(s)` };
} catch (error) {
return { success: false, error: error.message };
}
}
async function pdfExtractImages(data) {
try {
const pdfjsLib = await loadPdfjs();
// sharp is only needed here; require lazily to match the module's existing
// pattern of not pulling heavy optional deps in until an operation runs.
const sharp = require('sharp');
const fileData = new Uint8Array(fs.readFileSync(data.inputPath));
const pdf = await pdfjsLib.getDocument({
data: fileData,
standardFontDataUrl: getStandardFontDataUrl(),
}).promise;
if (!fs.existsSync(data.outputDir)) {
fs.mkdirSync(data.outputDir, { recursive: true });
}
const baseName = path.basename(data.inputPath, path.extname(data.inputPath));
const files = [];
let imageIndex = 0;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const opList = await page.getOperatorList();
for (let i = 0; i < opList.fnArray.length; i++) {
if (opList.fnArray[i] !== pdfjsLib.OPS.paintImageXObject) {
continue;
}
const objId = opList.argsArray[i][0];
try {
const imgObj = await new Promise((resolve) => page.objs.get(objId, resolve));
if (!imgObj || !imgObj.data || !imgObj.width || !imgObj.height) {
continue;
}
const channels =
imgObj.kind === pdfjsLib.ImageKind.RGBA_32BPP
? 4
: imgObj.kind === pdfjsLib.ImageKind.GRAYSCALE_1BPP
? 1
: 3;
imageIndex++;
const outputFile = path.join(
data.outputDir,
`${baseName}_page${pageNum}_img${imageIndex}.png`
);
await sharp(Buffer.from(imgObj.data), {
raw: { width: imgObj.width, height: imgObj.height, channels },
})
.png()
.toFile(outputFile);
files.push(outputFile);
} catch {
// Skip images pdfjs/sharp can't decode (e.g. unsupported color spaces).
continue;
}
}
}
return {
success: true,
count: files.length,
files,
message: `Successfully extracted ${files.length} image(s)`,
};
} catch (error) {
return { success: false, error: error.message };
}
}
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':
return pdfMerge(data);
case 'split':
return pdfSplit(data);
case 'compress':
return pdfCompress(data);
case 'rotate':
return pdfRotate(data);
case 'delete':
return pdfDeletePages(data);
case 'reorder':
return pdfReorder(data);
case 'watermark':
return pdfWatermark(data);
case 'encrypt':
return pdfEncrypt(data);
case 'decrypt':
return pdfDecrypt(data);
case 'permissions':
return pdfSetPermissions(data);
case 'extractText':
return pdfExtractText(data);
case 'pageNumbers':
return pdfAddPageNumbers(data);
case 'crop':
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}` });
}
}
async function getPageCount(filePath) {
const pdfBytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(pdfBytes);
return pdf.getPageCount();
}
module.exports = {
parsePageRanges,
hexToRgb,
pdfEncryptionSupported,
PDF_ENCRYPTION_UNAVAILABLE_MESSAGE,
pdfMerge,
pdfSplit,
pdfCompress,
pdfRotate,
pdfDeletePages,
pdfReorder,
pdfWatermark,
pdfEncrypt,
pdfDecrypt,
pdfSetPermissions,
pdfExtractText,
pdfAddPageNumbers,
pdfCrop,
pdfExtractImages,
pdfGetFormFields,
pdfFillForm,
executeOperation,
getPageCount,
};