/** * PDF处理工具库 * * 提供PDF预览图提取功能 * 依赖:需要系统安装 poppler-utils (pdftoppm) */ import { exec } 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 execAsync = promisify(exec) 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}`) // 如果没有提供输出路径,生成临时文件 const tempOutputPath = outputPath || path.join(tmpdir(), `pdf-preview-${Date.now()}.jpg`) const tempPpmPath = path.join(tmpdir(), `pdf-preview-${Date.now()}.ppm`) // 使用 pdftoppm 提取第一页为JPEG格式 // -f 1 -l 1: 只提取第一页 // -scale-to: 只指定一个维度(宽度),高度自动按比例缩放,保持PDF原始长宽比 // -jpeg: 输出JPEG格式 const baseOutputPath = tempPpmPath.replace(/\.ppm$/, '') // 提取时使用较大的尺寸,确保有足够的像素用于后续处理 // 只指定宽度,高度自动按比例缩放,保持PDF原始长宽比 const extractSize = Math.max(maxWidth, maxHeight) * 2 const command = `pdftoppm -f 1 -l 1 -scale-to ${extractSize} -jpeg "${pdfFilePath}" "${baseOutputPath}"` console.log(`[PDF] 执行命令: pdftoppm -f 1 -l 1 -scale-to ${extractSize} -jpeg ...`) try { const { stdout, stderr } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large PDFs }) if (stderr && !stderr.includes('Writing') && !stderr.includes('Page')) { console.warn(`[PDF] pdftoppm stderr: ${stderr.substring(0, 200)}`) } } catch (execError: any) { // 检查是否是命令不存在 if (execError?.code === 127 || execError?.message?.includes('pdftoppm')) { return { success: false, error: 'PDF处理工具未安装,请安装 poppler-utils 包' } } // 其他错误继续处理,可能是PDF文件问题 console.warn(`[PDF] pdftoppm执行警告: ${execError?.message?.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 } }