safeer-pdf-generator
Version:
Framework-agnostic PDF generation library with chunking, merging, S3 upload, and email delivery
333 lines • 13.5 kB
JavaScript
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import { MergeError } from '../core/errors.js';
import { noOpLogger } from '../config/defaults.js';
import { Timer } from '../utils/timing.js';
/**
* Service for merging PDF documents
*/
export class MergeService {
constructor(logger = noOpLogger) {
this.logger = logger;
}
/**
* Merge multiple PDF buffers into a single PDF
*/
async mergePdfs(chunks, options = {}) {
const timer = new Timer();
const { logging = this.logger } = options;
if (!chunks || chunks.length === 0) {
throw new MergeError('No PDF chunks provided for merging');
}
if (chunks.length === 1) {
logging.debug('Only one chunk provided, validating and returning');
const chunk = chunks[0];
if (!Buffer.isBuffer(chunk) || chunk.length === 0) {
throw new MergeError('Invalid chunk: not a valid buffer');
}
try {
await PDFDocument.load(chunk);
return chunk;
}
catch (error) {
throw new MergeError(`Invalid PDF chunk: ${error instanceof Error ? error.message : 'Unknown error'}`, 1, error);
}
}
logging.info(`Starting merge of ${chunks.length} PDF chunks`);
try {
const mergedPdf = await PDFDocument.create();
let totalPages = 0;
// Process chunks sequentially to reduce memory usage
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!Buffer.isBuffer(chunk) || chunk.length === 0) {
throw new MergeError(`Invalid chunk at index ${i}: not a valid buffer`);
}
timer.mark(`chunk-${i}-start`);
try {
logging.debug(`Processing chunk ${i + 1}/${chunks.length} (${(chunk.length / 1024).toFixed(1)}KB)`);
const pdf = await PDFDocument.load(chunk);
const pageCount = pdf.getPageCount();
if (pageCount === 0) {
logging.warn(`Chunk ${i + 1} has no pages, skipping`);
continue;
}
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach(page => mergedPdf.addPage(page));
totalPages += pageCount;
timer.mark(`chunk-${i}-end`);
const chunkTime = timer.elapsedSince(`chunk-${i}-start`);
logging.debug(`Chunk ${i + 1} processed in ${chunkTime.toFixed(1)}ms (${pageCount} pages)`);
}
catch (error) {
throw new MergeError(`Failed to process chunk ${i + 1}`, chunks.length, error);
}
}
if (totalPages === 0) {
throw new MergeError('No valid pages found in any chunks');
}
timer.mark('save-start');
const mergedBuffer = Buffer.from(await mergedPdf.save());
timer.mark('save-end');
const totalTime = timer.elapsed();
const saveTime = timer.elapsedSince('save-start');
logging.info(`PDF merge completed: ${totalPages} pages, ` +
`${(mergedBuffer.length / 1024 / 1024).toFixed(2)}MB, ` +
`${totalTime.toFixed(1)}ms total (${saveTime.toFixed(1)}ms save)`);
return mergedBuffer;
}
catch (error) {
const elapsed = timer.elapsed();
if (error instanceof MergeError) {
throw error;
}
throw new MergeError(`PDF merge failed after ${elapsed.toFixed(1)}ms: ${error instanceof Error ? error.message : 'Unknown error'}`, chunks.length, error);
}
}
/**
* Get information about PDF buffers without merging
*/
async analyzePdfs(chunks) {
const chunkInfo = [];
let totalPages = 0;
let totalSize = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const sizeKB = Math.round(chunk.length / 1024);
totalSize += chunk.length;
try {
if (!Buffer.isBuffer(chunk) || chunk.length === 0) {
chunkInfo.push({
index: i,
pages: 0,
sizeKB,
valid: false,
error: 'Invalid buffer',
});
continue;
}
const pdf = await PDFDocument.load(chunk);
const pages = pdf.getPageCount();
totalPages += pages;
chunkInfo.push({
index: i,
pages,
sizeKB,
valid: true,
});
}
catch (error) {
chunkInfo.push({
index: i,
pages: 0,
sizeKB,
valid: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return {
totalChunks: chunks.length,
totalPages,
totalSize,
chunkInfo,
};
}
/**
* Validate PDF buffers
*/
async validatePdfs(chunks) {
const errors = [];
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!Buffer.isBuffer(chunk)) {
errors.push({
chunkIndex: i,
error: 'Not a buffer',
});
continue;
}
if (chunk.length === 0) {
errors.push({
chunkIndex: i,
error: 'Empty buffer',
});
continue;
}
try {
const pdf = await PDFDocument.load(chunk);
const pageCount = pdf.getPageCount();
if (pageCount === 0) {
errors.push({
chunkIndex: i,
error: 'PDF has no pages',
});
}
}
catch (error) {
errors.push({
chunkIndex: i,
error: `Invalid PDF: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Split a PDF into individual page buffers
*/
async splitPdf(pdfBuffer) {
try {
const pdf = await PDFDocument.load(pdfBuffer);
const pageCount = pdf.getPageCount();
const pageBuffers = [];
this.logger.info(`Splitting PDF into ${pageCount} individual pages`);
for (let i = 0; i < pageCount; i++) {
const newPdf = await PDFDocument.create();
const [copiedPage] = await newPdf.copyPages(pdf, [i]);
newPdf.addPage(copiedPage);
const pageBuffer = Buffer.from(await newPdf.save());
pageBuffers.push(pageBuffer);
}
this.logger.info(`PDF split completed: ${pageBuffers.length} pages`);
return pageBuffers;
}
catch (error) {
throw new MergeError(`Failed to split PDF: ${error instanceof Error ? error.message : 'Unknown error'}`, undefined, error);
}
}
/**
* Extract specific pages from a PDF
*/
async extractPages(pdfBuffer, pageIndices) {
try {
const pdf = await PDFDocument.load(pdfBuffer);
const totalPages = pdf.getPageCount();
// Validate page indices
const validIndices = pageIndices.filter(index => index >= 0 && index < totalPages);
if (validIndices.length === 0) {
throw new MergeError('No valid page indices provided');
}
const newPdf = await PDFDocument.create();
const pages = await newPdf.copyPages(pdf, validIndices);
pages.forEach(page => newPdf.addPage(page));
const extractedBuffer = Buffer.from(await newPdf.save());
this.logger.info(`Extracted ${validIndices.length} pages from ${totalPages}-page PDF`);
return extractedBuffer;
}
catch (error) {
throw new MergeError(`Failed to extract pages: ${error instanceof Error ? error.message : 'Unknown error'}`, undefined, error);
}
}
/**
* Get PDF metadata
*/
async getPdfInfo(pdfBuffer) {
try {
const pdf = await PDFDocument.load(pdfBuffer);
const pages = pdf.getPageCount();
const sizeKB = Math.round(pdfBuffer.length / 1024);
// Get metadata if available
const title = pdf.getTitle();
const author = pdf.getAuthor();
const creator = pdf.getCreator();
const producer = pdf.getProducer();
const creationDate = pdf.getCreationDate();
const modificationDate = pdf.getModificationDate();
return {
pages,
sizeKB,
...(title && { title }),
...(author && { author }),
...(creator && { creator }),
...(producer && { producer }),
...(creationDate && { creationDate }),
...(modificationDate && { modificationDate }),
};
}
catch (error) {
throw new MergeError(`Failed to get PDF info: ${error instanceof Error ? error.message : 'Unknown error'}`, undefined, error);
}
}
/**
* Add page numbers to a PDF document
*/
async addPageNumbers(pdfBuffer, options = {}) {
try {
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pages = pdfDoc.getPages();
const totalPages = pages.length;
// Load a standard font
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontSize = 9;
const color = rgb(0.5, 0.55, 0.6); // Similar to #7f8c8d
const { userInfo = {}, isRTL = false } = options;
const companyName = userInfo.name || 'Company';
const userName = userInfo.name || '';
const currentYear = new Date().getFullYear();
pages.forEach((page, index) => {
const { width } = page.getSize();
const currentPage = index + 1;
// Calculate positions for footer elements
const footerY = 15; // Distance from bottom
const separatorY = footerY + 30; // Line above footer
const leftX = 20;
const centerX = width / 2;
const rightX = width - 20;
// Clear any existing footer content by drawing a white rectangle
page.drawRectangle({
x: 0,
y: 0,
width: width,
height: 50, // Clear bottom 50px
color: rgb(1, 1, 1), // White
});
// Draw separator line above footer
page.drawLine({
start: { x: leftX, y: separatorY },
end: { x: rightX, y: separatorY },
thickness: 0.5,
color: rgb(0.8, 0.8, 0.8), // Light gray line
});
// Left section: Generated by information
const leftText = userName ? `Generated by ${userName}` : 'Generated by User';
const leftWidth = font.widthOfTextAtSize(leftText, fontSize);
page.drawText(leftText, {
x: isRTL ? rightX - leftWidth : leftX,
y: footerY,
size: fontSize,
font,
color,
});
// Center section: Company and Page numbers
const companyText = `${companyName} • ${currentYear}`;
const pageText = `Page ${currentPage} of ${totalPages}`;
// Draw page numbers line
const pageWidth = font.widthOfTextAtSize(companyText, fontSize);
page.drawText(companyText, {
x: centerX - pageWidth / 2,
y: footerY,
size: fontSize,
font,
color,
});
// Right section:
const dateWidth = font.widthOfTextAtSize(pageText, fontSize);
page.drawText(pageText, {
x: isRTL ? leftX : rightX - dateWidth,
y: footerY,
size: fontSize,
font,
color,
});
});
return Buffer.from(await pdfDoc.save());
}
catch (error) {
throw new MergeError(`Failed to add page numbers: ${error instanceof Error ? error.message : 'Unknown error'}`, undefined, error);
}
}
}
//# sourceMappingURL=MergeService.js.map