UNPKG

mindee

Version:

Mindee Client Library for Node.js

198 lines (197 loc) 8.43 kB
import { logger } from "../logger.js"; import { compressImage } from "../image/index.js"; import { loadOptionalDependency } from "../dependency/index.js"; import { extractTextFromPdf, hasSourceText, rasterizePage } from "./pdfUtils.js"; let pdfLib = null; async function getPdfLib() { if (!pdfLib) { const pdfLibImport = await loadOptionalDependency("@cantoo/pdf-lib", "Text Embedding"); pdfLib = pdfLibImport.default || pdfLibImport; } return pdfLib; } /** * Compresses each page of a provided PDF buffer. * @param pdfData The input PDF as a Buffer. * @param imageQuality Compression quality (70-100 for most JPG images). * @param forceSourceTextCompression If true, attempts to re-write detected text. * @param disableSourceText If true, doesn't re-apply source text to the output PDF. * @returns A Promise resolving to the compressed PDF as a Buffer. */ export async function compressPdf(pdfData, imageQuality = 85, forceSourceTextCompression = false, disableSourceText = true) { handleCompressionWarnings(forceSourceTextCompression, disableSourceText); if (await hasSourceText(pdfData)) { if (forceSourceTextCompression) { if (!disableSourceText) { logger.warn("Re-writing PDF source-text is an EXPERIMENTAL feature."); } else { logger.warn("Source file contains text, but disable_source_text flag. " + "is set to false. Resulting file will not contain any embedded text."); } } else { logger.warn("Found text inside of the provided PDF file. " + "Compression operation aborted since disableSourceText is set to 'true'."); return pdfData; } } const extractedText = disableSourceText ? await extractTextFromPdf(pdfData) : null; const extractedPdfInfo = await extractTextFromPdf(pdfData); const compressedPages = await compressPdfPages(pdfData, extractedPdfInfo, imageQuality, disableSourceText, extractedText); if (!compressedPages) { logger.warn("Could not compress PDF to a smaller size. Returning original PDF."); return pdfData; } return createNewPdfFromCompressedPages(compressedPages); } /** * Handles compression warnings based on the provided parameters. * @param forceSourceTextCompression If true, attempts to re-write detected text. * @param disableSourceText If true, doesn't re-apply source text to the output PDF. */ function handleCompressionWarnings(forceSourceTextCompression, disableSourceText) { if (forceSourceTextCompression) { if (!disableSourceText) { logger.warn("Re-writing PDF source-text is an EXPERIMENTAL feature."); } else { logger.warn("Source file contains text, but the disable_source_text is set to false. " + "Resulting file will not contain any embedded text."); } } } /** * Compresses PDF pages and returns an array of compressed page buffers. * @param pdfData The input PDF as a Buffer. * @param extractedPdfInfo Extracted PDF information. * @param imageQuality Initial compression quality. * @param disableSourceText If true, doesn't re-apply source text to the output PDF. * @param extractedText Extracted text from the PDF. * @returns A Promise resolving to an array of compressed page buffers, or null if compression fails. */ async function compressPdfPages(pdfData, extractedPdfInfo, imageQuality, disableSourceText, extractedText) { const originalSize = pdfData.length; const MIN_QUALITY = 1; let imageQualityLoop = imageQuality; while (imageQualityLoop >= MIN_QUALITY) { const compressedPages = await compressPagesWithQuality(pdfData, extractedPdfInfo, imageQualityLoop, disableSourceText, extractedText); const totalCompressedSize = calculateTotalCompressedSize(compressedPages); if (isCompressionSuccessful(totalCompressedSize, originalSize, imageQuality)) { return compressedPages; } imageQualityLoop -= Math.round(lerp(1, 10, imageQualityLoop / 100)); } return null; } /** * Compresses pages with a specific quality. * @param pdfData The input PDF as a Buffer. * @param extractedPdfInfo Extracted PDF information. * @param imageQuality Compression quality. * @param disableSourceText If true, doesn't re-apply source text to the output PDF. * @param extractedText Extracted text from the PDF. * @returns A Promise resolving to an array of compressed page buffers. */ async function compressPagesWithQuality(pdfData, extractedPdfInfo, imageQuality, disableSourceText, extractedText) { const pdfLib = await getPdfLib(); const pdfDoc = await pdfLib.PDFDocument.load(pdfData, { ignoreEncryption: true, password: "" }); const compressedPages = []; for (let i = 0; i < extractedPdfInfo.pages.length; i++) { const page = pdfDoc.getPages()[i]; const rasterizedPage = await rasterizePage(pdfData, i + 1, imageQuality); const compressedImage = await compressImage(rasterizedPage, imageQuality); if (!disableSourceText) { await addTextToPdfPage(page, extractedText); } compressedPages.push(compressedImage); } return compressedPages; } /** * Calculates the total size of compressed pages. * @param compressedPages Array of compressed page buffers. * @returns The total size of compressed pages. */ function calculateTotalCompressedSize(compressedPages) { return compressedPages.reduce((sum, page) => sum + page.length, 0); } /** * Checks if the compression was successful based on the compressed size and original size. * Note: Not quite sure how or why the rasterization quality ratio is correlated with the overhead generated by the * image's inclusion into the pdf data, but this makes the following lerp() necessary if we want consistency during * compression. * * @param totalCompressedSize Total size of compressed pages. * @param originalSize Original PDF size. * @param imageQuality Compression quality. * @returns True if compression was successful, false otherwise. */ function isCompressionSuccessful(totalCompressedSize, originalSize, imageQuality) { const overhead = lerp(0.54, 0.18, imageQuality / 100); return totalCompressedSize + totalCompressedSize * overhead < originalSize; } /** * Creates a new PDF document from compressed page buffers. * @param compressedPages Array of compressed page buffers. * @returns A Promise resolving to the new PDF as a Buffer. */ async function createNewPdfFromCompressedPages(compressedPages) { const pdfLib = await getPdfLib(); const newPdfDoc = await pdfLib.PDFDocument.create(); for (const compressedPage of compressedPages) { const image = await newPdfDoc.embedJpg(compressedPage); const newPage = newPdfDoc.addPage([image.width, image.height]); newPage.drawImage(image, { x: 0, y: 0, width: image.width, height: image.height, }); } const compressedPdfBytes = await newPdfDoc.save(); return Buffer.from(compressedPdfBytes); } async function addTextToPdfPage(page, textInfo) { if (textInfo === null) { return; } const pdfLib = await getPdfLib(); for (const textPages of textInfo.pages) { for (const textPage of textPages.content) { page.drawText(textPage.str, { x: textPage.x, y: textPage.y, size: textPage.height, color: pdfLib.rgb(0, 0, 0), font: await getFontFromName(textPage.fontName) }); } } } async function getFontFromName(fontName) { const pdfLib = await getPdfLib(); const pdfDoc = await pdfLib.PDFDocument.create(); let font; const standardFontValues = Object.values(pdfLib.StandardFonts); if (standardFontValues.includes(fontName)) { font = await pdfDoc.embedFont(fontName); } else { font = await pdfDoc.embedFont(pdfLib.StandardFonts.Helvetica); } return font; } /** * Performs linear interpolation between two numbers. * @param start The starting value. * @param end The ending value. * @param t The interpolation factor (0 to 1). * @returns The interpolated value. */ function lerp(start, end, t) { return start * (1 - t) + end * t; }