diff --git a/backend/pdf-utils.js b/backend/pdf-utils.js new file mode 100644 index 0000000..7c2a932 --- /dev/null +++ b/backend/pdf-utils.js @@ -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 };