HarmonyOS 7 PDF Kit:旋转表单页坐标反算与字段写入【鸿蒙心迹】
一、签名没有丢,只是写到了旋转前的坐标系
FormSeal 是一个设备巡检签批 Demo。用户从文件选择器打开 inspection_A17.pdf,在第 4 页勾选 14 个字段并写入两处签名,最后交给 PDF Kit 预览。验收时出现了很尴尬的结果:前三页正常,第 4 页的签名跑到右上角,文本框整体偏移;日志却显示 14/14 字段写入成功,保存也没有报错。
我没有先调偏移量,而是把 PDF 页盒、旋转角和屏幕触点全部打印出来。第 4 页原始尺寸是 595×842 pt,/Rotate 为 90°;手机预览区域是 360×509 vp,屏幕坐标原点在左上,PDF 绘制坐标原点在左下。我们把预览触点直接乘比例写进 PDF,相当于同时忽略了 Y 轴翻转、页面旋转和 fit-center 留白。

这次修复最终没有落在某个“神奇偏移值”上,而是形成了一个明确流水线:触点先还原到未旋转页面空间,字段写入内存副本,临时文件完整落盘并验证后再原子替换;任一步失败,只删除本次任务的临时副本。任务编号固定为 PDF-2142,这样页面、HiLog 和导出文件都能指向同一次操作。
二、把预览空间、旋转空间和 PDF 空间分开说清楚
工程目录刻意没有让页面直接操作文件。FormCoordinatePage 负责采集字段与手写轨迹,PageSpaceMapper 只做坐标变换,PdfFormWriter 只改内存字节,AtomicPdfStore 管临时文件和替换,PDF Kit 负责最终预览。
entry/src/main/ets/
├── pages/FormCoordinatePage.ets
├── pdf/PageSpaceMapper.ets
├── pdf/PdfFormWriter.ets
├── storage/AtomicPdfStore.ets
└── model/FormTask.ets
第一段代码解决坐标反算。输入是去掉 fit-center 留白后的局部触点,以及预览内容的实际宽高;输出始终是未旋转 PDF 页的坐标。这里只接受 0、90、180、270 四种标准旋转,发现异常角度直接终止任务,避免生成看似成功却不可用的文件。
interface Point { x: number; y: number }
interface PageSize { width: number; height: number }
export function normalizePageSpace(
local: Point,
preview: PageSize,
page: PageSize,
rotation: number
): Point {
const displayX = local.x * (rotation % 180 === 0 ? page.width : page.height) / preview.width
const displayYTop = local.y * (rotation % 180 === 0 ? page.height : page.width) / preview.height
switch (rotation) {
case 0: return { x: displayX, y: page.height - displayYTop }
case 90: return { x: displayYTop, y: displayX }
case 180: return { x: page.width - displayX, y: displayYTop }
case 270: return { x: page.width - displayYTop, y: page.height - displayX }
default: throw new Error(`UNSUPPORTED_ROTATION_${rotation}`)
}
}
写法的重点是先移除预览留白,再调用这个函数。若预览控件 360 vp 宽、内容实际只有 340 vp,却仍按 360 计算,旋转公式再正确也会整体漂移。签名轨迹的每个点都要经过同一变换,不能只换算包围框左上角。页面缩放或窗口旋转时,旧轨迹应保留在 PDF 空间;重新渲染时再投影到新的预览空间,这样不会反复换算产生误差。
三、pdf-lib 只处理内存副本,页面状态不提前宣布成功
pdf-lib 能加载既有 PDF、取得页面并序列化为 Uint8Array。FormSeal 不在写字段时碰原文件:先加载原始字节,按字段模型绘制,再保存到内存。第二段代码展示第 4 页的核心写入逻辑,真实 Demo 的 14 个字段和两条签名都从同一任务模型进入。
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'
interface FieldValue { label: string; value: string; point: Point }
export async function applyFields(
source: Uint8Array,
fields: FieldValue[],
signaturePng: Uint8Array
): Promise<Uint8Array> {
const pdf = await PDFDocument.load(source)
const page = pdf.getPages()[3]
const font = await pdf.embedFont(StandardFonts.Helvetica)
const signature = await pdf.embedPng(signaturePng)
fields.forEach((field: FieldValue) => {
page.drawText(`${field.label}: ${field.value}`, {
x: field.point.x, y: field.point.y,
size: 9, font, color: rgb(0.12, 0.16, 0.22)
})
})
page.drawImage(signature, { x: 186, y: 122, width: 128, height: 42 })
page.drawImage(signature, { x: 356, y: 122, width: 128, height: 42 })
pdf.setProducer('FormSeal 1.0.0')
return await pdf.save({ useObjectStreams: false })
}
这里的两个签名坐标已经是 normalizePageSpace() 的输出,不再关心预览页旋转了多少度。useObjectStreams: false 是这个 Demo 为兼容下游旧检查器做的取舍,会牺牲一点体积,不能当作所有项目的默认值。标准字体也不覆盖中文字段,生产环境要注册并嵌入授权字体;否则“写入成功”可能只是英文正常、中文变方框。
页面状态在这一步仍是 WRITING,不能变成 SEALED。因为内存序列化成功不等于文件完整落盘,更不等于 PDF Kit 能重新打开。连续点击“生成签批件”时,按钮立即禁用,并以任务 ID 去重;如果允许并发写同一目标文件,后完成的旧任务会覆盖新结果。
四、临时文件不是备份,它只属于一次提交事务
之前的实现直接 truncate 目标文件后写入。写到一半遇到空间不足,原文件和新文件一起损坏。现在每个任务使用明确的临时名 inspection_A17.PDF-2142.tmp,写完、同步并验证后才替换目标 inspection_A17_sealed.pdf。第三段代码省略平台文件句柄细节,但保留事务顺序。
export class AtomicPdfStore {
async saveDraftAtomically(taskId: string, bytes: Uint8Array): Promise<string> {
const finalPath = `${this.cacheDir}/inspection_A17_sealed.pdf`
const tempPath = `${this.cacheDir}/inspection_A17.${taskId}.tmp`
try {
await this.writeAll(tempPath, bytes)
await this.flush(tempPath)
await this.verifyPdf(tempPath, 7)
await this.replace(tempPath, finalPath)
return finalPath
} catch (error) {
await this.removeIfExists(tempPath)
throw error
}
}
private async verifyPdf(path: string, expectedPages: number): Promise<void> {
const bytes = await this.readAll(path)
const pdf = await PDFDocument.load(bytes)
if (pdf.getPageCount() !== expectedPages) {
throw new Error('VERIFY_PAGE_COUNT_MISMATCH')
}
}
}
清理范围必须精确到本任务临时文件,不能在 catch 里清空整个 cache 目录。PDF Kit 预览可能仍持有上一次成功文件,粗暴清目录会把可回退版本也删掉。replace() 应由同一文件系统内的原子重命名或等价能力实现;临时目录若在另一分区,“重命名”可能退化成复制。页面离开时可以取消未开始的写入,但对已经进入 flush 的任务只标记 UI 不再接收回调,不能强杀到半个文件。

五、验证不是重新打开一次,而是检查关键事实
verifyPdf() 先确认能解析且仍是 7 页,随后生产版还会检查第 4 页旋转角为 90°、14 个字段的绘制记录存在、输出字节大于最小阈值。最终文件大小 1.84 MiB,字段映射 14/14,两处签名均在预期区域,越界字段为 0。本轮没有触发回滚,所以页面显示“回收临时副本 0”;这不是说没有清理机制,而是本次成功路径没有残留。

手机页展示的是提交后的最终事实:任务 PDF-2142,输入 inspection_A17.pdf,第 4/7 页,旋转 90°,字段 14/14,签名 2/2,输出 1.84 MiB,耗时 226 ms,状态 SEALED。这些数字与 HiLog 中的 mapped=14 signatures=2 overflow=0 state=SEALED 完全一致。预览按钮只有在目标文件替换完成后才可用,避免 PDF Kit 打开临时路径。
六、一次失败演练暴露了真正需要保护的东西
为了确认异常路径,我们把可用空间限制到小于输出体积。写入在 flush 前失败,页面状态从 WRITING 进入 FAILED,错误为 NO_SPACE_LEFT,临时文件被删除,上一版 inspection_A17_sealed.pdf 仍能预览。恢复空间后再次提交,沿用业务任务 PDF-2142 的同时增加内部 attempt,避免外部审核记录出现两个任务;只有成功 attempt 才写入审计结果。
坐标边界也做了单独测试。四种旋转各取页面四角与中心,共 20 个基准点,反算误差控制在 0.5 pt 内。触点超出内容区域时先裁剪或拒绝,不能让负坐标进入绘制;页面 CropBox 与 MediaBox 不一致时,以实际预览采用的页盒为准。若 PDF 带密码、动态 XFA 表单或不受 pdf-lib 支持的结构,Demo 直接提示“不支持编辑”,而不是尝试保存后再赌预览结果。
七、留下的工程判断
PDF 签批最容易被误认为“画几个字再保存”。实际可靠性来自三层契约。第一层是几何契约:屏幕左上原点、fit-center 留白、PDF 左下原点和 /Rotate 必须在同一公式里闭环。第二层是状态契约:LOADING → EDITING → WRITING → VERIFYING → SEALED 单向推进,只有最新 attempt 能改 UI。第三层是文件契约:原文件只读、临时副本独占、验证成功后原子替换、失败只回收本任务残留。
PDF Kit 在这里承担系统预览与最终验收,pdf-lib 负责内存级修改,两者不是互相替代。把坐标转换、字节修改和文件提交拆开后,任何问题都能落到具体层:是触点错了、绘制错了,还是提交错了。对需要审核追踪的巡检、合同和设备交付场景,这种可解释性比“多数文件能打开”更重要,也让下一步加入多页骑缝章、版本签名和导出审计时不必重写整条链路。
更多推荐



所有评论(0)