import sharp from 'sharp'; import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; const THUMB_MAX = 200; export const DEBUG_DIR = process.env.PDF_DEBUG_DIR || path.resolve('debug/pdf'); /** * Sanitizes a filename for filesystem storage. * @param {string} [name] * @returns {string} */ export function sanitizeFilename(name) { if (!name || typeof name !== 'string') return 'document'; return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_'); } /** * Safely writes a buffer or string to disk, ensuring directory exists. * @param {string} filePath * @param {Buffer|string} data */ export function writeDebugFile(filePath, data) { try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, data); return true; } catch (err) { console.warn(`[pdfDebug] Failed to write debug file ${filePath}:`, err.message); return false; } } /** * Renders page 1 of a PDF into a PNG buffer using pdftocairo. * @param {Buffer} pdfBuffer * @returns {Promise<{pngBuffer: Buffer, stderr: string, durationMs: number}>} */ export function renderPdfPageToPng(pdfBuffer) { return new Promise((resolve, reject) => { const startTime = Date.now(); const proc = spawn('pdftocairo', ['-png', '-singlefile', '-', '-'], { stdio: ['pipe', 'pipe', 'pipe'], }); const chunks = []; const errChunks = []; proc.stdout.on('data', (chunk) => chunks.push(chunk)); proc.stderr.on('data', (chunk) => errChunks.push(chunk)); proc.stdin.on('error', (err) => { // Prevent unhandled EPIPE crashes if pdftocairo exits early console.warn('[pdfDebug][pdftocairo] stdin stream error:', err.message); }); proc.on('error', (err) => { reject(new Error(`Failed to start pdftocairo: ${err.message}`)); }); proc.on('close', (code) => { const durationMs = Date.now() - startTime; const stderr = Buffer.concat(errChunks).toString('utf8').trim(); if (code !== 0) { reject(new Error(`pdftocairo exited with code ${code}${stderr ? `: ${stderr}` : ''}`)); } else { const pngBuffer = Buffer.concat(chunks); resolve({ pngBuffer, stderr, durationMs }); } }); try { proc.stdin.end(pdfBuffer); } catch (writeErr) { console.warn('[pdfDebug][pdftocairo] Failed writing to stdin:', writeErr.message); } }); } /** * Decodes content to a Buffer. If content is a base64 string, converts it to Buffer. * @param {Buffer|string} rawContent * @param {string} [mimetype] * @returns {Buffer} */ export function normalizeContentBuffer(rawContent, mimetype) { if (Buffer.isBuffer(rawContent)) { return rawContent; } if (typeof rawContent === 'string') { const trimmed = rawContent.trim(); // If it starts with %PDF or text header: if (trimmed.startsWith('%PDF-')) { return Buffer.from(rawContent, 'utf8'); } // If PDF or looks like base64, attempt base64 decode const isPdf = mimetype === 'application/pdf' || trimmed.startsWith('JVBER'); if (isPdf || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) { try { const b64 = Buffer.from(trimmed, 'base64'); if (b64.length > 0) { return b64; } } catch { // Fall back to utf8 } } return Buffer.from(rawContent, 'utf8'); } return Buffer.from(rawContent || ''); } /** * Debugs PDF upload and thumbnail generation: * 1. Writes source PDF to disk * 2. Renders page 1 PNG via pdftocairo and writes to disk * 3. Converts to AVIF thumbnail via sharp and writes to disk * 4. Writes complete JSON metadata to disk * * @param {Buffer|string} rawContent * @param {object} [options] * @param {string} [options.filename='upload.pdf'] * @param {string} [options.mimetype='application/pdf'] * @param {string} [options.debugDir] * @param {boolean} [options.saveToDisk=true] * @returns {Promise<{thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object}>} */ export async function debugGeneratePdfThumbnail(rawContent, options = {}) { const startTime = Date.now(); const filename = options.filename || 'upload.pdf'; const mimetype = options.mimetype || 'application/pdf'; const saveToDisk = options.saveToDisk ?? true; const targetDir = options.debugDir || DEBUG_DIR; const buffer = normalizeContentBuffer(rawContent, mimetype); const magic = buffer.slice(0, 8).toString('utf8').replace(/[^\x20-\x7E]/g, '.'); const isRealPdf = buffer.slice(0, 4).toString('utf8') === '%PDF'; const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const safeBase = `${timestamp}_${sanitizeFilename(filename)}`; const debugBasePath = path.join(targetDir, safeBase); console.log(`[pdfDebug] ========================================================`); console.log(`[pdfDebug] Processing PDF thumbnail for "${filename}"`); console.log(`[pdfDebug] Mimetype: ${mimetype}, Size: ${buffer.length} bytes, Magic: "${magic}", Valid PDF Header: ${isRealPdf}`); const metaLog = { timestamp: new Date().toISOString(), filename, mimetype, sizeBytes: buffer.length, magic, isRealPdf, savedFiles: {}, }; // 1. Write source PDF to disk if (saveToDisk) { const sourcePdfPath = `${debugBasePath}-source.pdf`; writeDebugFile(sourcePdfPath, buffer); metaLog.savedFiles.sourcePdf = sourcePdfPath; console.log(`[pdfDebug] Saved source PDF: ${sourcePdfPath}`); } // 2. Render page 1 PNG via pdftocairo let imageBuffer = null; try { const pdfRenderInfo = await renderPdfPageToPng(buffer); imageBuffer = pdfRenderInfo.pngBuffer; metaLog.pdftocairo = { pngBytes: imageBuffer.length, durationMs: pdfRenderInfo.durationMs, stderr: pdfRenderInfo.stderr || null, }; console.log(`[pdfDebug] pdftocairo rendered page 1 in ${pdfRenderInfo.durationMs}ms (${imageBuffer.length} bytes PNG)`); if (pdfRenderInfo.stderr) { console.warn(`[pdfDebug] pdftocairo stderr: ${pdfRenderInfo.stderr}`); } if (saveToDisk) { const page1PngPath = `${debugBasePath}-page1.png`; writeDebugFile(page1PngPath, imageBuffer); metaLog.savedFiles.page1Png = page1PngPath; console.log(`[pdfDebug] Saved page 1 PNG: ${page1PngPath}`); } } catch (err) { const durationMs = Date.now() - startTime; console.error(`[pdfDebug] pdftocairo failed for "${filename}" after ${durationMs}ms:`, err.message); metaLog.error = { stage: 'pdftocairo', message: err.message, stack: err.stack, }; metaLog.totalDurationMs = durationMs; metaLog.success = false; if (saveToDisk) { writeDebugFile(`${debugBasePath}-meta.json`, JSON.stringify(metaLog, null, 2)); } console.log(`[pdfDebug] ========================================================`); return { thumbavif: null, thumbx: null, thumby: null, meta: metaLog }; } // 3. Sharp resize & AVIF conversion try { const sharpStartTime = Date.now(); const thumbResult = await sharp(imageBuffer) .resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true }) .avif({ effort: 4 }) .toBuffer({ resolveWithObject: true }); const sharpDurationMs = Date.now() - sharpStartTime; const totalDurationMs = Date.now() - startTime; metaLog.sharp = { thumbx: thumbResult.info.width, thumby: thumbResult.info.height, avifBytes: thumbResult.data.length, durationMs: sharpDurationMs, }; metaLog.totalDurationMs = totalDurationMs; metaLog.success = true; console.log(`[pdfDebug] sharp generated AVIF thumbnail ${thumbResult.info.width}x${thumbResult.info.height} (${thumbResult.data.length} bytes) in ${sharpDurationMs}ms (total ${totalDurationMs}ms)`); if (saveToDisk) { const thumbAvifPath = `${debugBasePath}-thumb.avif`; writeDebugFile(thumbAvifPath, thumbResult.data); metaLog.savedFiles.thumbAvif = thumbAvifPath; console.log(`[pdfDebug] Saved AVIF thumbnail: ${thumbAvifPath}`); const metaJsonPath = `${debugBasePath}-meta.json`; writeDebugFile(metaJsonPath, JSON.stringify(metaLog, null, 2)); console.log(`[pdfDebug] Saved metadata log: ${metaJsonPath}`); } console.log(`[pdfDebug] Successfully completed thumbnail generation for "${filename}"`); console.log(`[pdfDebug] ========================================================`); return { thumbavif: thumbResult.data, thumbx: thumbResult.info.width, thumby: thumbResult.info.height, meta: metaLog, }; } catch (err) { const totalDurationMs = Date.now() - startTime; console.error(`[pdfDebug] sharp failed for "${filename}" after ${totalDurationMs}ms:`, err.message); metaLog.error = { stage: 'sharp', message: err.message, stack: err.stack, }; metaLog.totalDurationMs = totalDurationMs; metaLog.success = false; if (saveToDisk) { writeDebugFile(`${debugBasePath}-meta.json`, JSON.stringify(metaLog, null, 2)); } console.log(`[pdfDebug] ========================================================`); return { thumbavif: null, thumbx: null, thumby: null, meta: metaLog, }; } } /** * Default export compatible with libdashstore's generateDocumentThumb. */ export default async function generateDocumentThumb(content, mimetype, options = {}) { const opts = typeof options === 'object' && options !== null ? options : {}; const result = await debugGeneratePdfThumbnail(content, { mimetype, filename: opts.filename, debugDir: opts.debugDir, saveToDisk: opts.saveToDisk ?? true, }); return { thumbavif: result.thumbavif, thumbx: result.thumbx, thumby: result.thumby, }; }