feat: add pdf-utils.js — pdfInfo, pdfRenderPage, bufferToTempFile
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* pdf-utils.js — Thin wrappers around poppler-utils CLI tools.
|
||||||
|
*
|
||||||
|
* Uses pdfinfo + pdftoppm (from poppler-utils) for PDF metadata extraction
|
||||||
|
* and page rendering. No external Node dependencies required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a buffer to a temporary file. Returns { tmpPath, cleanup }.
|
||||||
|
* Caller MUST call cleanup() when done.
|
||||||
|
*/
|
||||||
|
function bufferToTempFile(buffer, ext = '.bin') {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refboard-pdf-'));
|
||||||
|
const tmpPath = path.join(tmpDir, `file${ext}`);
|
||||||
|
fs.writeFileSync(tmpPath, buffer);
|
||||||
|
return {
|
||||||
|
tmpPath,
|
||||||
|
cleanup() {
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch {}
|
||||||
|
try { fs.rmdirSync(tmpDir); } catch {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract PDF metadata via pdfinfo.
|
||||||
|
* Returns { pageCount, dimensions: [{ w, h }, ...] }
|
||||||
|
*/
|
||||||
|
async function pdfInfo(filePath) {
|
||||||
|
// Get basic info (page count)
|
||||||
|
const { stdout: basicOut } = await execFileAsync('pdfinfo', [filePath], { timeout: TIMEOUT_MS });
|
||||||
|
|
||||||
|
let pageCount = 0;
|
||||||
|
for (const line of basicOut.split('\n')) {
|
||||||
|
const match = line.match(/^Pages:\s+(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
pageCount = parseInt(match[1], 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageCount === 0) {
|
||||||
|
throw new Error('Could not determine PDF page count');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get per-page dimensions
|
||||||
|
const dimensions = [];
|
||||||
|
const { stdout: dimOut } = await execFileAsync(
|
||||||
|
'pdfinfo',
|
||||||
|
['-f', '1', '-l', String(pageCount), filePath],
|
||||||
|
{ timeout: TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse "Page N size: W x H pts" lines
|
||||||
|
for (const line of dimOut.split('\n')) {
|
||||||
|
const match = line.match(/^Page\s+\d+\s+size:\s+([\d.]+)\s+x\s+([\d.]+)/);
|
||||||
|
if (match) {
|
||||||
|
dimensions.push({
|
||||||
|
w: Math.round(parseFloat(match[1])),
|
||||||
|
h: Math.round(parseFloat(match[2])),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no per-page sizes found, try global "Page size:"
|
||||||
|
if (dimensions.length === 0) {
|
||||||
|
for (const line of basicOut.split('\n')) {
|
||||||
|
const match = line.match(/^Page size:\s+([\d.]+)\s+x\s+([\d.]+)/);
|
||||||
|
if (match) {
|
||||||
|
const dim = {
|
||||||
|
w: Math.round(parseFloat(match[1])),
|
||||||
|
h: Math.round(parseFloat(match[2])),
|
||||||
|
};
|
||||||
|
for (let i = 0; i < pageCount; i++) {
|
||||||
|
dimensions.push(dim);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pageCount, dimensions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single PDF page to PNG at the given DPI.
|
||||||
|
* Returns a PNG Buffer.
|
||||||
|
*/
|
||||||
|
async function pdfRenderPage(filePath, pageNum, dpi = 150) {
|
||||||
|
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refboard-render-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outPrefix = path.join(outDir, 'page');
|
||||||
|
|
||||||
|
await execFileAsync('pdftoppm', [
|
||||||
|
'-png',
|
||||||
|
'-r', String(dpi),
|
||||||
|
'-f', String(pageNum),
|
||||||
|
'-l', String(pageNum),
|
||||||
|
filePath,
|
||||||
|
outPrefix,
|
||||||
|
], { timeout: TIMEOUT_MS });
|
||||||
|
|
||||||
|
// pdftoppm names output like page-01.png, page-1.png, etc. — discover it
|
||||||
|
const files = fs.readdirSync(outDir).filter(f => f.endsWith('.png'));
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`pdftoppm produced no output for page ${pageNum}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngPath = path.join(outDir, files[0]);
|
||||||
|
const pngBuffer = fs.readFileSync(pngPath);
|
||||||
|
return pngBuffer;
|
||||||
|
} finally {
|
||||||
|
// Clean up temp dir
|
||||||
|
try {
|
||||||
|
for (const f of fs.readdirSync(outDir)) {
|
||||||
|
fs.unlinkSync(path.join(outDir, f));
|
||||||
|
}
|
||||||
|
fs.rmdirSync(outDir);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pdfInfo, pdfRenderPage, bufferToTempFile };
|
||||||
Reference in New Issue
Block a user