命令返回码是 0,生成的文件也能打开,送进档案系统却提示“不是 PDF/A”;换到印厂,又报输出意图缺失。这种转换最容易出现的就是假成功。
PDF/A、PDF/X 都不是在文件属性里改个标记,更不是补一段 XMP 元数据就结束了。字体有没有嵌入、颜色空间是否合规、加密和脚本有没有清掉、ICC 配置是否正确,都可能让文件验证失败。
这类活我一般不让 PyPDF2 硬扛。Python 负责参数、批处理和异常兜底,真正重写 PDF 的工作交给 Ghostscript。
PDF/A 偏长期归档,重点是文件脱离外部环境后仍能稳定显示;PDF/X 偏印刷交付,重点是颜色、页面框和输出条件。Ghostscript 的 pdfwrite 会重新生成 PDF,并尽量保持原文件的视觉效果。当前文档中,PDF/A 转换主要支持 1、2、3 三个版本的 b 级合规;PDF/X 这里我固定用 PDF/X-3,不在版本兼容上赌运气。
先准备三个东西:
PDFA_def.ps 和 PDFX_def.ps;
两个 .ps 文件可以从 Ghostscript 安装目录的 lib 目录复制出来。里面的 ICCProfile 要改成绝对路径。PDF/A 可以使用合适的 RGB 配置,PDF/X 则应该使用印厂或输出设备指定的 CMYK 配置。这个地方别随手找个 ICC 文件顶上,文件能生成,不代表颜色能交付。Ghostscript 官方也要求通过定义文件写入 OutputIntent。
Python 脚本不用写得太花:
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
classPdfProfileConverter:
def__init__(self, gs_bin: str | None = None):
self.gs_bin = gs_bin or self._find_ghostscript()
@staticmethod
def_find_ghostscript() -> str:
candidates = (
["gswin64c.exe", "gswin32c.exe"]
if os.name == "nt"
else ["gs"]
)
for name in candidates:
command = shutil.which(name)
if command:
return command
raise RuntimeError("没有找到 Ghostscript 命令行程序")
def_run(self, arguments: list[str]) -> None:
result = subprocess.run(
[self.gs_bin, *arguments],
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip()
raise RuntimeError(f"PDF 转换失败:\n{message}")
defto_pdfa(
self,
source: Path,
target: Path,
definition: Path,
) -> None:
self._run([
"-dBATCH",
"-dNOPAUSE",
"-dSAFER",
"-sDEVICE=pdfwrite",
"-dPDFA=2",
"-dPDFACompatibilityPolicy=2",
"-sColorConversionStrategy=RGB",
"-dEmbedAllFonts=true",
"-dSubsetFonts=true",
f"-sOutputFile={target.resolve()}",
str(definition.resolve()),
str(source.resolve()),
])
defto_pdfx(
self,
source: Path,
target: Path,
definition: Path,
) -> None:
self._run([
"-dBATCH",
"-dNOPAUSE",
"-dSAFER",
"-sDEVICE=pdfwrite",
"-dPDFX=3",
"-dPDFACompatibilityPolicy=2",
"-sColorConversionStrategy=CMYK",
"-sProcessColorModel=DeviceCMYK",
f"-sOutputFile={target.resolve()}",
str(definition.resolve()),
str(source.resolve()),
])
defto_normal_pdf(self, source: Path, target: Path) -> None:
self._run([
"-dBATCH",
"-dNOPAUSE",
"-dSAFER",
"-sDEVICE=pdfwrite",
"-dCompatibilityLevel=1.7",
"-dAutoRotatePages=/None",
f"-sOutputFile={target.resolve()}",
str(source.resolve()),
])
调用时把定义文件传进去:
from pathlib import Path
worker = PdfProfileConverter()
worker.to_pdfa(
Path("contract.pdf"),
Path("contract-a2b.pdf"),
Path("profiles/PDFA_def.ps"),
)
worker.to_pdfx(
Path("brochure.pdf"),
Path("brochure-x3.pdf"),
Path("profiles/PDFX_def.ps"),
)
worker.to_normal_pdf(
Path("contract-a2b.pdf"),
Path("contract-normal.pdf"),
)
我这里特意把 PDFACompatibilityPolicy 设置成了 2。
默认值 0 有点坑人:遇到不兼容内容时,Ghostscript 可能继续输出,文件里甚至还保留着 PDF/A 元数据,但实际并不合规。设置成 2 后,只要碰到无法处理的内容就直接失败。批处理宁可明确报错,也不要生产一批看起来正常的假 PDF/A。官方对这个参数的说明也是:0 继续输出,1 忽略不兼容特性,2 立即中止。
转换完成还不能直接交付。PDF/A 我会再跑一次 veraPDF:
defvalidate_pdfa(pdf_file: Path, validator: str = "verapdf") -> None:
result = subprocess.run(
[validator, str(pdf_file.resolve())],
text=True,
capture_output=True,
check=False,
)
report = result.stdout + result.stderr
if"PASS"notin report.upper():
raise RuntimeError(f"PDF/A 验证未通过:\n{report}")
veraPDF 本来就是专门做 PDF/A 合规验证的,覆盖 PDF/A-1 到 PDF/A-4 的验证规则。Ghostscript 负责生成,veraPDF 负责验收,这两个角色不要混在一起。
至于“PDF/A 转回 PDF”,有一点得说清:PDF/A 本来就是 PDF 的受限子集。所谓转回普通 PDF,通常只是使用 pdfwrite 再重写一次,不再声明 PDF/A 合规。
但已经被转换过程删除的 JavaScript、加密、外部依赖或者被压平的透明效果,不会凭空恢复。这个操作只能解除交付格式上的限制,不能把原文件丢掉的能力找回来。
所以原始 PDF 别删。归档文件、印刷文件、业务原件,最好分开保存。拿一个 PDF 在三种标准之间反复横跳,最后通常没人说得清它到底还是不是原来那个文件。