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:
+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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user