@openlee/pdf-images
v1.0.0
Published
合同模板 PDF 转无损 PNG 图片 SDK
Readme
PDF to Images SDK
基于 PDF.js 的前端 PDF 转图片 SDK,专为合同模板场景设计——确保内容不变形、不移位、无损输出,完整保留水印 / 签章 / 印章。
作者: Jas.lee
邮箱: [email protected]
特性
- 不变形 — 基于 DPI 精确计算
scale = DPI / 72,不依赖屏幕pixelRatio - 不移位 — 使用
intent: 'print'+annotationMode: ENABLE完整渲染所有可见元素 - 无损输出 — 仅 PNG 格式,白色背景强制填充(避免透明 PDF 变黑)
- 水印 / 签章完整保留 — 注释层全量渲染,印章不会丢失
- 中文字体支持 — CMap + StandardFontData 配置,中文印章正常显示
- 多源输入 — URL / File / ArrayBuffer / TypedArray / PDFDocumentProxy
- DPI 可配 — 96(屏幕预览)/ 150(标准)/ 300(高质量)/ 600(档案级)
- 大文件分批处理 —
convertLargeFile()按批渲染 + 回调,避免内存溢出 - PDF 预校验 —
validate()提前校验有效性,返回的实例可复用
安装
npm install @openlee/pdf-images快速开始
使用 Vite 插件(推荐)
Vite 插件会自动将 CMap 和字体资源复制到构建输出目录,确保中文 PDF 正常渲染:
npm install @openlee/pdf-images// vite.config.ts
import { defineConfig } from 'vite';
import { pdfImagesPlugin } from '@openlee/pdf-images/vite';
export default defineConfig({
plugins: [
pdfImagesPlugin({ assetsDir: 'pdf-images' })
]
});// 代码中使用
import { ContractPDFToImageSDK } from '@openlee/pdf-images';
const sdk = new ContractPDFToImageSDK({
dpi: 150,
cMapUrl: '/pdf-images/cmaps/',
standardFontDataUrl: '/pdf-images/standard_fonts/',
});
const results = await sdk.convert('https://example.com/contract.pdf');不使用构建工具
如果项目不使用 Vite,需要手动复制资源文件:
# 复制资源到 public 目录
cp -r node_modules/@openlee/pdf-images/dist/cmaps public/cmaps
cp -r node_modules/@openlee/pdf-images/dist/standard_fonts public/standard_fontsimport { ContractPDFToImageSDK } from '@openlee/pdf-images';
const sdk = new ContractPDFToImageSDK({
dpi: 150,
cMapUrl: '/cmaps/',
standardFontDataUrl: '/standard_fonts/',
});使用 CDN(需要网络)
开发环境可以使用 CDN 加载资源:
import { ContractPDFToImageSDK } from '@openlee/pdf-images';
const VERSION = '0.1.0';
const CDN = `https://cdn.jsdelivr.net/npm/@openlee/pdf-images@${VERSION}/dist`;
const sdk = new ContractPDFToImageSDK({
dpi: 150,
cMapUrl: `${CDN}/cmaps/`,
standardFontDataUrl: `${CDN}/standard_fonts/`,
});校验 + 复用(避免重复加载)
const validation = await sdk.validate('https://example.com/contract.pdf');
if (!validation.valid) {
throw new Error(`PDF 无效: ${validation.error}`);
}
console.log(`有效 PDF,共 ${validation.numPages} 页`);
// 直接传入 validation.pdf,省掉一次网络请求
const results = await sdk.convert(validation.pdf);单页转换
const page3 = await sdk.convertPage('https://example.com/contract.pdf', 3);大文件分批处理
await sdk.convertLargeFile('https://example.com/large-contract.pdf', {
batchSize: 5,
onBatchComplete: (batch) => {
// 每批完成后立即上传,不等全部渲染结束
batch.forEach(({ pageNum, blob }) => {
const formData = new FormData();
formData.append('image', blob, `page-${pageNum}.png`);
fetch('/api/upload', { method: 'POST', body: formData });
});
},
});API 概览
构造函数参数
| 参数 | 类型 | 默认值 | 必填 | 说明 |
|------|------|--------|:----:|------|
| dpi | number | 150 | | 渲染 DPI,可选 96 / 150 / 300 / 600 |
| cMapUrl | string | — | 是 | CMap 文件路径(中文 PDF 必需,使用 Vite 插件自动复制) |
| standardFontDataUrl | string | — | 是 | 标准字体数据路径(使用 Vite 插件自动复制) |
| workerSrc | string | — | | PDF.js Worker 文件路径(不提供时使用 FakeWorker 模式) |
| concurrency | number | 2 | | 并发渲染页数 |
| outputDataUrl | boolean | true | | 是否同时生成 Data URL(关闭可节省内存) |
| onProgress | function | — | | 进度回调 (ProgressInfo) => void |
| onError | function | — | | 单页错误回调 (ErrorInfo) => void |
| onBatchComplete | function | — | | 批次完成回调(仅 convertLargeFile) |
核心方法
| 方法 | 说明 |
|------|------|
| validate(source) | 校验 PDF 有效性,返回 { valid, numPages, pdf } |
| convert(source, options?) | 全量转换,返回 PageResult[] |
| convertPage(source, pageNum, options?) | 单页转换,返回 PageResult |
| convertLargeFile(source, options) | 大文件分批处理,返回 Promise<void> |
| destroy() | 释放所有资源 |
PageResult
interface PageResult {
pageNum: number; // 页码(从 1 开始)
width: number; // 图片宽度(CSS 像素)
height: number; // 图片高度(CSS 像素)
dpi: number; // 实际渲染 DPI
blob: Blob; // PNG Blob(上传 / 保存)
dataUrl?: string; // Data URL(页面预览,outputDataUrl=true 时存在)
}技术方案
为什么选 PDF.js
| 方案 | 浏览器运行 | 渲染质量 | 中文支持 | 水印/签章保留 | |------|:---------:|:-------:|:-------:|:-----------:| | PDF.js | 是 | 高 | 好 | 完整 | | pdf-lib | 是 | 无渲染 | 一般 | 不支持 | | pdf2pic | 否(Node) | 高 | 好 | 完整 |
PDF.js 是 Firefox 内置的 PDF 渲染器(48k+ stars),在浏览器端能实现最高保真度的渲染。
渲染管线
输入 PDF(URL / File / ArrayBuffer / TypedArray / PDFDocumentProxy)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ loadDocument() │
│ ├─ normalizeSource() 统一转为 pdfjsLib.getDocument() 参数 │
│ ├─ 注入 cMapUrl + standardFontDataUrl(中文印章必需) │
│ └─ 返回 PDFDocumentProxy │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ renderPage() × N 页(按 concurrency 分批并发) │
│ │
│ 1. page.getViewport({ scale: DPI / 72 }) │
│ 2. createElement('canvas') │
│ └─ getContext('2d', { alpha: false }) ← 禁用透明通道 │
│ 3. fillStyle = '#FFFFFF' + fillRect() ← 白色背景填充 │
│ 4. imageSmoothingQuality = 'high' ← 高质量插值 │
│ 5. page.render({ │
│ intent: 'print', ← 打印模式,含所有可见元素 │
│ annotationMode: ENABLE, ← 渲染注释层(水印/签章) │
│ }) │
│ 6. canvas.toBlob('image/png') ← PNG Blob │
│ 7. canvas.toDataURL('image/png') ← Data URL(可选) │
│ 8. canvas.width = 0 ← 立即释放显存 │
└─────────────────────────────────────────────────────────────┘
│
▼
PageResult[] (pageNum, width, height, dpi, blob, dataUrl?)关键设计决策
1. DPI 驱动,不依赖 pixelRatio
scale = targetDPI / 72 (PDF 默认 72 DPI,即 1/72 英寸)屏幕 pixelRatio 因设备而异(1x/2x/3x),会导致输出尺寸不可控。DPI 驱动确保同一配置在任何设备上输出相同像素尺寸:
| DPI | scale | A4 输出尺寸 | 适用场景 | |-----|-------|------------|---------| | 96 | 1.33 | 794 × 1123 px | 屏幕预览 | | 150 | 2.08 | 1240 × 1754 px | 标准输出(推荐) | | 300 | 4.17 | 2480 × 3508 px | 印刷 / 印章清晰 | | 600 | 8.33 | 4960 × 7016 px | 档案级存储 |
2. 水印 / 签章不丢失
page.render({
intent: 'print', // 打印模式:渲染所有可见元素
annotationMode: AnnotationMode.ENABLE, // 启用注释层:水印、签章、印章
});默认的 intent: 'display' 会跳过部分注释层元素。合同场景必须用 'print' 模式。
3. 透明背景不变黑
const ctx = canvas.getContext('2d', { alpha: false }); // 禁用透明通道
ctx.fillStyle = '#FFFFFF'; // 白色填充
ctx.fillRect(0, 0, canvas.width, canvas.height);alpha: false 时 canvas 默认背景为黑色。先填白再渲染,确保透明 PDF 也是白色底。
4. resolveDoc 复用模式
validate() 返回的 pdf(PDFDocumentProxy)可以直接传给 convert(),内部通过鸭子类型检测:
// 已加载的 PDFDocumentProxy → 直接复用,shouldDestroy = false
if (this.isPDFDocumentProxy(source)) {
return { pdf: source, shouldDestroy: false };
}
// 其他来源 → 加载新文档,shouldDestroy = true,用完自动销毁这样避免了「校验一次网络请求 + 转换又一次网络请求」的问题。
5. 内存管理
- 渲染完立即
canvas.width = 0释放显存(高 DPI 下 canvas 可达数十 MB) convertLargeFile()按批回调,不累积全部结果到内存destroy()统一清理所有打开的 PDFDocumentProxy
构建配置
Vite 构建时将 pdfjs-dist 打包进 SDK,用户无需单独安装。同时通过 copyPdfjsAssets 插件自动复制 CMap 和字体资源到 dist/。
// vite.config.ts
build: {
target: 'es2022', // 兼容 pdfjs-dist 现代语法
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: () => 'pdf-to-images.es.js',
},
// pdfjs-dist 打包进 SDK(不 external),用户无需单独安装
}框架集成
import { useEffect, useState } from 'react';
import { ContractPDFToImageSDK } from '@openlee/pdf-images';
function ContractViewer({ pdfUrl }) {
const [images, setImages] = useState([]);
const [progress, setProgress] = useState(0);
useEffect(() => {
const sdk = new ContractPDFToImageSDK({
dpi: 150,
onProgress: ({ percent }) => setProgress(percent),
});
sdk.convert(pdfUrl)
.then(setImages)
.finally(() => sdk.destroy());
}, [pdfUrl]);
return (
<div>
{images.length === 0
? <p>渲染中... {progress}%</p>
: images.map(({ pageNum, dataUrl }) => (
<img key={pageNum} src={dataUrl} alt={`第 ${pageNum} 页`} />
))}
</div>
);
}<template>
<div>
<p v-if="loading">渲染中... {{ progress }}%</p>
<img
v-for="img in images"
:key="img.pageNum"
:src="img.dataUrl"
:alt="`第 ${img.pageNum} 页`"
/>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ContractPDFToImageSDK } from '@openlee/pdf-images';
const props = defineProps(['pdfUrl']);
const images = ref([]);
const progress = ref(0);
const loading = ref(false);
let sdk = null;
onMounted(async () => {
sdk = new ContractPDFToImageSDK({
dpi: 150,
onProgress: ({ percent }) => progress.value = percent,
});
loading.value = true;
images.value = await sdk.convert(props.pdfUrl);
loading.value = false;
});
onUnmounted(() => sdk?.destroy());
</script>错误处理
try {
const validation = await sdk.validate(pdfUrl);
if (!validation.valid) throw new Error(validation.error);
const results = await sdk.convert(validation.pdf);
} catch (error) {
switch (error.name) {
case 'PasswordException': // PDF 加密,需输入密码
case 'InvalidPDFException': // 文件损坏
case 'MissingPDFException': // 文件不存在
case 'UnexpectedResponseException': // 服务器错误 / CORS
case 'WorkerError': // Worker 加载失败
case 'RenderingError': // 渲染异常
}
}浏览器兼容性
Chrome 88+ / Firefox 78+ / Safari 14+ / Edge 88+
本地开发
npm install # 安装依赖
npm run dev # 启动 Demo(http://localhost:5173)
npm run build # 构建 SDK(输出到 dist/)
npm test # 运行单元测试
npm run typecheck # TypeScript 类型检查文档
License
MIT
