Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
256 lines
8.3 KiB
TypeScript
256 lines
8.3 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
}
|