gongxiangp2/lib/pdf-processor.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

256 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
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<PdfProcessResult> {
return withImageProcessingSlot(() =>
generatePdfPreviewWithSlot(pdfFilePath, outputPath, maxWidth, maxHeight)
)
}
async function generatePdfPreviewWithSlot(
pdfFilePath: string,
outputPath: string | undefined,
maxWidth: number,
maxHeight: number
): Promise<PdfProcessResult> {
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<boolean> {
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
}
}