| 项目 |
内容 |
| 适用产品 |
福昕 PDF SDK for Web(WebSDK)11.1,UIExtension 全功能查看器 |
| 场景 |
B/S 业务系统中打开”已签名的工程图纸/合同”,内核禁止修改正文;需要 ① 判断是否受限、② 在浏览器端转成可在线编辑的普通 PDF 后入库/加工 |
| 结论 |
两步均可行且已闭环实测。路径只有一条:逐个清空全部已签署签名域 → 另存 → 重新打开校验 |
一、判断:这份 PDF 是否被签名限制了编辑
1.1 先分清两种互不相干的成因
| 来源 |
特征 |
关键 API |
| 加密权限(owner password) |
文件带 /Encrypt,权限位由加密字典 /P 控制 |
pdfDoc.hasOwnerPassword()、getPermissions() |
| 数字签名(本文重点) |
文件带签名,内核据此把”修改文档”权限置 0 |
pdfDoc.getPDFForm()、docRender.getUserPermission() |
数字签名本身又分两种,业务表现一致(都不能改正文),只是严格程度不同:
- 认证签名(DocMDP):签名字典带
/Reference/TransformMethod /DocMDP + /TransformParams /P 1|2|3,硬性限定允许做哪些改动;
- 普通签名(approval):无 DocMDP,但内核同样禁止修改正文。
实务提示:客户常把两者统称为”签名限制了编辑权限”。先按下面判档,再决定处理路径。
1.2 判档:读权限位
// ① 枚举已签署的签名域
const form = pdfDoc.getPDFForm();
const fields = form ? await form.getFields() : [];
const signed = [];
for (const f of fields) {
const ext = f.getExtType && f.getExtType();
if (!ext || ext.type !== 7) continue; // ★ 签名域是对象 {type:7},不是数字 7
if (!(await f.isSigned())) continue;
signed.push({ name: f.getName(), signer: await f.getSigner(),
subFilter: await f.getSubfilter(), signTime: (await f.getSignInfo()).signTime });
}
// ② 读受限档位(权威依据)
const up = docRender.getUserPermission();
const checks = {
cannotModifyAny: up.checkCannotModifyAny(),
modify: up.checkModify(), // 0 / 8
fillForm: up.checkFillForm(), // 0 / 256
annotForm: up.checkAnnotForm(), // 0 / 32
assemble: up.checkAssemble() // 0 / 1024
};
let level;
if (checks.cannotModifyAny || (checks.modify === 0 && checks.fillForm === 0)) level = 'P1';
else if (checks.modify === 0 && checks.fillForm > 0 && checks.annotForm === 0) level = 'P2';
else if (checks.modify === 0 && checks.fillForm > 0 && checks.annotForm > 0) level = 'P3_OR_ORDINARY';
else if (checks.modify === 0) level = 'ORDINARY';
else level = 'NONE';
判档表(WebSDK 11.1 实测值):
| 文档类型 |
checkCannotModifyAny |
checkModify |
checkFillForm |
checkAnnotForm |
getUserPermissions() |
| 无签名 |
false |
8 |
256 |
32 |
4294967295 |
| 普通签名 |
false |
0 |
256 |
32 |
4294966263 |
| DocMDP P=1 |
true |
0 |
0 |
0 |
4294965975 |
| DocMDP P=2 |
false |
0 |
256 |
0 |
4294966231 |
| DocMDP P=3 |
false |
0 |
256 |
32 |
4294966263 |
1.3 两条必须知道的边界(实测)
getSignInfo() 给不出 DocMDP 档位:getSignInfo().mdpAction.type 恒为 0、getMDPAction() 恒为 {type:'none'} —— 它是签名域锁定动作(FieldMDP),与 DocMDP 的 /TransformParams/P 无关。档位只能由权限位反推。
- P=3 与普通签名在公开接口下无法区分(权限位逐位相同、
getSignInfo() 也相同),要精确区分需解析 PDF 的 /Perms 与签名字典 /Reference。但两者业务表现与处理路径完全一致,不影响落地。
二、转换:把它变成可在线编辑的普通 PDF
2.1 五条路,只有一条推荐
| 路径 |
做法 |
结果 |
| A |
直接另存 getFile({flags:0}) |
❌ 无效:产物仍带签名、仍受限 |
| B |
removeSignature(name) |
❌ 先抛 Permission error |
| C |
逐个 clearSignedData() → getFile() |
✅ 推荐(唯一验证可行的路径) |
| D |
createNewDoc + insertPages 页面级重建 |
⚠️ 可编辑,但丢失全部 OCG 图层(无法恢复)——分层图纸不要用 |
| E |
extractPages |
⚠️ 可编辑,但返回值是分块的、须拼接,且同样丢图层 |
2.2 推荐实现(可直接替换)
async function convertToEditablePDF(pdfui) {
const docRender = await pdfui.getPDFDocRender();
const pdfDoc = await docRender.getPDFDoc();
// ① 逐个清除"已签署"的签名域 —— 必须全部清,只清一个无效
const form = await pdfDoc.getPDFForm();
for (const f of (form ? await form.getFields() : [])) {
const ext = f.getExtType && f.getExtType();
if (!ext || ext.type !== 7) continue;
if (!(await f.isSigned())) continue;
await f.clearSignedData(); // 低层 API,DocMDP P=1 上同样可用
}
// ② 另存
const file = await pdfDoc.getFile({ flags: 0 });
const blob = new Blob([await file.arrayBuffer()], { type: 'application/pdf' });
// ③ 闭环校验(务必做)
await pdfui.openPDFByFile(new File([blob], 'converted.pdf', { type: 'application/pdf' }));
const after = await pdfui.getPDFDocRender().then(r => r.getUserPermission());
if (after.checkModify() === 0) throw new Error('转换后仍受限,请检查是否遗漏签名域');
return blob;
}
2.3 落地时的 5 个注意点(均为实测)
- 必须清除”全部”已签署签名域:多签名文档只清一个时,文档仍受限(另一个签名还在)。→ 循环清除每一个 + 逐个回读确认。
- “另存”不等于”解锁”:
getFile({flags:0}) 在受限文档上会成功,但产物仍受限——它只解决”能不能写出文件”,不解决”能不能编辑”。
- 签名域识别用
getExtType().type === 7:它返回的是对象 {type:7},不是数字 7;按 === 7 过滤会漏掉全部签名域。
- 该接口未列入公开
.d.ts(Web 层):clearSignedData() 在原生 SDK 已公开、可正常使用,但 Web 层没有类型声明 → 请锁定 SDK 版本,升级时把 §三 的闭环校验当回归用例跑一遍。
- SDK 未提供现成的”清除签名”UI 入口:需自行加工具栏按钮或自制右键菜单项,内部调用
clearSignedData() 即可。
P=1(checkFillForm == 0)同样走第 2.2 节这条路径 —— 低层 clearSignedData() 不经过 UI 服务层的 checkPermission(fillForm) 校验,实测可用。
三、闭环校验清单(转换后必须逐项确认)
- 已签署签名域数量为 0;
getUserPermission().checkModify() > 0;
- 真正执行一次编辑不再抛
Permission error(例如 pdfDoc.setMetadataValue(k, v, false));
- 图纸类文档:页数、MediaBox、
/Rotate、OCG 图层数与名称、注释数逐项与原件一致。
提示:清签名后,内存中的文档仍受限(checkModify 依旧为 0),必须”另存 + 重新打开”才解锁——这是内核在打开文档时判定”该文档带签名”的行为,不是接口异常。
四、合规提醒(务必向业务方说明)
清除签名后,原签名的密码学有效性即失效,文档不再可验签。是否允许这样做属于客户业务与合规决策,SDK 只提供能力。建议:转换前让业务方确认审批链,转换后留痕(谁、何时、为何转换、原件留存)。
五、签名外观:通常不必保留
清除签名会同时清空签名域的外观流(实测:外观流从绘制签章图的内容变成空的 q Q),即转换后签章外观会消失。
实际项目中这通常可接受(例如本文所依据的客户案例,最终用户已确认”外观不重要、必要时可不显示,重点是能编辑图纸内容”)。
若确有客户要求保留外观,两条实测结论供参考:
- 必须先取图、再清签名:
sigField.getImage() 在受限文档上即可读取(无需修改权限),清签名后再取就没了;
- 不要在受限文档上直接
flatten(PDFPage.flatten / PDFDoc.flatten / PDFPage.flattenAnnot 全部抛 Permission error,且同一会话内即使已清签名仍被拦)→ 必须另存并重新打开产物后才能 flatten。
需要时的链路:getImage() → 清签名 → 另存 → 重新打开 → addAnnot({type:'stamp', rect}) + stamp.setImage(url) 贴回 →(可选)flatten(0) 固化 → 再另存。
附:验证环境
WebSDK 11.1 全功能版(UIExtension)+ 真实 Chrome;素材包括自造的 DocMDP P1/P2/P3 认证签名 PDF(openssl 生成自签证书并逐份 openssl cms -verify 校验有效)与普通签名 PDF,以及客户真实已签工程图纸(2 个普通签名、25 个 OCG 图层、MediaBox 2384×3370、/Rotate 270)。
关键实测数据:推荐路径对客户真实图纸的转换结果 —— 已签署签名域 2 → 0、checkModify 0 → 8、真实编辑调用成功、页数/尺寸/旋转/25 个 OCG 图层/注释数全部保全,产物约 560 KB、耗时约 0.6 s;同一路径连续压测 41/41 次通过(含 DocMDP P=1 素材与客户真实图纸)。