gongxiangp2/lib/pdf-processor.ts
chenpei cd6e40d9d2 优化:网络传输性能和稳定性 - TCP缓冲区+并发控制优化
主要改动:
1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题
2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET
3. 动态并发控制 - 根据网络环境自动调整
4. Nginx超时增加到120s - 支持大分片上传
5. 上传进度修复 - 修复剩余时间计算
6. 新增任务-页面上传器和详情组件
7. 统一部署文档为deploy-remote-build.ps1

问题修复:
- 公网用户上传从200-900KB/s提升到预期3-5MB/s
- 内网用户上传更稳定,无连接重置错误
- UploadProgress组件剩余时间计算修正

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 09:33:09 +08:00

206 lines
6.8 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 { 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<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`)
// 使用 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文件损坏等
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
}
}