阅读视图
南都电源:股东朱保义拟合计减持不超1.58%股份
亿纬锂能:2026年一季度净利同比预增25%-35%
煌上煌:股东新余煌上煌拟减持不超3%公司股份
武汉艾米森生命科技股份有限公司-B向港交所提交上市申请书
开勒股份:拟收购威泰思光电不低于51%股权
华友钴业:2025年净利润61.1亿元,同比增长47.07%
兆易创新:实控人朱一明拟减持1.60%股份
从一个截图函数到一个 npm 包——pdf-snapshot 的诞生记
一个 PDF 文档页面截图工具的渐进式演化之路
背景
事情要从一个内部知识库项目说起。
产品同学提了一个需求:知识库里存了大量 PDF 文档,在预览列表页希望能展示文档的缩略图,用户点击缩略图后再打开完整的 PDF 文件。听起来很简单对吧?但问题是——库里只有 PDF 文件,没有缩略图。
于是摆在我面前的问题就很清晰了:如何从 PDF 文件中生成缩略图?
一番调研后发现,Node.js 生态里虽然有一些 PDF 相关的库,但要么功能太重(整个 PDF 编辑器级别)、要么只能跑在浏览器端、要么 API 设计不太友好。最后决定基于 pdf-parse 封装一个轻量级的截图工具。
本以为写个工具函数就完事了,没想到这个小需求最终演变成了一个完整的 npm 包。下面就来聊聊这个渐进式的演化过程。
渐进式方案演进
阶段一:一个 utils 函数
最初的需求很简单——给知识库用,能生成缩略图就行。
于是我在项目里写了个 utils/pdfSnapshot.ts,核心逻辑大概长这样:
import { PDFParse } from 'pdf-parse';
export async function snapshotPdf(filePath: string, pages: number[]) {
const pdfBuffer = await readFile(filePath);
const pdfParser = new PDFParse({ data: pdfBuffer });
const result = await pdfParser.getScreenshot({
partial: pages,
scale: 1.5,
imageBuffer: true,
});
return result.pages.map(page => ({
page: page.pageNumber,
data: Buffer.from(page.data),
}));
}
嗯,几十行代码,需求搞定,下班!
阶段二:抽成独立模块
好景不长,没过多久,隔壁组的同事找过来了:
"嘿,听说你写了个 PDF 截图的工具?我们这边有个文档预处理服务也需要这个功能,能不能给我们用用?"
于是我把这个函数从业务项目里抽出来,放到了一个独立的内部模块里。
但抽离的过程中发现了一些问题:
-
内存泄漏风险:
pdfjs-dist(pdf-parse的底层依赖)会在内存里缓存解析结果,大量 PDF 处理后内存蹭蹭往上涨 - 缺少取消机制:处理几百页的大文件时,用户等不及想取消,但没有中断的能力
- 输入格式单一:只支持文件路径,不支持 Buffer 和流式输入
既然要给其他模块用了,这些问题就得解决。于是开始了第一次重构:
- 引入子进程隔离,PDF 渲染跑在独立进程里,进程退出后内存自动释放
- 支持 AbortController 取消操作
- 支持文件路径 / Buffer / ReadableStream 三种输入格式
阶段三:发布为 npm 包
又过了一段时间,其他团队的同事也找过来了:
"你们那个 PDF 截图工具挺好用的,我们想在另一个项目里用,能不能发个 npm 包?" "对了,我们有个批量处理的场景,能不能加个进度回调?" "还有,我们运维同学想在脚本里用,能不能支持命令行?"
好家伙,需求越来越多了。
既然要发 npm 包,那就得认真对待了。于是有了这次比较彻底的重构:
- 完善的 TypeScript 类型定义
- 进度回调机制(
onProgress) - CLI 工具支持,方便脚本调用和 AI Agent 集成
- 多种输出格式:Buffer / Base64 / 文件路径
- 超时控制,避免子进程卡死
最终,这个工具从一个几十行的函数,演变成了一个结构完整的 npm 包——@guangmingz/pdf-snapshot。
设计思路与实现框架
聊完演化过程,来深入剖析一下 pdf-snapshot 的设计思路。
核心设计原则
在设计这个工具时,我遵循了几个核心原则:
- 主进程零污染:PDF 渲染是内存大户,不能污染主进程
- 输入输出灵活:支持多种输入格式和输出格式,适应不同场景
- 可控性强:支持取消、超时、进度回调
- API 简洁:一个函数搞定,不需要复杂的初始化流程
模块架构
整个项目的目录结构如下:
src/
├── core/
│ ├── snapshot.ts # 核心截图函数(主进程)
│ ├── pdf-info.ts # 获取 PDF 信息
│ └── worker.ts # 子进程 Worker(实际渲染)
├── utils/
│ ├── input-normalizer.ts # 输入归一化
│ ├── page-resolver.ts # 页码解析
│ ├── output-formatter.ts # 输出格式化
│ └── worker-manager.ts # 子进程管理
├── cli/
│ └── index.ts # 命令行入口
├── types.ts # 类型定义
├── errors.ts # 错误类
├── constants.ts # 常量
└── index.ts # 导出入口
可以看到,模块划分还是比较清晰的:
- core:核心逻辑,包括主进程入口和子进程 Worker
- utils:工具函数,处理输入输出和子进程管理
- cli:命令行接口
子进程隔离:内存泄漏的终极解法
这是整个设计中最关键的一环。
为什么要用子进程?因为 pdfjs-dist 在解析 PDF 时会在 V8 堆上分配大量内存,即使调用了 destroy() 方法,也很难完全释放。如果在主进程里处理大量 PDF,内存会越积越多,最终 OOM。
解法很简单也很粗暴——用子进程。子进程退出后,操作系统会自动回收它占用的所有内存,干净利落。
整个流程如下:
┌─────────────────────────────────────────────────────────────────┐
│ 主进程 (Main Process) │
├─────────────────────────────────────────────────────────────────┤
│ 1. 接收输入 (文件路径 / Buffer / Stream) │
│ 2. 归一化为临时文件路径 │
│ 3. 解析页码参数 │
│ 4. Fork 子进程,传递任务参数 │
│ 5. 等待子进程完成,接收结果文件路径 │
│ 6. 根据 output 参数格式化输出 │
│ 7. 清理临时文件 │
└───────────────────────────┬─────────────────────────────────────┘
│ IPC 通信(传递路径,不传 Buffer)
▼
┌─────────────────────────────────────────────────────────────────┐
│ 子进程 (Worker Process) │
├─────────────────────────────────────────────────────────────────┤
│ 1. 读取 PDF 文件 │
│ 2. 调用 pdf-parse 渲染指定页面 │
│ 3. 将截图写入临时目录 │
│ 4. 返回文件路径 + 元数据 │
│ 5. 退出进程(内存自动释放) │
└─────────────────────────────────────────────────────────────────┘
这里有个细节值得一提:IPC 通信只传文件路径,不传 Buffer。
为什么?因为 IPC 传输大数据很慢,一张截图可能有几 MB,如果通过 IPC 传 Buffer,性能会很差。所以我们让子进程把截图写到临时目录,IPC 只传路径和元数据(宽高、大小),主进程再按需读取。
子进程的核心代码:
process.on('message', async (msg: WorkerRequest) => {
const { pdfPath, pages, scale, outputDir } = msg;
let pdfParser: PDFParse | null = null;
try {
const pdfBuffer = await readFile(pdfPath);
pdfParser = new PDFParse({ data: pdfBuffer });
// 一次性传入所有页码,避免重复解析 PDF
const screenshotResult = await pdfParser.getScreenshot({
partial: pages,
scale,
imageBuffer: true,
});
const results: PageInfo[] = [];
for (const page of screenshotResult.pages) {
const filePath = join(outputDir, `page-${page.pageNumber}.png`);
await writeFile(filePath, Buffer.from(page.data));
results.push({ pageNumber: page.pageNumber, filePath, width: page.width, height: page.height });
}
process.send!({ success: true, pages: results });
} catch (error) {
process.send!({ success: false, error: error.message });
} finally {
await pdfParser?.destroy();
process.exit(0); // 退出进程,内存自动释放
}
});
输入归一化:统一处理多种输入格式
为了支持文件路径、Buffer、ReadableStream 三种输入格式,我设计了一个「输入归一化」层:
export async function normalizeInput(input: PdfInput): Promise<{ path: string; isTempFile: boolean }> {
// 文件路径:直接使用
if (typeof input === 'string') {
return { path: input, isTempFile: false };
}
// Buffer / Stream:写入临时文件
const tempPath = join(tmpdir(), `pdf-${randomUUID()}.pdf`);
if (Buffer.isBuffer(input)) {
await writeFile(tempPath, input);
} else {
// Stream
const chunks: Buffer[] = [];
for await (const chunk of input) {
chunks.push(chunk);
}
await writeFile(tempPath, Buffer.concat(chunks));
}
return { path: tempPath, isTempFile: true };
}
不管用户传什么格式,最终都归一化为文件路径,后续逻辑只需要处理文件路径即可。这种「归一化」的设计模式在很多场景下都很实用。
取消与超时:让操作可控
处理大文件时,用户可能等不及想取消;或者子进程卡死了需要超时兜底。这两个能力是生产环境必备的。
取消能力基于标准的 AbortController:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5 秒后取消
try {
await snapshotPdf('./large.pdf', { signal: controller.signal });
} catch (error) {
if (error instanceof SnapshotAbortedError) {
console.log('操作被取消');
}
}
超时控制在子进程管理器里实现:
const timer = setTimeout(() => {
child.kill('SIGKILL'); // 强制杀死子进程
reject(new SnapshotTimeoutError(timeout));
}, timeout);
进度回调:让等待不再焦虑
批量处理时,用户需要知道当前进度。虽然子进程是一次性处理所有页面的,但我们至少可以在「开始」和「完成」两个时机通知用户:
await snapshotPdf('./document.pdf', {
pageRange: [1, 100],
onProgress: (progress) => {
// progress.stage: 'preparing' | 'completed'
// progress.percent: 0 | 100
console.log(`[${progress.stage}] ${progress.percent}%`);
},
});
为什么不支持逐页进度?因为
pdf-parse的getScreenshot是一次性处理所有页面的,中间没有回调钩子。如果要实现逐页进度,需要改成逐页调用,但这样会有性能问题(每次调用都要重新解析 PDF)。权衡之下,选择了「阶段进度」的方案。
CLI 工具:让 AI 也能用
最后聊聊 CLI 工具。
为什么要做 CLI?除了方便运维同学写脚本,还有一个重要原因——方便 AI Agent 调用。
现在各种 AI 编程助手越来越流行,它们通常通过命令行来调用工具。如果你的工具只有 API 没有 CLI,AI 就很难直接使用。
pdf-snapshot 的 CLI 使用起来很简单:
# 截取第 1-10 页
pdf-snapshot -r 1-10 -o ./output document.pdf
# 截取指定页
pdf-snapshot -p 1,5,10 document.pdf
# 从标准输入读取(支持管道)
cat document.pdf | pdf-snapshot -o ./output -r 1-5 -
# 仅查看 PDF 信息
pdf-snapshot --info document.pdf
CLI 的实现基于 commander,核心是把命令行参数映射到 snapshotPdf 的 options:
program
.argument('<input>', 'PDF 文件路径')
.option('-o, --output <dir>', '输出目录', './pdf-screenshots')
.option('-p, --pages <pages>', '离散页码')
.option('-r, --range <range>', '页码范围')
.option('-s, --scale <number>', '缩放比例', '1.5')
.action(async (input, opts) => {
const results = await snapshotPdf(input, {
output: 'file',
outputDir: opts.output,
pageRange: parseRange(opts.range),
pages: parsePages(opts.pages),
scale: parseFloat(opts.scale),
});
console.log(`✅ 完成!已保存 ${results.length} 张截图`);
});
还贴心地加了进度条:
⏳ 正在截图...
[████████████████████████████████████████] 100% | 50/50 页
✅ 完成!已保存 50 张截图到 ./pdf-screenshots
总结
回顾 pdf-snapshot 的演化过程:
- 阶段一:一个 utils 函数,解决单点需求
- 阶段二:抽成独立模块,解决内存泄漏、支持取消和多种输入格式
- 阶段三:发布 npm 包,增加进度回调、CLI 工具、完善类型定义
这个过程其实挺有代表性的。很多时候我们写的工具函数,一开始只是为了解决眼前的问题,但随着需求的增加和使用场景的扩展,它会逐渐演化成一个更通用、更健壮的模块。
关键是要在演化过程中保持代码的可维护性和可扩展性。子进程隔离、输入归一化、取消超时机制……这些设计不是一开始就有的,而是在实际使用中逐步发现问题、解决问题后沉淀下来的。
最后,如果你也有 PDF 截图的需求,欢迎试试 pdf-snapshot!
GitHub 地址:pdf-snapshot
有问题欢迎提 Issue,有改进想法欢迎 PR!
*ST国华:首次触及市值退市风险警示
OpenAI要求加利福尼亚州和特拉华州调查马斯克
深信服:拟8000万元-9000万元回购公司股份
氪星晚报|潘兴广场拟94亿欧元现金加股票收购环球音乐集团;高盛策略师:当前科技股估值带来投资机会;中国央行连续第17个月增持黄金
大公司:
4月6日,有媒体报道,富士康正在试产苹果首款折叠屏手机。对此,4月7日,苹果产业链人士向记者表示,苹果首款折叠屏手机项目方案早已确定,目前该项目正在正常推进中,试产是产品正式量产前必经阶段,一般需经过工程验证、设计验证、生产试产验证,最后再到正式量产,按正常推进计划,该产品将于今年秋季发布。(证券时报)
36氪获悉,截至4月7日,名创优品已在全球落地65家“乐园系”门店,涵盖MINISO SPACE、MINISO FRIENDS、MINISO LAND、SUPER MINISO等核心店态。名创优品表示,该系列门店平均业绩较常规门店增长超900%,预计2026年底将达到200家。
亿万富翁投资者比尔·阿克曼旗下潘兴广场(Pershing Square)当地时间4月6日宣布,拟以94亿欧元现金加股票的方式收购环球音乐集团,已提交不具约束力的提案。现金和股票的总对价预计每股价值30.40欧元,较环球音乐集团的股价溢价78%。(界面)
36氪获悉,领益智造在互动平台表示,公司已为国内外头部客户供应折叠屏终端硬件,核心产品涵盖不锈钢/钛合金/碳纤维等材质的折叠屏支撑件及中框、铜/不锈钢/钢铜复合/铝合金/钛等材质的VC均热板、折叠屏转轴模组、模切功能件/结构件、充电器等关键组件。目前公司折叠屏各类项目推进顺利,正按规划推进量产爬坡及客户交付。
4月7日,一则关于海康威视的传言在社交平台迅速传播。网传图片显示,海康威视监控系统出现漏洞,总部300多人被带走调查。针对这一传言,记者以投资者身份致电海康威视,接线工作人员明确表示,上述传言系谣言,公司不存在上述情况,并强调“不信谣不传谣”。据该工作人员个人了解,公司目前在伊朗暂无相关业务。(21财经)
投融资:
36氪获悉,超高仿生情感交互机器人企业“首形科技”宣布完成数亿元人民币A1轮融资。本轮由华控基金及某互联网大厂联合领投,嘉御资本、鹏瑞基金、亦庄国投、上海半导体产投、南山战新投,以及老股东招商局创投、顺为资本、弘晖基金、厚雪资本、东方富海跟投。本轮资金将主要用于多模态具身交互系统与情绪基座模型的持续迭代升级,仿生面部核心部件与材料体系的规模化优化,以及标准化交付与全球市场拓展。
近日,浙江云澎科技有限公司宣布已完成A+轮融资。本次融资由亚投睿才(台州路桥)创业投资合伙企业(有限合伙)、金华市金婺赋能股权投资合伙企业(有限合伙)共同投资。云澎科技以“AI赋能健康生活·数智引领产业未来”为核心,打造以“膳食营养健康”为中心的全场景解决方案。资本市场的持续加码,为云澎科技布局AI+健康产业新赛道、拓展业务边界注入强劲动力。
新产品:
谷歌4月7日宣布通过AI提升心理健康支持:Gemini新增“求助模块”,识别危机时提供一键连接热线;Google.org投入3000万美元资助全球热线,并利用AI模拟平台培训志愿者;同时加强对未成年人的情感隔离保护,确保AI辅助而非替代专业诊疗。(界面)
今日观点:
高盛策略师近日指出,科技板块的相对低迷“开始产生具有吸引力的估值机会”。以Peter Oppenheimer为首的高盛团队表示,超大规模数据中心资本支出引发的担忧,导致科技股估值相对预期盈利增长低于全球市场整体估值。该团队指出,科技股市盈率市盈率低于非必需消费品、必需消费品和工业行业,但其增长率依然强劲,认为该行业没有处于泡沫中,其估值“低于以往峰值时的典型水平”。伊朗战争使该行业更具吸引力,因为科技公司的现金流对经济增长的敏感性较低。(财联社)
其他值得关注的新闻:
发改委:2026年4月7日国家继续实施调控,成品油价格适当调整
36氪获悉,3月23日国内成品油价格调整以来,国际市场原油价格大幅震荡。为减缓国际油价上涨对国内的冲击,国家继续对成品油价格采取调控措施。按照成品油价格机制计算,自4月7日24时起,国内汽、柴油(标准品)价格每吨应分别上调800元、770元,调控后实际上调420元、400元。
记者4月7日从自然资源部获悉,中国第42次南极考察队成功完成我国首次南极冰层热水钻探试验,钻深达3413米,突破了国际极地热水钻探的2540米的最深纪录。(央视新闻)
36氪获悉,中国央行数据显示,中国3月末黄金储备报7,438万盎司,2月末为7422万盎司,为连续第17个月增持黄金。
法国央行近期披露了一个特殊项目,即其卖掉了该行在美国托管的黄金储备,然后在欧洲买回相应重量的黄金。此举保证了2026年初时该行的黄金储备与2025年初一致,但又因为买进卖出时的价格差异而产生了可观的外汇收益。该交易涉及129吨非标准金条,占法国央行金条储备的5%,目前该行已将全部黄金储备集中到巴黎金库。法国央行行长强调,将金条留在巴黎而不是纽约的决定并非出于政治动机。(财联社)
市场监管总局今天(7日)公布数据显示,2025年,全国事业单位和规模以上企业广告业务收入首次突破2万亿元,达20502.1亿元,比2020年收入规模实现“翻一番”,年均增长率达16.8%。(央视新闻)
日本总务省4月7日公布的调查结果显示,由于通货膨胀持续高企令民众实际可支配收入受到挤压,今年2月,日本实际家庭消费支出连续第三个月同比下滑。数据显示,2月日本两人及以上家庭月平均消费支出为28.94万日元(1美元约合159日元),与去年同期相比减少0.4%,扣除物价因素后实际同比下降1.8%,实际家庭消费自去年12月以来连续3个月同比下滑。(新华社)