💡 完整可运行示例已在 GitHub 上提供:
python-linux-container-pdf-signing

介绍

该脚本在本地可以运行。将其容器化后使用 python:3.11-slim,但在 import groupdocs.signature 时失败。修复后,又在第一次签名时失败。两个错误都没有说明实际缺少的是什么。

使用 Python 进行容器签名是一个 GroupDocs.Signature 工作流,需要两个供应层而不是一个:绑定所基于的 .NET 运行时库,以及每个文本签名必须渲染的字体。本教程先构建这两层,然后提供在运行时解析字体族的脚本(而不是硬编码),使相同代码既能在容器中运行,也能在你编写代码的机器上运行。

为什么两层都重要

GroupDocs.Signature 的 Python 版是一个 .NET 绑定,因此在任何导入成功之前必须先存在 libicu 和兼容 OpenSSL 1.1 的库。这是一层,并在 Running in Docker 中有详细说明。

两层常被混为一谈的原因是,它们都会在导入相关的时刻失败,而且错误信息都没有指明原因。缺少 libssl1.1 会导致加载器报共享对象错误;缺少字体会导致签名错误被包装在代理异常中。两者都没有说“你的基础镜像太小”,这正是它们实际想表达的意思。

第二层是字体,这往往让人感到意外。python:3.11-slim 完全不包含任何字体文件。GroupDocs.Signature 不会自动替代缺失的字体族——如果指定了未安装的族会抛异常,且不会写入任何内容——而且清除字体也不是解决办法,因为库随后会请求自己的默认字体并同样失败。在没有字体的镜像中,文本签名根本不可能实现。

前置条件

  • Python 3.11(对应的 wheel 最高支持 CPython 3.14 以下)
  • groupdocs-signature-net==26.1
  • 如需刻意观察两次失败,可使用 Docker(大约十分钟即可完成)。

安装

pip install groupdocs-signature-net==26.1

步骤 1 - 构建 .NET 层

libssl1.1 在 bookworm 中不存在,需要从固定的 Debian 快照获取:

ENV SNAPSHOT_DATE=20220328T000000Z
RUN echo "deb [trusted=yes] http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} bullseye main" \
        > /etc/apt/sources.list.d/debian-archive.list \
    && apt-get -o Acquire::Check-Valid-Until=false update \
    && apt-get install -y --no-install-recommends \
        libicu67 \
        libssl1.1 \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

关键点:

  • 这一层仅使导入能够成功;它不涉及字体。
  • 固定快照日期可以在归档迁移后保持构建可复现。

步骤 2 - 构建字体层

以下四个软件包作为单独的一层安装,便于在需要复现失败时注释掉:

RUN apt-get update && apt-get install -y --no-install-recommends \
        fontconfig \
        fonts-dejavu-core \
        fonts-liberation \
        fonts-noto-cjk \
    && fc-cache -f \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*
  • fontconfig 是解析器,提供 fc-list。
  • fonts-dejavu-core 提供拉丁、希腊和西里尔字母的最小集合。
  • fonts-liberation 覆盖文档中按名称引用 Arial 或 Times New Roman 的情况。
  • fonts-noto-cjk 覆盖中文、日文和韩文。

步骤 3 - 让库自行查询可用的字体族

直接在 /usr/share/fonts 中搜索文件名看似可行,实际上并不等同:fonts-noto-cjk 安装的 NotoSansCJK-Regular.ttc 的族名是 Noto Sans CJK JP。可移植的做法是进行一次探测——在临时文件中进行真实签名——并将失败转换为返回值:

with signature.Signature(source_path) as sign:
    options = TextSignOptions()
    options.text = "probe"
    options.left = 10
    options.top = 10
    options.width = 60
    options.height = 20
    font = SignatureFont()
    font.family_name = family_name
    font.size = 10.0
    options.font = font
    sign.sign(scratch, [options])
return None

请特别注意 font.size = 10.0。绑定会把大小映射为 .NET 的 float,并在收到 int 时抛出 “numeric argument expected, got ‘int’”。因为这发生在探测阶段,所有候选族都会失败,输出看起来就像是没有字体的镜像。我曾在已经包含所有三种字体包的镜像中再次添加它们,才发现了这个字面错误。

于是使用循环进行解析:

for candidate in candidates:
    if try_family(source_path, candidate) is None:
        return candidate
return None

步骤 4 - 使用解析得到的字体进行签名并验证

拉丁族是必需的,CJK 族是可选的:

with signature.Signature(source_path) as sign:
    options = [build_text_options(LATIN_TEXT, latin_family, 50)]
    if cjk_family:
        options.append(build_text_options(CJK_TEXT, cjk_family, 120))
    result = sign.sign(output_path, options)
    return len(result.succeeded)

随后进行验证,因为 CJK 渲染为空白方框时不会抛出异常:

options = TextVerifyOptions()
options.text = expected_text
options.match_type = gsd.TextMatchType.CONTAINS
options.all_pages = True
result = sign.verify(options)

这里使用 CONTAINS 是有意为之:在评估模式下库会向页面添加试验文本,若使用完全匹配会把本来正常的文档误报为失败。

文档中说 Python 对 Linux 支持有限,是真的吗?

Running in Docker 页面列出了已准备好在 Linux 上运行的 Python 包,但把 Signature 排除在外。使用 groupdocs-signature-net==26.1 时,本示例在 python:3.11-slim 中完成了签名和验证(包括 CJK),前提是两层都已安装。将该列表视为已过时,而不是阻碍因素,并在部署前自行验证对应版本。

实际应用场景

  • 发票服务:在生成的 PDF 上盖章批准行,需要 .NET 层、一个拉丁字体以及启动时的解析检查。该检查可以把错误的部署转化为容器启动失败,而不是让发票队列逐个静默失败。
  • 文档门户:接受任意脚本的客户姓名,需要额外的 CJK 包以及验证步骤,因为这一步是防止渲染为方框却仍被标记为成功的唯一手段。

解析检查的放置位置

将其放在每个进程只运行一次的地方:模块级调用、FastAPI 的 lifespan 处理器、Django 的 AppConfig.ready,或是 worker 主入口的前几行。检查会返回两个值——拉丁族和 CJK 族——并应在启动日志中与字体数量一起记录。

这样做的好处不仅是节省探测时间,还能把错误从请求处理阶段(往往是单个客户的问题,堆栈跟踪没人看)提前到启动阶段(部署未成功启动,运维人员已经在监控)。容器若因 “no usable font family, install fonts-dejavu-core” 而退出,无需进一步调试。

常见问题排查

import groupdocs.signature 失败
.NET 层缺失或构建时快照仓库不可达。这是一层的问题,与字体无关。先检查构建日志中 apt 步骤的输出,再去处理签名代码,因为快照获取失败并不会阻止镜像继续构建。

所有候选字体都失败,但 fc-list 能列出字体
检查 font.size 是否误用了 int,并在添加更多软件包前先修正。

签名已生成但 CJK 文本显示为方框
缺少 fonts-noto-cjk。签名使用的族没有对应代码点的字形,这也是验证步骤存在的原因:它会在这种情况下报错,而签名本身却显示成功。

两个镜像实际输出的对比

分别运行两次并读取前四行。无字体的镜像会报告 font files on disk: 0,两行解析结果均为 (none),随后是故意的缺字体错误并以退出码 3 结束,显示最小修复提示。已配置的镜像会报告非零的字体计数,拉丁显示为 DejaVu Sans,CJK 显示为 Noto Sans CJK JP,两次签名成功,且两段文本均通过验证。

将这对输出粘贴到部署文档中,后续更换基础镜像时即可对照健康容器的表现,而无需深入了解 fontconfig。

结论

两层加一次探测。先安装 .NET 依赖,再至少安装 fontconfig 与 DejaVu,使用探测而非假设来解析字体族,最后在完成工作前进行验证。代码量不大,却是事后才会显而易见、事前却几乎不可见的关键点。示例仓库同时提供了两个 Dockerfile,工作镜像与破损镜像的差别仅在于一次构建。

其他资源