mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user