PDF 每页转 PNG:扫描件、课件批量导出
用 poppler 的 pdftoppm 把 PDF 每一页导出成 PNG,支持只转指定页、调整 150 或 300 dpi。先读页数和页面尺寸,再写盘,原 PDF 不动。
本页目录
30 秒速查
- 先装 poppler,确认
pdftoppm -v能跑- 整份转:
node resources/pdf-to-png/pdf-to-png.mjs input/sample.pdf- 只转第 2–5 页:加
--pages 2-5- 输出在 PDF 旁边的
sample-png/- 屏幕看 150 dpi,印刷或放大看 300 dpi
这篇教程解决什么问题
课件、合同、扫描件是 PDF,平台却要一张张图片:公众号要配图,PPT 要素材,小红书要预览图。手动截图,页面会歪,分辨率也不稳。
这篇用 poppler 的 pdftoppm:读 PDF 页数,按页导出 PNG,能指定页码范围,也能调 dpi。原 PDF 不动,结果写进单独目录。

装 poppler
pdftoppm 和 pdfinfo 都在 poppler 里,两个命令一起装。
macOS 用 Homebrew:
brew install poppler
Ubuntu 或 Debian:
sudo apt install poppler-utils
Windows 没有一条命令的安装方式。到 poppler-windows 的 Release 下载 zip,解压后把里面的 bin 目录加到 PATH(就是写进电脑找程序的那张名单;加法与排查看ffmpeg 不是内部或外部命令)。
装完确认两个命令都在:
pdftoppm -v
pdfinfo -v
版本号会不同,能看到 pdftoppm version 和 pdfinfo version 就行。
建两个文件
在 resources/pdf-to-png/ 下放两个文件:core.mjs 负责读信息、调命令、收集结果;pdf-to-png.mjs 只负责参数和打印。
pdf-to-png/
├── core.mjs
└── pdf-to-png.mjs
写核心模块
保存为 resources/pdf-to-png/core.mjs:
import { execFile } from "node:child_process";
import { mkdir, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import sharp from "sharp";
const run = promisify(execFile);
export function parsePages(value) {
if (!value) return null;
const match = /^(\d+)(?:-(\d+))?$/.exec(value);
if (!match) throw new Error("--pages 格式是 1 或 2-5。");
const first = Number(match[1]);
const last = match[2] ? Number(match[2]) : first;
if (first < 1 || last < first) {
throw new Error("--pages 从 1 开始,结束页不能小于起始页。");
}
return { first, last };
}
export function buildArgs({ input, prefix, dpi = 150, pages = null }) {
const args = ["-png", "-r", String(dpi)];
if (pages) args.push("-f", String(pages.first), "-l", String(pages.last));
args.push(input, prefix);
return args;
}
export async function readPdfInfo(input, { pdfinfo = "pdfinfo" } = {}) {
const { stdout } = await run(pdfinfo, [input], { maxBuffer: 10 * 1024 * 1024 });
const pages = Number(/^Pages:\s+(\d+)/m.exec(stdout)?.[1]);
const size = /^Page size:\s+(.+)$/m.exec(stdout)?.[1]?.trim() ?? "未知";
if (!Number.isFinite(pages) || pages < 1) {
throw new Error(`读不出页数:${input}`);
}
return { pages, size };
}
export async function convertPdf({
input,
outDir,
dpi = 150,
pages = null,
pdftoppm = "pdftoppm",
pdfinfo = "pdfinfo",
} = {}) {
const info = await readPdfInfo(input, { pdfinfo });
await mkdir(outDir, { recursive: true });
const stem = path.basename(input, path.extname(input));
const prefix = path.join(outDir, `${stem}-page`);
await run(pdftoppm, buildArgs({ input, prefix, dpi, pages }), {
maxBuffer: 20 * 1024 * 1024,
});
const base = path.basename(prefix);
const names = (await readdir(outDir))
.filter((name) => name.startsWith(`${base}-`) && name.endsWith(".png"))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const files = [];
for (const name of names) {
const file = path.join(outDir, name);
const detail = await stat(file);
const meta = await sharp(file).metadata();
files.push({
file,
page: Number(new RegExp(`${base}-(\\d+)\\.png$`).exec(name)?.[1]),
bytes: detail.size,
width: meta.width,
height: meta.height,
});
}
return { info, prefix, files };
}
三个函数各管一件事:
parsePages():把2-5变成{ first: 2, last: 5 },顺手挡住5-2这种倒序;readPdfInfo():从pdfinfo的输出里取页数和页面尺寸;convertPdf():先建目录,再调pdftoppm,最后按页码排序并读每张图的宽高。
pdftoppm 的输出文件名由前缀决定,所以脚本用 <PDF 名>-page 当前缀,转完再按数字排序,page-10 不会排到 page-2 前面。
写命令入口
保存为 resources/pdf-to-png/pdf-to-png.mjs:
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { convertPdf, parsePages } from "./core.mjs";
function kb(bytes) {
return Math.max(1, Math.round(bytes / 1024)) + " KB";
}
function display(file) {
const relative = path.relative(process.cwd(), file);
return relative.startsWith("..") ? file : relative;
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const { values, positionals } = parseArgs({
options: {
out: { type: "string" },
dpi: { type: "string", default: "150" },
pages: { type: "string" },
},
allowPositionals: true,
});
const input = positionals[0];
if (!input) {
console.error("用法:node resources/pdf-to-png/pdf-to-png.mjs input/sample.pdf [--dpi 150] [--pages 1-3]");
process.exitCode = 1;
} else {
const started = Date.now();
try {
const pages = parsePages(values.pages);
const outDir =
values.out ??
path.join(path.dirname(input), `${path.basename(input, path.extname(input))}-png`);
const result = await convertPdf({
input,
outDir,
dpi: Number(values.dpi),
pages,
});
console.log(`文件:${input}`);
console.log(`页数:${result.info.pages} 页面:${result.info.size}`);
console.log(`分辨率:${values.dpi} dpi\n`);
for (const item of result.files) {
console.log(
`第 ${item.page} 页 ${display(item.file)} ${item.width}×${item.height} px ${kb(item.bytes)}`,
);
}
console.log(
`\n共 ${result.files.length} 页,用时 ${((Date.now() - started) / 1000).toFixed(1)} 秒。`,
);
} catch (error) {
console.error(
error.code === "ENOENT"
? "找不到 pdftoppm 或 pdfinfo。先装 poppler,再确认命令在 PATH 里。"
: error.message,
);
process.exitCode = 1;
}
}
}
整份转
拿一份两页的 A4 PDF 试:
node resources/pdf-to-png/pdf-to-png.mjs input/sample.pdf
真实输出:
文件:input/sample.pdf
页数:2 页面:595 x 842 pts (A4)
分辨率:150 dpi
第 1 页 input/sample-png/sample-page-1.png 1240×1755 px 21 KB
第 2 页 input/sample-png/sample-page-2.png 1240×1755 px 22 KB
共 2 页,用时 0.2 秒。
A4 在 150 dpi 下是 1240×1755 像素,做公众号配图、PPT 素材够用。文件默认放在 PDF 旁边的 sample-png/,不改原文件。

只转指定页
页码从 1 开始,--pages 支持单页和范围:
node resources/pdf-to-png/pdf-to-png.mjs input/sample.pdf --pages 2 --out input/only-2
文件:input/sample.pdf
页数:2 页面:595 x 842 pts (A4)
分辨率:150 dpi
第 2 页 input/only-2/sample-page-2.png 1240×1755 px 22 KB
共 1 页,用时 0.1 秒。
只要合同第 3 页、课件第 2–5 页时,先转指定页,能省一半时间,也不用事后翻文件夹删图。
调分辨率
| 场景 | dpi | A4 输出像素 | 两页体积 |
|---|---|---|---|
| 屏幕预览、配图 | 150 | 1240×1755 | 约 43 KB |
| 印刷、放大看细节 | 300 | 2480×3509 | 约 117 KB |
300 dpi 的实际输出:
第 1 页 input/dpi300/sample-page-1.png 2480×3509 px 59 KB
第 2 页 input/dpi300/sample-page-2.png 2480×3509 px 58 KB
共 2 页,用时 0.6 秒。
dpi 翻倍,像素变成四倍,文件也会明显变大。扫描件本身只有 150 dpi 的信息,强行转 300 dpi 不会变清楚,只是把图放大。

常见坑
1. 扫描件转出来还是糊
pdftoppm 只负责把 PDF 页面画成图,不会补细节。原 PDF 里的图如果是 96 dpi 的扫描件,300 dpi 输出也只是更大地糊。先放大原 PDF 看一眼,源头不清楚就重新扫描。
2. 输出目录里有上次的旧页
脚本不会删文件。同一份 PDF 上次转了 10 页,这次只转 2 页,page-3.png 到 page-10.png 还会留在目录里。换页码范围前先清空输出目录。
3. Windows 上提示找不到命令
poppler 的 Windows 包解压后不会自动进 PATH。把 bin 目录加进去,再开一个新的终端;旧终端不会重新读 PATH。
4. 中文变成方块或乱码
pdftoppm 渲染的是 PDF 里嵌入的字体。原 PDF 没嵌字体时,换一台机器可能缺字。用能正常显示原 PDF 的机器转,或者让源文件导出时嵌入字体。
下一步读什么
常见问题
转出来的文字为什么发虚?
多半是原 PDF 本身就是扫描图,或者 dpi 太低。先看原 PDF 放大后清不清楚;源文件清楚,就把 --dpi 调到 300 再转。
能直接转 Word 或 PPT 吗?
不能。pdftoppm 只读 PDF。先把 Word、PPT 另存为 PDF,再跑这个脚本。
加密的 PDF 怎么办?
脚本没有处理密码。先用阅读器输入密码,另存一份不加密的 PDF,再转。
输出目录里为什么有上次的旧页?
脚本不会删除已有文件。改了页码范围或换了 PDF,先清空输出目录再跑,避免把旧图当成这次的结果。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。