mindee
Version:
Mindee Client Library for Node.js
198 lines (197 loc) • 8.43 kB
JavaScript
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;
}