三甲大楼方又圆
五重纸框难填单
草台班子今犹在
不见当年程序员
以上是定场诗。
阅读本文需要有基本的macOS操作经验,会使用终端类的App。
继续阅读代表您知晓并同意不适当的操作可能会损害您的利益,与本文作者无关。
C 缘起
最近我有很多发票要报销,收款方给的这些发票是PDF文件,而我报销是要通过指定的的App上传图片文件——它只支持图片文件,PDF不可以。
这有何难?不就是转换吗?
Dm 古法手搓
我用macOS自带的预览(Preview)就能做到,打开一个PDF文件然后导出图片:

转了几个之后我觉得不对劲——我的发票很多,这样太累了。而且有些PDF文件是多页的,这么搞它只会转换首页到图像文件,我还得在预览的左边栏拖拽各页的缩略图到头部(成为首页)再分别转换,更累了。手搓还带来了人工出错的可能,尤其是脑残眼残手残的人,漏了哪页都很麻烦。我可以预见到我以后还有很多发票需要提交,必须得简化这个转换操作。

Em 自动化
访达(Finder)自带的快速操作(快速動作、Quick Actions)有一些出厂预先设置的操作,比如iPhone用户常用的HEIC转为其它格式。

但是!PDF它!没!有!
得自己创建。
打开自动操作(Automator)这个App,这是一个拖拽点点点就能“编程”的好东西。功能很多很强大,当然能搞快速操作。
ℹ️ 这个App很不起眼,找不到可以用CMD+空格来直接启动。

然后建立一个新的操作,文件类型选择PDF,App选择访达。第一步搜索“渲染PDF为图像”,第二步则是选择将成品放到哪个文件夹,要没有这一步就找不到了。

保存下就好了。之后在访达里面对PDF使用右键菜单就可以转换了。多页文件也能转换。总体来看效果不太好。
- 所有图像文件都输出同一个文件夹,不能自选去处,也不能和原始PDF文件在一起。
- 文件名也是乱乱的。
- 无论原始的发票是横向还是纵向,输出的图像文件每个都是A4纸大小,纵向。(这个问题是发票开具者的锅,不赖macOS)

F 写个脚本
既然上面的方法不太令人满意,那么我们自己写一段。
前面还是一样的,但是不再使用系统提供的PDF渲染,如图,我们叫它去执行一个/bin/zsh脚本。

for pdf in "$@"; do
/usr/bin/swift /Users/<用户名>/.local/bin/pdf2png.swift "$pdf"
done
关于脚本语言,可选的有Python和Swift,现代 macOS 上,使用 Swift 调用 Quartz 框架比使用 Python 更加原生、高效且无需额外配置环境。
写一段Swift脚本:
没有文件夹的先创建下,这里需要使用终端类App,比如macOS自带的,CMD+空格然后输入ter即可:
mkdir -p ~/.local/bin
然后把下面脚本存成~/.local/bin/pdf2png.swift
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
let args = CommandLine.arguments
guard args.count >= 2 else {
fputs("Usage: pdf2png.swift <file.pdf>\n", stderr)
exit(1)
}
let pdfPath = args[1]
let pdfURL = URL(fileURLWithPath: pdfPath)
guard let document = CGPDFDocument(pdfURL as CFURL) else {
fputs("Cannot open PDF: \(pdfPath)\n", stderr)
exit(1)
}
let directory = pdfURL.deletingLastPathComponent()
let baseName = pdfURL.deletingPathExtension().lastPathComponent
// Approximately matches the raster size produced by Preview
// in the sample PDFs.
let scale: CGFloat = 3.8
for pageNumber in 1...document.numberOfPages {
guard let page = document.page(at: pageNumber) else {
fputs("Cannot read page \(pageNumber)\n", stderr)
continue
}
/*
IMPORTANT:
Use CropBox rather than MediaBox.
Many PDFs, especially electronic invoices, contain a large
MediaBox but define the actual visible page using CropBox.
Preview follows the CropBox as well.
*/
let boxType: CGPDFBox = .cropBox
let box = page.getBoxRect(boxType)
let rotation = page.rotationAngle
let pageWidth: CGFloat
let pageHeight: CGFloat
if rotation == 90 || rotation == 270 {
pageWidth = box.height
pageHeight = box.width
} else {
pageWidth = box.width
pageHeight = box.height
}
let pixelWidth = Int(round(pageWidth * scale))
let pixelHeight = Int(round(pageHeight * scale))
guard pixelWidth > 0, pixelHeight > 0 else {
fputs("Invalid page size on page \(pageNumber)\n", stderr)
continue
}
guard let context = CGContext(
data: nil,
width: pixelWidth,
height: pixelHeight,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
fputs("Cannot create bitmap context for page \(pageNumber)\n", stderr)
continue
}
// White background.
context.setFillColor(CGColor.white)
context.fill(
CGRect(
x: 0,
y: 0,
width: CGFloat(pixelWidth),
height: CGFloat(pixelHeight)
)
)
context.saveGState()
// Convert PDF points to bitmap pixels.
context.scaleBy(x: scale, y: scale)
let targetRect = CGRect(
x: 0,
y: 0,
width: pageWidth,
height: pageHeight
)
/*
Let Core Graphics handle:
- CropBox origin
- PDF coordinate system
- page rotation
- translation into the output bitmap
*/
let transform = page.getDrawingTransform(
boxType,
rect: targetRect,
rotate: 0,
preserveAspectRatio: true
)
context.concatenate(transform)
context.drawPDFPage(page)
context.restoreGState()
guard let image = context.makeImage() else {
fputs("Cannot render page \(pageNumber)\n", stderr)
continue
}
let outputName: String
if document.numberOfPages == 1 {
outputName = "\(baseName).png"
} else {
outputName = String(
format: "%@-%03d.png",
baseName,
pageNumber
)
}
let outputURL = directory.appendingPathComponent(outputName)
guard let destination = CGImageDestinationCreateWithURL(
outputURL as CFURL,
UTType.png.identifier as CFString,
1,
nil
) else {
fputs("Cannot create output file: \(outputURL.path)\n", stderr)
continue
}
CGImageDestinationAddImage(
destination,
image,
nil
)
if !CGImageDestinationFinalize(destination) {
fputs("Failed to write: \(outputURL.path)\n", stderr)
continue
}
print(
"Created: \(outputURL.path) " +
"(\(pixelWidth) × \(pixelHeight))"
)
}
然后我们就得到了一个好用的PDF转PNG工具:
- 访达右键 PDF →快速操作
- 每一页转成 PNG
- PNG 输出到原 PDF 所在目录
- 单页 PDF:
文件名.png - 多页 PDF:
文件名-001.png、文件名-002.png…… - 自动处理横版、竖版、页面旋转、A4、非 A4 都可以
- “所见即所得”
- PNG清晰可读,图像与画布同大

G 后感
前面Em里面第3个缺点,它会在大大的画布里面放小小的图,我说不是macOS的锅——我学了一点PDF的基本概念:https://www.prepressure.com/pdf/basics/page-boxes,正是发票开具者落盘时使用了不匹配的各种“Box”导致的。
| Box | 大概意思 |
|---|---|
| MediaBox | PDF 页面的“整张纸” |
| CropBox | 阅读器默认应该展示的区域 |
| BleedBox | 印刷出血区域 |
| TrimBox | 最终裁切后的成品尺寸 |
| ArtBox | 页面实际作品/内容区域 |
所以我们使用“CropBox”:我就要我在预览App里面看到的那么大。
关于这些“Box”,其实正规PDF软件生成的PDF是不用考虑这些,这些参数都是匹配好的,“所得即所见”。
而我拿到的发票(费用清单)甚至每页的页脚都写着:
PDF created with pdfxxactory Pro trial version www.pdxxactory.com
这就令人很难绷。
♥️感谢大家观看,看过了就算会了~
评论