三甲大楼方又圆

五重纸框难填单

草台班子今犹在

不见当年程序员

以上是定场诗。

阅读本文需要有基本的macOS操作经验,会使用终端类的App。

继续阅读代表您知晓并同意不适当的操作可能会损害您的利益,与本文作者无关。

C 缘起

最近我有很多发票要报销,收款方给的这些发票是PDF文件,而我报销是要通过指定的的App上传图片文件——它只支持图片文件,PDF不可以。

这有何难?不就是转换吗?

Dm 古法手搓

我用macOS自带的预览(Preview)就能做到,打开一个PDF文件然后导出图片:

preview

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

long pdf

Em 自动化

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

quick action

但是!PDF它!没!有!

得自己创建。

打开自动操作(Automator)这个App,这是一个拖拽点点点就能“编程”的好东西。功能很多很强大,当然能搞快速操作。

ℹ️ 这个App很不起眼,找不到可以用CMD+空格来直接启动。

automator

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

render pdf

保存下就好了。之后在访达里面对PDF使用右键菜单就可以转换了。多页文件也能转换。总体来看效果不太好。

  1. 所有图像文件都输出同一个文件夹,不能自选去处,也不能和原始PDF文件在一起。
  2. 文件名也是乱乱的。
  3. 无论原始的发票是横向还是纵向,输出的图像文件每个都是A4纸大小,纵向。(这个问题是发票开具者的锅,不赖macOS)

export as a4

F 写个脚本

既然上面的方法不太令人满意,那么我们自己写一段。

前面还是一样的,但是不再使用系统提供的PDF渲染,如图,我们叫它去执行一个/bin/zsh脚本。

custom script

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清晰可读,图像与画布同大

pdf2png

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

这就令人很难绷。

♥️感谢大家观看,看过了就算会了~