mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-24 07:20:16 +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",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"test": "jest",
|
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||||
"lint": "eslint src tests",
|
"lint": "eslint src tests",
|
||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
|
|||||||
+217
@@ -1953,6 +1953,164 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
|
||||||
|
|
||||||
<!-- Progress indicator -->
|
<!-- Progress indicator -->
|
||||||
@@ -2402,6 +2560,65 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Decrypt</span>
|
<span>Decrypt</span>
|
||||||
</button>
|
</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>
|
<div class="pdf-toolbar-separator"></div>
|
||||||
<!-- File Info and Close -->
|
<!-- File Info and Close -->
|
||||||
<span id="pdf-filename" class="pdf-filename"></span>
|
<span id="pdf-filename" class="pdf-filename"></span>
|
||||||
|
|||||||
+20
-1
@@ -1432,6 +1432,25 @@ function createMenu() {
|
|||||||
label: 'Add Watermark...',
|
label: 'Add Watermark...',
|
||||||
click: () => showPDFEditorDialog('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',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
@@ -1463,7 +1482,7 @@ function createMenu() {
|
|||||||
title: 'About PDF Editor',
|
title: 'About PDF Editor',
|
||||||
message: 'PDF Editor',
|
message: 'PDF Editor',
|
||||||
detail:
|
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'],
|
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) {
|
async function pdfWatermark(data) {
|
||||||
try {
|
try {
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
@@ -250,48 +274,8 @@ async function pdfWatermark(data) {
|
|||||||
const page = pdf.getPage(pageIndex);
|
const page = pdf.getPage(pageIndex);
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
let x,
|
const { x, y } = resolvePosition(data.position, width, height, 50);
|
||||||
y,
|
const rotation = data.position === 'diagonal' ? 45 : 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
page.drawText(data.text, {
|
page.drawText(data.text, {
|
||||||
x,
|
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) {
|
function executeOperation(operation, data) {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
case 'merge':
|
case 'merge':
|
||||||
@@ -423,6 +611,14 @@ function executeOperation(operation, data) {
|
|||||||
return pdfDecrypt(data);
|
return pdfDecrypt(data);
|
||||||
case 'permissions':
|
case 'permissions':
|
||||||
return pdfSetPermissions(data);
|
return pdfSetPermissions(data);
|
||||||
|
case 'extractText':
|
||||||
|
return pdfExtractText(data);
|
||||||
|
case 'pageNumbers':
|
||||||
|
return pdfAddPageNumbers(data);
|
||||||
|
case 'crop':
|
||||||
|
return pdfCrop(data);
|
||||||
|
case 'extractImages':
|
||||||
|
return pdfExtractImages(data);
|
||||||
default:
|
default:
|
||||||
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||||
}
|
}
|
||||||
@@ -447,6 +643,10 @@ module.exports = {
|
|||||||
pdfEncrypt,
|
pdfEncrypt,
|
||||||
pdfDecrypt,
|
pdfDecrypt,
|
||||||
pdfSetPermissions,
|
pdfSetPermissions,
|
||||||
|
pdfExtractText,
|
||||||
|
pdfAddPageNumbers,
|
||||||
|
pdfCrop,
|
||||||
|
pdfExtractImages,
|
||||||
executeOperation,
|
executeOperation,
|
||||||
getPageCount,
|
getPageCount,
|
||||||
};
|
};
|
||||||
|
|||||||
+153
@@ -3862,6 +3862,38 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
if (permInput) permInput.value = openedFilePath;
|
if (permInput) permInput.value = openedFilePath;
|
||||||
}
|
}
|
||||||
break;
|
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;
|
title.textContent = titleText;
|
||||||
document.getElementById(sectionId).classList.remove('hidden');
|
document.getElementById(sectionId).classList.remove('hidden');
|
||||||
@@ -4040,6 +4072,46 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
inputId: 'permissions-output-path',
|
inputId: 'permissions-output-path',
|
||||||
saveDialog: true,
|
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) => {
|
browseButtons.forEach((button) => {
|
||||||
const btn = document.getElementById(button.id);
|
const btn = document.getElementById(button.id);
|
||||||
@@ -4151,6 +4223,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
checkbox: 'permissions-overwrite',
|
checkbox: 'permissions-overwrite',
|
||||||
section: 'permissions-saveas-section',
|
section: 'permissions-saveas-section',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
checkbox: 'page-numbers-overwrite',
|
||||||
|
section: 'page-numbers-saveas-section',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checkbox: 'crop-overwrite',
|
||||||
|
section: 'crop-saveas-section',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
overwriteCheckboxes.forEach((item) => {
|
overwriteCheckboxes.forEach((item) => {
|
||||||
const checkbox = document.getElementById(item.checkbox);
|
const checkbox = document.getElementById(item.checkbox);
|
||||||
@@ -4432,6 +4512,67 @@ function processPDFOperation() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
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();
|
clearPDFStatus();
|
||||||
// Show progress
|
// Show progress
|
||||||
@@ -5924,6 +6065,18 @@ document.getElementById('pdf-tb-encrypt')?.addEventListener('click', () => {
|
|||||||
document.getElementById('pdf-tb-decrypt')?.addEventListener('click', () => {
|
document.getElementById('pdf-tb-decrypt')?.addEventListener('click', () => {
|
||||||
openPdfEditorDialog('decrypt');
|
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
|
// 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