Files
refboard-ayon/backend/pdf-utils.js
T

135 lines
3.6 KiB
JavaScript

/**
* 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 };