feat(pdf): add extract text, page numbers, crop, extract images operations

Adds four new PDFOperations: pdfExtractText (pdfjs-dist getTextContent),
pdfAddPageNumbers (reuses pdfWatermark's position-mapping logic, extracted
into a shared resolvePosition helper), pdfCrop (page.setCropBox against the
existing MediaBox), and pdfExtractImages (pdfjs-dist operator list +
paintImageXObject + sharp). Wired into executeOperation's switch and the PDF
editor dialog UI (4 new sections/toolbar buttons/menu items) with no new IPC
channel needed.

pdfjs-dist v5 is ESM-only, so it's loaded via dynamic import() of its
Node-friendly legacy build; Jest needs --experimental-vm-modules to support
that, so the test scripts now set NODE_OPTIONS accordingly via cross-env.

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent abcfb03e52
commit 2334ab30ed
6 changed files with 867 additions and 46 deletions
+3 -3
View File
@@ -5,9 +5,9 @@
"main": "src/main.js",
"scripts": {
"start": "electron .",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
"test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch",
"test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"lint": "eslint src tests",
"lint:fix": "eslint src tests --fix",
"format": "prettier --write src tests",
+217
View File
@@ -1953,6 +1953,164 @@
</div>
</div>
<!-- Extract Text Section -->
<div id="pdf-extract-text-section" class="pdf-operation-section hidden">
<div class="export-section">
<label>PDF File:</label>
<div class="folder-input-group">
<input
type="text"
id="extract-text-input-path"
placeholder="Select PDF file..."
readonly
/>
<button id="browse-extract-text-input">Browse</button>
</div>
</div>
<div class="export-section">
<label>Save Extracted Text As:</label>
<div class="folder-input-group">
<input
type="text"
id="extract-text-output-path"
placeholder="Select save location..."
readonly
/>
<button id="browse-extract-text-output">Save As</button>
</div>
<small>Text from every page is saved to a single .txt file</small>
</div>
</div>
<!-- Page Numbers Section -->
<div id="pdf-page-numbers-section" class="pdf-operation-section hidden">
<div class="export-section">
<label>PDF File:</label>
<div class="folder-input-group">
<input
type="text"
id="page-numbers-input-path"
placeholder="Select PDF file..."
readonly
/>
<button id="browse-page-numbers-input">Browse</button>
</div>
</div>
<div class="export-section">
<label>Position:</label>
<select id="page-numbers-position">
<option value="bottom-center" selected>Bottom Center</option>
<option value="bottom-left">Bottom Left</option>
<option value="bottom-right">Bottom Right</option>
<option value="top-center">Top Center</option>
<option value="top-left">Top Left</option>
<option value="top-right">Top Right</option>
</select>
</div>
<div class="export-section">
<label>Start Number:</label>
<input type="number" id="page-numbers-start" min="1" value="1" placeholder="1" />
</div>
<div class="export-section">
<label class="checkbox-inline"
><input type="checkbox" id="page-numbers-overwrite" /> Overwrite original
file</label
>
</div>
<div class="export-section" id="page-numbers-saveas-section">
<label>Save As:</label>
<div class="folder-input-group">
<input
type="text"
id="page-numbers-output-path"
placeholder="Select save location..."
readonly
/>
<button id="browse-page-numbers-output">Save As</button>
</div>
</div>
</div>
<!-- Crop Pages Section -->
<div id="pdf-crop-section" class="pdf-operation-section hidden">
<div class="export-section">
<label>PDF File:</label>
<div class="folder-input-group">
<input
type="text"
id="crop-input-path"
placeholder="Select PDF file..."
readonly
/>
<button id="browse-crop-input">Browse</button>
</div>
</div>
<div class="export-section">
<label>Crop Margins (points):</label>
<div class="checkbox-group">
<label
>Top: <input type="number" id="crop-margin-top" min="0" value="0"
/></label>
<label
>Bottom: <input type="number" id="crop-margin-bottom" min="0" value="0"
/></label>
<label
>Left: <input type="number" id="crop-margin-left" min="0" value="0"
/></label>
<label
>Right: <input type="number" id="crop-margin-right" min="0" value="0"
/></label>
</div>
<small>1 point = 1/72 inch. Margins are trimmed from each edge.</small>
</div>
<div class="export-section">
<label class="checkbox-inline"
><input type="checkbox" id="crop-overwrite" /> Overwrite original file</label
>
</div>
<div class="export-section" id="crop-saveas-section">
<label>Save As:</label>
<div class="folder-input-group">
<input
type="text"
id="crop-output-path"
placeholder="Select save location..."
readonly
/>
<button id="browse-crop-output">Save As</button>
</div>
</div>
</div>
<!-- Extract Images Section -->
<div id="pdf-extract-images-section" class="pdf-operation-section hidden">
<div class="export-section">
<label>PDF File:</label>
<div class="folder-input-group">
<input
type="text"
id="extract-images-input-path"
placeholder="Select PDF file..."
readonly
/>
<button id="browse-extract-images-input">Browse</button>
</div>
</div>
<div class="export-section">
<label>Output Folder:</label>
<div class="folder-input-group">
<input
type="text"
id="extract-images-output-folder"
placeholder="Select output folder..."
readonly
/>
<button id="browse-extract-images-output">Browse</button>
</div>
<small>Each embedded image is saved as a separate PNG file</small>
</div>
</div>
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
<!-- Progress indicator -->
@@ -2402,6 +2560,65 @@
</svg>
<span>Decrypt</span>
</button>
<button id="pdf-tb-extract-text" class="pdf-editor-btn" title="Extract Text">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="8" y1="13" x2="16" y2="13"></line>
<line x1="8" y1="17" x2="16" y2="17"></line>
</svg>
<span>Extract Text</span>
</button>
<button id="pdf-tb-page-numbers" class="pdf-editor-btn" title="Add Page Numbers">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="1"></rect>
<text x="8" y="17" font-size="10" stroke="none" fill="currentColor">#</text>
</svg>
<span>Page #s</span>
</button>
<button id="pdf-tb-crop" class="pdf-editor-btn" title="Crop Pages">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 2v14a2 2 0 002 2h14"></path>
<path d="M18 22V8a2 2 0 00-2-2H2"></path>
</svg>
<span>Crop</span>
</button>
<button id="pdf-tb-extract-images" class="pdf-editor-btn" title="Extract Images">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<span>Images</span>
</button>
<div class="pdf-toolbar-separator"></div>
<!-- File Info and Close -->
<span id="pdf-filename" class="pdf-filename"></span>
+20 -1
View File
@@ -1432,6 +1432,25 @@ function createMenu() {
label: 'Add Watermark...',
click: () => showPDFEditorDialog('watermark'),
},
{
label: 'Add Page Numbers...',
click: () => showPDFEditorDialog('pageNumbers'),
},
{
label: 'Crop Pages...',
click: () => showPDFEditorDialog('crop'),
},
{
type: 'separator',
},
{
label: 'Extract Text...',
click: () => showPDFEditorDialog('extractText'),
},
{
label: 'Extract Images...',
click: () => showPDFEditorDialog('extractImages'),
},
{
type: 'separator',
},
@@ -1463,7 +1482,7 @@ function createMenu() {
title: 'About PDF Editor',
message: 'PDF Editor',
detail:
'Comprehensive PDF editing capabilities powered by pdf-lib.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
'Comprehensive PDF editing capabilities powered by pdf-lib and pdfjs-dist.\n\nFeatures:\n• Merge multiple PDF files\n• Split PDF into separate files\n• Compress PDF to reduce file size\n• Rotate pages (90°, 180°, 270°)\n• Delete unwanted pages\n• Reorder pages\n• Add text watermarks\n• Add page numbers\n• Crop pages\n• Extract text\n• Extract embedded images\n\nSecurity Features:\n• Password protection (encryption)\n• Remove passwords (decryption)\n• Set document permissions\n\n100% offline and open-source.',
buttons: ['OK'],
});
},
+242 -42
View File
@@ -230,6 +230,30 @@ async function pdfReorder(data) {
}
}
// 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);
@@ -250,48 +274,8 @@ async function pdfWatermark(data) {
const page = pdf.getPage(pageIndex);
const { width, height } = page.getSize();
let x,
y,
rotation = 0;
switch (data.position) {
case 'center':
x = width / 2;
y = height / 2;
break;
case 'diagonal':
x = width / 2;
y = height / 2;
rotation = 45;
break;
case 'top-left':
x = 50;
y = height - 50;
break;
case 'top-center':
x = width / 2;
y = height - 50;
break;
case 'top-right':
x = width - 50;
y = height - 50;
break;
case 'bottom-left':
x = 50;
y = 50;
break;
case 'bottom-center':
x = width / 2;
y = 50;
break;
case 'bottom-right':
x = width - 50;
y = 50;
break;
default:
x = width / 2;
y = height / 2;
}
const { x, y } = resolvePosition(data.position, width, height, 50);
const rotation = data.position === 'diagonal' ? 45 : 0;
page.drawText(data.text, {
x,
@@ -401,6 +385,210 @@ async function pdfSetPermissions(data) {
}
}
// 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 };
}
}
function executeOperation(operation, data) {
switch (operation) {
case 'merge':
@@ -423,6 +611,14 @@ function executeOperation(operation, data) {
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);
default:
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
}
@@ -447,6 +643,10 @@ module.exports = {
pdfEncrypt,
pdfDecrypt,
pdfSetPermissions,
pdfExtractText,
pdfAddPageNumbers,
pdfCrop,
pdfExtractImages,
executeOperation,
getPageCount,
};
+153
View File
@@ -3862,6 +3862,38 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
if (permInput) permInput.value = openedFilePath;
}
break;
case 'extractText':
sectionId = 'pdf-extract-text-section';
titleText = 'Extract Text';
if (openedFilePath) {
const extractTextInput = document.getElementById('extract-text-input-path');
if (extractTextInput) extractTextInput.value = openedFilePath;
}
break;
case 'pageNumbers':
sectionId = 'pdf-page-numbers-section';
titleText = 'Add Page Numbers';
if (openedFilePath) {
const pageNumbersInput = document.getElementById('page-numbers-input-path');
if (pageNumbersInput) pageNumbersInput.value = openedFilePath;
}
break;
case 'crop':
sectionId = 'pdf-crop-section';
titleText = 'Crop Pages';
if (openedFilePath) {
const cropInput = document.getElementById('crop-input-path');
if (cropInput) cropInput.value = openedFilePath;
}
break;
case 'extractImages':
sectionId = 'pdf-extract-images-section';
titleText = 'Extract Images';
if (openedFilePath) {
const extractImagesInput = document.getElementById('extract-images-input-path');
if (extractImagesInput) extractImagesInput.value = openedFilePath;
}
break;
}
title.textContent = titleText;
document.getElementById(sectionId).classList.remove('hidden');
@@ -4040,6 +4072,46 @@ document.addEventListener('DOMContentLoaded', () => {
inputId: 'permissions-output-path',
saveDialog: true,
},
{
id: 'browse-extract-text-input',
inputId: 'extract-text-input-path',
saveDialog: false,
},
{
id: 'browse-extract-text-output',
inputId: 'extract-text-output-path',
saveDialog: true,
},
{
id: 'browse-page-numbers-input',
inputId: 'page-numbers-input-path',
saveDialog: false,
},
{
id: 'browse-page-numbers-output',
inputId: 'page-numbers-output-path',
saveDialog: true,
},
{
id: 'browse-crop-input',
inputId: 'crop-input-path',
saveDialog: false,
},
{
id: 'browse-crop-output',
inputId: 'crop-output-path',
saveDialog: true,
},
{
id: 'browse-extract-images-input',
inputId: 'extract-images-input-path',
saveDialog: false,
},
{
id: 'browse-extract-images-output',
inputId: 'extract-images-output-folder',
folder: true,
},
];
browseButtons.forEach((button) => {
const btn = document.getElementById(button.id);
@@ -4151,6 +4223,14 @@ document.addEventListener('DOMContentLoaded', () => {
checkbox: 'permissions-overwrite',
section: 'permissions-saveas-section',
},
{
checkbox: 'page-numbers-overwrite',
section: 'page-numbers-saveas-section',
},
{
checkbox: 'crop-overwrite',
section: 'crop-saveas-section',
},
];
overwriteCheckboxes.forEach((item) => {
const checkbox = document.getElementById(item.checkbox);
@@ -4432,6 +4512,67 @@ function processPDFOperation() {
return;
}
break;
case 'extractText':
operationData.inputPath = document.getElementById('extract-text-input-path').value.trim();
operationData.outputPath = document.getElementById('extract-text-output-path').value.trim();
if (!operationData.inputPath || !operationData.outputPath) {
showPDFValidationMessage(
'Select an input PDF and where to save the extracted text.',
'#extract-text-input-path'
);
return;
}
break;
case 'pageNumbers':
operationData.inputPath = document.getElementById('page-numbers-input-path').value.trim();
operationData.overwrite = document.getElementById('page-numbers-overwrite').checked;
operationData.outputPath = operationData.overwrite
? operationData.inputPath
: document.getElementById('page-numbers-output-path').value.trim();
operationData.position = document.getElementById('page-numbers-position').value;
operationData.startNumber =
parseInt(document.getElementById('page-numbers-start').value) || 1;
if (!operationData.inputPath || !operationData.outputPath) {
showPDFValidationMessage(
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
'#page-numbers-input-path'
);
return;
}
break;
case 'crop':
operationData.inputPath = document.getElementById('crop-input-path').value.trim();
operationData.overwrite = document.getElementById('crop-overwrite').checked;
operationData.outputPath = operationData.overwrite
? operationData.inputPath
: document.getElementById('crop-output-path').value.trim();
operationData.margins = {
top: parseFloat(document.getElementById('crop-margin-top').value) || 0,
bottom: parseFloat(document.getElementById('crop-margin-bottom').value) || 0,
left: parseFloat(document.getElementById('crop-margin-left').value) || 0,
right: parseFloat(document.getElementById('crop-margin-right').value) || 0,
};
if (!operationData.inputPath || !operationData.outputPath) {
showPDFValidationMessage(
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
'#crop-input-path'
);
return;
}
break;
case 'extractImages':
operationData.inputPath = document.getElementById('extract-images-input-path').value.trim();
operationData.outputDir = document
.getElementById('extract-images-output-folder')
.value.trim();
if (!operationData.inputPath || !operationData.outputDir) {
showPDFValidationMessage(
'Select an input PDF and an output folder.',
'#extract-images-input-path'
);
return;
}
break;
}
clearPDFStatus();
// Show progress
@@ -5924,6 +6065,18 @@ document.getElementById('pdf-tb-encrypt')?.addEventListener('click', () => {
document.getElementById('pdf-tb-decrypt')?.addEventListener('click', () => {
openPdfEditorDialog('decrypt');
});
document.getElementById('pdf-tb-extract-text')?.addEventListener('click', () => {
openPdfEditorDialog('extractText');
});
document.getElementById('pdf-tb-page-numbers')?.addEventListener('click', () => {
openPdfEditorDialog('pageNumbers');
});
document.getElementById('pdf-tb-crop')?.addEventListener('click', () => {
openPdfEditorDialog('crop');
});
document.getElementById('pdf-tb-extract-images')?.addEventListener('click', () => {
openPdfEditorDialog('extractImages');
});
// ============================================
// DYNAMIC PANE RESIZER
+232
View File
@@ -0,0 +1,232 @@
/**
* @jest-environment node
*
* PDFOperations.js tests for Task 15's new operations: extractText, pageNumbers,
* crop, extractImages. Uses pdf-lib to build minimal fixture PDFs at test time,
* mirroring the fixture pattern used by tests/main/ImageOperations.test.js.
*
* NOTE: pdfExtractText/pdfExtractImages use pdfjs-dist (ESM-only) via a dynamic
* `import()`, which requires Node's `--experimental-vm-modules` flag under Jest
* (set via NODE_OPTIONS in the npm test scripts) and a `node` test environment
* (jsdom lacks the fetch API globals pdfjs-dist needs).
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const sharp = require('sharp');
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const PDFOperations = require('../../src/main/PDFOperations');
describe('PDFOperations - Task 15 new operations', () => {
let tmpDir, inputPath;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfops_'));
inputPath = path.join(tmpDir, 'in.pdf');
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const page1 = doc.addPage([600, 800]);
page1.drawText('Hello Task 15 Page One', {
x: 50,
y: 700,
size: 20,
font,
color: rgb(0, 0, 0),
});
const page2 = doc.addPage([600, 800]);
page2.drawText('Second Page Content', { x: 50, y: 700, size: 20, font, color: rgb(0, 0, 0) });
fs.writeFileSync(inputPath, await doc.save());
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('pdfExtractText', () => {
it('extracts text from all pages', async () => {
const result = await PDFOperations.pdfExtractText({ inputPath });
expect(result.success).toBe(true);
expect(result.text).toContain('Hello Task 15 Page One');
expect(result.text).toContain('Second Page Content');
});
it('returns failure for a nonexistent file', async () => {
const result = await PDFOperations.pdfExtractText({
inputPath: path.join(tmpDir, 'missing.pdf'),
});
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
it('also saves the text to outputPath when provided', async () => {
const outputPath = path.join(tmpDir, 'extracted.txt');
const result = await PDFOperations.pdfExtractText({ inputPath, outputPath });
expect(result.success).toBe(true);
expect(fs.existsSync(outputPath)).toBe(true);
const saved = fs.readFileSync(outputPath, 'utf8');
expect(saved).toContain('Hello Task 15 Page One');
expect(result.message).toContain(outputPath);
});
});
describe('pdfAddPageNumbers', () => {
it('adds a page number to every page at the requested position', async () => {
const outputPath = path.join(tmpDir, 'numbered.pdf');
const result = await PDFOperations.pdfAddPageNumbers({
inputPath,
outputPath,
position: 'bottom-center',
startNumber: 1,
});
expect(result.success).toBe(true);
expect(fs.existsSync(outputPath)).toBe(true);
const extracted = await PDFOperations.pdfExtractText({ inputPath: outputPath });
expect(extracted.success).toBe(true);
expect(extracted.text).toContain('1');
expect(extracted.text).toContain('2');
const savedPdf = await PDFDocument.load(fs.readFileSync(outputPath));
expect(savedPdf.getPageCount()).toBe(2);
});
it('honors a custom startNumber', async () => {
const outputPath = path.join(tmpDir, 'numbered-start5.pdf');
const result = await PDFOperations.pdfAddPageNumbers({
inputPath,
outputPath,
position: 'bottom-right',
startNumber: 5,
});
expect(result.success).toBe(true);
const extracted = await PDFOperations.pdfExtractText({ inputPath: outputPath });
expect(extracted.text).toContain('5');
expect(extracted.text).toContain('6');
});
});
describe('pdfCrop', () => {
it('shrinks the crop box by the given margins', async () => {
const outputPath = path.join(tmpDir, 'cropped.pdf');
const result = await PDFOperations.pdfCrop({
inputPath,
outputPath,
margins: { top: 50, bottom: 50, left: 20, right: 20 },
});
expect(result.success).toBe(true);
expect(fs.existsSync(outputPath)).toBe(true);
const croppedPdf = await PDFDocument.load(fs.readFileSync(outputPath));
const page = croppedPdf.getPage(0);
const cropBox = page.getCropBox();
expect(cropBox.x).toBe(20);
expect(cropBox.y).toBe(50);
expect(cropBox.width).toBe(560); // 600 - 20 - 20
expect(cropBox.height).toBe(700); // 800 - 50 - 50
});
it('fails gracefully when margins exceed the page size', async () => {
const outputPath = path.join(tmpDir, 'cropped-invalid.pdf');
const result = await PDFOperations.pdfCrop({
inputPath,
outputPath,
margins: { top: 500, bottom: 500, left: 0, right: 0 },
});
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('pdfExtractImages', () => {
it('extracts embedded raster images as PNG files', async () => {
const imgPath = path.join(tmpDir, 'red.png');
await sharp({
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toFile(imgPath);
const doc = await PDFDocument.create();
const page = doc.addPage([300, 300]);
const pngImage = await doc.embedPng(fs.readFileSync(imgPath));
page.drawImage(pngImage, { x: 50, y: 50, width: 100, height: 100 });
const imagePdfPath = path.join(tmpDir, 'with-image.pdf');
fs.writeFileSync(imagePdfPath, await doc.save());
const outputDir = path.join(tmpDir, 'extracted');
const result = await PDFOperations.pdfExtractImages({
inputPath: imagePdfPath,
outputDir,
});
expect(result.success).toBe(true);
expect(result.count).toBeGreaterThanOrEqual(1);
expect(result.files.length).toBe(result.count);
for (const file of result.files) {
expect(fs.existsSync(file)).toBe(true);
const meta = await sharp(file).metadata();
expect(meta.format).toBe('png');
}
});
it('returns zero images for a text-only PDF', async () => {
const outputDir = path.join(tmpDir, 'extracted-none');
const result = await PDFOperations.pdfExtractImages({ inputPath, outputDir });
expect(result.success).toBe(true);
expect(result.count).toBe(0);
expect(result.files).toEqual([]);
});
});
describe('executeOperation dispatch', () => {
it('dispatches extractText', async () => {
const result = await PDFOperations.executeOperation('extractText', { inputPath });
expect(result.success).toBe(true);
});
it('dispatches pageNumbers', async () => {
const outputPath = path.join(tmpDir, 'dispatch-numbered.pdf');
const result = await PDFOperations.executeOperation('pageNumbers', {
inputPath,
outputPath,
position: 'bottom-center',
startNumber: 1,
});
expect(result.success).toBe(true);
});
it('dispatches crop', async () => {
const outputPath = path.join(tmpDir, 'dispatch-cropped.pdf');
const result = await PDFOperations.executeOperation('crop', {
inputPath,
outputPath,
margins: { top: 10, bottom: 10, left: 10, right: 10 },
});
expect(result.success).toBe(true);
});
it('dispatches extractImages', async () => {
const outputDir = path.join(tmpDir, 'dispatch-extracted');
const result = await PDFOperations.executeOperation('extractImages', {
inputPath,
outputDir,
});
expect(result.success).toBe(true);
});
});
});