Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules/
package-lock.json
dist/
packages/*/dist/
coverage/
Expand Down
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ pnpm add @hufe921/jsofd pdfjs-dist # + PDF → OFD 转换

## 核心能力

| 类别 | 说明 |
| -------- | ---------------------------------------------------------------------- |
| 文本 | 字体/字号/颜色、对齐(含两端)、自动断行(中英混排)、基线、旋转、字距 |
| 中文字体 | 内置宋/黑/楷/仿宋 + **TTF 嵌入**(任何阅读器渲染一致) |
| 图形 | 直线、贝塞尔、矩形、圆角矩形、圆、椭圆、三角形、虚线、透明度 |
| 图像 | PNG/JPEG/GIF/BMP/TIFF 原样嵌入、旋转、去重 |
| 交互 | 超链接、层级书签、附件 |
| PDF 转换 | 逐条重放绘制指令流,保留真实排版数据 |
| 输出 | 全同步:`arraybuffer` · `blob` · `dataurlstring` 等 |
| 类别 | 说明 |
| -------- | ------------------------------------------------------------------------------------- |
| 文本 | 字体/字号/颜色、对齐(含两端)、自动断行(中英混排)、基线、旋转、字距 |
| 中文字体 | 内置宋/黑/楷/仿宋 + **TTF 嵌入**(任何阅读器渲染一致) |
| 图形 | 直线、贝塞尔、矩形、圆角矩形、圆、椭圆、三角形、虚线、透明度 |
| 图像 | PNG/JPEG/GIF/BMP/TIFF 原样嵌入、旋转、去重 |
| 交互 | 超链接、层级书签、附件 |
| 安全 | 水印、脱敏;SES 制章/签章与可信验签;GM/T 0099 口令加密(SM4-CBC) |
| PDF 转换 | 逐条重放绘制指令流,保留真实排版数据 |
| OFD 读取 | 解析 OFD 包:页面/文本/图形/图片/书签/附件/元数据,导出 SVG/PNG、全文提取、可编辑回写 |
| 输出 | 全同步:`arraybuffer` · `blob` · `dataurlstring` 等 |

## 嵌入字体(中文推荐)

Expand All @@ -65,6 +67,17 @@ const doc = await pdfToOfd(pdfBytes, { workerSrc: '/pdf.worker.min.mjs' });
doc.save('converted.ofd');
```

## 读取 OFD

```ts
import { parseOfd, extractText, pageToSvg } from '@hufe921/jsofd';

const ofd = await parseOfd(ofdBytes); // 解析 GB/T 33190 包
extractText(ofd); // 每页文本
pageToSvg(ofd.pages[0], { zoom: 3 }); // 渲染为可选中文字的 SVG
ofd.outlines; // 书签树 / 附件 / 签名清单 / DocType
```

## 开发

```sh
Expand Down
3 changes: 3 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export default defineConfig({
{ text: '图像嵌入', link: '/guide/images' },
{ text: '嵌入字体', link: '/guide/fonts' },
{ text: 'PDF → OFD 转换', link: '/guide/pdf-import' },
{ text: '读取 OFD 文件', link: '/guide/ofd-reader' },
{ text: 'SES 电子签章', link: '/guide/signatures' },
{ text: 'OFD 口令加密', link: '/guide/encryption' },
{ text: '高级功能', link: '/guide/advanced' },
{ text: '与 jsPDF 的差异', link: '/guide/differences' },
],
Expand Down
30 changes: 30 additions & 0 deletions docs/guide/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,33 @@ doc.autoPaging(chapters.join('\n\n'), { x: 20, topMargin: 28, bottomMargin: 28 }
const endY = doc.table(20, doc.autoPaging 返回值 + 10, { columns, rows });
doc.headerFooter({ footer: (d, i) => d.text(`${i.pageNumber} / ${i.pageCount}`, 105, 288, { align: 'center' }) });
```

## 水印

文字或图片水印,支持单枚居中与平铺两种模式,可放在内容上方或下方:

```ts
// 文字水印:默认全部页面、45° 斜排、半透明
doc.addWatermark({ text: '绝密', opacity: 0.12 });

// 平铺 + 仅当前页 + 置于内容下方
doc.addWatermark({ text: '草稿', mode: 'tile', fontSize: 24, layer: 'under' });

// 图片水印(PNG 透明底最佳),只盖第 2 页
doc.addWatermark({ image: sealPng, imageWidth: 120, pages: [2] });
```

## 关键字脱敏

在已排版的文本中定位关键字并遮盖,**字形推进不变、版面不错位**:

```ts
// 遮盖模式:黑色矩形盖住关键字,文本替换为空格(提取/复制不再泄漏)
const hits = doc.redact(['王小明', '13800138000']);
// hits → [{ keyword: '王小明', page: 1, count: 1 }, ...]

// 星号模式:关键字替换为 *,其余位置不动
doc.redact('绝密', { mode: 'asterisk', pages: 'current' });
```

`hits` 里 `page` 为 1 起始页码(0 表示未命中),`count` 为该页命中次数。
2 changes: 1 addition & 1 deletion docs/guide/differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jsOFD 的目标是**让 jsPDF 代码零成本迁移到 OFD**:方法名、参
| -------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `doc.addFont()` 的 postScriptName 三段式重载 | 保留 jsPDF 的 `(postScriptName, fontName, fontStyle)` 签名,同时推荐更明确的 `addFontTtf` |
| `doc.setLanguage` 写入 /Lang | OFD 的语言标记挂在附件与元数据上,仅保留方法兼容、无渲染影响 |
| 加密 / 权限(`encryption` 选项) | OFD 加密是独立的标准分册(GB/T 33190.7),涉及证书体系,不适合轻量库内置 |
| 加密 / 权限(`encryption` 选项) | 不兼容 jsPDF 的选项式加密;使用独立的 `encryptOfdPassword` / `decryptOfdPassword`(GM/T 0099 口令方案) |
| `html()` 的 CSS 布局引擎 | 完整 CSS 排版是一个浏览器级工程;`html()` 定位为语义标签渲染器,需要精确版式请用原生 API |

## 行为差异细节
Expand Down
48 changes: 48 additions & 0 deletions docs/guide/encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# OFD 口令加密与解密

jsOFD 按 GM/T 0099-2020 附录 C 实现口令加密:包内文件使用随机 16 字节 FEK 和 IV 做 **SM4-CBC/PKCS#7** 加密,口令经 **SM3-KDF** 派生密钥包装 FEK。加密与电子签章是不同能力:加密后没有正确口令无法还原正文。

## 加密

```ts
import { encryptOfdPassword } from '@hufe921/jsofd';

const encrypted = await encryptOfdPassword(ofdBytes, {
userName: 'demo',
password: '12345678',
});
```

默认加密全部包内文件并删除对应明文。也可以按路径局部加密:

```ts
const encrypted = await encryptOfdPassword(ofdBytes, {
userName: 'demo',
password: '12345678',
filter: (name) => name.startsWith('Doc_0/Pages/Page_0/'),
});
```

局部加密应把页面内容、注释、模板及其引用资源一起纳入,否则解密端可能无法正确渲染。

生成物包含:

- `Encryptions.xml`:解密入口、加密范围和密钥文件位置;
- `decryptseed.dat`:口令方案 `1.1.1`、用户信息、包装后的 FEK 和 IV;
- `entriesmap.dat`:加密后的明密文路径映射;
- 各包内密文 `.dat` 文件。

## 解密

```ts
import { decryptOfdPassword } from '@hufe921/jsofd';

const plainBytes = await decryptOfdPassword(encrypted, {
userName: 'demo',
password: '12345678',
});
```

解密会还原映射中的明文路径和内容。口令错误、密文被篡改或 PKCS#7 填充损坏时会抛出异常,不会返回部分明文。

当前范围是单次口令加密操作;证书加密(方案 `1.1.2`)和多重加密不在此 API 内。生产系统应通过安全通道传递口令,不要在代码、日志或分享链接中硬编码真实口令。
55 changes: 55 additions & 0 deletions docs/guide/ofd-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 读取 OFD 文件

jsOFD 不仅能生成 OFD,也能**读取**符合 GB/T 33190-2016 的 OFD 包,解析为对象模型后做提取、渲染或二次加工。

```ts
import { parseOfd, extractText, pageToSvg } from '@hufe921/jsofd';

const ofd = await parseOfd(fileBytes); // Uint8Array | ArrayBuffer

ofd.docType; // "OFD" | "OFD-H"
ofd.meta; // { docId, title, author, subject, ... }
ofd.pages.length; // 页数
ofd.pages[0].width; // 页宽(mm)
ofd.outlines; // 书签树(含目标页码)
ofd.attachments; // 附件(文件名字节)
ofd.signatures; // 签名文件清单
ofd.fonts; // 嵌入字体(名字 + 字节)
ofd.limitations; // 未支持特性的清单
```

## 页面对象

每页的 `objects` 是按绘制顺序排列的对象数组:

```ts
for (const o of ofd.pages[0].objects) {
if (o.kind === 'text') o.codes; // TextCode:文本 + 每字符偏移
if (o.kind === 'path') o.ops; // M / L / C(三次) / Q(二次) / Z
if (o.kind === 'image') o.data; // 图片字节(PNG/JPEG/GIF/BMP/TIFF)
}
```

所有坐标单位为 **毫米**,与 OFD 原生单位一致;颜色统一转成 RGB(灰度/CMYK 自动换算),`alpha` 为 0–1。

## 文本提取

```ts
const perPage = extractText(ofd); // string[],每页一段
```

## 渲染为 SVG

```ts
const svg = pageToSvg(ofd.pages[0], { zoom: 3 });
document.querySelector('#preview').innerHTML = svg;
```

- 文字保持为可选中的真实 `<text>`(逐字 tspan 按 DeltaX 定位);
- 图片内嵌为 data URL;
- `zoom` 控制输出像素尺寸,`includeText: false` 可隐藏文字层(双层预览场景)。

## 当前限制

解析器聚焦生成侧往返与主流阅读场景,以下特性会跳过并记录在 `limitations` 中:
复合对象(CompositeObject)嵌套、裁剪区(Clip)、页面注解外观(StampAnnot)、GoTo 之外的动作。`parseOfd` 只列出签名文件清单;SES 制章、签章与可信验签见[电子签章与可信验签](./signatures.md)。
116 changes: 116 additions & 0 deletions docs/guide/signatures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# SES 电子签章与可信验签

jsOFD 支持 GB/T 38540-2020 SES V4 电子印章、OFD 多次签章、可见印章外观,以及基于显式信任锚的验签。电子签章用于证明身份和完整性,**不会加密正文**。

## 生产密钥接口

生产私钥不应进入浏览器或 jsOFD。实现 `Sm2Signer`,把准确的 TBS DER 字节交给 UKey、KMS、密码机或服务端:

```ts
import type { Sm2Signer } from '@hufe921/jsofd';

const signer: Sm2Signer = {
async sign({ tbs, certificate, userId, signal }) {
return signingService.signSm2Der({ tbs, certificate, userId, signal });
},
};
```

`createLocalTestSm2Signer(privateKey)` 只用于本地测试,不应用于生产密钥。

## 生成电子印章

制章和签章是两级独立签名。下面用测试签名器演示接口形状:

```ts
import { createSesSeal, createLocalTestSm2Signer } from '@hufe921/jsofd';

const eSeal = await createSesSeal({
info: {
header: { id: 'ES', version: 4, vid: 'JSOFD' },
esId: 'SEAL-001',
property: {
type: 1,
name: '测试印章',
certList: [makerCertificate],
createDate: new Date('2026-01-01T00:00:00Z'),
validStart: new Date('2026-01-01T00:00:00Z'),
validEnd: new Date('2027-01-01T00:00:00Z'),
},
picture: { type: 'PNG', data: sealPng, width: 40, height: 40 },
},
makerCertificate,
signer: makerSigner,
});
```

已有合规制章系统时,也可以直接传入其生成的标准 `SESeal` DER,无需重新制章。

## 签章

```ts
import { signOfd } from '@hufe921/jsofd';

const signedBytes = await signOfd(ofdBytes, {
eSeal,
signingCertificate,
signer,
stamp: { page: 1, x: 120, y: 240, w: 40, h: 40 },
});
```

生成的包包含 `Signatures.xml`、独立的 `Sign_N/Signature.xml`、`Seal.esl` 和 `SignedValue.dat`。印章图片取自 SES 电子印章,不是单独伪造的 PNG 外观。

默认允许继续签章,`Signatures.xml` 不进入本次保护列表,旧签章文件会保留。若 `allowAdditionalSignatures: false`,签名列表也纳入保护,之后不能再追加签章。

## 可信验签

验签必须显式提供受信根证书;包内公钥和证书本身不构成信任依据。

```ts
import { verifyOfd } from '@hufe921/jsofd';

const report = await verifyOfd(signedBytes, {
trustAnchors: [rootCaDer],
intermediateCertificates: [intermediateCaDer],
revocationProvider: {
async check(certificate, issuer, at) {
return revocationService.status(certificate, issuer, at); // good | revoked | unknown
},
},
requireRevocation: true,
});

for (const signature of report.signatures) {
console.log(signature.status); // valid | invalid | untrusted | indeterminate
console.log(signature.referencesValid, signature.signatureValid);
console.log(signature.signerTrust.status, signature.makerTrust.status);
}
```

结果按每个签章分别返回:

- `referencesValid`:受保护包内文件的 SM3 是否全部匹配;
- `signatureValid`:`Signature.xml` 摘要绑定和签章者 SM2 签名是否正确;
- `sealSignatureValid`:制章者签名及 `Seal.esl` 绑定是否正确;
- `signerTrust` / `makerTrust`:两条证书链的信任结果;
- `status`:按当前信任和吊销策略得出的综合状态。

数学签名正确但证书不受信时,结果为 `untrusted`,不能当作有效签章。缺少策略要求的吊销证据时为 `indeterminate`,不会静默放行。

## 数科与福昕联调

仓库提供 `packages/core/tests/manual/ses-interop.test.ts`。在仓库外的安全目录放入:

- 共享或分别命名的 `root.der`、`intermediate.der`;也可使用 `maker-root.der`、`signer-root.der` 和对应 intermediate;
- `maker.der`、`signer.der`;
- 仅用于联调的 `maker-key.hex`、`signer-key.hex`(32 字节 SM2 私钥 hex);
- `seal.png`。

然后运行:

```sh
JSOFD_SES_TEST_DIR=/secure/local/path pnpm --filter @hufe921/jsofd exec vitest run tests/manual/ses-interop.test.ts
```

脚本先在本地验证单次签章、两次签章和篡改样本,再把 `single.ofd`、`multiple.ofd`、`tampered.ofd` 写到系统临时目录,供数科和福昕检查。真实证书、私钥和生成物不要提交仓库。
Loading
Loading