safeer-pdf-generator
Version:
Framework-agnostic PDF generation library with chunking, merging, S3 upload, and email delivery
379 lines • 16.1 kB
JavaScript
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { TajawalBase64 } from './fonts/TajawalFont.js';
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 + footer (separator, "Generated by", company line) to a PDF.
*
* Footer composition is controlled per-line via `footer.show*` toggles and per-string
* via `footer.labels`. A line is omitted when its underlying value is empty (no
* placeholder text). Pass `footer: false` (or `footer.show: false`) to skip the
* entire footer including the separator.
*/
async addPageNumbers(pdfBuffer, options = {}) {
try {
const pdfDoc = await PDFDocument.load(pdfBuffer);
pdfDoc.registerFontkit(fontkit);
const pages = pdfDoc.getPages();
const totalPages = pages.length;
const { userInfo = {}, isRTL = false, footer } = options;
// Master toggle: explicit `footer: false` or `footer.show: false` → skip entirely.
const footerEnabled = footer !== false && footer?.show !== false;
if (!footerEnabled) {
return Buffer.from(await pdfDoc.save());
}
const footerCfg = footer || {};
const showGeneratedBy = footerCfg.showGeneratedBy !== false;
const showCompany = footerCfg.showCompany !== false;
const showPageNumbers = footerCfg.showPageNumbers !== false;
let font;
if (isRTL) {
try {
const fontBytes = Buffer.from(TajawalBase64, 'base64');
font = await pdfDoc.embedFont(fontBytes);
}
catch (error) {
this.logger?.warn(`Failed to load Arabic font, falling back to Helvetica: ${error}`);
font = await pdfDoc.embedFont(StandardFonts.Helvetica);
}
}
else {
font = await pdfDoc.embedFont(StandardFonts.Helvetica);
}
const fontSize = 9;
const color = rgb(0.5, 0.55, 0.6); // Similar to #7f8c8d
// Distinct values: company (footer center) vs. generating user (footer left).
const companyName = userInfo.name || '';
const generatedBy = userInfo.generatedBy || '';
const currentYear = new Date().getFullYear();
// Build the three footer strings (or null when their line should be omitted).
const generatedByLabel = footerCfg.labels?.generatedBy ?? (isRTL ? 'تم الإنشاء بواسطة' : 'Generated by');
const leftText = showGeneratedBy && generatedBy ? `${generatedByLabel} ${generatedBy}` : null;
const companyText = showCompany && companyName ? `${companyName} • ${currentYear}` : null;
const pageText = showPageNumbers
? (footerCfg.labels?.page
? footerCfg.labels.page(0, totalPages) // placeholder; replaced per-page below
: '')
: null;
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 …"
if (leftText) {
const leftWidth = font.widthOfTextAtSize(leftText, fontSize);
page.drawText(leftText, {
x: isRTL ? rightX - leftWidth : leftX,
y: footerY,
size: fontSize,
font,
color,
});
}
// Center section: "<company> • <year>"
if (companyText) {
const companyWidth = font.widthOfTextAtSize(companyText, fontSize);
page.drawText(companyText, {
x: centerX - companyWidth / 2,
y: footerY,
size: fontSize,
font,
color,
});
}
// Right section: "Page X of Y"
if (pageText !== null) {
const renderedPageText = footerCfg.labels?.page
? footerCfg.labels.page(currentPage, totalPages)
: isRTL
? `صفحة ${currentPage} من ${totalPages}`
: `Page ${currentPage} of ${totalPages}`;
const pageWidth = font.widthOfTextAtSize(renderedPageText, fontSize);
page.drawText(renderedPageText, {
x: isRTL ? leftX : rightX - pageWidth,
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