pdf-decomposer
Version:
📚 Extract text and images (as buffers) from PDF files using native parsing and OCR with Tesseract.
202 lines (175 loc) • 5.03 kB
text/typescript
import os from 'os';
import path from 'path';
import fs from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { randomUUID } from 'crypto';
const execFileAsync = promisify(execFile);
export type PageDecompose = {
page: number;
text: string;
images: Array<Buffer>;
};
export type DecomposeOptions = {
ocr?: boolean;
};
// Interface pour les images retournées (buffers)
export type Decompose = Array<PageDecompose>;
/**
* Extract text / images from pdf
* @param bufferOrPath buffer or path of file
* @returns TODO
*/
export async function decompose(
bufferOrPath: Buffer | string,
options?: DecomposeOptions
): Promise<Decompose> {
const buffer =
typeof bufferOrPath === 'string'
? getFileBuffer(bufferOrPath)
: bufferOrPath;
isFileValid(buffer);
// Work in temp folder
const packageDir = path.join(os.tmpdir(), 'pdf-decomposer');
// Clean old parsing
if (fs.existsSync(packageDir)) fs.rmSync(packageDir, { recursive: true });
const tmpDir = path.join(packageDir, randomUUID());
fs.mkdirSync(tmpDir, { recursive: true });
const tmpFilePath = path.join(tmpDir, 'original.pdf');
fs.writeFileSync(tmpFilePath, buffer);
const nbPages = await getPdfPageCount(tmpFilePath);
if (options?.ocr) {
// Convert pdf to png if using OCR
const pdfToPngDir = path.join(tmpDir, 'pdf-to-png');
if (!fs.existsSync(pdfToPngDir)) fs.mkdirSync(pdfToPngDir);
await execFileAsync('pdftoppm', [
'-png',
tmpFilePath,
path.join(pdfToPngDir, 'page'),
]);
}
const pages: Array<PageDecompose> = [];
for (let i = 1; i <= nbPages; i++) {
const [text, images] = await Promise.all([
extractPageText(tmpFilePath, i, options),
extractPageImages(tmpFilePath, i),
]);
pages.push({
page: i,
text,
images,
});
}
// Sort pages by page number
return pages.sort((a, b) => a.page - b.page);
}
/**
* Check if file is valid throw error if not
* @param path File path
* @returns Buffer
* @throws Error if file does not exist or is not a file
*/
function getFileBuffer(path: string): Buffer {
if (!fs.existsSync(path)) throw new Error(`File ${path} does not exist`);
if (!fs.lstatSync(path).isFile()) throw new Error(`${path} is not a file`);
return fs.readFileSync(path);
}
/**
* Check if file is valid throw error if not
* @param buffer File buffer
* @returns void
* @throws Error if file is not a pdf
*/
function isFileValid(buffer: Buffer): void {
if (!isMimeValid(buffer)) {
throw new Error('File is not a pdf');
}
}
/**
* Check if file is a pdf
* @param buffer File buffer
* @returns boolean true if pdf
* @throws Error if not a pdf
*/
function isMimeValid(buffer: Buffer): boolean {
if (!buffer || buffer.length < 4) return false;
// PDF: always start with "%PDF"
return buffer.subarray(0, 4).toString() === '%PDF';
}
/**
* Get pdf pages count
* @param pdfPath path to pdf file
* @returns number of pages
*/
async function getPdfPageCount(pdfPath: string): Promise<number> {
const { stdout } = await execFileAsync('pdfinfo', [pdfPath]);
const match = stdout.match(/Pages:\s+(\d+)/);
return match ? parseInt(match[1], 10) : 0;
}
/**
* Get one pdf page text
* @param pdfPath path to pdf file
* @param pageIndex number of the page
* @returns string text
* @throws Error if pdftotext fails
*/
async function extractPageText(
pdfPath: string,
pageIndex: number,
options?: DecomposeOptions
): Promise<string> {
if (!options?.ocr) {
const { stdout } = await execFileAsync('pdftotext', [
'-f',
`${pageIndex}`,
'-l',
`${pageIndex}`,
'-layout',
pdfPath,
'-', // <--- important : "-" = use stdout
]);
return stdout;
}
const imagePath = path.join(
path.dirname(pdfPath),
'pdf-to-png',
`page-${pageIndex}.png`
);
if (!fs.existsSync(imagePath))
throw new Error(`Page ${pageIndex} image not found`);
try {
const { stdout } = await execFileAsync('tesseract', [imagePath, 'stdout']); // 10 Mo max buffer
return stdout.trim();
} catch {
throw new Error(`Error OCR on page ${pageIndex}`);
}
}
/**
* Get one pdf page images
* @param pdfPath path to pdf file
* @param pageIndex number of the page
* @returns image buffers
* @throws Error if pdfimages fails
*/
async function extractPageImages(
pdfPath: string,
pageIndex: number
): Promise<Array<Buffer>> {
const tmpDir = path.join(path.dirname(pdfPath), 'img-extract');
const outputPrefix = path.join(tmpDir, `page-${pageIndex}-img`);
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir);
await execFileAsync('pdfimages', [
'-f',
`${pageIndex}`,
'-l',
`${pageIndex}`,
'-png',
pdfPath,
outputPrefix,
]);
const imageFiles = fs
.readdirSync(tmpDir)
.filter((name) => name.startsWith(`page-${pageIndex}-img`))
.map((name) => path.join(tmpDir, name));
return imageFiles.map((file) => fs.readFileSync(file));
}