From 2334ab30ed53711d0130393b2c8ecd1823868bd2 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 10:27:07 +0530 Subject: [PATCH] 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 --- package.json | 6 +- src/index.html | 217 +++++++++++++++++++++++ src/main.js | 21 ++- src/main/PDFOperations.js | 284 ++++++++++++++++++++++++++----- src/renderer.js | 153 +++++++++++++++++ tests/main/PDFOperations.test.js | 232 +++++++++++++++++++++++++ 6 files changed, 867 insertions(+), 46 deletions(-) create mode 100644 tests/main/PDFOperations.test.js diff --git a/package.json b/package.json index 8156aef..5c05cff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.html b/src/index.html index 1481af0..f4954cb 100644 --- a/src/index.html +++ b/src/index.html @@ -1953,6 +1953,164 @@ + + + + + + + + + + + + @@ -2402,6 +2560,65 @@ Decrypt + + + +
diff --git a/src/main.js b/src/main.js index c6d4b38..a9c8117 100644 --- a/src/main.js +++ b/src/main.js @@ -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'], }); }, diff --git a/src/main/PDFOperations.js b/src/main/PDFOperations.js index 0fe7a5f..8fb974a 100644 --- a/src/main/PDFOperations.js +++ b/src/main/PDFOperations.js @@ -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, }; diff --git a/src/renderer.js b/src/renderer.js index fb63aa1..ad69490 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -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 diff --git a/tests/main/PDFOperations.test.js b/tests/main/PDFOperations.test.js new file mode 100644 index 0000000..289a686 --- /dev/null +++ b/tests/main/PDFOperations.test.js @@ -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); + }); + }); +});