/** * PDF处理工具库 * * 提供PDF预览图提取功能 * 依赖:需要系统安装 poppler-utils (pdftoppm) */ import { execFile } from 'child_process' import { promisify } from 'util' import { readFile, unlink, writeFile, open } from 'fs/promises' import { tmpdir } from 'os' import path from 'path' import sharp from 'sharp' const execFileAsync = promisify(execFile) interface PdfProcessResult { success: boolean previewPath?: string width?: number height?: number error?: string } /** * 从PDF文件生成预览图(提取第一页) * @param pdfFilePath PDF文件路径 * @param outputPath 输出预览图路径(可选,如果不提供则自动生成临时文件) * @param maxWidth 最大宽度,默认1200px(与3D场景预览图一致) * @param maxHeight 最大高度,默认1600px(与3D场景预览图一致) * @returns 处理结果 */ export async function generatePdfPreview( pdfFilePath: string, outputPath?: string, maxWidth: number = 1200, maxHeight: number = 1600 ): Promise { try { console.log(`[PDF] 开始处理PDF文件: ${pdfFilePath}`) // 如果没有提供输出路径,生成唯一的临时文件 // 使用 crypto.randomBytes 确保并发上传时不会冲突 const randomSuffix = require('crypto').randomBytes(8).toString('hex') const tempOutputPath = outputPath || path.join(tmpdir(), `pdf-preview-${Date.now()}-${randomSuffix}.jpg`) const tempPpmPath = path.join(tmpdir(), `pdf-preview-${Date.now()}-${randomSuffix}.ppm`) // R38修复:检查磁盘空间(PDF处理需要临时空间存储提取的图片) // 估算:大尺寸JPEG + 最终输出图片 + 安全余量 const estimatedTempSpace = 500 * 1024 * 1024 // 预留500MB try { const { ensureEnoughDiskSpace } = require('../lib/disk-space') await ensureEnoughDiskSpace(estimatedTempSpace, 'PDF预览生成') } catch (diskError: any) { console.error('[PDF] Disk space check failed:', diskError.message) return { success: false, error: diskError.message } } // 使用 pdftoppm 提取第一页为JPEG格式 // -f 1 -l 1: 只提取第一页 // -scale-to: 只指定一个维度(宽度),高度自动按比例缩放,保持PDF原始长宽比 // -jpeg: 输出JPEG格式 const baseOutputPath = tempPpmPath.replace(/\.ppm$/, '') // 提取时使用较大的尺寸,确保有足够的像素用于后续处理 // 只指定宽度,高度自动按比例缩放,保持PDF原始长宽比 const extractSize = Math.max(maxWidth, maxHeight) * 2 console.log(`[PDF] 执行命令: pdftoppm -f 1 -l 1 -scale-to ${extractSize} -jpeg ...`) try { // R14修复:添加超时控制 - PDF处理最多10分钟 const { stderr } = await execFileAsync('pdftoppm', [ '-f', '1', '-l', '1', '-scale-to', String(extractSize), '-jpeg', pdfFilePath, baseOutputPath, ], { maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large PDFs timeout: 10 * 60 * 1000, // 10分钟超时 }) if (stderr && !stderr.includes('Writing') && !stderr.includes('Page')) { console.warn(`[PDF] pdftoppm stderr: ${stderr.substring(0, 200)}`) } } catch (execError: any) { // R14修复:检查是否是超时 if (execError?.killed && execError?.signal === 'SIGTERM') { return { success: false, error: 'PDF处理超时(10分钟),可能文件过大或损坏' } } // 检查是否是命令不存在 if ( execError?.code === 127 || execError?.code === 'ENOENT' || execError?.message?.includes('pdftoppm') ) { return { success: false, error: 'PDF处理工具未安装,请安装 poppler-utils 包' } } // 任何执行错误都应该返回失败,而不是继续处理 // 包括:权限问题、磁盘空间不足、PDF文件损坏等 const errorMsg = execError?.message || '未知错误' console.error(`[PDF] pdftoppm执行失败: ${errorMsg}`) return { success: false, error: `PDF预览图提取失败: ${errorMsg.substring(0, 200)}` } } // pdftoppm 会生成带页码的文件名,如 base-1.jpg // 尝试多种可能的文件名格式 const possiblePaths = [ `${baseOutputPath}-1.jpg`, `${baseOutputPath}-01.jpg`, `${baseOutputPath}-001.jpg`, `${baseOutputPath}-1.ppm`, `${baseOutputPath}-01.ppm`, `${baseOutputPath}-001.ppm`, ] // 查找实际生成的文件 let imageBuffer: Buffer | null = null let foundPath: string | null = null for (const possiblePath of possiblePaths) { try { imageBuffer = await readFile(possiblePath) foundPath = possiblePath break } catch { // 继续尝试下一个路径 } } if (!imageBuffer || !foundPath) { return { success: false, error: `无法找到PDF提取的图片文件,请检查PDF文件是否有效` } } console.log(`[PDF] 成功提取PDF第一页,图片大小: ${imageBuffer.length} bytes`) // 使用sharp处理图片(保持PDF原始长宽比,只限制最大尺寸,类似3D场景预览图处理) let sharpInstance = sharp(imageBuffer) // 获取图片尺寸 const metadata = await sharpInstance.metadata() const width = metadata.width || 0 const height = metadata.height || 0 console.log(`[PDF] 原始图片尺寸: ${width}x${height}`) console.log(`[PDF] 最大尺寸限制: ${maxWidth}x${maxHeight} (保持长宽比)`) // 如果是长图(高度超过maxHeight),先裁剪顶部区域(类似PSD处理) if (height > maxHeight) { console.log(`[PDF] 检测到长图 ${width}x${height},裁剪到顶部${maxHeight}px`) sharpInstance = sharpInstance.extract({ left: 0, top: 0, width: width, height: maxHeight }) } // 使用 inside 模式缩放,保持PDF原始长宽比,只限制最大尺寸(与3D场景预览图处理一致) await sharpInstance .resize(maxWidth, maxHeight, { fit: 'inside', // 保持长宽比,限制在最大尺寸内 withoutEnlargement: true // 不放大(如果PDF提取的图片已经小于限制尺寸) }) .jpeg({ quality: 90, mozjpeg: true }) .toFile(tempOutputPath) // 读取最终生成的预览图尺寸 const finalMetadata = await sharp(tempOutputPath).metadata() const finalWidth = finalMetadata.width || maxWidth const finalHeight = finalMetadata.height || maxHeight console.log(`[PDF] 预览图生成成功: ${tempOutputPath} (${finalWidth}x${finalHeight})`) // 清理临时文件(pdftoppm生成的文件) try { if (foundPath) { await unlink(foundPath).catch(() => {}) } // 尝试清理其他可能的临时文件 for (const path of possiblePaths) { await unlink(path).catch(() => {}) } } catch (e) { // 忽略清理错误 } return { success: true, previewPath: tempOutputPath, width: finalWidth, height: finalHeight } } catch (error) { console.error('[PDF] Error generating PDF preview:', error) return { success: false, error: error instanceof Error ? error.message : 'Unknown error' } } } /** * 检查文件是否为PDF格式 * @param filePath 文件路径 * @returns 是否为PDF文件 */ export async function isPdfFile(filePath: string): Promise { try { const fileHandle = await open(filePath, 'r') const buffer = Buffer.alloc(4) await fileHandle.read(buffer, 0, 4, 0) await fileHandle.close() // PDF文件以 %PDF 开头 return buffer.toString('ascii', 0, 4) === '%PDF' } catch { return false } }