mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 23:42:16 +08:00
Compare commits
474
Commits
07eb44c037
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff61c5ddeb | ||
|
|
47f1a3b77c | ||
|
|
951a947bbe | ||
|
|
5e98ec2789 | ||
|
|
e7a44bfa1a | ||
|
|
8936d98590 | ||
|
|
d4274f48c2 | ||
|
|
26bad97aab | ||
|
|
f7233616f7 | ||
|
|
8a4967c064 | ||
|
|
cffc78626b | ||
|
|
2350504cfe | ||
|
|
86e4d7d6ae | ||
|
|
f05275c6b8 | ||
|
|
ad23f0f8b0 | ||
|
|
f4a7516985 | ||
|
|
c8aaaca773 | ||
|
|
035da09334 | ||
|
|
1abeba6f99 | ||
|
|
12b04c184c | ||
|
|
47c593286d | ||
|
|
e519bb0527 | ||
|
|
b9f3274967 | ||
|
|
37b3e59282 | ||
|
|
f284a40897 | ||
|
|
8d6db4f91d | ||
|
|
3404677b15 | ||
|
|
8d435fe109 | ||
|
|
385d7aa531 | ||
|
|
94157b10c3 | ||
|
|
aebcda51a3 | ||
|
|
19084bb40b | ||
|
|
545b697310 | ||
|
|
1a25472b43 | ||
|
|
af80fcc9ba | ||
|
|
838a769c5b | ||
|
|
5b4f2472f7 | ||
|
|
fb51fd3b07 | ||
|
|
80e548f93e | ||
|
|
9c3a04b7eb | ||
|
|
9bb6c9141a | ||
|
|
4320b53d4a | ||
|
|
e5d7f577c7 | ||
|
|
7a23dcc6dd | ||
|
|
12aaada982 | ||
|
|
62631472ea | ||
|
|
9d6bf3e87f | ||
|
|
f8c8259502 | ||
|
|
f4429b126a | ||
|
|
50898934dc | ||
|
|
c2b0339fdf | ||
|
|
e5658de69d | ||
|
|
057bcba868 | ||
|
|
89a7ef04c7 | ||
|
|
e35533859c | ||
|
|
1215f7aa08 | ||
|
|
48d1d29b7e | ||
|
|
ba68acbc60 | ||
|
|
a75b90dffc | ||
|
|
da3f64c74a | ||
|
|
7e3f2c468a | ||
|
|
d384527371 | ||
|
|
2dcbafb70d | ||
|
|
f246b0f543 | ||
|
|
dfea3d60fd | ||
|
|
e91e146b50 | ||
|
|
f2c71c5acf | ||
|
|
b59d82a68a | ||
|
|
44eb9e20b9 | ||
|
|
bc71750c78 | ||
|
|
143d5e7f92 | ||
|
|
f76a1ecd1a | ||
|
|
af8b64e748 | ||
|
|
e67f329776 | ||
|
|
7bc31b6b84 | ||
|
|
dfd4f733f9 | ||
|
|
88adea348b | ||
|
|
20374f52f1 | ||
|
|
6dae7bdc20 | ||
|
|
9ee47731c9 | ||
|
|
4fee3dfd8e | ||
|
|
f322daa616 | ||
|
|
04efce42d8 | ||
|
|
41a50d9e96 | ||
|
|
f1864952ad | ||
|
|
413d41c31d | ||
|
|
fb4f03465b | ||
|
|
04d7d2d672 | ||
|
|
15a532a1ca | ||
|
|
1061b07942 | ||
|
|
01e29618f4 | ||
|
|
51ab8846e3 | ||
|
|
05d9657c7c | ||
|
|
cc7858b536 | ||
|
|
4fc468a27d | ||
|
|
630306e871 | ||
|
|
c351b80344 | ||
|
|
e66afe6b07 | ||
|
|
cc274bc571 | ||
|
|
730586b98f | ||
|
|
6832554ea3 | ||
|
|
6601340fcc | ||
|
|
47347e5364 | ||
|
|
7414a00305 | ||
|
|
4573cb942b | ||
|
|
15c64ed6f2 | ||
|
|
3816226ba5 | ||
|
|
3607c15162 | ||
|
|
8cbf0546ee | ||
|
|
411171bc60 | ||
|
|
2eaf5366bf | ||
|
|
17383adab6 | ||
|
|
f77899e576 | ||
|
|
94983ee23c | ||
|
|
d9c334e8aa | ||
|
|
6063492bd7 | ||
|
|
2cec0b17d3 | ||
|
|
986295ae57 | ||
|
|
92326d0a14 | ||
|
|
0d6a21fe9a | ||
|
|
1de949abfc | ||
|
|
1c576bde18 | ||
|
|
8774ac67f1 | ||
|
|
3ef0e36026 | ||
|
|
19aa5523f8 | ||
|
|
f0c0b9db94 | ||
|
|
df81d41d95 | ||
|
|
82b5275ad7 | ||
|
|
299229c584 | ||
|
|
75995cd8dc | ||
|
|
b875b885f1 | ||
|
|
c4345420d5 | ||
|
|
3f8240d3c1 | ||
|
|
410df7c3b0 | ||
|
|
89e080a9fc | ||
|
|
8e8aca399b | ||
|
|
a82d5858bd | ||
|
|
297e093457 | ||
|
|
7b4eb5ff60 | ||
|
|
2984b07c83 | ||
|
|
865ddd47f4 | ||
|
|
a022c3e636 | ||
|
|
615f94bcac | ||
|
|
e73db10de1 | ||
|
|
0ae9ad6562 | ||
|
|
799041171b | ||
|
|
86f34bbdea | ||
|
|
0021228898 | ||
|
|
a9b26b3257 | ||
|
|
5eed2d4e07 | ||
|
|
f830bcefa7 | ||
|
|
4c475e9b67 | ||
|
|
c10aa4dda7 | ||
|
|
69e2e52b96 | ||
|
|
571f8426fc | ||
|
|
7682a7dd5d | ||
|
|
68c58ef8c9 | ||
|
|
62206c7a4b | ||
|
|
850057f46f | ||
|
|
3a39fb293f | ||
|
|
bafc3e399f | ||
|
|
f6338784e1 | ||
|
|
2016b85256 | ||
|
|
629ff60efd | ||
|
|
7ddb67ea41 | ||
|
|
d717b704d8 | ||
|
|
76125e19d8 | ||
|
|
ac2eb2b5c7 | ||
|
|
85baeda7f9 | ||
|
|
49e5445ccd | ||
|
|
5b6dbd4afc | ||
|
|
de03bdfc6a | ||
|
|
f15b40545a | ||
|
|
a73d08d328 | ||
|
|
dacdf0b3c9 | ||
|
|
c2d525e77f | ||
|
|
bcac3673f6 | ||
|
|
0aa7575980 | ||
|
|
361a2b3fd3 | ||
|
|
fb23d92d08 | ||
|
|
89e7f09de8 | ||
|
|
9cb3a898c4 | ||
|
|
b680f0bc27 | ||
|
|
3bed2bfe77 | ||
|
|
3d3220a0ad | ||
|
|
50da1d9144 | ||
|
|
5183ba2ab0 | ||
|
|
994988d425 | ||
|
|
72a85e26cc | ||
|
|
2063745224 | ||
|
|
9b362d2f02 | ||
|
|
67f4cf9d2c | ||
|
|
974b515374 | ||
|
|
c250ca914e | ||
|
|
201082daa0 | ||
|
|
b4d566019d | ||
|
|
580592bbf8 | ||
|
|
764e8a750a | ||
|
|
3f774056da | ||
|
|
3227134bfc | ||
|
|
6ca44cd9a5 | ||
|
|
753149260f | ||
|
|
7c870d09e7 | ||
|
|
5f1a4e7dcc | ||
|
|
92a0957de2 | ||
|
|
8cf8303088 | ||
|
|
d9fb144e35 | ||
|
|
6b0332224f | ||
|
|
eac5a659e6 | ||
|
|
1abfdaf431 | ||
|
|
90514f2e18 | ||
|
|
075797402d | ||
|
|
eb20f66ffb | ||
|
|
4f530eb5b8 | ||
|
|
66aa87304b | ||
|
|
010ab9f314 | ||
|
|
83d936e9f8 | ||
|
|
ea55e997ee | ||
|
|
5fd3854af8 | ||
|
|
a882e3e137 | ||
|
|
a29b37ddef | ||
|
|
010baf3370 | ||
|
|
13b074c922 | ||
|
|
20d8dcebde | ||
|
|
f0094e2af6 | ||
|
|
e8ac15b0f9 | ||
|
|
83159a475c | ||
|
|
f3cc35e78c | ||
|
|
bfecdeb0d6 | ||
|
|
1a8de5a3f1 | ||
|
|
ec24b6a17d | ||
|
|
84808cda61 | ||
|
|
aab5d40f93 | ||
|
|
e222cddabb | ||
|
|
b06df462ea | ||
|
|
73889edefb | ||
|
|
d97c6f4e6f | ||
|
|
151ddb0a20 | ||
|
|
6a4b322e2c | ||
|
|
b7d852d879 | ||
|
|
dd0b14be77 | ||
|
|
f952c5d91d | ||
|
|
a2de650b52 | ||
|
|
627a06cda3 | ||
|
|
460701f4d9 | ||
|
|
a4f979ed2f | ||
|
|
92785cea4f | ||
|
|
ffb92b9f2d | ||
|
|
975a831954 | ||
|
|
fe77e8fa37 | ||
|
|
a665d5c11b | ||
|
|
7126568b47 | ||
|
|
4c65552909 | ||
|
|
a8d7699337 | ||
|
|
c2751ae186 | ||
|
|
0d2e1ccdf2 | ||
|
|
a0899fa150 | ||
|
|
0eae7c72d0 | ||
|
|
abfe4c2287 | ||
|
|
16b09e52e4 | ||
|
|
331e26b71e | ||
|
|
7a39cb3d29 | ||
|
|
829d59e652 | ||
|
|
0dafb2eb48 | ||
|
|
44c6087dc5 | ||
|
|
c386acedab | ||
|
|
e261287baa | ||
|
|
253debb3f9 | ||
|
|
d44d096972 | ||
|
|
e8c3f355d7 | ||
|
|
dc59aefeb7 | ||
|
|
eedfa72721 | ||
|
|
ec2602746c | ||
|
|
eaefd730ae | ||
|
|
2e5241733a | ||
|
|
bd8add89f3 | ||
|
|
84ca546d54 | ||
|
|
c5224c54fb | ||
|
|
0466276704 | ||
|
|
c1f85c04df | ||
|
|
640d2d68f2 | ||
|
|
1021726b4d | ||
|
|
2d16fc833d | ||
|
|
8e31f888d3 | ||
|
|
0ab785ebc3 | ||
|
|
87334eb84c | ||
|
|
be8465884a | ||
|
|
78bb70d5a2 | ||
|
|
745f39a614 | ||
|
|
1a9059429e | ||
|
|
3709a62c24 | ||
|
|
2904a0d914 | ||
|
|
eb8e17efa8 | ||
|
|
896d624c3e | ||
|
|
64997b6c39 | ||
|
|
a0dff862f1 | ||
|
|
0aed2a5609 | ||
|
|
8a1caf7952 | ||
|
|
adc5c5a3c5 | ||
|
|
e7a8bf89de | ||
|
|
06714b7150 | ||
|
|
274e132b88 | ||
|
|
c97e5c9de4 | ||
|
|
6639653843 | ||
|
|
68d6a19656 | ||
|
|
b8d90174a0 | ||
|
|
7a0bbbe8df | ||
|
|
45b9e93041 | ||
|
|
9688031dad | ||
|
|
c3a4b161d8 | ||
|
|
c47793d4de | ||
|
|
2daae08a0f | ||
|
|
30f4f76260 | ||
|
|
528162a276 | ||
|
|
d3a713f504 | ||
|
|
2cf96a872a | ||
|
|
365f3ba8f9 | ||
|
|
33ae0b1a98 | ||
|
|
bb3abdea1d | ||
|
|
8f9e194c0c | ||
|
|
1432562c6f | ||
|
|
2573053a82 | ||
|
|
dc48962eb3 | ||
|
|
239baf9ce8 | ||
|
|
8dfd610cd5 | ||
|
|
078b67ccf0 | ||
|
|
e64575ec16 | ||
|
|
e1bf215e6a | ||
|
|
b5a8c9af9c | ||
|
|
5e0bca66e3 | ||
|
|
0b1dc7e6c8 | ||
|
|
6926ceafc9 | ||
|
|
59e3a7d36e | ||
|
|
5dea48d54b | ||
|
|
6e8eb7cfcc | ||
|
|
307e3a718a | ||
|
|
43d0713488 | ||
|
|
7f6282fd94 | ||
|
|
3d54b15b11 | ||
|
|
75b6943e10 | ||
|
|
7008f36d2b | ||
|
|
3164548eb4 | ||
|
|
96a8997482 | ||
|
|
fa476f18a3 | ||
|
|
97a00cf1a8 | ||
|
|
aacf789615 | ||
|
|
89252a7454 | ||
|
|
4be11e7372 | ||
|
|
82d78feb6b | ||
|
|
07e1ec18e1 | ||
|
|
ef901bea0f | ||
|
|
9791571d75 | ||
|
|
a182f025e1 | ||
|
|
de229c163b | ||
|
|
6299c0f8f9 | ||
|
|
61cfb79f25 | ||
|
|
71a3a4ee79 | ||
|
|
9228c0acbb | ||
|
|
70003486b8 | ||
|
|
d07972346b | ||
|
|
961bc52038 | ||
|
|
b3c4bbc844 | ||
|
|
bec13d523f | ||
|
|
21bd592358 | ||
|
|
20e1bf95cd | ||
|
|
5cc811c2a1 | ||
|
|
0be872691d | ||
|
|
64f9ec4241 | ||
|
|
ee3fd02a0a | ||
|
|
9ade2466ba | ||
|
|
67aabbda84 | ||
|
|
de414de754 | ||
|
|
1edcc53ea3 | ||
|
|
a812d7ed8e | ||
|
|
b03cdf3ad0 | ||
|
|
e4d38eb289 | ||
|
|
2db0cbcb95 | ||
|
|
aedc1493fb | ||
|
|
ddf0dda283 | ||
|
|
f024eff35d | ||
|
|
6f9bebae26 | ||
|
|
f5463b9479 | ||
|
|
6d782de471 | ||
|
|
0afb368829 | ||
|
|
8a88a86264 | ||
|
|
fc01fbdc17 | ||
|
|
cd5ea45a89 | ||
|
|
c74dff1e41 | ||
|
|
0139433e25 | ||
|
|
523f66983e | ||
|
|
7bdb03f7bd | ||
|
|
0e423c9713 | ||
|
|
656a0d51b0 | ||
|
|
ab0c6dfed0 | ||
|
|
7841eaa997 | ||
|
|
b64668514f | ||
|
|
979b660703 | ||
|
|
5a88a9f31f | ||
|
|
7e774f99df | ||
|
|
09bb08987e | ||
|
|
489db301c5 | ||
|
|
7df35ac122 | ||
|
|
a3594d1280 | ||
|
|
078c4e0736 | ||
|
|
e91c30ca3c | ||
|
|
91e5b57e24 | ||
|
|
3f797e656b | ||
|
|
261971cc41 | ||
|
|
e5685e5c7b | ||
|
|
e86fc6a4e0 | ||
|
|
3780a25140 | ||
|
|
85fbb77d63 | ||
|
|
f8263f798f | ||
|
|
5d1fd593cd | ||
|
|
3d37b0d510 | ||
|
|
4caf49d3d0 | ||
|
|
8d3e6944aa | ||
|
|
4b9dbd6447 | ||
|
|
778d36d373 | ||
|
|
d8dc25293e | ||
|
|
92149b220e | ||
|
|
7bce701907 | ||
|
|
c51ae86876 | ||
|
|
9a32c510c4 | ||
|
|
ff166b1ee6 | ||
|
|
2370fb07d8 | ||
|
|
9c84264ac7 | ||
|
|
05579265b8 | ||
|
|
8979550127 | ||
|
|
47b1957e72 | ||
|
|
2a7fbd7f61 | ||
|
|
c4b81155a9 | ||
|
|
17a9a03361 | ||
|
|
a710bc5f41 | ||
|
|
19dc79e8e3 | ||
|
|
225f60919b | ||
|
|
d7e9604282 | ||
|
|
2e3809179b | ||
|
|
8864f6c7ba | ||
|
|
10e724d57f | ||
|
|
52d7b6aa64 | ||
|
|
34e17383f1 | ||
|
|
e5f34c6ea6 | ||
|
|
478c18cb1f | ||
|
|
2c06e5ff69 | ||
|
|
cfbaa8a177 | ||
|
|
6c3068b813 | ||
|
|
36c0dc3ca0 | ||
|
|
c3f6b61dfd | ||
|
|
a3098212eb | ||
|
|
6a9d2a12eb | ||
|
|
f71000befd | ||
|
|
13dbacad15 | ||
|
|
d5944b30ff | ||
|
|
226688bbdc | ||
|
|
62c81e7061 | ||
|
|
78379bfa6e | ||
|
|
cf8dfd046b | ||
|
|
051fc3b65e | ||
|
|
63e56c5ab8 | ||
|
|
01b1740a83 | ||
|
|
4853ad702b | ||
|
|
e9579e3d46 | ||
|
|
efe8c2ff85 | ||
|
|
6d8ac6ed92 | ||
|
|
d181d9edca | ||
|
|
e6a8786f0c | ||
|
|
b20f411f0d | ||
|
|
89bbbba46e | ||
|
|
5bc92dddd1 | ||
|
|
c405c2c5bf | ||
|
|
492adccb81 | ||
|
|
53769b65ab | ||
|
|
341ad7cd7e |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
export TZ=Asia/Shanghai
|
||||
GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -o assets/webui/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/webui.go
|
||||
export Version="$(git show -s --format='%ct-%H' HEAD)"
|
||||
GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -o assets/webui/wasm/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main -trimpath -ldflags "-s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=$Version" ./cmd/webui/webui.go
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
.devcontainer/
|
||||
releases/
|
||||
go.work
|
||||
go.work.sum
|
||||
assets/webui/ofdgo.wasm
|
||||
*.exe
|
||||
*.exe~
|
||||
assets/webui/wasm/ofdgo.wasm
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# 基础镜像
|
||||
FROM alpine:3.24.2
|
||||
FROM alpine:3.24.3
|
||||
|
||||
# 作者信息
|
||||
LABEL authors="肖其顿 (XIAO QI DUN)"
|
||||
|
||||
@@ -3,14 +3,28 @@
|
||||
|
||||
# 主要能力
|
||||
- 文档处理:创建、读写、拆分、合并、压缩
|
||||
- 渲染转换:导入、导出、矢量、位图、批量
|
||||
- 文档安全:加密、解密、签名、签章、验签
|
||||
- 文档阅读:搜索、缩放、旋转、双页、连续
|
||||
- 文档管理:新建、导入、切换、排序、删除
|
||||
- 版本管理:创建、改名、切换、另存、删除
|
||||
- 页面管理:添加、导入、复制、排序、删除
|
||||
- 文字处理:插入、编辑、排版、搜索、提取
|
||||
- 文档编辑:选区、重排、事务、撤销、重做
|
||||
- 文字处理:插入、编辑、替换、排版、提取
|
||||
- 文字样式:混排、粗体、斜体、阴影、底色
|
||||
- 字体处理:匹配、内嵌、外置、子集、提取
|
||||
- 图片处理:插入、替换、缩放、裁剪、提取
|
||||
- 图形处理:绘制、组合、变换、对齐、分布
|
||||
- 注解管理:批注、高亮、链接、水印、印章
|
||||
- 资源管理:字体、色彩、图片、媒体、附件
|
||||
- 图形处理:绘制、变换、组合、对齐、分布
|
||||
- 色彩管理:调色、渐变、图案、透明、转换
|
||||
- 文档结构:图层、模板、目录、书签、标引
|
||||
- 资源管理:导入、绑定、替换、提取、清理
|
||||
- 注解管理:批注、高亮、水印、印章、链接
|
||||
- 交互动作:跳转、链接、附件、音频、视频
|
||||
- 电子发票:提取、创建、编辑、校验、同步
|
||||
- 文档安全:签名、签章、验签、加密、解密
|
||||
- 三维处理:解析、材质、纹理、动画、渲染
|
||||
- 页面渲染:矢量、位图、裁剪、蒙版、合成
|
||||
- 批量转换:选页、并行、进度、取消、打包
|
||||
- 打印输出:预览、双面、合印、小册、海报
|
||||
- 应用交互:标签、链接、关联、拖放、复用
|
||||
|
||||
# 在线使用
|
||||
[OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务
|
||||
@@ -44,13 +58,35 @@ docker run -d -p 80:80 ccr.ccs.tencentyun.com/xiaoqidun/ofdgo:latest
|
||||
:: 1. 编译OFDGo WASM
|
||||
set GOOS=js
|
||||
set GOARCH=wasm
|
||||
go build -o assets/webui/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go
|
||||
go build -o assets/webui/wasm/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go
|
||||
:: 2. 编译OFDGo WebUI
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
go build -o ofdgo_webui.exe -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/webui.go
|
||||
```
|
||||
|
||||
# 原生桌面
|
||||
基于 [GUIGo](https://github.com/xiaoqidun/guigo) 实现,运行前需参照其说明安装系统依赖
|
||||
|
||||
```batch
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: 构建Linux arm64(aarch64)
|
||||
set GOOS=linux
|
||||
set GOARCH=arm64
|
||||
go build -o ofdgo_linux_arm64 -trimpath -ldflags "-s -w -buildid=" ./cmd/webview
|
||||
|
||||
:: 构建Linux amd64(x64/x86_64)
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
go build -o ofdgo_linux_amd64 -trimpath -ldflags "-s -w -buildid=" ./cmd/webview
|
||||
|
||||
:: 构建Windows amd64(x64/x86_64)
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
go build -o ofdgo_windows_amd64.exe -trimpath -ldflags "-H windowsgui -s -w -buildid=" ./cmd/webview
|
||||
```
|
||||
|
||||
# 安装引擎
|
||||
```shell
|
||||
go get -u github.com/xiaoqidun/ofdgo
|
||||
@@ -61,6 +97,7 @@ go get -u github.com/xiaoqidun/ofdgo
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
@@ -89,12 +126,11 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// 3. 保存文档
|
||||
ofdFile, err := os.Create("test.ofd")
|
||||
output, err := ofdgo.NewFileOutput("test.ofd")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ofdFile.Close()
|
||||
if _, err := editor.WriteTo(ofdFile); err != nil {
|
||||
if _, err := editor.SaveAs(context.Background(), output, ofdgo.WriteOptions{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -105,15 +141,20 @@ func main() {
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 打开文档
|
||||
reader, err := ofdgo.Open("test.ofd")
|
||||
input, err := ofdgo.OpenFile(context.Background(), "test.ofd", ofdgo.InputOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
reader, err := ofdgo.NewReader(input)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -125,12 +166,11 @@ func main() {
|
||||
}
|
||||
editor.Info.Title = "编辑后的"
|
||||
// 3. 另存文档
|
||||
ofdFile, err := os.Create("edited.ofd")
|
||||
output, err := ofdgo.NewFileOutput("edited.ofd")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ofdFile.Close()
|
||||
if _, err := editor.WriteTo(ofdFile); err != nil {
|
||||
if _, err := editor.SaveAs(context.Background(), output, ofdgo.WriteOptions{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -150,15 +190,11 @@ import (
|
||||
|
||||
func main() {
|
||||
// 1. 打开文档
|
||||
pdfFile, err := os.Open("test.pdf")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer pdfFile.Close()
|
||||
info, err := pdfFile.Stat()
|
||||
input, err := ofdgo.OpenFile(context.Background(), "test.pdf", ofdgo.InputOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
// 2. 创建文件
|
||||
ofdFile, err := os.Create("test.ofd")
|
||||
if err != nil {
|
||||
@@ -166,7 +202,7 @@ func main() {
|
||||
}
|
||||
defer ofdFile.Close()
|
||||
// 3. 转换文档
|
||||
report, err := ofdgo.ConvertPDF(context.Background(), pdfFile, info.Size(), ofdFile, ofdgo.PDFImportOptions{})
|
||||
report, err := ofdgo.ConvertPDF(context.Background(), input, ofdFile, ofdgo.PDFImportOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -182,6 +218,7 @@ func main() {
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
@@ -190,7 +227,12 @@ import (
|
||||
|
||||
func main() {
|
||||
// 1. 打开OFD文件
|
||||
reader, err := ofdgo.Open("test.ofd")
|
||||
input, err := ofdgo.OpenFile(context.Background(), "test.ofd", ofdgo.InputOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
reader, err := ofdgo.NewReader(input)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -214,6 +256,7 @@ func main() {
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
@@ -222,12 +265,13 @@ import (
|
||||
|
||||
func main() {
|
||||
// 1. 打开OFD文件
|
||||
data, err := os.ReadFile("test.ofd")
|
||||
input, err := ofdgo.OpenFile(context.Background(), "test.ofd", ofdgo.InputOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
// 2. 验证OFD签名
|
||||
reports, err := ofdgo.VerifySignaturesBytes(data)
|
||||
reports, err := ofdgo.VerifySignatures(input)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -255,5 +299,44 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
# 提取发票
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 打开OFD文件
|
||||
input, err := ofdgo.OpenFile(context.Background(), "invoice.ofd", ofdgo.InputOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
reader, err := ofdgo.NewReader(input)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
// 2. 提取发票信息
|
||||
report, err := reader.Invoices()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// 3. 输出提取结果
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 授权协议
|
||||
本项目使用 [Apache License 2.0](https://github.com/xiaoqidun/ofdgo/blob/main/LICENSE) 授权协议
|
||||
+6
-17
@@ -14,25 +14,14 @@
|
||||
|
||||
package webuiassets
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
)
|
||||
import "embed"
|
||||
|
||||
// FS WebUI嵌入静态文件
|
||||
//
|
||||
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
|
||||
//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_theme.js ofdgo_util.js ofdgo_view.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
|
||||
var FS embed.FS
|
||||
|
||||
// Checksum 计算WebUI嵌入资源校验值
|
||||
// 返回: string SHA-256十六进制校验值
|
||||
func Checksum() string {
|
||||
hash := sha256.New()
|
||||
entries, _ := FS.ReadDir(".")
|
||||
for _, entry := range entries {
|
||||
data, _ := FS.ReadFile(entry.Name())
|
||||
hash.Write(data)
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
// NativeFS WebView嵌入静态文件
|
||||
//
|
||||
//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_theme.js ofdgo_util.js ofdgo_view.js
|
||||
var NativeFS embed.FS
|
||||
+440
-232
File diff suppressed because it is too large.
Load diff
+2098
-420
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
+7021
-2903
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,12 @@
|
||||
"src": "./ofdgo.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "./ofdgo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
+1047
-143
File diff suppressed because it is too large.
Load diff
+119
-47
@@ -1,39 +1,59 @@
|
||||
import { bindDropdownPointer, positionDropdown } from "./ofdgo_util.js";
|
||||
|
||||
const DATABASE = "ofdgo";
|
||||
|
||||
export class FontPicker {
|
||||
constructor(input, toggle, list, onChange, onOpen) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
||||
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false });
|
||||
input.addEventListener("input", () => this.show(input.value));
|
||||
input.addEventListener("focus", () => input.select());
|
||||
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
||||
input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
|
||||
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); this.show(); } });
|
||||
input.addEventListener("keydown", event => this.keyDown(event));
|
||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.open) this.close();
|
||||
else { input.focus(); this.show(); }
|
||||
});
|
||||
list.addEventListener("pointerdown", event => {
|
||||
if (event.target.closest("[role=option]")) event.preventDefault();
|
||||
});
|
||||
list.addEventListener("click", event => {
|
||||
const option = event.target.closest("[role=option]");
|
||||
if (option) this.choose(Number(option.dataset.index));
|
||||
else { input.focus(); input.select(); this.show(); }
|
||||
});
|
||||
bindDropdownPointer(list, index => this.choose(index));
|
||||
window.addEventListener("resize", () => this.close());
|
||||
}
|
||||
|
||||
setFonts(fonts, selected) {
|
||||
this.close();
|
||||
setFonts(fonts, selected, preserve = false) {
|
||||
const open = preserve && this.open;
|
||||
if (!open) this.close();
|
||||
this.fonts = fonts;
|
||||
this.groups = fonts.map((_, index) => [index]);
|
||||
this.names = new Map();
|
||||
for (const font of fonts) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
const key = font => font?.id || font?.postscriptName;
|
||||
const key = font => font?.richKey || font?.id || font?.postscriptName;
|
||||
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
|
||||
this.restore();
|
||||
if (open) {
|
||||
const active = this.matches[this.active]?.font;
|
||||
this.show(this.query, false);
|
||||
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
|
||||
if (index >= 0) this.activate(index);
|
||||
} else this.restore();
|
||||
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
|
||||
if (this.fonts !== fonts) return;
|
||||
this.groups = groups;
|
||||
this.names.clear();
|
||||
for (const group of groups) {
|
||||
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
if (this.open) {
|
||||
const active = this.matches[this.active]?.index;
|
||||
this.show(this.query, false);
|
||||
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
|
||||
if (index >= 0) this.activate(index);
|
||||
}
|
||||
else this.restore();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
@@ -43,36 +63,45 @@ export class FontPicker {
|
||||
|
||||
restore() {
|
||||
const font = this.fonts[Number(this.value)];
|
||||
this.input.value = font?.fullName || font?.name || "";
|
||||
const value = font?.menuFamily || font?.fullName || font?.name || "";
|
||||
if (this.input.value !== value) {
|
||||
this.input.value = value;
|
||||
if (document.activeElement === this.input) this.input.select();
|
||||
}
|
||||
this.input.scrollLeft = 0;
|
||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||
this.input.setAttribute("aria-expanded", String(this.open));
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
|
||||
sourceLabel(font) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
if (this.names.get(name) < 2) return "";
|
||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||
}
|
||||
|
||||
show(query = "", load = true) {
|
||||
if (this.input.disabled) return;
|
||||
this.query = query;
|
||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
||||
this.matches = this.groups.map(group => {
|
||||
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
|
||||
return { font: this.fonts[index], index, group };
|
||||
}).filter(({ group }) => {
|
||||
const name = normalize(group.flatMap(index => {
|
||||
const font = this.fonts[index];
|
||||
return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])];
|
||||
}).filter(Boolean).join(" "));
|
||||
return terms.every(term => name.includes(term));
|
||||
});
|
||||
this.list.replaceChildren();
|
||||
for (const { font, index } of this.matches) {
|
||||
const option = document.createElement("div");
|
||||
option.className = "font-option";
|
||||
option.id = `${this.list.id}-${index}`;
|
||||
option.dataset.index = String(index);
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-disabled", String(Boolean(font.disabled)));
|
||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||
const name = document.createElement("span");
|
||||
name.textContent = font.fullName || font.name;
|
||||
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||
option.append(name);
|
||||
const source = this.sourceLabel(font);
|
||||
if (source) {
|
||||
@@ -92,30 +121,24 @@ export class FontPicker {
|
||||
}
|
||||
if (!this.open) this.list.showPopover();
|
||||
this.open = true;
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.position();
|
||||
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
|
||||
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
|
||||
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||
if (load) this.onOpen?.();
|
||||
}
|
||||
|
||||
position() {
|
||||
if (!this.open) return;
|
||||
const rect = this.input.parentElement.getBoundingClientRect();
|
||||
if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; }
|
||||
const width = Math.min(rect.width, window.innerWidth - 16);
|
||||
Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
|
||||
top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` });
|
||||
if (!positionDropdown(this.list, this.input.parentElement)) this.close();
|
||||
}
|
||||
|
||||
activate(index) {
|
||||
this.active = index;
|
||||
[...this.list.children].forEach((option, i) => option.setAttribute("aria-selected", String(i === index)));
|
||||
[...this.list.children].forEach((option, i) => option.setAttribute("data-selected", String(i === index)));
|
||||
const option = this.list.children[index];
|
||||
if (option) {
|
||||
this.input.setAttribute("aria-activedescendant", option.id);
|
||||
option.scrollIntoView({ block: "nearest" });
|
||||
} else this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
|
||||
choose(index) {
|
||||
@@ -123,16 +146,27 @@ export class FontPicker {
|
||||
const changed = this.value !== String(index);
|
||||
this.value = String(index);
|
||||
this.close();
|
||||
if (changed) return this.onChange();
|
||||
if (changed) {
|
||||
const task = this.changing = Promise.resolve(this.onChange());
|
||||
return task.finally(() => { if (this.changing === task) this.changing = null; });
|
||||
}
|
||||
}
|
||||
|
||||
commitExact() {
|
||||
const value = this.input.value.trim().toLocaleLowerCase();
|
||||
const matches = font => font && !font.disabled
|
||||
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
&& [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
const selected = Number(this.value);
|
||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
||||
if (index >= 0) this.choose(index);
|
||||
if (index >= 0) return this.choose(index);
|
||||
}
|
||||
|
||||
prepareCommit(event) {
|
||||
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
|
||||
this.commitExact();
|
||||
this.close();
|
||||
}
|
||||
return this.changing;
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -141,7 +175,7 @@ export class FontPicker {
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (event.isComposing) return;
|
||||
if (event.isComposing || event.key === "Tab" && (event.ctrlKey || event.metaKey || event.altKey)) return;
|
||||
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
@@ -163,7 +197,8 @@ export class FontPicker {
|
||||
}
|
||||
|
||||
export class FontManager {
|
||||
constructor({ onChange, onPermissionChange }) {
|
||||
constructor({ onChange, onPermissionChange, host = null }) {
|
||||
this.host = host;
|
||||
this.userFonts = [];
|
||||
this.localFonts = [];
|
||||
this.catalog = [];
|
||||
@@ -271,7 +306,7 @@ export class FontManager {
|
||||
|
||||
async queryLocal() {
|
||||
try {
|
||||
const available = await window.queryLocalFonts();
|
||||
const available = this.host ? await this.host.fonts() : await window.queryLocalFonts();
|
||||
this.catalog = available;
|
||||
this.catalogLoaded = true;
|
||||
this.permission = "granted";
|
||||
@@ -298,6 +333,7 @@ export class FontManager {
|
||||
}
|
||||
|
||||
async read(font) {
|
||||
if (font.nativeFile) return font.nativeFile;
|
||||
if (font.blob) {
|
||||
const blob = await font.blob();
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
@@ -316,13 +352,44 @@ export class FontManager {
|
||||
}), ...this.catalog];
|
||||
}
|
||||
|
||||
async loadFaces(inspect) {
|
||||
while (true) {
|
||||
async loadFaces(inspect, current = () => true) {
|
||||
while (current()) {
|
||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
||||
if (!file) return;
|
||||
if (!file) break;
|
||||
try { file.faces = await inspect(await this.read(file)); }
|
||||
catch { file.faces = []; }
|
||||
catch { if (!current()) return; file.faces = []; }
|
||||
}
|
||||
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
|
||||
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
||||
while (current() && pending.length) {
|
||||
const font = pending.shift();
|
||||
try {
|
||||
const faces = await inspect(await this.read(font));
|
||||
const face = faces.find(face => face.postscriptName === font.postscriptName);
|
||||
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
|
||||
else font.metadataFailed = true;
|
||||
} catch { if (current()) font.metadataFailed = true; }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async defaultFont(order, inspect, current = () => true) {
|
||||
const fonts = this.editorFonts().filter(font => !font.disabled);
|
||||
if (!fonts.length) return;
|
||||
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
|
||||
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
|
||||
const indices = await order(fonts, families);
|
||||
let fallback = fonts.length;
|
||||
for (const index of indices) {
|
||||
if (!current()) return;
|
||||
try {
|
||||
await inspect(fonts[index], "中文汉字编辑水印,。!?");
|
||||
if (current()) return fonts[index];
|
||||
} catch (error) {
|
||||
if (error.missingGlyphs) fallback = Math.min(fallback, index);
|
||||
}
|
||||
}
|
||||
if (current()) return fonts[fallback];
|
||||
}
|
||||
|
||||
async add(fonts) {
|
||||
@@ -402,7 +469,7 @@ export class FontManager {
|
||||
for (const font of fonts) {
|
||||
if (disabled.has(this.localName(font))) continue;
|
||||
const names = [this.localName(font), font.postscriptName && `${font.postscriptName}.ttf`,
|
||||
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`];
|
||||
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`, ...(font.names || []).map(name => `${name}.ttf`)];
|
||||
for (const name of names) {
|
||||
if (name && !entries.has(name)) entries.set(name, font);
|
||||
}
|
||||
@@ -411,10 +478,15 @@ export class FontManager {
|
||||
}
|
||||
|
||||
canReadLocal() {
|
||||
return typeof window.queryLocalFonts === "function";
|
||||
return !!this.host || typeof window.queryLocalFonts === "function";
|
||||
}
|
||||
|
||||
async refreshPermission() {
|
||||
if (this.host) {
|
||||
this.permission = "granted";
|
||||
this.onPermissionChange();
|
||||
return;
|
||||
}
|
||||
if (!this.canReadLocal() || !navigator.permissions?.query) {
|
||||
this.onPermissionChange();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
let engineModule = null;
|
||||
let engineScheduler = null;
|
||||
let engineSequence = 0;
|
||||
const engineRuntimes = new Map();
|
||||
|
||||
function requestKey(worker, data) {
|
||||
return worker.shared ? JSON.stringify([data.engineID, data.id]) : data.id;
|
||||
}
|
||||
|
||||
export function engineLimits() {
|
||||
engineScheduler ||= new BrowserScheduler();
|
||||
return {parallelism: engineScheduler.capacity, memoryBytes: engineScheduler.budget};
|
||||
}
|
||||
|
||||
class BrowserScheduler {
|
||||
constructor() {
|
||||
this.budget = Math.max(256, Math.min(2048, (navigator.deviceMemory || 2) * 256)) * 1024 * 1024;
|
||||
this.capacity = Math.max(1, Math.min((navigator.hardwareConcurrency || 2) - 1, Math.floor(this.budget / (128 << 20))));
|
||||
this.limit = Math.min(this.capacity, Math.max(1, Math.floor(this.budget / (512 << 20))));
|
||||
this.workers = new Set();
|
||||
this.queue = [];
|
||||
this.sequence = 0;
|
||||
}
|
||||
|
||||
get memory() {
|
||||
return [...this.workers].reduce((total, worker) => total + Math.max(worker.memory, worker.reservation), 0);
|
||||
}
|
||||
|
||||
active() {
|
||||
return [...this.workers].reduce((total, worker) => total + new Set([...worker.operations.values()].map(data => data.engineID)).size, 0);
|
||||
}
|
||||
|
||||
owner(worker, data) {
|
||||
return worker.shared ? worker.sessions.get(data.engineID) : worker;
|
||||
}
|
||||
|
||||
priority(task) {
|
||||
return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload || !!task.data.background);
|
||||
}
|
||||
|
||||
submit(worker, data, transfer) {
|
||||
if (data.preload && this.owner(worker, data)?.pagesPaused) {
|
||||
worker.receive({data:{engineID:data.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
|
||||
return;
|
||||
}
|
||||
this.queue.push({worker, data, transfer, queued:performance.now(), order:++this.sequence});
|
||||
this.drain();
|
||||
}
|
||||
|
||||
send(task) {
|
||||
const {worker, data, transfer} = task;
|
||||
worker.requests.set(requestKey(worker, data), performance.now());
|
||||
worker.operations.set(requestKey(worker, data), data);
|
||||
try { worker.worker.postMessage(data, transfer); }
|
||||
catch (error) {
|
||||
worker.requests.delete(requestKey(worker, data));
|
||||
worker.operations.delete(requestKey(worker, data));
|
||||
worker.receive({data:{engineID:data.engineID, id:data.id, ok:false, error:error.message}});
|
||||
}
|
||||
}
|
||||
|
||||
complete(worker, data) {
|
||||
if (data.memory) worker.memory = data.memory;
|
||||
if (data.id == null || data.type) return;
|
||||
const key = requestKey(worker, data);
|
||||
const started = worker.requests.get(key);
|
||||
const elapsed = data.elapsed ?? (started === undefined ? 0 : performance.now() - started);
|
||||
const operation = worker.operations.get(key), owner = this.owner(worker, data);
|
||||
if (worker.primary && started !== undefined && data.ok && owner) {
|
||||
if (operation?.name === "ofdgoOpen") owner.openTime = elapsed;
|
||||
if (operation?.name === "ofdgoRenderPage") owner.pageTime = owner.pageTime ? owner.pageTime * 0.75 + elapsed * 0.25 : elapsed;
|
||||
}
|
||||
worker.requests.delete(key);
|
||||
worker.operations.delete(key);
|
||||
if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1);
|
||||
else if (started !== undefined && data.ok && elapsed > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, this.limit + 1);
|
||||
}
|
||||
|
||||
drain() {
|
||||
if (this.draining) return;
|
||||
this.draining = true;
|
||||
try {
|
||||
if (this.queue.length) for (const worker of [...this.workers].sort((a,b) => b.memory-a.memory)) {
|
||||
if (this.memory + (64 << 20) <= this.budget) break;
|
||||
if (!worker.requests.size) worker.reclaim?.();
|
||||
}
|
||||
this.queue.sort((a, b) => this.priority(a) - this.priority(b) + (a.queued - b.queued) / 2000);
|
||||
for (let i = 0; i < this.queue.length;) {
|
||||
const task = this.queue[i];
|
||||
if (task.worker.closed) { this.queue.splice(i, 1); continue; }
|
||||
if (this.queue.some(previous => previous.worker === task.worker && previous.data.engineID === task.data.engineID && previous.order < task.order)) { i++; continue; }
|
||||
const busy = [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name !== "ofdgoSaveSnapshot");
|
||||
const snapshot = task.data.name === "ofdgoSaveSnapshot" && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name === "ofdgoSaveSnapshot");
|
||||
const active = this.active();
|
||||
const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload && !task.data.background;
|
||||
const foregroundActive = [...this.workers].some(worker => [...worker.operations.values()].some(data => data.name !== "ofdgoSaveSnapshot" && worker.primary && this.owner(worker, data)?.foreground === true && !data.preload && !data.background));
|
||||
const continuing = task.worker.primary && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID);
|
||||
const available = continuing || active < this.limit || interactive && !foregroundActive && active < this.limit + 1;
|
||||
if (busy || snapshot || !available || !task.worker.primary && active && this.memory + (64 << 20) > this.budget) { i++; continue; }
|
||||
this.queue.splice(i, 1);
|
||||
this.send(task);
|
||||
}
|
||||
} finally { this.draining = false; }
|
||||
}
|
||||
|
||||
cancel(worker, data) {
|
||||
const index = this.queue.findIndex(task => task.worker === worker && requestKey(worker, task.data) === requestKey(worker, data));
|
||||
if (index < 0) return false;
|
||||
const task = this.queue.splice(index, 1)[0];
|
||||
worker.receive({data:{engineID:task.data.engineID, id:data.id, ok:false, canceled:true, error:"操作已取消"}});
|
||||
this.drain();
|
||||
return true;
|
||||
}
|
||||
|
||||
pause(worker, engineID) {
|
||||
const paused = this.queue.filter(task => task.worker === worker && task.data.engineID === engineID && task.data.preload);
|
||||
this.queue = this.queue.filter(task => !paused.includes(task));
|
||||
for (const task of paused) worker.receive({data:{engineID, id:task.data.id, ok:false, canceled:true, error:"预加载已暂停"}});
|
||||
}
|
||||
|
||||
remove(worker) {
|
||||
this.workers.delete(worker);
|
||||
this.queue = this.queue.filter(task => task.worker !== worker);
|
||||
worker.requests.clear();
|
||||
worker.operations.clear();
|
||||
this.drain();
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserWorker {
|
||||
constructor(options) {
|
||||
engineScheduler ||= new BrowserScheduler();
|
||||
this.primary = !!options.primary;
|
||||
this.shared = !!options.shared;
|
||||
this.engineID = this.shared ? null : `engine-${++engineSequence}`;
|
||||
this.sessions = new Map();
|
||||
this.closingSessions = new Set();
|
||||
this.batch = !options.primary && !options.page;
|
||||
this.requests = new Map();
|
||||
this.operations = new Map();
|
||||
this.foreground = !this.batch;
|
||||
this.pagesPaused = false;
|
||||
this.memory = 64 << 20;
|
||||
this.reservation = 0;
|
||||
this.closed = false;
|
||||
this.url = String(options.url || "./ofdgo_wasm.js");
|
||||
this.worker = new Worker(this.url);
|
||||
engineScheduler.workers.add(this);
|
||||
this.worker.onmessage = event => {
|
||||
if (this.closed || event.data.type === "module") return;
|
||||
engineScheduler.complete(this, event.data);
|
||||
if (event.data.type === "exit" && (!this.shared || !event.data.engineID)) this.terminate();
|
||||
try { this.receive(event); }
|
||||
finally { engineScheduler.drain(); }
|
||||
};
|
||||
this.worker.onerror = event => { this.terminate(); this.fail("onerror", event); };
|
||||
this.worker.onmessageerror = event => { this.terminate(); this.fail("onmessageerror", event); };
|
||||
this.worker.addEventListener("message", event => {
|
||||
if (event.data.type !== "module") return;
|
||||
event.stopImmediatePropagation();
|
||||
engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; });
|
||||
engineModule.then(module => { if (!this.closed) this.worker.postMessage({ type:"module", module }); }, error => { if (!this.closed) this.worker.postMessage({ type:"module", error:error.message }); });
|
||||
});
|
||||
if (!this.shared) this.worker.postMessage({type:"open", engineID:this.engineID});
|
||||
}
|
||||
|
||||
receive(event) {
|
||||
if (!event.data.engineID && !["progress", "exit"].includes(event.data.type)) return;
|
||||
if (!this.shared) {
|
||||
if (event.data.engineID && event.data.engineID !== this.engineID) return;
|
||||
this.onmessage?.(event);
|
||||
return;
|
||||
}
|
||||
if (event.data.type === "closed") {
|
||||
if (!this.closingSessions.delete(event.data.engineID)) return;
|
||||
for (const [key, data] of this.operations) if (data.engineID === event.data.engineID) {
|
||||
this.requests.delete(key);
|
||||
this.operations.delete(key);
|
||||
}
|
||||
if (!this.sessions.size && !this.closingSessions.size) this.terminate();
|
||||
return;
|
||||
}
|
||||
const targets = event.data.engineID ? [this.sessions.get(event.data.engineID)] : [...this.sessions.values()];
|
||||
for (const session of targets) session?.receive(event);
|
||||
}
|
||||
|
||||
fail(name, event) {
|
||||
if (!this.shared) { this[name]?.(event); return; }
|
||||
for (const session of [...this.sessions.values()]) {
|
||||
session.terminate();
|
||||
session[name]?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
postMessage(data, transfer) {
|
||||
if (this.closed) throw new DOMException("引擎已关闭", "AbortError");
|
||||
if (!this.shared) data = {...data, engineID:this.engineID};
|
||||
if (data.type) {
|
||||
if (data.type === "cancel" && engineScheduler.cancel(this, data)) return;
|
||||
this.worker.postMessage(data, transfer);
|
||||
} else engineScheduler.submit(this, data, transfer);
|
||||
}
|
||||
|
||||
setForeground(foreground) {
|
||||
this.foreground = !!foreground;
|
||||
this.pausePages(!this.foreground);
|
||||
}
|
||||
|
||||
pausePages(paused = true) {
|
||||
this.pagesPaused = !!paused;
|
||||
if (this.pagesPaused) engineScheduler.pause(this, this.engineID);
|
||||
engineScheduler.drain();
|
||||
}
|
||||
|
||||
terminate() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (engineRuntimes.get(this.url) === this) engineRuntimes.delete(this.url);
|
||||
this.closingSessions.clear();
|
||||
this.worker.terminate();
|
||||
engineScheduler.remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserSession {
|
||||
constructor(options) {
|
||||
const url = String(options.url || "./ofdgo_wasm.js");
|
||||
let runtime = engineRuntimes.get(url);
|
||||
if (!runtime || runtime.closed) {
|
||||
runtime = new BrowserWorker({primary:true, shared:true, url});
|
||||
engineRuntimes.set(url, runtime);
|
||||
}
|
||||
this.runtime = runtime;
|
||||
this.engineID = `engine-${++engineSequence}`;
|
||||
this.requests = new Set();
|
||||
this.foreground = true;
|
||||
this.pagesPaused = false;
|
||||
this.closed = false;
|
||||
runtime.sessions.set(this.engineID, this);
|
||||
runtime.postMessage({type:"open", engineID:this.engineID});
|
||||
}
|
||||
|
||||
receive(event) {
|
||||
if (this.closed) return;
|
||||
if (event.data.id != null && !event.data.type) this.requests.delete(event.data.id);
|
||||
if (event.data.type === "exit") this.terminate();
|
||||
this.onmessage?.(event);
|
||||
}
|
||||
|
||||
postMessage(data, transfer) {
|
||||
if (this.closed) throw new DOMException("引擎已关闭", "AbortError");
|
||||
if (!data.type) this.requests.add(data.id);
|
||||
this.runtime.postMessage({...data, engineID:this.engineID}, transfer);
|
||||
}
|
||||
|
||||
setForeground(foreground) {
|
||||
this.foreground = !!foreground;
|
||||
this.pausePages(!this.foreground);
|
||||
}
|
||||
|
||||
pausePages(paused = true) {
|
||||
this.pagesPaused = !!paused;
|
||||
if (this.pagesPaused) engineScheduler.pause(this.runtime, this.engineID);
|
||||
engineScheduler.drain();
|
||||
}
|
||||
|
||||
terminate() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.runtime.sessions.delete(this.engineID);
|
||||
engineScheduler.queue = engineScheduler.queue.filter(task => task.worker !== this.runtime || task.data.engineID !== this.engineID);
|
||||
this.requests.clear();
|
||||
if (!this.runtime.closed) {
|
||||
this.runtime.closingSessions.add(this.engineID);
|
||||
this.runtime.postMessage({type:"close", engineID:this.engineID});
|
||||
}
|
||||
engineScheduler.drain();
|
||||
}
|
||||
}
|
||||
|
||||
export function createEngineWorker(options = {}) {
|
||||
if (nativeHost) return nativeHost.worker();
|
||||
if (options.page || options.batch) return new BrowserWorker(options);
|
||||
if (options.primary) return new BrowserSession(options);
|
||||
return new BrowserEngine(options);
|
||||
}
|
||||
|
||||
export async function documentBytes(file) {
|
||||
if (file?.nativeFile) return file;
|
||||
if (file instanceof Blob) return new Response(file.stream()).blob();
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
class BrowserEngine {
|
||||
constructor(options) {
|
||||
this.primary = createEngineWorker({primary:true, url:options.url});
|
||||
this.engineID = this.primary.engineID;
|
||||
this.workerURL = options.url;
|
||||
this.primary.onmessage = event => {
|
||||
const data = event.data;
|
||||
if (data.memory) this.memory = data.memory;
|
||||
if (this.source) this.parallelism = engineScheduler.limit;
|
||||
const rendered = data.id != null && !data.type && this.requests.delete(data.id);
|
||||
this.onmessage?.(event);
|
||||
if (rendered && data.ok) this.dispatchPages();
|
||||
};
|
||||
this.primary.onerror = event => this.onerror?.(event);
|
||||
this.primary.onmessageerror = event => this.onmessageerror?.(event);
|
||||
this.requests = new Map();
|
||||
this.fontRequests = new Map();
|
||||
this.primaryPages = new Set();
|
||||
this.slots = new Set();
|
||||
this.sequence = 0;
|
||||
this.revision = 0;
|
||||
this.memory = 0;
|
||||
this.parallelism = 1;
|
||||
this.budget = engineScheduler.budget;
|
||||
this.closed = false;
|
||||
this.source = null;
|
||||
this.pageSource = null;
|
||||
this.foreground = true;
|
||||
this.pagesPaused = false;
|
||||
}
|
||||
|
||||
configurePages(source) {
|
||||
const current = this.pageSource;
|
||||
const fonts = (source?.fonts || []).map(font => font?.data !== undefined ? {name:font.name, data:font.data} : font);
|
||||
const sameNames = JSON.stringify(source?.fontNames || []) === JSON.stringify(current?.fontNames || []);
|
||||
const sameFonts = current && fonts.length === current.fonts.length && fonts.every((font, index) => font === current.fonts[index] || font?.data !== undefined && font.name === current.fonts[index]?.name && font.data === current.fonts[index]?.data);
|
||||
if (source && current && source.file === current.file && !!source.pdf === !!current.pdf && (source.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts && sameNames) return;
|
||||
this.invalidatePages();
|
||||
if (!source || this.closed) return;
|
||||
this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""};
|
||||
this.source = {...source, file: source.file instanceof Blob ? source.file : new Blob([source.file])};
|
||||
this.parallelism = engineScheduler.limit;
|
||||
}
|
||||
|
||||
setForeground(foreground) {
|
||||
this.foreground = !!foreground;
|
||||
this.primary.setForeground(this.foreground);
|
||||
for (const slot of this.slots) slot.worker.setForeground(this.foreground);
|
||||
this.pausePages(!this.foreground);
|
||||
}
|
||||
|
||||
pausePages(paused = true) {
|
||||
this.pagesPaused = !!paused;
|
||||
this.primary.pausePages(this.pagesPaused);
|
||||
for (const slot of this.slots) slot.worker.pausePages(this.pagesPaused);
|
||||
}
|
||||
|
||||
invalidatePages() {
|
||||
this.revision++;
|
||||
this.primaryPages.clear();
|
||||
this.source = null;
|
||||
this.pageSource = null;
|
||||
this.parallelism = 1;
|
||||
this.startupTime = 0;
|
||||
this.primary.pageTime = 0;
|
||||
for (const slot of this.slots) this.closeSlot(slot);
|
||||
}
|
||||
|
||||
closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) {
|
||||
if (!this.slots.delete(slot)) return;
|
||||
slot.worker.terminate();
|
||||
for (const [id, request] of this.fontRequests) if (request.worker === slot.worker) this.fontRequests.delete(id);
|
||||
slot.rejectReady(error);
|
||||
for (const pending of slot.pending.values()) pending.reject(error);
|
||||
slot.pending.clear();
|
||||
}
|
||||
|
||||
createSlot(source) {
|
||||
const started = performance.now();
|
||||
const worker = createEngineWorker({page:true, url:this.workerURL});
|
||||
worker.setForeground(this.foreground);
|
||||
worker.pausePages(this.pagesPaused);
|
||||
const slot = {worker, pending: new Map(), busy: false, memory: Math.max(128 << 20, this.memory), pages:new Set(), lastPage:0};
|
||||
worker.reservation = slot.memory;
|
||||
worker.reclaim = () => { if (!slot.busy) this.closeSlot(slot); };
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
slot.rejectReady = reject;
|
||||
worker.onmessage = ({data}) => {
|
||||
if (data.memory) slot.memory = data.memory;
|
||||
if (this.source) this.parallelism = engineScheduler.limit;
|
||||
if (data.type === "ready") { resolve(); return; }
|
||||
if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); return; }
|
||||
if (data.type === "font") {
|
||||
const request = `${slot.worker.engineID}:${data.request}`;
|
||||
this.fontRequests.set(request, {worker:slot.worker, request:data.request});
|
||||
this.onmessage?.({data:{...data, id:slot.requestID, request}});
|
||||
return;
|
||||
}
|
||||
if (data.type) return;
|
||||
const request = slot.pending.get(data.id);
|
||||
if (!request) return;
|
||||
slot.pending.delete(data.id);
|
||||
if (data.ok) request.resolve(data);
|
||||
else request.reject(Object.assign(new Error(data.error), {code:data.code, name:data.canceled ? "AbortError" : "Error"}));
|
||||
};
|
||||
worker.onerror = worker.onmessageerror = () => this.closeSlot(slot, new Error("页面加载失败"));
|
||||
});
|
||||
this.slots.add(slot);
|
||||
slot.ready = ready.then(async () => {
|
||||
let opened = source.pdf
|
||||
? await this.callSlot(slot, "ofdgoOpenPDF", [source.file, source.fonts, null, source.annotations, source.fontNames])
|
||||
: await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]);
|
||||
if ((source.document || 0) !== (opened.data.documentIndex || 0)) opened = await this.callSlot(slot, "ofdgoSelectDocument", [source.document]);
|
||||
if ((source.version || "") !== (opened.data.documentVersion || "")) await this.callSlot(slot, "ofdgoSelectVersion", [source.version || ""]);
|
||||
if (this.slots.has(slot)) this.startupTime = performance.now() - started;
|
||||
});
|
||||
slot.ready.catch(() => {});
|
||||
return slot;
|
||||
}
|
||||
|
||||
callSlot(slot, name, args, preload = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.slots.has(slot)) { reject(new DOMException("页面加载已取消", "AbortError")); return; }
|
||||
const id = --this.sequence;
|
||||
slot.pending.set(id, {resolve, reject});
|
||||
try { slot.worker.postMessage({id, name, args, preload}); }
|
||||
catch (error) { slot.pending.delete(id); reject(error); }
|
||||
});
|
||||
}
|
||||
|
||||
pageSlot(data) {
|
||||
if (this.primaryPages.has(data.args[0])) return null;
|
||||
const preferred = [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0]));
|
||||
const pending = [...this.requests.values()].filter(slot => slot === null).length - Number(this.requests.has(data.id));
|
||||
if (!preferred && pending <= 0) return null;
|
||||
let slot = preferred || [...this.slots].filter(slot => !slot.busy).sort((a,b) => Math.abs(a.lastPage-data.args[0])-Math.abs(b.lastPage-data.args[0]))[0];
|
||||
const worthwhile = (this.primary.pageTime || 0) * pending >= Math.max(50, this.startupTime || this.primary.openTime || 0);
|
||||
if (!slot && worthwhile && this.foreground && !this.pagesPaused && this.slots.size < this.parallelism - 1
|
||||
&& engineScheduler.memory + Math.max(128 << 20, this.memory) <= this.budget) slot = this.createSlot(this.source);
|
||||
return slot;
|
||||
}
|
||||
|
||||
dispatchPages() {
|
||||
if (this.closed || !this.source || !this.foreground || this.pagesPaused) return;
|
||||
const draining = engineScheduler.draining;
|
||||
engineScheduler.draining = true;
|
||||
try {
|
||||
const queued = engineScheduler.queue.filter(task => task.worker === this.primary.runtime && task.data.engineID === this.engineID).sort((a,b) => a.order-b.order);
|
||||
for (const task of queued) {
|
||||
if (task.data.name !== "ofdgoRenderPage") break;
|
||||
if (this.requests.get(task.data.id) !== null) continue;
|
||||
let slot;
|
||||
try { slot = this.pageSlot(task.data); } catch { break; }
|
||||
if (!slot) break;
|
||||
engineScheduler.queue.splice(engineScheduler.queue.indexOf(task), 1);
|
||||
this.primary.requests.delete(task.data.id);
|
||||
this.renderSlot(slot, task.data, task.transfer);
|
||||
}
|
||||
} finally { engineScheduler.draining = draining; }
|
||||
}
|
||||
|
||||
postMessage(data, transfer) {
|
||||
if (this.closed) return;
|
||||
if (data.type === "font" && typeof data.request === "string") {
|
||||
const request = this.fontRequests.get(data.request);
|
||||
if (request) {
|
||||
this.fontRequests.delete(data.request);
|
||||
request.worker.postMessage({...data, request:request.request}, transfer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!data.type && data.preload && this.pagesPaused) {
|
||||
this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
|
||||
return;
|
||||
}
|
||||
if (data.type === "cancel") {
|
||||
const slot = this.requests.get(data.id);
|
||||
if (slot && slot !== this.primary) this.closeSlot(slot);
|
||||
else this.primary.postMessage(data);
|
||||
return;
|
||||
}
|
||||
if (!data.type && data.name !== "ofdgoRenderPage" && ![
|
||||
"ofdgoLoadPDF", "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData",
|
||||
"ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets",
|
||||
"ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles",
|
||||
"ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures",
|
||||
"ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice",
|
||||
"ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal",
|
||||
"ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument",
|
||||
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
|
||||
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
|
||||
].includes(data.name)) this.invalidatePages();
|
||||
const slot = data.name === "ofdgoRenderPage" && this.source ? this.pageSlot(data) : null;
|
||||
if (!slot) {
|
||||
if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null);
|
||||
this.primary.postMessage(data, transfer);
|
||||
return;
|
||||
}
|
||||
this.renderSlot(slot, data, transfer);
|
||||
}
|
||||
|
||||
renderSlot(slot, data, transfer) {
|
||||
slot.busy = true;
|
||||
slot.requestID = data.id;
|
||||
this.requests.set(data.id, slot);
|
||||
const revision = this.revision;
|
||||
let fallback = false;
|
||||
slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => {
|
||||
if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError");
|
||||
if (this.source.pdf && ((JSON.parse(result.data.annotations || "null") || []).length || result.data.links?.length)) {
|
||||
this.primaryPages.add(data.args[0]);
|
||||
this.requests.set(data.id, this.primary);
|
||||
fallback = true;
|
||||
this.primary.postMessage(data, transfer);
|
||||
return;
|
||||
}
|
||||
slot.lastPage = data.args[0];
|
||||
slot.pages.delete(slot.lastPage);
|
||||
slot.pages.add(slot.lastPage);
|
||||
if (slot.pages.size > 16) slot.pages.delete(slot.pages.values().next().value);
|
||||
if (!this.closed) this.onmessage?.({data: {...result, engineID:this.engineID, id: data.id}});
|
||||
}).catch(error => {
|
||||
if (!this.closed && revision === this.revision && error.name !== "AbortError") {
|
||||
this.closeSlot(slot, error);
|
||||
this.parallelism = Math.max(1, this.slots.size + 1);
|
||||
this.requests.set(data.id, this.primary);
|
||||
fallback = true;
|
||||
this.primary.postMessage(data, transfer);
|
||||
return;
|
||||
}
|
||||
if (!this.closed) this.onmessage?.({data: {engineID:this.engineID, id: data.id, ok: false, error: error.message, canceled: error.name === "AbortError"}});
|
||||
}).finally(() => {
|
||||
if (!fallback) this.requests.delete(data.id);
|
||||
slot.busy = false;
|
||||
slot.worker.reservation = 0;
|
||||
const memory = engineScheduler.memory;
|
||||
if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); }
|
||||
this.dispatchPages();
|
||||
engineScheduler.drain();
|
||||
});
|
||||
}
|
||||
|
||||
terminate() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.invalidatePages();
|
||||
this.primary.terminate();
|
||||
this.requests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFilePicker(options) {
|
||||
return nativeHost ? nativeHost.savePicker(options) : window.showSaveFilePicker ? window.showSaveFilePicker(options) : null;
|
||||
}
|
||||
|
||||
export function directoryPicker() {
|
||||
return nativeHost ? nativeHost.directoryPicker() : window.showDirectoryPicker({mode:"readwrite"});
|
||||
}
|
||||
|
||||
class NativeFile {
|
||||
constructor(value) {
|
||||
Object.assign(this, value);
|
||||
if (value.handle) this.handle = new NativeHandle(nativeHost, value.handle);
|
||||
this.byteLength = this.size;
|
||||
this.type ||= "application/octet-stream";
|
||||
if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile);
|
||||
}
|
||||
|
||||
async arrayBuffer(signal) {
|
||||
const response = await fetch(this.url, {signal});
|
||||
if (!response.ok) throw new Error("读取文件失败");
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {nativeFile:this.nativeFile, size:this.size, name:this.name};
|
||||
}
|
||||
|
||||
async text() {
|
||||
return new TextDecoder().decode(await this.arrayBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
class NativeHandle {
|
||||
constructor(host, value) {
|
||||
this.host = host;
|
||||
Object.assign(this, value);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {nativeHandle:this.nativeHandle, name:this.name, kind:this.kind};
|
||||
}
|
||||
|
||||
async getFile() {
|
||||
return new NativeFile(await this.host.call("file", {handle:this.nativeHandle}));
|
||||
}
|
||||
|
||||
async getFileHandle(name, options = {}) {
|
||||
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:false}));
|
||||
}
|
||||
|
||||
async getDirectoryHandle(name, options = {}) {
|
||||
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:true}));
|
||||
}
|
||||
|
||||
removeEntry(name, options = {}) {
|
||||
return this.host.call("remove", {handle:this.nativeHandle, name, recursive:!!options.recursive});
|
||||
}
|
||||
|
||||
async createWritable() {
|
||||
const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle});
|
||||
return {
|
||||
write: data => this.host.call("write.data", {writer, data}),
|
||||
close: () => this.host.call("write.commit", {writer}),
|
||||
abort: () => this.host.call("write.abort", {writer}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class NativeHost {
|
||||
constructor(bridge) {
|
||||
this.bridge = bridge;
|
||||
this.parallelism = 1;
|
||||
this.cacheBytes = 32 * 1024 * 1024;
|
||||
this.reading = 0;
|
||||
this.readQueue = [];
|
||||
this.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => {
|
||||
this.call("release", {file}).catch(() => {});
|
||||
}) : null;
|
||||
}
|
||||
|
||||
call(name, value, signal) {
|
||||
return this.bridge.call(`ofdgo.host.${name}`, value, {signal});
|
||||
}
|
||||
|
||||
async readResource(file, signal) {
|
||||
if (this.reading >= this.parallelism) await new Promise(resolve => this.readQueue.push(resolve));
|
||||
else this.reading++;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
return await file.arrayBuffer(signal);
|
||||
} finally {
|
||||
try { await this.call("release", {file:file.nativeFile}); }
|
||||
finally {
|
||||
const next = this.readQueue.shift();
|
||||
if (next) next();
|
||||
else this.reading--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async decode(value, signal) {
|
||||
const reads = [];
|
||||
const visit = (item, field, replace) => {
|
||||
if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return;
|
||||
if (item.nativeFile) {
|
||||
const file = new NativeFile(item);
|
||||
if (field === "file") replace(file);
|
||||
else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes))));
|
||||
return;
|
||||
}
|
||||
for (const [child, content] of Object.entries(item)) {
|
||||
if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result);
|
||||
}
|
||||
};
|
||||
visit(value, "", result => value = result);
|
||||
const results = await Promise.allSettled(reads);
|
||||
const failed = results.find(result => result.status === "rejected");
|
||||
if (failed) throw failed.reason;
|
||||
return value;
|
||||
}
|
||||
|
||||
async release(value) {
|
||||
if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return;
|
||||
if (value?.nativeFile && value.temporary) {
|
||||
await this.call("release", {file:value.nativeFile}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const item of Object.values(value)) await this.release(item);
|
||||
}
|
||||
}
|
||||
|
||||
worker() {
|
||||
const host = this;
|
||||
let closed = false;
|
||||
let unsubscribe;
|
||||
let pending = Promise.resolve();
|
||||
let termination = null;
|
||||
const readers = new Set();
|
||||
const inputs = new Set();
|
||||
const requests = new Map();
|
||||
const completions = new Map();
|
||||
const emit = data => {
|
||||
if (data.type === "result") { completions.get(data.id)?.(data.result); return; }
|
||||
if (!closed) worker.onmessage?.({data:{...data, engineID:worker.engineID}});
|
||||
};
|
||||
const ready = host.call("engine.open", {}).then(({engine, parallelism, cacheBytes}) => {
|
||||
worker.engineID = engine;
|
||||
if (closed) return engine;
|
||||
host.parallelism = Math.max(1, parallelism || 1);
|
||||
host.cacheBytes = cacheBytes || host.cacheBytes;
|
||||
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
|
||||
emit({type:"ready"});
|
||||
return engine;
|
||||
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
|
||||
const worker = {
|
||||
engineID:null,
|
||||
foreground:true,
|
||||
pagesPaused:false,
|
||||
onmessage:null,
|
||||
onerror:null,
|
||||
configurePages() {},
|
||||
setForeground(foreground) {
|
||||
worker.foreground = !!foreground;
|
||||
worker.pausePages(!worker.foreground);
|
||||
},
|
||||
pausePages(paused = true) {
|
||||
worker.pagesPaused = !!paused;
|
||||
},
|
||||
postMessage(data) {
|
||||
if (closed) return;
|
||||
if (data.type === "cancel") {
|
||||
requests.get(data.id)?.abort();
|
||||
return;
|
||||
}
|
||||
if (data.type === "font") return;
|
||||
if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoOpenPDF"].includes(data.name)) {
|
||||
const input = data.args?.[0];
|
||||
if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
requests.set(data.id, controller);
|
||||
const parallel = data.name === "ofdgoRenderPage";
|
||||
const barrier = parallel ? pending : Promise.all([pending, ...readers]);
|
||||
const operation = barrier.then(async () => {
|
||||
const engine = await ready;
|
||||
if (closed) return;
|
||||
if (data.preload && worker.pagesPaused) throw new DOMException("预加载已暂停", "AbortError");
|
||||
controller.signal.throwIfAborted();
|
||||
const args = {engine, id:data.id, name:data.name, args:data.args};
|
||||
let result;
|
||||
if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles") {
|
||||
const completed = new Promise(resolve => completions.set(data.id, resolve));
|
||||
const cancel = () => host.call("engine.cancel", {engine, id:data.id}).catch(() => {});
|
||||
controller.signal.addEventListener("abort", cancel, {once:true});
|
||||
try {
|
||||
await host.call("engine.start", args);
|
||||
if (controller.signal.aborted) cancel();
|
||||
result = await completed;
|
||||
await host.call("engine.finish", {engine, id:data.id});
|
||||
} finally {
|
||||
controller.signal.removeEventListener("abort", cancel);
|
||||
completions.delete(data.id);
|
||||
}
|
||||
} else result = await host.call("engine.call", args, controller.signal);
|
||||
if (closed) { await host.release(result); return; }
|
||||
let decoded;
|
||||
try {
|
||||
controller.signal.throwIfAborted();
|
||||
decoded = await host.decode(result, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
}
|
||||
catch (error) { await host.release(result); throw error; }
|
||||
if (closed) { await host.release(result); return; }
|
||||
emit({id:data.id, ...decoded});
|
||||
}).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => requests.delete(data.id));
|
||||
if (parallel) {
|
||||
readers.add(operation);
|
||||
operation.finally(() => readers.delete(operation));
|
||||
} else if (data.name !== "ofdgoSaveSnapshot") pending = operation;
|
||||
},
|
||||
terminate() {
|
||||
if (closed) return termination;
|
||||
closed = true;
|
||||
for (const controller of requests.values()) controller.abort();
|
||||
termination = ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(async () => {
|
||||
unsubscribe?.();
|
||||
for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"});
|
||||
completions.clear();
|
||||
await Promise.all([...inputs].map(file => host.call("release", {file}).catch(() => {})));
|
||||
inputs.clear();
|
||||
});
|
||||
return termination;
|
||||
},
|
||||
};
|
||||
ready.catch(() => {});
|
||||
return worker;
|
||||
}
|
||||
|
||||
async savePicker(options) {
|
||||
return new NativeHandle(this, await this.call("save", options));
|
||||
}
|
||||
|
||||
async directoryPicker() {
|
||||
return new NativeHandle(this, await this.call("directory", {}));
|
||||
}
|
||||
|
||||
async fonts() {
|
||||
return (await this.call("fonts", {})).map(({file, ...font}) => ({...font, nativeFile:new NativeFile(file)}));
|
||||
}
|
||||
|
||||
async start(onFiles, onError) {
|
||||
let launching = Promise.resolve();
|
||||
this.bridge.on("ofdgo.launch", values => {
|
||||
launching = launching.then(async () => {
|
||||
try { await onFiles(values.map(value => new NativeFile(value)), true); }
|
||||
finally { await this.call("launch.done", {}); }
|
||||
}).catch(onError);
|
||||
});
|
||||
this.bridge.on("ofdgo.files", async values => {
|
||||
try { await onFiles(values.map(value => new NativeFile(value))); }
|
||||
catch (error) { onError(error); }
|
||||
});
|
||||
this.bridge.on("ofdgo.error", message => onError(new Error(message)));
|
||||
const title = document.querySelector("title");
|
||||
if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true});
|
||||
await this.call("ready", {});
|
||||
}
|
||||
|
||||
async discardFiles(files) {
|
||||
await Promise.all(files.filter(file => file.drop && file.nativeFile).map(file => this.call("drop.release", {file:file.nativeFile})));
|
||||
}
|
||||
|
||||
installFileInputs(onError) {
|
||||
document.addEventListener("click", async event => {
|
||||
const input = event.target;
|
||||
if (!(input instanceof HTMLInputElement) || input.type !== "file" || input.disabled) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
const files = (await this.call("open", {multiple:input.multiple, directory:input.webkitdirectory, accept:input.accept})).map(value => new NativeFile(value));
|
||||
if (!files.length) return;
|
||||
const transfer = new DataTransfer();
|
||||
for (const file of files) transfer.items.add(new File([], file.name, {type:file.type}));
|
||||
delete input.files;
|
||||
input.files = transfer.files;
|
||||
Object.defineProperty(input, "files", {configurable:true, get:() => input.value ? files : []});
|
||||
input.dispatchEvent(new Event("input", {bubbles:true}));
|
||||
input.dispatchEvent(new Event("change", {bubbles:true}));
|
||||
} catch (error) { if (error.name !== "AbortError") onError(error); }
|
||||
}, true);
|
||||
}
|
||||
|
||||
async download(data, mime, name) {
|
||||
const handle = await this.savePicker({suggestedName:name});
|
||||
const writer = await handle.createWritable();
|
||||
try { await writer.write(data); await writer.close(); }
|
||||
catch (error) { await writer.abort().catch(() => {}); throw error; }
|
||||
}
|
||||
}
|
||||
|
||||
export const nativeHost = globalThis.guigo ? new NativeHost(globalThis.guigo) : null;
|
||||
@@ -0,0 +1,493 @@
|
||||
let printQueue = Promise.resolve();
|
||||
|
||||
export class PrintDialog {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
const panel = this.panel || document.querySelector("#printPanel");
|
||||
this.el = Object.fromEntries(["Panel", "Form", "Settings", "Printer", "Properties", "Copies", "Collation", "Duplex", "BackReverse", "BackRotate", "Contents", "Scope", "Range", "Parity", "Reverse", "Mode", "Paper", "Width", "Height", "Orientation", "Scaling", "Scale", "MarginTop", "MarginRight", "MarginBottom", "MarginLeft", "Grid", "PageOrder", "Gap", "Binding", "BookletSheets", "SheetFrom", "SheetTo", "Overlap", "Sides", "Rotate", "Grayscale", "Borders", "CropMarks", "Raster", "DPI", "DPIValue", "TileOnlyLarge", "Labels", "Preview", "Previous", "Next", "PreviewNumber", "PageControl", "PageCount", "Status", "Spinner", "ProgressTrack", "ProgressBar", "Cancel", "Submit"].map(name => [name, name === "Panel" ? panel : panel.querySelector(`#print${name}`)]));
|
||||
this.printerOptions = this.el.Printer.innerHTML;
|
||||
this.paperOptions = this.el.Paper.innerHTML;
|
||||
this.settings = new WeakMap();
|
||||
this.queue = Promise.resolve();
|
||||
this.sequence = 0;
|
||||
this.urls = [];
|
||||
this.el.Settings.addEventListener("input", event => { if (event.target === this.el.Printer) this.choosePrinter(); else this.schedule(); });
|
||||
this.el.Properties.addEventListener("click", () => this.properties());
|
||||
this.el.Form.addEventListener("submit", event => { event.preventDefault(); this.print(); });
|
||||
this.el.Cancel.addEventListener("click", () => this.el.Panel.close());
|
||||
this.el.Panel.addEventListener("close", () => this.close());
|
||||
this.el.Panel.addEventListener("cancel", event => { if (this.waiting) event.preventDefault(); });
|
||||
this.el.Previous.addEventListener("click", () => this.preview(this.side - 1));
|
||||
this.el.Next.addEventListener("click", () => this.preview(this.side + 1));
|
||||
this.el.PreviewNumber.addEventListener("change", () => this.preview(Number(this.el.PreviewNumber.value) - 1));
|
||||
this.el.PreviewNumber.addEventListener("keydown", event => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
if (!event.repeat) this.el.PreviewNumber.dispatchEvent(new Event("change"));
|
||||
});
|
||||
this.observer = new ResizeObserver(([entry]) => { this.previewSize = entry.contentRect; this.resize(); });
|
||||
this.observer.observe(this.el.Preview);
|
||||
this.afterPrint = () => { if (this.waiting === "browser") this.finishPrint("打印窗口已关闭"); };
|
||||
this.beforePrint = () => {
|
||||
if (!this.context || !this.el.Panel.open) return;
|
||||
const root = document.querySelector("#ofdgoPrintDocument");
|
||||
if (!root?.children.length) {
|
||||
this.root = root || this.createRoot();
|
||||
this.root.textContent = "请先在OFDGo中打开打印预览,准备完整文档后打印。";
|
||||
}
|
||||
};
|
||||
window.addEventListener("afterprint", this.afterPrint);
|
||||
window.addEventListener("beforeprint", this.beforePrint);
|
||||
}
|
||||
|
||||
open(context) {
|
||||
if (this.context) return;
|
||||
const saved = this.settings.get(context.tab)?.get(context.document);
|
||||
this.context = context;
|
||||
this.restoring = true;
|
||||
this.prepared = false;
|
||||
this.layout = null;
|
||||
this.job = this.profile = null;
|
||||
this.side = 0;
|
||||
this.sequence++;
|
||||
this.el.Printer.innerHTML = this.printerOptions;
|
||||
this.el.Paper.innerHTML = this.paperOptions;
|
||||
this.el.Form.reset();
|
||||
this.orientation = saved?.orientation ?? this.el.Orientation.value;
|
||||
this.duplexBeforeBooklet = saved?.duplex ?? this.el.Duplex.value;
|
||||
this.scalingBeforeBooklet = saved?.scaling ?? this.el.Scaling.value;
|
||||
this.restore(saved);
|
||||
this.mode = this.el.Mode.value;
|
||||
if (!saved) this.el.Scope.value = context.selection.length ? "selected" : "all";
|
||||
this.el.Scope.querySelector('option[value="selected"]').disabled = !context.selection.length;
|
||||
this.el.Sides.value = this.manual() ? "front" : "all";
|
||||
if (!saved) this.el.Contents.value = context.annotations ? "documentAndAnnotations" : "document";
|
||||
this.status();
|
||||
this.el.Preview.replaceChildren();
|
||||
this.show(this.el.Panel);
|
||||
this.loadDevices(saved);
|
||||
}
|
||||
|
||||
isDevice() { return this.el.Printer.value.startsWith("device:"); }
|
||||
manual() { return this.el.Duplex.value.startsWith("manual"); }
|
||||
|
||||
async loadDevices(saved) {
|
||||
const sequence = ++this.sequence;
|
||||
const selected = saved?.values.Printer;
|
||||
this.loading = true;
|
||||
this.controls();
|
||||
const browser = this.el.Printer.querySelector('option[value="browser"]');
|
||||
browser.disabled = !!this.nativeHost || typeof window.print !== "function";
|
||||
if (browser.disabled) this.el.Printer.value = "pdf";
|
||||
try {
|
||||
if (this.nativeHost) {
|
||||
this.status("正在读取设备", true);
|
||||
const capabilities = await this.nativeHost.call("print.capabilities", {});
|
||||
if (!this.current(sequence)) return;
|
||||
this.capabilities = capabilities;
|
||||
browser.disabled = !capabilities.systemDialog;
|
||||
const printers = capabilities.direct ? await this.nativeHost.call("print.printers", {}) : [];
|
||||
if (!this.current(sequence)) return;
|
||||
for (const printer of printers) this.el.Printer.add(new Option(printer.name, `device:${printer.name}`));
|
||||
const printer = printers.find(printer => printer.default) || printers[0];
|
||||
this.el.Printer.value = printer ? `device:${printer.name}` : "pdf";
|
||||
}
|
||||
} catch (error) { if (this.current(sequence)) this.status(error.message); }
|
||||
finally {
|
||||
if (this.current(sequence)) {
|
||||
this.loading = false;
|
||||
if ([...this.el.Printer.options].some(option => option.value === selected && !option.disabled)) this.el.Printer.value = selected;
|
||||
await this.choosePrinter(this.el.Printer.value === selected ? saved : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async choosePrinter(saved) {
|
||||
const sequence = ++this.sequence;
|
||||
const paper = this.el.Paper.value;
|
||||
this.layout = this.job = this.profile = null;
|
||||
this.loading = true;
|
||||
this.status();
|
||||
this.controls();
|
||||
if (!this.isDevice()) {
|
||||
this.el.Paper.innerHTML = this.paperOptions;
|
||||
this.el.Paper.value = paper;
|
||||
if (!this.el.Paper.value) {
|
||||
const [width, height] = paper.split(",").map(Number);
|
||||
if (width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height)) {
|
||||
this.el.Paper.value = "custom";
|
||||
this.el.Width.value = width;
|
||||
this.el.Height.value = height;
|
||||
} else this.el.Paper.value = "210,297";
|
||||
}
|
||||
}
|
||||
this.el.Properties.hidden = true;
|
||||
try {
|
||||
if (this.isDevice()) {
|
||||
this.status("正在读取纸张", true);
|
||||
const profile = await this.nativeHost.call("print.query", {printer:this.el.Printer.value.slice(7), copies:1, duplex:"simplex"});
|
||||
if (!this.current(sequence)) return;
|
||||
this.profile = profile;
|
||||
this.el.Paper.replaceChildren(...profile.papers.map(paper => new Option(paper.name, `${paper.width},${paper.height}`)));
|
||||
if (profile.customPaper) this.el.Paper.add(new Option("自定义", "custom"));
|
||||
this.el.Paper.value = `${profile.options.width},${profile.options.height}`;
|
||||
if (!this.el.Paper.value && profile.customPaper) { this.el.Paper.value = "custom"; this.el.Width.value = profile.options.width; this.el.Height.value = profile.options.height; }
|
||||
if (!this.el.Paper.value) throw new Error("设备默认纸张不在支持列表中");
|
||||
this.el.Orientation.value = profile.options.orientation;
|
||||
this.el.Copies.max = Math.min(999, profile.maxCopies || 999);
|
||||
this.el.Copies.value = 1;
|
||||
this.el.Grayscale.checked = profile.options.color === "grayscale";
|
||||
this.el.Grayscale.disabled = !profile.color;
|
||||
this.el.Properties.hidden = !profile.properties;
|
||||
} else this.el.Grayscale.disabled = false;
|
||||
} catch (error) { if (this.current(sequence)) { this.status(error.message); return; } }
|
||||
finally { if (this.current(sequence)) { this.loading = false; this.controls(); } }
|
||||
if (this.current(sequence)) {
|
||||
this.restore(saved);
|
||||
if (this.el.Scope.value === "selected" && !this.context.selection.length) this.el.Scope.value = "all";
|
||||
this.restoring = false;
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
async properties() {
|
||||
if (!this.isDevice() || this.running || this.loading) return;
|
||||
const sequence = this.sequence;
|
||||
this.loading = true;
|
||||
this.status("正在打开属性", true);
|
||||
this.controls();
|
||||
try { await this.nativeHost.call("print.properties", {printer:this.el.Printer.value.slice(7)}); }
|
||||
catch (error) { if (this.current(sequence)) this.status(error.message); }
|
||||
finally { if (this.current(sequence)) { this.loading = false; await this.choosePrinter(); } }
|
||||
}
|
||||
|
||||
deviceOptions() {
|
||||
const [width, height] = this.el.Paper.value === "custom" ? [Number(this.el.Width.value), Number(this.el.Height.value)] : this.el.Paper.value.split(",").map(Number);
|
||||
const manual = this.manual(), partial = this.el.Sides.value !== "all";
|
||||
return {printer:this.el.Printer.value.slice(7), width, height, orientation:this.el.Orientation.value,
|
||||
copies:Number(this.el.Copies.value), collation:this.el.Collation.checked ? "collated" : "uncollated",
|
||||
duplex:manual || partial ? "simplex" : this.el.Duplex.value, color:this.el.Grayscale.checked ? "grayscale" : "color",
|
||||
scale:1, pagesPerSide:1, margins:{top:0,right:0,bottom:0,left:0},
|
||||
...(this.capabilities?.backgrounds ? {backgrounds:true} : {}), ...(this.capabilities?.headersFooters ? {headersFooters:false} : {})};
|
||||
}
|
||||
|
||||
options(profile) {
|
||||
const el = this.el;
|
||||
const number = name => {
|
||||
const value = Number(el[name].value);
|
||||
if (!el[name].value.trim() || !Number.isFinite(value)) throw new Error("请输入有效数值");
|
||||
return value;
|
||||
};
|
||||
let [paperWidth, paperHeight] = el.Paper.value === "source" ? [0, 0] : el.Paper.value === "custom" ? [number("Width"), number("Height")] : el.Paper.value.split(",").map(Number);
|
||||
if (el.Orientation.value === "landscape") [paperWidth, paperHeight] = [paperHeight, paperWidth];
|
||||
if (profile) [paperWidth, paperHeight] = [profile.width, profile.height];
|
||||
const scope = el.Scope.value;
|
||||
const [rows, columns] = el.Grid.value.split(",").map(Number);
|
||||
if (scope === "selected" && !this.context.selection.length) throw new Error("未选择页面");
|
||||
if (scope === "range" && !el.Range.value.trim()) throw new Error("请输入页码范围");
|
||||
const manual = this.manual(), duplex = el.Duplex.value.replace("manualL", "l").replace("manualS", "s");
|
||||
if (manual && this.isDevice() && number("Copies") !== 1) throw new Error("手动双面每次仅支持1份");
|
||||
return {
|
||||
...(scope === "current" ? {pages:[this.context.page]} : scope === "selected" ? {pages:this.context.selection} : scope === "range" ? {pageRange:el.Range.value.trim()} : {}),
|
||||
mode:el.Mode.value, scaling:el.Scaling.value, paperWidth, paperHeight,
|
||||
margins:{top:number("MarginTop"), right:number("MarginRight"), bottom:number("MarginBottom"), left:number("MarginLeft")},
|
||||
...(profile ? {printableArea:{x:profile.printableArea.x, y:profile.printableArea.y, w:profile.printableArea.width, h:profile.printableArea.height}} : {}),
|
||||
copies:this.isDevice() ? number("Copies") : 1, collate:el.Collation.checked, duplex,
|
||||
scale:el.Scale.disabled ? 1 : number("Scale") / 100, rotate:el.Rotate.checked, reverse:el.Reverse.checked !== (manual && el.Sides.value === "back" && el.BackReverse.checked), parity:el.Parity.value, sides:el.Sides.value,
|
||||
backRotate:manual && el.BackRotate.checked,
|
||||
rows, columns, pageOrder:el.PageOrder.value, gap:el.Gap.disabled ? 0 : number("Gap"), binding:el.Binding.value, bookletSheets:!el.BookletSheets.disabled && el.BookletSheets.value ? number("BookletSheets") : 0, overlap:el.Overlap.disabled ? 0 : number("Overlap"),
|
||||
...(el.Mode.value === "booklet" ? {sheetFrom:el.SheetFrom.value ? number("SheetFrom") : 0, sheetTo:el.SheetTo.value ? number("SheetTo") : 0} : {}),
|
||||
contents:el.Contents.value, grayscale:el.Grayscale.checked, borders:el.Borders.checked, cropMarks:el.CropMarks.checked, raster:el.Raster.checked, dpi:number("DPI"), tileOnlyLarge:el.TileOnlyLarge.checked, labels:el.Labels.checked
|
||||
};
|
||||
}
|
||||
|
||||
schedule() {
|
||||
const el = this.el, mode = el.Mode.value;
|
||||
const source = el.Paper.querySelector('option[value="source"]');
|
||||
if (source) source.disabled = mode !== "single";
|
||||
if (mode !== "single" && el.Paper.value === "source") el.Paper.value = "210,297";
|
||||
if (mode === "booklet" && this.mode !== "booklet") {
|
||||
this.orientation = el.Orientation.value;
|
||||
this.duplexBeforeBooklet = el.Duplex.value;
|
||||
this.scalingBeforeBooklet = el.Scaling.value;
|
||||
el.Scaling.value = "shrink";
|
||||
el.Duplex.value = this.isDevice() && !this.profile?.duplex ? "manualShortEdge" : "shortEdge";
|
||||
} else if (mode !== "booklet" && this.mode === "booklet") {
|
||||
el.Orientation.value = this.orientation;
|
||||
el.Duplex.value = this.duplexBeforeBooklet;
|
||||
el.Scaling.value = this.scalingBeforeBooklet;
|
||||
}
|
||||
if (mode === "booklet") el.Orientation.value = "landscape";
|
||||
this.mode = mode;
|
||||
if (this.isDevice() && !this.profile?.duplex && ["longEdge", "shortEdge"].includes(el.Duplex.value)) el.Duplex.value = el.Duplex.value === "longEdge" ? "manualLongEdge" : "manualShortEdge";
|
||||
if (mode === "booklet" && el.Duplex.value === "simplex") el.Duplex.value = this.isDevice() && !this.profile?.duplex ? "manualShortEdge" : "shortEdge";
|
||||
if (el.Duplex.value === "simplex") el.Sides.value = "all";
|
||||
const isManual = this.manual();
|
||||
el.Sides.querySelector('option[value="all"]').disabled = isManual;
|
||||
if (isManual && el.Sides.value === "all") el.Sides.value = "front";
|
||||
for (const option of el.Duplex.options) option.disabled = this.isDevice() && !this.profile?.duplex && ["longEdge", "shortEdge"].includes(option.value) || mode === "booklet" && ["simplex", "longEdge", "manualLongEdge"].includes(option.value);
|
||||
el.Orientation.disabled = mode === "booklet" || el.Paper.value === "source";
|
||||
const rows = {device:this.isDevice(), sides:el.Duplex.value !== "simplex", manual:isManual, range:el.Scope.value === "range", paper:el.Paper.value === "custom", scale:el.Scaling.value === "custom" || mode === "poster", multiple:mode === "multiple", gap:mode === "multiple" || mode === "booklet", booklet:mode === "booklet", poster:mode === "poster"};
|
||||
for (const row of el.Panel.querySelectorAll("[data-print-row]")) {
|
||||
row.hidden = !rows[row.dataset.printRow];
|
||||
for (const input of row.querySelectorAll("input, select")) input.disabled = row.hidden;
|
||||
}
|
||||
el.Scaling.disabled = mode === "poster" || mode === "booklet";
|
||||
el.DPI.disabled = !el.Raster.checked && !el.Grayscale.checked;
|
||||
const sequence = ++this.sequence;
|
||||
this.layout = null;
|
||||
this.job = null;
|
||||
this.status();
|
||||
this.controls();
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.queue = this.queue.catch(() => {}).then(() => this.refresh(sequence));
|
||||
}, 160);
|
||||
}
|
||||
|
||||
async refresh(sequence) {
|
||||
if (!this.current(sequence)) return;
|
||||
const context = this.context;
|
||||
try {
|
||||
if (!this.el.Form.checkValidity()) {
|
||||
if (this.el.Panel.open) this.el.Form.reportValidity();
|
||||
else this.status("请检查打印设置");
|
||||
return;
|
||||
}
|
||||
this.status("正在准备预览", true);
|
||||
const deviceOptions = this.isDevice() ? this.deviceOptions() : null;
|
||||
const profile = deviceOptions ? await this.nativeHost.call("print.query", deviceOptions) : null;
|
||||
if (!this.current(sequence)) return;
|
||||
this.profile = profile;
|
||||
const options = this.options(profile);
|
||||
const layout = await context.call(this.prepared ? "ofdgoLayoutPrint" : "ofdgoPreparePrint", options);
|
||||
if (!this.current(sequence)) return;
|
||||
this.prepared = true;
|
||||
this.layout = layout;
|
||||
if (this.isDevice()) this.el.Copies.max = Math.min(999, this.profile.maxCopies || 999, layout.copiesLimit > 0 ? layout.copiesLimit : 999);
|
||||
await this.preview(Math.min(this.side, layout.sides.length - 1));
|
||||
if (!this.current(sequence) || this.layout !== layout) return;
|
||||
this.job = {deviceOptions, fingerprint:profile?.fingerprint, manual:this.manual(), sides:options.sides, backContent:layout.backContent};
|
||||
this.status();
|
||||
if (layout.sides.some(side => side.placements.some(placement => placement.clipped))) this.status("内容超出可打印区域");
|
||||
if (this.manual() && options.sides === "back" && !layout.backContent) this.status("背面为空白");
|
||||
} catch (error) {
|
||||
if (this.current(sequence)) this.status(error.code === "printDenied" ? "文档禁止打印" : error.message);
|
||||
} finally {
|
||||
if (this.current(sequence)) this.controls();
|
||||
}
|
||||
}
|
||||
|
||||
current(sequence = this.sequence) { return sequence === this.sequence && this.context && !this.context.tab.closed; }
|
||||
|
||||
async preview(index) {
|
||||
if (!this.layout || this.running) return;
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.layout.sides.length) { this.controls(); return; }
|
||||
const sequence = this.sequence, preview = this.previewSequence = (this.previewSequence || 0) + 1;
|
||||
this.side = index;
|
||||
this.controls();
|
||||
try {
|
||||
let {svg} = await this.context.call("ofdgoPrintSide", index);
|
||||
if (!this.current(sequence) || preview !== this.previewSequence) return;
|
||||
if (this.profile) {
|
||||
const area = this.profile.printableArea;
|
||||
svg = svg.replace("</svg>", `<rect x="${area.x}" y="${area.y}" width="${area.width}" height="${area.height}" fill="none" stroke="#88949e" stroke-width="0.2" stroke-dasharray="1 1"/></svg>`);
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([svg], {type:"image/svg+xml"}));
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
try { await img.decode(); } catch (error) { URL.revokeObjectURL(url); throw error; }
|
||||
if (!this.current(sequence) || preview !== this.previewSequence) { URL.revokeObjectURL(url); return; }
|
||||
if (this.previewURL) URL.revokeObjectURL(this.previewURL);
|
||||
this.previewURL = url;
|
||||
this.el.Preview.replaceChildren(img);
|
||||
this.resize();
|
||||
} catch (error) { if (this.current(sequence) && preview === this.previewSequence) { this.status(error.message); this.layout = null; this.controls(); } }
|
||||
}
|
||||
|
||||
resize() {
|
||||
const img = this.el.Preview.querySelector("img"), side = this.layout?.sides[this.side];
|
||||
if (!img || !side || !this.previewSize) return;
|
||||
const factor = Math.min(this.previewSize.width / side.width, this.previewSize.height / side.height);
|
||||
img.style.width = `${Math.max(1, side.width * factor)}px`;
|
||||
img.style.height = `${Math.max(1, side.height * factor)}px`;
|
||||
}
|
||||
|
||||
status(text = "", busy = false, completed = 0, total = 0) {
|
||||
this.el.Status.textContent = total ? `${text} · ${completed} / ${total}` : text;
|
||||
this.el.Spinner.hidden = !busy;
|
||||
this.el.ProgressTrack.hidden = !busy || !total;
|
||||
this.el.ProgressBar.style.width = `${total ? completed / total * 100 : 0}%`;
|
||||
}
|
||||
|
||||
controls() {
|
||||
const count = this.layout?.sides.length || 0, busy = !!this.running || !!this.loading;
|
||||
this.el.DPIValue.textContent = this.el.DPI.value;
|
||||
this.el.Settings.disabled = busy;
|
||||
this.el.Submit.disabled = !count || !this.job || busy;
|
||||
if (this.el.Printer.value !== "pdf" && this.manual() && this.el.Sides.value === "back" && !this.layout?.backContent) this.el.Submit.disabled = true;
|
||||
this.el.Previous.disabled = busy || !count || this.side === 0;
|
||||
this.el.Next.disabled = busy || !count || this.side + 1 === count;
|
||||
this.el.PreviewNumber.disabled = busy || !count;
|
||||
this.el.PreviewNumber.max = count || 1;
|
||||
this.el.PreviewNumber.value = count ? this.side + 1 : 1;
|
||||
this.el.PageCount.textContent = String(count);
|
||||
this.el.PageControl.style.setProperty("--page-width", `${String(count || 1).length + 0.5}ch`);
|
||||
this.el.Cancel.textContent = busy && !this.waiting ? "取消" : "关闭";
|
||||
this.el.Cancel.disabled = !!this.waiting;
|
||||
}
|
||||
|
||||
restore(saved) {
|
||||
for (const [name, value] of Object.entries(saved?.values || {})) {
|
||||
if (name === "Printer") continue;
|
||||
const node = this.el[name];
|
||||
if (node.type === "checkbox") node.checked = value;
|
||||
else if (!node.options || [...node.options].some(option => option.value === value)) node.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
remember() {
|
||||
const context = this.context;
|
||||
if (!context || this.restoring) return;
|
||||
let settings = this.settings.get(context.tab);
|
||||
if (!settings) this.settings.set(context.tab, settings = new Map());
|
||||
const values = {};
|
||||
for (const [name, node] of Object.entries(this.el)) if (node.closest("#printSettings") && name !== "Sides" && node.matches("input, select")) values[name] = node.type === "checkbox" ? node.checked : node.value;
|
||||
settings.set(context.document, {values, orientation:this.orientation, duplex:this.duplexBeforeBooklet, scaling:this.scalingBeforeBooklet});
|
||||
}
|
||||
|
||||
createRoot() {
|
||||
const root = document.createElement("div");
|
||||
root.id = "ofdgoPrintDocument";
|
||||
document.body.append(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async print() {
|
||||
if (!this.layout || !this.job || this.running) return;
|
||||
this.previewSequence++;
|
||||
if (this.el.Printer.value === "pdf") return this.pdf();
|
||||
const sequence = this.sequence, context = this.context;
|
||||
const job = this.job;
|
||||
this.running = true;
|
||||
this.controls();
|
||||
this.status("正在等待打印", true);
|
||||
const previous = printQueue;
|
||||
let release;
|
||||
printQueue = new Promise(resolve => { release = resolve; });
|
||||
await previous;
|
||||
if (!this.current(sequence)) { release(); return; }
|
||||
this.releasePrint = release;
|
||||
try {
|
||||
this.root = document.querySelector("#ofdgoPrintDocument") || this.createRoot();
|
||||
this.root.replaceChildren();
|
||||
const style = document.createElement("style");
|
||||
style.textContent = this.layout.sides.map((side, i) => `@page ofdgoPrint${i}{size:${side.width}mm ${side.height}mm;margin:0}`).join("");
|
||||
this.root.append(style);
|
||||
this.status("正在准备纸面", true, 0, this.layout.sides.length);
|
||||
for (let i = 0; i < this.layout.sides.length; i++) {
|
||||
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
|
||||
const {svg} = await context.call("ofdgoPrintSide", i);
|
||||
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
|
||||
const url = URL.createObjectURL(new Blob([svg], {type:"image/svg+xml"}));
|
||||
this.urls.push(url);
|
||||
const img = new Image(), section = document.createElement("section"), side = this.layout.sides[i];
|
||||
img.src = url;
|
||||
section.append(img);
|
||||
section.style.cssText = `page:ofdgoPrint${i};width:${side.width}mm;height:${side.height}mm`;
|
||||
this.root.append(section);
|
||||
await img.decode();
|
||||
if (this.current(sequence)) this.status("正在准备纸面", true, i + 1, this.layout.sides.length);
|
||||
}
|
||||
await document.fonts.ready;
|
||||
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
|
||||
if (this.el.Panel.open) {
|
||||
this.previousTitle = document.title;
|
||||
document.title = this.printTitle = context.name;
|
||||
}
|
||||
this.waiting = this.nativeHost ? "native" : "browser";
|
||||
this.controls();
|
||||
this.status(job.deviceOptions ? "正在提交打印" : "正在打开打印", true);
|
||||
if (job.deviceOptions) {
|
||||
const result = await this.nativeHost.call("print", {...job.deviceOptions, fingerprint:job.fingerprint});
|
||||
this.finishPrint(result?.submitted ? "打印已提交" : "打印作业未被接受");
|
||||
if (this.current(sequence) && result?.submitted && job.manual && job.sides === "front") {
|
||||
if (job.backContent) { this.el.Sides.value = "back"; this.schedule(); }
|
||||
else this.status("背面为空白");
|
||||
}
|
||||
} else if (this.nativeHost) {
|
||||
const capabilities = await this.nativeHost.call("print.capabilities", {});
|
||||
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
|
||||
if (!capabilities.systemDialog) throw new Error("当前运行时不支持系统打印");
|
||||
if (!capabilities.dialogCompletion) this.waiting = "browser";
|
||||
await this.nativeHost.call("print.dialog", {kind:"system"});
|
||||
if (capabilities.dialogCompletion) this.finishPrint("打印窗口已关闭");
|
||||
} else if (typeof window.print === "function") window.print();
|
||||
else throw new Error("当前浏览器不支持打印");
|
||||
} catch (error) {
|
||||
if (this.releasePrint === release) this.finishPrint();
|
||||
if (this.current(sequence)) {
|
||||
this.status(error.name === "AbortError" ? "打印已取消" : error.message);
|
||||
}
|
||||
} finally {
|
||||
if (this.current(sequence)) {
|
||||
if (!this.waiting) this.running = false;
|
||||
this.controls();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finishPrint(message = "") {
|
||||
if (this.previousTitle !== undefined) {
|
||||
if (document.title === this.printTitle) document.title = this.previousTitle;
|
||||
delete this.previousTitle;
|
||||
}
|
||||
if (this.releasePrint) this.root?.replaceChildren();
|
||||
for (const url of this.urls) URL.revokeObjectURL(url);
|
||||
this.urls = [];
|
||||
this.releasePrint?.();
|
||||
this.releasePrint = null;
|
||||
this.status(message);
|
||||
this.waiting = this.running = false;
|
||||
this.controls();
|
||||
if (this.destroyed) window.removeEventListener("afterprint", this.afterPrint);
|
||||
}
|
||||
|
||||
async pdf() {
|
||||
if (!this.layout || this.running) return;
|
||||
const sequence = this.sequence, context = this.context;
|
||||
this.running = true;
|
||||
this.controls();
|
||||
this.status("正在生成文件", true);
|
||||
try {
|
||||
const result = await context.call("ofdgoPrintPDF");
|
||||
if (this.current(sequence)) {
|
||||
await this.download(result.bytes, "application/pdf", `${context.name.replace(/\.[^.]+$/, "")}-打印.pdf`);
|
||||
if (this.current(sequence)) this.status("文件已生成");
|
||||
}
|
||||
} catch (error) { if (this.current(sequence)) this.status(error.message); }
|
||||
finally { if (this.current(sequence)) { this.running = false; this.controls(); } }
|
||||
}
|
||||
|
||||
close() {
|
||||
const context = this.context;
|
||||
this.remember();
|
||||
this.sequence++;
|
||||
clearTimeout(this.timer);
|
||||
if (context) this.cancel(context.tab);
|
||||
if (!this.waiting) this.finishPrint();
|
||||
if (this.previewURL) URL.revokeObjectURL(this.previewURL);
|
||||
this.previewURL = null;
|
||||
this.el.Preview.replaceChildren();
|
||||
this.context = null;
|
||||
this.queue = this.queue.catch(() => {}).then(() => context?.call("ofdgoDiscardPrint").catch(() => {}));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.close();
|
||||
this.observer.disconnect();
|
||||
window.removeEventListener("beforeprint", this.beforePrint);
|
||||
if (!this.waiting) window.removeEventListener("afterprint", this.afterPrint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const preference = matchMedia("(prefers-color-scheme: dark)");
|
||||
const key = "ofdgo.theme";
|
||||
let mode = root.dataset.themeMode || "system";
|
||||
let host = null, report = null, pending = Promise.resolve();
|
||||
if (!root.dataset.themeMode) {
|
||||
try { mode = localStorage.getItem(key) || "system"; } catch {}
|
||||
}
|
||||
if (!["system", "light", "dark"].includes(mode)) mode = "system";
|
||||
function apply() {
|
||||
root.dataset.themeMode = mode;
|
||||
root.dataset.theme = mode === "system" ? preference.matches ? "dark" : "light" : mode;
|
||||
document.querySelector('meta[name="theme-color"]').content = root.dataset.theme === "dark" ? "#272e33" : "#fafbfc";
|
||||
for (const button of document.querySelectorAll("[data-theme-choice]")) button.dataset.pressed = String(button.dataset.themeChoice === mode);
|
||||
}
|
||||
function sync() {
|
||||
if (!host) return;
|
||||
const value = {mode, dark: root.dataset.theme === "dark"};
|
||||
pending = pending.then(() => host.call("appearance", value)).catch(error => report(error));
|
||||
}
|
||||
function set(value) {
|
||||
mode = value;
|
||||
apply();
|
||||
if (!host) {
|
||||
try { localStorage.setItem(key, mode); } catch {}
|
||||
}
|
||||
sync();
|
||||
}
|
||||
preference.addEventListener("change", () => {
|
||||
if (mode !== "system") return;
|
||||
apply();
|
||||
sync();
|
||||
});
|
||||
window.addEventListener("storage", event => {
|
||||
if (host || event.key !== key && event.key !== null) return;
|
||||
mode = ["light", "dark"].includes(event.newValue) ? event.newValue : "system";
|
||||
apply();
|
||||
});
|
||||
window.ofdgoTheme = {set, connect(value, onError) { host = value; report = onError; apply(); sync(); }};
|
||||
apply();
|
||||
})();
|
||||
@@ -0,0 +1,550 @@
|
||||
export class DocumentDialogs {
|
||||
constructor({ document: root = document, targets = [], titlebar = null } = {}) {
|
||||
Object.assign(this, { document: root, targets, titlebar, owner: null, records: new Map(), tabs: new Map(), released: new WeakSet(), inert: new Map(), styles: new Map() });
|
||||
this.backdrop = root.createElement("div");
|
||||
this.backdrop.className = "document-dialog-backdrop";
|
||||
this.backdrop.hidden = true;
|
||||
Object.assign(this.backdrop.style, { position: "fixed", inset: "0", background: "var(--backdrop)", zIndex: "1000" });
|
||||
root.body.append(this.backdrop);
|
||||
root.addEventListener("keydown", event => {
|
||||
if (event.defaultPrevented || this.applicationOpen()) return;
|
||||
const panel = this.current()?.stack.at(-1);
|
||||
if (!panel) return;
|
||||
if (event.key === "Tab" && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
const selector = "button, input, select, textarea, a[href], [tabindex], [contenteditable=true]";
|
||||
const elements = [this.titlebar, panel].filter(Boolean).flatMap(root => [...root.querySelectorAll(selector)])
|
||||
.filter(node => node.tabIndex >= 0 && !node.disabled && !node.closest("[inert]") && node.getClientRects().length);
|
||||
const index = elements.indexOf(root.activeElement), direction = event.shiftKey ? -1 : 1;
|
||||
const target = index < 0 ? event.shiftKey ? elements.at(-1) : elements[0] : elements[(index + direction + elements.length) % elements.length];
|
||||
event.preventDefault();
|
||||
if (target) target.focus();
|
||||
else panel.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancel(panel);
|
||||
}, true);
|
||||
this.backdrop.addEventListener("pointerdown", event => { event.preventDefault(); this.focus(); });
|
||||
root.defaultView.addEventListener("resize", () => this.paint());
|
||||
}
|
||||
|
||||
register(panel, { shared = true, persistent = false } = {}) {
|
||||
if (this.records.has(panel)) return panel;
|
||||
const record = { show: panel.show.bind(panel), showModal: panel.showModal.bind(panel), close: panel.close.bind(panel), owner: null, application: false, shared, clicks: new WeakMap(), initial: shared && !persistent ? this.snapshot(panel) : null };
|
||||
this.records.set(panel, record);
|
||||
panel.showModal = panel.show = () => this.openFor(this.owner, panel);
|
||||
panel.close = value => {
|
||||
if (record.application) record.close(value);
|
||||
else this.closeFor(this.owner, panel, value);
|
||||
};
|
||||
panel.requestClose = value => {
|
||||
if (record.application) {
|
||||
const event = new this.document.defaultView.Event("cancel", { cancelable: true });
|
||||
if (panel.dispatchEvent(event)) record.close(value);
|
||||
} else this.cancel(panel, value);
|
||||
};
|
||||
panel.addEventListener("close", () => {
|
||||
if (record.application) { record.application = false; this.paint(); }
|
||||
});
|
||||
panel.addEventListener("click", event => { record.clicks.set(event, this.owner); }, true);
|
||||
panel.addEventListener("click", event => {
|
||||
if (!record.application && (record.clicks.get(event) !== this.owner || !this.current()?.stack.includes(panel))) event.preventDefault();
|
||||
});
|
||||
panel.addEventListener("submit", event => {
|
||||
if (record.application || event.defaultPrevented) return;
|
||||
const method = event.submitter?.getAttribute("formmethod") || event.target.getAttribute("method");
|
||||
if (method?.toLowerCase() !== "dialog") return;
|
||||
event.preventDefault();
|
||||
this.closeFor(this.owner, panel, event.submitter?.value || "");
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
current(tab = this.owner) {
|
||||
return tab ? this.tabs.get(tab) : null;
|
||||
}
|
||||
|
||||
entry(tab, panel) {
|
||||
if (!tab || this.released.has(tab)) throw new Error("Document dialog owner is unavailable");
|
||||
this.register(panel);
|
||||
if (!this.tabs.has(tab)) this.tabs.set(tab, { entries: new Map(), stack: [] });
|
||||
const entries = this.tabs.get(tab).entries;
|
||||
if (!entries.has(panel)) entries.set(panel, { snapshot: this.records.get(panel).initial, setup: [], updates: [], resolve: null, promise: null });
|
||||
return entries.get(panel);
|
||||
}
|
||||
|
||||
setOwner(tab) {
|
||||
if (this.owner === tab) return;
|
||||
if (this.owner) this.suspend(this.owner);
|
||||
if (tab) this.restore(tab);
|
||||
}
|
||||
|
||||
openFor(tab, panel, setup) {
|
||||
const entry = this.entry(tab, panel);
|
||||
if (setup) entry.setup.push(setup);
|
||||
const state = this.tabs.get(tab);
|
||||
state.stack = state.stack.filter(value => value !== panel);
|
||||
state.stack.push(panel);
|
||||
if (tab !== this.owner) return panel;
|
||||
this.unpaint();
|
||||
this.records.get(panel).owner = tab;
|
||||
this.prepare(entry, panel);
|
||||
if (!this.records.get(panel).shared) this.document.body.append(panel);
|
||||
if (!panel.open) this.records.get(panel).show();
|
||||
this.paint();
|
||||
this.focus();
|
||||
return panel;
|
||||
}
|
||||
|
||||
request(panel, tab = this.owner, setup) {
|
||||
if (!tab || this.released.has(tab)) return Promise.resolve(null);
|
||||
const entry = this.entry(tab, panel);
|
||||
if (entry.promise) return entry.promise;
|
||||
entry.promise = new Promise(resolve => { entry.resolve = resolve; });
|
||||
const promise = entry.promise;
|
||||
this.openFor(tab, panel, setup);
|
||||
return promise;
|
||||
}
|
||||
|
||||
updateFor(tab, panel, setup) {
|
||||
if (!tab || this.released.has(tab)) return false;
|
||||
const entry = this.current(tab)?.entries.get(panel);
|
||||
if (!entry) return false;
|
||||
if (tab === this.owner && this.records.get(panel).owner === tab) setup(panel);
|
||||
else entry.updates.push(setup);
|
||||
return true;
|
||||
}
|
||||
|
||||
closeFor(tab, panel, value) {
|
||||
const state = this.current(tab), entry = state?.entries.get(panel);
|
||||
if (!state?.stack.includes(panel)) return;
|
||||
const visible = tab === this.owner;
|
||||
if (visible) {
|
||||
this.unpaint();
|
||||
if (value !== undefined) panel.returnValue = String(value);
|
||||
panel.removeAttribute("open");
|
||||
if (this.records.get(panel).initial) entry.snapshot = this.snapshot(panel);
|
||||
}
|
||||
const result = value === undefined ? visible ? panel.returnValue : entry.snapshot?.returnValue ?? panel.returnValue : String(value);
|
||||
state.stack = state.stack.filter(value => value !== panel);
|
||||
const resolve = entry.resolve;
|
||||
entry.resolve = entry.promise = null;
|
||||
entry.setup = [];
|
||||
if (visible) {
|
||||
this.paint();
|
||||
panel.dispatchEvent(new this.document.defaultView.Event("close"));
|
||||
this.focus();
|
||||
}
|
||||
resolve?.(result);
|
||||
}
|
||||
|
||||
cancel(panel, value) {
|
||||
const event = new this.document.defaultView.Event("cancel", { cancelable: true });
|
||||
if (panel.dispatchEvent(event)) this.closeFor(this.owner, panel, value);
|
||||
}
|
||||
|
||||
suspend(tab) {
|
||||
if (tab !== this.owner) return;
|
||||
this.unpaint();
|
||||
for (const [panel, entry] of this.current(tab)?.entries || []) {
|
||||
const record = this.records.get(panel);
|
||||
if (record.application || record.owner !== tab) continue;
|
||||
if (record.initial) entry.snapshot = this.snapshot(panel);
|
||||
else if (!record.shared) {
|
||||
entry.focus = panel.contains(this.document.activeElement) ? this.document.activeElement : null;
|
||||
panel.remove();
|
||||
}
|
||||
panel.removeAttribute("open");
|
||||
}
|
||||
this.owner = null;
|
||||
this.paint();
|
||||
}
|
||||
|
||||
restore(tab) {
|
||||
if (!tab || this.released.has(tab)) return false;
|
||||
if (this.owner === tab) return true;
|
||||
if (this.owner) this.suspend(this.owner);
|
||||
if (!this.tabs.has(tab)) this.tabs.set(tab, { entries: new Map(), stack: [] });
|
||||
this.owner = tab;
|
||||
const state = this.current(tab);
|
||||
for (const [panel, entry] of state.entries) {
|
||||
const record = this.records.get(panel);
|
||||
if (record.application) continue;
|
||||
if (record.initial) this.applySnapshot(entry.snapshot, panel);
|
||||
else if (!record.shared) this.document.body.append(panel);
|
||||
record.owner = tab;
|
||||
}
|
||||
for (const [panel, entry] of state.entries) {
|
||||
if (this.records.get(panel).application) continue;
|
||||
for (const update of entry.updates.splice(0)) update(panel);
|
||||
}
|
||||
for (const panel of state.stack) {
|
||||
this.prepare(state.entries.get(panel), panel);
|
||||
if (!panel.open) this.records.get(panel).show();
|
||||
}
|
||||
this.paint();
|
||||
this.focus(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
release(tab) {
|
||||
if (!tab || this.released.has(tab)) return;
|
||||
const visible = this.owner === tab;
|
||||
if (visible) this.suspend(tab);
|
||||
const state = this.current(tab);
|
||||
this.tabs.delete(tab);
|
||||
this.released.add(tab);
|
||||
for (const entry of state?.entries.values() || []) entry.resolve?.(null);
|
||||
for (const [panel] of state?.entries || []) {
|
||||
const record = this.records.get(panel);
|
||||
if (!record.shared) {
|
||||
panel.remove();
|
||||
this.records.delete(panel);
|
||||
continue;
|
||||
}
|
||||
if (record.application || record.owner !== tab) continue;
|
||||
if (record.initial) this.applySnapshot(record.initial, panel);
|
||||
record.owner = null;
|
||||
}
|
||||
state?.entries.clear();
|
||||
if (state) state.stack.length = 0;
|
||||
}
|
||||
|
||||
showApplication(panel) {
|
||||
this.register(panel);
|
||||
if ([...this.tabs.values()].some(state => state.stack.includes(panel))) throw new Error("Document dialog is already in use");
|
||||
const record = this.records.get(panel);
|
||||
record.application = true;
|
||||
try { record.showModal(); }
|
||||
catch (error) { record.application = false; throw error; }
|
||||
}
|
||||
|
||||
applicationOpen() {
|
||||
return [...this.records.values()].some(record => record.application);
|
||||
}
|
||||
|
||||
prepare(entry, panel) {
|
||||
for (const setup of entry.setup.splice(0)) setup(panel);
|
||||
}
|
||||
|
||||
snapshot(panel) {
|
||||
const nodes = [panel, ...panel.querySelectorAll("*")].map(node => ({ node,
|
||||
children: [...node.childNodes], attributes: [...node.attributes].filter(attribute => node !== panel || attribute.name !== "open").map(attribute => [attribute.name, attribute.value]),
|
||||
value: "value" in node && node.type !== "file" ? node.value : undefined,
|
||||
checked: node.checked, indeterminate: node.indeterminate, selected: node.selected,
|
||||
files: node.type === "file" ? [...node.files] : undefined, scrollTop: node.scrollTop, scrollLeft: node.scrollLeft,
|
||||
selectionStart: node.selectionStart, selectionEnd: node.selectionEnd, selectionDirection: node.selectionDirection,
|
||||
text: [...node.childNodes].filter(child => child.nodeType === 3).map(child => [child, child.data]) }));
|
||||
const focus = panel.contains(this.document.activeElement) ? this.document.activeElement : null;
|
||||
const range = this.document.getSelection();
|
||||
const selection = !focus?.matches("input, textarea") && range?.anchorNode && panel.contains(range.anchorNode) && panel.contains(range.focusNode)
|
||||
? { anchor: range.anchorNode, start: range.anchorOffset, focus: range.focusNode, end: range.focusOffset } : null;
|
||||
return { nodes, focus, selection, returnValue: panel.returnValue };
|
||||
}
|
||||
|
||||
applySnapshot(snapshot, panel) {
|
||||
panel.removeAttribute("open");
|
||||
for (const item of snapshot.nodes) {
|
||||
const { node, children, attributes } = item;
|
||||
if (node.childNodes.length !== children.length || children.some((child, index) => node.childNodes[index] !== child)) node.replaceChildren(...children);
|
||||
for (const attribute of [...node.attributes]) if (!attributes.some(([name]) => name === attribute.name)) node.removeAttribute(attribute.name);
|
||||
for (const [name, value] of attributes) if (node.getAttribute(name) !== value) node.setAttribute(name, value);
|
||||
for (const [text, value] of item.text) text.data = value;
|
||||
}
|
||||
for (const item of snapshot.nodes) {
|
||||
const { node } = item;
|
||||
if (item.value !== undefined && node.tagName !== "SELECT") node.value = item.value;
|
||||
for (const key of ["checked", "indeterminate", "selected"]) if (item[key] !== undefined) node[key] = item[key];
|
||||
if (item.files !== undefined) {
|
||||
const transfer = new this.document.defaultView.DataTransfer();
|
||||
for (const file of item.files) transfer.items.add(file);
|
||||
node.files = transfer.files;
|
||||
}
|
||||
if (item.selectionStart != null && typeof node.setSelectionRange === "function") node.setSelectionRange(item.selectionStart, item.selectionEnd, item.selectionDirection);
|
||||
node.scrollTop = item.scrollTop;
|
||||
node.scrollLeft = item.scrollLeft;
|
||||
}
|
||||
panel.returnValue = snapshot.returnValue;
|
||||
}
|
||||
|
||||
unpaint() {
|
||||
for (const [node, value] of this.inert) node.inert = value;
|
||||
this.inert.clear();
|
||||
for (const [panel, styles] of this.styles) for (const [name, [value, priority]] of styles) {
|
||||
if (value) panel.style.setProperty(name, value, priority);
|
||||
else panel.style.removeProperty(name);
|
||||
}
|
||||
this.styles.clear();
|
||||
}
|
||||
|
||||
paint() {
|
||||
this.unpaint();
|
||||
const stack = this.current()?.stack || [];
|
||||
const top = stack.at(-1);
|
||||
this.backdrop.hidden = !top;
|
||||
if (!top) return;
|
||||
const inset = Math.max(0, this.titlebar?.getBoundingClientRect().bottom || 0);
|
||||
this.backdrop.style.top = `${inset}px`;
|
||||
this.backdrop.style.zIndex = String(1000 + (stack.length - 1) * 2);
|
||||
const disable = node => {
|
||||
if (!node || node === this.titlebar || node === this.backdrop || this.records.has(node)) return;
|
||||
if (node.contains(this.titlebar) || [...this.records.keys()].some(panel => node.contains(panel))) {
|
||||
for (const child of node.children) disable(child);
|
||||
return;
|
||||
}
|
||||
if (!this.inert.has(node)) this.inert.set(node, node.inert);
|
||||
node.inert = true;
|
||||
};
|
||||
for (const target of typeof this.targets === "function" ? this.targets() : this.targets) disable(target);
|
||||
for (const [index, panel] of stack.entries()) {
|
||||
if (panel !== top) { this.inert.set(panel, panel.inert); panel.inert = true; }
|
||||
const values = { position: "fixed", inset: `${inset}px 0 0`, zIndex: String(1001 + index * 2), maxHeight: `calc(100dvh - ${inset + 24}px)`, "--dialog-inset": `${inset}px` };
|
||||
const styles = new Map();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const name = key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
styles.set(name, [panel.style.getPropertyValue(name), panel.style.getPropertyPriority(name)]);
|
||||
panel.style.setProperty(name, value);
|
||||
}
|
||||
this.styles.set(panel, styles);
|
||||
}
|
||||
}
|
||||
|
||||
focus(restore = false) {
|
||||
const panel = this.current()?.stack.at(-1);
|
||||
if (!panel || this.applicationOpen()) return;
|
||||
const saved = restore ? this.current().entries.get(panel)?.snapshot?.focus || this.current().entries.get(panel)?.focus : null;
|
||||
const target = saved?.isConnected && panel.contains(saved) ? saved : panel.querySelector("[autofocus], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]") || panel;
|
||||
if (restore || !panel.contains(this.document.activeElement)) target.focus({ preventScroll: true });
|
||||
const selection = restore && this.current().entries.get(panel)?.snapshot?.selection;
|
||||
if (selection?.anchor?.isConnected && selection.focus?.isConnected) this.document.getSelection().setBaseAndExtent(selection.anchor, selection.start, selection.focus, selection.end);
|
||||
if (restore) for (const item of this.current().entries.get(panel)?.snapshot?.nodes || []) { item.node.scrollTop = item.scrollTop; item.node.scrollLeft = item.scrollLeft; }
|
||||
}
|
||||
}
|
||||
|
||||
export function positionDropdown(list, anchor, fit = false) {
|
||||
const rect = anchor.getBoundingClientRect(), viewport = window.visualViewport;
|
||||
const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
|
||||
const width = viewport?.width || window.innerWidth, height = viewport?.height || window.innerHeight;
|
||||
if (rect.right <= left || rect.left >= left + width || rect.bottom <= top || rect.top >= top + height) return false;
|
||||
const below = top + height - rect.bottom - 12, above = rect.top - top - 12;
|
||||
const limit = Math.min(280, Math.max(below, above));
|
||||
if (limit < 20) return false;
|
||||
const minimum = Math.min(rect.width, width - 16);
|
||||
Object.assign(list.style, { width: fit ? "max-content" : `${minimum}px`, minWidth: `${minimum}px`, maxWidth: `${width - 16}px`, maxHeight: `${limit}px` });
|
||||
const size = list.getBoundingClientRect();
|
||||
Object.assign(list.style, { left: `${Math.max(left + 8, Math.min(rect.left, left + width - size.width - 8))}px`,
|
||||
top: `${below >= size.height || below >= above ? rect.bottom + 4 : rect.top - size.height - 4}px` });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function bindDropdownPointer(list, choose) {
|
||||
list.addEventListener("pointerdown", event => {
|
||||
if (event.pointerType !== "touch" && event.target.closest("[data-index]")) event.preventDefault();
|
||||
});
|
||||
list.addEventListener("mousedown", event => { if (event.target.closest("[data-index]")) event.preventDefault(); });
|
||||
list.addEventListener("click", event => {
|
||||
const option = event.target.closest("[data-index]");
|
||||
if (option) choose(Number(option.dataset.index));
|
||||
});
|
||||
}
|
||||
|
||||
export class SelectPicker {
|
||||
constructor(onOpen) {
|
||||
Object.assign(this, { onOpen, select: null, options: [], active: -1, selected: -1, query: "", queryTime: 0, frame: 0, touchClick: false });
|
||||
this.list = document.createElement("div");
|
||||
this.list.className = "dropdown-list";
|
||||
this.list.setAttribute("popover", "manual");
|
||||
bindDropdownPointer(this.list, index => this.choose(index));
|
||||
window.addEventListener("pointerdown", event => {
|
||||
if (event.button !== 0) return;
|
||||
this.touchClick = false;
|
||||
const select = event.target.closest("select");
|
||||
if (select && !select.matches(":disabled")) event.preventDefault();
|
||||
if (this.select && event.target !== this.select && !this.list.contains(event.target)) this.close();
|
||||
}, true);
|
||||
window.addEventListener("pointerup", event => {
|
||||
const select = event.target.closest("select");
|
||||
if (event.pointerType !== "touch" || !select || select.matches(":disabled")) return;
|
||||
event.preventDefault();
|
||||
if (this.select === select) this.close();
|
||||
else this.show(select);
|
||||
this.touchClick = true;
|
||||
}, true);
|
||||
window.addEventListener("mousedown", event => {
|
||||
if (event.button === 0 && event.target.closest("select")) event.preventDefault();
|
||||
}, true);
|
||||
window.addEventListener("click", event => {
|
||||
const select = event.target.closest("select");
|
||||
if (!select || select.matches(":disabled")) return;
|
||||
event.preventDefault();
|
||||
if (this.touchClick) { this.touchClick = false; return; }
|
||||
if (this.select === select) this.close();
|
||||
else this.show(select);
|
||||
}, true);
|
||||
window.addEventListener("keydown", event => this.keyDown(event), true);
|
||||
window.addEventListener("focusin", event => {
|
||||
if (this.select && event.target !== this.select && !this.list.contains(event.target)) this.close();
|
||||
});
|
||||
window.addEventListener("scroll", event => {
|
||||
if (this.select && !this.list.contains(event.target)) this.position();
|
||||
}, true);
|
||||
window.addEventListener("resize", () => this.close());
|
||||
window.addEventListener("blur", () => this.close());
|
||||
window.addEventListener("pagehide", () => this.close());
|
||||
document.addEventListener("close", event => {
|
||||
if (this.select && event.target.contains(this.select)) this.close();
|
||||
}, true);
|
||||
document.addEventListener("reset", event => {
|
||||
if (this.select?.form === event.target) this.close();
|
||||
}, true);
|
||||
document.addEventListener("change", event => {
|
||||
if (event.target === this.select) this.render();
|
||||
}, true);
|
||||
}
|
||||
|
||||
show(select) {
|
||||
this.close();
|
||||
this.onOpen?.();
|
||||
select.focus({ preventScroll: true });
|
||||
if (!select.isConnected || select.matches(":disabled")) return;
|
||||
this.select = select;
|
||||
(select.closest("dialog") || document.body).append(this.list);
|
||||
this.render();
|
||||
if (!this.select) return;
|
||||
this.list.showPopover();
|
||||
this.position();
|
||||
if (!this.select) return;
|
||||
this.activate(this.active);
|
||||
this.observer = new MutationObserver(() => this.render());
|
||||
this.observer.observe(select, { subtree: true, childList: true, characterData: true, attributes: true });
|
||||
this.watch();
|
||||
}
|
||||
|
||||
render() {
|
||||
const select = this.select;
|
||||
if (!select) return;
|
||||
const previous = this.options[this.active]?.option, changed = this.selected !== select.selectedIndex;
|
||||
this.selected = select.selectedIndex;
|
||||
this.options = [...select.options].filter(option => !option.hidden && !option.parentElement.hidden).map(option => ({
|
||||
option, disabled: option.disabled || option.parentElement.tagName === "OPTGROUP" && option.parentElement.disabled,
|
||||
}));
|
||||
if (!this.options.length) { this.close(); return; }
|
||||
this.list.replaceChildren();
|
||||
let group = null;
|
||||
for (const [index, { option, disabled }] of this.options.entries()) {
|
||||
if (option.parentElement.tagName === "OPTGROUP" && group !== option.parentElement) {
|
||||
group = option.parentElement;
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "dropdown-group";
|
||||
heading.textContent = group.label;
|
||||
this.list.append(heading);
|
||||
}
|
||||
const row = document.createElement("div");
|
||||
row.className = "dropdown-option";
|
||||
row.dataset.index = String(index);
|
||||
row.dataset.disabled = String(disabled);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = option.label;
|
||||
row.append(label);
|
||||
row.title = option.title;
|
||||
this.list.append(row);
|
||||
}
|
||||
const selected = this.options.findIndex(({ option, disabled }) => !disabled && option === (changed ? select.selectedOptions[0] : previous));
|
||||
this.activate(selected < 0 ? this.options.findIndex(({ disabled }) => !disabled) : selected);
|
||||
if (this.list.matches(":popover-open")) this.position();
|
||||
}
|
||||
|
||||
watch() {
|
||||
const select = this.select;
|
||||
if (!select) return;
|
||||
if (!select.isConnected || select.matches(":disabled") || !select.getClientRects().length || select.closest("[hidden], [inert], dialog:not([open])")) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (this.selected !== select.selectedIndex) this.render();
|
||||
if (this.select) this.frame = requestAnimationFrame(() => this.watch());
|
||||
}
|
||||
|
||||
position() {
|
||||
if (this.select && !positionDropdown(this.list, this.select, true)) this.close();
|
||||
}
|
||||
|
||||
activate(index) {
|
||||
this.active = index;
|
||||
for (const row of this.list.querySelectorAll(".dropdown-option")) {
|
||||
row.dataset.selected = String(Number(row.dataset.index) === index);
|
||||
if (row.dataset.selected === "true" && this.list.matches(":popover-open")) row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
choose(index) {
|
||||
const select = this.select, item = this.options[index];
|
||||
if (!select || select.matches(":disabled") || !item || item.disabled || !select.contains(item.option)) return;
|
||||
const changed = select.selectedIndex !== item.option.index;
|
||||
select.selectedIndex = item.option.index;
|
||||
this.close();
|
||||
if (changed) {
|
||||
select.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.select) return;
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
cancelAnimationFrame(this.frame);
|
||||
this.frame = 0;
|
||||
this.list.hidePopover();
|
||||
this.list.remove();
|
||||
this.list.replaceChildren();
|
||||
this.select = null;
|
||||
this.options = [];
|
||||
this.active = this.selected = -1;
|
||||
this.query = "";
|
||||
this.queryTime = 0;
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (event.defaultPrevented || event.isComposing) return;
|
||||
this.touchClick = false;
|
||||
if (event.ctrlKey || event.metaKey || event.key === "Tab") { this.close(); return; }
|
||||
const select = event.target.closest("select");
|
||||
if (!select || select.matches(":disabled") || event.altKey && !["ArrowDown", "ArrowUp"].includes(event.key)) return;
|
||||
const key = event.key, open = this.select === select;
|
||||
if (key === "Escape") {
|
||||
if (!open) return;
|
||||
this.close();
|
||||
} else if (key === "F4" || event.altKey) {
|
||||
if (open) this.close();
|
||||
else this.show(select);
|
||||
} else if (["Enter", " "].includes(key)) {
|
||||
if (open) this.choose(this.active);
|
||||
else this.show(select);
|
||||
} else if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(key)) {
|
||||
if (!open) this.show(select);
|
||||
if (this.select && (open || !["ArrowDown", "ArrowUp"].includes(key))) {
|
||||
const available = this.options.flatMap(({ disabled }, index) => disabled ? [] : [index]);
|
||||
const at = available.indexOf(this.active), direction = ["ArrowUp", "PageUp"].includes(key) ? -1 : 1;
|
||||
const step = key.startsWith("Page") ? Math.max(1, Math.floor(this.list.clientHeight / (this.list.querySelector(".dropdown-option")?.offsetHeight || 32)) - 1) : 1;
|
||||
const next = key === "Home" ? 0 : key === "End" ? available.length - 1 : Math.max(0, Math.min(available.length - 1, at + direction * step));
|
||||
this.activate(available[next] ?? -1);
|
||||
}
|
||||
} else if (key.length === 1) {
|
||||
if (!open) this.show(select);
|
||||
const now = performance.now();
|
||||
this.query = (now - this.queryTime < 700 ? this.query : "") + key.toLocaleLowerCase();
|
||||
this.queryTime = now;
|
||||
const repeated = [...this.query].every(value => value === this.query[0]), query = repeated ? this.query[0] : this.query;
|
||||
const start = repeated ? this.active + 1 : Math.max(0, this.active);
|
||||
for (let offset = 0; offset < this.options.length; offset++) {
|
||||
const index = (start + offset) % this.options.length, item = this.options[index];
|
||||
if (!item.disabled && item.option.label.trim().toLocaleLowerCase().startsWith(query)) { this.activate(index); break; }
|
||||
}
|
||||
} else return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+169
-57
@@ -1,11 +1,15 @@
|
||||
importScripts("./wasm_exec.js");
|
||||
|
||||
let pending = Promise.resolve();
|
||||
const operations = new Map();
|
||||
const fontRequests = new Map();
|
||||
const engines = new Map();
|
||||
let resolveRuntime;
|
||||
let rejectRuntime;
|
||||
const runtimeReady = new Promise((resolve, reject) => { resolveRuntime = resolve; rejectRuntime = reject; });
|
||||
runtimeReady.catch(() => {});
|
||||
let fontSequence = 0;
|
||||
let engineMemory = null;
|
||||
|
||||
function requestFont(id, signal, name, done) {
|
||||
function requestFont(engine, id, signal, name, done) {
|
||||
const fontRequests = engine.fontRequests;
|
||||
if (signal.aborted) { done(null, "", true); return; }
|
||||
const request = ++fontSequence;
|
||||
const cancel = () => {
|
||||
@@ -17,22 +21,79 @@ function requestFont(id, signal, name, done) {
|
||||
signal.removeEventListener("abort", cancel);
|
||||
done(result.bytes, result.error || "", signal.aborted);
|
||||
});
|
||||
self.postMessage({ id, type: "font", request, name });
|
||||
self.postMessage({ engineID: engine.id, id, type: "font", request, name });
|
||||
}
|
||||
self.onmessage = ({ data }) => {
|
||||
if (data.type === "module") {
|
||||
if (data.error) exitWASM(new Error(data.error));
|
||||
else loadWASM(data.module).catch(exitWASM);
|
||||
return;
|
||||
}
|
||||
const engineID = data.engineID;
|
||||
if (typeof engineID !== "string" || !engineID) {
|
||||
self.postMessage({ engineID, id: data.id, ok: false, error: "missing engine ID" });
|
||||
return;
|
||||
}
|
||||
if (data.type === "open") {
|
||||
if (engines.has(engineID)) {
|
||||
self.postMessage({ engineID, type: "exit", error: "engine already exists" });
|
||||
return;
|
||||
}
|
||||
const engine = { id: engineID, operations: new Map(), fontRequests: new Map(), active: new Set(), closed: false };
|
||||
engines.set(engineID, engine);
|
||||
engine.pending = runtimeReady.then(async () => {
|
||||
if (engine.closed) return;
|
||||
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
|
||||
if (!opened.ok) throw new Error(opened.error);
|
||||
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends", null));
|
||||
if (!backends.ok) throw new Error(backends.error);
|
||||
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
|
||||
}).catch(error => {
|
||||
engine.closed = true;
|
||||
self.postMessage({ engineID, type: "exit", error: error.message });
|
||||
});
|
||||
return;
|
||||
}
|
||||
const engine = engines.get(engineID);
|
||||
if (!engine && data.type === "close") {
|
||||
self.postMessage({ engineID, type: "closed" });
|
||||
return;
|
||||
}
|
||||
if (!engine || engine.closed && data.type !== "close") {
|
||||
self.postMessage({ engineID, id: data.id, ok: false, error: "unknown or closed engine" });
|
||||
return;
|
||||
}
|
||||
if (data.type === "close") {
|
||||
engine.closed = true;
|
||||
for (const operation of engine.operations.values()) operation.abort();
|
||||
engine.pending.then(async () => {
|
||||
await Promise.all([...engine.active]);
|
||||
await runtimeReady;
|
||||
globalThis.ofdgoEngineClose(engineID);
|
||||
engines.delete(engineID);
|
||||
self.postMessage({ engineID, type: "closed" });
|
||||
}).catch(() => engines.delete(engineID));
|
||||
return;
|
||||
}
|
||||
if (data.type === "font") {
|
||||
fontRequests.get(data.request)?.(data);
|
||||
fontRequests.delete(data.request);
|
||||
engine.fontRequests.get(data.request)?.(data);
|
||||
engine.fontRequests.delete(data.request);
|
||||
return;
|
||||
}
|
||||
if (data.type === "cancel") {
|
||||
operations.get(data.id)?.abort();
|
||||
engine.operations.get(data.id)?.abort();
|
||||
return;
|
||||
}
|
||||
if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportMedia" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
|
||||
operations.set(data.id, new AbortController());
|
||||
}
|
||||
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data));
|
||||
engine.operations.set(data.id, new AbortController());
|
||||
engine.pending = engine.pending.then(() => {
|
||||
const operation = (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(engine, data) : handleMessage(engine, data)).catch(error => {
|
||||
engine.operations.delete(data.id);
|
||||
self.postMessage({ engineID, id: data.id, ok: false, error: error.message });
|
||||
});
|
||||
engine.active.add(operation);
|
||||
operation.finally(() => engine.active.delete(operation));
|
||||
if (data.name !== "ofdgoSaveSnapshot") return operation;
|
||||
});
|
||||
};
|
||||
|
||||
async function availableOutput(directory, base, format) {
|
||||
@@ -48,7 +109,9 @@ async function availableOutput(directory, base, format) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchMessage({ id, name, args }) {
|
||||
async function handleBatchMessage(engine, { id, name, args }) {
|
||||
const operations = engine.operations;
|
||||
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
|
||||
const signal = operations.get(id).signal;
|
||||
const channel = new MessageChannel();
|
||||
const finish = (done, err) => {
|
||||
@@ -78,17 +141,20 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
if (name === "ofdgoConvertFile") {
|
||||
const [file, options, fonts, destination] = args;
|
||||
credentials = options.credentials;
|
||||
const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data;
|
||||
const formats = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoOutputFormats", null)).data;
|
||||
const format = formats.find(item => item.value === options.format);
|
||||
if (!format) throw new Error("不支持此格式");
|
||||
root = destination;
|
||||
const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
|
||||
const target = root ? await availableOutput(root, base, format) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
|
||||
if (root && format.paged) {
|
||||
directory = await root.getDirectoryHandle(target.name, { create: true });
|
||||
const target = root ? await navigator.locks.request("ofdgo-convert-output", { signal }, async () => {
|
||||
signal.throwIfAborted();
|
||||
const target = await availableOutput(root, base, format);
|
||||
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
|
||||
else { await root.getFileHandle(target.name, { create: true }); directory = root; }
|
||||
created = target.name;
|
||||
self.postMessage({ id, type: "output", name: created });
|
||||
} else directory = root;
|
||||
postMessage({ id, type: "output", name: created });
|
||||
return target;
|
||||
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
|
||||
const boundary = async (action, pageName) => {
|
||||
signal.throwIfAborted();
|
||||
if (action === "open") {
|
||||
@@ -99,7 +165,7 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
const handle = await directory.getFileHandle(fileName, { create: true });
|
||||
if (!format.paged) {
|
||||
created = fileName;
|
||||
self.postMessage({ id, type: "output", name: created });
|
||||
postMessage({ id, type: "output", name: created });
|
||||
}
|
||||
current.handle = handle;
|
||||
output = await handle.createWritable();
|
||||
@@ -112,19 +178,19 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
chunks = [];
|
||||
}
|
||||
};
|
||||
payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write,
|
||||
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoConvertFile", done => finish(done), read(file), file.size, options, write,
|
||||
(phase, completed, total, done) => {
|
||||
self.postMessage({ id, type: "conversion", phase, completed, total });
|
||||
postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
|
||||
finish(done);
|
||||
},
|
||||
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
|
||||
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done));
|
||||
done => finish(done), fonts, (name, done) => requestFont(engine, id, signal, name, done));
|
||||
} else {
|
||||
const [entries, handle, compression] = args;
|
||||
if (handle) output = await handle.createWritable();
|
||||
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
|
||||
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPackFiles", done => finish(done), entries.map(entry => ({ name: entry.name, size: entry.file.size })),
|
||||
index => read(entries[index].file), write,
|
||||
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
|
||||
(completed, total, done) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); },
|
||||
done => finish(done), compression);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
@@ -143,7 +209,7 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
}
|
||||
result.data.size = size;
|
||||
operations.delete(id);
|
||||
self.postMessage({ id, ...result });
|
||||
postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
|
||||
} catch (err) {
|
||||
await output?.abort().catch(() => {});
|
||||
let message = signal.aborted ? "转换已取消" : err.message;
|
||||
@@ -151,7 +217,7 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
try { await root.removeEntry(created, { recursive: true }); }
|
||||
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
|
||||
}
|
||||
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted });
|
||||
postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
|
||||
} finally {
|
||||
credentials?.password?.fill(0);
|
||||
credentials?.key?.fill(0);
|
||||
@@ -161,18 +227,32 @@ async function handleBatchMessage({ id, name, args }) {
|
||||
operations.delete(id);
|
||||
}
|
||||
}
|
||||
async function handleMessage({ id, name, args }) {
|
||||
async function handleMessage(engine, { id, name, args }) {
|
||||
const operations = engine.operations;
|
||||
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
|
||||
let output;
|
||||
let saveFile;
|
||||
let saveStamp;
|
||||
let channel;
|
||||
const signal = operations.get(id)?.signal;
|
||||
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
|
||||
const converting = name === "ofdgoConvertPDF";
|
||||
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned";
|
||||
const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoOpenPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
|
||||
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
|
||||
const converting = name === "ofdgoOpenPDF";
|
||||
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
|
||||
const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name);
|
||||
if (engine.pdf) {
|
||||
const checked = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoCheckDocument", null, name));
|
||||
if (!checked.ok) { postMessage({id, ...checked}); operations.delete(id); return; }
|
||||
}
|
||||
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
|
||||
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
|
||||
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
if (["ofdgoOpen", "ofdgoOpenPDF", "ofdgoLoadImport"].includes(name) && args[0] instanceof Blob) {
|
||||
const file = args[0], reader = new FileReaderSync();
|
||||
args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))};
|
||||
}
|
||||
if (name === "ofdgoDetectFormat") {
|
||||
const file = args[0], reader = new FileReaderSync();
|
||||
channel = new MessageChannel();
|
||||
@@ -188,6 +268,9 @@ async function handleMessage({ id, name, args }) {
|
||||
catch (err) { return { error: err.message }; }
|
||||
};
|
||||
}
|
||||
if (name === "ofdgoChangeResource" && (args[0] === "add" || args[0] === "replace")) {
|
||||
args[4] = new Uint8Array(new FileReaderSync().readAsArrayBuffer(args[4]));
|
||||
}
|
||||
if (name === "ofdgoImportImages") {
|
||||
const files = args[0], reader = new FileReaderSync();
|
||||
args[0] = files.map(file => file.name);
|
||||
@@ -198,7 +281,7 @@ async function handleMessage({ id, name, args }) {
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
if (signal) {
|
||||
if (cancellable) {
|
||||
channel = new MessageChannel();
|
||||
const finish = (done, err, commit = false) => {
|
||||
channel.port1.onmessage = () => {
|
||||
@@ -207,14 +290,19 @@ async function handleMessage({ id, name, args }) {
|
||||
};
|
||||
channel.port2.postMessage(null);
|
||||
};
|
||||
if (importing || converting) {
|
||||
if (resources) {
|
||||
args.push(done => finish(done));
|
||||
} else if (importing || converting) {
|
||||
args.push((phase, completed, total, done) => {
|
||||
self.postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total });
|
||||
postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total });
|
||||
finish(done, null, importing && phase === "commit");
|
||||
});
|
||||
if (converting) args.push((name, done) => requestFont(id, signal, name, done));
|
||||
if (converting) args.push((name, done) => requestFont(engine, id, signal, name, done));
|
||||
} else {
|
||||
const file = args.pop();
|
||||
const destination = args.pop();
|
||||
const file = destination?.fileHandle || destination;
|
||||
saveFile = file;
|
||||
saveStamp = destination?.saveStamp;
|
||||
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
|
||||
const indices = saving ? args.shift() : null;
|
||||
if (file) output = await file.createWritable();
|
||||
@@ -234,28 +322,55 @@ async function handleMessage({ id, name, args }) {
|
||||
});
|
||||
if (name === "ofdgoExportDocument") {
|
||||
args.push((completed, total, done) => {
|
||||
self.postMessage({ id, type: "export", stage: "pages", completed, total });
|
||||
postMessage({ id, type: "export", stage: "pages", completed, total });
|
||||
finish(done);
|
||||
});
|
||||
} else if (name === "ofdgoExportVersion") {
|
||||
args.push((phase, completed, total, done) => {
|
||||
postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
|
||||
finish(done);
|
||||
}, done => finish(done));
|
||||
} else if (saving) {
|
||||
args.push((phase, completed, total, done) => {
|
||||
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
|
||||
postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
|
||||
finish(done);
|
||||
});
|
||||
if (compression) args.push(indices, compression, done => finish(done));
|
||||
if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done));
|
||||
else if (indices != null) args.push(indices);
|
||||
}
|
||||
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
|
||||
}
|
||||
}
|
||||
const payload = await globalThis[name](...args);
|
||||
if (engine.pdf && name !== "ofdgoOpenPDF") {
|
||||
channel ||= new MessageChannel();
|
||||
const context = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPDFContext", null, (phase, completed, total, done) => {
|
||||
postMessage({id, type: "conversion", phase, completed, total});
|
||||
channel.port1.onmessage = () => done("", signal.aborted);
|
||||
channel.port2.postMessage(null);
|
||||
}, (name, done) => requestFont(engine, id, signal, name, done));
|
||||
engine.pdf = JSON.parse(context).data === true;
|
||||
}
|
||||
const started = name === "ofdgoOpen" || name === "ofdgoRenderPage" ? performance.now() : null;
|
||||
const payload = await globalThis.ofdgoEngineCall(engine.id, name, done => {
|
||||
channel ||= new MessageChannel();
|
||||
channel.port1.onmessage = () => done("", signal.aborted);
|
||||
channel.port2.postMessage(null);
|
||||
}, ...args);
|
||||
signal?.throwIfAborted();
|
||||
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
if (signal && !importing && !converting && result.ok) {
|
||||
operations.delete(id);
|
||||
if (result.ok && name === "ofdgoOpenPDF") engine.pdf = true;
|
||||
if (result.ok && (name === "ofdgoOpen" || name === "ofdgoLoadPDF" && result.data.done)) engine.pdf = false;
|
||||
if (started !== null && result.ok) result.elapsed = performance.now() - started;
|
||||
if (cancellable && !importing && !converting && !resources && result.ok) {
|
||||
result.data.size = size;
|
||||
if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" });
|
||||
if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
|
||||
if (output) {
|
||||
if (saveStamp) {
|
||||
const current = await saveFile.getFile();
|
||||
if (current.size !== saveStamp.size || current.lastModified !== saveStamp.modified) throw new Error("文件已被修改,请另存");
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
operations.delete(id);
|
||||
await output.close();
|
||||
} else {
|
||||
result.data.blob = new Blob(chunks, { type: result.data.mime });
|
||||
@@ -264,16 +379,16 @@ async function handleMessage({ id, name, args }) {
|
||||
await output.abort();
|
||||
}
|
||||
output = null;
|
||||
const transfer = result.data?.bytes ? [result.data.bytes.buffer] : (result.data?.images || []).map((image) => image.bytes.buffer);
|
||||
self.postMessage({ id, ...result }, transfer);
|
||||
const transfer = [...new Set([result.data?.bytes, ...(result.data?.images || []).map(image => image.bytes), ...(result.data?.fonts || []).map(font => font.data)].filter(bytes => bytes?.byteLength).map(bytes => bytes.buffer))];
|
||||
postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer);
|
||||
} catch (err) {
|
||||
await output?.abort().catch(() => {});
|
||||
const result = { id, ok: false, error: err.message };
|
||||
const result = { id, ok: false, error: err.message, errorName: err.name };
|
||||
if (signal?.aborted) {
|
||||
result.error = importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消";
|
||||
result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : cancellable ? "导出已取消" : "操作已取消";
|
||||
result.canceled = true;
|
||||
}
|
||||
self.postMessage(result);
|
||||
postMessage(result);
|
||||
} finally {
|
||||
password?.fill(0);
|
||||
key?.fill(0);
|
||||
@@ -285,23 +400,20 @@ async function handleMessage({ id, name, args }) {
|
||||
}
|
||||
|
||||
function exitWASM(err) {
|
||||
rejectRuntime(err || new Error("引擎运行中断"));
|
||||
self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" });
|
||||
self.close();
|
||||
}
|
||||
|
||||
async function loadWASM() {
|
||||
async function loadWASM(module) {
|
||||
const go = new Go();
|
||||
self.postMessage({ type: "progress", phase: "download", text: "正在下载引擎", percent: 16 });
|
||||
const response = await fetch("./ofdgo.wasm");
|
||||
self.postMessage({ type: "progress", phase: "compile", text: "正在编译引擎", percent: 35 });
|
||||
const { instance } = await WebAssembly.instantiateStreaming(response, go.importObject);
|
||||
const instance = await WebAssembly.instantiate(module, go.importObject);
|
||||
engineMemory = instance.exports.mem;
|
||||
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
|
||||
go.run(instance).then(exitWASM, exitWASM);
|
||||
if (!go.exited) {
|
||||
const backends = JSON.parse(globalThis.ofdgoRenderBackends());
|
||||
if (!backends.ok) throw new Error(backends.error);
|
||||
self.postMessage({ type: "ready", backends: backends.data });
|
||||
resolveRuntime();
|
||||
}
|
||||
}
|
||||
|
||||
loadWASM().catch(exitWASM);
|
||||
self.postMessage({ type: "module" });
|
||||
@@ -1,6 +1,6 @@
|
||||
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
||||
const META_CACHE = `${CACHE_PREFIX}meta`;
|
||||
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
|
||||
const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_print.js", "ofdgo_theme.js", "ofdgo_util.js", "ofdgo_view.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
|
||||
.map((path) => new URL(path, self.registration.scope).href);
|
||||
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
|
||||
|
||||
@@ -92,6 +92,8 @@ async function readResource(event, navigation) {
|
||||
bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId);
|
||||
}
|
||||
if (!bundle || !await caches.has(bundle.name)) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
if (client && resourceURL(client.url) !== self.registration.scope) return fetch(event.request);
|
||||
return Response.error();
|
||||
}
|
||||
if (event.resultingClientId) {
|
||||
@@ -106,8 +108,9 @@ async function readResource(event, navigation) {
|
||||
}
|
||||
|
||||
async function updateBundle(meta) {
|
||||
const signal = AbortSignal.timeout(120000);
|
||||
const resources = await Promise.all(ASSETS.map(async (url) => {
|
||||
const response = await fetch(new Request(url, { cache: "no-store" }));
|
||||
const response = await fetch(new Request(url, { cache: "no-store", signal }));
|
||||
if (!response.ok || response.redirected) {
|
||||
throw new Error("资源下载失败");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package wasmassets
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS WebUI嵌入WASM引擎
|
||||
//
|
||||
//go:embed ofdgo.wasm
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,18 @@
|
||||
setlocal
|
||||
set Version=
|
||||
for /f %%V in ('git show -s --format^=%%ct-%%H HEAD') do set Version=%%V
|
||||
|
||||
set GOOS=linux
|
||||
set GOARCH=arm64
|
||||
set CGO_ENABLED=0
|
||||
go build -o releases/ofdgo_linux_arm64 -trimpath -ldflags "-s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=%Version%" ./cmd/webview
|
||||
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
go build -o releases/ofdgo_linux_amd64 -trimpath -ldflags "-s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=%Version%" ./cmd/webview
|
||||
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
go build -o releases/ofdgo_windows_amd64.exe -trimpath -ldflags "-H windowsgui -s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=%Version%" ./cmd/webview
|
||||
+26
-1
@@ -18,6 +18,9 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -25,6 +28,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo/assets/webui"
|
||||
"github.com/xiaoqidun/ofdgo/assets/webui/wasm"
|
||||
"github.com/xiaoqidun/ofdgo/internal/build"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -39,12 +44,18 @@ func main() {
|
||||
// 返回: http.Handler HTTP处理器
|
||||
func serveWebUI() http.Handler {
|
||||
files := http.FileServerFS(webuiassets.FS)
|
||||
checksum := webuiassets.Checksum()
|
||||
wasm := http.FileServerFS(wasmassets.FS)
|
||||
checksum := resourceChecksum()
|
||||
serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo_work.js")
|
||||
serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-OFDGo-Checksum", checksum)
|
||||
w.Header().Set("X-OFDGo-Build-Version", build.Version)
|
||||
if r.URL.Path == "/ofdgo.wasm" {
|
||||
wasm.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/ofdgo_work.js" {
|
||||
http.ServeContent(w, r, "ofdgo_work.js", time.Time{}, bytes.NewReader(serviceWorker))
|
||||
return
|
||||
@@ -52,3 +63,17 @@ func serveWebUI() http.Handler {
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// resourceChecksum 计算WebUI界面与WASM引擎的资源校验值
|
||||
// 返回: string SHA-256十六进制校验值
|
||||
func resourceChecksum() string {
|
||||
hash := sha256.New()
|
||||
for _, resources := range []embed.FS{webuiassets.FS, wasmassets.FS} {
|
||||
entries, _ := resources.ReadDir(".")
|
||||
for _, entry := range entries {
|
||||
data, _ := resources.ReadFile(entry.Name())
|
||||
hash.Write(data)
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build (windows && amd64) || (linux && (amd64 || arm64))
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// commandLine 读取桌面参数并处理参数错误
|
||||
// 返回: []string 文件路径, string 浏览器数据目录
|
||||
func commandLine() ([]string, string) {
|
||||
paths, dataDir, ok := parseArguments(os.Args[1:])
|
||||
if !ok {
|
||||
os.Exit(2)
|
||||
}
|
||||
return paths, dataDir
|
||||
}
|
||||
|
||||
// parseArguments 解析双横线选项及后续文件路径
|
||||
// 入参: args 启动参数
|
||||
// 返回: []string 文件路径, string 浏览器数据目录, bool 参数是否有效
|
||||
func parseArguments(args []string) ([]string, string, bool) {
|
||||
var dataDir string
|
||||
for len(args) > 0 {
|
||||
arg := args[0]
|
||||
switch {
|
||||
case arg == "--":
|
||||
return args[1:], dataDir, true
|
||||
case arg == "--data-dir":
|
||||
if len(args) < 2 || strings.HasPrefix(args[1], "-") {
|
||||
return nil, dataDir, false
|
||||
}
|
||||
dataDir, args = args[1], args[1:]
|
||||
case strings.HasPrefix(arg, "--data-dir="):
|
||||
dataDir = strings.TrimPrefix(arg, "--data-dir=")
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return nil, dataDir, false
|
||||
default:
|
||||
return args, dataDir, true
|
||||
}
|
||||
args = args[1:]
|
||||
}
|
||||
return nil, dataDir, true
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build linux && (amd64 || arm64)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo/internal/webview"
|
||||
)
|
||||
|
||||
func main() {
|
||||
paths, dataDir := commandLine()
|
||||
if err := webview.Run(context.Background(), paths, dataDir); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !(windows && amd64) && !(linux && (amd64 || arm64))
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "OFDGo WebView Unsupported")
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build windows && amd64
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo/internal/webview"
|
||||
)
|
||||
|
||||
func main() {
|
||||
paths, dataDir := commandLine()
|
||||
if err := webview.Run(context.Background(), paths, dataDir); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
message, _ := syscall.UTF16PtrFromString(err.Error())
|
||||
title, _ := syscall.UTF16PtrFromString("OFDGo")
|
||||
syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -3,16 +3,18 @@ module github.com/xiaoqidun/ofdgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/emmansun/gmsm v0.45.0
|
||||
github.com/go-fonts/latin-modern v0.3.3
|
||||
github.com/andybalholm/brotli v1.2.7
|
||||
github.com/emmansun/gmsm v0.45.2
|
||||
github.com/go-text/typesetting v0.3.5
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
||||
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893
|
||||
github.com/xiaoqidun/jbig2 v1.1.4
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7
|
||||
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
|
||||
github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f
|
||||
github.com/xiaoqidun/j2kgo v1.0.2
|
||||
github.com/xiaoqidun/jbig2 v1.1.8
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.47.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/text v0.43.0
|
||||
)
|
||||
|
||||
@@ -23,9 +25,10 @@ require (
|
||||
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect
|
||||
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect
|
||||
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/benoitkugler/textlayout v0.3.2 // indirect
|
||||
github.com/benoitkugler/textprocessing v0.0.6 // indirect
|
||||
github.com/ebitengine/purego v0.11.2 // indirect
|
||||
github.com/go-fonts/latin-modern v0.3.3 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 // indirect
|
||||
@@ -33,7 +36,6 @@ require (
|
||||
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
||||
github.com/yuin/goldmark v1.8.5 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
modernc.org/knuth v0.6.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
star-tex.org/x/tex v0.7.1 // indirect
|
||||
|
||||
@@ -20,16 +20,18 @@ github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v
|
||||
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
|
||||
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
|
||||
github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
|
||||
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
|
||||
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
|
||||
github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
|
||||
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
|
||||
github.com/emmansun/gmsm v0.45.0 h1:PU/6ObyR3g05YN3jn1L5GZDowzyAi8Xhkq4svzYNyiA=
|
||||
github.com/emmansun/gmsm v0.45.0/go.mod h1:HgLSvmdePGLo+Fo2PNgdTQU6lqAepSyriSQJRpECY6w=
|
||||
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
||||
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/emmansun/gmsm v0.45.2 h1:GUqMkdHDrEvxqcRtIfhkyqMzgwtiQsnCVYtioOdYlQc=
|
||||
github.com/emmansun/gmsm v0.45.2/go.mod h1:RfnfrzyB9XbDi73nF1NHbJeqJj+a6pg/pXr5lrZKMok=
|
||||
github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
|
||||
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
|
||||
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
|
||||
@@ -50,20 +52,22 @@ github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 h1:ZdkidVdpLW13BQ9a+
|
||||
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388/go.mod h1:C/WY5lmWfMtPFYYBTd3Lzdn4FTLr+RxlIeiBNye+/os=
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c h1:eXJXpQAo8l0AdVnsjJctG38GlBnHnwHfhLOTLYxZYNE=
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c/go.mod h1:K9Ux3cwFtyAFRITZ46g7v+ORXgI3ENtQa5rL9yjNz5Y=
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 h1:phNTWRYLLTlPpUgxkd6FDmWLlrOzpEpQAqq1ryLXYS8=
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6/go.mod h1:ipESUihcEdgRBhIwTRHmBTOMamccMQrp4iCxfa1aFws=
|
||||
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 h1:z7YAbjYz/bmG0AUx2pqmClRxUI2aCxP0rQMJAMcdmok=
|
||||
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918/go.mod h1:ipESUihcEdgRBhIwTRHmBTOMamccMQrp4iCxfa1aFws=
|
||||
github.com/tdewolff/minify/v2 v2.24.17 h1:6AbitfVyq0M7aW6i+XL7+49DeTQZwloOMs9O574arBg=
|
||||
github.com/tdewolff/minify/v2 v2.24.17/go.mod h1:kVqn9vxXUKtlHexSNrWbYePqioOT5mc4ou/KVSMpfCM=
|
||||
github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8feI=
|
||||
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
||||
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 h1:jmQlBO7R+bcimOMs9jjyDWR1FqiPNGtZqA/V1WiYfPA=
|
||||
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
||||
github.com/xiaoqidun/jbig2 v1.1.4 h1:LF6J1si4JY+TszTh9+m4KAg9/ba0JjfXoGtQTQC2S8c=
|
||||
github.com/xiaoqidun/jbig2 v1.1.4/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7 h1:VFrEkWzhLLgoMZ4FNH/uN/Mdfo/TSGKzIPjqCL4m3J4=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7/go.mod h1:fXz1emtF1YCpcIv+fglDZstjkHxjS2l3G4DVMgUzVnk=
|
||||
github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f h1:wek1tEiTjf0i5eT6w6ILJLEPwdXP+TaH3qtRBOO1xeI=
|
||||
github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f/go.mod h1:bXz+dPMVntPIVwePTTuMXt8Q0z1YoUGuROQSsXyUEfY=
|
||||
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
|
||||
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
||||
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
||||
github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrChanged 表示目标文件已被其他操作修改
|
||||
var ErrChanged = errors.New("file changed since opening")
|
||||
|
||||
// ErrBusy 表示目标文件正在写入
|
||||
var ErrBusy = errors.New("file save is in progress")
|
||||
|
||||
// activeFiles 协调当前进程内的目标写入
|
||||
var activeFiles sync.Map
|
||||
|
||||
// Target 保存文件路径及最近确认的文件状态,可跨次写入复用
|
||||
type Target struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
// Writer 写入同目录临时文件,提交前检查取消及目标冲突
|
||||
type Writer struct {
|
||||
mu sync.Mutex
|
||||
target *Target
|
||||
file *os.File
|
||||
ctx context.Context
|
||||
err error
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewTemp 创建私有输入缓存文件,调用方负责关闭和删除
|
||||
// 入参: directory 缓存目录,空值使用系统临时目录
|
||||
// 返回: *os.File 缓存文件, error 创建错误
|
||||
func NewTemp(directory string) (*os.File, error) {
|
||||
if directory == "" {
|
||||
directory = os.TempDir()
|
||||
}
|
||||
for range 100 {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(10000000))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := filepath.Join(directory, fmt.Sprintf("ofdgo-%07d.tmp", n.Int64()))
|
||||
file, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return file, err
|
||||
}
|
||||
return nil, os.ErrExist
|
||||
}
|
||||
|
||||
// NewTarget 记录目标当前状态,允许目标尚不存在
|
||||
// 入参: name 目标路径
|
||||
// 返回: *Target 保存目标, error 路径或文件错误
|
||||
func NewTarget(name string) (*Target, error) {
|
||||
if name == "" {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
name, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Lstat(name)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if info != nil && !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("save target is not a regular file")
|
||||
}
|
||||
return &Target{name: name, info: info}, nil
|
||||
}
|
||||
|
||||
// check 检查目标是否保持最近确认的状态
|
||||
// 返回: error 文件状态错误
|
||||
func (t *Target) check() error {
|
||||
info, err := os.Lstat(t.name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if t.info == nil {
|
||||
return nil
|
||||
}
|
||||
return ErrChanged
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.info == nil || !os.SameFile(t.info, info) || t.info.Size() != info.Size() || !t.info.ModTime().Equal(info.ModTime()) {
|
||||
return ErrChanged
|
||||
}
|
||||
if info.Mode().Perm()&0222 == 0 {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Begin 创建独占写入事务,调用方必须提交或终止
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: *Writer 事务, error 创建错误
|
||||
func (t *Target) Begin(ctx context.Context) (*Writer, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, loaded := activeFiles.LoadOrStore(t.name, true); loaded {
|
||||
return nil, ErrBusy
|
||||
}
|
||||
if err := t.check(); err != nil {
|
||||
activeFiles.Delete(t.name)
|
||||
return nil, err
|
||||
}
|
||||
for range 100 {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(10000000))
|
||||
if err != nil {
|
||||
activeFiles.Delete(t.name)
|
||||
return nil, err
|
||||
}
|
||||
name := fmt.Sprintf("%s.%07d.tmp", t.name, n.Int64())
|
||||
f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
activeFiles.Delete(t.name)
|
||||
return nil, err
|
||||
}
|
||||
return &Writer{target: t, file: f, ctx: ctx}, nil
|
||||
}
|
||||
activeFiles.Delete(t.name)
|
||||
return nil, os.ErrExist
|
||||
}
|
||||
|
||||
// Write 追加数据并记录写入错误
|
||||
// 入参: data 数据
|
||||
// 返回: int 写入长度, error 写入错误
|
||||
func (w *Writer) Write(data []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
if w.err == nil {
|
||||
w.err = w.ctx.Err()
|
||||
}
|
||||
if w.err != nil {
|
||||
return 0, w.err
|
||||
}
|
||||
n, err := w.file.Write(data)
|
||||
if err == nil && n != len(data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
w.err = err
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Commit 完成写入并替换目标,成功后刷新目标状态
|
||||
// 返回: error 提交错误
|
||||
func (w *Writer) Commit() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return os.ErrClosed
|
||||
}
|
||||
w.closed = true
|
||||
defer activeFiles.Delete(w.target.name)
|
||||
defer os.Remove(w.file.Name())
|
||||
w.target.mu.Lock()
|
||||
defer w.target.mu.Unlock()
|
||||
if err := errors.Join(w.err, w.ctx.Err(), w.target.check()); err != nil {
|
||||
return errors.Join(err, w.file.Close())
|
||||
}
|
||||
if w.target.info != nil {
|
||||
if err := w.file.Chmod(w.target.info.Mode().Perm()); err != nil {
|
||||
return errors.Join(err, w.file.Close())
|
||||
}
|
||||
}
|
||||
if err := w.file.Sync(); err != nil {
|
||||
return errors.Join(err, w.file.Close())
|
||||
}
|
||||
info, err := w.file.Stat()
|
||||
if err = errors.Join(err, w.file.Close(), w.ctx.Err()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.target.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Replace(w.file.Name(), w.target.name); err != nil {
|
||||
return err
|
||||
}
|
||||
w.target.info = info
|
||||
if current, err := os.Stat(w.target.name); err == nil {
|
||||
w.target.info = current
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Abort 丢弃临时输出,不改变目标
|
||||
// 返回: error 清理错误
|
||||
func (w *Writer) Abort() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
w.closed = true
|
||||
defer activeFiles.Delete(w.target.name)
|
||||
return errors.Join(w.file.Close(), os.Remove(w.file.Name()))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package file
|
||||
|
||||
import "os"
|
||||
|
||||
// Open 打开文件,路径替换后已有读取句柄仍有效
|
||||
// 入参: name 文件路径
|
||||
// 返回: *os.File 文件, error 打开错误
|
||||
func Open(name string) (*os.File, error) {
|
||||
return os.Open(name)
|
||||
}
|
||||
|
||||
// Replace 替换目标路径
|
||||
// 入参: source 临时路径, target 目标路径
|
||||
// 返回: error 替换错误
|
||||
func Replace(source, target string) error {
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package file
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// replaceFileProc 保留目标文件属性并替换文件内容
|
||||
var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
|
||||
|
||||
// Open 保留替换共享权限,路径替换后已有读取句柄仍有效
|
||||
// 入参: name 文件路径
|
||||
// 返回: *os.File 文件, error 打开错误
|
||||
func Open(name string) (*os.File, error) {
|
||||
path, err := nativePath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: name, Err: err}
|
||||
}
|
||||
return os.NewFile(uintptr(handle), name), nil
|
||||
}
|
||||
|
||||
// Replace 替换目标,目标不存在时重命名
|
||||
// 入参: source 临时路径, target 目标路径
|
||||
// 返回: error 替换错误
|
||||
func Replace(source, target string) error {
|
||||
from, err := nativePath(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := nativePath(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0)
|
||||
if result != 0 {
|
||||
return nil
|
||||
}
|
||||
if err == syscall.ERROR_FILE_NOT_FOUND {
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
return &os.LinkError{Op: "replace", Old: source, New: target, Err: err}
|
||||
}
|
||||
|
||||
// nativePath 转换绝对长路径
|
||||
// 入参: name 路径
|
||||
// 返回: *uint16 原生路径, error 转换错误
|
||||
func nativePath(name string) (*uint16, error) {
|
||||
path, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasPrefix(path, `\\?\`) {
|
||||
if strings.HasPrefix(path, `\\`) {
|
||||
path = `\\?\UNC\` + path[2:]
|
||||
} else {
|
||||
path = `\\?\` + path
|
||||
}
|
||||
}
|
||||
return syscall.UTF16PtrFromString(path)
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -30,16 +30,16 @@ import (
|
||||
// cffDict 使用float64存储所有数值,以统一处理整数和实数
|
||||
type cffDict map[int][]float64
|
||||
|
||||
// wrapCFFToOTF 将CFF裸数据包装为OpenType字体格式
|
||||
// WrapCFFToOTF 将CFF裸数据包装为OpenType字体格式
|
||||
// 入参: cffData CFF字体数据
|
||||
// 返回: []byte OTF字体数据, map[rune]uint16 字符映射, error 错误信息
|
||||
func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
|
||||
func WrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
|
||||
numGlyphs, err := parseCFFAndCountGlyphs(cffData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mapping := getCmapFromCFF(cffData, int(numGlyphs))
|
||||
sanitized, err := sanitizeCFF(cffData)
|
||||
sanitized, err := SanitizeCFF(cffData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -70,10 +70,10 @@ func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
|
||||
tables["maxp"] = buildCFFMaxpTable(uint16(numGlyphs))
|
||||
tables["OS/2"] = buildOS2Table()
|
||||
tables["name"] = buildNameTable()
|
||||
tables["post"] = buildPostTable()
|
||||
tables["post"] = BuildPostTable()
|
||||
tables["hmtx"] = buildHmtxTable(widths)
|
||||
tables["cmap"] = buildCmapTable(uint16(numGlyphs), mapping)
|
||||
data, err := serializeOTF(tables)
|
||||
tables["cmap"] = BuildCmapTable(uint16(numGlyphs), mapping)
|
||||
data, err := SerializeOTF(tables)
|
||||
return data, mapping, err
|
||||
}
|
||||
|
||||
@@ -81,13 +81,13 @@ func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
|
||||
// 入参: data CFF字体数据
|
||||
// 返回: []byte 标准化后的CFF数据, error 错误信息
|
||||
func normalizeCFFCharstrings(data []byte) ([]byte, error) {
|
||||
return normalizeCFFCharstringsAt(data, 0)
|
||||
return NormalizeCFFCharstringsAt(data, 0)
|
||||
}
|
||||
|
||||
// normalizeCFFCharstringsAt 统一CFF设计坐标,现有OpenType字体使用head表的设计单位
|
||||
// NormalizeCFFCharstringsAt 统一CFF设计坐标,现有OpenType字体使用head表的设计单位
|
||||
// 入参: data CFF字体数据, unitsPerEm 设计单位,0时由字体矩阵确定
|
||||
// 返回: []byte 标准化后的CFF数据, error 错误信息
|
||||
func normalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
|
||||
func NormalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
|
||||
if len(data) < 4 || int(data[2]) >= len(data) {
|
||||
return nil, fmt.Errorf("invalid CFF header")
|
||||
}
|
||||
@@ -131,6 +131,14 @@ func normalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, cid := dict[1230]; !cid {
|
||||
for op := 1231; op <= 1238; op++ {
|
||||
if _, exists := dict[op]; exists {
|
||||
delete(dict, op)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if transform != nil {
|
||||
scale := 1 / float64(unitsPerEm)
|
||||
dict[1207] = []float64{scale, 0, 0, scale, 0, 0}
|
||||
@@ -214,10 +222,10 @@ func replaceCFFCharstrings(data []byte, dict cffDict, topStart, topSize, charSta
|
||||
return result
|
||||
}
|
||||
|
||||
// sanitizeCFF 尝试清洗CFF数据,转换CID字体并合并FontMatrix
|
||||
// SanitizeCFF 尝试清洗CFF数据,转换CID字体并合并FontMatrix
|
||||
// 入参: data 原始CFF数据
|
||||
// 返回: []byte 清洗后的CFF数据, error 错误信息
|
||||
func sanitizeCFF(data []byte) ([]byte, error) {
|
||||
func SanitizeCFF(data []byte) ([]byte, error) {
|
||||
if !isBareCFFData(data) {
|
||||
return nil, fmt.Errorf("invalid CFF header")
|
||||
}
|
||||
@@ -260,6 +268,21 @@ func sanitizeCFF(data []byte) ([]byte, error) {
|
||||
if kind == 1 {
|
||||
return normalizeCFFType1(data, topDict)
|
||||
}
|
||||
private, err := readType2Private(data, topDict[18], &type2Font{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := len(private)
|
||||
if err := normalizeCFFPrivate(private); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(private) != count {
|
||||
font, err := readType2Font(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rebuildNamedCFF(data, font, font.chars, nil)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
fdArrOff, err := cffDictOffset(data, topDict, 1236)
|
||||
@@ -316,10 +339,9 @@ func sanitizeCFF(data []byte) ([]byte, error) {
|
||||
localSubrData = data[start:end]
|
||||
}
|
||||
charStringsData := data[charStringsOff:charEnd]
|
||||
delete(topDict, 1230)
|
||||
delete(topDict, 1236)
|
||||
delete(topDict, 1237)
|
||||
delete(topDict, 1234)
|
||||
for op := 1230; op <= 1238; op++ {
|
||||
delete(topDict, op)
|
||||
}
|
||||
delete(topDict, 15)
|
||||
delete(topDict, 16)
|
||||
var finalPrivData []byte
|
||||
@@ -342,17 +364,21 @@ func sanitizeCFF(data []byte) ([]byte, error) {
|
||||
return encodeSanitizedCFF(data[:hdrSize], nameIndexData, topDict, stringIndexData, globalSubrIndexData, charStringsData, finalPrivData, localSubrData), nil
|
||||
}
|
||||
|
||||
// normalizeCFFPrivate 移除旧版CFF中不生效的缺省字段,拒绝需要额外解释的取值
|
||||
// normalizeCFFPrivate 移除废弃的加粗阈值及未加密标记,拒绝加密字形程序
|
||||
// 入参: dict 私有字典
|
||||
// 返回: error 不支持的旧版行为
|
||||
func normalizeCFFPrivate(dict cffDict) error {
|
||||
for op, value := range map[int]float64{1215: 0, 1216: -1} {
|
||||
if values, ok := dict[op]; ok {
|
||||
if len(values) != 1 || values[0] != value {
|
||||
return fmt.Errorf("unsupported legacy CFF private operator %d", op)
|
||||
}
|
||||
delete(dict, op)
|
||||
if values, ok := dict[1215]; ok {
|
||||
if len(values) != 1 || !finite(values[0]) {
|
||||
return fmt.Errorf("invalid legacy CFF bold threshold")
|
||||
}
|
||||
delete(dict, 1215)
|
||||
}
|
||||
if values, ok := dict[1216]; ok {
|
||||
if len(values) != 1 || values[0] != -1 {
|
||||
return fmt.Errorf("unsupported legacy CFF private operator 1216")
|
||||
}
|
||||
delete(dict, 1216)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -397,9 +423,9 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict
|
||||
return nil, fmt.Errorf("invalid CFF FDArray count")
|
||||
}
|
||||
fonts := make([]type2Font, fdCount)
|
||||
var type1Fonts []*type1Program
|
||||
var type1Fonts []*Type1Program
|
||||
if kind == 1 {
|
||||
type1Fonts = make([]*type1Program, fdCount)
|
||||
type1Fonts = make([]*Type1Program, fdCount)
|
||||
}
|
||||
privateDicts := make([]cffDict, fdCount)
|
||||
matrices := make([][6]float64, fdCount)
|
||||
@@ -475,10 +501,9 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict
|
||||
}
|
||||
inlined[gid] = output.Bytes()
|
||||
}
|
||||
delete(topDict, 1230)
|
||||
delete(topDict, 1236)
|
||||
delete(topDict, 1237)
|
||||
delete(topDict, 1234)
|
||||
for op := 1230; op <= 1238; op++ {
|
||||
delete(topDict, op)
|
||||
}
|
||||
delete(topDict, 15)
|
||||
delete(topDict, 16)
|
||||
delete(topDict, 1206)
|
||||
@@ -602,13 +627,20 @@ func parseCFFDict(data []byte) cffDict {
|
||||
// 入参: data 字典数据
|
||||
// 返回: cffDict 字典映射, error 编码或操作数错误
|
||||
func readCFFDict(data []byte) (cffDict, error) {
|
||||
return readCFFDictionary(data, false)
|
||||
}
|
||||
|
||||
// readCFFDictionary 读取字体字典,CFF2顶层字典允许变化存储偏移操作符
|
||||
// 入参: data 字典数据, cff2 是否为CFF2顶层字典
|
||||
// 返回: cffDict 字典映射, error 编码或操作数错误
|
||||
func readCFFDictionary(data []byte, cff2 bool) (cffDict, error) {
|
||||
dict := make(cffDict)
|
||||
var operands []float64
|
||||
i := 0
|
||||
for i < len(data) {
|
||||
b := data[i]
|
||||
i++
|
||||
if b <= 21 {
|
||||
if b <= 21 || cff2 && b == 24 {
|
||||
op := int(b)
|
||||
if b == 12 {
|
||||
if i >= len(data) {
|
||||
@@ -718,7 +750,7 @@ func parseCFFReal(data []byte) (string, int) {
|
||||
return sb.String(), i
|
||||
}
|
||||
|
||||
// encodeCFFDict 编码CFF字典 (仅使用float64操作数)
|
||||
// encodeCFFDict 编码CFF字典,将字体类型标识置于首项
|
||||
// 入参: dict CFF字典映射
|
||||
// 返回: []byte 编码后的字典数据
|
||||
func encodeCFFDict(dict cffDict) []byte {
|
||||
@@ -728,6 +760,13 @@ func encodeCFFDict(dict cffDict) []byte {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
for index, op := range keys {
|
||||
if op == 1220 || op == 1230 {
|
||||
copy(keys[1:index+1], keys[:index])
|
||||
keys[0] = op
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, op := range keys {
|
||||
vals := dict[op]
|
||||
if len(vals) == 0 && (op >= 6 && op <= 9 || op == 1212 || op == 1213) {
|
||||
@@ -812,10 +851,8 @@ func encodeNumberCFF(buf *bytes.Buffer, val float64) {
|
||||
// 返回: []byte 编码后的索引数据
|
||||
func encodeCFFIndex(items []([]byte)) []byte {
|
||||
count := len(items)
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.BigEndian, uint16(count))
|
||||
if count == 0 {
|
||||
return buf.Bytes()
|
||||
return []byte{0, 0}
|
||||
}
|
||||
totalSize := 0
|
||||
for _, item := range items {
|
||||
@@ -831,25 +868,21 @@ func encodeCFFIndex(items []([]byte)) []byte {
|
||||
if totalSize+1 > 16777215 {
|
||||
offSize = 4
|
||||
}
|
||||
buf.WriteByte(byte(offSize))
|
||||
base := 3 + (count+1)*offSize
|
||||
data := make([]byte, base+totalSize)
|
||||
binary.BigEndian.PutUint16(data, uint16(count))
|
||||
data[2] = byte(offSize)
|
||||
offset := 1
|
||||
putOffset(buf, offset, offSize)
|
||||
for _, item := range items {
|
||||
offset += len(item)
|
||||
putOffset(buf, offset, offSize)
|
||||
for i := 0; i <= count; i++ {
|
||||
for j := 0; j < offSize; j++ {
|
||||
data[3+i*offSize+offSize-1-j] = byte(offset >> (8 * j))
|
||||
}
|
||||
if i < count {
|
||||
copy(data[base+offset-1:], items[i])
|
||||
offset += len(items[i])
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
buf.Write(item)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// putOffset 写入指定大小的偏移量
|
||||
// 入参: buf 缓冲区, val 偏移值, size 字节大小
|
||||
func putOffset(buf *bytes.Buffer, val int, size int) {
|
||||
tmp := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(tmp, uint32(val))
|
||||
buf.Write(tmp[4-size:])
|
||||
return data
|
||||
}
|
||||
|
||||
// getCFFIndexCount 读取CFF索引的计数和大小
|
||||
@@ -1028,6 +1061,9 @@ func getCFFCharsetInfo(data []byte, numGlyphs int) ([]int, string, string, int,
|
||||
if topDictData == nil {
|
||||
return nil, "", "", 0, false
|
||||
}
|
||||
if _, _, _, err := scanCFFIndex(data, stringIndexOff); err != nil {
|
||||
return nil, "", "", 0, false
|
||||
}
|
||||
td := parseCFFDict(topDictData)
|
||||
registry, ordering := getCFFROS(data, stringIndexOff, td)
|
||||
actual, err := parseCFFAndCountGlyphs(data)
|
||||
@@ -1059,13 +1095,24 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
mapping := make(map[rune]uint16)
|
||||
return cffCharsetMapping(data, sids, registry, ordering, stringIndexOff)
|
||||
}
|
||||
|
||||
// cffCharsetMapping 根据已解析的CFF字符集恢复Unicode映射
|
||||
// 入参: data CFF数据, sids 字符标识, registry 注册机构, ordering 字符集, stringIndexOff 字符串索引偏移
|
||||
// 返回: map[rune]uint16 恢复的映射表
|
||||
func cffCharsetMapping(data []byte, sids []int, registry, ordering string, stringIndexOff int) map[rune]uint16 {
|
||||
capacity := max(0, len(sids)-1)
|
||||
if registry == "" || registry == "Adobe" && ordering == "GB1" {
|
||||
capacity *= 2
|
||||
}
|
||||
mapping := make(map[rune]uint16, capacity)
|
||||
if registry == "Adobe" && ordering == "GB1" {
|
||||
for gid, cid := range sids {
|
||||
if gid == 0 {
|
||||
continue
|
||||
}
|
||||
mapping[packedGlyphRune(uint16(gid))] = uint16(gid)
|
||||
mapping[PackedGlyphRune(uint16(gid))] = uint16(gid)
|
||||
if r, ok := adobeGB1CIDToUnicode(cid); ok {
|
||||
mapping[r] = uint16(gid)
|
||||
}
|
||||
@@ -1077,7 +1124,7 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
|
||||
if gid == 0 {
|
||||
continue
|
||||
}
|
||||
mapping[packedGlyphRune(uint16(gid))] = uint16(gid)
|
||||
mapping[PackedGlyphRune(uint16(gid))] = uint16(gid)
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
@@ -1092,25 +1139,25 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
|
||||
}
|
||||
} else {
|
||||
idx := sid - 391
|
||||
name = readStringIndexItem(data, stringIndexOff, idx)
|
||||
name = string(readStringIndexItem(data, stringIndexOff, idx))
|
||||
}
|
||||
r := rune(0)
|
||||
if name != "" {
|
||||
r = getUnicodeFromName(name)
|
||||
}
|
||||
if r == 0 {
|
||||
r = packedGlyphRune(uint16(gid))
|
||||
r = PackedGlyphRune(uint16(gid))
|
||||
}
|
||||
mapping[packedGlyphRune(uint16(gid))] = uint16(gid)
|
||||
mapping[PackedGlyphRune(uint16(gid))] = uint16(gid)
|
||||
mapping[r] = uint16(gid)
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
|
||||
// getCFFCIDRuneMap 获取CID到包装字体字符的映射
|
||||
// GetCFFCIDRuneMap 获取CID到包装字体字符的映射
|
||||
// 入参: data CFF或OpenType字体数据
|
||||
// 返回: map[uint16]rune CID映射
|
||||
func getCFFCIDRuneMap(data []byte) map[uint16]rune {
|
||||
func GetCFFCIDRuneMap(data []byte) map[uint16]rune {
|
||||
cffData := getCFFData(data)
|
||||
if cffData == nil {
|
||||
return nil
|
||||
@@ -1119,17 +1166,17 @@ func getCFFCIDRuneMap(data []byte) map[uint16]rune {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sids, registry, _, _, ok := getCFFCharsetInfo(cffData, numGlyphs)
|
||||
sids, registry, ordering, stringIndexOff, ok := getCFFCharsetInfo(cffData, numGlyphs)
|
||||
if !ok || registry == "" {
|
||||
return nil
|
||||
}
|
||||
mapping := getCmapFromCFF(cffData, numGlyphs)
|
||||
mapping := cffCharsetMapping(cffData, sids, registry, ordering, stringIndexOff)
|
||||
if len(mapping) == 0 {
|
||||
return nil
|
||||
}
|
||||
gidRunes := make(map[uint16]rune)
|
||||
for run, gid := range mapping {
|
||||
if run != packedGlyphRune(gid) {
|
||||
if run != PackedGlyphRune(gid) {
|
||||
gidRunes[gid] = run
|
||||
}
|
||||
}
|
||||
@@ -1179,7 +1226,7 @@ func getCFFData(data []byte) []byte {
|
||||
}
|
||||
offset := int(binary.BigEndian.Uint32(data[pos+8 : pos+12]))
|
||||
length := int(binary.BigEndian.Uint32(data[pos+12 : pos+16]))
|
||||
if offset < 0 || length < 0 || offset+length > len(data) {
|
||||
if offset < 0 || length < 0 || offset > len(data) || length > len(data)-offset {
|
||||
return nil
|
||||
}
|
||||
return data[offset : offset+length]
|
||||
@@ -1261,31 +1308,29 @@ func parseCFFCharset(data []byte, offset, count int) ([]int, error) {
|
||||
|
||||
// readStringIndexItem 读取CFF字符串索引项
|
||||
// 入参: data CFF数据, offset 索引偏移, idx 索引号
|
||||
// 返回: string 读取的字符串
|
||||
func readStringIndexItem(data []byte, offset int, idx int) string {
|
||||
if offset >= len(data) {
|
||||
return ""
|
||||
// 返回: []byte 共享原始数据的只读字符串,索引无效时为nil
|
||||
func readStringIndexItem(data []byte, offset int, idx int) []byte {
|
||||
if offset < 0 || offset > len(data)-2 || idx < 0 {
|
||||
return nil
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(data[offset:]))
|
||||
if idx >= count || offset > len(data)-3 {
|
||||
return nil
|
||||
}
|
||||
offSize := int(data[offset+2])
|
||||
if idx >= count {
|
||||
return ""
|
||||
if offSize < 1 || offSize > 4 || count+1 > (len(data)-offset-3)/offSize {
|
||||
return nil
|
||||
}
|
||||
offArrayStart := offset + 3
|
||||
p1 := offArrayStart + idx*offSize
|
||||
p2 := p1 + offSize
|
||||
if p2+offSize > len(data) {
|
||||
return ""
|
||||
}
|
||||
loc1 := readCFFOffset(data, p1, offSize)
|
||||
loc2 := readCFFOffset(data, p2, offSize)
|
||||
dataStart := offArrayStart + (count+1)*offSize
|
||||
start := dataStart + loc1 - 1
|
||||
length := loc2 - loc1
|
||||
if start < 0 || start+length > len(data) {
|
||||
return ""
|
||||
if loc1 < 1 || loc2 < loc1 || loc2-1 > len(data)-dataStart {
|
||||
return nil
|
||||
}
|
||||
return string(data[start : start+length])
|
||||
return data[dataStart+loc1-1 : dataStart+loc2-1]
|
||||
}
|
||||
|
||||
// getCFFROS 读取CID字体ROS信息
|
||||
@@ -1309,7 +1354,7 @@ func getCFFSIDString(data []byte, stringIndexOff int, sid int) string {
|
||||
return cffStandardStrings[sid]
|
||||
}
|
||||
if sid > 390 {
|
||||
return readStringIndexItem(data, stringIndexOff, sid-391)
|
||||
return string(readStringIndexItem(data, stringIndexOff, sid-391))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1341,16 +1386,17 @@ func adobeGB1CIDToUnicode(cid int) (rune, bool) {
|
||||
if row < 16 || row > 87 || cell < 1 || cell > 94 {
|
||||
return 0, false
|
||||
}
|
||||
gbk := []byte{byte(row + 0xA0), byte(cell + 0xA0)}
|
||||
decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(gbk)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
gbk := [2]byte{byte(row + 0xA0), byte(cell + 0xA0)}
|
||||
var decoded [2 * utf8.UTFMax]byte
|
||||
nDst, nSrc, err := simplifiedchinese.GBK.NewDecoder().Transform(decoded[:], gbk[:], true)
|
||||
if err != nil || nDst == 0 || nSrc != len(gbk) {
|
||||
return 0, false
|
||||
}
|
||||
rs := []rune(string(decoded))
|
||||
if len(rs) != 1 {
|
||||
r, size := utf8.DecodeRune(decoded[:nDst])
|
||||
if size != nDst {
|
||||
return 0, false
|
||||
}
|
||||
return rs[0], true
|
||||
return r, true
|
||||
}
|
||||
|
||||
// getUnicodeFromName 根据字形名称获取对应的Unicode字符
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/go-text/typesetting/font/cff"
|
||||
"github.com/go-text/typesetting/font/opentype"
|
||||
"github.com/go-text/typesetting/font/opentype/tables"
|
||||
)
|
||||
|
||||
// DefaultCFF2Font 将CFF2默认实例转换为静态OpenType字体,保留字形编号和三次轮廓
|
||||
// 入参: data 独立字体数据
|
||||
// 返回: []byte 字体数据,非CFF2原样返回, error 解析或编码错误
|
||||
func DefaultCFF2Font(data []byte) (result []byte, err error) {
|
||||
if len(data) < 12 || string(data[:4]) != "OTTO" {
|
||||
return data, nil
|
||||
}
|
||||
found := false
|
||||
for i, count := 0, int(binary.BigEndian.Uint16(data[4:])); i < count && i < (len(data)-12)/16; i++ {
|
||||
if string(data[12+i*16:16+i*16]) == "CFF2" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return data, nil
|
||||
}
|
||||
table, err := FileTables(data, 0)
|
||||
if err != nil || table["CFF2"] == nil {
|
||||
return data, err
|
||||
}
|
||||
if table["CFF "] != nil || table["glyf"] != nil || len(table["head"]) < 54 || len(table["maxp"]) < 6 || len(table["hhea"]) < 36 {
|
||||
return nil, fmt.Errorf("invalid CFF2 font tables")
|
||||
}
|
||||
defer func() {
|
||||
if failure := recover(); failure != nil {
|
||||
result, err = nil, fmt.Errorf("invalid CFF2 font: %v", failure)
|
||||
}
|
||||
}()
|
||||
cffData := table["CFF2"]
|
||||
if len(cffData) < 5 || cffData[0] != 2 || cffData[1] != 0 || cffData[2] < 5 || binary.BigEndian.Uint32(table["maxp"]) != 0x5000 {
|
||||
return nil, fmt.Errorf("invalid CFF2 header")
|
||||
}
|
||||
start := int(cffData[2])
|
||||
end := start + int(binary.BigEndian.Uint16(cffData[3:]))
|
||||
if end > len(cffData) {
|
||||
return nil, fmt.Errorf("invalid CFF2 dictionary length")
|
||||
}
|
||||
dict, err := readCFFDictionary(cffData[start:end], true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matrix, err := readCFFMatrix(dict, [6]float64{.001, 0, 0, .001, 0, 0})
|
||||
units := binary.BigEndian.Uint16(table["head"][18:])
|
||||
if err != nil || units < 16 || units > 16384 || matrix[0] != matrix[3] || matrix[1] != 0 || matrix[2] != 0 || matrix[4] != 0 || matrix[5] != 0 || math.Abs(matrix[0]*float64(units)-1) > 1e-6 {
|
||||
return nil, fmt.Errorf("CFF2 FontMatrix does not match design units")
|
||||
}
|
||||
program, err := cff.ParseCFF2(table["CFF2"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dict[24]) != 0 {
|
||||
fvar := table["fvar"]
|
||||
if len(fvar) < 16 || binary.BigEndian.Uint32(fvar) != 0x10000 || int(binary.BigEndian.Uint16(fvar[8:])) != program.VarStore.AxisCount() {
|
||||
return nil, fmt.Errorf("CFF2 variation axis count mismatch")
|
||||
}
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(table["maxp"][4:]))
|
||||
metrics := int(binary.BigEndian.Uint16(table["hhea"][34:]))
|
||||
if count == 0 || len(program.Charstrings) != count || metrics == 0 || metrics > count || len(table["hmtx"]) < metrics*4+(count-metrics)*2 {
|
||||
return nil, fmt.Errorf("invalid CFF2 glyph metrics")
|
||||
}
|
||||
chars := make([][]byte, count)
|
||||
for gid := range chars {
|
||||
segments, _, err := program.LoadGlyph(tables.GlyphID(gid), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CFF2 glyph %d: %w", gid, err)
|
||||
}
|
||||
width := binary.BigEndian.Uint16(table["hmtx"][min(gid, metrics-1)*4:])
|
||||
chars[gid], err = cffOutlineProgram(segments, width)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CFF2 glyph %d: %w", gid, err)
|
||||
}
|
||||
}
|
||||
table["CFF "], err = staticCIDFont(table, chars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tag := range []string{"CFF2", "fvar", "avar", "STAT", "HVAR", "VVAR", "MVAR", "DSIG"} {
|
||||
delete(table, tag)
|
||||
}
|
||||
return SerializeOTF(table)
|
||||
}
|
||||
|
||||
// cffOutlineProgram 将默认设计轮廓编码为无子程序的Type2字形
|
||||
// 入参: segments 字体设计坐标路径, width 水平字宽
|
||||
// 返回: []byte 字形程序, error 坐标超出编码范围
|
||||
func cffOutlineProgram(segments []opentype.Segment, width uint16) ([]byte, error) {
|
||||
var output bytes.Buffer
|
||||
if width < 32768 {
|
||||
if err := encodeType2Number(&output, float64(width)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
for range 2 {
|
||||
if err := encodeType2Number(&output, float64(width)/2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
output.Write([]byte{12, 10})
|
||||
}
|
||||
var x, y float64
|
||||
for _, segment := range segments {
|
||||
count, op := 1, byte(21)
|
||||
switch segment.Op {
|
||||
case opentype.SegmentOpMoveTo:
|
||||
case opentype.SegmentOpLineTo:
|
||||
op = 5
|
||||
case opentype.SegmentOpCubeTo:
|
||||
count, op = 3, 8
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid CFF2 path operation")
|
||||
}
|
||||
for _, point := range segment.Args[:count] {
|
||||
nx, ny := float64(point.X), float64(point.Y)
|
||||
if err := encodeType2Number(&output, nx-x); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := encodeType2Number(&output, ny-y); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, y = nx, ny
|
||||
}
|
||||
output.WriteByte(op)
|
||||
}
|
||||
output.WriteByte(14)
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
// staticCIDFont 封装独立Type2字形,CID与字形编号一致,不依赖字形名称
|
||||
// 入参: table OpenType表, chars 字形程序
|
||||
// 返回: []byte CFF字体, error 字体结构错误
|
||||
func staticCIDFont(table map[string][]byte, chars [][]byte) ([]byte, error) {
|
||||
if len(chars) == 0 || len(chars) > 65535 || len(table["head"]) < 54 {
|
||||
return nil, fmt.Errorf("invalid static CFF glyph count")
|
||||
}
|
||||
units := binary.BigEndian.Uint16(table["head"][18:])
|
||||
if units < 16 || units > 16384 {
|
||||
return nil, fmt.Errorf("invalid CFF design units")
|
||||
}
|
||||
name := FaceInfo(table["name"], 0).PostScriptName
|
||||
if name == "" {
|
||||
name = "OpenTypeInstance"
|
||||
}
|
||||
names := encodeCFFIndex([][]byte{[]byte(name)})
|
||||
strings := encodeCFFIndex([][]byte{[]byte("Adobe"), []byte("Identity")})
|
||||
cids := make([]int, len(chars))
|
||||
for gid := 1; gid < len(chars); gid++ {
|
||||
cids[gid] = gid
|
||||
}
|
||||
charset := encodeCFFSubsetCharset(cids)
|
||||
selects := []byte{3, 0, 1, 0, 0, 0}
|
||||
selects = binary.BigEndian.AppendUint16(selects, uint16(len(chars)))
|
||||
fonts := encodeCFFIndex([][]byte{encodeCFFDict(cffDict{18: {0, 0}})})
|
||||
charIndex := encodeCFFIndex(chars)
|
||||
scale := 1 / float64(units)
|
||||
dict := cffDict{1230: {391, 392, 0}, 1234: {float64(len(chars))}, 1207: {scale, 0, 0, scale, 0, 0}, 15: {0}, 17: {0}, 1236: {0}, 1237: {0}}
|
||||
for i := 0; i < 4; i++ {
|
||||
dict[5] = append(dict[5], float64(int16(binary.BigEndian.Uint16(table["head"][36+2*i:]))))
|
||||
}
|
||||
var top []byte
|
||||
for range 16 {
|
||||
top = encodeCFFIndex([][]byte{encodeCFFDict(dict)})
|
||||
offset := 4 + len(names) + len(top) + len(strings) + 2
|
||||
if dict[15][0] == float64(offset) {
|
||||
var output bytes.Buffer
|
||||
for _, part := range [][]byte{{1, 0, 4, 4}, names, top, strings, {0, 0}, charset, selects, fonts, charIndex} {
|
||||
output.Write(part)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
dict[15][0] = float64(offset)
|
||||
dict[1237][0] = float64(offset + len(charset))
|
||||
dict[1236][0] = float64(offset + len(charset) + len(selects))
|
||||
dict[17][0] = float64(offset + len(charset) + len(selects) + len(fonts))
|
||||
}
|
||||
return nil, fmt.Errorf("CFF offsets did not converge")
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// cidSubsetFont 保存CID字体裁剪所需的字典、映射和执行资源
|
||||
type cidSubsetFont struct {
|
||||
prefix []byte
|
||||
strings [][]byte
|
||||
top cffDict
|
||||
dicts []cffDict
|
||||
private []cffDict
|
||||
fonts []*type2Font
|
||||
selects []int
|
||||
charset []byte
|
||||
}
|
||||
|
||||
// readCIDSubsetFont 读取CID字体的原始字典及逐字形归属,不合并字体字典
|
||||
// 入参: data CFF数据
|
||||
// 返回: *cidSubsetFont 裁剪资源, error 结构错误或不支持的字典
|
||||
func readCIDSubsetFont(data []byte) (*cidSubsetFont, error) {
|
||||
if !isBareCFFData(data) {
|
||||
return nil, fmt.Errorf("invalid CFF header")
|
||||
}
|
||||
names, end, err := readType2Index(data, int(data[2]))
|
||||
if err != nil || len(names) != 1 {
|
||||
return nil, fmt.Errorf("invalid CFF name index")
|
||||
}
|
||||
tops, end, err := readType2Index(data, end)
|
||||
if err != nil || len(tops) != 1 {
|
||||
return nil, fmt.Errorf("invalid CFF top index")
|
||||
}
|
||||
strings, end, err := readType2Index(data, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
globals, _, err := readType2Index(data, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
top, err := readCFFDict(tops[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ros := top[1230]
|
||||
if len(ros) != 3 {
|
||||
return nil, fmt.Errorf("invalid CFF ROS")
|
||||
}
|
||||
for index, value := range ros {
|
||||
limit := float64(math.MaxInt32)
|
||||
if index < 2 {
|
||||
limit = float64(min(64999, 390+len(strings)))
|
||||
}
|
||||
if !finite(value) || value < 0 || value > limit || value != math.Trunc(value) {
|
||||
return nil, fmt.Errorf("invalid CFF ROS operand")
|
||||
}
|
||||
}
|
||||
for op := range top {
|
||||
switch op {
|
||||
case 0, 1, 2, 3, 4, 5, 13, 14, 15, 17, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1230, 1231, 1232, 1233, 1234, 1236, 1237:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported CID CFF top operator %d", op)
|
||||
}
|
||||
}
|
||||
if value, ok := top[1206]; ok && (len(value) != 1 || value[0] != 2) {
|
||||
return nil, fmt.Errorf("unsupported CFF charstring type")
|
||||
}
|
||||
if _, err := readCFFMatrix(top, [6]float64{.001, 0, 0, .001, 0, 0}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
charOffset, err := cffDictOffset(data, top, 17)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chars, _, err := readType2Index(data, charOffset)
|
||||
if err != nil || len(chars) == 0 {
|
||||
return nil, fmt.Errorf("invalid CFF charstrings")
|
||||
}
|
||||
charsetOffset, err := cffDictOffset(data, top, 15)
|
||||
if err != nil || charsetOffset <= 2 {
|
||||
return nil, fmt.Errorf("invalid CID CFF charset offset")
|
||||
}
|
||||
cids, err := parseCFFCharset(data, charsetOffset, len(chars))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fdOffset, err := cffDictOffset(data, top, 1236)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fds, _, err := readType2Index(data, fdOffset)
|
||||
if err != nil || len(fds) == 0 || len(fds) > 256 {
|
||||
return nil, fmt.Errorf("invalid CFF font dictionary count")
|
||||
}
|
||||
selectOffset, err := cffDictOffset(data, top, 1237)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selects, err := readType2FDSelect(data, selectOffset, len(chars), len(fds))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data[selectOffset] == 3 && binary.BigEndian.Uint16(data[selectOffset+3:]) != 0 {
|
||||
return nil, fmt.Errorf("CFF FDSelect must include glyph zero")
|
||||
}
|
||||
font := &cidSubsetFont{
|
||||
prefix: append(bytes.Clone(data[:int(data[2])]), encodeCFFIndex(names)...),
|
||||
strings: strings, top: top, selects: selects, charset: encodeCFFSubsetCharset(cids),
|
||||
dicts: make([]cffDict, len(fds)), private: make([]cffDict, len(fds)), fonts: make([]*type2Font, len(fds)),
|
||||
}
|
||||
for index, fd := range fds {
|
||||
dict, err := readCFFDict(fd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for op := range dict {
|
||||
switch op {
|
||||
case 18, 1207, 1238:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported CFF font dictionary operator %d", op)
|
||||
}
|
||||
}
|
||||
if _, err := readCFFMatrix(dict, [6]float64{1, 0, 0, 1, 0, 0}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dict[18]) != 2 {
|
||||
return nil, fmt.Errorf("missing CFF private dictionary")
|
||||
}
|
||||
resource := &type2Font{data: data, chars: chars, globals: globals, seed: 1, cid: true}
|
||||
private, err := readType2Private(data, dict[18], resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := encodeCFFSubsetPrivate(private); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
font.dicts[index], font.private[index], font.fonts[index] = dict, private, resource
|
||||
}
|
||||
return font, nil
|
||||
}
|
||||
|
||||
// write 重建CID字体偏移,保留CID映射、字体矩阵及各字典的私有参数
|
||||
// 入参: chars 裁剪后的字形程序
|
||||
// 返回: []byte CFF数据, error 偏移或字典错误
|
||||
func (f *cidSubsetFont) write(chars [][]byte) ([]byte, error) {
|
||||
suffix := append(encodeCFFIndex(f.strings), encodeCFFIndex(f.fonts[0].globals)...)
|
||||
selects := encodeCFFSubsetFDSelect(f.selects)
|
||||
programs := encodeCFFIndex(chars)
|
||||
private := make([][]byte, len(f.fonts))
|
||||
locals := make([][]byte, len(f.fonts))
|
||||
fds := make([][]byte, len(f.fonts))
|
||||
for index, font := range f.fonts {
|
||||
var err error
|
||||
private[index], err = encodeCFFSubsetPrivate(f.private[index])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := f.private[index][19]; ok {
|
||||
locals[index] = encodeCFFIndex(font.locals)
|
||||
}
|
||||
fds[index] = encodeCFFDict(f.dicts[index])
|
||||
}
|
||||
for range 16 {
|
||||
top := encodeCFFIndex([][]byte{encodeCFFDict(f.top)})
|
||||
array := encodeCFFIndex(fds)
|
||||
offset := len(f.prefix) + len(top) + len(suffix)
|
||||
f.top[15] = []float64{float64(offset)}
|
||||
offset += len(f.charset)
|
||||
f.top[1237] = []float64{float64(offset)}
|
||||
offset += len(selects)
|
||||
f.top[17] = []float64{float64(offset)}
|
||||
offset += len(programs)
|
||||
f.top[1236] = []float64{float64(offset)}
|
||||
offset += len(array)
|
||||
for index := range f.fonts {
|
||||
f.dicts[index][18] = []float64{float64(len(private[index])), float64(offset)}
|
||||
fds[index] = encodeCFFDict(f.dicts[index])
|
||||
offset += len(private[index]) + len(locals[index])
|
||||
}
|
||||
updatedTop := encodeCFFIndex([][]byte{encodeCFFDict(f.top)})
|
||||
updatedArray := encodeCFFIndex(fds)
|
||||
if len(updatedTop) != len(top) || len(updatedArray) != len(array) {
|
||||
continue
|
||||
}
|
||||
result := make([]byte, 0, offset)
|
||||
for _, part := range [][]byte{f.prefix, updatedTop, suffix, f.charset, selects, programs, updatedArray} {
|
||||
result = append(result, part...)
|
||||
}
|
||||
for index := range f.fonts {
|
||||
result = append(result, private[index]...)
|
||||
result = append(result, locals[index]...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("CID CFF offsets did not converge")
|
||||
}
|
||||
|
||||
// encodeCFFSubsetFDSelect 选择较短的逐字形或分段格式保存字体字典归属
|
||||
// 入参: selects 字形所属字典
|
||||
// 返回: []byte FDSelect数据
|
||||
func encodeCFFSubsetFDSelect(selects []int) []byte {
|
||||
ranges := []byte{3, 0, 0}
|
||||
count := 0
|
||||
for gid, fd := range selects {
|
||||
if gid == 0 || fd != selects[gid-1] {
|
||||
ranges = binary.BigEndian.AppendUint16(ranges, uint16(gid))
|
||||
ranges = append(ranges, byte(fd))
|
||||
count++
|
||||
}
|
||||
}
|
||||
binary.BigEndian.PutUint16(ranges[1:], uint16(count))
|
||||
ranges = binary.BigEndian.AppendUint16(ranges, uint16(len(selects)))
|
||||
if len(ranges) < len(selects)+1 {
|
||||
return ranges
|
||||
}
|
||||
result := make([]byte, len(selects)+1)
|
||||
for gid, fd := range selects {
|
||||
result[gid+1] = byte(fd)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// compactCFFGlyphs 重排已裁剪字体的字形,同步字符集、编码和字体字典归属
|
||||
// 入参: data 已裁剪的CFF数据, keep 保留的原字形编号
|
||||
// 返回: []byte 紧凑CFF数据, []uint16 新编号对应的原编号, error 结构或编码错误
|
||||
func compactCFFGlyphs(data []byte, keep map[uint16]bool) ([]byte, []uint16, error) {
|
||||
ids := []uint16{0}
|
||||
for gid, used := range keep {
|
||||
if used && gid != 0 {
|
||||
ids = append(ids, gid)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
font, err := readType2Font(data)
|
||||
if err == nil {
|
||||
sids, _, _, _, ok := getCFFCharsetInfo(data, len(font.chars))
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("invalid CFF charset")
|
||||
}
|
||||
_, end, _ := readType2Index(data, int(data[2]))
|
||||
tops, _, _ := readType2Index(data, end)
|
||||
dict, _ := readCFFDict(tops[0])
|
||||
encoding, err := cffSubsetEncoding(data, dict)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if encoding != nil {
|
||||
codes, err := readCFFSubsetEncoding(encoding, sids)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ordered := []uint16{0}
|
||||
seen := map[uint16]bool{0: true}
|
||||
for _, gid := range codes {
|
||||
if keep[gid] && !seen[gid] {
|
||||
ordered = append(ordered, gid)
|
||||
seen[gid] = true
|
||||
}
|
||||
}
|
||||
for _, gid := range ids {
|
||||
if !seen[gid] {
|
||||
ordered = append(ordered, gid)
|
||||
}
|
||||
}
|
||||
ids = ordered
|
||||
}
|
||||
chars := make([][]byte, len(ids))
|
||||
for index, gid := range ids {
|
||||
if int(gid) >= len(font.chars) {
|
||||
return nil, nil, fmt.Errorf("CFF glyph index out of range")
|
||||
}
|
||||
chars[index] = font.chars[gid]
|
||||
}
|
||||
result, err := rebuildNamedCFF(data, font, chars, ids)
|
||||
return result, ids, err
|
||||
}
|
||||
cid, err := readCIDSubsetFont(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cids, _, _, _, ok := getCFFCharsetInfo(data, len(cid.selects))
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("invalid CID CFF charset")
|
||||
}
|
||||
chars := make([][]byte, len(ids))
|
||||
selected := make([]int, len(ids))
|
||||
selects := make([]int, len(ids))
|
||||
dicts, private, fonts := []cffDict{}, []cffDict{}, []*type2Font{}
|
||||
remap := make(map[int]int)
|
||||
for index, gid := range ids {
|
||||
if int(gid) >= len(cid.selects) {
|
||||
return nil, nil, fmt.Errorf("CFF glyph index out of range")
|
||||
}
|
||||
fd := cid.selects[gid]
|
||||
mapped, exists := remap[fd]
|
||||
if !exists {
|
||||
mapped = len(fonts)
|
||||
remap[fd] = mapped
|
||||
dicts = append(dicts, cid.dicts[fd])
|
||||
private = append(private, cid.private[fd])
|
||||
fonts = append(fonts, cid.fonts[fd])
|
||||
}
|
||||
chars[index], selected[index], selects[index] = cid.fonts[fd].chars[gid], cids[gid], mapped
|
||||
}
|
||||
cid.dicts, cid.private, cid.fonts = dicts, private, fonts
|
||||
cid.charset, cid.selects = encodeCFFSubsetCharset(selected), selects
|
||||
result, err := cid.write(chars)
|
||||
return result, ids, err
|
||||
}
|
||||
|
||||
// compactCFFTables 重建按字形编号索引的度量和字符映射,不保留旧编号的布局表
|
||||
// 入参: font 原字体, cff 紧凑CFF数据, ids 新编号对应的原编号
|
||||
// 返回: []byte 独立OpenType字体, error 封装错误
|
||||
func compactCFFTables(font *SFNT, cff []byte, ids []uint16) ([]byte, error) {
|
||||
tables := make(map[string][]byte)
|
||||
for _, tag := range []string{"head", "hhea", "maxp", "OS/2", "post", "name", "vhea", "gasp"} {
|
||||
if data := font.Tables[tag]; data != nil {
|
||||
tables[tag] = bytes.Clone(data)
|
||||
}
|
||||
}
|
||||
var hmtx, vmtx []byte
|
||||
mapping := make(map[rune]uint16)
|
||||
for index, gid := range ids {
|
||||
hmtx = binary.BigEndian.AppendUint16(hmtx, font.GlyphAdvance(gid))
|
||||
hmtx = binary.BigEndian.AppendUint16(hmtx, uint16(font.sideBearing(gid, false)))
|
||||
if font.vertical != 0 {
|
||||
vmtx = binary.BigEndian.AppendUint16(vmtx, font.GlyphVerticalAdvance(gid))
|
||||
vmtx = binary.BigEndian.AppendUint16(vmtx, uint16(font.sideBearing(gid, true)))
|
||||
}
|
||||
for _, char := range font.glyphCharacters(gid) {
|
||||
mapping[char] = uint16(index)
|
||||
}
|
||||
}
|
||||
tables["CFF "], tables["hmtx"] = cff, hmtx
|
||||
tables["cmap"] = BuildCmapTable(uint16(len(ids)), mapping)
|
||||
binary.BigEndian.PutUint16(tables["hhea"][34:], uint16(len(ids)))
|
||||
binary.BigEndian.PutUint16(tables["maxp"][4:], uint16(len(ids)))
|
||||
if len(tables["post"]) >= 32 {
|
||||
tables["post"] = tables["post"][:32]
|
||||
binary.BigEndian.PutUint32(tables["post"], 0x30000)
|
||||
}
|
||||
if font.vertical != 0 {
|
||||
tables["vmtx"] = vmtx
|
||||
binary.BigEndian.PutUint16(tables["vhea"][34:], uint16(len(ids)))
|
||||
}
|
||||
if data := font.Tables["VORG"]; data != nil {
|
||||
var err error
|
||||
tables["VORG"], err = compactCFFVerticalOrigins(data, font.NumGlyphs(), ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return SerializeOTF(tables)
|
||||
}
|
||||
|
||||
// compactCFFVerticalOrigins 重排CFF字体的逐字形纵向原点,保留默认原点
|
||||
// 入参: data VORG表, count 原字形数, ids 新编号对应的原编号
|
||||
// 返回: []byte 重建的VORG表, error 表结构或记录顺序错误
|
||||
func compactCFFVerticalOrigins(data []byte, count uint16, ids []uint16) ([]byte, error) {
|
||||
if len(data) < 8 || binary.BigEndian.Uint32(data) != 0x10000 {
|
||||
return nil, fmt.Errorf("invalid CFF vertical origin header")
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(data[6:]))
|
||||
if length > (len(data)-8)/4 {
|
||||
return nil, fmt.Errorf("truncated CFF vertical origins")
|
||||
}
|
||||
origins := make(map[uint16]uint16, length)
|
||||
previous := -1
|
||||
for index := range length {
|
||||
pos := 8 + index*4
|
||||
gid := binary.BigEndian.Uint16(data[pos:])
|
||||
if gid >= count || int(gid) <= previous {
|
||||
return nil, fmt.Errorf("invalid CFF vertical origin glyph")
|
||||
}
|
||||
origins[gid] = binary.BigEndian.Uint16(data[pos+2:])
|
||||
previous = int(gid)
|
||||
}
|
||||
result := bytes.Clone(data[:8])
|
||||
for index, gid := range ids {
|
||||
if origin, ok := origins[gid]; ok {
|
||||
result = binary.BigEndian.AppendUint16(result, uint16(index))
|
||||
result = binary.BigEndian.AppendUint16(result, origin)
|
||||
}
|
||||
}
|
||||
binary.BigEndian.PutUint16(result[6:], uint16((len(result)-8)/4))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// encodeCFFSubsetCharset 选择最短的标准字符集格式,保持SID或CID及字形顺序
|
||||
// 入参: ids 按字形编号排列的SID或CID,包含.notdef
|
||||
// 返回: []byte 字符集数据
|
||||
func encodeCFFSubsetCharset(ids []int) []byte {
|
||||
sizes := [3]int{1 + 2*(len(ids)-1), 1, 1}
|
||||
for first := 1; first < len(ids); {
|
||||
end := first + 1
|
||||
for end < len(ids) && ids[end] == ids[end-1]+1 {
|
||||
end++
|
||||
}
|
||||
sizes[1] += 3 * ((end - first + 255) / 256)
|
||||
sizes[2] += 4
|
||||
first = end
|
||||
}
|
||||
format := 0
|
||||
for index, size := range sizes {
|
||||
if size < sizes[format] {
|
||||
format = index
|
||||
}
|
||||
}
|
||||
result := make([]byte, 1, sizes[format])
|
||||
result[0] = byte(format)
|
||||
for first := 1; first < len(ids); {
|
||||
end := first + 1
|
||||
if format != 0 {
|
||||
limit := len(ids)
|
||||
if format == 1 {
|
||||
limit = min(limit, first+256)
|
||||
}
|
||||
for end < limit && ids[end] == ids[end-1]+1 {
|
||||
end++
|
||||
}
|
||||
}
|
||||
result = binary.BigEndian.AppendUint16(result, uint16(ids[first]))
|
||||
if format == 1 {
|
||||
result = append(result, byte(end-first-1))
|
||||
} else if format == 2 {
|
||||
result = binary.BigEndian.AppendUint16(result, uint16(end-first-1))
|
||||
}
|
||||
first = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// readCFFSubsetEncoding 解析自定义编码及补充映射,校验重复编码和字形归属
|
||||
// 入参: data 编码数据, sids 原字符集
|
||||
// 返回: [256]uint16 编码对应的字形编号, error 编码结构错误
|
||||
func readCFFSubsetEncoding(data []byte, sids []int) ([256]uint16, error) {
|
||||
var codes [256]uint16
|
||||
if len(data) < 2 {
|
||||
return codes, fmt.Errorf("truncated CFF encoding")
|
||||
}
|
||||
pos, gid := 2, 1
|
||||
for index := 0; index < int(data[1]); index++ {
|
||||
if pos >= len(data) {
|
||||
return codes, fmt.Errorf("truncated CFF encoding")
|
||||
}
|
||||
first, count := int(data[pos]), 1
|
||||
pos++
|
||||
switch data[0] & 0x7f {
|
||||
case 0:
|
||||
case 1:
|
||||
if pos >= len(data) {
|
||||
return codes, fmt.Errorf("truncated CFF encoding range")
|
||||
}
|
||||
count += int(data[pos])
|
||||
pos++
|
||||
default:
|
||||
return codes, fmt.Errorf("invalid CFF encoding format")
|
||||
}
|
||||
if first+count > len(codes) || gid+count > len(sids) {
|
||||
return codes, fmt.Errorf("CFF encoding range out of bounds")
|
||||
}
|
||||
for code := first; code < first+count; code++ {
|
||||
if codes[code] != 0 {
|
||||
return codes, fmt.Errorf("duplicate CFF encoding")
|
||||
}
|
||||
codes[code] = uint16(gid)
|
||||
gid++
|
||||
}
|
||||
}
|
||||
if data[0]&0x80 != 0 {
|
||||
if pos >= len(data) {
|
||||
return codes, fmt.Errorf("truncated CFF encoding supplements")
|
||||
}
|
||||
count := int(data[pos])
|
||||
pos++
|
||||
if count > (len(data)-pos)/3 {
|
||||
return codes, fmt.Errorf("truncated CFF encoding supplements")
|
||||
}
|
||||
for range count {
|
||||
code, sid := data[pos], int(binary.BigEndian.Uint16(data[pos+1:]))
|
||||
pos += 3
|
||||
mapped := slices.Index(sids[:gid], sid)
|
||||
if codes[code] != 0 || mapped <= 0 {
|
||||
return codes, fmt.Errorf("invalid CFF supplemental glyph")
|
||||
}
|
||||
codes[code] = uint16(mapped)
|
||||
}
|
||||
}
|
||||
if data[0]&0x7f > 1 || pos != len(data) {
|
||||
return codes, fmt.Errorf("invalid CFF encoding length or format")
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// encodeCFFSubsetEncoding 按新字形顺序重建主编码及同字形的补充编码
|
||||
// 入参: codes 原编码映射, sids 原字符集, ids 新编号对应的原编号
|
||||
// 返回: []byte 编码数据, error 字形顺序不满足编码约束
|
||||
func encodeCFFSubsetEncoding(codes [256]uint16, sids []int, ids []uint16) ([]byte, error) {
|
||||
var base, supplements []byte
|
||||
encodings := make(map[uint16][]byte)
|
||||
for code, gid := range codes {
|
||||
if gid != 0 {
|
||||
encodings[gid] = append(encodings[gid], byte(code))
|
||||
}
|
||||
}
|
||||
unencoded := false
|
||||
for _, gid := range ids[1:] {
|
||||
values := encodings[gid]
|
||||
if len(values) == 0 {
|
||||
unencoded = true
|
||||
} else if unencoded {
|
||||
return nil, fmt.Errorf("CFF encoded glyph follows unencoded glyph")
|
||||
} else {
|
||||
base = append(base, values[0])
|
||||
for _, code := range values[1:] {
|
||||
supplements = append(supplements, code)
|
||||
supplements = binary.BigEndian.AppendUint16(supplements, uint16(sids[gid]))
|
||||
}
|
||||
}
|
||||
}
|
||||
var result []byte
|
||||
if len(base) == 256 {
|
||||
for index, code := range base {
|
||||
if int(code) != index {
|
||||
return nil, fmt.Errorf("CFF encoding exceeds format zero capacity")
|
||||
}
|
||||
}
|
||||
result = []byte{1, 1, 0, 255}
|
||||
} else {
|
||||
result = append([]byte{0, byte(len(base))}, base...)
|
||||
}
|
||||
if len(supplements) != 0 {
|
||||
result[0] |= 0x80
|
||||
result = append(result, byte(len(supplements)/3))
|
||||
result = append(result, supplements...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -12,9 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cffExpertCharset 按CFF规范附录C保存Expert字符集的SID顺序
|
||||
var cffExpertCharset = [...]uint16{
|
||||
@@ -0,0 +1,325 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
)
|
||||
|
||||
// subsetCFFFont 裁剪CFF字体,保留组合组件和度量,按需重排字形
|
||||
// 入参: sfnt 已解析字体, glyphs 使用的字形, preserve 是否保留原字形编号
|
||||
// 返回: []byte 字体子集, error 裁剪错误
|
||||
func subsetCFFFont(sfnt *SFNT, glyphs []uint16, preserve bool) ([]byte, error) {
|
||||
keep := map[uint16]bool{0: true}
|
||||
for _, gid := range glyphs {
|
||||
if gid >= sfnt.NumGlyphs() {
|
||||
return nil, fmt.Errorf("CFF glyph index out of range")
|
||||
}
|
||||
keep[gid] = true
|
||||
}
|
||||
data, err := subsetCFFCharstrings(sfnt.Tables["CFF "], keep)
|
||||
if err != nil {
|
||||
return bytes.Clone(sfnt.Write()), nil
|
||||
}
|
||||
if !preserve {
|
||||
if compact, ids, err := compactCFFGlyphs(data, keep); err == nil {
|
||||
return compactCFFTables(sfnt, compact, ids)
|
||||
}
|
||||
}
|
||||
tables := maps.Clone(sfnt.Tables)
|
||||
tables["CFF "] = data
|
||||
mapping := make(map[rune]uint16)
|
||||
for gid := range keep {
|
||||
for _, char := range sfnt.glyphCharacters(gid) {
|
||||
mapping[char] = gid
|
||||
}
|
||||
}
|
||||
tables["cmap"] = BuildCmapTable(sfnt.NumGlyphs(), mapping)
|
||||
for _, tag := range []string{"DSIG", "GSUB", "GPOS", "GDEF"} {
|
||||
delete(tables, tag)
|
||||
}
|
||||
return SerializeOTF(tables)
|
||||
}
|
||||
|
||||
// subsetCFFCharstrings 清空未引用的Type2轮廓及子程序,保留原字宽、提示和编号
|
||||
// 入参: data CFF数据, keep 保留的字形,调用后包含组合组件
|
||||
// 返回: []byte 重建的CFF数据, error 结构或依赖错误
|
||||
func subsetCFFCharstrings(data []byte, keep map[uint16]bool) ([]byte, error) {
|
||||
font, err := readType2Font(data)
|
||||
if err != nil {
|
||||
cid, err := readCIDSubsetFont(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chars, err := subsetType2Programs(cid.fonts, cid.selects, keep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cid.write(chars)
|
||||
}
|
||||
chars, err := subsetType2Programs([]*type2Font{font}, nil, keep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rebuildNamedCFF(data, font, chars, nil)
|
||||
}
|
||||
|
||||
// subsetType2Programs 按字形所属字典收集子程序依赖并清空未使用的轮廓
|
||||
// 入参: fonts 执行资源, selects 字形所属字典,单字典时可为空, keep 保留的字形
|
||||
// 返回: [][]byte 字形程序, error 执行或字宽错误
|
||||
func subsetType2Programs(fonts []*type2Font, selects []int, keep map[uint16]bool) ([][]byte, error) {
|
||||
keep[0] = true
|
||||
programs := fonts[0].chars
|
||||
locals := make([][]bool, len(fonts))
|
||||
empty := make([]map[float64][]byte, len(fonts))
|
||||
for index, font := range fonts {
|
||||
locals[index] = make([]bool, len(font.locals))
|
||||
empty[index] = make(map[float64][]byte)
|
||||
}
|
||||
globals := make([]bool, len(fonts[0].globals))
|
||||
var stack [48]float64
|
||||
for gid := range keep {
|
||||
if int(gid) >= len(programs) {
|
||||
return nil, fmt.Errorf("CFF glyph index out of range")
|
||||
}
|
||||
fd := 0
|
||||
if selects != nil {
|
||||
fd = selects[gid]
|
||||
}
|
||||
font := fonts[fd]
|
||||
steps := 0
|
||||
state := type2State{font: font, args: stack[:0], width: font.def, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep, localUsage: locals[fd], globalUsage: globals}
|
||||
if _, err := state.run(font.chars[gid], 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
chars := make([][]byte, len(programs))
|
||||
for gid, program := range programs {
|
||||
if keep[uint16(gid)] {
|
||||
chars[gid] = program
|
||||
continue
|
||||
}
|
||||
fd := 0
|
||||
if selects != nil {
|
||||
fd = selects[gid]
|
||||
}
|
||||
font := fonts[fd]
|
||||
steps := 0
|
||||
state := type2State{font: font, args: stack[:0], width: font.def, widthOnly: true, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep}
|
||||
if _, err := state.run(program, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if encoded, ok := empty[fd][state.width]; ok {
|
||||
chars[gid] = encoded
|
||||
continue
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if state.width != font.def {
|
||||
value := state.width - font.nominal
|
||||
divisor := 1
|
||||
if value < -32768 || value >= 32768 {
|
||||
divisor = 2
|
||||
}
|
||||
for range divisor {
|
||||
if err := encodeType2Number(&encoded, value/float64(divisor)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if parseNumberType2(encoded.Bytes(), 0)*float64(divisor) != value {
|
||||
return nil, fmt.Errorf("CFF width cannot be represented exactly")
|
||||
}
|
||||
if divisor == 2 {
|
||||
encoded.Write([]byte{12, 10})
|
||||
}
|
||||
}
|
||||
encoded.WriteByte(14)
|
||||
chars[gid] = encoded.Bytes()
|
||||
empty[fd][state.width] = chars[gid]
|
||||
}
|
||||
globalPrograms := subsetCFFSubroutines(fonts[0].globals, globals)
|
||||
for index, font := range fonts {
|
||||
font.locals = subsetCFFSubroutines(font.locals, locals[index])
|
||||
font.globals = globalPrograms
|
||||
}
|
||||
return chars, nil
|
||||
}
|
||||
|
||||
// subsetCFFSubroutines 将未引用的子程序替换为空返回,保持调用编号及偏移基数
|
||||
// 入参: programs 子程序索引, used 引用标记
|
||||
// 返回: [][]byte 裁剪后的子程序索引
|
||||
func subsetCFFSubroutines(programs [][]byte, used []bool) [][]byte {
|
||||
result := make([][]byte, len(programs))
|
||||
empty := []byte{11}
|
||||
for index, program := range programs {
|
||||
if used[index] {
|
||||
result[index] = program
|
||||
} else {
|
||||
result[index] = empty
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// rebuildNamedCFF 重建非CID的CFF偏移,保留字符集、编码和私有提示参数
|
||||
// 入参: data 原始CFF数据, font 执行资源, chars 替换后的字形程序, ids 字形重排顺序,nil时保留原编号
|
||||
// 返回: []byte 重建的CFF数据, error 不支持的结构
|
||||
func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte, ids []uint16) ([]byte, error) {
|
||||
names, end, err := readType2Index(data, int(data[2]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tops, end, err := readType2Index(data, end)
|
||||
if err != nil || len(tops) != 1 {
|
||||
return nil, fmt.Errorf("invalid CFF top dictionary")
|
||||
}
|
||||
strings, _, err := readType2Index(data, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dict, err := readCFFDict(tops[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for op := range dict {
|
||||
switch op {
|
||||
case 0, 1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1222, 1223:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported CFF top operator %d", op)
|
||||
}
|
||||
}
|
||||
sids, _, _, _, ok := getCFFCharsetInfo(data, len(font.chars))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid CFF charset")
|
||||
}
|
||||
encoding, err := cffSubsetEncoding(data, dict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ids != nil {
|
||||
if encoding != nil {
|
||||
codes, err := readCFFSubsetEncoding(encoding, sids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoding, err = encodeCFFSubsetEncoding(codes, sids, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
selected := make([]int, len(ids))
|
||||
for index, gid := range ids {
|
||||
selected[index] = sids[gid]
|
||||
}
|
||||
sids = selected
|
||||
}
|
||||
charset := encodeCFFSubsetCharset(sids)
|
||||
private, err := readType2Private(data, dict[18], &type2Font{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeCFFPrivate(private); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privateData, err := encodeCFFSubsetPrivate(private)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := bytes.Clone(data[:int(data[2])])
|
||||
prefix = append(prefix, encodeCFFIndex(names)...)
|
||||
suffix := encodeCFFIndex(strings)
|
||||
suffix = append(suffix, encodeCFFIndex(font.globals)...)
|
||||
encodedChars := encodeCFFIndex(chars)
|
||||
var top []byte
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
top = encodeCFFIndex([][]byte{encodeCFFDict(dict)})
|
||||
offset := len(prefix) + len(top) + len(suffix)
|
||||
updated := maps.Clone(dict)
|
||||
updated[15] = []float64{float64(offset)}
|
||||
offset += len(charset)
|
||||
if encoding != nil {
|
||||
updated[16] = []float64{float64(offset)}
|
||||
}
|
||||
offset += len(encoding)
|
||||
updated[17] = []float64{float64(offset)}
|
||||
updated[18] = []float64{float64(len(privateData)), float64(offset + len(encodedChars))}
|
||||
encoded := encodeCFFIndex([][]byte{encodeCFFDict(updated)})
|
||||
if len(encoded) == len(top) {
|
||||
result := prefix
|
||||
for _, part := range [][]byte{encoded, suffix, charset, encoding, encodedChars, privateData, encodeCFFIndex(font.locals)} {
|
||||
result = append(result, part...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
dict = updated
|
||||
}
|
||||
return nil, fmt.Errorf("CFF dictionary offsets did not converge")
|
||||
}
|
||||
|
||||
// encodeCFFSubsetPrivate 保留私有字典参数,将局部子程序安排在字典之后
|
||||
// 入参: private 私有字典
|
||||
// 返回: []byte 字典数据, error 不支持的参数或偏移错误
|
||||
func encodeCFFSubsetPrivate(private cffDict) ([]byte, error) {
|
||||
for op := range private {
|
||||
switch op {
|
||||
case 6, 7, 8, 9, 10, 11, 19, 20, 21, 1209, 1210, 1211, 1212, 1213, 1214, 1217, 1218, 1219:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported CFF private operator %d", op)
|
||||
}
|
||||
}
|
||||
for range 16 {
|
||||
data := encodeCFFDict(private)
|
||||
value, ok := private[19]
|
||||
if !ok || len(value) == 1 && value[0] == float64(len(data)) {
|
||||
return data, nil
|
||||
}
|
||||
private[19] = []float64{float64(len(data))}
|
||||
}
|
||||
return nil, fmt.Errorf("CFF private offset did not converge")
|
||||
}
|
||||
|
||||
// cffSubsetEncoding 读取自定义CFF编码及补充映射,预定义编码无需复制
|
||||
// 入参: data CFF数据, dict 顶层字典
|
||||
// 返回: []byte 编码数据, error 编码格式错误
|
||||
func cffSubsetEncoding(data []byte, dict cffDict) ([]byte, error) {
|
||||
values := dict[16]
|
||||
if values == nil || len(values) == 1 && (values[0] == 0 || values[0] == 1) {
|
||||
return nil, nil
|
||||
}
|
||||
start, err := cffDictOffset(data, dict, 16)
|
||||
if err != nil || start > len(data)-2 {
|
||||
return nil, fmt.Errorf("invalid CFF encoding offset")
|
||||
}
|
||||
end := start + 2
|
||||
switch data[start] & 0x7f {
|
||||
case 0:
|
||||
end += int(data[start+1])
|
||||
case 1:
|
||||
end += 2 * int(data[start+1])
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid CFF encoding format")
|
||||
}
|
||||
if data[start]&0x80 != 0 {
|
||||
if end >= len(data) {
|
||||
return nil, fmt.Errorf("truncated CFF encoding supplements")
|
||||
}
|
||||
end += 1 + 3*int(data[end])
|
||||
}
|
||||
if end > len(data) {
|
||||
return nil, fmt.Errorf("truncated CFF encoding")
|
||||
}
|
||||
return data[start:end], nil
|
||||
}
|
||||
@@ -12,9 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// cffCharstringType 读取CFF字形程序类型,缺省为Type2
|
||||
// 入参: dict 顶层字典
|
||||
@@ -32,13 +34,13 @@ func cffCharstringType(dict cffDict) (int, error) {
|
||||
|
||||
// readCFFType1Program 读取Type1字形及无偏置局部子程序,保留源字形顺序
|
||||
// 入参: data CFF字体, chars 字形程序, private 私有字典范围, cid 是否为CID字体
|
||||
// 返回: *type1Program 执行资源, error 字符集或私有字典错误
|
||||
func readCFFType1Program(data []byte, chars [][]byte, private []float64, cid bool) (*type1Program, error) {
|
||||
// 返回: *Type1Program 执行资源, error 字符集或私有字典错误
|
||||
func readCFFType1Program(data []byte, chars [][]byte, private []float64, cid bool) (*Type1Program, error) {
|
||||
resources := &type2Font{}
|
||||
if _, err := readType2Private(data, private, resources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
program := &type1Program{subrs: resources.locals, lenIV: -1}
|
||||
program := &Type1Program{subrs: resources.locals, lenIV: -1}
|
||||
if cid {
|
||||
return program, nil
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -31,10 +31,10 @@ type cmapSegment struct {
|
||||
offset uint16
|
||||
}
|
||||
|
||||
// normalizeCmap 转换扩展字符子表,保留各编码记录及原字形编号
|
||||
// NormalizeCmap 转换扩展字符子表,保留各编码记录及原字形编号
|
||||
// 入参: data 字符映射表
|
||||
// 返回: []byte 等价字符映射表, error 子表错误
|
||||
func normalizeCmap(data []byte) ([]byte, error) {
|
||||
func NormalizeCmap(data []byte) ([]byte, error) {
|
||||
if len(data) < 4 {
|
||||
return data, nil
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func normalizeCmap(data []byte) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table := buildCmapTable(0, mapping)
|
||||
table := BuildCmapTable(0, mapping)
|
||||
if language > 65535 {
|
||||
table = buildCmapTableFormat12(0, mapping)
|
||||
}
|
||||
@@ -255,17 +255,17 @@ func parseCmapFormat2(data []byte) (map[rune]uint16, error) {
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// packedGlyphRune 获取包装字体字符
|
||||
// PackedGlyphRune 获取包装字体字符
|
||||
// 入参: gid 字形ID
|
||||
// 返回: rune 包装字体字符
|
||||
func packedGlyphRune(gid uint16) rune {
|
||||
func PackedGlyphRune(gid uint16) rune {
|
||||
return 0xF0000 + rune(gid)
|
||||
}
|
||||
|
||||
// parseCmapMappings 解析cmap字符映射
|
||||
// ParseCmapMappings 解析cmap字符映射
|
||||
// 入参: data cmap表数据
|
||||
// 返回: map[rune]uint16 字符到字形映射
|
||||
func parseCmapMappings(data []byte) map[rune]uint16 {
|
||||
func ParseCmapMappings(data []byte) map[rune]uint16 {
|
||||
if len(data) < 4 {
|
||||
return nil
|
||||
}
|
||||
@@ -427,18 +427,18 @@ func parseCmapFormat12(data []byte, result map[rune]uint16) {
|
||||
}
|
||||
}
|
||||
|
||||
// addPackedGlyphMapping 添加字形ID私有映射
|
||||
// AddPackedGlyphMapping 添加字形ID私有映射
|
||||
// 入参: mapping 字符映射, numGlyphs 字形数量
|
||||
func addPackedGlyphMapping(mapping map[rune]uint16, numGlyphs uint16) {
|
||||
func AddPackedGlyphMapping(mapping map[rune]uint16, numGlyphs uint16) {
|
||||
for i := uint16(0); i < numGlyphs; i++ {
|
||||
mapping[packedGlyphRune(i)] = i
|
||||
mapping[PackedGlyphRune(i)] = i
|
||||
}
|
||||
}
|
||||
|
||||
// buildCmapTable 构建格式4或12字符映射,省略缺字映射
|
||||
// BuildCmapTable 构建格式4或12字符映射,省略缺字映射
|
||||
// 入参: numGlyphs 字形数量, mapping 字符映射
|
||||
// 返回: []byte cmap表数据
|
||||
func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
|
||||
func BuildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
|
||||
if shouldBuildCmapFormat12(mapping) {
|
||||
return buildCmapTableFormat12(numGlyphs, mapping)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"maps"
|
||||
"slices"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-text/typesetting/font/opentype/tables"
|
||||
)
|
||||
|
||||
// subsetCmapVariations 裁剪Unicode变体映射,将非默认变体的字形加入保留集合
|
||||
// 入参: data 字符映射表, chars 使用字符, glyphs 保留字形, count 字形总数
|
||||
// 返回: []byte 格式14子表,无变体时为空, bool 映射是否有效
|
||||
func subsetCmapVariations(data []byte, chars map[rune]bool, glyphs map[uint16]bool, count uint16) ([]byte, bool) {
|
||||
if len(data) < 4 {
|
||||
return nil, false
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(data[2:]))
|
||||
if n > (len(data)-4)/8 {
|
||||
return nil, false
|
||||
}
|
||||
var source []byte
|
||||
for i := 0; i < n; i++ {
|
||||
record := data[4+i*8:]
|
||||
offset := uint64(binary.BigEndian.Uint32(record[4:]))
|
||||
if offset < uint64(4+n*8) || offset+2 > uint64(len(data)) {
|
||||
return nil, false
|
||||
}
|
||||
if binary.BigEndian.Uint16(data[offset:]) != 14 {
|
||||
continue
|
||||
}
|
||||
if source != nil || binary.BigEndian.Uint16(record) != 0 || binary.BigEndian.Uint16(record[2:]) != 5 || offset+10 > uint64(len(data)) {
|
||||
return nil, false
|
||||
}
|
||||
length := uint64(binary.BigEndian.Uint32(data[offset+2:]))
|
||||
if length < 10 || offset+length > uint64(len(data)) {
|
||||
return nil, false
|
||||
}
|
||||
source = data[offset : offset+length]
|
||||
}
|
||||
if source == nil {
|
||||
return nil, true
|
||||
}
|
||||
return subsetCmapVariationTable(source, chars, glyphs, count)
|
||||
}
|
||||
|
||||
// subsetCmapVariationTable 按使用字符筛选默认及非默认变体,保持选择符和映射有序
|
||||
// 入参: data 格式14子表, chars 使用字符, glyphs 保留字形, count 字形总数
|
||||
// 返回: []byte 裁剪后的子表, bool 子表是否有效
|
||||
func subsetCmapVariationTable(data []byte, chars map[rune]bool, glyphs map[uint16]bool, count uint16) ([]byte, bool) {
|
||||
n := uint64(binary.BigEndian.Uint32(data[6:]))
|
||||
header := 10 + n*11
|
||||
if header > uint64(len(data)) {
|
||||
return nil, false
|
||||
}
|
||||
used := slices.Sorted(maps.Keys(chars))
|
||||
var records, payload []byte
|
||||
previous := rune(-1)
|
||||
for i := uint64(0); i < n; i++ {
|
||||
raw := data[10+i*11:]
|
||||
for j, size := range []uint64{4, 5} {
|
||||
offset := uint64(binary.BigEndian.Uint32(raw[3+j*4:]))
|
||||
if offset == 0 {
|
||||
continue
|
||||
}
|
||||
if offset < header || offset+4 > uint64(len(data)) {
|
||||
return nil, false
|
||||
}
|
||||
entries := uint64(binary.BigEndian.Uint32(data[offset:]))
|
||||
if offset+4+entries*size > uint64(len(data)) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
record, _, err := tables.ParseVariationSelector(raw, data)
|
||||
selector := cmapVariationRune(record.VarSelector)
|
||||
if err != nil || !utf8.ValidRune(selector) || selector <= previous || !validCmapVariation(record, count) {
|
||||
return nil, false
|
||||
}
|
||||
previous = selector
|
||||
if !chars[selector] {
|
||||
continue
|
||||
}
|
||||
defaults := subsetCmapDefaultVariations(record.DefaultUVS.Ranges, used)
|
||||
var alternatives []byte
|
||||
for _, entry := range record.NonDefaultUVS.Ranges {
|
||||
if chars[cmapVariationRune(entry.UnicodeValue)] {
|
||||
alternatives = append(alternatives, entry.UnicodeValue[:]...)
|
||||
alternatives = binary.BigEndian.AppendUint16(alternatives, uint16(entry.GlyphID))
|
||||
glyphs[uint16(entry.GlyphID)] = true
|
||||
}
|
||||
}
|
||||
if len(defaults)+len(alternatives) == 0 {
|
||||
continue
|
||||
}
|
||||
start := len(records)
|
||||
records = append(records, record.VarSelector[:]...)
|
||||
records = append(records, make([]byte, 8)...)
|
||||
for j, entries := range [][]byte{defaults, alternatives} {
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
binary.BigEndian.PutUint32(records[start+3+j*4:], uint32(len(payload)+1))
|
||||
payload = binary.BigEndian.AppendUint32(payload, uint32(len(entries)/(4+j)))
|
||||
payload = append(payload, entries...)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
if uint64(len(records))+uint64(len(payload))+10 > 1<<32-1 {
|
||||
return nil, false
|
||||
}
|
||||
for i := 0; i < len(records); i += 11 {
|
||||
for _, j := range []int{3, 7} {
|
||||
offset := binary.BigEndian.Uint32(records[i+j:])
|
||||
if offset != 0 {
|
||||
binary.BigEndian.PutUint32(records[i+j:], offset-1+uint32(10+len(records)))
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]byte, 10, 10+len(records)+len(payload))
|
||||
binary.BigEndian.PutUint16(out, 14)
|
||||
binary.BigEndian.PutUint32(out[2:], uint32(cap(out)))
|
||||
binary.BigEndian.PutUint32(out[6:], uint32(len(records)/11))
|
||||
out = append(out, records...)
|
||||
return append(out, payload...), true
|
||||
}
|
||||
|
||||
// validCmapVariation 检查变体映射的字符范围、排序、互斥关系及字形编号
|
||||
// 入参: record 选择符记录, count 字形总数
|
||||
// 返回: bool 映射是否有效
|
||||
func validCmapVariation(record tables.VariationSelector, count uint16) bool {
|
||||
defaults := record.DefaultUVS.Ranges
|
||||
previous := rune(-1)
|
||||
for _, entry := range defaults {
|
||||
first := cmapVariationRune(entry.StartUnicodeValue)
|
||||
last := first + rune(entry.AdditionalCount)
|
||||
if first <= previous || !utf8.ValidRune(first) || !utf8.ValidRune(last) || first < 0xd800 && last >= 0xd800 {
|
||||
return false
|
||||
}
|
||||
previous = last
|
||||
}
|
||||
previous = -1
|
||||
index := 0
|
||||
for _, entry := range record.NonDefaultUVS.Ranges {
|
||||
char := cmapVariationRune(entry.UnicodeValue)
|
||||
if char <= previous || !utf8.ValidRune(char) || uint16(entry.GlyphID) >= count {
|
||||
return false
|
||||
}
|
||||
previous = char
|
||||
for index < len(defaults) && cmapVariationRune(defaults[index].StartUnicodeValue)+rune(defaults[index].AdditionalCount) < char {
|
||||
index++
|
||||
}
|
||||
if index < len(defaults) && cmapVariationRune(defaults[index].StartUnicodeValue) <= char {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// subsetCmapDefaultVariations 筛选默认变体的使用字符,将连续字符合并为格式14范围
|
||||
// 入参: ranges 默认变体范围, chars 已排序的使用字符
|
||||
// 返回: []byte 范围记录,不含数量字段
|
||||
func subsetCmapDefaultVariations(ranges []tables.UnicodeRange, chars []rune) []byte {
|
||||
var out []byte
|
||||
index := 0
|
||||
last := rune(-2)
|
||||
for _, char := range chars {
|
||||
for index < len(ranges) && cmapVariationRune(ranges[index].StartUnicodeValue)+rune(ranges[index].AdditionalCount) < char {
|
||||
index++
|
||||
}
|
||||
if index == len(ranges) {
|
||||
break
|
||||
}
|
||||
if cmapVariationRune(ranges[index].StartUnicodeValue) > char {
|
||||
continue
|
||||
}
|
||||
if char == last+1 && out[len(out)-1] < 255 {
|
||||
out[len(out)-1]++
|
||||
} else {
|
||||
out = append(out, byte(char>>16), byte(char>>8), byte(char), 0)
|
||||
}
|
||||
last = char
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cmapVariationRune 读取变体映射中的24位Unicode码点
|
||||
// 入参: value 三字节码点
|
||||
// 返回: rune Unicode码点
|
||||
func cmapVariationRune(value [3]byte) rune {
|
||||
return rune(value[0])<<16 | rune(value[1])<<8 | rune(value[2])
|
||||
}
|
||||
|
||||
// buildCmapWithVariations 在生成的Unicode映射中插入格式14记录,保持编码记录有序
|
||||
// 入参: count 字形总数, mapping 字符映射, variations 格式14子表
|
||||
// 返回: []byte 字符映射表
|
||||
func buildCmapWithVariations(count uint16, mapping map[rune]uint16, variations []byte) []byte {
|
||||
base := BuildCmapTable(count, mapping)
|
||||
if len(variations) == 0 {
|
||||
return base
|
||||
}
|
||||
payload := base[binary.BigEndian.Uint32(base[8:]):]
|
||||
out := make([]byte, 28, 28+len(payload)+len(variations))
|
||||
binary.BigEndian.PutUint16(out[2:], 3)
|
||||
encoding := uint16(3)
|
||||
if binary.BigEndian.Uint16(payload) == 12 {
|
||||
encoding = 4
|
||||
}
|
||||
binary.BigEndian.PutUint16(out[6:], encoding)
|
||||
binary.BigEndian.PutUint32(out[8:], 28)
|
||||
binary.BigEndian.PutUint16(out[14:], 5)
|
||||
binary.BigEndian.PutUint32(out[16:], uint32(28+len(payload)))
|
||||
last := 4 + 8*(int(binary.BigEndian.Uint16(base[2:]))-1)
|
||||
copy(out[20:28], base[last:last+8])
|
||||
binary.BigEndian.PutUint32(out[24:], 28)
|
||||
out = append(out, payload...)
|
||||
return append(out, variations...)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
|
||||
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
|
||||
type Face struct {
|
||||
Index int `json:"index"`
|
||||
FullName string `json:"fullName"`
|
||||
Family string `json:"family"`
|
||||
Style string `json:"style"`
|
||||
PostScriptName string `json:"postscriptName"`
|
||||
Names []string `json:"names"`
|
||||
MenuFamily string `json:"menuFamily"`
|
||||
MenuStyle string `json:"menuStyle"`
|
||||
FamilyKey string `json:"familyKey"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
}
|
||||
|
||||
// FaceInfo 从名称表读取字体信息
|
||||
// 入参: data 名称表数据, index 零起始字体索引
|
||||
// 返回: Face 字体信息
|
||||
func FaceInfo(data []byte, index int) Face {
|
||||
return faceInfoLanguage(data, index, 0xffff, 0xffff)
|
||||
}
|
||||
|
||||
// faceInfoLanguage 优先读取指定平台和语言的显示名称,保留标识及全部别名
|
||||
// 入参: data 名称表数据, index 字体索引, platform 平台标识, language 语言标识
|
||||
// 返回: Face 字体信息
|
||||
func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
|
||||
records, values := fontNameValues(data)
|
||||
name := func(ids ...uint16) string {
|
||||
for _, id := range ids {
|
||||
for _, record := range records {
|
||||
if record.Name == id {
|
||||
if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
localized := func(ids ...uint16) string {
|
||||
for _, id := range ids {
|
||||
for _, record := range records {
|
||||
if record.Platform == platform && record.Language == language && record.Name == id {
|
||||
if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name(ids...)
|
||||
}
|
||||
key := ""
|
||||
if family, version := name(1), name(5); family != "" && version != "" {
|
||||
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
|
||||
}
|
||||
family, style := localized(1), localized(2)
|
||||
if family == "" {
|
||||
family = localized(16, 4, 6)
|
||||
}
|
||||
if style == "" {
|
||||
style = localized(17)
|
||||
}
|
||||
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
|
||||
}
|
||||
|
||||
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
|
||||
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
|
||||
// 返回: Face 字体信息
|
||||
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
|
||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
||||
key := face.FamilyKey
|
||||
if head := tables["head"]; len(head) >= 46 {
|
||||
key += string(head[4:8])
|
||||
style := binary.BigEndian.Uint16(head[44:])
|
||||
face.Bold, face.Italic = style&1 != 0, style&2 != 0
|
||||
}
|
||||
if os2 := tables["OS/2"]; len(os2) >= 64 {
|
||||
key += string(os2[58:62])
|
||||
style := binary.BigEndian.Uint16(os2[62:])
|
||||
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
|
||||
}
|
||||
if face.FamilyKey != "" {
|
||||
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
|
||||
}
|
||||
return face
|
||||
}
|
||||
|
||||
// FileCount 校验字体或集合头并读取字体数量
|
||||
// 入参: data OpenType数据
|
||||
// 返回: int 字体数量, error 格式错误
|
||||
func FileCount(data []byte) (int, error) {
|
||||
if len(data) < 12 {
|
||||
return 0, fmt.Errorf("invalid font header")
|
||||
}
|
||||
switch string(data[:4]) {
|
||||
case "\x00\x01\x00\x00", "OTTO", "true":
|
||||
return 1, nil
|
||||
case "ttcf":
|
||||
version := binary.BigEndian.Uint32(data[4:])
|
||||
count := uint64(binary.BigEndian.Uint32(data[8:]))
|
||||
if (version != 0x00010000 && version != 0x00020000) || count == 0 || count > uint64(len(data)-12)/4 {
|
||||
return 0, fmt.Errorf("invalid font collection header")
|
||||
}
|
||||
return int(count), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid OpenType header")
|
||||
}
|
||||
}
|
||||
|
||||
// ReadFaces 读取已解封装字体或集合的名称信息
|
||||
// 入参: data OpenType数据
|
||||
// 返回: []Face 字体列表, error 格式或名称表错误
|
||||
func ReadFaces(data []byte) ([]Face, error) {
|
||||
return ReadLocalizedFaces(data, 0xffff, 0xffff)
|
||||
}
|
||||
|
||||
// ReadLocalizedFaces 读取指定平台和语言的字体名称,缺失时沿用名称表顺序
|
||||
// 入参: data OpenType数据, platform 名称表平台标识, language 名称表语言标识
|
||||
// 返回: []Face 字体列表, error 格式或名称表错误
|
||||
func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error) {
|
||||
count, err := FileCount(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
faces := make([]Face, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
tables, err := FileTables(data, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
face := TableFaceInfo(tables, index, platform, language)
|
||||
if face.FullName == "" {
|
||||
return nil, fmt.Errorf("font %d has no usable name", index)
|
||||
}
|
||||
faces = append(faces, face)
|
||||
}
|
||||
return faces, nil
|
||||
}
|
||||
|
||||
// ExtractFace 提取已解封装字体中的集合项,规范字符映射并保留原始表
|
||||
// 入参: data OpenType数据, index 零起始字体索引
|
||||
// 返回: []byte 独立字体数据, error 格式或索引错误
|
||||
func ExtractFace(data []byte, index int) ([]byte, error) {
|
||||
var err error
|
||||
if bytes.HasPrefix(data, []byte("ttcf")) {
|
||||
data, err = ExtractCollectionFont(data, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = 0
|
||||
}
|
||||
tables, err := FileTables(data, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmap, err := NormalizeCmap(tables["cmap"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bytes.Equal(cmap, tables["cmap"]) {
|
||||
tables["cmap"] = cmap
|
||||
return SerializeOTF(tables)
|
||||
}
|
||||
return bytes.Clone(data), nil
|
||||
}
|
||||
|
||||
// MissingGlyphs 检查独立字体的字符覆盖,忽略段落换行并按首次出现顺序去重
|
||||
// 入参: data 独立OpenType数据, text 待检查文字
|
||||
// 返回: string 缺失字符, error 字体或文字格式错误
|
||||
func MissingGlyphs(data []byte, text string) (string, error) {
|
||||
if !utf8.ValidString(text) {
|
||||
return "", fmt.Errorf("text must be UTF-8")
|
||||
}
|
||||
data, err := DefaultCFF2Font(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sfnt, err := ParseSFNT(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
seen := make(map[rune]bool)
|
||||
var missing strings.Builder
|
||||
for _, char := range text {
|
||||
if char == '\r' || char == '\n' || seen[char] {
|
||||
continue
|
||||
}
|
||||
seen[char] = true
|
||||
if sfnt.GlyphIndex(char) == 0 {
|
||||
missing.WriteRune(char)
|
||||
}
|
||||
}
|
||||
return missing.String(), nil
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -21,12 +21,16 @@ import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"github.com/tdewolff/font"
|
||||
"github.com/go-text/typesetting/font/cff"
|
||||
"github.com/go-text/typesetting/font/opentype"
|
||||
)
|
||||
|
||||
// sfntOutliner 缓存字体程序并提取独立字形轮廓
|
||||
type sfntOutliner struct {
|
||||
font *font.SFNT
|
||||
// Outliner 缓存字体程序并提取独立字形轮廓
|
||||
type Outliner struct {
|
||||
Font *SFNT
|
||||
cffOnce sync.Once
|
||||
cff *cff.CFF
|
||||
cffErr error
|
||||
once sync.Once
|
||||
program *ttInterpreter
|
||||
err error
|
||||
@@ -42,12 +46,52 @@ type ttGlyph struct {
|
||||
warning error
|
||||
}
|
||||
|
||||
// path 按TrueType指令和隐含点规则生成闭合轮廓,CFF保留原有解析
|
||||
// Path 按字体设计坐标提取闭合轮廓,TrueType保留字形指令处理
|
||||
// 入参: path 路径接收器, glyph 字形编号, scale 字体单位缩放
|
||||
// 返回: error 字形解析错误
|
||||
func (f *sfntOutliner) path(path font.Pather, glyph uint16, scale float64) error {
|
||||
if !f.font.IsTrueType {
|
||||
return f.font.GlyphPath(path, glyph, 0, 0, 0, scale, font.NoHinting)
|
||||
func (f *Outliner) Path(path glyphPather, glyph uint16, scale float64) error {
|
||||
type point struct{ X, Y float64 }
|
||||
if !f.Font.IsTrueType {
|
||||
f.cffOnce.Do(func() {
|
||||
data, err := SanitizeCFF(f.Font.Tables["CFF "])
|
||||
if err == nil {
|
||||
data, err = NormalizeCFFCharstringsAt(data, f.Font.UnitsPerEm())
|
||||
}
|
||||
if err != nil {
|
||||
f.cffErr = err
|
||||
return
|
||||
}
|
||||
f.cff, f.cffErr = cff.Parse(data)
|
||||
})
|
||||
if f.cffErr != nil {
|
||||
return f.cffErr
|
||||
}
|
||||
segments, _, err := f.cff.LoadGlyph(glyph)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open := false
|
||||
for _, segment := range segments {
|
||||
p := segment.Args
|
||||
switch segment.Op {
|
||||
case opentype.SegmentOpMoveTo:
|
||||
if open {
|
||||
path.Close()
|
||||
}
|
||||
path.MoveTo(float64(p[0].X)*scale, float64(p[0].Y)*scale)
|
||||
open = true
|
||||
case opentype.SegmentOpLineTo:
|
||||
path.LineTo(float64(p[0].X)*scale, float64(p[0].Y)*scale)
|
||||
case opentype.SegmentOpCubeTo:
|
||||
path.CubeTo(float64(p[0].X)*scale, float64(p[0].Y)*scale, float64(p[1].X)*scale, float64(p[1].Y)*scale, float64(p[2].X)*scale, float64(p[2].Y)*scale)
|
||||
default:
|
||||
return fmt.Errorf("invalid CFF curve")
|
||||
}
|
||||
}
|
||||
if open {
|
||||
path.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
contour, err := f.glyph(glyph, make(map[uint16]bool))
|
||||
if err != nil {
|
||||
@@ -64,23 +108,23 @@ func (f *sfntOutliner) path(path font.Pather, glyph uint16, scale float64) error
|
||||
first = last + 1
|
||||
continue
|
||||
}
|
||||
point := func(i int) Point {
|
||||
return Point{X: float64(points[i].x) * scale / 64, Y: float64(points[i].y) * scale / 64}
|
||||
coordinate := func(i int) point {
|
||||
return point{X: float64(points[i].x) * scale / 64, Y: float64(points[i].y) * scale / 64}
|
||||
}
|
||||
start, end := point(first), point(last)
|
||||
start, end := coordinate(first), coordinate(last)
|
||||
begin, stop := first, last
|
||||
if points[first].on {
|
||||
begin++
|
||||
} else if points[last].on {
|
||||
start, stop = end, last-1
|
||||
} else {
|
||||
start = Point{X: (start.X + end.X) / 2, Y: (start.Y + end.Y) / 2}
|
||||
start = point{X: (start.X + end.X) / 2, Y: (start.Y + end.Y) / 2}
|
||||
}
|
||||
path.MoveTo(start.X, start.Y)
|
||||
var control Point
|
||||
var control point
|
||||
pending := false
|
||||
for i := begin; i <= stop; i++ {
|
||||
p := point(i)
|
||||
p := coordinate(i)
|
||||
if points[i].on {
|
||||
if pending {
|
||||
path.QuadTo(control.X, control.Y, p.X, p.Y)
|
||||
@@ -107,14 +151,14 @@ func (f *sfntOutliner) path(path font.Pather, glyph uint16, scale float64) error
|
||||
// glyph 解码简单或复合字形并执行对应字节码,不改写字体资源
|
||||
// 入参: id 字形编号, active 递归访问集合
|
||||
// 返回: ttGlyph 字形轮廓, error 字形错误
|
||||
func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error) {
|
||||
func (f *Outliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error) {
|
||||
var result ttGlyph
|
||||
if id >= f.font.NumGlyphs() || active[id] {
|
||||
if id >= f.Font.NumGlyphs() || active[id] {
|
||||
return result, fmt.Errorf("invalid TrueType component reference %d", id)
|
||||
}
|
||||
active[id] = true
|
||||
defer delete(active, id)
|
||||
data, err := trueTypeGlyphData(f.font.Tables, id)
|
||||
data, err := trueTypeGlyphData(f.Font.Tables, id)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -146,14 +190,14 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
|
||||
if len(program) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
outlineOnly, err := ttOutlineOnly(f.font.Tables, program)
|
||||
outlineOnly, err := ttOutlineOnly(f.Font.Tables, program)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if outlineOnly {
|
||||
return result, nil
|
||||
}
|
||||
f.once.Do(func() { f.program, f.err = newTTInterpreter(f.font.Tables, f.font.UnitsPerEm()) })
|
||||
f.once.Do(func() { f.program, f.err = newTTInterpreter(f.Font.Tables, f.Font.UnitsPerEm()) })
|
||||
if f.err != nil {
|
||||
if ttRecoverableHint(f.err) {
|
||||
result.warning = f.err
|
||||
@@ -182,16 +226,16 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
|
||||
// phantomPoints 按水平和垂直度量生成字形末尾的四个虚拟点
|
||||
// 入参: id 字形编号, data 完整字形头
|
||||
// 返回: [4]ttPoint 左右边距与上下原点
|
||||
func (f *sfntOutliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
|
||||
left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.font.Hmtx.LeftSideBearing(id))
|
||||
func (f *Outliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
|
||||
left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.Font.sideBearing(id, false))
|
||||
top := int32(int16(binary.BigEndian.Uint16(data[8:])))
|
||||
if f.font.Vmtx != nil {
|
||||
top += int32(f.font.Vmtx.TopSideBearing(id))
|
||||
if f.Font.vertical != 0 {
|
||||
top += int32(f.Font.sideBearing(id, true))
|
||||
}
|
||||
return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.font.GlyphAdvance(id))) * 64, ox: (left + int32(f.font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64}}
|
||||
return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.Font.GlyphAdvance(id))) * 64, ox: (left + int32(f.Font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64}}
|
||||
}
|
||||
|
||||
// ttRecoverableHint 判断栈参数、指令引用或控制流错误是否允许保留执行前轮廓
|
||||
// ttRecoverableHint 判断栈参数、区点轮廓及函数引用或控制流错误是否允许保留执行前轮廓
|
||||
// 入参: err 指令错误
|
||||
// 返回: bool 是否可恢复
|
||||
func ttRecoverableHint(err error) bool {
|
||||
@@ -231,7 +275,7 @@ func trueTypeGlyphData(tables map[string][]byte, id uint16) ([]byte, error) {
|
||||
|
||||
// recordGlyphWarning 记录字形恢复提示,允许并发提取轮廓
|
||||
// 入参: id 字形编号, warning 恢复原因
|
||||
func (f *sfntOutliner) recordGlyphWarning(id uint16, warning error) {
|
||||
func (f *Outliner) recordGlyphWarning(id uint16, warning error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.warnings == nil {
|
||||
@@ -240,10 +284,10 @@ func (f *sfntOutliner) recordGlyphWarning(id uint16, warning error) {
|
||||
f.warnings[id] = warning
|
||||
}
|
||||
|
||||
// glyphWarning 返回字形恢复为执行前轮廓的原因
|
||||
// GlyphWarning 返回字形恢复为执行前轮廓的原因
|
||||
// 入参: id 字形编号
|
||||
// 返回: error 恢复原因,无恢复时为空
|
||||
func (f *sfntOutliner) glyphWarning(id uint16) error {
|
||||
func (f *Outliner) GlyphWarning(id uint16) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.warnings[id]
|
||||
@@ -339,7 +383,7 @@ func readTTSimple(data []byte, count int) (ttGlyph, []byte, error) {
|
||||
// composite 合成分量变换和点匹配,保留分量顺序及父级字节码
|
||||
// 入参: glyph 父字形编号, data 复合字形数据, phantoms 父字形虚拟点, active 递归访问集合
|
||||
// 返回: ttGlyph 字形轮廓, []byte 字节码, error 数据错误
|
||||
func (f *sfntOutliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint, active map[uint16]bool) (ttGlyph, []byte, error) {
|
||||
func (f *Outliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint, active map[uint16]bool) (ttGlyph, []byte, error) {
|
||||
var result ttGlyph
|
||||
pos := 10
|
||||
total := -1
|
||||
@@ -432,7 +476,7 @@ func (f *sfntOutliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint,
|
||||
} else {
|
||||
if total < 0 {
|
||||
var err error
|
||||
total, err = trueTypePointCount(f.font.Tables, glyph, map[uint16]int{}, map[uint16]bool{})
|
||||
total, err = trueTypePointCount(f.Font.Tables, glyph, map[uint16]int{}, map[uint16]bool{})
|
||||
if err != nil {
|
||||
return result, nil, err
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// errTTStackUnderflow 表示字形程序缺少必需的栈参数
|
||||
var errTTStackUnderflow = errors.New("TrueType instruction stack underflow")
|
||||
|
||||
// errTTInvalidReference 表示字形指令引用不存在的点或函数
|
||||
// errTTInvalidReference 表示字形指令引用不存在的区、点、轮廓或函数
|
||||
var errTTInvalidReference = errors.New("invalid TrueType instruction reference")
|
||||
|
||||
// errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围
|
||||
@@ -380,7 +380,8 @@ func (v *ttInterpreter) execute(code []byte, depth int) error {
|
||||
case op >= 0x13 && op <= 0x16:
|
||||
z := v.pop()
|
||||
if z < 0 || z > 1 {
|
||||
return fmt.Errorf("invalid TrueType zone")
|
||||
v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, z)
|
||||
break
|
||||
}
|
||||
if op == 0x16 {
|
||||
g.zone = [3]int32{z, z, z}
|
||||
@@ -854,7 +855,7 @@ func (v *ttInterpreter) shift(op byte) {
|
||||
if op <= 0x35 {
|
||||
contour := v.pop()
|
||||
if contour < 0 || int64(contour) >= int64(len(v.ends)) {
|
||||
v.err = fmt.Errorf("invalid TrueType contour reference")
|
||||
v.err = fmt.Errorf("%w: contour %d", errTTInvalidReference, contour)
|
||||
return
|
||||
}
|
||||
if contour > 0 {
|
||||
@@ -864,7 +865,7 @@ func (v *ttInterpreter) shift(op byte) {
|
||||
} else {
|
||||
target = v.pop()
|
||||
if target < 0 || target > 1 {
|
||||
v.err = fmt.Errorf("invalid TrueType zone")
|
||||
v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, target)
|
||||
return
|
||||
}
|
||||
to = len(v.zones[target])
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -22,19 +22,19 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// errTTInvalidLocations 表示字形索引不满足递增偏移要求
|
||||
var errTTInvalidLocations = errors.New("invalid TrueType glyph locations")
|
||||
// ErrTTInvalidLocations 表示字形索引不满足递增偏移要求
|
||||
var ErrTTInvalidLocations = errors.New("invalid TrueType glyph locations")
|
||||
|
||||
// normalizeTrueTypeLocations 验证物理字形边界后按原编号重排,不改变空字形和分量引用
|
||||
// NormalizeTrueTypeLocations 验证物理字形边界后按原编号重排,不改变空字形和分量引用
|
||||
// 入参: tables 独占字体表目录,表内容只读
|
||||
// 返回: bool 是否重建索引, error 无法可靠恢复的索引或轮廓错误
|
||||
func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
|
||||
func NormalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
|
||||
if _, ok := tables["glyf"]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
head, maxp, loca, glyf := tables["head"], tables["maxp"], tables["loca"], tables["glyf"]
|
||||
if len(head) < 54 || len(maxp) < 6 {
|
||||
return false, fmt.Errorf("%w: missing header", errTTInvalidLocations)
|
||||
return false, fmt.Errorf("%w: missing header", ErrTTInvalidLocations)
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(maxp[4:]))
|
||||
width := 2
|
||||
@@ -43,10 +43,10 @@ func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
|
||||
case 1:
|
||||
width = 4
|
||||
default:
|
||||
return false, fmt.Errorf("%w: format", errTTInvalidLocations)
|
||||
return false, fmt.Errorf("%w: format", ErrTTInvalidLocations)
|
||||
}
|
||||
if count == 0 || len(loca) < (count+1)*width {
|
||||
return false, fmt.Errorf("%w: truncated index", errTTInvalidLocations)
|
||||
return false, fmt.Errorf("%w: truncated index", ErrTTInvalidLocations)
|
||||
}
|
||||
location := func(i int) uint64 {
|
||||
if width == 2 {
|
||||
@@ -58,7 +58,7 @@ func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
|
||||
for i := range count + 1 {
|
||||
offset := location(i)
|
||||
if offset > uint64(len(glyf)) {
|
||||
return false, fmt.Errorf("%w: offset %d outside table", errTTInvalidLocations, i)
|
||||
return false, fmt.Errorf("%w: offset %d outside table", ErrTTInvalidLocations, i)
|
||||
}
|
||||
ordered = ordered && previous <= offset
|
||||
previous = offset
|
||||
@@ -83,31 +83,31 @@ func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := bodies[start]; duplicate {
|
||||
return false, fmt.Errorf("%w: ambiguous shared offset %d", errTTInvalidLocations, start)
|
||||
return false, fmt.Errorf("%w: ambiguous shared offset %d", ErrTTInvalidLocations, start)
|
||||
}
|
||||
index, _ := slices.BinarySearch(physical, start)
|
||||
if index+1 >= len(physical) {
|
||||
return false, fmt.Errorf("%w: missing glyph %d body", errTTInvalidLocations, id)
|
||||
return false, fmt.Errorf("%w: missing glyph %d body", ErrTTInvalidLocations, id)
|
||||
}
|
||||
data := glyf[start:physical[index+1]]
|
||||
size, refs, err := trueTypeGlyphSize(data, count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: glyph %d: %w", errTTInvalidLocations, id, err)
|
||||
return false, fmt.Errorf("%w: glyph %d: %w", ErrTTInvalidLocations, id, err)
|
||||
}
|
||||
if len(data)-size > 3 || len(bytes.Trim(data[size:], "\x00")) != 0 {
|
||||
return false, fmt.Errorf("%w: ambiguous glyph %d padding", errTTInvalidLocations, id)
|
||||
return false, fmt.Errorf("%w: ambiguous glyph %d padding", ErrTTInvalidLocations, id)
|
||||
}
|
||||
bodies[start], components[id] = data[:size], refs
|
||||
total += (uint64(size) + 3) &^ 3
|
||||
if total > uint64(^uint(0)>>1) || total > uint64(^uint32(0)) {
|
||||
return false, fmt.Errorf("%w: rebuilt table exceeds capacity", errTTInvalidLocations)
|
||||
return false, fmt.Errorf("%w: rebuilt table exceeds capacity", ErrTTInvalidLocations)
|
||||
}
|
||||
}
|
||||
state := make([]uint8, count)
|
||||
var visit func(int) error
|
||||
visit = func(id int) error {
|
||||
if state[id] == 1 {
|
||||
return fmt.Errorf("%w: cyclic component %d", errTTInvalidLocations, id)
|
||||
return fmt.Errorf("%w: cyclic component %d", ErrTTInvalidLocations, id)
|
||||
}
|
||||
if state[id] == 2 {
|
||||
return nil
|
||||
@@ -0,0 +1,806 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 字体匹配按精确、部分和模糊匹配排列,零值表示未匹配
|
||||
const (
|
||||
MatchNone = iota
|
||||
MatchExact
|
||||
MatchPartial
|
||||
MatchFuzzy
|
||||
)
|
||||
|
||||
// matchRules 保存常用字体的名称、文件名及系统字体匹配规则
|
||||
var matchRules = normalizeMatchRules([]matchRule{
|
||||
{
|
||||
Keys: []string{"小标宋", "方正小标宋", "xiaobiaosong", "fzxiaobiaosong", "fzxbs"},
|
||||
Names: []string{"小标宋体", "方正小标宋简体", "FZXiaoBiaoSong-B05", "FZXiaoBiaoSong-B05S"},
|
||||
Files: []string{"FZXBSJW.TTF", "fzxbs*.ttf", "fzxiaobiaosong*.ttf", "xiaobiaosong*.ttf"},
|
||||
System: "FZXiaoBiaoSong-B05S",
|
||||
NoSyntheticBold: true,
|
||||
},
|
||||
{
|
||||
Keys: []string{"宋体", "新宋体", "simsun", "nsimsun", "simsunextb", "song", "songti", "stsong", "书宋", "方正书宋"},
|
||||
Names: []string{"宋体", "新宋体", "SimSun", "NSimSun", "SongTi", "STSong"},
|
||||
Files: []string{"simsun.ttc", "simsun.ttf", "nsimsun.ttf", "STSONG.TTF"},
|
||||
System: "SimSun",
|
||||
},
|
||||
{
|
||||
Keys: []string{"黑体", "hei", "simhei", "heiti", "sthei", "方正黑体"},
|
||||
Names: []string{"黑体", "SimHei", "HeiTi", "STHeiti", "方正黑体简体"},
|
||||
Files: []string{"simhei.ttf", "STXIHEI.TTF", "FZHT_GB18030.TTF", "fzht*.ttf"},
|
||||
System: "SimHei",
|
||||
},
|
||||
{
|
||||
Keys: []string{"楷体", "kai", "kaiti", "simkai", "stkaiti", "方正楷体"},
|
||||
Names: []string{"楷体", "KaiTi", "SimKai", "STKaiti", "方正楷体简体"},
|
||||
Files: []string{"simkai.ttf", "kaiti.ttf", "STKAITI.TTF", "FZKTJ.TTF", "fzkai*.ttf", "fzkt*.ttf"},
|
||||
System: "KaiTi",
|
||||
},
|
||||
{
|
||||
Keys: []string{"仿宋", "fang", "fangsong", "simfang", "stfangsong", "方正仿宋"},
|
||||
Names: []string{"仿宋", "FangSong", "SimFang", "STFangsong", "方正仿宋简体"},
|
||||
Files: []string{"simfang.ttf", "fangsong.ttf", "STFANGSO.TTF", "FZFSJ.TTF", "fzfs*.ttf"},
|
||||
System: "FangSong",
|
||||
},
|
||||
{
|
||||
Keys: []string{"微软雅黑", "microsoftyahei", "yahei", "msyh"},
|
||||
Names: []string{"微软雅黑", "Microsoft YaHei", "YaHei", "MSYH"},
|
||||
Files: []string{"msyh.ttc", "msyh.ttf"},
|
||||
System: "Microsoft YaHei",
|
||||
},
|
||||
{
|
||||
Keys: []string{"arialblack"},
|
||||
Names: []string{"Arial Black", "ArialBlack"},
|
||||
Files: []string{"ariblk.ttf", "arial black*.ttf", "arialblack*.ttf"},
|
||||
System: "Arial Black",
|
||||
},
|
||||
{
|
||||
Keys: []string{"arial"},
|
||||
Names: []string{"Arial"},
|
||||
Files: []string{"arial.ttf", "arial*.ttf"},
|
||||
System: "Arial",
|
||||
},
|
||||
{
|
||||
Keys: []string{"helvetica"},
|
||||
Names: []string{"Helvetica", "Arial"},
|
||||
Files: []string{"arial.ttf", "arial*.ttf"},
|
||||
System: "Arial",
|
||||
},
|
||||
{
|
||||
Keys: []string{"couriernew", "courier"},
|
||||
Names: []string{"Courier New", "CourierNew", "Courier"},
|
||||
Files: []string{"cour.ttf", "courier new*.ttf", "courier*.ttf"},
|
||||
System: "Courier New",
|
||||
},
|
||||
{
|
||||
Keys: []string{"segoeui"},
|
||||
Names: []string{"Segoe UI", "SegoeUI"},
|
||||
Files: []string{"segoeui.ttf", "segoe*.ttf"},
|
||||
System: "Segoe UI",
|
||||
},
|
||||
{
|
||||
Keys: []string{"timesnewroman", "times"},
|
||||
Names: []string{"Times New Roman", "TimesNewRoman"},
|
||||
Files: []string{"times.ttf", "times new roman*.ttf", "times*.ttf"},
|
||||
System: "Times New Roman",
|
||||
},
|
||||
})
|
||||
|
||||
// fontNameReplacer 移除字体名称匹配时忽略的分隔符
|
||||
var fontNameReplacer = strings.NewReplacer(" ", "", "-", "", "_", "", "(", "", ")", "", "(", "", ")", "")
|
||||
|
||||
// matchRule 字体匹配规则
|
||||
type matchRule struct {
|
||||
Keys []string
|
||||
Names []string
|
||||
Files []string
|
||||
System string
|
||||
NoSyntheticBold bool
|
||||
}
|
||||
|
||||
// FileCandidate 字体文件候选
|
||||
type FileCandidate struct {
|
||||
Name string
|
||||
Face int
|
||||
base string
|
||||
lowerBase string
|
||||
filename string
|
||||
normalized string
|
||||
names []string
|
||||
fold string
|
||||
}
|
||||
|
||||
// patternMatcher 字体文件匹配器
|
||||
type patternMatcher struct {
|
||||
pattern string
|
||||
lowerPattern string
|
||||
stem string
|
||||
aliases []string
|
||||
priority int
|
||||
}
|
||||
|
||||
// FileMatch 字体文件匹配结果
|
||||
type FileMatch struct {
|
||||
Name string
|
||||
Face int
|
||||
Priority int
|
||||
Rank int
|
||||
styleRank int
|
||||
sortName string
|
||||
}
|
||||
|
||||
// fileKey 字体文件与集合索引的去重键
|
||||
type fileKey struct {
|
||||
Name string
|
||||
Face int
|
||||
}
|
||||
|
||||
// NormalizeName 规范化字体名称
|
||||
// 入参: name 字体名称
|
||||
// 返回: string 规范化后的字体名称
|
||||
func NormalizeName(name string) string {
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = path.Base(strings.TrimSpace(name))
|
||||
if IsFontFileName(name) {
|
||||
name = strings.TrimSuffix(name, path.Ext(name))
|
||||
}
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
return fontNameReplacer.Replace(name)
|
||||
}
|
||||
|
||||
// IsFontFileName 判断是否为字体文件名
|
||||
// 入参: name 文件名
|
||||
// 返回: bool 是否为字体文件
|
||||
func IsFontFileName(name string) bool {
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".ttf", ".otf", ".ttc", ".otc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AppendFontName 追加字体名称
|
||||
// 入参: names 字体名称列表, seen 去重映射, name 字体名称
|
||||
// 返回: []string 字体名称列表
|
||||
func AppendFontName(names []string, seen map[string]bool, name string) []string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return names
|
||||
}
|
||||
key := NormalizeName(name)
|
||||
if key == "" || seen[key] {
|
||||
return names
|
||||
}
|
||||
seen[key] = true
|
||||
return append(names, name)
|
||||
}
|
||||
|
||||
// normalizeMatchRules 预处理固定字体规则的匹配名称,保留候选名称及顺序
|
||||
// 入参: rules 字体匹配规则
|
||||
// 返回: []matchRule 预处理后的规则
|
||||
func normalizeMatchRules(rules []matchRule) []matchRule {
|
||||
for i := range rules {
|
||||
rule := &rules[i]
|
||||
keys := append(append(rule.Keys, rule.Names...), rule.System)
|
||||
rule.Keys = nil
|
||||
for _, key := range keys {
|
||||
key = NormalizeName(key)
|
||||
if key != "" && !slices.Contains(rule.Keys, key) {
|
||||
rule.Keys = append(rule.Keys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// CandidateNames 获取字体候选名称
|
||||
// 入参: names 字体名称列表
|
||||
// 返回: []string 字体候选名称
|
||||
func CandidateNames(names ...string) []string {
|
||||
return candidateNamesByLevel(MatchFuzzy, names...)
|
||||
}
|
||||
|
||||
// ExactCandidateNames 获取精确字体候选名称
|
||||
// 入参: names 字体名称列表
|
||||
// 返回: []string 精确字体候选名称
|
||||
func ExactCandidateNames(names ...string) []string {
|
||||
return candidateNamesByLevel(MatchExact, names...)
|
||||
}
|
||||
|
||||
// qualifiedNames 获取保留名称后缀的字体候选
|
||||
// 入参: name 字体名称
|
||||
// 返回: []string 保留后缀的候选名称
|
||||
func qualifiedNames(name string) []string {
|
||||
name = NormalizeName(name)
|
||||
key := NormalizeName(name)
|
||||
var names []string
|
||||
seen := make(map[string]bool)
|
||||
for _, rule := range matchRules {
|
||||
if ruleMatchLevel(rule, key) != MatchPartial {
|
||||
continue
|
||||
}
|
||||
prefix := ""
|
||||
for _, alias := range rule.Keys {
|
||||
if len(alias) > len(prefix) && strings.HasPrefix(name, alias) {
|
||||
prefix = alias
|
||||
}
|
||||
}
|
||||
suffix := strings.TrimPrefix(name, prefix)
|
||||
for _, alias := range append([]string{rule.System}, rule.Names...) {
|
||||
names = AppendFontName(names, seen, NormalizeName(alias)+suffix)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// candidateNamesByLevel 获取指定等级内的字体候选名称
|
||||
// 入参: maxLevel 最大匹配等级, names 字体名称列表
|
||||
// 返回: []string 字体候选名称
|
||||
func candidateNamesByLevel(maxLevel int, names ...string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
var bases []string
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
result = AppendFontName(result, seen, name)
|
||||
bases = append(bases, name)
|
||||
}
|
||||
levels := matchLevels(bases)
|
||||
for level := MatchExact; level <= maxLevel; level++ {
|
||||
for i := range bases {
|
||||
for j, rule := range matchRules {
|
||||
if levels[i*len(matchRules)+j] == level {
|
||||
result = AppendFontName(result, seen, rule.System)
|
||||
for _, item := range rule.Names {
|
||||
result = AppendFontName(result, seen, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// patternStem 获取字体匹配模式主干
|
||||
// 入参: pattern 字体文件匹配模式
|
||||
// 返回: string 字体匹配模式主干
|
||||
func patternStem(pattern string) string {
|
||||
stem := strings.TrimSuffix(pattern, "*")
|
||||
if IsFontFileName(stem) {
|
||||
stem = strings.TrimSuffix(stem, path.Ext(stem))
|
||||
}
|
||||
if index := strings.IndexAny(stem, "*?["); index >= 0 {
|
||||
stem = stem[:index]
|
||||
}
|
||||
return NormalizeName(stem)
|
||||
}
|
||||
|
||||
// FilePatterns 获取字体文件匹配模式
|
||||
// 入参: names 字体名称列表
|
||||
// 返回: []string 字体文件匹配模式
|
||||
func FilePatterns(names ...string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
levels := matchLevels(names)
|
||||
for level := MatchExact; level <= MatchFuzzy; level++ {
|
||||
for _, name := range candidateNamesAtLevel(level, names, levels) {
|
||||
result = appendPattern(result, seen, name+"*")
|
||||
}
|
||||
for i := range names {
|
||||
for j, rule := range matchRules {
|
||||
if levels[i*len(matchRules)+j] == level {
|
||||
for _, item := range rule.Files {
|
||||
result = appendPattern(result, seen, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SystemNames 获取系统字体名称
|
||||
// 入参: names 字体名称列表
|
||||
// 返回: []string 系统字体名称
|
||||
func SystemNames(names ...string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
for _, item := range candidateNamesByLevel(MatchPartial, name) {
|
||||
result = AppendFontName(result, seen, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// candidateNamesAtLevel 获取指定等级的字体候选名称
|
||||
// 入参: level 匹配等级, names 字体名称列表, levels 名称与规则的匹配等级
|
||||
// 返回: []string 字体候选名称
|
||||
func candidateNamesAtLevel(level int, names []string, levels []int) []string {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
for i, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if level == MatchExact {
|
||||
result = AppendFontName(result, seen, name)
|
||||
}
|
||||
for j, rule := range matchRules {
|
||||
if levels[i*len(matchRules)+j] == level {
|
||||
result = AppendFontName(result, seen, rule.System)
|
||||
for _, item := range rule.Names {
|
||||
result = AppendFontName(result, seen, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DefaultSystemNames 获取默认系统字体名称
|
||||
// 返回: []string 默认系统字体名称
|
||||
func DefaultSystemNames() []string {
|
||||
return []string{
|
||||
"SimSun", "Microsoft YaHei", "SimHei", "KaiTi", "FangSong",
|
||||
"Noto Sans CJK SC", "Source Han Sans SC", "Arial", "Segoe UI", "Times New Roman",
|
||||
}
|
||||
}
|
||||
|
||||
// NoSyntheticBold 判断字体是否禁用合成粗体
|
||||
// 入参: names 字体名称列表
|
||||
// 返回: bool 是否禁用合成粗体
|
||||
func NoSyntheticBold(names ...string) bool {
|
||||
for _, name := range names {
|
||||
name = NormalizeName(name)
|
||||
for _, rule := range matchRules {
|
||||
if !rule.NoSyntheticBold {
|
||||
continue
|
||||
}
|
||||
level := ruleMatchLevel(rule, name)
|
||||
if level >= MatchExact && level <= MatchPartial {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchLevels 按名称和规则顺序计算匹配等级
|
||||
// 入参: names 字体名称列表
|
||||
// 返回: []int 名称与规则的匹配等级
|
||||
func matchLevels(names []string) []int {
|
||||
levels := make([]int, len(names)*len(matchRules))
|
||||
for i, name := range names {
|
||||
name = NormalizeName(name)
|
||||
for j, rule := range matchRules {
|
||||
levels[i*len(matchRules)+j] = ruleMatchLevel(rule, name)
|
||||
}
|
||||
}
|
||||
return levels
|
||||
}
|
||||
|
||||
// ruleMatchLevel 获取字体规则匹配等级
|
||||
// 入参: rule 字体匹配规则, name 规范化后的字体名称
|
||||
// 返回: int 匹配等级
|
||||
func ruleMatchLevel(rule matchRule, name string) int {
|
||||
if name == "" {
|
||||
return MatchNone
|
||||
}
|
||||
level := MatchNone
|
||||
for _, item := range rule.Keys {
|
||||
if name == item {
|
||||
return MatchExact
|
||||
}
|
||||
if strings.HasPrefix(name, item) {
|
||||
level = MatchPartial
|
||||
} else if level == MatchNone && strings.Contains(name, item) {
|
||||
level = MatchFuzzy
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// appendPattern 追加字体文件匹配模式
|
||||
// 入参: patterns 匹配模式列表, seen 去重映射, pattern 匹配模式
|
||||
// 返回: []string 匹配模式列表
|
||||
func appendPattern(patterns []string, seen map[string]bool, pattern string) []string {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return patterns
|
||||
}
|
||||
key := strings.ToLower(pattern)
|
||||
if seen[key] {
|
||||
return patterns
|
||||
}
|
||||
seen[key] = true
|
||||
return append(patterns, pattern)
|
||||
}
|
||||
|
||||
// AppendFileNames 建立各字体的内部名称与文件别名匹配候选
|
||||
// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称
|
||||
// 返回: []FileCandidate 字体候选列表
|
||||
func AppendFileNames(candidates []FileCandidate, index int, faces [][]string) []FileCandidate {
|
||||
file := candidates[index]
|
||||
replaced := false
|
||||
for face, names := range faces {
|
||||
file.Face = face
|
||||
file.names = make([]string, len(names))
|
||||
for i, name := range names {
|
||||
file.names[i] = NormalizeName(name)
|
||||
}
|
||||
for _, name := range file.names {
|
||||
file.normalized = name
|
||||
if !replaced {
|
||||
candidates[index] = file
|
||||
replaced = true
|
||||
} else {
|
||||
candidates = append(candidates, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// CollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项
|
||||
// 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体
|
||||
// 返回: int 零起始字体索引
|
||||
func CollectionIndex(data []byte, names []string, bold, italic bool) int {
|
||||
return NameIndex(FileNames(bytes.NewReader(data)), names, bold, italic)
|
||||
}
|
||||
|
||||
// NameIndex 按名称与样式选择字体索引,未匹配时保留首项
|
||||
// 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体
|
||||
// 返回: int 零起始字体索引
|
||||
func NameIndex(faces [][]string, names []string, bold, italic bool) int {
|
||||
candidates := AppendFileNames([]FileCandidate{{}}, 0, faces)
|
||||
if matches := FileMatches(candidates, names, bold, italic); len(matches) > 0 {
|
||||
return matches[0].Face
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FileMatches 匹配字体文件候选
|
||||
// 入参: candidates 字体候选, names 字体名称, bold 是否粗体, italic 是否斜体
|
||||
// 返回: []FileMatch 按优先级排列的字体匹配
|
||||
func FileMatches(candidates []FileCandidate, names []string, bold, italic bool) []FileMatch {
|
||||
matches := make([]FileMatch, 0, len(candidates))
|
||||
seen := make(map[fileKey]int, len(candidates))
|
||||
if len(names) == 0 {
|
||||
for _, name := range DefaultSystemNames() {
|
||||
matches = append(matches, FileMatches(candidates, []string{name}, bold, italic)...)
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
return matches[i].Rank < matches[j].Rank
|
||||
})
|
||||
unique := matches[:0]
|
||||
for _, match := range matches {
|
||||
key := fileKey{strings.ToLower(match.Name), match.Face}
|
||||
if _, ok := seen[key]; !ok {
|
||||
seen[key] = len(unique)
|
||||
unique = append(unique, match)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
for i, name := range names {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
for j, alias := range append([]string{name}, qualifiedNames(name)...) {
|
||||
matcher := patternMatcher{stem: NormalizeName(alias), priority: (i-len(names))*2 + min(j, 1)}
|
||||
if matcher.stem == "" {
|
||||
continue
|
||||
}
|
||||
for _, file := range candidates {
|
||||
if rank := matcher.rankCandidate(file); rank == MatchExact {
|
||||
appendFileMatch(&matches, seen, matcher, file, rank, bold, italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, matcher := range newPatternMatchers(FilePatterns(names...)) {
|
||||
for _, file := range candidates {
|
||||
rank := matcher.rankCandidate(file)
|
||||
appendFileMatch(&matches, seen, matcher, file, rank, bold, italic)
|
||||
}
|
||||
}
|
||||
sortFileMatches(matches)
|
||||
return matches
|
||||
}
|
||||
|
||||
// FileCandidates 筛选字体文件候选
|
||||
// 入参: names 文件名列表, base 文件名提取函数
|
||||
// 返回: []FileCandidate 字体文件候选列表
|
||||
func FileCandidates(names []string, base func(string) string) []FileCandidate {
|
||||
files := make([]FileCandidate, 0, len(names))
|
||||
for _, name := range names {
|
||||
if IsFontFileName(name) {
|
||||
baseName := base(name)
|
||||
normalized := NormalizeName(strings.TrimSuffix(baseName, path.Ext(baseName)))
|
||||
files = append(files, FileCandidate{
|
||||
Name: name,
|
||||
base: baseName,
|
||||
lowerBase: strings.ToLower(baseName),
|
||||
filename: normalized,
|
||||
normalized: normalized,
|
||||
fold: strings.ToLower(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// newPatternMatcher 创建字体文件匹配器
|
||||
// 入参: pattern 匹配模式
|
||||
// 返回: patternMatcher 字体文件匹配器
|
||||
func newPatternMatcher(pattern string) patternMatcher {
|
||||
matcher := patternMatcher{
|
||||
pattern: pattern,
|
||||
lowerPattern: strings.ToLower(pattern),
|
||||
stem: patternStem(pattern),
|
||||
}
|
||||
if matcher.stem != "" {
|
||||
for _, alias := range ExactCandidateNames(matcher.stem) {
|
||||
alias = NormalizeName(alias)
|
||||
if alias != "" {
|
||||
matcher.aliases = append(matcher.aliases, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matcher
|
||||
}
|
||||
|
||||
// newPatternMatchers 创建字体文件匹配器列表
|
||||
// 入参: patterns 匹配模式列表
|
||||
// 返回: []patternMatcher 字体文件匹配器列表
|
||||
func newPatternMatchers(patterns []string) []patternMatcher {
|
||||
matchers := make([]patternMatcher, 0, len(patterns))
|
||||
for priority, pattern := range patterns {
|
||||
matcher := newPatternMatcher(pattern)
|
||||
matcher.priority = priority
|
||||
matchers = append(matchers, matcher)
|
||||
}
|
||||
return matchers
|
||||
}
|
||||
|
||||
// rankCandidate 获取字体文件候选匹配等级
|
||||
// 入参: file 字体文件候选
|
||||
// 返回: int 匹配等级
|
||||
func (m patternMatcher) rankCandidate(file FileCandidate) int {
|
||||
exactPath, _ := path.Match(m.pattern, file.base)
|
||||
lowerPath, _ := path.Match(m.lowerPattern, file.lowerBase)
|
||||
if m.stem == "" {
|
||||
if exactPath || lowerPath {
|
||||
return MatchFuzzy
|
||||
}
|
||||
return MatchNone
|
||||
}
|
||||
name := m.candidateName(file)
|
||||
if name == m.stem {
|
||||
return MatchExact
|
||||
}
|
||||
for _, alias := range m.aliases {
|
||||
if name == alias {
|
||||
return MatchExact
|
||||
}
|
||||
}
|
||||
if fileKnownStyleSuffix(m.styleSuffixNormalized(name)) {
|
||||
return MatchExact
|
||||
}
|
||||
if strings.HasPrefix(name, m.stem) {
|
||||
return MatchPartial
|
||||
}
|
||||
for _, alias := range m.aliases {
|
||||
if strings.HasPrefix(name, alias) {
|
||||
return MatchPartial
|
||||
}
|
||||
}
|
||||
if exactPath || lowerPath || strings.Contains(name, m.stem) {
|
||||
return MatchFuzzy
|
||||
}
|
||||
for _, alias := range m.aliases {
|
||||
if strings.Contains(name, alias) {
|
||||
return MatchFuzzy
|
||||
}
|
||||
}
|
||||
return MatchNone
|
||||
}
|
||||
|
||||
// candidateName 保留同族内部名称的区分,只有无关联时使用调用方文件别名
|
||||
// 入参: file 字体文件候选
|
||||
// 返回: string 用于统一匹配名称与样式的规范名称
|
||||
func (m patternMatcher) candidateName(file FileCandidate) string {
|
||||
if len(file.names) == 0 || file.base == "" || m.stem == "" {
|
||||
return file.normalized
|
||||
}
|
||||
for _, name := range file.names {
|
||||
if strings.HasPrefix(name, m.stem) {
|
||||
return file.normalized
|
||||
}
|
||||
for _, alias := range m.aliases {
|
||||
if strings.HasPrefix(name, alias) {
|
||||
return file.normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return file.filename
|
||||
}
|
||||
|
||||
// styleSuffixNormalized 获取规范字体名称的样式后缀
|
||||
// 入参: name 规范字体名称
|
||||
// 返回: string 样式后缀
|
||||
func (m patternMatcher) styleSuffixNormalized(name string) string {
|
||||
if m.stem == "" || name == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(name, m.stem) {
|
||||
return strings.TrimPrefix(name, m.stem)
|
||||
}
|
||||
for _, alias := range m.aliases {
|
||||
if strings.HasPrefix(name, alias) {
|
||||
return strings.TrimPrefix(name, alias)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// appendFileMatch 追加字体文件匹配结果
|
||||
// 入参: matches 匹配结果, seen 已匹配文件, matcher 匹配器, file 字体文件候选, rank 匹配等级, bold 是否粗体, italic 是否斜体
|
||||
func appendFileMatch(matches *[]FileMatch, seen map[fileKey]int, matcher patternMatcher, file FileCandidate, rank int, bold, italic bool) {
|
||||
if rank == MatchNone {
|
||||
return
|
||||
}
|
||||
suffix := matcher.styleSuffixNormalized(matcher.candidateName(file))
|
||||
if rank == MatchFuzzy {
|
||||
suffix = matcher.styleSuffixNormalized(NormalizeName(file.base))
|
||||
}
|
||||
next := FileMatch{
|
||||
Name: file.Name,
|
||||
Face: file.Face,
|
||||
Priority: matcher.priority,
|
||||
Rank: rank,
|
||||
styleRank: fileStyleRank(suffix, bold, italic),
|
||||
sortName: file.lowerBase,
|
||||
}
|
||||
key := fileKey{file.fold, file.Face}
|
||||
if index, ok := seen[key]; ok {
|
||||
if fileMatchLess(next, (*matches)[index]) {
|
||||
(*matches)[index] = next
|
||||
}
|
||||
return
|
||||
}
|
||||
seen[key] = len(*matches)
|
||||
*matches = append(*matches, next)
|
||||
}
|
||||
|
||||
// sortFileMatches 排序字体文件匹配结果
|
||||
// 入参: matches 匹配结果
|
||||
func sortFileMatches(matches []FileMatch) {
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
return fileMatchLess(matches[i], matches[j])
|
||||
})
|
||||
}
|
||||
|
||||
// fileMatchLess 判断字体文件匹配结果优先级
|
||||
// 入参: left 左侧匹配结果, right 右侧匹配结果
|
||||
// 返回: bool 左侧是否优先
|
||||
func fileMatchLess(left, right FileMatch) bool {
|
||||
if left.Priority != right.Priority {
|
||||
return left.Priority < right.Priority
|
||||
}
|
||||
if left.Rank != right.Rank {
|
||||
return left.Rank < right.Rank
|
||||
}
|
||||
if left.styleRank != right.styleRank {
|
||||
return left.styleRank < right.styleRank
|
||||
}
|
||||
return left.sortName < right.sortName
|
||||
}
|
||||
|
||||
// FileMatchNames 获取字体文件匹配名称
|
||||
// 入参: matches 匹配结果
|
||||
// 返回: []string 字体文件列表
|
||||
func FileMatchNames(matches []FileMatch) []string {
|
||||
names := make([]string, 0, len(matches))
|
||||
seen := make(map[string]bool, len(matches))
|
||||
for _, match := range matches {
|
||||
key := strings.ToLower(match.Name)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
names = append(names, match.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// fileStyleRank 获取字体文件样式匹配等级
|
||||
// 入参: suffix 样式后缀, bold 是否粗体, italic 是否斜体
|
||||
// 返回: int 样式匹配等级
|
||||
func fileStyleRank(suffix string, bold, italic bool) int {
|
||||
fileBold, fileItalic := fileStyleFromSuffix(suffix)
|
||||
rank := 0
|
||||
if fileBold != bold {
|
||||
rank += 2
|
||||
}
|
||||
if fileItalic != italic {
|
||||
rank += 2
|
||||
}
|
||||
if !bold && !italic && !fileBold && !fileItalic && suffix != "" && !fileRegularSuffix(suffix) {
|
||||
rank++
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// fileStyleFromSuffix 获取样式后缀对应的字体样式
|
||||
// 入参: suffix 样式后缀
|
||||
// 返回: bool 是否粗体, bool 是否斜体
|
||||
func fileStyleFromSuffix(suffix string) (bool, bool) {
|
||||
bold := suffix == "b" ||
|
||||
suffix == "bd" ||
|
||||
suffix == "bold" ||
|
||||
suffix == "bi" ||
|
||||
suffix == "bolditalic" ||
|
||||
suffix == "boldoblique"
|
||||
italic := suffix == "i" ||
|
||||
suffix == "it" ||
|
||||
suffix == "italic" ||
|
||||
suffix == "oblique" ||
|
||||
suffix == "bi" ||
|
||||
suffix == "bolditalic" ||
|
||||
suffix == "boldoblique"
|
||||
return bold, italic
|
||||
}
|
||||
|
||||
// fileRegularSuffix 判断是否为常规样式后缀
|
||||
// 入参: suffix 样式后缀
|
||||
// 返回: bool 是否为常规样式后缀
|
||||
func fileRegularSuffix(suffix string) bool {
|
||||
switch suffix {
|
||||
case "r", "regular", "normal", "常规":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// fileKnownStyleSuffix 判断是否为已知样式后缀
|
||||
// 入参: suffix 样式后缀
|
||||
// 返回: bool 是否为已知样式后缀
|
||||
func fileKnownStyleSuffix(suffix string) bool {
|
||||
switch suffix {
|
||||
case "r", "regular", "normal", "常规",
|
||||
"b", "bd", "bold",
|
||||
"i", "it", "italic", "oblique",
|
||||
"bi", "bolditalic", "boldoblique":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
// glyphPather 接收字体设计坐标中的轮廓指令
|
||||
type glyphPather interface {
|
||||
MoveTo(float64, float64)
|
||||
LineTo(float64, float64)
|
||||
QuadTo(float64, float64, float64, float64)
|
||||
CubeTo(float64, float64, float64, float64, float64, float64)
|
||||
Close()
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -33,35 +33,10 @@ type fontNameRecord struct {
|
||||
Offset uint16
|
||||
}
|
||||
|
||||
// appendFontFileNames 建立各字体的内部名称与文件别名匹配候选
|
||||
// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称
|
||||
// 返回: []fontFileCandidate 字体候选列表
|
||||
func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate {
|
||||
file := candidates[index]
|
||||
replaced := false
|
||||
for face, names := range faces {
|
||||
file.face = face
|
||||
file.names = make([]string, len(names))
|
||||
for i, name := range names {
|
||||
file.names[i] = fontNormalizeName(name)
|
||||
}
|
||||
for _, name := range file.names {
|
||||
file.normalized = name
|
||||
if !replaced {
|
||||
candidates[index] = file
|
||||
replaced = true
|
||||
} else {
|
||||
candidates = append(candidates, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// fontFileNames 按字体索引读取字体或集合中各项的名称
|
||||
// FileNames 按字体索引读取字体或集合中各项的名称
|
||||
// 入参: file 字体数据
|
||||
// 返回: [][]string 各字体名称及带样式的字体族名称
|
||||
func fontFileNames(file io.ReaderAt) [][]string {
|
||||
func FileNames(file io.ReaderAt) [][]string {
|
||||
var header [12]byte
|
||||
if _, err := file.ReadAt(header[:], 0); err != nil {
|
||||
return nil
|
||||
@@ -84,24 +59,6 @@ func fontFileNames(file io.ReaderAt) [][]string {
|
||||
return faces
|
||||
}
|
||||
|
||||
// fontCollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项
|
||||
// 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体
|
||||
// 返回: int 零起始字体索引
|
||||
func fontCollectionIndex(data []byte, names []string, bold, italic bool) int {
|
||||
return fontNameIndex(fontFileNames(bytes.NewReader(data)), names, bold, italic)
|
||||
}
|
||||
|
||||
// fontNameIndex 按名称与样式选择字体索引,未匹配时保留首项
|
||||
// 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体
|
||||
// 返回: int 零起始字体索引
|
||||
func fontNameIndex(faces [][]string, names []string, bold, italic bool) int {
|
||||
candidates := appendFontFileNames([]fontFileCandidate{{}}, 0, faces)
|
||||
if matches := fontFileMatches(candidates, names, bold, italic); len(matches) > 0 {
|
||||
return matches[0].face
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fontFaceNames 读取指定字体表目录中的名称
|
||||
// 入参: file 字体数据, offset 字体表目录偏移
|
||||
// 返回: []string 字体名称及带样式的字体族名称
|
||||
@@ -129,16 +86,23 @@ func fontFaceNames(file io.ReaderAt, offset int64) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return fontNamesFromTable(data)
|
||||
return NamesFromTable(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fontNamesFromTable 解析字体名称表
|
||||
// NamesFromTable 解析字体名称表
|
||||
// 入参: data 名称表数据
|
||||
// 返回: []string 字体名称列表
|
||||
func fontNamesFromTable(data []byte) []string {
|
||||
func NamesFromTable(data []byte) []string {
|
||||
records, values := fontNameValues(data)
|
||||
return fontNamesFromValues(records, values)
|
||||
}
|
||||
|
||||
// fontNamesFromValues 从已解码记录生成去重名称,保留原有顺序及样式匹配
|
||||
// 入参: records 名称记录, values 已解码的名称文本
|
||||
// 返回: []string 字体名称列表
|
||||
func fontNamesFromValues(records []fontNameRecord, values map[[4]uint16]string) []string {
|
||||
var names []string
|
||||
seen := make(map[string]bool)
|
||||
for _, record := range records {
|
||||
@@ -154,7 +118,7 @@ func fontNamesFromTable(data []byte) []string {
|
||||
default:
|
||||
continue
|
||||
}
|
||||
names = appendFontName(names, seen, name)
|
||||
names = AppendFontName(names, seen, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -178,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
|
||||
values := make(map[[4]uint16]string)
|
||||
for _, record := range records {
|
||||
switch record.Name {
|
||||
case 1, 2, 4, 6, 16, 17:
|
||||
case 1, 2, 4, 5, 6, 8, 16, 17:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// FixDataAggressive 激进修复字体数据
|
||||
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
||||
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
||||
// 入参: data 原始字体数据, fixCmap 是否修复cmap, fixName 是否修复name
|
||||
// 返回: bool 是否修复, []byte 修复后数据, map[rune]uint16 字符映射, bool 是否缺失cmap, error 错误信息
|
||||
func FixDataAggressive(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uint16, bool, error) {
|
||||
if len(data) < 4 {
|
||||
return false, data, nil, false, nil
|
||||
}
|
||||
tag := string(data[0:4])
|
||||
u32Tag := binary.BigEndian.Uint32(data[0:4])
|
||||
isTrueType := u32Tag == 0x00010000 || tag == "true"
|
||||
isOpenType := tag == "OTTO"
|
||||
isTTC := tag == "ttcf"
|
||||
if isTTC {
|
||||
return false, data, nil, false, nil
|
||||
}
|
||||
if isBareCFFData(data) {
|
||||
otf, mapping, err := WrapCFFToOTF(data)
|
||||
if err != nil {
|
||||
return false, data, nil, false, err
|
||||
}
|
||||
return true, otf, mapping, false, nil
|
||||
}
|
||||
if !isTrueType && !isOpenType {
|
||||
return false, data, nil, false, nil
|
||||
}
|
||||
fixed, newData, mapping, mc, err := fixTrueType(data, fixCmap, fixName)
|
||||
return fixed, newData, mapping, mc, err
|
||||
}
|
||||
|
||||
// SFNTData 保留字体原始表数据和时间,重新计算目录与校验和
|
||||
// 入参: tables 原始字体表
|
||||
// 返回: []byte 独立SFNT数据
|
||||
func SFNTData(tables map[string][]byte) []byte {
|
||||
data, _ := SerializeOTF(tables)
|
||||
return data
|
||||
}
|
||||
|
||||
// isBareCFFData 检查是否为CFF裸字体数据
|
||||
// 入参: data 字体数据
|
||||
// 返回: bool 是否为CFF裸数据
|
||||
func isBareCFFData(data []byte) bool {
|
||||
if len(data) < 4 {
|
||||
return false
|
||||
}
|
||||
if data[0] != 1 || data[1] != 0 {
|
||||
return false
|
||||
}
|
||||
hdrSize := int(data[2])
|
||||
offSize := int(data[3])
|
||||
return 4 <= hdrSize && hdrSize <= len(data) && 1 <= offSize && offSize <= 4
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-text/typesetting/font/opentype/tables"
|
||||
)
|
||||
|
||||
// SFNT 保存独立字体表和基础度量,不包含绘图库对象
|
||||
type SFNT struct {
|
||||
Tables map[string][]byte
|
||||
IsTrueType, IsCFF bool
|
||||
cmaps []sfntCmap
|
||||
units, count, horizontal, vertical uint16
|
||||
reverseOnce sync.Once
|
||||
reverse map[uint16][]rune
|
||||
}
|
||||
|
||||
// sfntCmap 保存编码记录及字符映射,连续区间与共享子表不重复展开
|
||||
type sfntCmap struct {
|
||||
platform, encoding, format uint16
|
||||
mapping map[rune]uint16
|
||||
groups []byte
|
||||
entries uint64
|
||||
duplicate bool
|
||||
}
|
||||
|
||||
// ParseSFNT 校验独立字体的必需表、度量范围及字符映射
|
||||
// 入参: data 独立SFNT数据
|
||||
// 返回: *SFNT 字体, error 格式错误
|
||||
func ParseSFNT(data []byte) (*SFNT, error) {
|
||||
table, err := FileTables(data, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseSFNTTables(table)
|
||||
}
|
||||
|
||||
// ParseSFNTTables 校验字体表并解析度量和字符映射,借用表数据且不修改其内容
|
||||
// 入参: table 字体表
|
||||
// 返回: *SFNT 字体, error 格式错误
|
||||
func ParseSFNTTables(table map[string][]byte) (*SFNT, error) {
|
||||
head, maxp, hhea := table["head"], table["maxp"], table["hhea"]
|
||||
if len(head) != 54 || len(maxp) < 6 || len(hhea) < 36 {
|
||||
return nil, fmt.Errorf("missing or truncated font metrics")
|
||||
}
|
||||
f := &SFNT{Tables: maps.Clone(table), IsTrueType: table["glyf"] != nil, IsCFF: table["CFF "] != nil}
|
||||
f.units, f.count = binary.BigEndian.Uint16(head[18:]), binary.BigEndian.Uint16(maxp[4:])
|
||||
f.horizontal = binary.BigEndian.Uint16(hhea[34:])
|
||||
if f.units < 16 || f.units > 16384 || f.count == 0 || f.IsTrueType == f.IsCFF || !validSFNTMetrics(table["hmtx"], f.count, f.horizontal) {
|
||||
return nil, fmt.Errorf("invalid font outlines or horizontal metrics")
|
||||
}
|
||||
if vhea := table["vhea"]; vhea != nil {
|
||||
if len(vhea) < 36 {
|
||||
return nil, fmt.Errorf("truncated vertical metrics header")
|
||||
}
|
||||
f.vertical = binary.BigEndian.Uint16(vhea[34:])
|
||||
if !validSFNTMetrics(table["vmtx"], f.count, f.vertical) {
|
||||
return nil, fmt.Errorf("invalid vertical metrics")
|
||||
}
|
||||
}
|
||||
if f.IsTrueType {
|
||||
format := binary.BigEndian.Uint16(head[50:])
|
||||
if format > 1 || len(table["loca"]) < (int(f.count)+1)*(2+2*int(format)) {
|
||||
return nil, fmt.Errorf("invalid glyph location table")
|
||||
}
|
||||
}
|
||||
cmaps, err := readSFNTCmaps(table["cmap"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.cmaps = cmaps
|
||||
for _, cmap := range f.cmaps {
|
||||
if cmap.duplicate {
|
||||
continue
|
||||
}
|
||||
for _, glyph := range cmap.pairs() {
|
||||
if glyph >= f.count {
|
||||
return nil, fmt.Errorf("cmap glyph index out of range")
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// validSFNTMetrics 校验完整度量记录和末尾边距数组的范围
|
||||
// 入参: data 度量表, count 字形数, metrics 完整度量记录数
|
||||
// 返回: bool 是否有效
|
||||
func validSFNTMetrics(data []byte, count, metrics uint16) bool {
|
||||
return metrics != 0 && metrics <= count && len(data) >= int(metrics)*4+int(count-metrics)*2
|
||||
}
|
||||
|
||||
// readSFNTCmaps 校验各编码子表并保留原顺序,共享子表只解析一次
|
||||
// 入参: data cmap表
|
||||
// 返回: []sfntCmap 编码记录, error 表结构错误
|
||||
func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
|
||||
data, err := NormalizeCmap(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := tables.ParseCmap(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(data[2:]))
|
||||
result := make([]sfntCmap, 0, count)
|
||||
parsed := make(map[uint32]sfntCmap)
|
||||
for i := range count {
|
||||
record := data[4+8*i:]
|
||||
offset := binary.BigEndian.Uint32(record[4:])
|
||||
if uint64(offset)+2 > uint64(len(data)) {
|
||||
return nil, fmt.Errorf("invalid cmap offset")
|
||||
}
|
||||
sub := data[offset:]
|
||||
format := binary.BigEndian.Uint16(sub)
|
||||
cmap, found := parsed[offset]
|
||||
if !found {
|
||||
cmap.mapping = make(map[rune]uint16)
|
||||
var err error
|
||||
switch format {
|
||||
case 0:
|
||||
parseCmapFormat0(sub, cmap.mapping)
|
||||
case 4:
|
||||
parseCmapFormat4(sub, cmap.mapping)
|
||||
case 6:
|
||||
parseCmapFormat6(sub, cmap.mapping)
|
||||
case 12:
|
||||
if len(sub) >= 16 {
|
||||
length := uint64(binary.BigEndian.Uint32(sub[4:]))
|
||||
count := uint64(binary.BigEndian.Uint32(sub[12:]))
|
||||
if length >= 16 && length <= uint64(len(sub)) && count <= (length-16)/12 {
|
||||
cmap.groups = sub[16 : 16+12*count]
|
||||
for offset := 0; offset < len(cmap.groups); offset += 12 {
|
||||
first := binary.BigEndian.Uint32(cmap.groups[offset:])
|
||||
last := binary.BigEndian.Uint32(cmap.groups[offset+4:])
|
||||
if first > last || offset != 0 && first <= binary.BigEndian.Uint32(cmap.groups[offset-8:]) {
|
||||
cmap.groups = nil
|
||||
parseCmapFormat12(sub, cmap.mapping)
|
||||
break
|
||||
}
|
||||
}
|
||||
if cmap.groups != nil {
|
||||
cmap.mapping = nil
|
||||
for range cmap.pairs() {
|
||||
cmap.entries++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
cmap.mapping, err = parseCmapFormat2(sub)
|
||||
case 8, 10, 13:
|
||||
cmap.mapping, err = parseCmapExtended(sub)
|
||||
case 14:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported cmap format %d", format)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed[offset] = cmap
|
||||
}
|
||||
cmap.platform, cmap.encoding, cmap.format, cmap.duplicate = binary.BigEndian.Uint16(record), binary.BigEndian.Uint16(record[2:]), format, found
|
||||
result = append(result, cmap)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pairs 按原映射返回字符与字形,连续编码段不展开存储
|
||||
// 返回: iter.Seq2[rune, uint16] 非零有效字符映射
|
||||
func (c sfntCmap) pairs() iter.Seq2[rune, uint16] {
|
||||
return func(yield func(rune, uint16) bool) {
|
||||
for char, glyph := range c.mapping {
|
||||
if !yield(char, glyph) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for offset := 0; offset+12 <= len(c.groups); offset += 12 {
|
||||
group := c.groups[offset:]
|
||||
first, last, glyph := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:]), binary.BigEndian.Uint32(group[8:])
|
||||
if first > last || first > utf8.MaxRune || glyph > 65535 {
|
||||
continue
|
||||
}
|
||||
end := min(uint64(last), uint64(first)+65535-uint64(glyph), uint64(utf8.MaxRune))
|
||||
for char := uint64(first); char <= end; char++ {
|
||||
id := uint16(uint64(glyph) + char - uint64(first))
|
||||
if id != 0 && utf8.ValidRune(rune(char)) && !yield(rune(char), id) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lookup 查找原字符映射,连续编码段使用有序区间查询
|
||||
// 入参: char 字符
|
||||
// 返回: uint16 字形编号, bool 是否存在映射
|
||||
func (c sfntCmap) lookup(char rune) (uint16, bool) {
|
||||
if glyph, ok := c.mapping[char]; ok {
|
||||
return glyph, true
|
||||
}
|
||||
if !utf8.ValidRune(char) {
|
||||
return 0, false
|
||||
}
|
||||
low, high := 0, len(c.groups)/12
|
||||
for low < high {
|
||||
middle := low + (high-low)/2
|
||||
group := c.groups[middle*12:]
|
||||
first, last := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:])
|
||||
if uint32(char) < first {
|
||||
high = middle
|
||||
} else if uint32(char) > last {
|
||||
low = middle + 1
|
||||
} else {
|
||||
glyph := uint64(binary.BigEndian.Uint32(group[8:])) + uint64(uint32(char)-first)
|
||||
return uint16(glyph), glyph > 0 && glyph <= 65535
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// UnitsPerEm 返回字体设计单位
|
||||
// 返回: uint16 每em单位数
|
||||
func (f *SFNT) UnitsPerEm() uint16 { return f.units }
|
||||
|
||||
// NumGlyphs 返回字形总数
|
||||
// 返回: uint16 字形数
|
||||
func (f *SFNT) NumGlyphs() uint16 { return f.count }
|
||||
|
||||
// CmapEntries 返回各编码记录的映射项总数,共享子表按记录分别计数
|
||||
// 返回: uint64 用于缓存预算的映射项数量
|
||||
func (f *SFNT) CmapEntries() uint64 {
|
||||
var count uint64
|
||||
for _, cmap := range f.cmaps {
|
||||
count += uint64(len(cmap.mapping)) + cmap.entries
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GlyphIndex 按编码表顺序查找字符,不替换缺失字形
|
||||
// 入参: char 字符
|
||||
// 返回: uint16 字形编号
|
||||
func (f *SFNT) GlyphIndex(char rune) uint16 {
|
||||
for _, cmap := range f.cmaps {
|
||||
if cmap.duplicate {
|
||||
continue
|
||||
}
|
||||
if gid, ok := cmap.lookup(char); ok && gid != 0 {
|
||||
return gid
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// glyphCharacters 返回指向指定字形的全部字符,连续区间按需反查
|
||||
// 入参: gid 字形编号
|
||||
// 返回: []rune 排序后的字符
|
||||
func (f *SFNT) glyphCharacters(gid uint16) []rune {
|
||||
f.reverseOnce.Do(func() {
|
||||
f.reverse = make(map[uint16][]rune)
|
||||
seen := make(map[rune]bool)
|
||||
for _, cmap := range f.cmaps {
|
||||
if cmap.duplicate {
|
||||
continue
|
||||
}
|
||||
for char, glyph := range cmap.mapping {
|
||||
if glyph != 0 && !seen[char] && f.GlyphIndex(char) == glyph {
|
||||
f.reverse[glyph] = append(f.reverse[glyph], char)
|
||||
seen[char] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, chars := range f.reverse {
|
||||
slices.Sort(chars)
|
||||
}
|
||||
})
|
||||
if gid == 0 {
|
||||
return nil
|
||||
}
|
||||
chars := slices.Clone(f.reverse[gid])
|
||||
for _, cmap := range f.cmaps {
|
||||
if cmap.duplicate {
|
||||
continue
|
||||
}
|
||||
for offset := 0; offset+12 <= len(cmap.groups); offset += 12 {
|
||||
group := cmap.groups[offset:]
|
||||
first, last, glyph := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:]), binary.BigEndian.Uint32(group[8:])
|
||||
if first > last || uint32(gid) < glyph {
|
||||
continue
|
||||
}
|
||||
char := uint64(first) + uint64(uint32(gid)-glyph)
|
||||
if char <= uint64(last) && char <= utf8.MaxRune && utf8.ValidRune(rune(char)) && f.GlyphIndex(rune(char)) == gid {
|
||||
chars = append(chars, rune(char))
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(chars)
|
||||
return slices.Compact(chars)
|
||||
}
|
||||
|
||||
// GlyphAdvance 返回水平字宽
|
||||
// 入参: gid 字形编号
|
||||
// 返回: uint16 字宽
|
||||
func (f *SFNT) GlyphAdvance(gid uint16) uint16 {
|
||||
if gid >= f.count {
|
||||
return 0
|
||||
}
|
||||
return binary.BigEndian.Uint16(f.Tables["hmtx"][int(min(gid, f.horizontal-1))*4:])
|
||||
}
|
||||
|
||||
// GlyphVerticalAdvance 返回纵向字距,无垂直度量时使用em高度
|
||||
// 入参: gid 字形编号
|
||||
// 返回: uint16 纵向字距
|
||||
func (f *SFNT) GlyphVerticalAdvance(gid uint16) uint16 {
|
||||
if f.vertical == 0 {
|
||||
return f.units
|
||||
}
|
||||
if gid >= f.count {
|
||||
return 0
|
||||
}
|
||||
return binary.BigEndian.Uint16(f.Tables["vmtx"][int(min(gid, f.vertical-1))*4:])
|
||||
}
|
||||
|
||||
// sideBearing 返回指定方向的字形边距
|
||||
// 入参: gid 字形编号, vertical 是否读取顶部边距
|
||||
// 返回: int16 边距
|
||||
func (f *SFNT) sideBearing(gid uint16, vertical bool) int16 {
|
||||
data, count := f.Tables["hmtx"], f.horizontal
|
||||
if vertical {
|
||||
data, count = f.Tables["vmtx"], f.vertical
|
||||
}
|
||||
if count == 0 || gid >= f.count {
|
||||
return 0
|
||||
}
|
||||
offset := int(gid)*4 + 2
|
||||
if gid >= count {
|
||||
offset = int(count)*4 + int(gid-count)*2
|
||||
}
|
||||
return int16(binary.BigEndian.Uint16(data[offset:]))
|
||||
}
|
||||
|
||||
// VerticalMetrics 依据OS/2排版标志选择行度量,缺失时使用hhea
|
||||
// 返回: uint16 上升高度, uint16 下降高度, uint16 行间隙
|
||||
func (f *SFNT) VerticalMetrics() (uint16, uint16, uint16) {
|
||||
hhea, os2 := f.Tables["hhea"], f.Tables["OS/2"]
|
||||
a, d, gap := int(int16(binary.BigEndian.Uint16(hhea[4:]))), int(int16(binary.BigEndian.Uint16(hhea[6:]))), int(int16(binary.BigEndian.Uint16(hhea[8:])))
|
||||
if len(os2) >= 78 {
|
||||
if binary.BigEndian.Uint16(os2[62:])&0x80 != 0 {
|
||||
ta, td := int(int16(binary.BigEndian.Uint16(os2[68:]))), int(int16(binary.BigEndian.Uint16(os2[70:])))
|
||||
if ta > 0 && td < 0 {
|
||||
a, d, gap = ta, td, int(int16(binary.BigEndian.Uint16(os2[72:])))
|
||||
}
|
||||
} else {
|
||||
wa, wd := int(binary.BigEndian.Uint16(os2[74:])), int(binary.BigEndian.Uint16(os2[76:]))
|
||||
if wa != 0 && wd != 0 {
|
||||
gap, a, d = a-d+gap-wa-wd, wa, -wd
|
||||
}
|
||||
}
|
||||
}
|
||||
return uint16(max(0, a)), uint16(max(0, -d)), uint16(min(65535, max(0, gap)))
|
||||
}
|
||||
|
||||
// Write 保留原始表数据并重新计算字体校验和
|
||||
// 返回: []byte 独立字体
|
||||
func (f *SFNT) Write() []byte { data, _ := SerializeOTF(f.Tables); return data }
|
||||
|
||||
// glyphDependencies 批量收集复合字形依赖,校验引用、记录长度和嵌套深度
|
||||
// 入参: glyphs 字形编号
|
||||
// 返回: []uint16 依赖字形, error 字形错误
|
||||
func (f *SFNT) glyphDependencies(glyphs ...uint16) ([]uint16, error) {
|
||||
heights, active := make(map[uint16]int), make(map[uint16]bool)
|
||||
var result []uint16
|
||||
var visit func(uint16) error
|
||||
visit = func(id uint16) error {
|
||||
if id >= f.count || active[id] || len(active) > 64 {
|
||||
return fmt.Errorf("invalid composite glyph reference")
|
||||
}
|
||||
if height := heights[id]; height != 0 {
|
||||
if len(active)+height > 65 {
|
||||
return fmt.Errorf("invalid composite glyph reference")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
heights[id], active[id] = 1, true
|
||||
defer delete(active, id)
|
||||
data, err := trueTypeGlyphData(f.Tables, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(data) < 10 {
|
||||
return fmt.Errorf("truncated glyph header")
|
||||
}
|
||||
if int16(binary.BigEndian.Uint16(data)) >= 0 {
|
||||
return nil
|
||||
}
|
||||
for pos := 10; ; {
|
||||
if len(data)-pos < 4 {
|
||||
return fmt.Errorf("truncated composite glyph")
|
||||
}
|
||||
flags, child := binary.BigEndian.Uint16(data[pos:]), binary.BigEndian.Uint16(data[pos+2:])
|
||||
pos += 6
|
||||
if flags&1 != 0 {
|
||||
pos += 2
|
||||
}
|
||||
switch flags & 0xc8 {
|
||||
case 8:
|
||||
pos += 2
|
||||
case 0x40:
|
||||
pos += 4
|
||||
case 0x80:
|
||||
pos += 8
|
||||
case 0:
|
||||
default:
|
||||
return fmt.Errorf("invalid composite transform")
|
||||
}
|
||||
if pos > len(data) {
|
||||
return fmt.Errorf("truncated composite transform")
|
||||
}
|
||||
if heights[child] == 0 {
|
||||
result = append(result, child)
|
||||
}
|
||||
if err := visit(child); err != nil {
|
||||
return err
|
||||
}
|
||||
heights[id] = max(heights[id], heights[child]+1)
|
||||
if flags&0x20 == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, id := range glyphs {
|
||||
if err := visit(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/go-text/typesetting/font/opentype/tables"
|
||||
)
|
||||
|
||||
// Subsettable 判断默认资源后端能否无损裁剪该字体
|
||||
// 入参: sfnt 已解析字体
|
||||
// 返回: bool 是否支持裁剪
|
||||
func Subsettable(sfnt *SFNT) bool {
|
||||
if fontEmbeddingFlags(sfnt)&0x0100 != 0 || slices.ContainsFunc([]string{"COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) {
|
||||
return false
|
||||
}
|
||||
if sfnt.IsCFF {
|
||||
if sfnt.Tables["fvar"] != nil {
|
||||
return false
|
||||
}
|
||||
font, err := readType2Font(sfnt.Tables["CFF "])
|
||||
if err == nil {
|
||||
return len(font.chars) == int(sfnt.NumGlyphs())
|
||||
}
|
||||
cid, err := readCIDSubsetFont(sfnt.Tables["CFF "])
|
||||
return err == nil && len(cid.fonts[0].chars) == int(sfnt.NumGlyphs())
|
||||
}
|
||||
return sfnt.IsTrueType && (sfnt.Tables["fvar"] == nil || variableSFNTSubsettable(sfnt))
|
||||
}
|
||||
|
||||
// fontEmbeddingFlags 读取OpenType的嵌入标志,未提供OS/2表时返回零
|
||||
// 入参: sfnt 已解析字体
|
||||
// 返回: uint16 嵌入标志
|
||||
func fontEmbeddingFlags(sfnt *SFNT) uint16 {
|
||||
if table := sfnt.Tables["OS/2"]; len(table) >= 10 {
|
||||
return binary.BigEndian.Uint16(table[8:10])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ValidateEmbedding 校验字体是否允许作为可编辑轮廓字体嵌入
|
||||
// 入参: data 独立OpenType字体
|
||||
// 返回: error 字体解析或嵌入限制
|
||||
func ValidateEmbedding(data []byte) error {
|
||||
sfnt, err := ParseSFNT(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flags := fontEmbeddingFlags(sfnt)
|
||||
if flags&0x0200 != 0 || flags&0x000e != 0 && flags&0x0008 == 0 {
|
||||
return fmt.Errorf("font does not permit editable embedding")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SFNTName 获取字体中的名称
|
||||
// 入参: sfnt 字体, names 名称类型,按顺序查找
|
||||
// 返回: string 字体名称
|
||||
func SFNTName(sfnt *SFNT, names ...uint16) string {
|
||||
records, values := fontNameValues(sfnt.Tables["name"])
|
||||
for _, name := range names {
|
||||
for _, record := range records {
|
||||
if record.Name != name {
|
||||
continue
|
||||
}
|
||||
if value := values[[4]uint16{record.Platform, record.Encoding, record.Language, name}]; value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubsetSFNT 按实际用字裁剪字体,保留字符映射、名称及度量
|
||||
// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号
|
||||
// 返回: []byte 字体子集, error 错误信息
|
||||
func SubsetSFNT(data []byte, glyphs []uint16, mapped bool) ([]byte, error) {
|
||||
data, err := DefaultCFF2Font(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sfnt, err := ParseSFNT(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fontEmbeddingFlags(sfnt)&0x0100 != 0 {
|
||||
return bytes.Clone(data), nil
|
||||
}
|
||||
if sfnt.IsCFF {
|
||||
count, err := parseCFFAndCountGlyphs(sfnt.Tables["CFF "])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != int(sfnt.NumGlyphs()) {
|
||||
return nil, fmt.Errorf("CFF glyph count does not match maxp")
|
||||
}
|
||||
return subsetCFFFont(sfnt, glyphs, mapped)
|
||||
}
|
||||
if sfnt.Tables["fvar"] != nil {
|
||||
return subsetVariableSFNTFont(sfnt, glyphs)
|
||||
}
|
||||
if mapped {
|
||||
usage := newUsage()
|
||||
for _, id := range glyphs {
|
||||
usage.Glyphs[id] = true
|
||||
for _, char := range sfnt.glyphCharacters(id) {
|
||||
usage.Characters[char] = true
|
||||
}
|
||||
}
|
||||
tables := make(map[string][]byte)
|
||||
for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "vhea", "vmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} {
|
||||
if table := sfnt.Tables[tag]; table != nil {
|
||||
tables[tag] = table
|
||||
}
|
||||
}
|
||||
fixed, err := SerializeOTF(tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sfnt.Tables = tables
|
||||
if subset := subsetSourceSFNT(sfnt, usage, len(fixed)); subset != nil {
|
||||
return subset, nil
|
||||
}
|
||||
return fixed, nil
|
||||
}
|
||||
return subsetCompactSFNTFont(sfnt, glyphs)
|
||||
}
|
||||
|
||||
// SubsetSourceSFNT 裁剪静态TrueType或CFF原有字体,保留字形编号、组合依赖、度量和提示指令
|
||||
// 字形替换结果一并保留,集合、可变、彩色和未知表保持原样
|
||||
// 入参: data 字体数据, usage 全包用字记录
|
||||
// 返回: []byte 更小的字体子集,无确定收益时为空
|
||||
func SubsetSourceSFNT(data []byte, usage *Usage) []byte {
|
||||
if usage == nil || usage.Unsafe || len(usage.Characters)+len(usage.Glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) {
|
||||
return nil
|
||||
}
|
||||
fontTables, err := FileTables(data, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for tag := range fontTables {
|
||||
switch tag {
|
||||
case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "CFF ", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "VORG", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "GSUB", "meta", "DSIG", "FFTM", "PCLT":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
sfnt, err := ParseSFNTTables(fontTables)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return subsetSourceSFNT(sfnt, usage, len(data))
|
||||
}
|
||||
|
||||
// subsetSourceSFNT 复用已校验的字体表裁剪字形,仅返回小于原编码的结果
|
||||
// 入参: sfnt 已解析字体, usage 用字记录, size 原编码长度
|
||||
// 返回: []byte 更小的字体子集,无确定收益时为空
|
||||
func subsetSourceSFNT(sfnt *SFNT, usage *Usage, size int) []byte {
|
||||
if usage == nil || usage.Unsafe || len(usage.Characters)+len(usage.Glyphs) == 0 || !sfnt.IsTrueType && !sfnt.IsCFF || fontEmbeddingFlags(sfnt)&0x0100 != 0 {
|
||||
return nil
|
||||
}
|
||||
var unicodeTables []sfntCmap
|
||||
for _, record := range sfnt.cmaps {
|
||||
if record.format == 14 {
|
||||
continue
|
||||
}
|
||||
if record.platform == 0 || record.platform == 3 && (record.encoding == 1 || record.encoding == 10) {
|
||||
unicodeTables = append(unicodeTables, record)
|
||||
}
|
||||
}
|
||||
if len(unicodeTables) == 0 {
|
||||
return nil
|
||||
}
|
||||
glyphs := maps.Clone(usage.Glyphs)
|
||||
if glyphs == nil {
|
||||
glyphs = make(map[uint16]bool)
|
||||
}
|
||||
glyphs[0] = true
|
||||
mapping := make(map[rune]uint16, len(usage.Characters))
|
||||
for char := range usage.Characters {
|
||||
id := sfnt.GlyphIndex(char)
|
||||
for _, cmap := range unicodeTables {
|
||||
if alternate, ok := cmap.lookup(char); ok && alternate != id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
glyphs[id] = true
|
||||
if id != 0 {
|
||||
mapping[char] = id
|
||||
}
|
||||
}
|
||||
variations, ok := subsetCmapVariations(sfnt.Tables["cmap"], usage.Characters, glyphs, sfnt.NumGlyphs())
|
||||
if !ok || !retainSFNTSubstitutions(sfnt.Tables["GSUB"], glyphs, sfnt.NumGlyphs()) {
|
||||
return nil
|
||||
}
|
||||
if sfnt.IsCFF {
|
||||
count, err := parseCFFAndCountGlyphs(sfnt.Tables["CFF "])
|
||||
if err != nil || count != int(sfnt.NumGlyphs()) {
|
||||
return nil
|
||||
}
|
||||
cff, err := subsetCFFCharstrings(sfnt.Tables["CFF "], glyphs)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tables := maps.Clone(sfnt.Tables)
|
||||
tables["CFF "] = cff
|
||||
tables["cmap"] = buildCmapWithVariations(sfnt.NumGlyphs(), mapping, variations)
|
||||
delete(tables, "DSIG")
|
||||
result, err := SerializeOTF(tables)
|
||||
if err != nil || len(result) >= size {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
for id := 0; id < int(sfnt.NumGlyphs()); id++ {
|
||||
if _, err := trueTypeGlyphData(sfnt.Tables, uint16(id)); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
dependencies, err := sfnt.glyphDependencies(slices.Collect(maps.Keys(glyphs))...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, dependency := range dependencies {
|
||||
glyphs[dependency] = true
|
||||
}
|
||||
glyfSize := 0
|
||||
for id, retained := range glyphs {
|
||||
if retained {
|
||||
data, _ := trueTypeGlyphData(sfnt.Tables, id)
|
||||
glyfSize += len(data) + len(data)%2
|
||||
}
|
||||
}
|
||||
locaFormat := binary.BigEndian.Uint16(sfnt.Tables["head"][50:])
|
||||
glyf := make([]byte, 0, glyfSize)
|
||||
loca := make([]byte, 0, (int(sfnt.NumGlyphs())+1)*(2+2*int(locaFormat)))
|
||||
for id := 0; id <= int(sfnt.NumGlyphs()); id++ {
|
||||
if locaFormat == 0 {
|
||||
loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2))
|
||||
} else {
|
||||
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
|
||||
}
|
||||
if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] {
|
||||
data, _ := trueTypeGlyphData(sfnt.Tables, uint16(id))
|
||||
glyf = append(glyf, data...)
|
||||
if len(glyf)%2 != 0 {
|
||||
glyf = append(glyf, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := maps.Clone(sfnt.Tables)
|
||||
tables["glyf"], tables["loca"] = glyf, loca
|
||||
tables["cmap"] = buildCmapWithVariations(sfnt.NumGlyphs(), mapping, variations)
|
||||
tables["head"] = bytes.Clone(tables["head"])
|
||||
clear(tables["head"][8:12])
|
||||
delete(tables, "DSIG")
|
||||
result, err := SerializeOTF(tables)
|
||||
if err != nil || len(result) >= size {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// retainSFNTSubstitutions 保留全部替换结果,使原字形替换表在裁剪后仍可使用
|
||||
// 入参: data 替换表, glyphs 待保留字形, count 字形总数
|
||||
// 返回: bool 替换表及结果编号是否有效
|
||||
func retainSFNTSubstitutions(data []byte, glyphs map[uint16]bool, count uint16) bool {
|
||||
if len(data) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(data) < 10 || binary.BigEndian.Uint16(data) != 1 || binary.BigEndian.Uint16(data[2:]) > 1 {
|
||||
return false
|
||||
}
|
||||
if binary.BigEndian.Uint16(data[2:]) == 1 && (len(data) < 14 || binary.BigEndian.Uint32(data[10:]) != 0) {
|
||||
return false
|
||||
}
|
||||
layout, _, err := tables.ParseLayout(data)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, lookup := range layout.LookupList.Lookups {
|
||||
subtables, err := lookup.AsGSUBLookups()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, subtable := range subtables {
|
||||
if extension, ok := subtable.(tables.ExtensionSubs); ok {
|
||||
if extension.ExtensionOffset < 8 || uint64(extension.ExtensionOffset) > uint64(len(extension.RawData)) {
|
||||
return false
|
||||
}
|
||||
subtable, err = extension.Resolve()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !retainSFNTSubstitution(subtable, glyphs, count) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// retainSFNTSubstitution 收集替换子表的输出字形,上下文替换的结果由被引用的查找表提供
|
||||
// 入参: subtable 替换子表, glyphs 待保留字形, count 字形总数
|
||||
// 返回: bool 子表类型及输出字形是否有效
|
||||
func retainSFNTSubstitution(subtable tables.GSUBLookup, glyphs map[uint16]bool, count uint16) bool {
|
||||
var outputs []tables.GlyphID
|
||||
switch subtable := subtable.(type) {
|
||||
case tables.SingleSubs:
|
||||
switch data := subtable.Data.(type) {
|
||||
case tables.SingleSubstData1:
|
||||
for id := uint16(0); id < count; id++ {
|
||||
if _, ok := data.Coverage.Index(tables.GlyphID(id)); ok {
|
||||
outputs = append(outputs, tables.GlyphID(id+uint16(data.DeltaGlyphID)))
|
||||
}
|
||||
}
|
||||
case tables.SingleSubstData2:
|
||||
outputs = data.SubstituteGlyphIDs
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case tables.MultipleSubs:
|
||||
for _, sequence := range subtable.Sequences {
|
||||
outputs = append(outputs, sequence.SubstituteGlyphIDs...)
|
||||
}
|
||||
case tables.AlternateSubs:
|
||||
for _, set := range subtable.AlternateSets {
|
||||
outputs = append(outputs, set.AlternateGlyphIDs...)
|
||||
}
|
||||
case tables.LigatureSubs:
|
||||
for _, set := range subtable.LigatureSets {
|
||||
for _, ligature := range set.Ligatures {
|
||||
outputs = append(outputs, ligature.LigatureGlyph)
|
||||
}
|
||||
}
|
||||
case tables.ReverseChainSingleSubs:
|
||||
outputs = subtable.SubstituteGlyphIDs
|
||||
case tables.ContextualSubs, tables.ChainedContextualSubs:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
for _, id := range outputs {
|
||||
if uint16(id) >= count {
|
||||
return false
|
||||
}
|
||||
glyphs[uint16(id)] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// subsetCompactSFNTFont 重排新建静态字体的字形,同步复合引用、字符映射和度量
|
||||
// 入参: sfnt 字体, glyphs 使用字形
|
||||
// 返回: []byte 紧凑字体子集, error 字形数据错误
|
||||
func subsetCompactSFNTFont(sfnt *SFNT, glyphs []uint16) ([]byte, error) {
|
||||
ids := []uint16{0}
|
||||
remap := map[uint16]uint16{0: 0}
|
||||
for _, id := range glyphs {
|
||||
if id >= sfnt.NumGlyphs() {
|
||||
return nil, fmt.Errorf("font glyph index out of range")
|
||||
}
|
||||
if _, ok := remap[id]; !ok {
|
||||
remap[id] = uint16(len(ids))
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
dependencies, err := sfnt.glyphDependencies(ids...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range dependencies {
|
||||
if _, ok := remap[id]; !ok {
|
||||
remap[id] = uint16(len(ids))
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
table := make(map[string][]byte)
|
||||
for _, tag := range []string{"head", "hhea", "maxp", "OS/2", "post", "name", "cvt ", "fpgm", "prep", "gasp", "vhea"} {
|
||||
if data := sfnt.Tables[tag]; data != nil {
|
||||
table[tag] = bytes.Clone(data)
|
||||
}
|
||||
}
|
||||
var glyf, loca, hmtx, vmtx []byte
|
||||
mapping := make(map[rune]uint16)
|
||||
for _, id := range ids {
|
||||
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
|
||||
data, err := trueTypeGlyphData(sfnt.Tables, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = bytes.Clone(data)
|
||||
if len(data) >= 10 && int16(binary.BigEndian.Uint16(data)) < 0 {
|
||||
for pos := 10; ; {
|
||||
flags := binary.BigEndian.Uint16(data[pos:])
|
||||
child := binary.BigEndian.Uint16(data[pos+2:])
|
||||
binary.BigEndian.PutUint16(data[pos+2:], remap[child])
|
||||
pos += 6
|
||||
if flags&1 != 0 {
|
||||
pos += 2
|
||||
}
|
||||
switch flags & 0xc8 {
|
||||
case 8:
|
||||
pos += 2
|
||||
case 0x40:
|
||||
pos += 4
|
||||
case 0x80:
|
||||
pos += 8
|
||||
}
|
||||
if flags&0x20 == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
glyf = append(glyf, data...)
|
||||
for len(glyf)%4 != 0 {
|
||||
glyf = append(glyf, 0)
|
||||
}
|
||||
hmtx = binary.BigEndian.AppendUint16(hmtx, sfnt.GlyphAdvance(id))
|
||||
hmtx = binary.BigEndian.AppendUint16(hmtx, uint16(sfnt.sideBearing(id, false)))
|
||||
if sfnt.vertical != 0 {
|
||||
vmtx = binary.BigEndian.AppendUint16(vmtx, sfnt.GlyphVerticalAdvance(id))
|
||||
vmtx = binary.BigEndian.AppendUint16(vmtx, uint16(sfnt.sideBearing(id, true)))
|
||||
}
|
||||
for _, char := range sfnt.glyphCharacters(id) {
|
||||
mapping[char] = remap[id]
|
||||
}
|
||||
}
|
||||
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
|
||||
table["glyf"], table["loca"], table["hmtx"] = glyf, loca, hmtx
|
||||
table["cmap"] = BuildCmapTable(uint16(len(ids)), mapping)
|
||||
binary.BigEndian.PutUint16(table["head"][50:], 1)
|
||||
binary.BigEndian.PutUint16(table["hhea"][34:], uint16(len(ids)))
|
||||
binary.BigEndian.PutUint16(table["maxp"][4:], uint16(len(ids)))
|
||||
if len(table["post"]) >= 32 {
|
||||
table["post"] = table["post"][:32]
|
||||
binary.BigEndian.PutUint32(table["post"], 0x30000)
|
||||
}
|
||||
if sfnt.vertical != 0 {
|
||||
table["vmtx"] = vmtx
|
||||
binary.BigEndian.PutUint16(table["vhea"][34:], uint16(len(ids)))
|
||||
}
|
||||
return SerializeOTF(table)
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,14 +23,14 @@ import (
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// symbolFontProgram 为标准符号字体补齐Unicode映射,保留字形编号和轮廓
|
||||
// SymbolProgram 为标准符号字体补齐Unicode映射,保留字形编号和轮廓
|
||||
// 入参: ctx 取消上下文, data 独立字体数据, name 标准字体名,为空时从字体名称表识别
|
||||
// 返回: []byte 字体数据, map[string]uint16 标准字形编号,不适用时为空, error 映射或取消错误
|
||||
func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, map[string]uint16, error) {
|
||||
func SymbolProgram(ctx context.Context, data []byte, name string) ([]byte, map[string]uint16, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tables, err := fontFileTables(data, 0)
|
||||
tables, err := FileTables(data, 0)
|
||||
if err != nil {
|
||||
return data, nil, nil
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, m
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
original := parseCmapMappings(tables["cmap"])
|
||||
original := ParseCmapMappings(tables["cmap"])
|
||||
mapping := make(map[rune]uint16)
|
||||
names := make(map[string]uint16)
|
||||
for _, glyph := range glyphs {
|
||||
@@ -90,8 +90,8 @@ func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, m
|
||||
if !legacy || len(mapping) == 0 {
|
||||
return data, names, nil
|
||||
}
|
||||
tables["cmap"] = buildCmapTable(0, mapping)
|
||||
program, err := serializeOTF(tables)
|
||||
tables["cmap"] = BuildCmapTable(0, mapping)
|
||||
program, err := SerializeOTF(tables)
|
||||
return program, names, err
|
||||
}
|
||||
|
||||
@@ -99,8 +99,8 @@ func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, m
|
||||
// 入参: data 名称表数据
|
||||
// 返回: string 标准字体名,无法识别时为空
|
||||
func symbolFontName(data []byte) string {
|
||||
for _, name := range fontNamesFromTable(data) {
|
||||
switch fontNormalizeName(name) {
|
||||
for _, name := range NamesFromTable(data) {
|
||||
switch NormalizeName(name) {
|
||||
case "symbol", "symbolmt", "symbolregular":
|
||||
return "Symbol"
|
||||
case "zapfdingbats", "zapfdingbatsregular", "itczapfdingbats":
|
||||
@@ -12,17 +12,53 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
// NormalizeOS2 去除已知版本OS/2表的冗余零尾部,保留所有标准字段
|
||||
// 入参: data 原始表数据
|
||||
// 返回: []byte 规范化表数据,未知版本、截断或非零尾部保持原样
|
||||
func NormalizeOS2(data []byte) []byte {
|
||||
if len(data) < 2 {
|
||||
return data
|
||||
}
|
||||
var size int
|
||||
switch binary.BigEndian.Uint16(data) {
|
||||
case 0:
|
||||
size = 78
|
||||
case 1:
|
||||
size = 86
|
||||
case 2, 3, 4:
|
||||
size = 96
|
||||
case 5:
|
||||
size = 100
|
||||
default:
|
||||
return data
|
||||
}
|
||||
if len(data) <= size {
|
||||
return data
|
||||
}
|
||||
for _, value := range data[size:] {
|
||||
if value != 0 {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return data[:size]
|
||||
}
|
||||
|
||||
// fixTrueType 尝试修复TrueType/OpenType字体数据
|
||||
// 入参: data 原始字体数据, fixCmap 是否修复cmap, fixName 是否修复name
|
||||
// 返回: bool 是否修复, []byte 修复后数据, map[rune]uint16 字符映射, bool 是否缺失cmap, error 错误信息
|
||||
func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uint16, bool, error) {
|
||||
original := data
|
||||
data, err := DefaultCFF2Font(data)
|
||||
if err != nil {
|
||||
return false, original, nil, false, err
|
||||
}
|
||||
if len(data) < 12 {
|
||||
return false, data, nil, false, nil
|
||||
}
|
||||
@@ -30,7 +66,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
isCFFSfnt := sfntTag == "OTTO"
|
||||
numTables := binary.BigEndian.Uint16(data[4:6])
|
||||
existingTables := make(map[string][]byte)
|
||||
malformedDirectory := false
|
||||
malformedDirectory := !bytes.Equal(original, data)
|
||||
pos := 12
|
||||
for i := 0; i < int(numTables); i++ {
|
||||
if len(data) < pos+16 {
|
||||
@@ -53,7 +89,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
}
|
||||
if existingTables["CFF "] != nil {
|
||||
isCFFSfnt = true
|
||||
sanitized, err := sanitizeCFF(existingTables["CFF "])
|
||||
sanitized, err := SanitizeCFF(existingTables["CFF "])
|
||||
if err != nil {
|
||||
return false, data, nil, false, err
|
||||
}
|
||||
@@ -61,7 +97,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
if head := existingTables["head"]; len(head) >= 20 {
|
||||
units = binary.BigEndian.Uint16(head[18:20])
|
||||
}
|
||||
normalized, err := normalizeCFFCharstringsAt(sanitized, units)
|
||||
normalized, err := NormalizeCFFCharstringsAt(sanitized, units)
|
||||
if err != nil {
|
||||
return false, data, nil, false, err
|
||||
}
|
||||
@@ -70,7 +106,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
malformedDirectory = true
|
||||
}
|
||||
}
|
||||
cmap, err := normalizeCmap(existingTables["cmap"])
|
||||
cmap, err := NormalizeCmap(existingTables["cmap"])
|
||||
if err != nil {
|
||||
return false, data, nil, false, err
|
||||
}
|
||||
@@ -78,6 +114,10 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
existingTables["cmap"] = cmap
|
||||
malformedDirectory = true
|
||||
}
|
||||
if os2 := NormalizeOS2(existingTables["OS/2"]); len(os2) != len(existingTables["OS/2"]) {
|
||||
existingTables["OS/2"] = os2
|
||||
malformedDirectory = true
|
||||
}
|
||||
missingHead := existingTables["head"] == nil
|
||||
if !missingHead {
|
||||
head := existingTables["head"]
|
||||
@@ -188,12 +228,12 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
descender = int16(binary.BigEndian.Uint16(hhea[6:8]))
|
||||
}
|
||||
if missingOS2 {
|
||||
newTables["OS/2"] = buildOS2TableWithMetrics(ascender, descender)
|
||||
newTables["OS/2"] = BuildOS2TableWithMetrics(ascender, descender)
|
||||
}
|
||||
var mapping map[rune]uint16
|
||||
if fixCmap {
|
||||
if !missingCmap {
|
||||
mapping = parseCmapMappings(newTables["cmap"])
|
||||
mapping = ParseCmapMappings(newTables["cmap"])
|
||||
}
|
||||
if len(mapping) == 0 && isCFFSfnt {
|
||||
mapping = getCmapFromCFF(newTables["CFF "], int(numGlyphs))
|
||||
@@ -201,16 +241,16 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
|
||||
if len(mapping) == 0 {
|
||||
mapping = make(map[rune]uint16)
|
||||
}
|
||||
addPackedGlyphMapping(mapping, numGlyphs)
|
||||
newTables["cmap"] = buildCmapTable(numGlyphs, mapping)
|
||||
AddPackedGlyphMapping(mapping, numGlyphs)
|
||||
newTables["cmap"] = BuildCmapTable(numGlyphs, mapping)
|
||||
}
|
||||
if missingName && fixName {
|
||||
newTables["name"] = buildNameTable()
|
||||
}
|
||||
if missingPost {
|
||||
newTables["post"] = buildPostTable()
|
||||
newTables["post"] = BuildPostTable()
|
||||
}
|
||||
finalData, err := serializeOTF(newTables)
|
||||
finalData, err := SerializeOTF(newTables)
|
||||
if err != nil {
|
||||
return false, data, nil, missingCmap, err
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -41,8 +41,8 @@ var type1StandardNames = func() [256]string {
|
||||
return names
|
||||
}()
|
||||
|
||||
// type1Program 保存解密后的Type1字形与子程序
|
||||
type type1Program struct {
|
||||
// Type1Program 保存解密后的Type1字形与子程序
|
||||
type Type1Program struct {
|
||||
glyphs []type1Glyph
|
||||
subrs [][]byte
|
||||
lenIV int
|
||||
@@ -63,7 +63,7 @@ type type1Scanner struct {
|
||||
|
||||
// type1Outline 保存字形指令的操作数栈及轮廓位置
|
||||
type type1Outline struct {
|
||||
program *type1Program
|
||||
program *Type1Program
|
||||
steps *int
|
||||
active map[string]bool
|
||||
args []float64
|
||||
@@ -84,24 +84,24 @@ type type1Outline struct {
|
||||
path bytes.Buffer
|
||||
}
|
||||
|
||||
// parseType1Program 读取Type1字体的字形程序及子程序
|
||||
// ParseType1Program 读取Type1字体的字形程序及子程序
|
||||
// 入参: data PFA或PFB字体数据
|
||||
// 返回: type1Program 字形和子程序, error 错误信息
|
||||
func parseType1Program(data []byte) (type1Program, error) {
|
||||
// 返回: Type1Program 字形和子程序, error 错误信息
|
||||
func ParseType1Program(data []byte) (Type1Program, error) {
|
||||
var err error
|
||||
data, err = flattenType1(data)
|
||||
if err != nil {
|
||||
return type1Program{}, err
|
||||
return Type1Program{}, err
|
||||
}
|
||||
matrix, start, err := readType1Header(data)
|
||||
if err != nil {
|
||||
return type1Program{}, err
|
||||
return Type1Program{}, err
|
||||
}
|
||||
for start < len(data) && type1Space(data[start]) {
|
||||
start++
|
||||
}
|
||||
if start == len(data) {
|
||||
return type1Program{}, fmt.Errorf("empty Type1 eexec data")
|
||||
return Type1Program{}, fmt.Errorf("empty Type1 eexec data")
|
||||
}
|
||||
encrypted := data[start:]
|
||||
if type1HexEncoded(encrypted) {
|
||||
@@ -121,15 +121,15 @@ func parseType1Program(data []byte) (type1Program, error) {
|
||||
}
|
||||
encrypted = make([]byte, hex.DecodedLen(len(digits)))
|
||||
if _, err := hex.Decode(encrypted, digits); err != nil {
|
||||
return type1Program{}, fmt.Errorf("invalid Type1 eexec encoding: %w", err)
|
||||
return Type1Program{}, fmt.Errorf("invalid Type1 eexec encoding: %w", err)
|
||||
}
|
||||
}
|
||||
if len(encrypted) < 4 {
|
||||
return type1Program{}, fmt.Errorf("invalid Type1 eexec data")
|
||||
return Type1Program{}, fmt.Errorf("invalid Type1 eexec data")
|
||||
}
|
||||
decrypted := type1Decrypt(encrypted, 55665)[4:]
|
||||
scanner := type1Scanner{data: decrypted}
|
||||
program := type1Program{lenIV: 4, matrix: matrix}
|
||||
program := Type1Program{lenIV: 4, matrix: matrix}
|
||||
section := ""
|
||||
for {
|
||||
token := scanner.next()
|
||||
@@ -140,7 +140,7 @@ func parseType1Program(data []byte) (type1Program, error) {
|
||||
case "/lenIV":
|
||||
value, err := strconv.Atoi(scanner.next())
|
||||
if err != nil || value < -1 {
|
||||
return type1Program{}, fmt.Errorf("invalid Type1 lenIV")
|
||||
return Type1Program{}, fmt.Errorf("invalid Type1 lenIV")
|
||||
}
|
||||
program.lenIV = value
|
||||
case "/Subrs":
|
||||
@@ -157,18 +157,18 @@ func parseType1Program(data []byte) (type1Program, error) {
|
||||
}
|
||||
index, err := strconv.Atoi(scanner.next())
|
||||
if err != nil || index < 0 || index > len(decrypted) {
|
||||
return type1Program{}, fmt.Errorf("invalid Type1 subroutine index")
|
||||
return Type1Program{}, fmt.Errorf("invalid Type1 subroutine index")
|
||||
}
|
||||
length, err := strconv.Atoi(scanner.next())
|
||||
if err != nil {
|
||||
return type1Program{}, fmt.Errorf("invalid Type1 subroutine length")
|
||||
return Type1Program{}, fmt.Errorf("invalid Type1 subroutine length")
|
||||
}
|
||||
if !type1BinaryStart(scanner.next()) {
|
||||
return type1Program{}, fmt.Errorf("missing Type1 subroutine data")
|
||||
return Type1Program{}, fmt.Errorf("missing Type1 subroutine data")
|
||||
}
|
||||
raw, err := scanner.binary(length)
|
||||
if err != nil {
|
||||
return type1Program{}, err
|
||||
return Type1Program{}, err
|
||||
}
|
||||
for len(program.subrs) <= index {
|
||||
program.subrs = append(program.subrs, nil)
|
||||
@@ -185,33 +185,91 @@ func parseType1Program(data []byte) (type1Program, error) {
|
||||
continue
|
||||
}
|
||||
if !type1BinaryStart(scanner.next()) {
|
||||
return type1Program{}, fmt.Errorf("missing Type1 glyph data")
|
||||
return Type1Program{}, fmt.Errorf("missing Type1 glyph data")
|
||||
}
|
||||
raw, err := scanner.binary(length)
|
||||
if err != nil {
|
||||
return type1Program{}, err
|
||||
return Type1Program{}, err
|
||||
}
|
||||
program.glyphs = append(program.glyphs, type1Glyph{name: name, data: raw})
|
||||
}
|
||||
}
|
||||
if len(program.glyphs) == 0 {
|
||||
return type1Program{}, fmt.Errorf("Type1 font has no glyphs")
|
||||
return Type1Program{}, fmt.Errorf("Type1 font has no glyphs")
|
||||
}
|
||||
for index := range program.subrs {
|
||||
if program.subrs[index] == nil {
|
||||
continue
|
||||
}
|
||||
program.subrs[index], err = type1CharString(program.subrs[index], program.lenIV)
|
||||
if err != nil {
|
||||
return type1Program{}, err
|
||||
return Type1Program{}, err
|
||||
}
|
||||
}
|
||||
for index := range program.glyphs {
|
||||
program.glyphs[index].data, err = type1CharString(program.glyphs[index].data, program.lenIV)
|
||||
if err != nil {
|
||||
return type1Program{}, fmt.Errorf("Type1 glyph %s: %w", program.glyphs[index].name, err)
|
||||
return Type1Program{}, fmt.Errorf("Type1 glyph %s: %w", program.glyphs[index].name, err)
|
||||
}
|
||||
}
|
||||
return program, nil
|
||||
}
|
||||
|
||||
// NormalizePDFType1 修复完整加密分段之后的PFB明文尾部,不补齐加密或字形数据
|
||||
// 入参: data 原始PFB程序
|
||||
// 返回: []byte 原程序或独立修复容器, bool 是否修复, error 容器或程序错误
|
||||
func NormalizePDFType1(data []byte) ([]byte, bool, error) {
|
||||
if len(data) < 6 || data[0] != 0x80 {
|
||||
return data, false, nil
|
||||
}
|
||||
binarySeen := false
|
||||
for offset := 0; offset+2 <= len(data) && data[offset] == 0x80; {
|
||||
kind := data[offset+1]
|
||||
if kind == 3 {
|
||||
return data, false, nil
|
||||
}
|
||||
if offset+6 > len(data) || kind != 1 && kind != 2 {
|
||||
return data, false, fmt.Errorf("incomplete PFB segment header")
|
||||
}
|
||||
length := uint64(binary.LittleEndian.Uint32(data[offset+2:]))
|
||||
remaining := len(data) - offset - 6
|
||||
if binarySeen && kind == 1 && remaining == 0 {
|
||||
result := append(bytes.Clone(data[:offset]), 0x80, 3)
|
||||
if _, err := ParseType1Program(result); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
if length > uint64(remaining) {
|
||||
return nil, false, fmt.Errorf("incomplete PFB encrypted or text segment")
|
||||
}
|
||||
if binarySeen && kind == 1 && length == 0 {
|
||||
tail := data[offset+6:]
|
||||
if len(tail) < 2 || !bytes.Equal(tail[len(tail)-2:], []byte{0x80, 3}) {
|
||||
return data, false, fmt.Errorf("unbounded PFB text trailer")
|
||||
}
|
||||
text := bytes.TrimSpace(tail[:len(tail)-2])
|
||||
if !bytes.HasSuffix(text, []byte("cleartomark")) {
|
||||
return data, false, fmt.Errorf("PFB trailer lacks cleartomark")
|
||||
}
|
||||
for _, b := range bytes.TrimSuffix(text, []byte("cleartomark")) {
|
||||
if b != '0' && !type1Space(b) {
|
||||
return data, false, fmt.Errorf("unexpected PFB text trailer")
|
||||
}
|
||||
}
|
||||
result := bytes.Clone(data)
|
||||
binary.LittleEndian.PutUint32(result[offset+2:], uint32(len(tail)-2))
|
||||
if _, err := ParseType1Program(result); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
binarySeen = binarySeen || kind == 2
|
||||
offset += 6 + int(length)
|
||||
}
|
||||
return data, false, nil
|
||||
}
|
||||
|
||||
// readType1Header 读取明文字体矩阵及加密数据起点
|
||||
// 入参: data Type1程序
|
||||
// 返回: []float64 字体矩阵, int 加密起点, error 头部错误
|
||||
@@ -426,7 +484,7 @@ func type1BinaryStart(token string) bool {
|
||||
// outline 将Type1字形指令转换为保留三次曲线的Type2程序
|
||||
// 入参: glyph 字形数据
|
||||
// 返回: []byte Type2字形程序, error 错误信息
|
||||
func (p *type1Program) outline(glyph type1Glyph) ([]byte, error) {
|
||||
func (p *Type1Program) outline(glyph type1Glyph) ([]byte, error) {
|
||||
state := type1Outline{program: p, active: map[string]bool{glyph.name: true}}
|
||||
ended, err := state.run(glyph.data, 0)
|
||||
if err != nil {
|
||||
@@ -840,10 +898,10 @@ func encodeType2Number(target *bytes.Buffer, value float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// toCFF 将Type1字形转换为保持三次轮廓的CFF字体
|
||||
// ToCFF 将Type1字形转换为保持三次轮廓的CFF字体
|
||||
// 入参: name 字体名称
|
||||
// 返回: []byte CFF数据, error 错误信息
|
||||
func (p *type1Program) toCFF(name string) ([]byte, error) {
|
||||
func (p *Type1Program) ToCFF(name string) ([]byte, error) {
|
||||
if len(p.glyphs) > math.MaxUint16 || name == "" {
|
||||
return nil, fmt.Errorf("invalid Type1 font identity")
|
||||
}
|
||||
@@ -907,9 +965,9 @@ func (p *type1Program) toCFF(name string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// glyphIDs 返回Type1名称到转换后字形编号的对应关系
|
||||
// GlyphIDs 返回Type1名称到转换后字形编号的对应关系
|
||||
// 返回: map[string]uint16 字形编号
|
||||
func (p *type1Program) glyphIDs() map[string]uint16 {
|
||||
func (p *Type1Program) GlyphIDs() map[string]uint16 {
|
||||
ids := map[string]uint16{".notdef": 0}
|
||||
next := uint16(1)
|
||||
for _, glyph := range p.glyphs {
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -30,6 +30,7 @@ type type2Font struct {
|
||||
names map[string]int
|
||||
nominal, def float64
|
||||
seed uint64
|
||||
cid bool
|
||||
}
|
||||
|
||||
// type2State 按Type2规范保存跨子程序共享的执行状态
|
||||
@@ -54,6 +55,9 @@ type type2State struct {
|
||||
output *bytes.Buffer
|
||||
seed uint64
|
||||
steps *int
|
||||
dependencies map[uint16]bool
|
||||
localUsage []bool
|
||||
globalUsage []bool
|
||||
}
|
||||
|
||||
// readType2Index 读取并校验CFF索引的全部偏移,不复制程序字节
|
||||
@@ -335,13 +339,18 @@ func (s *type2State) run(data []byte, depth int) (bool, error) {
|
||||
value := s.args[len(s.args)-1]
|
||||
s.args = s.args[:len(s.args)-1]
|
||||
subrs := s.font.locals
|
||||
usage := s.localUsage
|
||||
if op == 29 {
|
||||
subrs = s.font.globals
|
||||
usage = s.globalUsage
|
||||
}
|
||||
value += float64(cffSubrBias(len(subrs)))
|
||||
if value < 0 || value >= float64(len(subrs)) || value != math.Trunc(value) {
|
||||
return false, fmt.Errorf("invalid Type2 subroutine number")
|
||||
}
|
||||
if usage != nil {
|
||||
usage[int(value)] = true
|
||||
}
|
||||
ended, err := s.run(subrs[int(value)], depth+1)
|
||||
if ended || err != nil {
|
||||
return ended, err
|
||||
@@ -481,6 +490,9 @@ func (s *type2State) run(data []byte, depth int) (bool, error) {
|
||||
// 入参: code 标准字符码, x 横向偏移, y 纵向偏移
|
||||
// 返回: error 组件或指令错误
|
||||
func (s *type2State) appendComponent(code, x, y float64) error {
|
||||
if s.font.cid {
|
||||
return fmt.Errorf("CID CFF cannot use StandardEncoding components")
|
||||
}
|
||||
if code < 0 || code > 255 || code != math.Trunc(code) || type1StandardNames[int(code)] == "" {
|
||||
return fmt.Errorf("invalid Type2 component code")
|
||||
}
|
||||
@@ -499,7 +511,10 @@ func (s *type2State) appendComponent(code, x, y float64) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("missing Type2 component %s", name)
|
||||
}
|
||||
child := type2State{font: s.font, matrix: s.matrix, width: s.font.def, component: true, output: s.output, originX: x, originY: y, outX: s.outX, outY: s.outY, seed: s.font.seed + uint64(gid), steps: s.steps, stripHints: s.stripHints}
|
||||
if s.dependencies != nil {
|
||||
s.dependencies[uint16(gid)] = true
|
||||
}
|
||||
child := type2State{font: s.font, matrix: s.matrix, width: s.font.def, component: true, output: s.output, originX: x, originY: y, outX: s.outX, outY: s.outY, seed: s.font.seed + uint64(gid), steps: s.steps, stripHints: s.stripHints, dependencies: s.dependencies, localUsage: s.localUsage, globalUsage: s.globalUsage}
|
||||
_, err := child.run(s.font.chars[gid], 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Type2 component %s: %w", name, err)
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -27,6 +27,9 @@ func (s *type2State) calculate(op int) error {
|
||||
count := 0
|
||||
switch op {
|
||||
case 1223:
|
||||
if s.dependencies != nil {
|
||||
return fmt.Errorf("random Type2 program cannot be subset safely")
|
||||
}
|
||||
s.seed = uint64(uint32(s.seed*1664525 + 1013904223))
|
||||
return s.push(float64(s.seed+1) / 4294967296)
|
||||
case 1205, 1209, 1214, 1218, 1221, 1226, 1227, 1229:
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
// Usage 指定子集需要保留的字符、字形及引用是否存在不确定性
|
||||
type Usage struct {
|
||||
Characters map[rune]bool
|
||||
Glyphs map[uint16]bool
|
||||
Unsafe bool
|
||||
}
|
||||
|
||||
// newUsage 创建具有可写字符和字形集合的子集输入
|
||||
// 返回: *Usage 用字信息
|
||||
func newUsage() *Usage {
|
||||
return &Usage{Characters: make(map[rune]bool), Glyphs: make(map[uint16]bool)}
|
||||
}
|
||||
@@ -12,46 +12,42 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// otfTableRecord OTF表记录结构
|
||||
// 字段: tag 标签, checksum 校验和, offset 偏移, length 长度, data 数据
|
||||
type otfTableRecord struct {
|
||||
tag string
|
||||
checksum uint32
|
||||
offset int
|
||||
length int
|
||||
data []byte
|
||||
// finite 判断数值是否有限
|
||||
// 入参: value 数值
|
||||
// 返回: bool 是否有限
|
||||
func finite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
// extractCollectionFont 提取集合中的单个OpenType字体,保留表内容并重新计算校验和
|
||||
// ExtractCollectionFont 提取集合中的单个OpenType字体,保留表内容并重新计算校验和
|
||||
// 入参: data 集合数据, index 字体索引
|
||||
// 返回: []byte 单字体数据, error 错误信息
|
||||
func extractCollectionFont(data []byte, index int) ([]byte, error) {
|
||||
tables, err := fontFileTables(data, index)
|
||||
func ExtractCollectionFont(data []byte, index int) ([]byte, error) {
|
||||
tables, err := FileTables(data, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tables["head"]) < 12 {
|
||||
return nil, fmt.Errorf("invalid head font table")
|
||||
}
|
||||
tables["head"] = bytes.Clone(tables["head"])
|
||||
clear(tables["head"][8:12])
|
||||
return serializeOTF(tables)
|
||||
return SerializeOTF(tables)
|
||||
}
|
||||
|
||||
// fontFileTables 读取单字体或集合指定项的表目录,不复制表数据
|
||||
// FileTables 读取单字体或集合指定项的表目录,不复制表数据
|
||||
// 入参: data 字体数据, index 字体索引
|
||||
// 返回: map[string][]byte 字体表, error 错误信息
|
||||
func fontFileTables(data []byte, index int) (map[string][]byte, error) {
|
||||
count, err := fontFileCount(data)
|
||||
func FileTables(data []byte, index int) (map[string][]byte, error) {
|
||||
count, err := FileCount(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,23 +87,127 @@ func fontFileTables(data []byte, index int) (map[string][]byte, error) {
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// NormalizePDFProgram 修复可确定的PDF内嵌SFNT封装,不补造TrueType轮廓或字形编号
|
||||
// 入参: data 原始字体程序
|
||||
// 返回: []byte 原程序或独立修复程序, bool 是否修复, error 无法确认的目录或度量错误
|
||||
func NormalizePDFProgram(data []byte) ([]byte, bool, error) {
|
||||
if len(data) < 12 || string(data[:4]) != "OTTO" && string(data[:4]) != "\x00\x01\x00\x00" && string(data[:4]) != "true" {
|
||||
return data, false, nil
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
if count == 0 || count > (len(data)-12)/16 {
|
||||
return nil, false, fmt.Errorf("unrecoverable PDF font directory size")
|
||||
}
|
||||
first := len(data)
|
||||
for n := 0; n < count; n++ {
|
||||
record := data[12+n*16:]
|
||||
start, size := uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
|
||||
if size != 0 && start >= 12 && start+size <= uint64(len(data)) {
|
||||
first = min(first, int(start))
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
if first < 12+count*16 {
|
||||
if (first-12)%16 != 0 {
|
||||
return nil, false, fmt.Errorf("ambiguous PDF font directory overlap")
|
||||
}
|
||||
actual := (first - 12) / 16
|
||||
for n := 0; n < count; n++ {
|
||||
record := data[12+n*16:]
|
||||
printable := true
|
||||
for _, b := range record[:4] {
|
||||
printable = printable && b >= 32 && b <= 126
|
||||
}
|
||||
if printable != (n < actual) {
|
||||
return nil, false, fmt.Errorf("ambiguous PDF font table count")
|
||||
}
|
||||
if n < actual {
|
||||
start, size := uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
|
||||
if size != 0 && (start < uint64(first) || start+size > uint64(len(data))) {
|
||||
return nil, false, fmt.Errorf("incomplete PDF font table")
|
||||
}
|
||||
}
|
||||
}
|
||||
data = bytes.Clone(data)
|
||||
binary.BigEndian.PutUint16(data[4:6], uint16(actual))
|
||||
changed = true
|
||||
}
|
||||
tables, err := FileTables(data, 0)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if head := tables["head"]; len(head) > 54 && len(head) <= 57 {
|
||||
tables["head"] = head[:54]
|
||||
changed = true
|
||||
}
|
||||
if hhea := tables["hhea"]; len(hhea) > 36 && len(hhea) <= 39 && binary.BigEndian.Uint32(hhea) == 0x00010000 {
|
||||
tables["hhea"] = hhea[:36]
|
||||
changed = true
|
||||
}
|
||||
if maxp := tables["maxp"]; tables["glyf"] != nil && len(maxp) > 32 && len(maxp) <= 35 && binary.BigEndian.Uint32(maxp) == 0x00010000 {
|
||||
tables["maxp"] = maxp[:32]
|
||||
changed = true
|
||||
}
|
||||
if post := tables["post"]; len(post) > 32 && binary.BigEndian.Uint32(post) == 0x00030000 {
|
||||
tables["post"] = post[:32]
|
||||
changed = true
|
||||
}
|
||||
if cvt := tables["cvt "]; len(cvt)%2 != 0 && cvt[len(cvt)-1] == 0 {
|
||||
tables["cvt "] = cvt[:len(cvt)-1]
|
||||
changed = true
|
||||
}
|
||||
if len(tables["CFF "]) != 0 && len(tables["head"]) == 0 && len(tables["maxp"]) == 0 && len(tables["hhea"]) == 0 && len(tables["hmtx"]) == 0 {
|
||||
program, _, err := WrapCFFToOTF(tables["CFF "])
|
||||
return program, true, err
|
||||
}
|
||||
if maxp := tables["maxp"]; len(tables["CFF "]) != 0 && tables["glyf"] == nil && len(maxp) >= 6 && (binary.BigEndian.Uint32(maxp) == 0x00010000 || binary.BigEndian.Uint32(maxp) == 0x00005000 && len(maxp) != 6) {
|
||||
count, err := parseCFFAndCountGlyphs(tables["CFF "])
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if count != int(binary.BigEndian.Uint16(maxp[4:6])) {
|
||||
program, _, err := WrapCFFToOTF(tables["CFF "])
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
rebuilt, err := FileTables(program, 0)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(tables["head"]) < 20 || !bytes.Equal(tables["head"][18:20], rebuilt["head"][18:20]) {
|
||||
return nil, false, fmt.Errorf("CFF and SFNT design units differ")
|
||||
}
|
||||
return program, true, nil
|
||||
}
|
||||
tables["maxp"] = buildCFFMaxpTable(uint16(count))
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return data, false, nil
|
||||
}
|
||||
program, err := SerializeOTF(tables)
|
||||
return program, true, err
|
||||
}
|
||||
|
||||
// calcTableChecksum 计算字体表校验和
|
||||
// 入参: data 表数据
|
||||
// 返回: uint32 校验和
|
||||
func calcTableChecksum(data []byte) uint32 {
|
||||
var sum uint32
|
||||
length := len(data)
|
||||
for i := 0; i < length; i += 4 {
|
||||
if i+4 <= length {
|
||||
sum += binary.BigEndian.Uint32(data[i : i+4])
|
||||
} else {
|
||||
var val uint32
|
||||
rem := data[i:]
|
||||
for j, b := range rem {
|
||||
val |= uint32(b) << (24 - 8*j)
|
||||
}
|
||||
sum += val
|
||||
}
|
||||
var a, b, c, d uint32
|
||||
for len(data) >= 16 {
|
||||
a += binary.BigEndian.Uint32(data[:4])
|
||||
b += binary.BigEndian.Uint32(data[4:8])
|
||||
c += binary.BigEndian.Uint32(data[8:12])
|
||||
d += binary.BigEndian.Uint32(data[12:16])
|
||||
data = data[16:]
|
||||
}
|
||||
sum := a + b + c + d
|
||||
for len(data) >= 4 {
|
||||
sum += binary.BigEndian.Uint32(data[:4])
|
||||
data = data[4:]
|
||||
}
|
||||
for i, value := range data {
|
||||
sum += uint32(value) << (24 - 8*i)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
@@ -190,13 +290,13 @@ func buildTrueTypeMaxpTable(numGlyphs uint16) []byte {
|
||||
// buildOS2Table 构建OS/2表 (使用默认Metrics)
|
||||
// 返回: []byte OS/2表数据
|
||||
func buildOS2Table() []byte {
|
||||
return buildOS2TableWithMetrics(800, -200)
|
||||
return BuildOS2TableWithMetrics(800, -200)
|
||||
}
|
||||
|
||||
// buildOS2TableWithMetrics 构建OS/2表
|
||||
// BuildOS2TableWithMetrics 构建OS/2表
|
||||
// 入参: ascender 上升部, descender 下降部
|
||||
// 返回: []byte OS/2表数据
|
||||
func buildOS2TableWithMetrics(ascender, descender int16) []byte {
|
||||
func BuildOS2TableWithMetrics(ascender, descender int16) []byte {
|
||||
os2 := new(bytes.Buffer)
|
||||
binary.Write(os2, binary.BigEndian, uint16(3))
|
||||
binary.Write(os2, binary.BigEndian, int16(500))
|
||||
@@ -252,9 +352,9 @@ func buildNameTable() []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// buildPostTable 构建post表 (版本3.0, 无字形名称)
|
||||
// BuildPostTable 构建post表 (版本3.0, 无字形名称)
|
||||
// 返回: []byte post表数据
|
||||
func buildPostTable() []byte {
|
||||
func BuildPostTable() []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.BigEndian, uint32(0x00030000))
|
||||
binary.Write(buf, binary.BigEndian, uint32(0))
|
||||
@@ -280,68 +380,70 @@ func buildHmtxTable(widths []uint16) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// serializeOTF 序列化OpenType字体结构
|
||||
// SerializeOTF 序列化OpenType字体结构,一次分配输出,不修改输入表数据
|
||||
// 复用各表校验和计算整份字体的调整值,不重复扫描表数据
|
||||
// 入参: tables 表数据映射
|
||||
// 返回: []byte 完整字体数据, error 错误信息
|
||||
func serializeOTF(tables map[string][]byte) ([]byte, error) {
|
||||
numTables := uint16(len(tables))
|
||||
func SerializeOTF(tables map[string][]byte) ([]byte, error) {
|
||||
if len(tables) > math.MaxUint16/16 {
|
||||
return nil, fmt.Errorf("too many font tables")
|
||||
}
|
||||
headerSize := 12 + 16*len(tables)
|
||||
size := uint64(headerSize)
|
||||
tags := make([]string, 0, len(tables))
|
||||
for tag, data := range tables {
|
||||
if len(tag) != 4 {
|
||||
return nil, fmt.Errorf("invalid font table tag %q", tag)
|
||||
}
|
||||
if tag == "head" && len(data) < 12 {
|
||||
return nil, fmt.Errorf("invalid head font table")
|
||||
}
|
||||
size += (uint64(len(data)) + 3) &^ 3
|
||||
if size > math.MaxUint32 || size > uint64(math.MaxInt) {
|
||||
return nil, fmt.Errorf("font table data too large")
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
data := make([]byte, int(size))
|
||||
numTables := len(tables)
|
||||
entrySelector := 0
|
||||
for 1<<(entrySelector+1) <= int(numTables) {
|
||||
for 1<<(entrySelector+1) <= numTables {
|
||||
entrySelector++
|
||||
}
|
||||
searchRange := 1 << (entrySelector + 4)
|
||||
rangeShift := int(numTables)*16 - searchRange
|
||||
buf := new(bytes.Buffer)
|
||||
if _, ok := tables["CFF "]; ok {
|
||||
buf.WriteString("OTTO")
|
||||
rangeShift := numTables*16 - searchRange
|
||||
if tables["CFF "] != nil || tables["CFF2"] != nil {
|
||||
copy(data, "OTTO")
|
||||
} else {
|
||||
binary.Write(buf, binary.BigEndian, uint32(0x00010000))
|
||||
binary.BigEndian.PutUint32(data, 0x00010000)
|
||||
}
|
||||
binary.Write(buf, binary.BigEndian, numTables)
|
||||
binary.Write(buf, binary.BigEndian, uint16(searchRange))
|
||||
binary.Write(buf, binary.BigEndian, uint16(entrySelector))
|
||||
binary.Write(buf, binary.BigEndian, uint16(rangeShift))
|
||||
var tags []string
|
||||
for t := range tables {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
sort.Strings(tags)
|
||||
headerSize := 12 + 16*int(numTables)
|
||||
offset := headerSize
|
||||
var records []otfTableRecord
|
||||
for _, tag := range tags {
|
||||
data := tables[tag]
|
||||
pad := (4 - (len(data) % 4)) % 4
|
||||
padded := make([]byte, len(data)+pad)
|
||||
copy(padded, data)
|
||||
if tag == "head" && len(data) >= 12 {
|
||||
binary.BigEndian.PutUint16(data[4:], uint16(numTables))
|
||||
binary.BigEndian.PutUint16(data[6:], uint16(searchRange))
|
||||
binary.BigEndian.PutUint16(data[8:], uint16(entrySelector))
|
||||
binary.BigEndian.PutUint16(data[10:], uint16(rangeShift))
|
||||
offset, adjustment := headerSize, 0
|
||||
var checksum uint32
|
||||
for i, tag := range tags {
|
||||
table := tables[tag]
|
||||
length := (len(table) + 3) &^ 3
|
||||
padded := data[offset : offset+length]
|
||||
copy(padded, table)
|
||||
if tag == "head" {
|
||||
clear(padded[8:12])
|
||||
adjustment = offset + 8
|
||||
}
|
||||
cs := calcTableChecksum(padded)
|
||||
records = append(records, otfTableRecord{tag, cs, offset, len(data), padded})
|
||||
offset += len(padded)
|
||||
record := data[12+i*16 : 28+i*16]
|
||||
copy(record, tag)
|
||||
tableChecksum := calcTableChecksum(padded)
|
||||
checksum += tableChecksum
|
||||
binary.BigEndian.PutUint32(record[4:], tableChecksum)
|
||||
binary.BigEndian.PutUint32(record[8:], uint32(offset))
|
||||
binary.BigEndian.PutUint32(record[12:], uint32(len(table)))
|
||||
offset += length
|
||||
}
|
||||
for _, r := range records {
|
||||
buf.WriteString(r.tag)
|
||||
binary.Write(buf, binary.BigEndian, r.checksum)
|
||||
binary.Write(buf, binary.BigEndian, uint32(r.offset))
|
||||
binary.Write(buf, binary.BigEndian, uint32(r.length))
|
||||
if adjustment != 0 {
|
||||
binary.BigEndian.PutUint32(data[adjustment:], 0xB1B0AFBA-checksum-calcTableChecksum(data[:headerSize]))
|
||||
}
|
||||
for _, r := range records {
|
||||
buf.Write(r.data)
|
||||
}
|
||||
fullData := buf.Bytes()
|
||||
for _, r := range records {
|
||||
if r.tag == "head" {
|
||||
adjOffset := r.offset + 8
|
||||
if adjOffset+4 <= len(fullData) {
|
||||
binary.BigEndian.PutUint32(fullData[adjOffset:], 0)
|
||||
cs := calcTableChecksum(fullData)
|
||||
checksumAdj := 0xB1B0AFBA - cs
|
||||
binary.BigEndian.PutUint32(fullData[adjOffset:], checksumAdj)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return fullData, nil
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// variableSFNTSubsettable 核对TrueType可变轴与字形变化表的基本结构
|
||||
// 入参: sfnt 已解析字体
|
||||
// 返回: bool 是否具备可裁剪的变化数据
|
||||
func variableSFNTSubsettable(sfnt *SFNT) bool {
|
||||
fvar, gvar := sfnt.Tables["fvar"], sfnt.Tables["gvar"]
|
||||
if len(fvar) < 16 || len(gvar) < 20 || binary.BigEndian.Uint32(fvar) != 0x10000 || binary.BigEndian.Uint32(gvar) != 0x10000 {
|
||||
return false
|
||||
}
|
||||
axes := int(binary.BigEndian.Uint16(fvar[8:]))
|
||||
offset, size := int(binary.BigEndian.Uint16(fvar[4:])), int(binary.BigEndian.Uint16(fvar[10:]))
|
||||
return axes > 0 && size >= 20 && offset >= 16 && offset <= len(fvar) && axes <= (len(fvar)-offset)/size &&
|
||||
int(binary.BigEndian.Uint16(gvar[4:])) == axes && binary.BigEndian.Uint16(gvar[12:]) == sfnt.NumGlyphs()
|
||||
}
|
||||
|
||||
// subsetVariableSFNTFont 保留字形编号及可变轴,仅裁剪未使用的轮廓和字形变化数据
|
||||
// 入参: sfnt 已解析字体, glyphs 使用的字形
|
||||
// 返回: []byte 字体子集, error 字形或变化表错误
|
||||
func subsetVariableSFNTFont(sfnt *SFNT, glyphs []uint16) ([]byte, error) {
|
||||
if !variableSFNTSubsettable(sfnt) {
|
||||
return nil, fmt.Errorf("invalid TrueType variation tables")
|
||||
}
|
||||
usage := newUsage()
|
||||
usage.Glyphs[0] = true
|
||||
for _, gid := range glyphs {
|
||||
if gid >= sfnt.NumGlyphs() {
|
||||
return nil, fmt.Errorf("variable font glyph index out of range")
|
||||
}
|
||||
usage.Glyphs[gid] = true
|
||||
for _, char := range sfnt.glyphCharacters(gid) {
|
||||
usage.Characters[char] = true
|
||||
}
|
||||
}
|
||||
dependencies, err := sfnt.glyphDependencies(slices.Collect(maps.Keys(usage.Glyphs))...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, dependency := range dependencies {
|
||||
usage.Glyphs[dependency] = true
|
||||
}
|
||||
mapping := make(map[rune]uint16, len(usage.Characters))
|
||||
for char := range usage.Characters {
|
||||
mapping[char] = sfnt.GlyphIndex(char)
|
||||
}
|
||||
table := make(map[string][]byte)
|
||||
for _, tag := range []string{"head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", "vhea", "vmtx"} {
|
||||
if data := sfnt.Tables[tag]; data != nil {
|
||||
table[tag] = data
|
||||
}
|
||||
}
|
||||
table["cmap"] = BuildCmapTable(sfnt.NumGlyphs(), mapping)
|
||||
static, err := SerializeOTF(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if subset := SubsetSourceSFNT(static, usage); subset != nil {
|
||||
table, err = FileTables(subset, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
table["gvar"], err = subsetGlyphVariations(sfnt.Tables["gvar"], usage.Glyphs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tag := range []string{"fvar", "avar", "cvar", "HVAR", "VVAR", "MVAR", "STAT"} {
|
||||
if data := sfnt.Tables[tag]; data != nil {
|
||||
table[tag] = data
|
||||
}
|
||||
}
|
||||
return SerializeOTF(table)
|
||||
}
|
||||
|
||||
// subsetGlyphVariations 按原字形编号重建gvar偏移表,保留共享元组及使用字形的完整变化数据
|
||||
// 入参: data gvar表, glyphs 保留字形集合
|
||||
// 返回: []byte 裁剪后的gvar表, error 表结构错误
|
||||
func subsetGlyphVariations(data []byte, glyphs map[uint16]bool) ([]byte, error) {
|
||||
if len(data) < 20 || binary.BigEndian.Uint32(data) != 0x10000 {
|
||||
return nil, fmt.Errorf("invalid gvar header")
|
||||
}
|
||||
axes := uint64(binary.BigEndian.Uint16(data[4:]))
|
||||
tuples := uint64(binary.BigEndian.Uint16(data[6:]))
|
||||
shared := uint64(binary.BigEndian.Uint32(data[8:]))
|
||||
count := int(binary.BigEndian.Uint16(data[12:]))
|
||||
flags := binary.BigEndian.Uint16(data[14:])
|
||||
start := uint64(binary.BigEndian.Uint32(data[16:]))
|
||||
width := 2
|
||||
if flags&1 != 0 {
|
||||
width = 4
|
||||
}
|
||||
sharedSize := axes * tuples * 2
|
||||
headerEnd := uint64(20 + (count+1)*width)
|
||||
if flags > 1 || headerEnd > uint64(len(data)) || start < headerEnd || start > uint64(len(data)) || shared > uint64(len(data)) || sharedSize > uint64(len(data))-shared || sharedSize != 0 && shared < headerEnd {
|
||||
return nil, fmt.Errorf("invalid gvar offsets")
|
||||
}
|
||||
readOffset := func(index int) uint64 {
|
||||
if width == 2 {
|
||||
return uint64(binary.BigEndian.Uint16(data[20+index*2:])) * 2
|
||||
}
|
||||
return uint64(binary.BigEndian.Uint32(data[20+index*4:]))
|
||||
}
|
||||
output := make([]byte, 20+(count+1)*4)
|
||||
copy(output, data[:20])
|
||||
binary.BigEndian.PutUint16(output[14:], 1)
|
||||
binary.BigEndian.PutUint32(output[8:], uint32(len(output)))
|
||||
output = append(output, data[shared:shared+sharedSize]...)
|
||||
base := len(output)
|
||||
binary.BigEndian.PutUint32(output[16:], uint32(base))
|
||||
for gid := range count {
|
||||
first, last := readOffset(gid), readOffset(gid+1)
|
||||
if last < first || last > uint64(len(data))-start {
|
||||
return nil, fmt.Errorf("invalid gvar glyph offsets")
|
||||
}
|
||||
if last > first && sharedSize != 0 && start+first < shared+sharedSize && shared < start+last {
|
||||
return nil, fmt.Errorf("overlapping gvar glyph and shared tuple data")
|
||||
}
|
||||
binary.BigEndian.PutUint32(output[20+gid*4:], uint32(len(output)-base))
|
||||
if glyphs[uint16(gid)] {
|
||||
output = append(output, data[start+first:start+last]...)
|
||||
}
|
||||
}
|
||||
binary.BigEndian.PutUint32(output[20+count*4:], uint32(len(output)-base))
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
// fontContainerLimit 限制压缩字体展开后的表数据总量
|
||||
const fontContainerLimit = 256 << 20
|
||||
|
||||
// woff2Tags 保存WOFF2表目录的标准标签顺序
|
||||
var woff2Tags = [...]string{
|
||||
"cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post",
|
||||
"cvt ", "fpgm", "glyf", "loca", "prep", "CFF ", "VORG", "EBDT",
|
||||
"EBLC", "gasp", "hdmx", "kern", "LTSH", "PCLT", "VDMX", "vhea",
|
||||
"vmtx", "BASE", "GDEF", "GPOS", "GSUB", "EBSC", "JSTF", "MATH",
|
||||
"CBDT", "CBLC", "COLR", "CPAL", "SVG ", "sbix", "acnt", "avar",
|
||||
"bdat", "bloc", "bsln", "cvar", "fdsc", "feat", "fmtx", "fvar",
|
||||
"gvar", "hsty", "just", "lcar", "mort", "morx", "opbd", "prop",
|
||||
"trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill",
|
||||
}
|
||||
|
||||
// DecodeWOFF2 展开未变换的WOFF2表,字体集合及字形变换交由绘图适配器处理
|
||||
// 入参: data WOFF2文件
|
||||
// 返回: []byte OpenType数据, bool 是否已处理, error 封装错误
|
||||
func DecodeWOFF2(data []byte) ([]byte, bool, error) {
|
||||
if len(data) < 48 || string(data[:4]) != "wOF2" || uint64(binary.BigEndian.Uint32(data[8:])) != uint64(len(data)) {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 header")
|
||||
}
|
||||
if string(data[4:8]) == "ttcf" {
|
||||
return nil, false, nil
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(data[12:]))
|
||||
if count == 0 || count > (len(data)-48)/2 {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 table count")
|
||||
}
|
||||
tags := make([]string, 0, count)
|
||||
sizes := make([]uint32, 0, count)
|
||||
table := make(map[string][]byte, count)
|
||||
pos, total, transformed := 48, uint64(0), false
|
||||
for range count {
|
||||
if pos >= len(data) {
|
||||
return nil, true, fmt.Errorf("truncated WOFF2 directory")
|
||||
}
|
||||
flags := data[pos]
|
||||
pos++
|
||||
var tag string
|
||||
if index := flags & 63; index == 63 {
|
||||
if len(data)-pos < 4 {
|
||||
return nil, true, fmt.Errorf("truncated WOFF2 tag")
|
||||
}
|
||||
tag = string(data[pos : pos+4])
|
||||
pos += 4
|
||||
} else {
|
||||
tag = woff2Tags[index]
|
||||
}
|
||||
if _, exists := table[tag]; exists {
|
||||
return nil, true, fmt.Errorf("duplicate WOFF2 table %q", tag)
|
||||
}
|
||||
table[tag] = nil
|
||||
size, err := readWOFF2Base128(data, &pos)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
version := flags >> 6
|
||||
changed := version != 0
|
||||
if tag == "glyf" || tag == "loca" {
|
||||
if version != 0 && version != 3 {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 glyph transform")
|
||||
}
|
||||
changed = version == 0
|
||||
} else if version != 0 && (tag != "hmtx" || version != 1) {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 table transform %q", tag)
|
||||
}
|
||||
if changed {
|
||||
transformed = true
|
||||
size, err = readWOFF2Base128(data, &pos)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if tag == "loca" && size != 0 {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 loca length")
|
||||
}
|
||||
}
|
||||
total += uint64(size)
|
||||
if total+uint64(12+19*count) > fontContainerLimit {
|
||||
return nil, true, fmt.Errorf("WOFF2 font exceeds size limit")
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
sizes = append(sizes, size)
|
||||
}
|
||||
end := uint64(pos) + uint64(binary.BigEndian.Uint32(data[20:]))
|
||||
if end > uint64(len(data)) {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 compressed length")
|
||||
}
|
||||
if err := validateWOFFBlocks(data, end, 28); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if transformed {
|
||||
return nil, false, nil
|
||||
}
|
||||
reader := brotli.NewReader(bytes.NewReader(data[pos:end]))
|
||||
body, err := io.ReadAll(io.LimitReader(reader, int64(total)+1))
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("decode WOFF2 data: %w", err)
|
||||
}
|
||||
if uint64(len(body)) != total {
|
||||
return nil, true, fmt.Errorf("invalid WOFF2 expanded length")
|
||||
}
|
||||
for i, tag := range tags {
|
||||
size := int(sizes[i])
|
||||
table[tag], body = body[:size], body[size:]
|
||||
}
|
||||
decoded, err := SerializeOTF(table)
|
||||
return decoded, true, err
|
||||
}
|
||||
|
||||
// readWOFF2Base128 读取WOFF2无符号变长整数,拒绝溢出和前导零
|
||||
// 入参: data 编码数据, pos 当前读取位置
|
||||
// 返回: uint32 整数值, error 编码错误
|
||||
func readWOFF2Base128(data []byte, pos *int) (uint32, error) {
|
||||
var value uint32
|
||||
for i := range 5 {
|
||||
if *pos >= len(data) {
|
||||
return 0, fmt.Errorf("truncated WOFF2 integer")
|
||||
}
|
||||
b := data[*pos]
|
||||
*pos++
|
||||
if i == 0 && b == 0x80 || value > 0x1ffffff {
|
||||
return 0, fmt.Errorf("invalid WOFF2 integer")
|
||||
}
|
||||
value = value<<7 | uint32(b&127)
|
||||
if b&128 == 0 {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("invalid WOFF2 integer")
|
||||
}
|
||||
|
||||
// DecodeWOFF 展开WOFF表数据,不解析或更改字体轮廓
|
||||
// 入参: data WOFF文件
|
||||
// 返回: []byte OpenType数据, error 封装错误
|
||||
func DecodeWOFF(data []byte) ([]byte, error) {
|
||||
if len(data) < 44 || uint64(binary.BigEndian.Uint32(data[8:])) != uint64(len(data)) || binary.BigEndian.Uint16(data[14:]) != 0 {
|
||||
return nil, fmt.Errorf("invalid WOFF header")
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(data[12:]))
|
||||
if count == 0 || count > (len(data)-44)/20 {
|
||||
return nil, fmt.Errorf("invalid WOFF table count")
|
||||
}
|
||||
table := make(map[string][]byte, count)
|
||||
ranges := make([][2]uint64, 0, count)
|
||||
total := uint64(12 + 16*count)
|
||||
for i := range count {
|
||||
record := data[44+i*20:]
|
||||
tag := string(record[:4])
|
||||
offset, packed, size := uint64(binary.BigEndian.Uint32(record[4:])), uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
|
||||
total += (size + 3) &^ 3
|
||||
if _, exists := table[tag]; exists || offset < uint64(44+20*count) || offset%4 != 0 || packed > size || offset+packed > uint64(len(data)) || total > fontContainerLimit {
|
||||
return nil, fmt.Errorf("invalid WOFF table %q", tag)
|
||||
}
|
||||
body := data[offset : offset+packed]
|
||||
if packed < size {
|
||||
reader, err := zlib.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err = io.ReadAll(io.LimitReader(reader, int64(size)+1))
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uint64(len(body)) != size {
|
||||
return nil, fmt.Errorf("invalid WOFF expanded length")
|
||||
}
|
||||
}
|
||||
table[tag] = body
|
||||
ranges = append(ranges, [2]uint64{offset, offset + packed})
|
||||
}
|
||||
if total != uint64(binary.BigEndian.Uint32(data[16:])) {
|
||||
return nil, fmt.Errorf("invalid WOFF SFNT size")
|
||||
}
|
||||
sort.Slice(ranges, func(i, j int) bool { return ranges[i][0] < ranges[j][0] })
|
||||
end := uint64(44 + 20*count)
|
||||
for _, interval := range ranges {
|
||||
if interval[0] != (end+3)&^3 {
|
||||
return nil, fmt.Errorf("invalid WOFF table ranges")
|
||||
}
|
||||
end = interval[1]
|
||||
}
|
||||
if err := validateWOFFBlocks(data, end, 24); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SerializeOTF(table)
|
||||
}
|
||||
|
||||
// validateWOFFBlocks 校验附加数据块的边界及对齐,字体元数据不写入输出
|
||||
// 入参: data 字体文件, end 字体数据结束位置, fields 元数据偏移字段位置
|
||||
// 返回: error 附加块错误
|
||||
func validateWOFFBlocks(data []byte, end uint64, fields int) error {
|
||||
for _, pos := range []int{fields, fields + 12} {
|
||||
offset, length := uint64(binary.BigEndian.Uint32(data[pos:])), uint64(binary.BigEndian.Uint32(data[pos+4:]))
|
||||
if offset == 0 {
|
||||
if length != 0 || pos == fields && binary.BigEndian.Uint32(data[pos+8:]) != 0 {
|
||||
return fmt.Errorf("invalid WOFF optional block")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if length == 0 || offset != (end+3)&^3 || offset+length > uint64(len(data)) {
|
||||
return fmt.Errorf("invalid WOFF block range")
|
||||
}
|
||||
for _, b := range data[end:offset] {
|
||||
if b != 0 {
|
||||
return fmt.Errorf("invalid WOFF padding")
|
||||
}
|
||||
}
|
||||
end = offset + length
|
||||
}
|
||||
if end > uint64(len(data)) || uint64(len(data)) > (end+3)&^3 {
|
||||
return fmt.Errorf("invalid WOFF trailing data")
|
||||
}
|
||||
for _, b := range data[end:] {
|
||||
if b != 0 {
|
||||
return fmt.Errorf("invalid WOFF padding")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package geom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -27,33 +27,33 @@ type geometryArc struct {
|
||||
|
||||
// Curves 按毫米误差上限将端点式椭圆弧转换为三次贝塞尔曲线,其他指令保持原样
|
||||
// 入参: tolerance 正有限误差上限
|
||||
// 返回: GeometryPath 独立路径, error 参数错误
|
||||
func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
|
||||
if err := p.validate(); err != nil {
|
||||
// 返回: Path 独立路径, error 参数错误
|
||||
func (p Path) Curves(tolerance float64) (Path, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !rasterPositive(tolerance) {
|
||||
if !positive(tolerance) {
|
||||
return nil, fmt.Errorf("invalid curve tolerance")
|
||||
}
|
||||
result := make(GeometryPath, 0, len(p))
|
||||
result := make(Path, 0, len(p))
|
||||
var current, start Point
|
||||
for _, s := range p {
|
||||
switch s.Verb {
|
||||
case GeometryMove:
|
||||
case Move:
|
||||
start = s.End
|
||||
case GeometryArc:
|
||||
curves, err := arcCurves(current, s, tolerance)
|
||||
case Arc:
|
||||
curves, err := ArcCurves(current, s, tolerance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, curves...)
|
||||
current = s.End
|
||||
continue
|
||||
case GeometryClose:
|
||||
case ClosePath:
|
||||
result = append(result, s)
|
||||
current = start
|
||||
continue
|
||||
case GeometryLine, GeometryQuad, GeometryCubic:
|
||||
case Line, Quad, Cubic:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported geometry command %d", s.Verb)
|
||||
}
|
||||
@@ -63,10 +63,10 @@ func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// arcCurves 按标准端点参数求椭圆中心,只在绘制边界近似弧线
|
||||
// ArcCurves 按标准端点参数求椭圆中心,只在绘制边界近似弧线
|
||||
// 入参: start 起点, arc 弧线, tolerance 毫米误差上限
|
||||
// 返回: GeometryPath 贝塞尔片段, error 无效弧线
|
||||
func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPath, error) {
|
||||
// 返回: Path 贝塞尔片段, error 无效弧线
|
||||
func ArcCurves(start Point, arc Segment, tolerance float64) (Path, error) {
|
||||
a, err := centerArc(start, arc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,7 +75,7 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
|
||||
return nil, nil
|
||||
}
|
||||
if arc.RadiusX == 0 || arc.RadiusY == 0 {
|
||||
return GeometryPath{{Verb: GeometryLine, End: arc.End}}, nil
|
||||
return Path{{Verb: Line, End: arc.End}}, nil
|
||||
}
|
||||
radius := math.Max(math.Hypot(a.u.X, a.u.Y), math.Hypot(a.v.X, a.v.Y))
|
||||
step := math.Min(math.Pi/2, math.Pow(tolerance/radius*40000, 1.0/6))
|
||||
@@ -88,12 +88,12 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
|
||||
point := func(x, y float64) Point {
|
||||
return Point{a.center.X + a.u.X*x + a.v.X*y, a.center.Y + a.u.Y*x + a.v.Y*y}
|
||||
}
|
||||
result := make(GeometryPath, 0, count)
|
||||
result := make(Path, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
sa, ca := math.Sincos(a.theta + float64(i)*step)
|
||||
sb, cb := math.Sincos(a.theta + float64(i+1)*step)
|
||||
k := 4.0 / 3 * math.Tan(step/4)
|
||||
result = append(result, GeometrySegment{Verb: GeometryCubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
|
||||
result = append(result, Segment{Verb: Cubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
|
||||
}
|
||||
result[len(result)-1].End = arc.End
|
||||
return result, nil
|
||||
@@ -102,7 +102,7 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
|
||||
// centerArc 将端点式圆弧解析为中心式,统一半径修正和方向语义
|
||||
// 入参: start 起点, arc 弧线
|
||||
// 返回: geometryArc 中心式参数, error 无效参数或数值溢出
|
||||
func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
|
||||
func centerArc(start Point, arc Segment) (geometryArc, error) {
|
||||
rx, ry := arc.RadiusX, arc.RadiusY
|
||||
if !finite(rx) || !finite(ry) || rx < 0 || ry < 0 || !finite(arc.Rotation) ||
|
||||
!finite(start.X) || !finite(start.Y) || !finite(arc.End.X) || !finite(arc.End.Y) {
|
||||
@@ -116,7 +116,7 @@ func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
|
||||
}
|
||||
angle := math.Mod(arc.Rotation, 360) * math.Pi / 180
|
||||
sine, cosine := math.Sincos(angle)
|
||||
dx, dy := (start.X-arc.End.X)/2, (start.Y-arc.End.Y)/2
|
||||
dx, dy := arcCoordinateMean(start.X, -arc.End.X), arcCoordinateMean(start.Y, -arc.End.Y)
|
||||
x, y := cosine*dx+sine*dy, -sine*dx+cosine*dy
|
||||
scale := math.Hypot(x/rx, y/ry)
|
||||
if scale > 1 {
|
||||
@@ -139,13 +139,24 @@ func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
|
||||
if !arc.Sweep && delta > 0 {
|
||||
delta -= 2 * math.Pi
|
||||
}
|
||||
center := Point{cosine*cx - sine*cy + (start.X+arc.End.X)/2, sine*cx + cosine*cy + (start.Y+arc.End.Y)/2}
|
||||
center := Point{cosine*cx - sine*cy + arcCoordinateMean(start.X, arc.End.X), sine*cx + cosine*cy + arcCoordinateMean(start.Y, arc.End.Y)}
|
||||
if !finite(center.X) || !finite(center.Y) || !finite(rx) || !finite(ry) || !finite(delta) || !finite(theta) || delta == 0 {
|
||||
return geometryArc{}, fmt.Errorf("invalid arc extent")
|
||||
}
|
||||
return geometryArc{center, Point{rx * cosine, rx * sine}, Point{-ry * sine, ry * cosine}, theta, delta}, nil
|
||||
}
|
||||
|
||||
// arcCoordinateMean 计算有限坐标均值,仅在求和溢出时先减半,避免微小坐标提前下溢
|
||||
// 入参: a 第一个坐标, b 第二个坐标
|
||||
// 返回: float64 坐标均值
|
||||
func arcCoordinateMean(a, b float64) float64 {
|
||||
value := (a + b) / 2
|
||||
if math.IsInf(value, 0) {
|
||||
return a/2 + b/2
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// point 计算椭圆参数对应的坐标
|
||||
// 入参: angle 参数角
|
||||
// 返回: Point 椭圆上的点
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package geom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
// Bounds 通过解析极值计算直线、贝塞尔和椭圆弧的轴对齐边界
|
||||
// 不进行曲线展开且不使用Tolerance,结果受float64舍入误差影响,不保证向外舍入
|
||||
// 返回: Box 浮点路径范围, error 无效路径、弧线或数值溢出
|
||||
func (p GeometryPath) Bounds() (Box, error) {
|
||||
if err := p.validate(); err != nil {
|
||||
func (p Path) Bounds() (Box, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
left, top, right, bottom := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
@@ -34,11 +34,11 @@ func (p GeometryPath) Bounds() (Box, error) {
|
||||
var current, start Point
|
||||
for _, s := range p {
|
||||
switch s.Verb {
|
||||
case GeometryMove:
|
||||
case Move:
|
||||
start = s.End
|
||||
case GeometryClose:
|
||||
case ClosePath:
|
||||
s.End = start
|
||||
case GeometryArc:
|
||||
case Arc:
|
||||
a, err := centerArc(current, s)
|
||||
if err != nil {
|
||||
return Box{}, err
|
||||
@@ -52,9 +52,9 @@ func (p GeometryPath) Bounds() (Box, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
case GeometryQuad, GeometryCubic:
|
||||
case Quad, Cubic:
|
||||
for _, axis := range [][4]float64{{current.X, s.Control1.X, s.Control2.X, s.End.X}, {current.Y, s.Control1.Y, s.Control2.Y, s.End.Y}} {
|
||||
if s.Verb == GeometryQuad {
|
||||
if s.Verb == Quad {
|
||||
axis[2] = 0
|
||||
}
|
||||
scale := math.Max(math.Abs(axis[0]), math.Max(math.Abs(axis[1]), math.Max(math.Abs(axis[2]), math.Abs(axis[3]))))
|
||||
@@ -64,19 +64,19 @@ func (p GeometryPath) Bounds() (Box, error) {
|
||||
}
|
||||
}
|
||||
var roots []float64
|
||||
if s.Verb == GeometryQuad {
|
||||
if s.Verb == Quad {
|
||||
roots = geometryRoots(0, axis[0]-2*axis[1]+axis[3], axis[1]-axis[0])
|
||||
} else {
|
||||
roots = geometryRoots(-axis[0]+3*axis[1]-3*axis[2]+axis[3], 2*(axis[0]-2*axis[1]+axis[2]), axis[1]-axis[0])
|
||||
}
|
||||
for _, t := range roots {
|
||||
if t > 0 && t < 1 {
|
||||
q := geometryLerp(current, s.Control1, t)
|
||||
if s.Verb == GeometryQuad {
|
||||
q = geometryLerp(q, geometryLerp(s.Control1, s.End, t), t)
|
||||
q := Lerp(current, s.Control1, t)
|
||||
if s.Verb == Quad {
|
||||
q = Lerp(q, Lerp(s.Control1, s.End, t), t)
|
||||
} else {
|
||||
r := geometryLerp(s.Control1, s.Control2, t)
|
||||
q = geometryLerp(geometryLerp(q, r, t), geometryLerp(r, geometryLerp(s.Control2, s.End, t), t), t)
|
||||
r := Lerp(s.Control1, s.Control2, t)
|
||||
q = Lerp(Lerp(q, r, t), Lerp(r, Lerp(s.Control2, s.End, t), t), t)
|
||||
}
|
||||
add(q)
|
||||
}
|
||||
@@ -121,9 +121,9 @@ func geometryRoots(a, b, c float64) []float64 {
|
||||
return []float64{q / a, c / q}
|
||||
}
|
||||
|
||||
// geometryLerp 线性插值坐标
|
||||
// Lerp 线性插值坐标
|
||||
// 入参: a 起点, b 终点, t 插值比例
|
||||
// 返回: Point 插值坐标
|
||||
func geometryLerp(a, b Point, t float64) Point {
|
||||
func Lerp(a, b Point, t float64) Point {
|
||||
return Point{(1-t)*a.X + t*b.X, (1-t)*a.Y + t*b.Y}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package geom
|
||||
|
||||
import "math"
|
||||
|
||||
// polygonPoint 允许同结构的后端顶点直接参与裁剪,避免整片坐标转换
|
||||
type polygonPoint interface {
|
||||
~struct{ X, Y float64 }
|
||||
}
|
||||
|
||||
// ClipPolygon 按非退化凸窗口逐边裁剪多边形,保留原顶点绕向,不修改输入
|
||||
// 入参: a 绘制顶点, b 已通过ConvexPolygon检查的裁剪顶点,均不含重复闭合点
|
||||
// 返回: []P 交集顶点,少于三个顶点时不构成区域, bool 是否满足数值精度
|
||||
func ClipPolygon[P polygonPoint](a, b []P) ([]P, bool) {
|
||||
for i, start := range b {
|
||||
origin, end := Point(start), Point(b[(i+1)%len(b)])
|
||||
dx, dy := end.X-origin.X, end.Y-origin.Y
|
||||
length := math.Hypot(dx, dy)
|
||||
if length == 0 {
|
||||
continue
|
||||
}
|
||||
dx, dy = dx/length, dy/length
|
||||
distance := func(p P) float64 { return convexDistance(origin, Point(p), dx, dy) }
|
||||
sign := 1.0
|
||||
for _, p := range b {
|
||||
if d := distance(p); d != 0 {
|
||||
sign = math.Copysign(1, d)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(a) == 0 {
|
||||
break
|
||||
}
|
||||
var clipped []P
|
||||
appendPoint := func(p P) {
|
||||
if len(clipped) == 0 || p != clipped[len(clipped)-1] {
|
||||
clipped = append(clipped, p)
|
||||
}
|
||||
}
|
||||
previous := a[len(a)-1]
|
||||
previousDistance := sign * distance(previous)
|
||||
if !finite(previousDistance) {
|
||||
return nil, false
|
||||
}
|
||||
for _, p := range a {
|
||||
d := sign * distance(p)
|
||||
if !finite(d) {
|
||||
return nil, false
|
||||
}
|
||||
if (d >= 0) != (previousDistance >= 0) {
|
||||
scale := math.Max(math.Abs(d), math.Abs(previousDistance))
|
||||
fraction := (previousDistance / scale) / (previousDistance/scale - d/scale)
|
||||
intersection := Lerp(Point(previous), Point(p), fraction)
|
||||
if dx == 0 {
|
||||
intersection.X = origin.X
|
||||
}
|
||||
if dy == 0 {
|
||||
intersection.Y = origin.Y
|
||||
}
|
||||
appendPoint(P(intersection))
|
||||
}
|
||||
if d >= 0 {
|
||||
appendPoint(p)
|
||||
}
|
||||
previous, previousDistance = p, d
|
||||
}
|
||||
if len(clipped) > 1 && clipped[0] == clipped[len(clipped)-1] {
|
||||
clipped = clipped[:len(clipped)-1]
|
||||
}
|
||||
a = clipped
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// ConvexPolygon 检查所有顶点位于各边的同一侧,不修改输入
|
||||
// 入参: points 不含重复闭合点的独立顶点
|
||||
// 返回: []P 原顶点,非有限距离或异侧顶点返回nil, bool 是否为非退化凸多边形
|
||||
func ConvexPolygon[P polygonPoint](points []P) ([]P, bool) {
|
||||
positive := false
|
||||
for i, start := range points {
|
||||
origin, end := Point(start), Point(points[(i+1)%len(points)])
|
||||
dx, dy := end.X-origin.X, end.Y-origin.Y
|
||||
length := math.Hypot(dx, dy)
|
||||
if !finite(length) {
|
||||
return nil, false
|
||||
}
|
||||
if length == 0 {
|
||||
continue
|
||||
}
|
||||
dx, dy = dx/length, dy/length
|
||||
sign := 0.0
|
||||
for _, p := range points {
|
||||
d := convexDistance(origin, Point(p), dx, dy)
|
||||
if !finite(d) || sign*d < 0 {
|
||||
return nil, false
|
||||
}
|
||||
if d != 0 {
|
||||
sign, positive = math.Copysign(1, d), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return points, positive
|
||||
}
|
||||
|
||||
// convexDistance 按浮点乘法误差界识别共线点,不使用固定坐标容差
|
||||
// 入参: start 边起点, point 待测顶点, dx 横向单位分量, dy 纵向单位分量
|
||||
// 返回: float64 顶点到边的有向距离
|
||||
func convexDistance(start, point Point, dx, dy float64) float64 {
|
||||
x, y := point.X-start.X, point.Y-start.Y
|
||||
distance := math.FMA(dx, y, -dy*x)
|
||||
errorBound := (math.Abs(dx*y) + math.Abs(dy*x)) * 0x1p-49
|
||||
if finite(errorBound) && math.Abs(distance) <= errorBound {
|
||||
return 0
|
||||
}
|
||||
return distance
|
||||
}
|
||||
@@ -12,14 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package geom
|
||||
|
||||
import "math"
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// geometryConvexContains 判断单个非退化凸直线区域是否包含另一闭合直线区域
|
||||
// ConvexContains 判断单个非退化凸直线区域是否包含另一闭合直线区域
|
||||
// 入参: outer 外部区域, inner 内部区域
|
||||
// 返回: bool 是否完全包含,曲线及多轮廓返回false
|
||||
func geometryConvexContains(outer, inner GeometryPath) bool {
|
||||
func ConvexContains(outer, inner Path) bool {
|
||||
a, b := geometryPolygonPoints(outer), geometryPolygonPoints(inner)
|
||||
if len(a) < 3 || len(b) < 3 {
|
||||
return false
|
||||
@@ -68,12 +70,12 @@ func geometryConvexContains(outer, inner GeometryPath) bool {
|
||||
// geometryPolygonPoints 读取单个显式闭合直线轮廓的顶点,不修改原路径
|
||||
// 入参: path 直线轮廓
|
||||
// 返回: []Point 独立顶点,其他路径返回nil
|
||||
func geometryPolygonPoints(path GeometryPath) []Point {
|
||||
if len(path) < 4 || path[0].Verb != GeometryMove || path[len(path)-1].Verb != GeometryClose {
|
||||
func geometryPolygonPoints(path Path) []Point {
|
||||
if len(path) < 4 || path[0].Verb != Move || path[len(path)-1].Verb != ClosePath {
|
||||
return nil
|
||||
}
|
||||
for i, segment := range path[:len(path)-1] {
|
||||
if i > 0 && segment.Verb != GeometryLine || !finite(segment.End.X) || !finite(segment.End.Y) {
|
||||
if i > 0 && segment.Verb != Line || !finite(segment.End.X) || !finite(segment.End.Y) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package geom
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// IdentityMatrix 单位矩阵
|
||||
var IdentityMatrix = Matrix{1, 0, 0, 1, 0, 0}
|
||||
|
||||
// Box 矩形区域
|
||||
type Box struct {
|
||||
X, Y, W, H float64
|
||||
}
|
||||
|
||||
// Point 平面坐标点
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
// Matrix 2D仿射变换矩阵
|
||||
type Matrix struct {
|
||||
a, b, c, d, e, f float64
|
||||
}
|
||||
|
||||
// MatrixFromValues 从a、b、c、d、e、f构造矩阵,不经过文本格式化
|
||||
// 入参: values 矩阵分量
|
||||
// 返回: Matrix 变换矩阵
|
||||
func MatrixFromValues(values [6]float64) Matrix {
|
||||
return Matrix{a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5]}
|
||||
}
|
||||
|
||||
// Values 返回a、b、c、d、e、f的独立副本,保留原始浮点精度
|
||||
// 返回: [6]float64 矩阵分量
|
||||
func (m Matrix) Values() [6]float64 {
|
||||
return [6]float64{m.a, m.b, m.c, m.d, m.e, m.f}
|
||||
}
|
||||
|
||||
// Multiply 矩阵乘法 (m * o)
|
||||
// 入参: o 右侧矩阵
|
||||
// 返回: Matrix 结果矩阵
|
||||
func (m Matrix) Multiply(o Matrix) Matrix {
|
||||
return Matrix{
|
||||
a: m.a*o.a + m.c*o.b,
|
||||
b: m.b*o.a + m.d*o.b,
|
||||
c: m.a*o.c + m.c*o.d,
|
||||
d: m.b*o.c + m.d*o.d,
|
||||
e: m.a*o.e + m.c*o.f + m.e,
|
||||
f: m.b*o.e + m.d*o.f + m.f,
|
||||
}
|
||||
}
|
||||
|
||||
// Transform 应用变换矩阵
|
||||
// 入参: x X坐标, y Y坐标
|
||||
// 返回: float64 变换后X, float64 变换后Y
|
||||
func (m Matrix) Transform(x, y float64) (float64, float64) {
|
||||
nx := m.a*x + m.c*y + m.e
|
||||
ny := m.b*x + m.d*y + m.f
|
||||
return nx, ny
|
||||
}
|
||||
|
||||
// TransformBox 获取矩形经仿射变换后的轴对齐边界
|
||||
// 入参: box 原始矩形范围
|
||||
// 返回: Box 变换后的轴对齐边界
|
||||
func (m Matrix) TransformBox(box Box) Box {
|
||||
x, y := m.Transform(box.X, box.Y)
|
||||
left, top, right, bottom := x, y, x, y
|
||||
for _, point := range [][2]float64{{box.X + box.W, box.Y}, {box.X, box.Y + box.H}, {box.X + box.W, box.Y + box.H}} {
|
||||
x, y := m.Transform(point[0], point[1])
|
||||
left, top, right, bottom = math.Min(left, x), math.Min(top, y), math.Max(right, x), math.Max(bottom, y)
|
||||
}
|
||||
return Box{X: left, Y: top, W: right - left, H: bottom - top}
|
||||
}
|
||||
|
||||
// Invert 求仿射矩阵的逆变换
|
||||
// 返回: Matrix 逆矩阵,失败时返回单位矩阵, bool 是否存在有限逆矩阵
|
||||
func (m Matrix) Invert() (Matrix, bool) {
|
||||
for _, value := range m.Values() {
|
||||
if !finite(value) {
|
||||
return IdentityMatrix, false
|
||||
}
|
||||
}
|
||||
det, ds := matrixProductDifference(m.a, m.d, m.b, m.c)
|
||||
x, xs := matrixProductDifference(m.c, m.f, m.d, m.e)
|
||||
y, ys := matrixProductDifference(m.b, m.e, m.a, m.f)
|
||||
if det != 0 && ds && xs && ys {
|
||||
n := Matrix{a: m.d / det, b: -m.b / det, c: -m.c / det, d: m.a / det, e: x / det, f: y / det}
|
||||
valid := true
|
||||
for _, value := range n.Values() {
|
||||
valid = valid && finite(value)
|
||||
}
|
||||
if valid {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
determinant, exponent := matrixProductSum(m.a, m.d, -m.b, m.c)
|
||||
if determinant == 0 {
|
||||
return IdentityMatrix, false
|
||||
}
|
||||
divide := func(value float64, power int) float64 {
|
||||
mantissa, shift := math.Frexp(value)
|
||||
return math.Ldexp(mantissa/determinant, power+shift-exponent)
|
||||
}
|
||||
x, xe := matrixProductSum(m.c, m.f, -m.d, m.e)
|
||||
y, ye := matrixProductSum(m.b, m.e, -m.a, m.f)
|
||||
n := Matrix{a: divide(m.d, 0), b: divide(-m.b, 0), c: divide(-m.c, 0), d: divide(m.a, 0), e: divide(x, xe), f: divide(y, ye)}
|
||||
for _, value := range n.Values() {
|
||||
if !finite(value) {
|
||||
return IdentityMatrix, false
|
||||
}
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// matrixProductDifference 补偿乘积相减的舍入残差,检查溢出、下溢及严重相消
|
||||
// 入参: a 第一个乘数, b 第二个乘数, c 第三个乘数, d 第四个乘数
|
||||
// 返回: float64 乘积之差, bool 是否可直接用于求逆
|
||||
func matrixProductDifference(a, b, c, d float64) (float64, bool) {
|
||||
x, y := a*b, c*d
|
||||
value := x - y
|
||||
if !finite(value) || x == 0 && a != 0 && b != 0 || y == 0 && c != 0 && d != 0 {
|
||||
return value, false
|
||||
}
|
||||
if x != 0 && y != 0 {
|
||||
value = math.FMA(a, b, -y) + math.FMA(-c, d, y)
|
||||
}
|
||||
return value, x == 0 && y == 0 || math.Abs(value) >= 0x1p-1022 && math.Abs(value) >= 0x1p-48*math.Max(math.Abs(x), math.Abs(y))
|
||||
}
|
||||
|
||||
// matrixProductSum 以尾数及指数累加两个乘积,保留乘法舍入残差
|
||||
// 入参: a 第一个乘数, b 第二个乘数, c 第三个乘数, d 第四个乘数
|
||||
// 返回: float64 结果尾数, int 二进制指数
|
||||
func matrixProductSum(a, b, c, d float64) (float64, int) {
|
||||
a, ae := math.Frexp(a)
|
||||
b, be := math.Frexp(b)
|
||||
c, ce := math.Frexp(c)
|
||||
d, de := math.Frexp(d)
|
||||
x, y := a*b, c*d
|
||||
xe, ye := ae+be, ce+de
|
||||
power := max(xe, ye)
|
||||
if x == 0 {
|
||||
power = ye
|
||||
} else if y == 0 {
|
||||
power = xe
|
||||
}
|
||||
value := math.Ldexp(x, xe-power) + math.Ldexp(y, ye-power)
|
||||
value += math.Ldexp(math.FMA(a, b, -x), xe-power) + math.Ldexp(math.FMA(c, d, -y), ye-power)
|
||||
value, shift := math.Frexp(value)
|
||||
return value, power + shift
|
||||
}
|
||||
|
||||
// TranslationMatrix 构造平移矩阵
|
||||
// 入参: x X轴偏移, y Y轴偏移
|
||||
// 返回: Matrix 平移矩阵
|
||||
func TranslationMatrix(x, y float64) Matrix {
|
||||
return Matrix{a: 1, d: 1, e: x, f: y}
|
||||
}
|
||||
|
||||
// YScale 获取Y轴缩放比例
|
||||
// 返回: float64 缩放比例
|
||||
func (m Matrix) YScale() float64 {
|
||||
return math.Hypot(m.c, m.d)
|
||||
}
|
||||
|
||||
// Equal 比较毫米坐标,使用固定绝对误差,不受绘图库全局精度影响
|
||||
// 入参: a 第一个坐标, b 第二个坐标
|
||||
// 返回: bool 是否在容差内相等
|
||||
func Equal(a, b float64) bool {
|
||||
return math.Abs(a-b) <= 1e-10
|
||||
}
|
||||
|
||||
// AxisAligned 判断变换是否保持坐标轴平行,含直角旋转与镜像
|
||||
// 入参: m 仿射变换矩阵
|
||||
// 返回: bool 是否为非退化的轴对齐变换
|
||||
func AxisAligned(m Matrix) bool {
|
||||
return m.b == 0 && m.c == 0 && m.a != 0 && m.d != 0 || m.a == 0 && m.d == 0 && m.b != 0 && m.c != 0
|
||||
}
|
||||
|
||||
// Vector 变换位移,不包含平移分量
|
||||
// 入参: m 仿射变换矩阵, x 原始横向位移, y 原始纵向位移
|
||||
// 返回: float64 变换后的X位移, float64 变换后的Y位移
|
||||
func Vector(m Matrix, x, y float64) (float64, float64) {
|
||||
return m.a*x + m.c*y, m.b*x + m.d*y
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package geom
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// finite 判断数值是否有限
|
||||
// 入参: value 数值
|
||||
// 返回: bool 是否有限
|
||||
func finite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
// positive 判断正有限数
|
||||
// 入参: v 待检查数值
|
||||
// 返回: bool 是否为正有限数
|
||||
func positive(v float64) bool { return v > 0 && !math.IsInf(v, 0) && !math.IsNaN(v) }
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package geom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// 几何路径指令,包括直线、曲线、椭圆弧和闭合
|
||||
const (
|
||||
Move Verb = iota
|
||||
Line
|
||||
Quad
|
||||
Cubic
|
||||
Arc
|
||||
ClosePath
|
||||
)
|
||||
|
||||
// Path 保存独立于绘图库的几何路径,不扁平化曲线或舍入坐标
|
||||
// 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点
|
||||
type Path []Segment
|
||||
|
||||
// Verb 表示几何路径指令
|
||||
type Verb uint8
|
||||
|
||||
// Segment 保存路径端点、贝塞尔控制点或端点式椭圆弧参数
|
||||
// 弧线Rotation单位为度,Sweep为true表示顺时针,Large表示大弧
|
||||
type Segment struct {
|
||||
Verb Verb
|
||||
End Point
|
||||
Control1, Control2 Point
|
||||
RadiusX, RadiusY float64
|
||||
Rotation float64
|
||||
Large, Sweep bool
|
||||
}
|
||||
|
||||
// StrokeOptions 描述描边参数,长度与路径坐标采用相同单位
|
||||
// Width须为正有限数;Cap和Join使用标准线帽和连接样式名称,空值分别采用Butt和Miter
|
||||
// MiterLimit为尖角长度与半线宽的比值上限,由调用方设置默认值
|
||||
// MiterLimitSet供调用方区分显式零值与默认值,不参与几何计算
|
||||
// DashOffset为虚线起始偏移,允许负值;Dashes交替指定实线段和间隔长度,奇数项按重复一遍处理
|
||||
// Tolerance为曲线展开的误差目标,0采用0.001
|
||||
type StrokeOptions struct {
|
||||
Width float64
|
||||
Cap, Join string
|
||||
MiterLimit float64
|
||||
MiterLimitSet bool
|
||||
DashOffset float64
|
||||
Tolerance float64
|
||||
Dashes []float64
|
||||
}
|
||||
|
||||
// Close 为填充轮廓补齐隐式闭合边,不改变源路径
|
||||
// 入参: path 填充路径
|
||||
// 返回: Path 显式闭合路径
|
||||
func Close(path Path) Path {
|
||||
open, missing := false, false
|
||||
for _, segment := range path {
|
||||
if segment.Verb == Move && open {
|
||||
missing = true
|
||||
break
|
||||
}
|
||||
open = segment.Verb != ClosePath
|
||||
}
|
||||
if !missing && !open {
|
||||
return path
|
||||
}
|
||||
result := make(Path, 0, len(path)+1)
|
||||
open = false
|
||||
for _, segment := range path {
|
||||
if segment.Verb == Move && open {
|
||||
result = append(result, Segment{Verb: ClosePath})
|
||||
}
|
||||
result = append(result, segment)
|
||||
open = segment.Verb != ClosePath
|
||||
}
|
||||
if open {
|
||||
result = append(result, Segment{Verb: ClosePath})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateStroke 校验各后端共用的描边参数
|
||||
// 入参: options 描边样式
|
||||
// 返回: error 无效样式
|
||||
func ValidateStroke(options StrokeOptions) error {
|
||||
if !finite(options.Width) || options.Width <= 0 || !finite(options.Tolerance) || options.Tolerance < 0 || !finite(options.DashOffset) || !finite(options.MiterLimit) || options.MiterLimit < 0 {
|
||||
return fmt.Errorf("invalid stroke width or tolerance")
|
||||
}
|
||||
if options.Cap != "" && options.Cap != "Butt" && options.Cap != "Round" && options.Cap != "Square" {
|
||||
return fmt.Errorf("invalid stroke cap %q", options.Cap)
|
||||
}
|
||||
if options.Join != "" && options.Join != "Miter" && options.Join != "Round" && options.Join != "Bevel" {
|
||||
return fmt.Errorf("invalid stroke join %q", options.Join)
|
||||
}
|
||||
for _, dash := range options.Dashes {
|
||||
if !finite(dash) || dash < 0 {
|
||||
return fmt.Errorf("invalid stroke dash")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rectangle 构建页面坐标矩形,保留输入精度
|
||||
// 入参: box 矩形范围
|
||||
// 返回: Path 闭合路径
|
||||
func Rectangle(box Box) Path {
|
||||
return Path{
|
||||
{Verb: Move, End: Point{X: box.X, Y: box.Y}},
|
||||
{Verb: Line, End: Point{X: box.X + box.W, Y: box.Y}},
|
||||
{Verb: Line, End: Point{X: box.X + box.W, Y: box.Y + box.H}},
|
||||
{Verb: Line, End: Point{X: box.X, Y: box.Y + box.H}},
|
||||
{Verb: ClosePath, End: Point{X: box.X, Y: box.Y}},
|
||||
}
|
||||
}
|
||||
|
||||
// RectangleBounds 识别单个轴对齐矩形,不用包围盒代替实际路径
|
||||
// 入参: path 填充路径
|
||||
// 返回: Box 矩形范围, bool 是否为非退化矩形
|
||||
func RectangleBounds(path Path) (Box, bool) {
|
||||
if len(path) > 0 && path[len(path)-1].Verb == ClosePath {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
if len(path) == 5 && path[4].Verb == Line && path[4].End == path[0].End {
|
||||
path = path[:4]
|
||||
}
|
||||
if len(path) != 4 || path[0].Verb != Move || path[1].Verb != Line || path[2].Verb != Line || path[3].Verb != Line {
|
||||
return Box{}, false
|
||||
}
|
||||
a, b, c, d := path[0].End, path[1].End, path[2].End, path[3].End
|
||||
for _, point := range [...]Point{a, b, c, d} {
|
||||
if !finite(point.X) || !finite(point.Y) {
|
||||
return Box{}, false
|
||||
}
|
||||
}
|
||||
if a.X == c.X || a.Y == c.Y {
|
||||
return Box{}, false
|
||||
}
|
||||
if !(a.X == b.X && b.Y == c.Y && c.X == d.X && d.Y == a.Y || a.Y == b.Y && b.X == c.X && c.Y == d.Y && d.X == a.X) {
|
||||
return Box{}, false
|
||||
}
|
||||
width, height := math.Abs(c.X-a.X), math.Abs(c.Y-a.Y)
|
||||
if !finite(width) || !finite(height) {
|
||||
return Box{}, false
|
||||
}
|
||||
return Box{X: min(a.X, c.X), Y: min(a.Y, c.Y), W: width, H: height}, true
|
||||
}
|
||||
@@ -12,13 +12,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package geom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// UnsupportedError 表示当前几何实现无法展开的描边形态
|
||||
type UnsupportedError string
|
||||
|
||||
// geometryPolyline 保存展开后的子路径,独立记录闭合状态
|
||||
type geometryPolyline struct {
|
||||
points []Point
|
||||
@@ -26,16 +30,22 @@ type geometryPolyline struct {
|
||||
direction Point
|
||||
}
|
||||
|
||||
// Stroke 将曲线、虚线及线帽连接转换为非零填充轮廓
|
||||
// Error 返回不支持的描边原因
|
||||
// 返回: string 错误信息
|
||||
func (err UnsupportedError) Error() string {
|
||||
return string(err)
|
||||
}
|
||||
|
||||
// Stroke 按端点和转角样式将曲线、虚线转换为非零填充轮廓
|
||||
// 曲线按Tolerance展开,默认误差为0.001毫米,重叠轮廓保持同向绕数
|
||||
// 结果未做布尔整理,按非零规则填充,区域运算需能处理重叠子路径的后端
|
||||
// 入参: options 描边样式
|
||||
// 返回: GeometryPath 描边轮廓, error 无效参数或精度限制
|
||||
func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
|
||||
if err := validateStroke(options); err != nil {
|
||||
// 返回: Path 描边轮廓, error 无效参数或精度限制
|
||||
func (p Path) Stroke(options StrokeOptions) (Path, error) {
|
||||
if err := ValidateStroke(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.validate(); err != nil {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.Width/2 == 0 {
|
||||
@@ -68,7 +78,7 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result GeometryPath
|
||||
var result Path
|
||||
for _, line := range lines {
|
||||
parts := []geometryPolyline{line}
|
||||
if len(dashes) != 0 {
|
||||
@@ -87,7 +97,7 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := result.validate(); err != nil {
|
||||
if err := result.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
@@ -96,7 +106,7 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
|
||||
// strokePolylines 自适应展开贝塞尔,弦长亏损同时约束虚线累计长度误差
|
||||
// 入参: tolerance 展开误差
|
||||
// 返回: []geometryPolyline 子路径, error 展开限制
|
||||
func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, error) {
|
||||
func (p Path) strokePolylines(tolerance float64) ([]geometryPolyline, error) {
|
||||
curves, err := p.Curves(tolerance / 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -144,26 +154,26 @@ func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, er
|
||||
if depth == 24 {
|
||||
return fmt.Errorf("stroke curve tolerance cannot be met")
|
||||
}
|
||||
work := append([]Point(nil), points...)
|
||||
left, right := make([]Point, len(points)), make([]Point, len(points))
|
||||
var work, left, right [4]Point
|
||||
copy(work[:], points)
|
||||
for n := len(points); n > 0; n-- {
|
||||
left[len(points)-n], right[n-1] = work[0], work[n-1]
|
||||
for i := 0; i < n-1; i++ {
|
||||
work[i] = geometryLerp(work[i], work[i+1], .5)
|
||||
work[i] = Lerp(work[i], work[i+1], .5)
|
||||
}
|
||||
}
|
||||
if err := flatten(left, budget/2, depth+1); err != nil {
|
||||
if err := flatten(left[:len(points)], budget/2, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
return flatten(right, budget/2, depth+1)
|
||||
return flatten(right[:len(points)], budget/2, depth+1)
|
||||
}
|
||||
for _, s := range curves {
|
||||
switch s.Verb {
|
||||
case GeometryMove:
|
||||
case Move:
|
||||
flush()
|
||||
current, start = s.End, s.End
|
||||
line.points = []Point{current}
|
||||
case GeometryClose:
|
||||
case ClosePath:
|
||||
if drawn {
|
||||
if current != start {
|
||||
line.points = append(line.points, start)
|
||||
@@ -175,11 +185,12 @@ func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, er
|
||||
line.points = []Point{current}
|
||||
default:
|
||||
drawn = true
|
||||
points := []Point{current}
|
||||
if s.Verb == GeometryQuad || s.Verb == GeometryCubic {
|
||||
var buffer [4]Point
|
||||
points := append(buffer[:0], current)
|
||||
if s.Verb == Quad || s.Verb == Cubic {
|
||||
points = append(points, s.Control1)
|
||||
}
|
||||
if s.Verb == GeometryCubic {
|
||||
if s.Verb == Cubic {
|
||||
points = append(points, s.Control2)
|
||||
}
|
||||
points = append(points, s.End)
|
||||
@@ -236,7 +247,7 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g
|
||||
return nil, fmt.Errorf("stroke dash subdivision limit exceeded")
|
||||
}
|
||||
if pattern[index] == 0 && index%2 == 0 && len(run) == 0 {
|
||||
result = append(result, geometryPolyline{points: []Point{geometryLerp(a, b, at/length)}, direction: Point{(b.X - a.X) / length, (b.Y - a.Y) / length}})
|
||||
result = append(result, geometryPolyline{points: []Point{Lerp(a, b, at/length)}, direction: Point{(b.X - a.X) / length, (b.Y - a.Y) / length}})
|
||||
}
|
||||
index = (index + 1) % len(pattern)
|
||||
remaining = pattern[index]
|
||||
@@ -254,10 +265,10 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g
|
||||
steps++
|
||||
if index%2 == 0 {
|
||||
if len(run) == 0 {
|
||||
run = append(run, geometryLerp(a, b, at/length))
|
||||
run = append(run, Lerp(a, b, at/length))
|
||||
direction = Point{(b.X - a.X) / length, (b.Y - a.Y) / length}
|
||||
}
|
||||
end := geometryLerp(a, b, (at+step)/length)
|
||||
end := Lerp(a, b, (at+step)/length)
|
||||
if end != run[len(run)-1] {
|
||||
run = append(run, end)
|
||||
}
|
||||
@@ -283,8 +294,8 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g
|
||||
|
||||
// stroke 追加同向矩形、连接楔形及圆盘组成的非零填充区域
|
||||
// 入参: result 已有轮廓, options 描边样式
|
||||
// 返回: GeometryPath 填充轮廓, error 不支持的退化方形线帽
|
||||
func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions) (GeometryPath, error) {
|
||||
// 返回: Path 填充轮廓, error 不支持的退化方头端点
|
||||
func (line geometryPolyline) stroke(result Path, options StrokeOptions) (Path, error) {
|
||||
half := options.Width / 2
|
||||
polygon := func(points ...Point) {
|
||||
scale := 0.0
|
||||
@@ -307,29 +318,29 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions)
|
||||
points[i], points[j] = points[j], points[i]
|
||||
}
|
||||
}
|
||||
result = append(result, GeometrySegment{Verb: GeometryMove, End: points[0]})
|
||||
result = append(result, Segment{Verb: Move, End: points[0]})
|
||||
for _, p := range points[1:] {
|
||||
result = append(result, GeometrySegment{Verb: GeometryLine, End: p})
|
||||
result = append(result, Segment{Verb: Line, End: p})
|
||||
}
|
||||
result = append(result, GeometrySegment{Verb: GeometryClose})
|
||||
result = append(result, Segment{Verb: ClosePath})
|
||||
}
|
||||
disk := func(p Point) {
|
||||
result = append(result, GeometrySegment{Verb: GeometryMove, End: Point{p.X + half, p.Y}},
|
||||
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X - half, p.Y}},
|
||||
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X + half, p.Y}},
|
||||
GeometrySegment{Verb: GeometryClose})
|
||||
result = append(result, Segment{Verb: Move, End: Point{p.X + half, p.Y}},
|
||||
Segment{Verb: Arc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X - half, p.Y}},
|
||||
Segment{Verb: Arc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X + half, p.Y}},
|
||||
Segment{Verb: ClosePath})
|
||||
}
|
||||
points := line.points
|
||||
if len(points) == 1 {
|
||||
if line.closed {
|
||||
return nil, fmt.Errorf("directionless closed stroke: %w", ErrBackendUnavailable)
|
||||
return nil, UnsupportedError("directionless closed stroke")
|
||||
}
|
||||
if options.Cap == "Round" {
|
||||
disk(points[0])
|
||||
} else if options.Cap == "Square" {
|
||||
d := line.direction
|
||||
if d == (Point{}) {
|
||||
return nil, fmt.Errorf("directionless square stroke cap: %w", ErrBackendUnavailable)
|
||||
return nil, UnsupportedError("directionless square stroke cap")
|
||||
}
|
||||
p := points[0]
|
||||
u, v := Point{d.X * half, d.Y * half}, Point{-d.Y * half, d.X * half}
|
||||
@@ -337,7 +348,22 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
directions := make([]Point, len(points)-1)
|
||||
segments := len(points) - 1
|
||||
joins := segments - 1
|
||||
caps := 0
|
||||
if line.closed {
|
||||
joins++
|
||||
} else if options.Cap == "Round" {
|
||||
caps = 8
|
||||
} else if options.Cap == "Square" {
|
||||
caps = 10
|
||||
}
|
||||
joinSize := 4
|
||||
if options.Join == "" || options.Join == "Miter" {
|
||||
joinSize = 5
|
||||
}
|
||||
result = slices.Grow(result, min(segments*5+joins*joinSize+caps, 1<<20-len(result)))
|
||||
directions := make([]Point, segments)
|
||||
for i := range directions {
|
||||
a, b := points[i], points[i+1]
|
||||
length := math.Hypot(b.X-a.X, b.Y-a.Y)
|
||||
@@ -364,16 +390,13 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions)
|
||||
if cross < 0 {
|
||||
x, y = y, x
|
||||
}
|
||||
result = append(result, GeometrySegment{Verb: GeometryMove, End: p},
|
||||
GeometrySegment{Verb: GeometryLine, End: x},
|
||||
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: y},
|
||||
GeometrySegment{Verb: GeometryClose})
|
||||
result = append(result, Segment{Verb: Move, End: p},
|
||||
Segment{Verb: Line, End: x},
|
||||
Segment{Verb: Arc, RadiusX: half, RadiusY: half, Sweep: true, End: y},
|
||||
Segment{Verb: ClosePath})
|
||||
return
|
||||
}
|
||||
limit := options.MiterLimit
|
||||
if limit == 0 {
|
||||
limit = defaultMiterLimit
|
||||
}
|
||||
if options.Join == "" || options.Join == "Miter" {
|
||||
t := ((y.X-x.X)*b.Y - (y.Y-x.Y)*b.X) / cross
|
||||
m := Point{x.X + t*a.X, x.Y + t*a.Y}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package geom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
|
||||
// Transform 精确变换控制点和椭圆轴,退化弧沿投影极值分段保留折返
|
||||
// 入参: matrix 仿射变换
|
||||
// 返回: GeometryPath 独立路径, error 无效路径或数值溢出
|
||||
func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
if err := p.validate(); err != nil {
|
||||
// 返回: Path 独立路径, error 无效路径或数值溢出
|
||||
func (p Path) Transform(matrix Matrix) (Path, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, value := range matrix.Values() {
|
||||
@@ -33,17 +33,14 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
}
|
||||
}
|
||||
matrixScale := math.Max(math.Max(math.Abs(matrix.a), math.Abs(matrix.b)), math.Max(math.Abs(matrix.c), math.Abs(matrix.d)))
|
||||
determinant := 0.0
|
||||
if matrixScale != 0 {
|
||||
determinant = (matrix.a/matrixScale)*(matrix.d/matrixScale) - (matrix.b/matrixScale)*(matrix.c/matrixScale)
|
||||
}
|
||||
determinant, _ := matrixProductSum(matrix.a, matrix.d, -matrix.b, matrix.c)
|
||||
transform := func(p Point) Point {
|
||||
x, y := matrix.Transform(p.X, p.Y)
|
||||
return Point{x, y}
|
||||
}
|
||||
var result GeometryPath
|
||||
var result Path
|
||||
if len(p) > 0 {
|
||||
result = make(GeometryPath, 0, len(p))
|
||||
result = make(Path, 0, len(p))
|
||||
}
|
||||
var current, start Point
|
||||
for _, s := range p {
|
||||
@@ -51,15 +48,15 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
original := s.End
|
||||
s.End = transform(s.End)
|
||||
switch s.Verb {
|
||||
case GeometryMove:
|
||||
case Move:
|
||||
start = original
|
||||
case GeometryQuad:
|
||||
case Quad:
|
||||
s.Control1 = transform(s.Control1)
|
||||
case GeometryCubic:
|
||||
case Cubic:
|
||||
s.Control1, s.Control2 = transform(s.Control1), transform(s.Control2)
|
||||
case GeometryClose:
|
||||
case ClosePath:
|
||||
original, s.End = start, transform(start)
|
||||
case GeometryArc:
|
||||
case Arc:
|
||||
a, err := centerArc(current, originalSegment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -69,11 +66,11 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
continue
|
||||
}
|
||||
if s.RadiusX == 0 || s.RadiusY == 0 {
|
||||
s = GeometrySegment{Verb: GeometryLine, End: s.End}
|
||||
s = Segment{Verb: Line, End: s.End}
|
||||
} else {
|
||||
a.center = transform(a.center)
|
||||
a.u.X, a.u.Y = matrixVector(matrix, a.u.X, a.u.Y)
|
||||
a.v.X, a.v.Y = matrixVector(matrix, a.v.X, a.v.Y)
|
||||
a.u.X, a.u.Y = Vector(matrix, a.u.X, a.u.Y)
|
||||
a.v.X, a.v.Y = Vector(matrix, a.v.X, a.v.Y)
|
||||
scale := math.Max(math.Hypot(a.u.X, a.u.Y), math.Hypot(a.v.X, a.v.Y))
|
||||
if !finite(scale) {
|
||||
return nil, fmt.Errorf("arc transform overflow")
|
||||
@@ -82,7 +79,7 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
if matrixScale != 0 {
|
||||
return nil, fmt.Errorf("arc transform precision exhausted")
|
||||
}
|
||||
s = GeometrySegment{Verb: GeometryLine, End: s.End}
|
||||
s = Segment{Verb: Line, End: s.End}
|
||||
} else {
|
||||
ux, uy, vx, vy := a.u.X/scale, a.u.Y/scale, a.v.X/scale, a.v.Y/scale
|
||||
det := ux*vy - uy*vx
|
||||
@@ -100,9 +97,9 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
}
|
||||
slices.Sort(fractions)
|
||||
for _, f := range slices.Compact(fractions) {
|
||||
result = append(result, GeometrySegment{Verb: GeometryLine, End: a.point(a.theta + f*a.delta)})
|
||||
result = append(result, Segment{Verb: Line, End: a.point(a.theta + f*a.delta)})
|
||||
}
|
||||
s = GeometrySegment{Verb: GeometryLine, End: s.End}
|
||||
s = Segment{Verb: Line, End: s.End}
|
||||
} else {
|
||||
xx, yy, xy := ux*ux+vx*vx, uy*uy+vy*vy, ux*uy+vx*vy
|
||||
radius := math.Sqrt((xx + yy + math.Hypot(xx-yy, 2*xy)) / 2)
|
||||
@@ -121,32 +118,32 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
|
||||
result = append(result, s)
|
||||
current = original
|
||||
}
|
||||
if err := result.validate(); err != nil {
|
||||
if err := result.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validate 校验公共路径指令、子路径起点和有限坐标
|
||||
// Validate 校验公共路径指令、子路径起点和有限坐标
|
||||
// 返回: error 无效路径
|
||||
func (p GeometryPath) validate() error {
|
||||
func (p Path) Validate() error {
|
||||
started := false
|
||||
for _, s := range p {
|
||||
points := [3]Point{s.End, s.Control1, s.Control2}
|
||||
count := 1
|
||||
switch s.Verb {
|
||||
case GeometryMove:
|
||||
case Move:
|
||||
started = true
|
||||
case GeometryLine:
|
||||
case GeometryQuad:
|
||||
case Line:
|
||||
case Quad:
|
||||
count = 2
|
||||
case GeometryCubic:
|
||||
case Cubic:
|
||||
count = 3
|
||||
case GeometryArc:
|
||||
case Arc:
|
||||
if !finite(s.RadiusX) || !finite(s.RadiusY) || !finite(s.Rotation) || s.RadiusX < 0 || s.RadiusY < 0 {
|
||||
return fmt.Errorf("invalid arc parameters")
|
||||
}
|
||||
case GeometryClose:
|
||||
case ClosePath:
|
||||
count = 0
|
||||
default:
|
||||
return fmt.Errorf("unsupported geometry command %d", s.Verb)
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
// bmpColorMask 保存一个BMP颜色分量的位域位置和取值范围
|
||||
type bmpColorMask struct {
|
||||
mask uint32
|
||||
shift uint
|
||||
Max uint32
|
||||
}
|
||||
|
||||
// DecodeConfig 读取图片尺寸,BMP和TIFF只读取布局信息,不解码像素
|
||||
// 入参: input 图片输入流
|
||||
// 返回: image.Config 图片配置, string 图片格式, error 头部错误
|
||||
func DecodeConfig(input io.Reader) (image.Config, string, error) {
|
||||
buffer := bufio.NewReader(input)
|
||||
prefix, _ := buffer.Peek(34)
|
||||
if IsTIFF(prefix) {
|
||||
layout, err := readTIFFLayout(buffer)
|
||||
if err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
size := layout.DisplaySize()
|
||||
return image.Config{Width: size.X, Height: size.Y}, "tiff", nil
|
||||
}
|
||||
if len(prefix) == 34 && string(prefix[:2]) == "BM" {
|
||||
header := binary.LittleEndian.Uint32(prefix[14:18])
|
||||
bpp := binary.LittleEndian.Uint16(prefix[28:30])
|
||||
compression := binary.LittleEndian.Uint32(prefix[30:34])
|
||||
if (bpp == 16 || bpp == 32 && compression == 3) && (header == 40 || header == 52 || header == 56 || header == 108 || header == 124) {
|
||||
size := int(header) + 14
|
||||
if header == 40 && compression == 3 {
|
||||
size += 12
|
||||
}
|
||||
data := make([]byte, size)
|
||||
if _, err := io.ReadFull(buffer, data); err != nil {
|
||||
return image.Config{}, "bmp", err
|
||||
}
|
||||
config, err := decodeBMPConfig(data)
|
||||
return config, "bmp", err
|
||||
}
|
||||
}
|
||||
return image.DecodeConfig(buffer)
|
||||
}
|
||||
|
||||
// sample 将位域采样值映射到八位通道,缺省透明度为不透明
|
||||
// 入参: value 编码像素
|
||||
// 返回: uint8 通道值
|
||||
func (m bmpColorMask) sample(value uint32) uint8 {
|
||||
if m.mask == 0 {
|
||||
return 255
|
||||
}
|
||||
return uint8(uint64((value&m.mask)>>m.shift) * 255 / uint64(m.Max))
|
||||
}
|
||||
|
||||
// BMPColorMasks 读取BMP真彩色通道布局,校验位域连续性、重叠及像素范围
|
||||
// 入参: data 已校验头部的BMP数据, offset 像素偏移, bpp 像素位数, compression 编码方式
|
||||
// 返回: [4]bmpColorMask 红绿蓝和透明度通道, error 格式错误
|
||||
func BMPColorMasks(data []byte, offset int, bpp uint16, compression uint32) ([4]bmpColorMask, error) {
|
||||
var result [4]bmpColorMask
|
||||
if bpp != 16 && bpp != 24 && bpp != 32 {
|
||||
return result, fmt.Errorf("unsupported bmp depth")
|
||||
}
|
||||
masks := [4]uint32{0xff0000, 0xff00, 0xff}
|
||||
if bpp == 16 {
|
||||
masks = [4]uint32{0x7c00, 0x3e0, 0x1f}
|
||||
}
|
||||
switch compression {
|
||||
case 0:
|
||||
case 3:
|
||||
if bpp == 24 {
|
||||
return result, fmt.Errorf("invalid bmp bitfield depth")
|
||||
}
|
||||
header := binary.LittleEndian.Uint32(data[14:18])
|
||||
if offset < 66 || len(data) < 66 || header != 40 && header < 52 {
|
||||
return result, fmt.Errorf("truncated bmp color masks")
|
||||
}
|
||||
for i := range 3 {
|
||||
masks[i] = binary.LittleEndian.Uint32(data[54+i*4:])
|
||||
}
|
||||
if header >= 56 {
|
||||
masks[3] = binary.LittleEndian.Uint32(data[66:70])
|
||||
}
|
||||
default:
|
||||
return result, fmt.Errorf("unsupported bmp compression")
|
||||
}
|
||||
var used uint32
|
||||
for i, mask := range masks {
|
||||
if mask == 0 {
|
||||
if i < 3 {
|
||||
return result, fmt.Errorf("empty bmp color mask")
|
||||
}
|
||||
continue
|
||||
}
|
||||
shift := uint(bits.TrailingZeros32(mask))
|
||||
maximum := mask >> shift
|
||||
if mask&used != 0 || maximum&(maximum+1) != 0 || uint64(mask)>>bpp != 0 {
|
||||
return result, fmt.Errorf("invalid bmp color mask")
|
||||
}
|
||||
result[i] = bmpColorMask{mask: mask, shift: shift, Max: maximum}
|
||||
used |= mask
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// colorImage 保留源采样及独立的十六位显示像素
|
||||
type colorImage struct {
|
||||
*image.NRGBA64
|
||||
source image.Image
|
||||
}
|
||||
|
||||
// pngColor 保存PNG校准空间及缺省sRGB传递函数
|
||||
type pngColor struct {
|
||||
space *pdfgo.ColorSpace
|
||||
linearSRGB bool
|
||||
}
|
||||
|
||||
// pngColorProfile 读取PNG颜色元数据,ICC优先于sRGB及独立校准参数
|
||||
// 入参: ctx 取消上下文, data 原始编码
|
||||
// 返回: []byte ICC编码, *pngColor 校准参数, error 格式或配置错误
|
||||
func pngColorProfile(ctx context.Context, data []byte) ([]byte, *pngColor, error) {
|
||||
const profileLimit = 16 << 20
|
||||
chunks := make(map[string][]byte)
|
||||
for at := 8; at < len(data); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(data)-at < 8 {
|
||||
return nil, nil, fmt.Errorf("truncated PNG chunk")
|
||||
}
|
||||
kind := string(data[at+4 : at+8])
|
||||
if kind == "IDAT" || kind == "PLTE" {
|
||||
break
|
||||
}
|
||||
if len(data)-at < 12 {
|
||||
return nil, nil, fmt.Errorf("truncated PNG chunk")
|
||||
}
|
||||
size := uint64(binary.BigEndian.Uint32(data[at:]))
|
||||
if size > uint64(len(data)-at-12) {
|
||||
return nil, nil, fmt.Errorf("invalid PNG chunk length")
|
||||
}
|
||||
end := at + 8 + int(size)
|
||||
switch kind {
|
||||
case "iCCP", "sRGB", "gAMA", "cHRM":
|
||||
if _, ok := chunks[kind]; ok {
|
||||
return nil, nil, fmt.Errorf("invalid PNG %s chunk order", kind)
|
||||
}
|
||||
if crc32.ChecksumIEEE(data[at+4:end]) != binary.BigEndian.Uint32(data[end:]) {
|
||||
return nil, nil, fmt.Errorf("invalid PNG %s checksum", kind)
|
||||
}
|
||||
chunks[kind] = data[at+8 : end]
|
||||
}
|
||||
at = end + 4
|
||||
if kind == "IEND" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if chunk, ok := chunks["iCCP"]; ok {
|
||||
name := bytes.IndexByte(chunk, 0)
|
||||
if name < 1 || name > 79 || len(chunk) < name+3 || chunk[name+1] != 0 {
|
||||
return nil, nil, fmt.Errorf("invalid PNG ICC header")
|
||||
}
|
||||
for i, c := range chunk[:name] {
|
||||
if c < 32 || c > 126 && c < 161 || c == 32 && (i == 0 || i == name-1 || chunk[i-1] == 32) {
|
||||
return nil, nil, fmt.Errorf("invalid PNG ICC name")
|
||||
}
|
||||
}
|
||||
input := bytes.NewReader(chunk[name+2:])
|
||||
reader, err := zlib.NewReader(input)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
profile, err := io.ReadAll(io.LimitReader(contextInput{ReadCloser: reader, ctx: ctx}, profileLimit+1))
|
||||
closeErr := reader.Close()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, nil, closeErr
|
||||
}
|
||||
if len(profile) > profileLimit {
|
||||
return nil, nil, fmt.Errorf("PNG ICC profile exceeds size limit")
|
||||
}
|
||||
if input.Len() != 0 {
|
||||
return nil, nil, fmt.Errorf("trailing PNG ICC data")
|
||||
}
|
||||
kind := "RGB "
|
||||
if len(data) > 25 && (data[25] == 0 || data[25] == 4) {
|
||||
kind = "GRAY"
|
||||
}
|
||||
if len(profile) < 132 || string(profile[16:20]) != kind {
|
||||
return nil, nil, fmt.Errorf("ICC source model does not match PNG color space %q", kind)
|
||||
}
|
||||
return profile, nil, nil
|
||||
}
|
||||
if chunk, ok := chunks["sRGB"]; ok {
|
||||
if len(chunk) != 1 || chunk[0] > 3 {
|
||||
return nil, nil, fmt.Errorf("invalid PNG sRGB intent")
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
coordinates := [8]float64{0.3127, 0.3290, 0.6400, 0.3300, 0.3000, 0.6000, 0.1500, 0.0600}
|
||||
if chunk, ok := chunks["cHRM"]; ok {
|
||||
if len(chunk) != 32 {
|
||||
return nil, nil, fmt.Errorf("invalid PNG chromaticities")
|
||||
}
|
||||
for i := range coordinates {
|
||||
coordinates[i] = float64(binary.BigEndian.Uint32(chunk[i*4:])) / 100000
|
||||
}
|
||||
}
|
||||
var xyz [4][3]float64
|
||||
for i := range xyz {
|
||||
x, y := coordinates[2*i], coordinates[2*i+1]
|
||||
if y <= 0 || x < 0 || x+y > 1 {
|
||||
return nil, nil, fmt.Errorf("invalid PNG chromaticities")
|
||||
}
|
||||
xyz[i] = [3]float64{x / y, 1, (1 - x - y) / y}
|
||||
}
|
||||
white := xyz[0]
|
||||
det := func(a, b, c [3]float64) float64 {
|
||||
return a[0]*(b[1]*c[2]-b[2]*c[1]) - b[0]*(a[1]*c[2]-a[2]*c[1]) + c[0]*(a[1]*b[2]-a[2]*b[1])
|
||||
}
|
||||
d := det(xyz[1], xyz[2], xyz[3])
|
||||
if math.Abs(d) < 1e-12 {
|
||||
return nil, nil, fmt.Errorf("degenerate PNG chromaticities")
|
||||
}
|
||||
scale := [3]float64{det(white, xyz[2], xyz[3]) / d, det(xyz[1], white, xyz[3]) / d, det(xyz[1], xyz[2], white) / d}
|
||||
var matrix [9]float64
|
||||
for i := range 3 {
|
||||
for j := range 3 {
|
||||
matrix[3*i+j] = xyz[i+1][j] * scale[i]
|
||||
}
|
||||
}
|
||||
gamma := [3]float64{1, 1, 1}
|
||||
linear := true
|
||||
if chunk, ok := chunks["gAMA"]; ok {
|
||||
if len(chunk) != 4 || binary.BigEndian.Uint32(chunk) == 0 {
|
||||
return nil, nil, fmt.Errorf("invalid PNG gamma")
|
||||
}
|
||||
g := 100000 / float64(binary.BigEndian.Uint32(chunk))
|
||||
gamma = [3]float64{g, g, g}
|
||||
linear = false
|
||||
}
|
||||
space, err := pdfgo.NewCalRGBColorSpace(white, [3]float64{}, gamma, matrix)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &pngColor{space: space, linearSRGB: linear}, nil
|
||||
}
|
||||
|
||||
// jpegColorProfile 读取JPEG分段ICC配置,拒绝缺段、重复及模型不匹配
|
||||
// 入参: ctx 取消上下文, data JPEG编码, model 源颜色模型
|
||||
// 返回: []byte 独立配置编码,未声明时为nil, error 配置或取消错误
|
||||
func jpegColorProfile(ctx context.Context, data []byte, model color.Model) ([]byte, error) {
|
||||
var parts [255][]byte
|
||||
count, found, total := 0, 0, 0
|
||||
for at := 2; at < len(data); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data[at] != 0xff {
|
||||
return nil, fmt.Errorf("invalid JPEG marker")
|
||||
}
|
||||
for at < len(data) && data[at] == 0xff {
|
||||
at++
|
||||
}
|
||||
if at == len(data) {
|
||||
return nil, fmt.Errorf("truncated JPEG marker")
|
||||
}
|
||||
marker := data[at]
|
||||
at++
|
||||
if marker == 0xda || marker == 0xd9 {
|
||||
break
|
||||
}
|
||||
if marker == 0x01 || marker >= 0xd0 && marker <= 0xd7 {
|
||||
continue
|
||||
}
|
||||
if len(data)-at < 2 {
|
||||
return nil, fmt.Errorf("truncated JPEG segment")
|
||||
}
|
||||
size := int(binary.BigEndian.Uint16(data[at:]))
|
||||
if size < 2 || size > len(data)-at {
|
||||
return nil, fmt.Errorf("invalid JPEG segment length")
|
||||
}
|
||||
segment := data[at+2 : at+size]
|
||||
at += size
|
||||
if marker != 0xe2 || !bytes.HasPrefix(segment, []byte("ICC_PROFILE\x00")) {
|
||||
continue
|
||||
}
|
||||
if len(segment) <= 14 {
|
||||
return nil, fmt.Errorf("truncated JPEG color profile")
|
||||
}
|
||||
index, n := int(segment[12]), int(segment[13])
|
||||
if index == 0 || index > n || count != 0 && count != n || parts[index-1] != nil {
|
||||
return nil, fmt.Errorf("invalid JPEG color profile sequence")
|
||||
}
|
||||
count, found = n, found+1
|
||||
parts[index-1] = segment[14:]
|
||||
total += len(segment) - 14
|
||||
}
|
||||
if found == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if found != count {
|
||||
return nil, fmt.Errorf("incomplete JPEG color profile")
|
||||
}
|
||||
profile := make([]byte, 0, total)
|
||||
for _, part := range parts[:count] {
|
||||
profile = append(profile, part...)
|
||||
}
|
||||
kind := "RGB "
|
||||
if model == color.CMYKModel {
|
||||
kind = "CMYK"
|
||||
} else if model == color.GrayModel {
|
||||
kind = "GRAY"
|
||||
}
|
||||
if len(profile) < 132 || string(profile[16:20]) != kind {
|
||||
return nil, fmt.Errorf("ICC source model does not match JPEG color space %q", kind)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// jpegDisplayImage 统一JPEG显示变换,保留源分量并精确复用相邻同色的ICC结果
|
||||
// 入参: ctx 取消上下文, source 源像素, profile 颜色配置,可为nil
|
||||
// 返回: image.Image 显示像素, error 变换或取消错误
|
||||
func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
|
||||
cmyk, ok := source.(*image.CMYK)
|
||||
if !ok {
|
||||
if profile != nil {
|
||||
return applyTIFFColorProfile(ctx, source, profile)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
bounds := cmyk.Bounds()
|
||||
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx() > math.MaxInt/8/bounds.Dy() {
|
||||
return nil, fmt.Errorf("JPEG color image dimensions exceed capacity")
|
||||
}
|
||||
pixels := image.NewNRGBA64(bounds)
|
||||
var intent pdfgo.Name
|
||||
if profile != nil {
|
||||
intent = profile.DefaultIntent()
|
||||
}
|
||||
var previous color.CMYK
|
||||
var previousPixel color.NRGBA64
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if (x-bounds.Min.X)&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ink := cmyk.CMYKAt(x, y)
|
||||
if profile == nil {
|
||||
white := 255 - int(ink.K)
|
||||
pixels.SetNRGBA64(x, y, color.NRGBA64{
|
||||
R: uint16(max(0, white-int(ink.C))) * 257,
|
||||
G: uint16(max(0, white-int(ink.M))) * 257,
|
||||
B: uint16(max(0, white-int(ink.Y))) * 257,
|
||||
A: 65535,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if previousPixel.A != 0 && ink == previous {
|
||||
pixels.SetNRGBA64(x, y, previousPixel)
|
||||
continue
|
||||
}
|
||||
values := [4]float64{float64(ink.C) / 255, float64(ink.M) / 255, float64(ink.Y) / 255, float64(ink.K) / 255}
|
||||
rgb, err := profile.SRGB(values[:], intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous = ink
|
||||
previousPixel = color.NRGBA64{R: colorWord(rgb[0]), G: colorWord(rgb[1]), B: colorWord(rgb[2]), A: 65535}
|
||||
pixels.SetNRGBA64(x, y, previousPixel)
|
||||
}
|
||||
}
|
||||
return &colorImage{NRGBA64: pixels, source: source}, nil
|
||||
}
|
||||
|
||||
// colorWord 将归一化颜色舍入为十六位分量
|
||||
// 入参: value 颜色分量
|
||||
// 返回: uint16 十六位分量
|
||||
func colorWord(value float64) uint16 {
|
||||
return uint16(math.Round(max(0, min(1, value)) * 65535))
|
||||
}
|
||||
|
||||
// cmykGrayAt 从原始四色采样计算蒙版灰度,不应用显示配置
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.Gray 蒙版灰度
|
||||
func (p *planarTIFFImage) cmykGrayAt(x, y int) color.Gray {
|
||||
if !image.Pt(x, y).In(p.bounds) {
|
||||
return color.Gray{}
|
||||
}
|
||||
ink := p.inksAt(x, y)
|
||||
maximum := uint32(65535)
|
||||
if p.alpha == 1 {
|
||||
maximum = uint32(ink[4])
|
||||
if maximum == 0 {
|
||||
return color.Gray{}
|
||||
}
|
||||
}
|
||||
black := maximum - min(uint32(ink[3]), maximum)
|
||||
var rgb [3]uint32
|
||||
for i := range rgb {
|
||||
rgb[i] = (maximum - min(uint32(ink[i]), maximum)) * black / maximum
|
||||
if p.alpha != 1 {
|
||||
rgb[i] = rgb[i] * uint32(ink[4]) / 65535
|
||||
}
|
||||
}
|
||||
return color.Gray{Y: uint8((19595*rgb[0] + 38470*rgb[1] + 7471*rgb[2] + 1<<15) >> 24)}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// EncodedImage 保留PNG或JPEG原始编码,首次读取像素时解码
|
||||
// Bytes返回只读数据,Image返回共享只读像素,解码错误由Image返回
|
||||
type EncodedImage struct {
|
||||
data []byte
|
||||
format string
|
||||
config image.Config
|
||||
profile []byte
|
||||
pngColor *pngColor
|
||||
result atomic.Pointer[encodedImageResult]
|
||||
mu sync.Mutex
|
||||
pending chan struct{}
|
||||
}
|
||||
|
||||
// encodedImageResult 保存已完成的像素解码结果,不保存取消或超时错误
|
||||
type encodedImageResult struct {
|
||||
image image.Image
|
||||
err error
|
||||
}
|
||||
|
||||
// NewEncodedImage 校验PNG或JPEG头信息后复制输入,不解码像素
|
||||
// 入参: data 原始编码
|
||||
// 返回: *EncodedImage 惰性图片, error 头信息或格式错误
|
||||
func NewEncodedImage(data []byte) (*EncodedImage, error) {
|
||||
img, err := newEncodedImage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img.data = bytes.Clone(data)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// Bytes 返回只读原始编码,调用方不得修改
|
||||
// 返回: []byte 原始编码
|
||||
func (img *EncodedImage) Bytes() []byte { return img.data }
|
||||
|
||||
// MIME 返回图片媒体类型
|
||||
// 返回: string 媒体类型
|
||||
func (img *EncodedImage) MIME() string { return "image/" + img.format }
|
||||
|
||||
// ColorModel 返回显示像素的颜色模型,不解码像素
|
||||
// 返回: color.Model 颜色模型
|
||||
func (img *EncodedImage) ColorModel() color.Model {
|
||||
if img.profile != nil || img.pngColor != nil || img.config.ColorModel == color.CMYKModel {
|
||||
return color.NRGBA64Model
|
||||
}
|
||||
return img.config.ColorModel
|
||||
}
|
||||
|
||||
// Bounds 返回图片范围,不解码像素
|
||||
// 返回: image.Rectangle 图片范围
|
||||
func (img *EncodedImage) Bounds() image.Rectangle {
|
||||
return image.Rect(0, 0, img.config.Width, img.config.Height)
|
||||
}
|
||||
|
||||
// Image 首次解码图片并缓存结果或错误,支持并发读取
|
||||
// 返回: image.Image 只读像素, error 解码错误
|
||||
func (img *EncodedImage) Image() (image.Image, error) {
|
||||
if result := img.result.Load(); result != nil {
|
||||
return result.image, result.err
|
||||
}
|
||||
return img.ImageContext(context.Background())
|
||||
}
|
||||
|
||||
// ImageContext 解码并共享只读像素,取消或超时后可重试,nil使用后台上下文
|
||||
// 并发调用等待正在进行的解码,等待方取消不影响其他调用
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: image.Image 只读像素, error 解码或取消错误
|
||||
func (img *EncodedImage) ImageContext(ctx context.Context) (image.Image, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result := img.result.Load(); result != nil {
|
||||
return result.image, result.err
|
||||
}
|
||||
img.mu.Lock()
|
||||
if img.result.Load() != nil {
|
||||
img.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
pending := img.pending
|
||||
if pending == nil {
|
||||
pending = make(chan struct{})
|
||||
img.pending = pending
|
||||
img.mu.Unlock()
|
||||
return img.decodeContext(ctx, pending)
|
||||
}
|
||||
img.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-pending:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeContext 执行本次解码,退出时唤醒等待方,只缓存完整结果及稳定错误
|
||||
// 入参: ctx 取消上下文, pending 本次解码的完成通知
|
||||
// 返回: image.Image 只读像素, error 解码或取消错误
|
||||
func (img *EncodedImage) decodeContext(ctx context.Context, pending chan struct{}) (image.Image, error) {
|
||||
defer func() {
|
||||
img.mu.Lock()
|
||||
img.pending = nil
|
||||
close(pending)
|
||||
img.mu.Unlock()
|
||||
}()
|
||||
pixels, _, err := image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(img.data)), ctx: ctx})
|
||||
if err == nil && (img.format == "jpeg" || img.profile != nil) {
|
||||
var profile *pdfgo.ColorSpace
|
||||
if img.profile != nil {
|
||||
profile, err = pdfgo.NewICCColorSpace(img.profile)
|
||||
}
|
||||
if err == nil {
|
||||
pixels, err = jpegDisplayImage(ctx, pixels, profile)
|
||||
}
|
||||
}
|
||||
if err == nil && img.pngColor != nil {
|
||||
pixels, err = applyImageColorProfile(ctx, pixels, img.pngColor.space, img.pngColor.linearSRGB)
|
||||
}
|
||||
if cancelErr := ctx.Err(); cancelErr != nil {
|
||||
return nil, cancelErr
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
img.result.Store(&encodedImageResult{image: pixels, err: err})
|
||||
return pixels, err
|
||||
}
|
||||
|
||||
// At 返回像素颜色,解码失败时返回透明色,绘制前应通过Image检查错误
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.Color 像素颜色
|
||||
func (img *EncodedImage) At(x, y int) color.Color {
|
||||
decoded, err := img.Image()
|
||||
if err != nil {
|
||||
return color.Transparent
|
||||
}
|
||||
return decoded.At(x, y)
|
||||
}
|
||||
|
||||
// newEncodedImage 接管编码数据并读取头信息
|
||||
// 入参: data 原始编码
|
||||
// 返回: *EncodedImage 惰性图片, error 头信息或格式错误
|
||||
func newEncodedImage(data []byte) (*EncodedImage, error) {
|
||||
return newEncodedImageContext(context.Background(), data)
|
||||
}
|
||||
|
||||
// newEncodedImageContext 读取PNG或JPEG头信息,元数据扫描期间响应取消,不复制输入或解码像素
|
||||
// 入参: ctx 取消上下文, data 只读图片编码
|
||||
// 返回: *EncodedImage 惰性图片, error 头信息、格式或取消错误
|
||||
func newEncodedImageContext(ctx context.Context, data []byte) (*EncodedImage, error) {
|
||||
config, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
|
||||
if cancelErr := ctx.Err(); cancelErr != nil {
|
||||
return nil, cancelErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if format != "jpeg" && format != "png" {
|
||||
return nil, fmt.Errorf("unsupported encoded image %q", format)
|
||||
}
|
||||
img := &EncodedImage{data: data, format: format, config: config}
|
||||
if format == "jpeg" {
|
||||
img.profile, err = jpegColorProfile(ctx, data, config.ColorModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if format == "png" {
|
||||
img.profile, img.pngColor, err = pngColorProfile(ctx, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
// NormalizeFormat 标准化印章媒体类型
|
||||
// 入参: s 原始媒体类型
|
||||
// 返回: string 标准媒体类型
|
||||
func NormalizeFormat(s string) string {
|
||||
switch s {
|
||||
case "jpg":
|
||||
return "jpeg"
|
||||
case "bmp":
|
||||
return "bmp"
|
||||
case "jb2", "gbig2":
|
||||
return "jbig2"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif"
|
||||
"io"
|
||||
|
||||
_ "github.com/xiaoqidun/j2kgo"
|
||||
_ "github.com/xiaoqidun/jbig2"
|
||||
_ "golang.org/x/image/bmp"
|
||||
"golang.org/x/image/tiff"
|
||||
)
|
||||
|
||||
// transparentEdgeImage 共享只读像素并按需计算透明边缘和采样边距
|
||||
type transparentEdgeImage struct {
|
||||
source image.Image
|
||||
rect image.Rectangle
|
||||
}
|
||||
|
||||
// transparentEdgeImage64 按需补齐十六位像素边缘,保留原始只读图像
|
||||
type transparentEdgeImage64 struct {
|
||||
source image.Image
|
||||
rect image.Rectangle
|
||||
}
|
||||
|
||||
// Bounds 返回含采样边距的图像边界
|
||||
// 返回: image.Rectangle 图像边界
|
||||
func (s *transparentEdgeImage) Bounds() image.Rectangle { return s.rect }
|
||||
|
||||
// ColorModel 返回非预乘颜色模型
|
||||
// 返回: color.Model 颜色模型
|
||||
func (s *transparentEdgeImage) ColorModel() color.Model { return color.NRGBAModel }
|
||||
|
||||
// NRGBAAt 读取原始像素或按邻域补齐透明边缘,边界外透明
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.NRGBA 非预乘颜色
|
||||
func (s *transparentEdgeImage) NRGBAAt(x, y int) color.NRGBA {
|
||||
if !image.Pt(x, y).In(s.rect) {
|
||||
return color.NRGBA{}
|
||||
}
|
||||
b := s.source.Bounds()
|
||||
sx, sy := b.Min.X+min(max(x-1, 0), b.Dx()-1), b.Min.Y+min(max(y-1, 0), b.Dy()-1)
|
||||
c := NRGBAAt(s.source, sx, sy)
|
||||
if c.A == 0 {
|
||||
if edge, ok := transparentEdgeColor(s.source, sx, sy); ok {
|
||||
c = edge
|
||||
}
|
||||
}
|
||||
if x == 0 || y == 0 || x == s.rect.Max.X-1 || y == s.rect.Max.Y-1 {
|
||||
c = transparentPaddingColor(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// At 返回非预乘像素颜色
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.Color 像素颜色
|
||||
func (s *transparentEdgeImage) At(x, y int) color.Color { return s.NRGBAAt(x, y) }
|
||||
|
||||
// RGBA64At 返回预乘十六位像素,供采样器无装箱读取
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.RGBA64 预乘颜色
|
||||
func (s *transparentEdgeImage) RGBA64At(x, y int) color.RGBA64 {
|
||||
r, g, b, a := s.NRGBAAt(x, y).RGBA()
|
||||
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
||||
}
|
||||
|
||||
// Bounds 返回含采样边距的图像边界
|
||||
// 返回: image.Rectangle 图像边界
|
||||
func (s *transparentEdgeImage64) Bounds() image.Rectangle { return s.rect }
|
||||
|
||||
// ColorModel 返回十六位非预乘颜色模型
|
||||
// 返回: color.Model 颜色模型
|
||||
func (s *transparentEdgeImage64) ColorModel() color.Model { return color.NRGBA64Model }
|
||||
|
||||
// NRGBA64At 读取十六位原始颜色,透明邻域使用最小非零透明度保留边缘颜色
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.NRGBA64 非预乘颜色
|
||||
func (s *transparentEdgeImage64) NRGBA64At(x, y int) color.NRGBA64 {
|
||||
if !image.Pt(x, y).In(s.rect) {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
b := s.source.Bounds()
|
||||
sx, sy := b.Min.X+min(max(x-1, 0), b.Dx()-1), b.Min.Y+min(max(y-1, 0), b.Dy()-1)
|
||||
c := NRGBA64At(s.source, sx, sy)
|
||||
if c.A == 0 {
|
||||
var best color.NRGBA64
|
||||
for ny := max(sy-1, b.Min.Y); ny < min(sy+2, b.Max.Y); ny++ {
|
||||
for nx := max(sx-1, b.Min.X); nx < min(sx+2, b.Max.X); nx++ {
|
||||
v := NRGBA64At(s.source, nx, ny)
|
||||
if v.A > best.A {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.A != 0 {
|
||||
best.A = 1
|
||||
c = best
|
||||
}
|
||||
}
|
||||
if x == 0 || y == 0 || x == s.rect.Max.X-1 || y == s.rect.Max.Y-1 {
|
||||
c.A = 1
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// At 返回非预乘十六位像素颜色
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.Color 像素颜色
|
||||
func (s *transparentEdgeImage64) At(x, y int) color.Color { return s.NRGBA64At(x, y) }
|
||||
|
||||
// RGBA64At 返回保留原采样精度的预乘颜色
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.RGBA64 预乘颜色
|
||||
func (s *transparentEdgeImage64) RGBA64At(x, y int) color.RGBA64 {
|
||||
r, g, b, a := s.NRGBA64At(x, y).RGBA()
|
||||
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
||||
}
|
||||
|
||||
// Decode 解码图片数据
|
||||
// 入参: data 图片数据
|
||||
// 返回: image.Image 图片对象, string 图片格式, error 错误信息
|
||||
func Decode(data []byte) (image.Image, string, error) {
|
||||
return DecodeContext(context.Background(), data)
|
||||
}
|
||||
|
||||
// DecodeContext 解码图片并传递取消状态,PNG和JPEG仍保留惰性像素解码
|
||||
// 入参: ctx 取消上下文, data 只读图片编码
|
||||
// 返回: image.Image 图片对象, string 图片格式, error 解码或取消错误
|
||||
func DecodeContext(ctx context.Context, data []byte) (img image.Image, format string, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer func() {
|
||||
if cancelErr := ctx.Err(); cancelErr != nil {
|
||||
img, err = nil, cancelErr
|
||||
}
|
||||
}()
|
||||
if IsJPEG(data) || IsPNG(data) {
|
||||
img, err := newEncodedImageContext(ctx, data)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return img, img.format, nil
|
||||
}
|
||||
if IsTIFF(data) {
|
||||
layout, err := ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, "tiff", err
|
||||
}
|
||||
img, err := DecodeTIFF(ctx, data, layout)
|
||||
return img, "tiff", err
|
||||
}
|
||||
img, format, err = image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
|
||||
if err == nil {
|
||||
return img, NormalizeFormat(format), nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
if img, err := DecodeBMPContext(ctx, data); err == nil {
|
||||
return img, "bmp", nil
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// DecodePixelsContext 完整解码图片,确保像素可读取并传递取消状态
|
||||
// 入参: ctx 取消上下文, data 图片编码数据
|
||||
// 返回: image.Image 解码像素, string 图片格式, error 解码或取消错误
|
||||
func DecodePixelsContext(ctx context.Context, data []byte) (image.Image, string, error) {
|
||||
img, format, err := DecodeContext(ctx, data)
|
||||
if err != nil {
|
||||
return nil, format, err
|
||||
}
|
||||
pixels, err := PixelDataContext(ctx, img)
|
||||
return pixels, format, err
|
||||
}
|
||||
|
||||
// IsJPEG 判断是否为JPEG图片数据
|
||||
// 入参: data 图片数据
|
||||
// 返回: bool 是否为JPEG图片数据
|
||||
func IsJPEG(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0xFF && data[1] == 0xD8
|
||||
}
|
||||
|
||||
// IsPNG 判断是否为PNG图片数据
|
||||
// 入参: data 图片数据
|
||||
// 返回: bool 是否为PNG图片数据
|
||||
func IsPNG(data []byte) bool {
|
||||
return bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n"))
|
||||
}
|
||||
|
||||
// DecodeConfigData 解码图片尺寸
|
||||
// 入参: data 图片数据
|
||||
// 返回: image.Config 图片尺寸, string 图片格式, error 错误信息
|
||||
func DecodeConfigData(data []byte) (image.Config, string, error) {
|
||||
return DecodeConfigDataContext(context.Background(), data)
|
||||
}
|
||||
|
||||
// DecodeConfigDataContext 读取图片尺寸和颜色模型,传递布局解析中的取消状态
|
||||
// 入参: ctx 取消上下文, data 图片数据
|
||||
// 返回: image.Config 图片配置, string 图片格式, error 读取或取消错误
|
||||
func DecodeConfigDataContext(ctx context.Context, data []byte) (config image.Config, format string, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
defer func() {
|
||||
if cancelErr := ctx.Err(); cancelErr != nil {
|
||||
config, err = image.Config{}, cancelErr
|
||||
}
|
||||
}()
|
||||
if IsTIFF(data) {
|
||||
layout, err := ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
profile, err := readTIFFColorProfile(ctx, data, layout)
|
||||
if err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
var cfg image.Config
|
||||
if layout.needsSampleView() {
|
||||
img, _, err := tiffPlaneDirectories(ctx, data, layout)
|
||||
if err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
cfg.ColorModel = img.ColorModel()
|
||||
} else {
|
||||
input := newTIFFDirectoryInput(data, layout.Offset)
|
||||
input.ctx = ctx
|
||||
cfg, err = tiff.DecodeConfig(input)
|
||||
if err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
}
|
||||
if profile != nil || layout.Transparency != nil {
|
||||
cfg.ColorModel = color.NRGBA64Model
|
||||
}
|
||||
if layout.Transparency != nil {
|
||||
if _, err := readTIFFColorProfile(ctx, data, *layout.Transparency); err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
if _, _, err := tiffPlaneDirectories(ctx, data, *layout.Transparency); err != nil {
|
||||
return image.Config{}, "tiff", err
|
||||
}
|
||||
}
|
||||
size := layout.DisplaySize()
|
||||
cfg.Width, cfg.Height = size.X, size.Y
|
||||
return cfg, "tiff", nil
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
|
||||
if err == nil {
|
||||
return cfg, NormalizeFormat(format), nil
|
||||
}
|
||||
if cfg, err := decodeBMPConfig(data); err == nil {
|
||||
return cfg, "bmp", nil
|
||||
}
|
||||
return image.Config{}, "", err
|
||||
}
|
||||
|
||||
// decodeBMPConfig 解码BMP尺寸
|
||||
// 入参: data 图片数据
|
||||
// 返回: image.Config 图片尺寸, error 错误信息
|
||||
func decodeBMPConfig(data []byte) (image.Config, error) {
|
||||
offset, width, height, bpp, compression, err := ParseBMPHeader(data)
|
||||
if err != nil {
|
||||
return image.Config{}, err
|
||||
}
|
||||
if offset <= 0 || width <= 0 || height == 0 {
|
||||
return image.Config{}, fmt.Errorf("unsupported bmp")
|
||||
}
|
||||
if _, err := BMPColorMasks(data, offset, bpp, compression); err != nil {
|
||||
return image.Config{}, err
|
||||
}
|
||||
if height < 0 {
|
||||
height = -height
|
||||
}
|
||||
return image.Config{ColorModel: color.NRGBAModel, Width: width, Height: height}, nil
|
||||
}
|
||||
|
||||
// decodeBMPImage 解码BMP图片
|
||||
// 入参: data 图片数据
|
||||
// 返回: image.Image 图片对象, error 错误信息
|
||||
func decodeBMPImage(data []byte) (image.Image, error) {
|
||||
return DecodeBMPContext(context.Background(), data)
|
||||
}
|
||||
|
||||
// DecodeBMPContext 解码BMP真彩色像素,逐行检查取消
|
||||
// 入参: ctx 取消上下文, data 图片数据
|
||||
// 返回: image.Image 图片对象, error 格式或取消错误
|
||||
func DecodeBMPContext(ctx context.Context, data []byte) (image.Image, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset, width, height, bpp, compression, err := ParseBMPHeader(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if offset > len(data) {
|
||||
return nil, fmt.Errorf("invalid bmp pixel offset")
|
||||
}
|
||||
masks, err := BMPColorMasks(data, offset, bpp, compression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topDown := height < 0
|
||||
if topDown {
|
||||
height = -height
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return nil, fmt.Errorf("invalid bmp size")
|
||||
}
|
||||
stride := ((int64(width)*int64(bpp) + 31) / 32) * 4
|
||||
if stride > int64(len(data)-offset)/int64(height) {
|
||||
return nil, fmt.Errorf("truncated bmp")
|
||||
}
|
||||
if width > int(^uint(0)>>1)/4/height {
|
||||
return nil, fmt.Errorf("bmp dimensions exceed image capacity")
|
||||
}
|
||||
rowStride := int(stride)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srcY := y
|
||||
if !topDown {
|
||||
srcY = height - 1 - y
|
||||
}
|
||||
row := data[offset+srcY*rowStride:]
|
||||
for x := 0; x < width; x++ {
|
||||
switch bpp {
|
||||
case 16, 32:
|
||||
var value uint32
|
||||
if bpp == 16 {
|
||||
value = uint32(binary.LittleEndian.Uint16(row[x*2:]))
|
||||
} else {
|
||||
value = binary.LittleEndian.Uint32(row[x*4:])
|
||||
}
|
||||
p := y*img.Stride + x*4
|
||||
for i, mask := range masks {
|
||||
img.Pix[p+i] = mask.sample(value)
|
||||
}
|
||||
case 24:
|
||||
p := x * 3
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: row[p+2], G: row[p+1], B: row[p], A: 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// ParseBMPHeader 解析BMP文件头
|
||||
// 入参: data 图片数据
|
||||
// 返回: int 像素偏移, int 宽度, int 高度, uint16 位数, uint32 压缩方式, error 错误信息
|
||||
func ParseBMPHeader(data []byte) (int, int, int, uint16, uint32, error) {
|
||||
if len(data) < 54 || string(data[:2]) != "BM" {
|
||||
return 0, 0, 0, 0, 0, fmt.Errorf("not bmp")
|
||||
}
|
||||
offset := int(binary.LittleEndian.Uint32(data[10:14]))
|
||||
dibSize := binary.LittleEndian.Uint32(data[14:18])
|
||||
if dibSize < 40 {
|
||||
return 0, 0, 0, 0, 0, fmt.Errorf("unsupported bmp")
|
||||
}
|
||||
if uint64(dibSize)+14 > uint64(len(data)) || offset < 0 || uint64(offset) < uint64(dibSize)+14 {
|
||||
return 0, 0, 0, 0, 0, fmt.Errorf("invalid bmp pixel offset")
|
||||
}
|
||||
width := int(int32(binary.LittleEndian.Uint32(data[18:22])))
|
||||
height := int(int32(binary.LittleEndian.Uint32(data[22:26])))
|
||||
planes := binary.LittleEndian.Uint16(data[26:28])
|
||||
bpp := binary.LittleEndian.Uint16(data[28:30])
|
||||
compression := binary.LittleEndian.Uint32(data[30:34])
|
||||
if planes != 1 || width <= 0 || height == 0 || int64(height) == -int64(^uint(0)>>1)-1 {
|
||||
return 0, 0, 0, 0, 0, fmt.Errorf("invalid bmp")
|
||||
}
|
||||
return offset, width, height, bpp, compression, nil
|
||||
}
|
||||
|
||||
// PixelSource 获取图片像素源
|
||||
// 入参: img 图片对象
|
||||
// 返回: image.Image 图片像素源
|
||||
func PixelSource(img image.Image) image.Image {
|
||||
if decoded, err := PixelData(img); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// PixelData 获取像素并保留惰性解码错误,供绘制和像素处理入口校验
|
||||
// 入参: img 图片对象
|
||||
// 返回: image.Image 解码像素, error 解码错误
|
||||
func PixelData(img image.Image) (image.Image, error) {
|
||||
if src, ok := img.(interface{ Image() (image.Image, error) }); ok {
|
||||
return src.Image()
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// PixelDataContext 获取像素并传递取消状态,兼容不接收上下文的图像实现
|
||||
// 入参: ctx 取消上下文,nil使用后台上下文, img 图片对象
|
||||
// 返回: image.Image 解码像素, error 解码或取消错误
|
||||
func PixelDataContext(ctx context.Context, img image.Image) (image.Image, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if src, ok := img.(interface {
|
||||
ImageContext(context.Context) (image.Image, error)
|
||||
}); ok {
|
||||
return src.ImageContext(ctx)
|
||||
}
|
||||
pixels, err := PixelData(img)
|
||||
if cancelErr := ctx.Err(); cancelErr != nil {
|
||||
return nil, cancelErr
|
||||
}
|
||||
return pixels, err
|
||||
}
|
||||
|
||||
// GrayAt 获取图片灰度像素
|
||||
// 入参: img 图片对象, x X坐标, y Y坐标
|
||||
// 返回: color.Gray 灰度像素
|
||||
func GrayAt(img image.Image, x, y int) color.Gray {
|
||||
switch src := img.(type) {
|
||||
case *EncodedImage:
|
||||
pixels, err := src.Image()
|
||||
if err != nil {
|
||||
return color.Gray{}
|
||||
}
|
||||
return GrayAt(pixels, x, y)
|
||||
case *colorImage:
|
||||
return GrayAt(src.source, x, y)
|
||||
case *maskedTIFFImage:
|
||||
if src.alphaAt(x, y) == 0 {
|
||||
return color.Gray{}
|
||||
}
|
||||
point := src.samplePoint(src.source.Bounds(), x, y)
|
||||
return GrayAt(src.source, point.X, point.Y)
|
||||
case *transparentEdgeImage:
|
||||
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
|
||||
return color.Gray{}
|
||||
}
|
||||
bounds := src.source.Bounds()
|
||||
return GrayAt(src.source, bounds.Min.X+x-1, bounds.Min.Y+y-1)
|
||||
case *transparentEdgeImage64:
|
||||
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
|
||||
return color.Gray{}
|
||||
}
|
||||
bounds := src.source.Bounds()
|
||||
return GrayAt(src.source, bounds.Min.X+x-1, bounds.Min.Y+y-1)
|
||||
case *orientedTIFFImage:
|
||||
point, ok := src.sourcePoint(x, y)
|
||||
if !ok {
|
||||
return color.Gray{}
|
||||
}
|
||||
return GrayAt(src.source, point.X, point.Y)
|
||||
case *planarTIFFImage:
|
||||
if src.cmyk {
|
||||
return src.cmykGrayAt(x, y)
|
||||
}
|
||||
}
|
||||
if src, ok := img.(*image.Gray); ok {
|
||||
return src.GrayAt(x, y)
|
||||
}
|
||||
if src, ok := img.(*image.Paletted); ok {
|
||||
return color.GrayModel.Convert(src.At(x, y)).(color.Gray)
|
||||
}
|
||||
if src, ok := img.(image.RGBA64Image); ok {
|
||||
r, g, b, _ := src.RGBA64At(x, y).RGBA()
|
||||
return color.Gray{Y: uint8((19595*r + 38470*g + 7471*b + 1<<15) >> 24)}
|
||||
}
|
||||
return color.GrayModel.Convert(img.At(x, y)).(color.Gray)
|
||||
}
|
||||
|
||||
// NRGBAAt 读取八位非预乘像素,缩减位深时保留原始颜色分量
|
||||
// 入参: img 图片对象, x X坐标, y Y坐标
|
||||
// 返回: color.NRGBA NRGBA像素
|
||||
func NRGBAAt(img image.Image, x, y int) color.NRGBA {
|
||||
switch src := img.(type) {
|
||||
case *image.NRGBA:
|
||||
return src.NRGBAAt(x, y)
|
||||
case *image.NRGBA64:
|
||||
c := src.NRGBA64At(x, y)
|
||||
return color.NRGBA{R: uint8(c.R >> 8), G: uint8(c.G >> 8), B: uint8(c.B >> 8), A: uint8(c.A >> 8)}
|
||||
case interface{ NRGBAAt(int, int) color.NRGBA }:
|
||||
return src.NRGBAAt(x, y)
|
||||
case interface{ NRGBA64At(int, int) color.NRGBA64 }:
|
||||
c := src.NRGBA64At(x, y)
|
||||
return color.NRGBA{R: uint8(c.R >> 8), G: uint8(c.G >> 8), B: uint8(c.B >> 8), A: uint8(c.A >> 8)}
|
||||
case *image.RGBA, *image.RGBA64, *image.Gray, *image.Gray16, *image.Alpha, *image.Alpha16, *image.CMYK, *image.YCbCr, *image.NYCbCrA:
|
||||
c := src.(image.RGBA64Image).RGBA64At(x, y)
|
||||
if c.A == 0 {
|
||||
return color.NRGBA{}
|
||||
}
|
||||
r, g, b, a := uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A)
|
||||
if a != 0xffff {
|
||||
r = r * 0xffff / a
|
||||
g = g * 0xffff / a
|
||||
b = b * 0xffff / a
|
||||
}
|
||||
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
|
||||
}
|
||||
value := img.At(x, y)
|
||||
if c, ok := value.(color.NRGBA64); ok {
|
||||
return color.NRGBA{R: uint8(c.R >> 8), G: uint8(c.G >> 8), B: uint8(c.B >> 8), A: uint8(c.A >> 8)}
|
||||
}
|
||||
return color.NRGBAModel.Convert(value).(color.NRGBA)
|
||||
}
|
||||
|
||||
// AlphaAt 读取十六位透明度,原生图像避免颜色装箱
|
||||
// 入参: img 图像, x 横坐标, y 纵坐标
|
||||
// 返回: uint32 十六位透明度
|
||||
func AlphaAt(img image.Image, x, y int) uint32 {
|
||||
switch source := img.(type) {
|
||||
case *maskedTIFFImage:
|
||||
return source.alphaAt(x, y)
|
||||
case *image.YCbCr, *image.CMYK, *image.Gray, *image.Gray16:
|
||||
return 65535
|
||||
case *image.NYCbCrA:
|
||||
if !image.Pt(x, y).In(source.Rect) {
|
||||
return 0
|
||||
}
|
||||
return uint32(source.A[source.AOffset(x, y)]) * 257
|
||||
case *orientedTIFFImage:
|
||||
point, ok := source.sourcePoint(x, y)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return AlphaAt(source.source, point.X, point.Y)
|
||||
case *planarTIFFImage:
|
||||
if !image.Pt(x, y).In(source.bounds) {
|
||||
return 0
|
||||
}
|
||||
if source.alpha != 0 {
|
||||
index := 3
|
||||
if source.cmyk {
|
||||
index = 4
|
||||
} else if source.gray || len(source.palette) != 0 {
|
||||
index = 1
|
||||
}
|
||||
return uint32(source.sampleAt(index, x, y))
|
||||
}
|
||||
if len(source.palette) != 0 {
|
||||
index := uint32(source.sampleAt(0, x, y)) * uint32(len(source.palette)-1) / 65535
|
||||
return uint32(source.palette[index].A)
|
||||
}
|
||||
return 65535
|
||||
case *image.RGBA:
|
||||
if !(image.Point{X: x, Y: y}).In(source.Rect) {
|
||||
return 0
|
||||
}
|
||||
return uint32(source.Pix[source.PixOffset(x, y)+3]) * 257
|
||||
case *image.RGBA64:
|
||||
if !(image.Point{X: x, Y: y}).In(source.Rect) {
|
||||
return 0
|
||||
}
|
||||
i := source.PixOffset(x, y) + 6
|
||||
return uint32(source.Pix[i])<<8 | uint32(source.Pix[i+1])
|
||||
case *image.NRGBA:
|
||||
return uint32(source.NRGBAAt(x, y).A) * 257
|
||||
case *image.NRGBA64:
|
||||
return uint32(source.NRGBA64At(x, y).A)
|
||||
case *image.Alpha:
|
||||
return uint32(source.AlphaAt(x, y).A) * 257
|
||||
case *image.Alpha16:
|
||||
return uint32(source.Alpha16At(x, y).A)
|
||||
}
|
||||
if source, ok := img.(image.RGBA64Image); ok {
|
||||
return uint32(source.RGBA64At(x, y).A)
|
||||
}
|
||||
_, _, _, alpha := img.At(x, y).RGBA()
|
||||
return alpha
|
||||
}
|
||||
|
||||
// HighPrecision 判断颜色模型及调色板是否需要保留十六位采样
|
||||
// 入参: img 图片对象
|
||||
// 返回: bool 是否使用十六位颜色或透明度
|
||||
func HighPrecision(img image.Image) bool {
|
||||
model := img.ColorModel()
|
||||
if palette, ok := model.(color.Palette); ok {
|
||||
for _, value := range palette {
|
||||
switch value.(type) {
|
||||
case color.RGBA, color.NRGBA, color.Gray, color.Alpha, color.CMYK, color.YCbCr, color.NYCbCrA:
|
||||
default:
|
||||
pixel := color.NRGBA64Model.Convert(value).(color.NRGBA64)
|
||||
if pixel.R%257 != 0 || pixel.G%257 != 0 || pixel.B%257 != 0 || pixel.A%257 != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return model == color.NRGBA64Model || model == color.RGBA64Model || model == color.Gray16Model || model == color.Alpha16Model
|
||||
}
|
||||
|
||||
// NRGBA64At 读取十六位非预乘像素,保留透明像素颜色,原生图像避免颜色装箱
|
||||
// 入参: img 图片对象, x X坐标, y Y坐标
|
||||
// 返回: color.NRGBA64 NRGBA64像素
|
||||
func NRGBA64At(img image.Image, x, y int) color.NRGBA64 {
|
||||
switch src := img.(type) {
|
||||
case interface{ NRGBA64At(int, int) color.NRGBA64 }:
|
||||
return src.NRGBA64At(x, y)
|
||||
case *image.NRGBA:
|
||||
c := src.NRGBAAt(x, y)
|
||||
return color.NRGBA64{R: uint16(c.R) * 257, G: uint16(c.G) * 257, B: uint16(c.B) * 257, A: uint16(c.A) * 257}
|
||||
case *image.Gray:
|
||||
v := uint16(src.GrayAt(x, y).Y) * 257
|
||||
return color.NRGBA64{R: v, G: v, B: v, A: 0xffff}
|
||||
case *image.Gray16:
|
||||
c := src.Gray16At(x, y)
|
||||
return color.NRGBA64{R: c.Y, G: c.Y, B: c.Y, A: 0xffff}
|
||||
case *image.Alpha, *image.Alpha16:
|
||||
c := src.(image.RGBA64Image).RGBA64At(x, y)
|
||||
if c.A == 0 {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
return color.NRGBA64{R: 0xffff, G: 0xffff, B: 0xffff, A: c.A}
|
||||
case *image.RGBA, *image.RGBA64, *image.CMYK, *image.YCbCr, *image.NYCbCrA:
|
||||
c := src.(image.RGBA64Image).RGBA64At(x, y)
|
||||
if c.A == 0 {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
r, g, b, a := uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A)
|
||||
if a != 0xffff {
|
||||
r = r * 0xffff / a
|
||||
g = g * 0xffff / a
|
||||
b = b * 0xffff / a
|
||||
}
|
||||
return color.NRGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: c.A}
|
||||
}
|
||||
value := img.At(x, y)
|
||||
if c, ok := value.(color.NRGBA); ok {
|
||||
return color.NRGBA64{R: uint16(c.R) * 257, G: uint16(c.G) * 257, B: uint16(c.B) * 257, A: uint16(c.A) * 257}
|
||||
}
|
||||
return color.NRGBA64Model.Convert(value).(color.NRGBA64)
|
||||
}
|
||||
|
||||
// transparentEdgeColor 获取透明像素相邻的可见颜色
|
||||
// 入参: source 图片对象, x X坐标, y Y坐标
|
||||
// 返回: color.NRGBA 颜色, bool 是否存在
|
||||
func transparentEdgeColor(source image.Image, x, y int) (color.NRGBA, bool) {
|
||||
img, ok := source.(*image.NRGBA)
|
||||
if !ok {
|
||||
bounds := source.Bounds()
|
||||
var best color.NRGBA
|
||||
for ny := max(y-1, bounds.Min.Y); ny < min(y+2, bounds.Max.Y); ny++ {
|
||||
for nx := max(x-1, bounds.Min.X); nx < min(x+2, bounds.Max.X); nx++ {
|
||||
if nx == x && ny == y {
|
||||
continue
|
||||
}
|
||||
pixel := NRGBAAt(source, nx, ny)
|
||||
if pixel.A > best.A {
|
||||
best = pixel
|
||||
if best.A == 255 {
|
||||
best.A = 1
|
||||
return best, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.A == 0 {
|
||||
return color.NRGBA{}, false
|
||||
}
|
||||
best.A = 1
|
||||
return best, true
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
best, alpha := 0, uint8(0)
|
||||
left, right := max(x-1, bounds.Min.X), min(x+2, bounds.Max.X)
|
||||
scan:
|
||||
for ny := max(y-1, bounds.Min.Y); ny < min(y+2, bounds.Max.Y); ny++ {
|
||||
offset := img.PixOffset(left, ny)
|
||||
for nx := left; nx < right; nx++ {
|
||||
if (nx != x || ny != y) && img.Pix[offset+3] > alpha {
|
||||
best, alpha = offset, img.Pix[offset+3]
|
||||
if alpha == 255 {
|
||||
break scan
|
||||
}
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
}
|
||||
if alpha == 0 {
|
||||
return color.NRGBA{}, false
|
||||
}
|
||||
return color.NRGBA{R: img.Pix[best], G: img.Pix[best+1], B: img.Pix[best+2], A: 1}, true
|
||||
}
|
||||
|
||||
// transparentPaddingColor 获取透明补齐颜色
|
||||
// 入参: c 边缘颜色
|
||||
// 返回: color.NRGBA 补齐颜色
|
||||
func transparentPaddingColor(c color.NRGBA) color.NRGBA {
|
||||
c.A = 1
|
||||
return c
|
||||
}
|
||||
|
||||
// WithTransparentEdge 扫描透明边缘并共享只读像素,保留原图采样精度
|
||||
// 入参: ctx 取消上下文,nil使用后台上下文, img 图片对象
|
||||
// 返回: image.Image 补边图像, int 补边像素数, error 解码或取消错误
|
||||
func WithTransparentEdge(ctx context.Context, img image.Image) (image.Image, int, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if img == nil {
|
||||
return img, 0, nil
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return img, 0, nil
|
||||
}
|
||||
if src, ok := img.(*EncodedImage); ok && src.MIME() == "image/jpeg" {
|
||||
if src.profile != nil || src.config.ColorModel == color.CMYKModel {
|
||||
pixels, err := src.ImageContext(ctx)
|
||||
return pixels, 0, err
|
||||
}
|
||||
return img, 0, nil
|
||||
}
|
||||
source, err := PixelDataContext(ctx, img)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if encoded, ok := img.(*EncodedImage); ok && (encoded.profile != nil || encoded.pngColor != nil) {
|
||||
img = source
|
||||
}
|
||||
switch source.(type) {
|
||||
case *image.Gray, *image.Gray16, *image.YCbCr, *image.CMYK:
|
||||
return img, 0, nil
|
||||
}
|
||||
hasZero, hasVisible := false, false
|
||||
var pixels []byte
|
||||
var stride, step int
|
||||
wideAlpha := false
|
||||
switch src := source.(type) {
|
||||
case *image.NRGBA:
|
||||
pixels, stride, step = src.Pix, src.Stride, 4
|
||||
case *image.RGBA:
|
||||
pixels, stride, step = src.Pix, src.Stride, 4
|
||||
case *image.Alpha:
|
||||
pixels, stride, step = src.Pix, src.Stride, 1
|
||||
case *image.NRGBA64:
|
||||
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
|
||||
case *image.RGBA64:
|
||||
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
|
||||
case *image.Alpha16:
|
||||
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 2, true
|
||||
case *colorImage:
|
||||
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
|
||||
}
|
||||
if pixels != nil {
|
||||
hasZero = pixels[step-1] == 0 && (!wideAlpha || pixels[step-2] == 0)
|
||||
hasVisible = !hasZero
|
||||
scan:
|
||||
for y := 0; y < h; y++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
offset := y*stride + step - 1
|
||||
for x := 0; x < w; x++ {
|
||||
if x&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
if (pixels[offset] == 0 && (!wideAlpha || pixels[offset-1] == 0)) != hasZero {
|
||||
hasZero, hasVisible = true, true
|
||||
break scan
|
||||
}
|
||||
offset += step
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y && !(hasZero && hasVisible); y++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if (x-bounds.Min.X)&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
if AlphaAt(source, x, y) == 0 {
|
||||
hasZero = true
|
||||
} else {
|
||||
hasVisible = true
|
||||
}
|
||||
if hasZero && hasVisible {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if !hasZero || !hasVisible {
|
||||
return img, 0, nil
|
||||
}
|
||||
if HighPrecision(source) {
|
||||
return &transparentEdgeImage64{source: source, rect: image.Rect(0, 0, w+2, h+2)}, 1, nil
|
||||
}
|
||||
return &transparentEdgeImage{source: source, rect: image.Rect(0, 0, w+2, h+2)}, 1, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// contextInput 在读取图像编码时检查调用方取消状态
|
||||
type contextInput struct {
|
||||
io.ReadCloser
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// Read 检查取消状态后读取原始数据
|
||||
// 入参: data 数据缓冲区
|
||||
// 返回: int 读取字节数, error 读取或取消错误
|
||||
func (r contextInput) Read(data []byte) (int, error) {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.ReadCloser.Read(data)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"math"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// PNGDensityChunkSize 为PNG像素密度块占用的字节数
|
||||
const PNGDensityChunkSize = 21
|
||||
|
||||
// Density 保存横纵像素密度,Unit为0表示比例、1表示每米像素数
|
||||
type Density struct {
|
||||
X, Y float64
|
||||
Unit byte
|
||||
}
|
||||
|
||||
// PageInfo 图片方向与像素密度,不改变原始编码
|
||||
type PageInfo struct {
|
||||
Orientation int
|
||||
XDPI, YDPI float64
|
||||
}
|
||||
|
||||
// PageMetadataContext 读取图片方向与密度,扫描目录或数据段时响应取消
|
||||
// TIFF方向由解码器处理,JPEG的EXIF密度优先于JFIF
|
||||
// 入参: ctx 取消上下文, data 图片数据, format 图像格式, dpi 缺少物理密度时使用的DPI
|
||||
// 返回: PageInfo 方向与横纵DPI, error 元数据或取消错误
|
||||
func PageMetadataContext(ctx context.Context, data []byte, format string, dpi float64) (PageInfo, error) {
|
||||
info := PageInfo{Orientation: 1, XDPI: dpi, YDPI: dpi}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return info, err
|
||||
}
|
||||
var exif []byte
|
||||
var directory uint32
|
||||
scaleX, scaleY := 1.0, 1.0
|
||||
if format == "tiff" {
|
||||
layout, err := ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
directory = layout.Offset
|
||||
exif = data
|
||||
original, display := layout.size(), layout.DisplaySize()
|
||||
scaleX, scaleY = float64(display.X)/float64(original.X), float64(display.Y)/float64(original.Y)
|
||||
}
|
||||
if format == "bmp" && len(data) >= 54 && binary.LittleEndian.Uint32(data[14:18]) >= 40 {
|
||||
x := int32(binary.LittleEndian.Uint32(data[38:42]))
|
||||
y := int32(binary.LittleEndian.Uint32(data[42:46]))
|
||||
if x > 0 && y > 0 {
|
||||
info.XDPI, info.YDPI = float64(x)*0.0254, float64(y)*0.0254
|
||||
}
|
||||
}
|
||||
if format == "png" {
|
||||
for offset := 8; offset+12 <= len(data); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return info, err
|
||||
}
|
||||
n := uint64(binary.BigEndian.Uint32(data[offset:]))
|
||||
if n > uint64(len(data)-offset-12) {
|
||||
return info, fmt.Errorf("truncated PNG chunk")
|
||||
}
|
||||
end := offset + 8 + int(n)
|
||||
kind, payload := string(data[offset+4:offset+8]), data[offset+8:end]
|
||||
if kind == "pHYs" || kind == "eXIf" {
|
||||
if crc32.ChecksumIEEE(data[offset+4:end]) != binary.BigEndian.Uint32(data[end:]) {
|
||||
return info, fmt.Errorf("invalid PNG %s checksum", kind)
|
||||
}
|
||||
}
|
||||
if kind == "pHYs" && len(payload) == 9 {
|
||||
x, y := float64(binary.BigEndian.Uint32(payload)), float64(binary.BigEndian.Uint32(payload[4:]))
|
||||
if x > 0 && y > 0 {
|
||||
if payload[8] == 1 {
|
||||
info.XDPI, info.YDPI = x*0.0254, y*0.0254
|
||||
} else if payload[8] == 0 {
|
||||
info.YDPI = dpi * y / x
|
||||
}
|
||||
}
|
||||
} else if kind == "eXIf" {
|
||||
exif = payload
|
||||
}
|
||||
offset = end + 4
|
||||
if kind == "IEND" {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if format == "jpeg" {
|
||||
for offset := 2; offset < len(data); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return info, err
|
||||
}
|
||||
if data[offset] != 0xff {
|
||||
return info, fmt.Errorf("invalid JPEG marker")
|
||||
}
|
||||
for offset < len(data) && data[offset] == 0xff {
|
||||
offset++
|
||||
}
|
||||
if offset >= len(data) {
|
||||
break
|
||||
}
|
||||
marker := data[offset]
|
||||
offset++
|
||||
if marker == 0xda || marker == 0xd9 {
|
||||
break
|
||||
}
|
||||
if marker == 0x01 || marker >= 0xd0 && marker <= 0xd7 {
|
||||
continue
|
||||
}
|
||||
if offset+2 > len(data) {
|
||||
return info, fmt.Errorf("truncated JPEG marker")
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(data[offset:]))
|
||||
if n < 2 || n > len(data)-offset {
|
||||
return info, fmt.Errorf("truncated JPEG segment")
|
||||
}
|
||||
payload := data[offset+2 : offset+n]
|
||||
if marker == 0xe0 && len(payload) >= 14 && bytes.Equal(payload[:5], []byte("JFIF\x00")) {
|
||||
x, y := float64(binary.BigEndian.Uint16(payload[8:])), float64(binary.BigEndian.Uint16(payload[10:]))
|
||||
if x > 0 && y > 0 {
|
||||
switch payload[7] {
|
||||
case 0:
|
||||
info.YDPI = dpi * y / x
|
||||
case 1:
|
||||
info.XDPI, info.YDPI = x, y
|
||||
case 2:
|
||||
info.XDPI, info.YDPI = x*2.54, y*2.54
|
||||
}
|
||||
}
|
||||
} else if marker == 0xe1 && bytes.HasPrefix(payload, []byte("Exif\x00\x00")) {
|
||||
exif = payload[6:]
|
||||
}
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
if len(exif) != 0 {
|
||||
orientation, x, y, err := imageExifMetadata(exif, dpi, directory)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.Orientation = orientation
|
||||
if (format == "jpeg" || format == "tiff") && x > 0 && y > 0 {
|
||||
info.XDPI, info.YDPI = x, y
|
||||
}
|
||||
if format == "tiff" {
|
||||
if orientation >= 5 {
|
||||
info.XDPI, info.YDPI = info.YDPI, info.XDPI
|
||||
}
|
||||
info.Orientation = 1
|
||||
}
|
||||
}
|
||||
info.XDPI, info.YDPI = info.XDPI*scaleX, info.YDPI*scaleY
|
||||
return info, ctx.Err()
|
||||
}
|
||||
|
||||
// imageExifMetadata 只读取主图IFD中的方向和密度,不跟随缩略图与其他目录
|
||||
// 入参: data TIFF或EXIF数据, dpi 无绝对密度单位时的横向DPI,零值不换算, directory 目录偏移,零值使用首目录
|
||||
// 返回: int EXIF方向值,未声明时为1, float64 横向DPI, float64 纵向DPI, error 元数据错误
|
||||
func imageExifMetadata(data []byte, dpi float64, directory uint32) (int, float64, float64, error) {
|
||||
invalid := fmt.Errorf("invalid EXIF image metadata")
|
||||
if len(data) < 8 {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
var order binary.ByteOrder
|
||||
switch string(data[:2]) {
|
||||
case "II":
|
||||
order = binary.LittleEndian
|
||||
case "MM":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
if order.Uint16(data[2:]) != 42 {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
offset := uint64(order.Uint32(data[4:]))
|
||||
if directory != 0 {
|
||||
offset = uint64(directory)
|
||||
}
|
||||
if offset < 8 || offset+2 > uint64(len(data)) {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
count := uint64(order.Uint16(data[offset:]))
|
||||
if count*12+offset+6 > uint64(len(data)) {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
orientation, unit := 1, 2
|
||||
var x, y float64
|
||||
for i := uint64(0); i < count; i++ {
|
||||
entry := data[offset+2+i*12 : offset+14+i*12]
|
||||
tag, kind, count := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:])
|
||||
switch tag {
|
||||
case 0x112, 0x128:
|
||||
if kind != 3 || count != 1 {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
value := int(order.Uint16(entry[8:]))
|
||||
if tag == 0x112 {
|
||||
if value < 1 || value > 8 {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
orientation = value
|
||||
} else {
|
||||
unit = value
|
||||
}
|
||||
case 0x11a, 0x11b:
|
||||
at := uint64(order.Uint32(entry[8:]))
|
||||
if kind != 5 || count != 1 || at+8 > uint64(len(data)) {
|
||||
return 0, 0, 0, invalid
|
||||
}
|
||||
n, d := order.Uint32(data[at:]), order.Uint32(data[at+4:])
|
||||
if n > 0 && d > 0 {
|
||||
if tag == 0x11a {
|
||||
x = float64(n) / float64(d)
|
||||
} else {
|
||||
y = float64(n) / float64(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if unit == 3 {
|
||||
x, y = x*2.54, y*2.54
|
||||
} else if unit == 1 && dpi > 0 && x > 0 && y > 0 {
|
||||
x, y = dpi, dpi*y/x
|
||||
} else if unit != 2 {
|
||||
x, y = 0, 0
|
||||
}
|
||||
return orientation, x, y, nil
|
||||
}
|
||||
|
||||
// BMPDensity 提取已校验BMP头中的每米像素数
|
||||
// 入参: data 含至少40字节信息头的BMP编码
|
||||
// 返回: Density 像素密度, bool 密度是否可保留
|
||||
func BMPDensity(data []byte) (Density, bool) {
|
||||
xdpi, ydpi := int32(binary.LittleEndian.Uint32(data[38:42])), int32(binary.LittleEndian.Uint32(data[42:46]))
|
||||
if xdpi < 0 || ydpi < 0 {
|
||||
return Density{}, false
|
||||
}
|
||||
return Density{X: float64(xdpi), Y: float64(ydpi), Unit: 1}, true
|
||||
}
|
||||
|
||||
// TIFFConversionDensity 校验可转换的TIFF字段并提取规范化后的像素密度
|
||||
// 色彩配置、预乘透明度、扩展字段及其他目录需保留原始编码
|
||||
// 入参: data TIFF编码, layout 已校验的主图布局
|
||||
// 返回: Density PNG像素密度, bool 是否可完整转换
|
||||
func TIFFConversionDensity(data []byte, layout TIFFLayout) (Density, bool) {
|
||||
var density Density
|
||||
var order binary.ByteOrder = binary.LittleEndian
|
||||
if data[0] == 'M' {
|
||||
order = binary.BigEndian
|
||||
}
|
||||
if layout.Next != 0 || layout.Offset != order.Uint32(data[4:]) || layout.Reduced {
|
||||
return density, false
|
||||
}
|
||||
offset := int(layout.Offset)
|
||||
count := int(order.Uint16(data[offset:]))
|
||||
unit := uint32(2)
|
||||
var resolutions [2][2]uint32
|
||||
for i := range count {
|
||||
entry := data[offset+2+i*12 : offset+14+i*12]
|
||||
tag, kind, number := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:])
|
||||
value := order.Uint32(entry[8:])
|
||||
if kind == 3 && number == 1 {
|
||||
value = uint32(order.Uint16(entry[8:]))
|
||||
}
|
||||
switch tag {
|
||||
case 256, 257, 258, 259, 273, 274, 277, 278, 279, 320, 322, 323, 324, 325:
|
||||
case 262:
|
||||
if value == 5 {
|
||||
return density, false
|
||||
}
|
||||
case 254:
|
||||
if value != 0 {
|
||||
return density, false
|
||||
}
|
||||
case 255:
|
||||
if value != 1 {
|
||||
return density, false
|
||||
}
|
||||
case 266:
|
||||
if kind != 3 || number != 1 || value != 1 && value != 2 {
|
||||
return density, false
|
||||
}
|
||||
case 284:
|
||||
if kind != 3 || number != 1 || value != 1 && value != 2 {
|
||||
return density, false
|
||||
}
|
||||
case 292, 293:
|
||||
mask := uint32(7)
|
||||
if tag == 293 {
|
||||
mask = 2
|
||||
}
|
||||
if kind != 4 || number != 1 || value & ^mask != 0 {
|
||||
return density, false
|
||||
}
|
||||
case 317:
|
||||
if kind != 3 || number != 1 || value != 1 && value != 2 {
|
||||
return density, false
|
||||
}
|
||||
case 338:
|
||||
if kind != 3 || number != 1 || value != 2 {
|
||||
return density, false
|
||||
}
|
||||
case 339:
|
||||
if kind != 3 || number == 0 || number > 4 {
|
||||
return density, false
|
||||
}
|
||||
values := entry[8:]
|
||||
if number > 2 {
|
||||
if uint64(value)+uint64(number)*2 > uint64(len(data)) {
|
||||
return density, false
|
||||
}
|
||||
values = data[value:]
|
||||
}
|
||||
for j := uint32(0); j < number; j++ {
|
||||
if order.Uint16(values[j*2:]) != 1 {
|
||||
return density, false
|
||||
}
|
||||
}
|
||||
case 282, 283:
|
||||
if kind != 5 || number != 1 || uint64(value)+8 > uint64(len(data)) {
|
||||
return density, false
|
||||
}
|
||||
resolutions[tag-282] = [2]uint32{order.Uint32(data[value:]), order.Uint32(data[value+4:])}
|
||||
case 296:
|
||||
if kind != 3 || number != 1 || value < 1 || value > 3 {
|
||||
return density, false
|
||||
}
|
||||
unit = value
|
||||
default:
|
||||
return density, false
|
||||
}
|
||||
}
|
||||
if resolutions == [2][2]uint32{} {
|
||||
return density, true
|
||||
}
|
||||
x, y := resolutions[0], resolutions[1]
|
||||
if x[0] == 0 || x[1] == 0 || y[0] == 0 || y[1] == 0 {
|
||||
return density, false
|
||||
}
|
||||
if unit == 1 {
|
||||
a, b := uint64(x[0])*uint64(y[1]), uint64(y[0])*uint64(x[1])
|
||||
divisor, next := a, b
|
||||
for next != 0 {
|
||||
divisor, next = next, divisor%next
|
||||
}
|
||||
density.X, density.Y = float64(a/divisor), float64(b/divisor)
|
||||
} else {
|
||||
factor := 1 / 0.0254
|
||||
if unit == 3 {
|
||||
factor = 100
|
||||
}
|
||||
density = Density{X: float64(x[0]) / float64(x[1]) * factor, Y: float64(y[0]) / float64(y[1]) * factor, Unit: 1}
|
||||
}
|
||||
if math.Round(density.X) < 1 || math.Round(density.Y) < 1 || math.Round(density.X) > math.MaxInt32 || math.Round(density.Y) > math.MaxInt32 {
|
||||
return density, false
|
||||
}
|
||||
if layout.Orientation >= 5 {
|
||||
density.X, density.Y = density.Y, density.X
|
||||
}
|
||||
return density, true
|
||||
}
|
||||
|
||||
// Scale 按最终像素尺寸保留绝对密度或精确相对比例
|
||||
// 入参: before 原始像素尺寸, after 最终像素尺寸
|
||||
// 返回: Density 缩放后密度, bool 相对比例是否可完整保留
|
||||
func (density Density) Scale(before, after image.Point) (Density, bool) {
|
||||
hasDensity := density.X != 0 || density.Y != 0
|
||||
if hasDensity && before != after {
|
||||
if density.Unit == 0 {
|
||||
ratio := new(big.Rat).SetFrac64(int64(density.X)*int64(after.X), int64(density.Y)*int64(after.Y))
|
||||
ratio.Mul(ratio, new(big.Rat).SetFrac64(int64(before.Y), int64(before.X)))
|
||||
if ratio.Num().BitLen() > 31 || ratio.Denom().BitLen() > 31 {
|
||||
return density, false
|
||||
}
|
||||
density.X, density.Y = float64(ratio.Num().Int64()), float64(ratio.Denom().Int64())
|
||||
} else {
|
||||
density.X *= float64(after.X) / float64(before.X)
|
||||
density.Y *= float64(after.Y) / float64(before.Y)
|
||||
}
|
||||
}
|
||||
return density, true
|
||||
}
|
||||
|
||||
// WithPNGDensity 在无密度块的PNG头后插入pHYs,保留源编码不变
|
||||
// 入参: data 完整PNG编码, density 像素密度
|
||||
// 返回: []byte 带密度的编码, bool 密度是否可完整保留
|
||||
func WithPNGDensity(data []byte, density Density) ([]byte, bool) {
|
||||
x, y := math.Round(density.X), math.Round(density.Y)
|
||||
if x > math.MaxInt32 || y > math.MaxInt32 || density.X > 0 && x < 1 || density.Y > 0 && y < 1 {
|
||||
return nil, false
|
||||
}
|
||||
chunk := make([]byte, 21)
|
||||
binary.BigEndian.PutUint32(chunk, 9)
|
||||
copy(chunk[4:], "pHYs")
|
||||
binary.BigEndian.PutUint32(chunk[8:], uint32(x))
|
||||
binary.BigEndian.PutUint32(chunk[12:], uint32(y))
|
||||
chunk[16] = density.Unit
|
||||
binary.BigEndian.PutUint32(chunk[17:], crc32.ChecksumIEEE(chunk[4:17]))
|
||||
output := make([]byte, 0, len(data)+PNGDensityChunkSize)
|
||||
output = append(output, data[:33]...)
|
||||
output = append(output, chunk...)
|
||||
output = append(output, data[33:]...)
|
||||
return output, true
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/math/f64"
|
||||
)
|
||||
|
||||
// rasterScaleBufferLimit 限制整像素缩放器的浮点中间缓冲,超出时使用逐行采样
|
||||
const rasterScaleBufferLimit = 8 << 20
|
||||
|
||||
// rasterAxisSample 保存目标像素及其归一化源采样权重
|
||||
type rasterAxisSample struct {
|
||||
pixel int
|
||||
first int
|
||||
weights []float64
|
||||
}
|
||||
|
||||
// rasterImagePixel 保存未取整或裁切的预乘颜色累积值
|
||||
type rasterImagePixel struct {
|
||||
r, g, b, a float64
|
||||
}
|
||||
|
||||
// rasterImageRow 保存正在累积的目标行
|
||||
type rasterImageRow struct {
|
||||
sample rasterAxisSample
|
||||
values []rasterImagePixel
|
||||
}
|
||||
|
||||
// rasterImageSource 为通用图片提供预乘十六位采样
|
||||
type rasterImageSource struct{ image.Image }
|
||||
|
||||
// RGBA64At 读取通用颜色的原始预乘分量
|
||||
// 入参: x 横向坐标, y 纵向坐标
|
||||
// 返回: color.RGBA64 预乘颜色
|
||||
func (s rasterImageSource) RGBA64At(x, y int) color.RGBA64 {
|
||||
r, g, b, a := s.At(x, y).RGBA()
|
||||
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
||||
}
|
||||
|
||||
// drawRasterAxisImage 轴向缩放复用逐行采样,细轴按面积覆盖并保留镜像与小数坐标
|
||||
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作
|
||||
// 返回: bool 是否使用轴向采样
|
||||
func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool {
|
||||
sx, sy := math.Abs(m[0]), math.Abs(m[3])
|
||||
if m[1] != 0 || m[2] != 0 || sx == 0 || sy == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range m {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
b := src.Bounds()
|
||||
if b.Empty() {
|
||||
return true
|
||||
}
|
||||
r := TransformBounds(b, m, dst.Bounds())
|
||||
if r.Empty() {
|
||||
return true
|
||||
}
|
||||
if float64(r.Dx())*math.Min(float64(r.Dy()), math.Ceil(4*math.Max(1, sy))+2) > rasterScaleBufferLimit/32 {
|
||||
if sx*float64(b.Dx()) < 1 || sy*float64(b.Dy()) < 1 {
|
||||
return drawRasterFootprintImage(dst, src, m, op, r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !rasterFootprintWeightsFit(b, r, m) {
|
||||
return drawRasterFootprintImage(dst, src, m, op, r)
|
||||
}
|
||||
xs := rasterImageAxis(r.Min.X, r.Max.X, b.Min.X, b.Max.X, m[0], m[4])
|
||||
ys := rasterImageAxis(r.Min.Y, r.Max.Y, b.Min.Y, b.Max.Y, m[3], m[5])
|
||||
if len(xs) == 0 || len(ys) == 0 {
|
||||
return true
|
||||
}
|
||||
if m[3] < 0 {
|
||||
slices.Reverse(ys)
|
||||
}
|
||||
pixels, ok := src.(image.RGBA64Image)
|
||||
if !ok {
|
||||
pixels = rasterImageSource{src}
|
||||
}
|
||||
width := len(xs)
|
||||
horizontal := make([]rasterImagePixel, width)
|
||||
output := image.NewRGBA64(image.Rect(0, 0, width, 1))
|
||||
var active []rasterImageRow
|
||||
var free [][]rasterImagePixel
|
||||
next := 0
|
||||
for y := ys[0].first; next < len(ys) || len(active) != 0; y++ {
|
||||
if len(active) == 0 && y < ys[next].first {
|
||||
y = ys[next].first
|
||||
}
|
||||
for next < len(ys) && ys[next].first == y {
|
||||
var row []rasterImagePixel
|
||||
if n := len(free); n != 0 {
|
||||
row, free = free[n-1], free[:n-1]
|
||||
clear(row)
|
||||
} else {
|
||||
row = make([]rasterImagePixel, width)
|
||||
}
|
||||
active = append(active, rasterImageRow{sample: ys[next], values: row})
|
||||
next++
|
||||
}
|
||||
rasterImageHorizontal(horizontal, pixels, xs, y)
|
||||
n := 0
|
||||
for _, row := range active {
|
||||
weight := row.sample.weights[y-row.sample.first]
|
||||
if weight != 0 {
|
||||
for x, value := range horizontal {
|
||||
p := &row.values[x]
|
||||
p.r += value.r * weight
|
||||
p.g += value.g * weight
|
||||
p.b += value.b * weight
|
||||
p.a += value.a * weight
|
||||
}
|
||||
}
|
||||
if y+1 == row.sample.first+len(row.sample.weights) {
|
||||
rasterImageWriteRow(dst, output, row.values, xs[0].pixel, row.sample.pixel, op)
|
||||
free = append(free, row.values)
|
||||
} else {
|
||||
active[n] = row
|
||||
n++
|
||||
}
|
||||
}
|
||||
active = active[:n]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// rasterImageAxis 按连续源坐标生成CatmullRom权重,亚像素细轴使用面积覆盖权重
|
||||
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
|
||||
// 返回: []rasterAxisSample 轴向采样表
|
||||
func rasterImageAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample {
|
||||
if math.Abs(scale)*float64(limit-source) < 1 {
|
||||
return rasterImageFootprintAxis(first, end, source, limit, scale, offset)
|
||||
}
|
||||
result := make([]rasterAxisSample, 0, end-first)
|
||||
argument := math.Min(1, math.Abs(scale))
|
||||
support := draw.CatmullRom.Support / argument
|
||||
count := 0
|
||||
for pixel := first; pixel < end; pixel++ {
|
||||
center := (float64(pixel) + .5 - offset) / scale
|
||||
if center < float64(source) || center >= float64(limit) {
|
||||
continue
|
||||
}
|
||||
center -= .5
|
||||
start := int(max(float64(source), math.Floor(center-support)))
|
||||
stop := int(min(float64(limit), math.Ceil(center+support)))
|
||||
count += stop - start
|
||||
result = append(result, rasterAxisSample{pixel: pixel, first: start})
|
||||
}
|
||||
buffer := make([]float64, count)
|
||||
for index := range result {
|
||||
sample := &result[index]
|
||||
center := (float64(sample.pixel)+.5-offset)/scale - .5
|
||||
start := sample.first
|
||||
stop := int(min(float64(limit), math.Ceil(center+support)))
|
||||
n := stop - start
|
||||
weights := buffer[:n:n]
|
||||
buffer = buffer[n:]
|
||||
total := 0.0
|
||||
for i := range weights {
|
||||
distance := math.Abs((float64(start+i) - center) * argument)
|
||||
if distance < draw.CatmullRom.Support {
|
||||
weights[i] = draw.CatmullRom.At(distance)
|
||||
total += weights[i]
|
||||
}
|
||||
}
|
||||
for i := range weights {
|
||||
weights[i] /= total
|
||||
}
|
||||
sample.weights = weights
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// rasterFootprintWeightsFit 检查细轴权重表的合计预算,乘法前排除溢出
|
||||
// 入参: source 源边界, target 目标范围, m 像素变换
|
||||
// 返回: bool 权重表是否符合预算
|
||||
func rasterFootprintWeightsFit(source, target image.Rectangle, m [6]float64) bool {
|
||||
budget := rasterScaleBufferLimit / 8
|
||||
for _, axis := range [...]struct {
|
||||
source, target int
|
||||
scale float64
|
||||
}{{source.Dx(), target.Dx(), m[0]}, {source.Dy(), target.Dy(), m[3]}} {
|
||||
if math.Abs(axis.scale)*float64(axis.source) < 1 && axis.target > 0 {
|
||||
n, samples := axis.source, min(2, axis.target)
|
||||
if n > budget/samples {
|
||||
return false
|
||||
}
|
||||
budget -= n * samples
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// rasterImageWeightSpan 提供单个目标像素的源范围和权重,不分配整轴权重表
|
||||
// 入参: pixel 目标像素, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
|
||||
// 返回: int 源起点, int 源终点, func(int)float64 源权重
|
||||
func rasterImageWeightSpan(pixel, source, limit int, scale, offset float64) (int, int, func(int) float64) {
|
||||
if math.Abs(scale)*float64(limit-source) < 1 {
|
||||
left, right := (float64(pixel)-offset)/scale, (float64(pixel+1)-offset)/scale
|
||||
if right < left {
|
||||
left, right = right, left
|
||||
}
|
||||
if right <= float64(source) || left >= float64(limit) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return source, limit, func(x int) float64 {
|
||||
return math.Max(0, math.Min(float64(x)+1, right)-math.Max(float64(x), left)) * math.Abs(scale)
|
||||
}
|
||||
}
|
||||
center := (float64(pixel) + .5 - offset) / scale
|
||||
if center < float64(source) || center >= float64(limit) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
center -= .5
|
||||
argument := math.Min(1, math.Abs(scale))
|
||||
support := draw.CatmullRom.Support / argument
|
||||
start := int(max(float64(source), math.Floor(center-support)))
|
||||
stop := int(min(float64(limit), math.Ceil(center+support)))
|
||||
weight := func(x int) float64 {
|
||||
distance := math.Abs((float64(x) - center) * argument)
|
||||
if distance < draw.CatmullRom.Support {
|
||||
return draw.CatmullRom.At(distance)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
for x := start; x < stop; x++ {
|
||||
total += weight(x)
|
||||
}
|
||||
return start, stop, func(x int) float64 { return weight(x) / total }
|
||||
}
|
||||
|
||||
// drawRasterFootprintImage 在细轴权重超预算时按原累加顺序逐点绘制
|
||||
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作, target 目标范围
|
||||
// 返回: bool 是否完成绘制
|
||||
func drawRasterFootprintImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op, target image.Rectangle) bool {
|
||||
pixels, ok := src.(image.RGBA64Image)
|
||||
if !ok {
|
||||
pixels = rasterImageSource{src}
|
||||
}
|
||||
b := src.Bounds()
|
||||
output := image.NewRGBA64(image.Rect(0, 0, 1, 1))
|
||||
var value [1]rasterImagePixel
|
||||
for y := target.Min.Y; y < target.Max.Y; y++ {
|
||||
firstY, endY, wy := rasterImageWeightSpan(y, b.Min.Y, b.Max.Y, m[3], m[5])
|
||||
if firstY == endY {
|
||||
continue
|
||||
}
|
||||
for x := target.Min.X; x < target.Max.X; x++ {
|
||||
firstX, endX, wx := rasterImageWeightSpan(x, b.Min.X, b.Max.X, m[0], m[4])
|
||||
if firstX == endX {
|
||||
continue
|
||||
}
|
||||
value[0] = rasterImagePixel{}
|
||||
for sy := firstY; sy < endY; sy++ {
|
||||
var row rasterImagePixel
|
||||
for sx := firstX; sx < endX; sx++ {
|
||||
p, weight := pixels.RGBA64At(sx, sy), wx(sx)
|
||||
if weight != 0 {
|
||||
row.r += float64(p.R) * weight
|
||||
row.g += float64(p.G) * weight
|
||||
row.b += float64(p.B) * weight
|
||||
row.a += float64(p.A) * weight
|
||||
}
|
||||
}
|
||||
if weight := wy(sy); weight != 0 {
|
||||
value[0].r += row.r * weight
|
||||
value[0].g += row.g * weight
|
||||
value[0].b += row.b * weight
|
||||
value[0].a += row.a * weight
|
||||
}
|
||||
}
|
||||
rasterImageWriteRow(dst, output, value[:], x, y, op)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// rasterImageFootprintAxis 对不足一个目标像素的整轴保留真实交叠面积及预乘透明度
|
||||
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
|
||||
// 返回: []rasterAxisSample 轴向覆盖权重
|
||||
func rasterImageFootprintAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample {
|
||||
lo, hi := float64(source)*scale+offset, float64(limit)*scale+offset
|
||||
if hi < lo {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
result := make([]rasterAxisSample, 0, min(2, end-first))
|
||||
for pixel := first; pixel < end; pixel++ {
|
||||
if math.Min(float64(pixel+1), hi) > math.Max(float64(pixel), lo) {
|
||||
result = append(result, rasterAxisSample{pixel: pixel, first: source})
|
||||
}
|
||||
}
|
||||
buffer := make([]float64, len(result)*(limit-source))
|
||||
for index := range result {
|
||||
sample := &result[index]
|
||||
left, right := (float64(sample.pixel)-offset)/scale, (float64(sample.pixel+1)-offset)/scale
|
||||
if right < left {
|
||||
left, right = right, left
|
||||
}
|
||||
n := limit - source
|
||||
sample.weights, buffer = buffer[:n:n], buffer[n:]
|
||||
for i := range sample.weights {
|
||||
x := float64(source + i)
|
||||
sample.weights[i] = math.Max(0, math.Min(x+1, right)-math.Max(x, left)) * math.Abs(scale)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// rasterImageHorizontal 按源列复用像素,保留各目标像素的权重累加顺序与中间精度
|
||||
// 入参: row 横向缓冲, src 源像素, samples 采样表, y 源行坐标
|
||||
func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, samples []rasterAxisSample, y int) {
|
||||
clear(row)
|
||||
first, last := samples[0], samples[len(samples)-1]
|
||||
reversed := first.first > last.first || first.first == last.first && len(first.weights) > len(last.weights)
|
||||
start := min(first.first, last.first)
|
||||
end := max(first.first+len(first.weights), last.first+len(last.weights))
|
||||
active, next := 0, 0
|
||||
for x := start; x < end; x++ {
|
||||
for next < len(samples) {
|
||||
index := next
|
||||
if reversed {
|
||||
index = len(samples) - next - 1
|
||||
}
|
||||
if samples[index].first > x {
|
||||
break
|
||||
}
|
||||
next++
|
||||
}
|
||||
for active < next {
|
||||
index := active
|
||||
if reversed {
|
||||
index = len(samples) - active - 1
|
||||
}
|
||||
if x < samples[index].first+len(samples[index].weights) {
|
||||
break
|
||||
}
|
||||
active++
|
||||
}
|
||||
p := src.RGBA64At(x, y)
|
||||
r, g, b, a := float64(p.R), float64(p.G), float64(p.B), float64(p.A)
|
||||
for column := active; column < next; column++ {
|
||||
index := column
|
||||
if reversed {
|
||||
index = len(samples) - column - 1
|
||||
}
|
||||
sample := samples[index]
|
||||
weight := sample.weights[x-sample.first]
|
||||
if weight != 0 {
|
||||
value := &row[index]
|
||||
value.r += r * weight
|
||||
value.g += g * weight
|
||||
value.b += b * weight
|
||||
value.a += a * weight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rasterImageWriteRow 在两轴采样完成后取整并使用标准预乘合成
|
||||
// 入参: dst 目标图像, output 行像素缓冲, values 累积颜色, x 横向起点, y 目标行坐标, op 合成操作
|
||||
func rasterImageWriteRow(dst draw.Image, output *image.RGBA64, values []rasterImagePixel, x, y int, op draw.Op) {
|
||||
for i, value := range values {
|
||||
r := rasterImageComponent(value.r, value.a)
|
||||
g := rasterImageComponent(value.g, value.a)
|
||||
b := rasterImageComponent(value.b, value.a)
|
||||
a := rasterImageComponent(value.a, value.a)
|
||||
pixel := output.Pix[i*8 : i*8+8 : i*8+8]
|
||||
pixel[0], pixel[1] = uint8(r>>8), uint8(r)
|
||||
pixel[2], pixel[3] = uint8(g>>8), uint8(g)
|
||||
pixel[4], pixel[5] = uint8(b>>8), uint8(b)
|
||||
pixel[6], pixel[7] = uint8(a>>8), uint8(a)
|
||||
}
|
||||
draw.Draw(dst, image.Rect(x, y, x+len(values), y+1), output, image.Point{}, op)
|
||||
}
|
||||
|
||||
// rasterImageComponent 按预乘透明度裁切并取整十六位分量
|
||||
// 入参: value 采样分量, alpha 采样透明度
|
||||
// 返回: uint16 输出分量
|
||||
func rasterImageComponent(value, alpha float64) uint16 {
|
||||
if value > alpha || math.IsNaN(alpha) {
|
||||
value = alpha
|
||||
}
|
||||
value += .5
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value >= 65535 {
|
||||
return 65535
|
||||
}
|
||||
return uint16(value)
|
||||
}
|
||||
|
||||
// DrawPixels 对轴向缩放使用可分离采样,其余变换保留通用路径
|
||||
// 入参: dst 目标图像, src 源图像, m 按a、b、c、d、e、f排列的像素变换, op 合成操作
|
||||
func DrawPixels(dst draw.Image, src image.Image, m [6]float64, op draw.Op) {
|
||||
b := src.Bounds()
|
||||
if dr, ok := rasterScaleBounds(b, m); ok {
|
||||
if dr.Intersect(dst.Bounds()).Empty() {
|
||||
return
|
||||
}
|
||||
draw.CatmullRom.Scale(dst, dr, src, b, op, nil)
|
||||
return
|
||||
}
|
||||
if drawRasterAxisImage(dst, src, m, op) {
|
||||
return
|
||||
}
|
||||
src = rasterTransformSource(src, TransformBounds(b, m, dst.Bounds()))
|
||||
draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, src, b, op, nil)
|
||||
}
|
||||
|
||||
// rasterTransformSource 为放大的通用图像缓存十六位预乘像素,限制临时缓冲大小
|
||||
// 入参: src 源图像, affected 目标采样区域
|
||||
// 返回: image.Image 等值采样图像
|
||||
func rasterTransformSource(src image.Image, affected image.Rectangle) image.Image {
|
||||
switch src.(type) {
|
||||
case *image.RGBA, *image.RGBA64:
|
||||
return src
|
||||
}
|
||||
b := src.Bounds()
|
||||
if b.Empty() || affected.Empty() || b.Dx() > rasterScaleBufferLimit/8/b.Dy() || float64(affected.Dx())*float64(affected.Dy()) < float64(b.Dx())*float64(b.Dy()) {
|
||||
return src
|
||||
}
|
||||
pixels := image.NewRGBA64(b)
|
||||
draw.Draw(pixels, b, src, b.Min, draw.Src)
|
||||
return pixels
|
||||
}
|
||||
|
||||
// rasterScaleBounds 检查变换是否可无几何取整地使用缩放器,并限制其中间缓冲
|
||||
// 入参: b 源边界, m 像素变换
|
||||
// 返回: image.Rectangle 缩放边界, bool 是否适用
|
||||
func rasterScaleBounds(b image.Rectangle, m [6]float64) (image.Rectangle, bool) {
|
||||
if b.Empty() || m[1] != 0 || m[2] != 0 || m[0] <= 0 || m[0] > 1 || m[3] <= 0 || m[3] > 1 || m[0] == 1 && m[3] == 1 {
|
||||
return image.Rectangle{}, false
|
||||
}
|
||||
x0, y0 := float64(m[0]*float64(b.Min.X))+m[4], float64(m[3]*float64(b.Min.Y))+m[5]
|
||||
x1, y1 := float64(m[0]*float64(b.Max.X))+m[4], float64(m[3]*float64(b.Max.Y))+m[5]
|
||||
for _, v := range [4]float64{x0, y0, x1, y1} {
|
||||
if v != math.Floor(v) || v < math.MinInt32 || v > math.MaxInt32 {
|
||||
return image.Rectangle{}, false
|
||||
}
|
||||
}
|
||||
if x1 <= x0 || y1 <= y0 || b.Dx() > math.MaxInt32 || b.Dy() > math.MaxInt32 || (x1-x0)*float64(b.Dy()) > rasterScaleBufferLimit/32 {
|
||||
return image.Rectangle{}, false
|
||||
}
|
||||
return image.Rect(int(x0), int(y0), int(x1), int(y1)), true
|
||||
}
|
||||
|
||||
// TransformBounds 计算采样器可能写入的像素范围,并在整数转换前裁至目标边界
|
||||
// 入参: source 原始像素边界, m 像素变换, target 目标边界
|
||||
// 返回: image.Rectangle 受影响的目标像素区域
|
||||
func TransformBounds(source image.Rectangle, m [6]float64, target image.Rectangle) image.Rectangle {
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
for _, p := range [4]image.Point{source.Min, {X: source.Max.X, Y: source.Min.Y}, {X: source.Min.X, Y: source.Max.Y}, source.Max} {
|
||||
x := math.Floor(float64(m[0]*float64(p.X)) + float64(m[2]*float64(p.Y)) + m[4])
|
||||
y := math.Floor(float64(m[1]*float64(p.X)) + float64(m[3]*float64(p.Y)) + m[5])
|
||||
minX, minY = math.Min(minX, x), math.Min(minY, y)
|
||||
maxX, maxY = math.Max(maxX, x+1), math.Max(maxY, y+1)
|
||||
}
|
||||
minX, minY = math.Max(minX, float64(target.Min.X)), math.Max(minY, float64(target.Min.Y))
|
||||
maxX, maxY = math.Min(maxX, float64(target.Max.X)), math.Min(maxY, float64(target.Max.Y))
|
||||
if minX >= maxX || minY >= maxY {
|
||||
return image.Rectangle{}
|
||||
}
|
||||
return image.Rect(int(minX), int(minY), int(maxX), int(maxY))
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// StorageCost 预留十六位像素空间,并计入编码、调色板与底层像素缓冲容量
|
||||
// 包装图像可实现BackingImage() image.Image,使底层共享缓冲计入预算
|
||||
// 入参: img 图片资源, limit 缓存预算
|
||||
// 返回: int 估算字节数或超预算标记
|
||||
func StorageCost(img image.Image, limit int) int {
|
||||
cost := 256
|
||||
w, h := img.Bounds().Dx(), img.Bounds().Dy()
|
||||
if limit < cost || w <= 0 || h <= 0 || w > (limit-cost)/8/h {
|
||||
return limit + 1
|
||||
}
|
||||
pixels := w * h * 8
|
||||
if encoded, ok := img.(*EncodedImage); ok {
|
||||
if encoded.pngColor != nil {
|
||||
if limit-cost < 512 {
|
||||
return limit + 1
|
||||
}
|
||||
cost += 512
|
||||
}
|
||||
if cap(encoded.profile) > limit-cost {
|
||||
return limit + 1
|
||||
}
|
||||
cost += cap(encoded.profile)
|
||||
if encoded.profile != nil || encoded.pngColor != nil || encoded.config.ColorModel == color.CMYKModel {
|
||||
pixelSize := 12
|
||||
if encoded.config.ColorModel == color.RGBA64Model || encoded.config.ColorModel == color.NRGBA64Model {
|
||||
pixelSize = 16
|
||||
}
|
||||
if w > (limit-cost)/pixelSize/h {
|
||||
return limit + 1
|
||||
}
|
||||
pixels = w * h * pixelSize
|
||||
}
|
||||
if result := encoded.result.Load(); result != nil && result.image != nil {
|
||||
pixels = max(pixels, StorageCost(result.image, limit-cost))
|
||||
}
|
||||
if cap(encoded.data) > limit-cost-pixels {
|
||||
return limit + 1
|
||||
}
|
||||
return cost + pixels + cap(encoded.data)
|
||||
}
|
||||
var buffers [5]int
|
||||
switch source := img.(type) {
|
||||
case *colorImage:
|
||||
buffers[0] = cap(source.Pix)
|
||||
buffers[1] = StorageCost(source.source, limit-cost)
|
||||
case *image.RGBA:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.RGBA64:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.NRGBA:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.NRGBA64:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.Gray:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.Gray16:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.Alpha:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.Alpha16:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.CMYK:
|
||||
buffers[0] = cap(source.Pix)
|
||||
case *image.Paletted:
|
||||
if cap(source.Palette) > (limit-cost)/16 {
|
||||
return limit + 1
|
||||
}
|
||||
buffers[0], buffers[1] = cap(source.Pix), cap(source.Palette)*16
|
||||
case *image.YCbCr:
|
||||
buffers = [5]int{cap(source.Y), cap(source.Cb), cap(source.Cr)}
|
||||
case *image.NYCbCrA:
|
||||
buffers = [5]int{cap(source.Y), cap(source.Cb), cap(source.Cr), cap(source.A)}
|
||||
case *planarTIFFImage:
|
||||
if cap(source.palette) > (limit-cost)/8 {
|
||||
return limit + 1
|
||||
}
|
||||
cost += cap(source.palette) * 8
|
||||
if cap(source.planes) > (limit-cost)/16 || len(source.planes) > len(buffers) {
|
||||
return limit + 1
|
||||
}
|
||||
cost += cap(source.planes) * 16
|
||||
for i, plane := range source.planes {
|
||||
switch plane := plane.(type) {
|
||||
case *image.Gray:
|
||||
buffers[i] = cap(plane.Pix)
|
||||
case *image.Gray16:
|
||||
buffers[i] = cap(plane.Pix)
|
||||
default:
|
||||
buffers[i] = StorageCost(plane, limit-cost)
|
||||
}
|
||||
}
|
||||
case *tiffFaxImage:
|
||||
if cap(source.blocks) > (limit-cost)/16 {
|
||||
return limit + 1
|
||||
}
|
||||
cost += cap(source.blocks) * 16
|
||||
for _, block := range source.blocks {
|
||||
size := StorageCost(block, limit-cost)
|
||||
if size > limit-cost {
|
||||
return limit + 1
|
||||
}
|
||||
cost += size
|
||||
}
|
||||
case *orientedTIFFImage:
|
||||
buffers[0] = StorageCost(source.source, limit-cost)
|
||||
case *maskedTIFFImage:
|
||||
buffers[0] = StorageCost(source.source, limit-cost)
|
||||
buffers[1] = StorageCost(source.mask, limit-cost)
|
||||
case *transparentEdgeImage:
|
||||
buffers[0] = StorageCost(source.source, limit-cost)
|
||||
case *transparentEdgeImage64:
|
||||
buffers[0] = StorageCost(source.source, limit-cost)
|
||||
case interface{ BackingImage() image.Image }:
|
||||
buffers[0] = StorageCost(source.BackingImage(), limit-cost)
|
||||
}
|
||||
storage := 0
|
||||
for _, size := range buffers {
|
||||
if size > limit-cost-storage {
|
||||
return limit + 1
|
||||
}
|
||||
storage += size
|
||||
}
|
||||
if pixels > limit-cost {
|
||||
return limit + 1
|
||||
}
|
||||
return cost + max(pixels, storage)
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrTIFFRandomAccess 表示目录链需要回读,仅流式输入需重新打开资源
|
||||
var ErrTIFFRandomAccess = errors.New("TIFF directory requires random access")
|
||||
|
||||
// TIFFLayout 保存TIFF目录的图像布局及关联透明蒙版
|
||||
type TIFFLayout struct {
|
||||
Width, Height int
|
||||
TileWidth int
|
||||
TileHeight int
|
||||
Orientation int
|
||||
Offset, Next uint32
|
||||
Reduced, mask bool
|
||||
Planar bool
|
||||
auxiliary bool
|
||||
Samples uint32
|
||||
depth uint32
|
||||
fillOrder uint32
|
||||
compression uint32
|
||||
predictor uint32
|
||||
photometric uint32
|
||||
profileOffset uint32
|
||||
profileSize uint32
|
||||
t4, t6 uint32
|
||||
Transparency *TIFFLayout
|
||||
}
|
||||
|
||||
// tiffDirectoryInput 只替换解码视图中的首目录偏移,不复制或改写原始资源
|
||||
type tiffDirectoryInput struct {
|
||||
reader *bytes.Reader
|
||||
directory [4]byte
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// orientedTIFFImage 按方向映射像素坐标,复用原图数据与颜色精度
|
||||
type orientedTIFFImage struct {
|
||||
source image.Image
|
||||
TIFFLayout
|
||||
}
|
||||
|
||||
// IsTIFF 识别标准TIFF字节序与版本标识
|
||||
// 入参: data 文件头或完整编码
|
||||
// 返回: bool 是否为TIFF 6.0编码
|
||||
func IsTIFF(data []byte) bool {
|
||||
return len(data) >= 4 && (string(data[:4]) == "II\x2a\x00" || string(data[:4]) == "MM\x00\x2a")
|
||||
}
|
||||
|
||||
// readTIFFLayout 选择主图及其蒙版,允许缩略图并拒绝循环和多个主图
|
||||
// 入参: input 从文件头开始的输入流
|
||||
// 返回: TIFFLayout 存储尺寸与方向, error 文件结构或方向错误
|
||||
func readTIFFLayout(input io.Reader) (TIFFLayout, error) {
|
||||
return ReadTIFFLayoutContext(context.Background(), input)
|
||||
}
|
||||
|
||||
// ReadTIFFLayoutContext 扫描目录链并选择主图,读取期间响应取消
|
||||
// 入参: ctx 取消上下文, input 从文件头开始的输入流
|
||||
// 返回: TIFFLayout 存储尺寸与方向, error 结构、读取或取消错误
|
||||
func ReadTIFFLayoutContext(ctx context.Context, input io.Reader) (TIFFLayout, error) {
|
||||
var result TIFFLayout
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
var header [8]byte
|
||||
if _, err := io.ReadFull(input, header[:]); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !IsTIFF(header[:]) {
|
||||
return result, fmt.Errorf("invalid TIFF header")
|
||||
}
|
||||
var order binary.ByteOrder = binary.LittleEndian
|
||||
if header[0] == 'M' {
|
||||
order = binary.BigEndian
|
||||
}
|
||||
offset := int64(order.Uint32(header[4:]))
|
||||
position := int64(8)
|
||||
var seen map[uint32]bool
|
||||
var transparency *TIFFLayout
|
||||
for offset != 0 {
|
||||
if seen != nil && seen[uint32(offset)] {
|
||||
return result, fmt.Errorf("cyclic TIFF directory chain")
|
||||
}
|
||||
layout, end, err := readTIFFDirectory(ctx, input, order, offset, position)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if layout.mask {
|
||||
if !layout.Reduced {
|
||||
if transparency != nil {
|
||||
return result, fmt.Errorf("multiple TIFF transparency masks")
|
||||
}
|
||||
transparency = &layout
|
||||
}
|
||||
} else if !layout.Reduced || layout.Next == 0 && seen == nil {
|
||||
if result.Offset != 0 {
|
||||
return result, fmt.Errorf("multiple TIFF pages are not supported in OFD resources")
|
||||
}
|
||||
result = layout
|
||||
}
|
||||
if layout.Next != 0 {
|
||||
if seen == nil {
|
||||
seen = make(map[uint32]bool)
|
||||
}
|
||||
seen[uint32(offset)] = true
|
||||
}
|
||||
offset, position = int64(layout.Next), end
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return TIFFLayout{}, err
|
||||
}
|
||||
if result.Offset == 0 {
|
||||
return result, fmt.Errorf("missing TIFF main image")
|
||||
}
|
||||
result.Transparency = transparency
|
||||
if transparency != nil {
|
||||
size := result.DisplaySize()
|
||||
if size.X > int(^uint(0)>>1)/8/size.Y {
|
||||
return TIFFLayout{}, fmt.Errorf("TIFF mask image dimensions exceed capacity")
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readTIFFDirectory 读取目录中的图像布局和子文件类型,不读取像素
|
||||
// 入参: ctx 取消上下文, input 输入流, order 字节序, offset 目录偏移, position 当前流位置
|
||||
// 返回: TIFFLayout 目录布局, int64 读取后的流位置, error 结构、读取或取消错误
|
||||
func readTIFFDirectory(ctx context.Context, input io.Reader, order binary.ByteOrder, offset, position int64) (TIFFLayout, int64, error) {
|
||||
var result TIFFLayout
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
var header [4]byte
|
||||
if offset < 8 {
|
||||
return result, position, fmt.Errorf("invalid TIFF directory offset")
|
||||
}
|
||||
if seeker, ok := input.(io.Seeker); ok {
|
||||
if _, err := seeker.Seek(offset, io.SeekStart); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
} else {
|
||||
if offset < position {
|
||||
return result, position, ErrTIFFRandomAccess
|
||||
}
|
||||
reader := contextInput{ReadCloser: io.NopCloser(input), ctx: ctx}
|
||||
if _, err := io.CopyN(io.Discard, reader, offset-position); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
}
|
||||
if _, err := io.ReadFull(input, header[:2]); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
count := int(order.Uint16(header[:2]))
|
||||
result.Orientation, result.Offset, result.Samples, result.fillOrder = 1, uint32(offset), 1, 1
|
||||
result.depth = 1
|
||||
result.compression = 1
|
||||
result.predictor = 1
|
||||
var entry [12]byte
|
||||
var previous uint16
|
||||
var subfile, legacy uint32
|
||||
var hasSubfile bool
|
||||
var invalidMaskDepth bool
|
||||
for i := range count {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
if _, err := io.ReadFull(input, entry[:]); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
tag, kind, number := order.Uint16(entry[:2]), order.Uint16(entry[2:4]), order.Uint32(entry[4:8])
|
||||
if i > 0 && tag <= previous {
|
||||
return result, position, fmt.Errorf("invalid TIFF directory order")
|
||||
}
|
||||
previous = tag
|
||||
if tag == 338 {
|
||||
result.auxiliary = kind != 3 || number != 1 || order.Uint16(entry[8:]) == 0
|
||||
}
|
||||
if tag == 34675 {
|
||||
if kind != 7 || number < 132 {
|
||||
return result, position, fmt.Errorf("invalid TIFF color profile")
|
||||
}
|
||||
result.profileOffset, result.profileSize = order.Uint32(entry[8:]), number
|
||||
continue
|
||||
}
|
||||
if tag == 258 {
|
||||
invalidMaskDepth = kind != 3 || number != 1
|
||||
if !invalidMaskDepth {
|
||||
result.depth = uint32(order.Uint16(entry[8:10]))
|
||||
}
|
||||
}
|
||||
if tag != 254 && tag != 255 && tag != 256 && tag != 257 && tag != 259 && tag != 262 && tag != 266 && tag != 274 && tag != 277 && tag != 284 && tag != 292 && tag != 293 && tag != 317 && tag != 322 && tag != 323 {
|
||||
continue
|
||||
}
|
||||
if number != 1 || kind != 3 && kind != 4 || (tag == 255 || tag == 259 || tag == 262 || tag == 266 || tag == 274 || tag == 277 || tag == 284 || tag == 317) && kind != 3 || (tag == 254 || tag == 292 || tag == 293) && kind != 4 {
|
||||
return result, position, fmt.Errorf("invalid TIFF image layout")
|
||||
}
|
||||
value := uint64(order.Uint32(entry[8:12]))
|
||||
if kind == 3 {
|
||||
value = uint64(order.Uint16(entry[8:10]))
|
||||
}
|
||||
if (tag == 256 || tag == 257 || tag == 322 || tag == 323) && (value == 0 || value > uint64(int(^uint(0)>>1))) {
|
||||
return result, position, fmt.Errorf("invalid TIFF dimensions")
|
||||
}
|
||||
switch tag {
|
||||
case 254:
|
||||
subfile, hasSubfile = uint32(value), true
|
||||
case 255:
|
||||
legacy = uint32(value)
|
||||
case 256:
|
||||
result.Width = int(value)
|
||||
case 257:
|
||||
result.Height = int(value)
|
||||
case 259:
|
||||
result.compression = uint32(value)
|
||||
case 262:
|
||||
result.photometric = uint32(value)
|
||||
result.mask = value == 4
|
||||
case 266:
|
||||
if value != 1 && value != 2 {
|
||||
return result, position, fmt.Errorf("invalid TIFF fill order")
|
||||
}
|
||||
result.fillOrder = uint32(value)
|
||||
case 274:
|
||||
if value < 1 || value > 8 {
|
||||
return result, position, fmt.Errorf("invalid TIFF orientation")
|
||||
}
|
||||
result.Orientation = int(value)
|
||||
case 277:
|
||||
if value == 0 {
|
||||
return result, position, fmt.Errorf("invalid TIFF sample count")
|
||||
}
|
||||
result.Samples = uint32(value)
|
||||
case 284:
|
||||
if value != 1 && value != 2 {
|
||||
return result, position, fmt.Errorf("invalid TIFF planar configuration")
|
||||
}
|
||||
result.Planar = value == 2
|
||||
case 292:
|
||||
result.t4 = uint32(value)
|
||||
case 293:
|
||||
result.t6 = uint32(value)
|
||||
case 317:
|
||||
result.predictor = uint32(value)
|
||||
case 322:
|
||||
result.TileWidth = int(value)
|
||||
case 323:
|
||||
result.TileHeight = int(value)
|
||||
}
|
||||
}
|
||||
if _, err := io.ReadFull(input, header[:4]); err != nil {
|
||||
return result, position, err
|
||||
}
|
||||
result.Next = order.Uint32(header[:4])
|
||||
if hasSubfile {
|
||||
result.Reduced, result.mask = subfile&1 != 0, result.mask || subfile&4 != 0
|
||||
} else {
|
||||
result.Reduced = legacy == 2
|
||||
}
|
||||
if result.Width == 0 || result.Height == 0 {
|
||||
return result, position, fmt.Errorf("missing TIFF dimensions")
|
||||
}
|
||||
if (result.TileWidth == 0) != (result.TileHeight == 0) || result.TileWidth%16 != 0 || result.TileHeight%16 != 0 {
|
||||
return result, position, fmt.Errorf("invalid TIFF tile dimensions")
|
||||
}
|
||||
if result.mask && (invalidMaskDepth || result.photometric != 4 || result.Samples != 1 || result.depth != 1) {
|
||||
return result, position, fmt.Errorf("invalid TIFF transparency mask")
|
||||
}
|
||||
return result, offset + 6 + int64(count)*12, nil
|
||||
}
|
||||
|
||||
// newTIFFDirectoryInput 创建以指定目录作为主图的只读解码视图
|
||||
// 入参: data 已校验的TIFF编码, offset 主图目录偏移
|
||||
// 返回: *tiffDirectoryInput 随机访问输入
|
||||
func newTIFFDirectoryInput(data []byte, offset uint32) *tiffDirectoryInput {
|
||||
input := &tiffDirectoryInput{reader: bytes.NewReader(data)}
|
||||
var order binary.ByteOrder = binary.LittleEndian
|
||||
if data[0] == 'M' {
|
||||
order = binary.BigEndian
|
||||
}
|
||||
order.PutUint32(input.directory[:], offset)
|
||||
return input
|
||||
}
|
||||
|
||||
// patch 替换读取片段中与首目录指针重叠的部分
|
||||
// 入参: data 读取结果, offset 文件偏移
|
||||
func (r *tiffDirectoryInput) patch(data []byte, offset int64) {
|
||||
for i := range r.directory {
|
||||
at := int64(4+i) - offset
|
||||
if at >= 0 && at < int64(len(data)) {
|
||||
data[at] = r.directory[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read 读取图像编码并覆盖首目录指针
|
||||
// 入参: data 接收缓冲
|
||||
// 返回: int 读取字节数, error 读取错误
|
||||
func (r *tiffDirectoryInput) Read(data []byte) (int, error) {
|
||||
if r.ctx != nil {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
offset := r.reader.Size() - int64(r.reader.Len())
|
||||
n, err := r.reader.Read(data)
|
||||
r.patch(data[:n], offset)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadAt 随机读取图像编码并覆盖首目录指针
|
||||
// 入参: data 接收缓冲, offset 文件偏移
|
||||
// 返回: int 读取字节数, error 读取错误
|
||||
func (r *tiffDirectoryInput) ReadAt(data []byte, offset int64) (int, error) {
|
||||
if r.ctx != nil {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := r.reader.ReadAt(data, offset)
|
||||
r.patch(data[:n], offset)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// size 返回应用方向后的显示尺寸
|
||||
// 返回: image.Point 像素宽高
|
||||
func (l TIFFLayout) size() image.Point {
|
||||
if l.Orientation >= 5 {
|
||||
return image.Pt(l.Height, l.Width)
|
||||
}
|
||||
return image.Pt(l.Width, l.Height)
|
||||
}
|
||||
|
||||
// DisplaySize 返回同时保留主图和透明蒙版分辨率的显示尺寸
|
||||
// 返回: image.Point 保留高分辨率蒙版边缘的像素宽高
|
||||
func (l TIFFLayout) DisplaySize() image.Point {
|
||||
size := l.size()
|
||||
if l.Transparency != nil {
|
||||
mask := l.Transparency.size()
|
||||
size.X, size.Y = max(size.X, mask.X), max(size.Y, mask.Y)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// Bounds 返回从零开始的显示区域
|
||||
// 返回: image.Rectangle 显示区域
|
||||
func (s *orientedTIFFImage) Bounds() image.Rectangle {
|
||||
return image.Rectangle{Max: s.size()}
|
||||
}
|
||||
|
||||
// ColorModel 返回原始颜色模型
|
||||
// 返回: color.Model 颜色模型
|
||||
func (s *orientedTIFFImage) ColorModel() color.Model { return s.source.ColorModel() }
|
||||
|
||||
// sourcePoint 将显示坐标映射为存储坐标
|
||||
// 入参: x 横向显示坐标, y 纵向显示坐标
|
||||
// 返回: image.Point 原始坐标, bool 是否位于显示区域内
|
||||
func (s *orientedTIFFImage) sourcePoint(x, y int) (image.Point, bool) {
|
||||
if !image.Pt(x, y).In(s.Bounds()) {
|
||||
return image.Point{}, false
|
||||
}
|
||||
w, h := s.Width, s.Height
|
||||
switch s.Orientation {
|
||||
case 2:
|
||||
x = w - 1 - x
|
||||
case 3:
|
||||
x, y = w-1-x, h-1-y
|
||||
case 4:
|
||||
y = h - 1 - y
|
||||
case 5:
|
||||
x, y = y, x
|
||||
case 6:
|
||||
x, y = y, h-1-x
|
||||
case 7:
|
||||
x, y = w-1-y, h-1-x
|
||||
case 8:
|
||||
x, y = w-1-y, x
|
||||
}
|
||||
b := s.source.Bounds()
|
||||
return image.Pt(b.Min.X+x, b.Min.Y+y), true
|
||||
}
|
||||
|
||||
// At 返回原图颜色,不重新采样或量化
|
||||
// 入参: x 横向显示坐标, y 纵向显示坐标
|
||||
// 返回: color.Color 像素颜色,区域外透明
|
||||
func (s *orientedTIFFImage) At(x, y int) color.Color {
|
||||
p, ok := s.sourcePoint(x, y)
|
||||
if !ok {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
return s.source.At(p.X, p.Y)
|
||||
}
|
||||
|
||||
// NRGBA64At 读取非预乘十六位颜色,保留透明像素中的原始颜色
|
||||
// 入参: x 横向显示坐标, y 纵向显示坐标
|
||||
// 返回: color.NRGBA64 像素颜色
|
||||
func (s *orientedTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
|
||||
p, ok := s.sourcePoint(x, y)
|
||||
if !ok {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
return NRGBA64At(s.source, p.X, p.Y)
|
||||
}
|
||||
|
||||
// RGBA64At 返回预乘十六位像素,保留原始颜色精度
|
||||
// 入参: x 横向显示坐标, y 纵向显示坐标
|
||||
// 返回: color.RGBA64 像素颜色
|
||||
func (s *orientedTIFFImage) RGBA64At(x, y int) color.RGBA64 {
|
||||
r, g, b, a := s.At(x, y).RGBA()
|
||||
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"math/bits"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"golang.org/x/image/tiff"
|
||||
)
|
||||
|
||||
// tiffBitOrderInput 仅在读取像素块时反转字节内位序,保留目录和原始数据
|
||||
type tiffBitOrderInput struct {
|
||||
source io.ReaderAt
|
||||
ranges [][2]int64
|
||||
position int64
|
||||
}
|
||||
|
||||
// tiffBitOrderRanges 收集低位优先的未压缩一位图像块,压缩传真位序由解码器处理
|
||||
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 已校验的主图布局
|
||||
// 返回: [][2]int64 按偏移排序且不重叠的字节区间, error 字段或布局错误
|
||||
func tiffBitOrderRanges(ctx context.Context, data []byte, layout TIFFLayout) ([][2]int64, error) {
|
||||
if layout.fillOrder != 2 {
|
||||
return nil, nil
|
||||
}
|
||||
var order binary.ByteOrder = binary.LittleEndian
|
||||
if data[0] == 'M' {
|
||||
order = binary.BigEndian
|
||||
}
|
||||
offset := int(layout.Offset)
|
||||
count := int(order.Uint16(data[offset:]))
|
||||
entries := make(map[uint16][]byte, count)
|
||||
for i := range count {
|
||||
entry := data[offset+2+i*12 : offset+14+i*12]
|
||||
entries[order.Uint16(entry)] = entry
|
||||
}
|
||||
compression := entries[259]
|
||||
method := uint16(1)
|
||||
if compression != nil {
|
||||
if order.Uint16(compression[2:]) != 3 || order.Uint32(compression[4:]) != 1 {
|
||||
return nil, fmt.Errorf("invalid TIFF compression field")
|
||||
}
|
||||
method = order.Uint16(compression[8:])
|
||||
}
|
||||
if method == 3 || method == 4 {
|
||||
return nil, nil
|
||||
}
|
||||
if method != 1 {
|
||||
return nil, fmt.Errorf("unsupported TIFF fill order for compression %d", method)
|
||||
}
|
||||
if entry := entries[258]; entry != nil {
|
||||
if order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != layout.Samples {
|
||||
return nil, fmt.Errorf("invalid TIFF sample depths")
|
||||
}
|
||||
for i := uint32(0); i < layout.Samples; i++ {
|
||||
depth, err := tiffPlaneValue(data, order, entry, i)
|
||||
if err != nil || depth != 1 {
|
||||
return nil, fmt.Errorf("unsupported TIFF fill order for sample depth")
|
||||
}
|
||||
}
|
||||
}
|
||||
locations, lengths := entries[273], entries[279]
|
||||
value := func(tag uint16, fallback uint32) (uint32, error) {
|
||||
entry := entries[tag]
|
||||
if entry == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
if order.Uint32(entry[4:]) != 1 {
|
||||
return 0, fmt.Errorf("invalid TIFF scalar field %d", tag)
|
||||
}
|
||||
return tiffPlaneValue(data, order, entry, 0)
|
||||
}
|
||||
rows, err := value(278, ^uint32(0))
|
||||
if err != nil || rows == 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF rows per strip")
|
||||
}
|
||||
expected := (uint64(layout.Height)-1)/uint64(rows) + 1
|
||||
if entries[322] != nil {
|
||||
locations, lengths = entries[324], entries[325]
|
||||
width, err := value(322, 0)
|
||||
if err != nil || width == 0 || width%16 != 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF tile width")
|
||||
}
|
||||
height, err := value(323, 0)
|
||||
if err != nil || height == 0 || height%16 != 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF tile height")
|
||||
}
|
||||
expected = ((uint64(layout.Width)-1)/uint64(width) + 1) * ((uint64(layout.Height)-1)/uint64(height) + 1)
|
||||
if locations != nil && order.Uint16(locations[2:]) != 4 {
|
||||
return nil, fmt.Errorf("invalid TIFF tile offsets")
|
||||
}
|
||||
}
|
||||
if layout.Planar {
|
||||
if expected > uint64(^uint32(0))/uint64(layout.Samples) {
|
||||
return nil, fmt.Errorf("too many TIFF image blocks")
|
||||
}
|
||||
expected *= uint64(layout.Samples)
|
||||
}
|
||||
if locations == nil || lengths == nil {
|
||||
return nil, fmt.Errorf("missing TIFF block arrays")
|
||||
}
|
||||
blocks := order.Uint32(locations[4:])
|
||||
if blocks == 0 || uint64(blocks) != expected || order.Uint32(lengths[4:]) != blocks {
|
||||
return nil, fmt.Errorf("invalid TIFF block count")
|
||||
}
|
||||
var ranges [][2]int64
|
||||
for i := uint32(0); i < blocks; i++ {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
at, err := tiffPlaneValue(data, order, locations, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := tiffPlaneValue(data, order, lengths, i)
|
||||
if err != nil || size == 0 || uint64(at)+uint64(size) > uint64(len(data)) {
|
||||
return nil, fmt.Errorf("truncated TIFF image block")
|
||||
}
|
||||
ranges = append(ranges, [2]int64{int64(at), int64(at) + int64(size)})
|
||||
}
|
||||
slices.SortFunc(ranges, func(a, b [2]int64) int { return cmp.Compare(a[0], b[0]) })
|
||||
merged := ranges[:0]
|
||||
for _, span := range ranges {
|
||||
if len(merged) != 0 && span[0] <= merged[len(merged)-1][1] {
|
||||
merged[len(merged)-1][1] = max(merged[len(merged)-1][1], span[1])
|
||||
} else {
|
||||
merged = append(merged, span)
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// decodeTIFFBitOrder 使用只读视图校正位序,不分配整幅编码的副本
|
||||
// 入参: input 原始解码视图, ranges 需要反转位序的像素区间
|
||||
// 返回: image.Image 解码图像, error 解码错误
|
||||
func decodeTIFFBitOrder(input interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
}, ranges [][2]int64) (image.Image, error) {
|
||||
if len(ranges) == 0 {
|
||||
return tiff.Decode(input)
|
||||
}
|
||||
return tiff.Decode(&tiffBitOrderInput{source: input, ranges: ranges})
|
||||
}
|
||||
|
||||
// Read 顺序读取并校正像素字节的位序
|
||||
// 入参: data 接收缓冲
|
||||
// 返回: int 读取字节数, error 读取错误
|
||||
func (r *tiffBitOrderInput) Read(data []byte) (int, error) {
|
||||
n, err := r.ReadAt(data, r.position)
|
||||
r.position += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadAt 按文件偏移读取,只反转实际读取到的像素字节
|
||||
// 入参: data 接收缓冲, offset 文件偏移
|
||||
// 返回: int 读取字节数, error 读取错误
|
||||
func (r *tiffBitOrderInput) ReadAt(data []byte, offset int64) (int, error) {
|
||||
n, err := r.source.ReadAt(data, offset)
|
||||
index := sort.Search(len(r.ranges), func(i int) bool { return r.ranges[i][1] > offset })
|
||||
for ; index < len(r.ranges) && r.ranges[index][0] < offset+int64(n); index++ {
|
||||
span := r.ranges[index]
|
||||
for i := max(int64(0), span[0]-offset); i < min(int64(n), span[1]-offset); i++ {
|
||||
data[i] = bits.Reverse8(data[i])
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math/bits"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// tiffFaxImage 组合独立解码的传真条带或瓦片,保留紧凑的一位像素存储
|
||||
type tiffFaxImage struct {
|
||||
blocks []image.Image
|
||||
bounds image.Rectangle
|
||||
width int
|
||||
height int
|
||||
across int
|
||||
}
|
||||
|
||||
// decodeTIFFFax 按TIFF传真选项解码各图像块,复用PDFGo的T.4与T.6解码器
|
||||
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 已校验的主图布局
|
||||
// 返回: image.Image 图像, error 字段、解码或取消错误
|
||||
func decodeTIFFFax(ctx context.Context, data []byte, layout TIFFLayout) (image.Image, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if layout.Samples != 1 || layout.compression == 3 && layout.t4 & ^uint32(7) != 0 || layout.compression == 4 && layout.t6 & ^uint32(2) != 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF fax options")
|
||||
}
|
||||
var order binary.ByteOrder = binary.LittleEndian
|
||||
if data[0] == 'M' {
|
||||
order = binary.BigEndian
|
||||
}
|
||||
offset := int(layout.Offset)
|
||||
count := int(order.Uint16(data[offset:]))
|
||||
entries := make(map[uint16][]byte, count)
|
||||
for i := range count {
|
||||
entry := data[offset+2+i*12 : offset+14+i*12]
|
||||
entries[order.Uint16(entry)] = entry
|
||||
}
|
||||
value := func(tag uint16, fallback uint32) (uint32, error) {
|
||||
entry := entries[tag]
|
||||
if entry == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
if order.Uint32(entry[4:]) != 1 {
|
||||
return 0, fmt.Errorf("invalid TIFF scalar field %d", tag)
|
||||
}
|
||||
return tiffPlaneValue(data, order, entry, 0)
|
||||
}
|
||||
for _, tag := range []uint16{258, 317, 339} {
|
||||
v, err := value(tag, 1)
|
||||
if err != nil || v != 1 || entries[tag] != nil && order.Uint16(entries[tag][2:]) != 3 {
|
||||
return nil, fmt.Errorf("invalid TIFF fax sample field %d", tag)
|
||||
}
|
||||
}
|
||||
photo, err := value(262, 2)
|
||||
if err != nil || photo > 1 && photo != 4 || entries[338] != nil {
|
||||
return nil, fmt.Errorf("invalid TIFF fax color model")
|
||||
}
|
||||
width := uint32(layout.Width)
|
||||
height, err := value(278, ^uint32(0))
|
||||
if err != nil || height == 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF rows per strip")
|
||||
}
|
||||
height = min(height, uint32(layout.Height))
|
||||
locations, lengths := entries[273], entries[279]
|
||||
tiled := entries[322] != nil
|
||||
if tiled {
|
||||
width, err = value(322, 0)
|
||||
if err != nil || width == 0 || width%16 != 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF tile width")
|
||||
}
|
||||
height, err = value(323, 0)
|
||||
if err != nil || height == 0 || height%16 != 0 {
|
||||
return nil, fmt.Errorf("invalid TIFF tile height")
|
||||
}
|
||||
locations, lengths = entries[324], entries[325]
|
||||
if locations != nil && order.Uint16(locations[2:]) != 4 {
|
||||
return nil, fmt.Errorf("invalid TIFF tile offsets")
|
||||
}
|
||||
}
|
||||
if uint64(width) > uint64(int(^uint(0)>>1)) || uint64(height) > uint64(int(^uint(0)>>1)) || locations == nil || lengths == nil {
|
||||
return nil, fmt.Errorf("invalid TIFF fax blocks")
|
||||
}
|
||||
across := (uint64(layout.Width)-1)/uint64(width) + 1
|
||||
down := (uint64(layout.Height)-1)/uint64(height) + 1
|
||||
blocks := across * down
|
||||
if blocks != uint64(order.Uint32(locations[4:])) || blocks != uint64(order.Uint32(lengths[4:])) {
|
||||
return nil, fmt.Errorf("invalid TIFF fax block count")
|
||||
}
|
||||
result := &tiffFaxImage{bounds: image.Rect(0, 0, layout.Width, layout.Height), width: int(width), height: int(height), across: int(across)}
|
||||
for i := uint64(0); i < blocks; i++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at, err := tiffPlaneValue(data, order, locations, uint32(i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := tiffPlaneValue(data, order, lengths, uint32(i))
|
||||
if err != nil || size == 0 || uint64(at)+uint64(size) > uint64(len(data)) {
|
||||
return nil, fmt.Errorf("truncated TIFF fax block")
|
||||
}
|
||||
encoded := data[uint64(at) : uint64(at)+uint64(size)]
|
||||
if layout.fillOrder == 2 {
|
||||
reversed := make([]byte, len(encoded))
|
||||
for start := 0; start < len(encoded); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := start + min(32768, len(encoded)-start)
|
||||
for j := start; j < end; j++ {
|
||||
reversed[j] = bits.Reverse8(encoded[j])
|
||||
}
|
||||
start = end
|
||||
}
|
||||
encoded = reversed
|
||||
}
|
||||
rows := height
|
||||
if !tiled {
|
||||
rows = min(height, uint32(uint64(layout.Height)-i*uint64(height)))
|
||||
}
|
||||
k := pdfgo.Integer(0)
|
||||
if layout.compression == 4 {
|
||||
k = -1
|
||||
} else if layout.compression == 3 && layout.t4&1 != 0 {
|
||||
k = 1
|
||||
}
|
||||
params := pdfgo.Dictionary{
|
||||
"K": k, "Columns": pdfgo.Integer(width), "Rows": pdfgo.Integer(rows),
|
||||
"EndOfBlock": pdfgo.Boolean(false), "EndOfLine": pdfgo.Boolean(layout.compression == 3),
|
||||
"EncodedByteAlign": pdfgo.Boolean(layout.compression == 2), "BlackIs1": pdfgo.Boolean(photo == 1 || photo == 4),
|
||||
}
|
||||
object := &pdfgo.Image{Width: int(width), Height: int(rows), BitsPerComponent: 1, ColorSpace: pdfgo.Name("DeviceGray"), Stream: &pdfgo.Stream{Dictionary: pdfgo.Dictionary{"Filter": pdfgo.Name("CCITTFaxDecode"), "DecodeParms": params}, Data: encoded}}
|
||||
pixels, err := object.DecodeSamplesContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TIFF fax block %d: %w", i, err)
|
||||
}
|
||||
result.blocks = append(result.blocks, pixels)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Bounds 返回主图显示区域,不包含边缘瓦片的填充像素
|
||||
// 返回: image.Rectangle 图像区域
|
||||
func (s *tiffFaxImage) Bounds() image.Rectangle { return s.bounds }
|
||||
|
||||
// ColorModel 返回二值图像使用的灰度颜色模型
|
||||
// 返回: color.Model 灰度模型
|
||||
func (s *tiffFaxImage) ColorModel() color.Model { return color.GrayModel }
|
||||
|
||||
// At 读取所属条带或瓦片中的像素,区域外返回黑色
|
||||
// 入参: x 横向像素坐标, y 纵向像素坐标
|
||||
// 返回: color.Color 像素颜色
|
||||
func (s *tiffFaxImage) At(x, y int) color.Color {
|
||||
if !image.Pt(x, y).In(s.bounds) {
|
||||
return color.Gray{}
|
||||
}
|
||||
block := s.blocks[(y/s.height)*s.across+x/s.width]
|
||||
return block.At(x%s.width, y%s.height)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
// maskedTIFFImage 将独立透明蒙版叠加到主图,复用两个目录的只读像素
|
||||
type maskedTIFFImage struct {
|
||||
source, mask image.Image
|
||||
bounds image.Rectangle
|
||||
}
|
||||
|
||||
// DecodeTIFF 解码主图及透明蒙版,保留颜色精度与高分辨率蒙版
|
||||
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 主图布局
|
||||
// 返回: image.Image 图像, error 解码或取消错误
|
||||
func DecodeTIFF(ctx context.Context, data []byte, layout TIFFLayout) (image.Image, error) {
|
||||
source, err := decodeTIFFPixels(ctx, data, layout)
|
||||
if err != nil || layout.Transparency == nil {
|
||||
return source, err
|
||||
}
|
||||
mask, err := decodeTIFFPixels(ctx, data, *layout.Transparency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &maskedTIFFImage{source: source, mask: mask, bounds: image.Rectangle{Max: layout.DisplaySize()}}, nil
|
||||
}
|
||||
|
||||
// Bounds 返回主图和蒙版共同覆盖的像素区域
|
||||
// 返回: image.Rectangle 显示区域
|
||||
func (s *maskedTIFFImage) Bounds() image.Rectangle { return s.bounds }
|
||||
|
||||
// ColorModel 返回保留原图采样精度的非预乘颜色模型
|
||||
// 返回: color.Model 颜色模型
|
||||
func (s *maskedTIFFImage) ColorModel() color.Model { return color.NRGBA64Model }
|
||||
|
||||
// samplePoint 将显示坐标映射到主图或蒙版的像素中心
|
||||
// 入参: bounds 采样图像范围, x 横坐标, y 纵坐标
|
||||
// 返回: image.Point 采样坐标
|
||||
func (s *maskedTIFFImage) samplePoint(bounds image.Rectangle, x, y int) image.Point {
|
||||
size := s.bounds.Size()
|
||||
x, y = x-s.bounds.Min.X, y-s.bounds.Min.Y
|
||||
if bounds.Size() == size {
|
||||
return bounds.Min.Add(image.Pt(x, y))
|
||||
}
|
||||
return image.Pt(
|
||||
bounds.Min.X+int((uint64(x)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X)),
|
||||
bounds.Min.Y+int((uint64(y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y)),
|
||||
)
|
||||
}
|
||||
|
||||
// alphaAt 读取蒙版与主图透明度,不解算主图颜色
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: uint32 十六位透明度
|
||||
func (s *maskedTIFFImage) alphaAt(x, y int) uint32 {
|
||||
if !image.Pt(x, y).In(s.bounds) {
|
||||
return 0
|
||||
}
|
||||
mask := s.samplePoint(s.mask.Bounds(), x, y)
|
||||
if NRGBA64At(s.mask, mask.X, mask.Y).R == 0 {
|
||||
return 0
|
||||
}
|
||||
source := s.samplePoint(s.source.Bounds(), x, y)
|
||||
return AlphaAt(s.source, source.X, source.Y)
|
||||
}
|
||||
|
||||
// NRGBA64At 按像素中心读取主图及蒙版,零位隐藏原图并保留隐藏颜色
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.NRGBA64 非预乘十六位颜色
|
||||
func (s *maskedTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
|
||||
if !image.Pt(x, y).In(s.bounds) {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
source := s.samplePoint(s.source.Bounds(), x, y)
|
||||
pixel := NRGBA64At(s.source, source.X, source.Y)
|
||||
mask := s.samplePoint(s.mask.Bounds(), x, y)
|
||||
if NRGBA64At(s.mask, mask.X, mask.Y).R == 0 {
|
||||
pixel.A = 0
|
||||
}
|
||||
return pixel
|
||||
}
|
||||
|
||||
// At 返回非预乘颜色,区域外透明
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.Color 像素颜色
|
||||
func (s *maskedTIFFImage) At(x, y int) color.Color { return s.NRGBA64At(x, y) }
|
||||
|
||||
// RGBA64At 返回预乘十六位颜色,供采样器直接读取
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.RGBA64 预乘颜色
|
||||
func (s *maskedTIFFImage) RGBA64At(x, y int) color.RGBA64 {
|
||||
r, g, b, a := s.NRGBA64At(x, y).RGBA()
|
||||
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"math"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// tiffPlaneInput 将单分量目录附加到只读视图,像素仍从原始编码读取
|
||||
type tiffPlaneInput struct {
|
||||
source, directory []byte
|
||||
position int64
|
||||
order binary.ByteOrder
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// planarTIFFImage 组合颜色与Alpha分量,保留交错步长并按需映射调色板
|
||||
type planarTIFFImage struct {
|
||||
planes []image.Image
|
||||
bounds image.Rectangle
|
||||
depth uint32
|
||||
packed [5]uint32
|
||||
offsets [5]int
|
||||
alpha uint32
|
||||
gray bool
|
||||
cmyk bool
|
||||
dotRange [4][2]uint16
|
||||
palette []color.NRGBA64
|
||||
stride int
|
||||
invert bool
|
||||
predictorWidth int
|
||||
}
|
||||
|
||||
// needsSampleView 判断是否需通过分量视图处理存储布局,供尺寸读取和像素解码共用
|
||||
// 返回: bool 是否需要分量视图
|
||||
func (layout TIFFLayout) needsSampleView() bool {
|
||||
return layout.mask || layout.auxiliary || layout.photometric == 5 || layout.Planar && layout.Samples > 1 || layout.Samples == 2 || layout.depth < 8 && (layout.Samples > 1 || layout.depth > 1 || layout.predictor == 2)
|
||||
}
|
||||
|
||||
// DecodedPixelBytes 估算每像素解码缓冲,低位深分量按逐位展开后的字节数计量
|
||||
// 返回: uint64 每像素预算字节数
|
||||
func (layout TIFFLayout) DecodedPixelBytes() uint64 {
|
||||
samples := uint64(layout.Samples)
|
||||
profile := uint64(0)
|
||||
if layout.profileSize != 0 {
|
||||
profile = 8
|
||||
}
|
||||
if layout.Planar {
|
||||
if layout.photometric == 5 && samples > 4 {
|
||||
return 20 + profile
|
||||
}
|
||||
return 16 + profile
|
||||
}
|
||||
if !layout.needsSampleView() {
|
||||
return 8 + profile
|
||||
}
|
||||
bytes := uint64(1)
|
||||
if layout.depth == 2 || layout.depth == 4 {
|
||||
bytes = uint64(layout.depth)
|
||||
} else if layout.depth > 8 {
|
||||
bytes = 2
|
||||
}
|
||||
return max(uint64(8), samples*bytes) + profile
|
||||
}
|
||||
|
||||
// decodeTIFFPixels 按存储方式解码指定目录并统一颜色和显示方向
|
||||
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 目录布局
|
||||
// 返回: image.Image 图像, error 解码或取消错误
|
||||
func decodeTIFFPixels(ctx context.Context, data []byte, layout TIFFLayout) (image.Image, error) {
|
||||
profile, err := readTIFFColorProfile(ctx, data, layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ranges [][2]int64
|
||||
if layout.compression < 2 || layout.compression > 4 {
|
||||
ranges, err = tiffBitOrderRanges(ctx, data, layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var pixels image.Image
|
||||
if layout.compression >= 2 && layout.compression <= 4 {
|
||||
pixels, err = decodeTIFFFax(ctx, data, layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if layout.needsSampleView() {
|
||||
result, directories, err := tiffPlaneDirectories(ctx, data, layout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, directory := range directories {
|
||||
input := &tiffPlaneInput{source: data, directory: directory, order: binary.LittleEndian, ctx: ctx}
|
||||
if data[0] == 'M' {
|
||||
input.order = binary.BigEndian
|
||||
}
|
||||
plane, err := decodeTIFFBitOrder(input, ranges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.predictorWidth != 0 && (result.stride > 1 || result.packed[len(result.planes)] != 0) {
|
||||
if err := restoreTIFFPrediction(ctx, plane, max(result.stride, 1), result.predictorWidth, result.packed[len(result.planes)]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result.planes = append(result.planes, plane)
|
||||
}
|
||||
pixels = result
|
||||
} else {
|
||||
input := newTIFFDirectoryInput(data, layout.Offset)
|
||||
input.ctx = ctx
|
||||
var err error
|
||||
pixels, err = decodeTIFFBitOrder(input, ranges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if profile != nil {
|
||||
pixels, err = applyTIFFColorProfile(ctx, pixels, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if layout.Orientation != 1 {
|
||||
pixels = &orientedTIFFImage{source: pixels, TIFFLayout: layout}
|
||||
}
|
||||
return pixels, nil
|
||||
}
|
||||
|
||||
// tiffPlaneDirectories 为颜色、Alpha或调色板索引构建单通道目录,跳过无显示用途的独立平面
|
||||
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 已校验的主图布局
|
||||
// 返回: *planarTIFFImage 像素布局, [][]byte 各分量目录, error 字段或布局错误
|
||||
func tiffPlaneDirectories(ctx context.Context, data []byte, layout TIFFLayout) (*planarTIFFImage, [][]byte, error) {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if uint64(len(data))+65536*12+8 > math.MaxUint32 {
|
||||
return nil, nil, fmt.Errorf("TIFF plane directory offset exceeds format limit")
|
||||
}
|
||||
var order binary.ByteOrder = binary.LittleEndian
|
||||
if data[0] == 'M' {
|
||||
order = binary.BigEndian
|
||||
}
|
||||
offset := int(layout.Offset)
|
||||
count := int(order.Uint16(data[offset:]))
|
||||
entries := make(map[uint16][]byte, count)
|
||||
for i := range count {
|
||||
entry := data[offset+2+i*12 : offset+14+i*12]
|
||||
entries[order.Uint16(entry)] = entry
|
||||
}
|
||||
value := func(tag uint16, fallback uint32) (uint32, error) {
|
||||
entry := entries[tag]
|
||||
if entry == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
if order.Uint32(entry[4:]) != 1 {
|
||||
return 0, fmt.Errorf("invalid TIFF scalar field %d", tag)
|
||||
}
|
||||
return tiffPlaneValue(data, order, entry, 0)
|
||||
}
|
||||
samples, err := value(277, 1)
|
||||
if err != nil || samples < 1 || samples > math.MaxUint16 {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF plane count")
|
||||
}
|
||||
photo, err := value(262, math.MaxUint32)
|
||||
if err != nil || photo > 5 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF planar color model")
|
||||
}
|
||||
compression, err := value(259, 1)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
fill, err := value(266, 1)
|
||||
if err != nil || fill != 1 && (fill != 2 || compression != 1 && (compression < 2 || compression > 4)) {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF plane fill order")
|
||||
}
|
||||
predictor, err := value(317, 1)
|
||||
if err != nil || predictor != 1 && predictor != 2 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF plane predictor")
|
||||
}
|
||||
result := &planarTIFFImage{bounds: image.Rect(0, 0, layout.Width, layout.Height), gray: photo < 2 || photo == 4, invert: photo == 0, cmyk: photo == 5}
|
||||
if result.cmyk {
|
||||
for _, tag := range []uint16{332, 334} {
|
||||
want := uint32(1)
|
||||
if tag == 334 {
|
||||
want = 4
|
||||
}
|
||||
got, err := value(tag, want)
|
||||
if err != nil || got != want || entries[tag] != nil && order.Uint16(entries[tag][2:]) != 3 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF ink configuration")
|
||||
}
|
||||
}
|
||||
}
|
||||
planes := samples
|
||||
if !layout.Planar && samples > 1 {
|
||||
result.stride = int(samples)
|
||||
planes = 1
|
||||
}
|
||||
colors := uint32(3)
|
||||
if result.cmyk {
|
||||
colors = 4
|
||||
} else if photo != 2 {
|
||||
colors = 1
|
||||
}
|
||||
if samples != colors {
|
||||
entry := entries[338]
|
||||
if samples < colors || entry == nil || order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != samples-colors {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF extra samples")
|
||||
}
|
||||
for i := uint32(0); i < samples-colors; i++ {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
alpha, err := tiffPlaneValue(data, order, entry, i)
|
||||
if err != nil || alpha > 2 || alpha != 0 && result.alpha != 0 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF extra samples")
|
||||
}
|
||||
if alpha != 0 {
|
||||
result.alpha, result.offsets[colors] = alpha, int(i)
|
||||
}
|
||||
}
|
||||
} else if entries[338] != nil {
|
||||
return nil, nil, fmt.Errorf("unexpected TIFF extra samples")
|
||||
}
|
||||
width, err := value(322, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
blocks := uint64(0)
|
||||
locations, lengths := uint16(273), uint16(279)
|
||||
if width != 0 {
|
||||
height, err := value(323, 0)
|
||||
if err != nil || height == 0 || width%16 != 0 || height%16 != 0 {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF tile dimensions")
|
||||
}
|
||||
blocks = ((uint64(layout.Width)-1)/uint64(width) + 1) * ((uint64(layout.Height)-1)/uint64(height) + 1)
|
||||
locations, lengths = 324, 325
|
||||
} else {
|
||||
rows, err := value(278, math.MaxUint32)
|
||||
if err != nil || rows == 0 {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF rows per strip")
|
||||
}
|
||||
blocks = (uint64(layout.Height)-1)/uint64(rows) + 1
|
||||
}
|
||||
if blocks > math.MaxUint32/uint64(planes) {
|
||||
return nil, nil, fmt.Errorf("too many TIFF plane blocks")
|
||||
}
|
||||
for _, tag := range []uint16{locations, lengths} {
|
||||
entry := entries[tag]
|
||||
if entry == nil || uint64(order.Uint32(entry[4:])) != blocks*uint64(planes) || tag == 324 && order.Uint16(entry[2:]) != 4 {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF plane block count")
|
||||
}
|
||||
}
|
||||
for i := uint32(0); i < uint32(blocks)*planes; i++ {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
at, err := tiffPlaneValue(data, order, entries[locations], i)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
size, err := tiffPlaneValue(data, order, entries[lengths], i)
|
||||
if err != nil || size == 0 || uint64(at)+uint64(size) > uint64(len(data)) {
|
||||
return nil, nil, fmt.Errorf("truncated TIFF plane block")
|
||||
}
|
||||
}
|
||||
for _, tag := range []uint16{258, 339} {
|
||||
if entry := entries[tag]; entry != nil && (order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != samples) {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF sample array")
|
||||
}
|
||||
}
|
||||
var directories [][]byte
|
||||
var firstDepth uint32
|
||||
for plane := uint32(0); plane < samples; plane++ {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
slot := plane
|
||||
visible := plane < colors || result.alpha != 0 && plane == colors+uint32(result.offsets[colors])
|
||||
if !visible && result.stride == 0 {
|
||||
continue
|
||||
}
|
||||
if plane >= colors {
|
||||
slot = colors
|
||||
}
|
||||
depth := uint32(1)
|
||||
if entry := entries[258]; entry != nil {
|
||||
depth, err = tiffPlaneValue(data, order, entry, plane)
|
||||
}
|
||||
if err != nil || depth != 1 && depth != 2 && depth != 4 && depth != 8 && depth != 16 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF plane depth")
|
||||
}
|
||||
if plane == 0 {
|
||||
firstDepth = depth
|
||||
} else if result.stride > 1 && depth != firstDepth {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF mixed interleaved sample depths")
|
||||
}
|
||||
if !visible {
|
||||
continue
|
||||
}
|
||||
result.depth = max(result.depth, depth)
|
||||
if result.cmyk && plane < 4 {
|
||||
low, high := uint32(0), uint32(1<<depth-1)
|
||||
if entry := entries[336]; entry != nil {
|
||||
n := order.Uint32(entry[4:])
|
||||
if kind := order.Uint16(entry[2:]); kind != 1 && kind != 3 || n != 2 && n != 2*samples {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF dot range")
|
||||
}
|
||||
i := plane * 2
|
||||
if n == 2 {
|
||||
i = 0
|
||||
}
|
||||
if order.Uint16(entry[2:]) == 1 {
|
||||
values := entry[8:]
|
||||
if n > 4 {
|
||||
at := uint64(order.Uint32(values))
|
||||
if at+uint64(n) > uint64(len(data)) {
|
||||
return nil, nil, fmt.Errorf("truncated TIFF dot range")
|
||||
}
|
||||
values = data[at : at+uint64(n)]
|
||||
}
|
||||
low, high = uint32(values[i]), uint32(values[i+1])
|
||||
} else {
|
||||
low, err = tiffPlaneValue(data, order, entry, i)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
high, err = tiffPlaneValue(data, order, entry, i+1)
|
||||
}
|
||||
if err != nil || low == high || low > 1<<depth-1 || high > 1<<depth-1 {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF dot range")
|
||||
}
|
||||
}
|
||||
result.dotRange[plane] = [2]uint16{uint16(low * 65535 / (1<<depth - 1)), uint16(high * 65535 / (1<<depth - 1))}
|
||||
}
|
||||
factor := uint64(max(result.stride, 1))
|
||||
if depth == 1 && predictor == 2 {
|
||||
result.packed[slot] = 1
|
||||
}
|
||||
if depth == 2 || depth == 4 {
|
||||
result.packed[slot] = depth
|
||||
factor *= uint64(depth)
|
||||
if compression == 2 || compression == 3 || compression == 4 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF packed plane compression")
|
||||
}
|
||||
}
|
||||
pixelBytes := factor
|
||||
if depth > 8 {
|
||||
pixelBytes *= 2
|
||||
}
|
||||
if uint64(layout.Width)*pixelBytes > uint64(math.MaxInt)/uint64(layout.Height) || uint64(layout.Width)*factor > math.MaxUint32 || uint64(width)*factor > math.MaxUint32 {
|
||||
return nil, nil, fmt.Errorf("TIFF plane dimensions exceed format limit")
|
||||
}
|
||||
if predictor == 2 && (result.stride > 1 || depth < 8) {
|
||||
result.predictorWidth = layout.Width
|
||||
if width != 0 {
|
||||
result.predictorWidth = int(width)
|
||||
}
|
||||
}
|
||||
if entry := entries[339]; entry != nil {
|
||||
format, err := tiffPlaneValue(data, order, entry, plane)
|
||||
if err != nil || format != 1 {
|
||||
return nil, nil, fmt.Errorf("unsupported TIFF sample format")
|
||||
}
|
||||
}
|
||||
if photo == 3 && plane == 0 {
|
||||
entry := entries[320]
|
||||
if depth > 8 || entry == nil || order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != 3<<depth {
|
||||
return nil, nil, fmt.Errorf("invalid TIFF color map")
|
||||
}
|
||||
n := uint32(1) << depth
|
||||
result.palette = make([]color.NRGBA64, n)
|
||||
for i := range n {
|
||||
var components [3]uint16
|
||||
for c := range components {
|
||||
v, err := tiffPlaneValue(data, order, entry, uint32(c)*n+i)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
components[c] = uint16(v)
|
||||
if v%257 != 0 {
|
||||
result.depth = 16
|
||||
}
|
||||
}
|
||||
result.palette[i] = color.NRGBA64{R: components[0], G: components[1], B: components[2], A: 65535}
|
||||
}
|
||||
}
|
||||
if result.stride > 1 && plane != 0 {
|
||||
continue
|
||||
}
|
||||
directory := make([]byte, 2, 6+count*12)
|
||||
for i := range count {
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
entry := bytes.Clone(data[offset+2+i*12 : offset+14+i*12])
|
||||
tag := order.Uint16(entry)
|
||||
if tag == 338 || tag == 320 || tag >= 332 && tag <= 337 || tag == 34675 {
|
||||
continue
|
||||
}
|
||||
scalar := uint32(0)
|
||||
switch tag {
|
||||
case 258:
|
||||
scalar = depth
|
||||
if result.packed[slot] != 0 {
|
||||
scalar = 1
|
||||
}
|
||||
case 256, 322:
|
||||
if factor != 1 {
|
||||
original, err := tiffPlaneValue(data, order, entry, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
order.PutUint16(entry[2:], 4)
|
||||
order.PutUint32(entry[4:], 1)
|
||||
order.PutUint32(entry[8:], original*uint32(factor))
|
||||
}
|
||||
case 262:
|
||||
scalar = 1
|
||||
case 277, 284, 339:
|
||||
scalar = 1
|
||||
case 317:
|
||||
if predictor == 2 && (result.stride > 1 || depth < 8) {
|
||||
order.PutUint16(entry[8:], 1)
|
||||
}
|
||||
case locations, lengths:
|
||||
entry, err = tiffPlaneArray(data, order, entry, uint32(blocks)*plane, uint32(blocks))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if tag == 258 || tag == 262 || tag == 277 || tag == 284 || tag == 339 {
|
||||
order.PutUint16(entry[2:], 3)
|
||||
order.PutUint32(entry[4:], 1)
|
||||
clear(entry[8:])
|
||||
order.PutUint16(entry[8:], uint16(scalar))
|
||||
}
|
||||
directory = append(directory, entry...)
|
||||
}
|
||||
order.PutUint16(directory, uint16((len(directory)-2)/12))
|
||||
directories = append(directories, append(directory, 0, 0, 0, 0))
|
||||
}
|
||||
return result, directories, nil
|
||||
}
|
||||
|
||||
// restoreTIFFPrediction 在解码缓冲内恢复水平差分,各行及瓦片独立计算
|
||||
// 入参: ctx 取消上下文,可为nil, pixels 单通道解码视图, stride 每像素分量数, width 原始条带或瓦片宽度, packed 展开为一位采样的原位深,零表示未展开
|
||||
// 返回: error 图像类型或取消错误
|
||||
func restoreTIFFPrediction(ctx context.Context, pixels image.Image, stride, width int, packed uint32) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var data []byte
|
||||
var pitch, step int
|
||||
switch pixels := pixels.(type) {
|
||||
case *image.Gray:
|
||||
data, pitch, step = pixels.Pix, pixels.Stride, max(int(packed), 1)
|
||||
case *image.Gray16:
|
||||
data, pitch, step = pixels.Pix, pixels.Stride, 2
|
||||
default:
|
||||
return fmt.Errorf("unsupported TIFF predictor image")
|
||||
}
|
||||
bounds := pixels.Bounds()
|
||||
columns := bounds.Dx() / max(int(packed), 1)
|
||||
block := width * stride
|
||||
for y := range bounds.Dy() {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
row := data[y*pitch : y*pitch+columns*step]
|
||||
for start := 0; start < columns; {
|
||||
end := start + min(block, columns-start)
|
||||
for x := start + stride; x < end; {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
limit := x + min(4096, end-x)
|
||||
for ; x < limit; x++ {
|
||||
at, previous := x*step, (x-stride)*step
|
||||
if packed != 0 {
|
||||
var current, prior uint16
|
||||
for bit := range step {
|
||||
current = current<<1 | uint16(row[at+bit]>>7)
|
||||
prior = prior<<1 | uint16(row[previous+bit]>>7)
|
||||
}
|
||||
value := current + prior
|
||||
for bit := range step {
|
||||
row[at+bit] = byte(value>>uint(step-1-bit)&1) * 255
|
||||
}
|
||||
} else if step == 2 {
|
||||
value := binary.BigEndian.Uint16(row[at:]) + binary.BigEndian.Uint16(row[previous:])
|
||||
binary.BigEndian.PutUint16(row[at:], value)
|
||||
} else {
|
||||
row[at] += row[previous]
|
||||
}
|
||||
}
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// tiffPlaneValue 读取SHORT或LONG数组中的一项,校验完整数组范围
|
||||
// 入参: data 原编码, order 字节序, entry 目录项, index 元素序号
|
||||
// 返回: uint32 元素值, error 类型或越界错误
|
||||
func tiffPlaneValue(data []byte, order binary.ByteOrder, entry []byte, index uint32) (uint32, error) {
|
||||
kind, count := order.Uint16(entry[2:]), order.Uint32(entry[4:])
|
||||
if kind != 3 && kind != 4 || index >= count {
|
||||
return 0, fmt.Errorf("invalid TIFF plane array")
|
||||
}
|
||||
size := uint64(2)
|
||||
if kind == 4 {
|
||||
size = 4
|
||||
}
|
||||
values := entry[8:]
|
||||
if size*uint64(count) > 4 {
|
||||
at := uint64(order.Uint32(values))
|
||||
if at+size*uint64(count) > uint64(len(data)) {
|
||||
return 0, fmt.Errorf("truncated TIFF plane array")
|
||||
}
|
||||
values = data[at:]
|
||||
}
|
||||
if size == 2 {
|
||||
return uint32(order.Uint16(values[uint64(index)*size:])), nil
|
||||
}
|
||||
return order.Uint32(values[uint64(index)*size:]), nil
|
||||
}
|
||||
|
||||
// tiffPlaneArray 将块索引缩减为指定分量的范围,较长数组继续借用原编码
|
||||
// 入参: data 原编码, order 字节序, entry 目录项副本, first 起始序号, count 元素数
|
||||
// 返回: []byte 修改后的目录项, error 类型或越界错误
|
||||
func tiffPlaneArray(data []byte, order binary.ByteOrder, entry []byte, first, count uint32) ([]byte, error) {
|
||||
if _, err := tiffPlaneValue(data, order, entry, first+count-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := uint32(2)
|
||||
if order.Uint16(entry[2:]) == 4 {
|
||||
size = 4
|
||||
}
|
||||
if uint64(count)*uint64(size) <= 4 {
|
||||
var values [4]byte
|
||||
for i := uint32(0); i < count; i++ {
|
||||
value, _ := tiffPlaneValue(data, order, entry, first+i)
|
||||
if size == 2 {
|
||||
order.PutUint16(values[i*size:], uint16(value))
|
||||
} else {
|
||||
order.PutUint32(values[i*size:], value)
|
||||
}
|
||||
}
|
||||
copy(entry[8:], values[:])
|
||||
} else {
|
||||
order.PutUint32(entry[8:], order.Uint32(entry[8:])+first*size)
|
||||
}
|
||||
order.PutUint32(entry[4:], count)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// Read 从组合视图顺序读取
|
||||
// 入参: data 接收缓冲
|
||||
// 返回: int 字节数, error 读取或取消错误
|
||||
func (r *tiffPlaneInput) Read(data []byte) (int, error) {
|
||||
n, err := r.ReadAt(data, r.position)
|
||||
r.position += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadAt 从原始编码或附加目录读取并替换首目录指针
|
||||
// 入参: data 接收缓冲, offset 文件偏移
|
||||
// 返回: int 字节数, error 读取或取消错误
|
||||
func (r *tiffPlaneInput) ReadAt(data []byte, offset int64) (int, error) {
|
||||
if r.ctx != nil {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if offset < 0 {
|
||||
return 0, fmt.Errorf("negative TIFF read offset")
|
||||
}
|
||||
start := int64(len(r.source))
|
||||
directory := start + start%2
|
||||
written := 0
|
||||
if offset < start {
|
||||
written = copy(data, r.source[offset:])
|
||||
}
|
||||
if offset+int64(written) == start && start != directory && written < len(data) {
|
||||
data[written] = 0
|
||||
written++
|
||||
}
|
||||
if at := offset + int64(written) - directory; at >= 0 && at < int64(len(r.directory)) {
|
||||
written += copy(data[written:], r.directory[at:])
|
||||
}
|
||||
var pointer [4]byte
|
||||
r.order.PutUint32(pointer[:], uint32(directory))
|
||||
for i, value := range pointer {
|
||||
if at := int64(4+i) - offset; at >= 0 && at < int64(written) {
|
||||
data[at] = value
|
||||
}
|
||||
}
|
||||
if written != len(data) {
|
||||
return written, io.EOF
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// Bounds 返回存储坐标范围
|
||||
// 返回: image.Rectangle 图像范围
|
||||
func (p *planarTIFFImage) Bounds() image.Rectangle { return p.bounds }
|
||||
|
||||
// ColorModel 按透明度类型和最高分量精度返回颜色模型
|
||||
// 返回: color.Model 颜色模型
|
||||
func (p *planarTIFFImage) ColorModel() color.Model {
|
||||
if p.gray && p.alpha == 0 {
|
||||
if p.depth > 8 {
|
||||
return color.Gray16Model
|
||||
}
|
||||
return color.GrayModel
|
||||
}
|
||||
if p.alpha == 1 {
|
||||
if p.depth > 8 || p.cmyk {
|
||||
return color.RGBA64Model
|
||||
}
|
||||
return color.RGBAModel
|
||||
}
|
||||
if p.depth > 8 || p.cmyk {
|
||||
return color.NRGBA64Model
|
||||
}
|
||||
return color.NRGBAModel
|
||||
}
|
||||
|
||||
// components 直接读取灰度分量,保留原始透明度语义
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: [4]uint16 红、绿、蓝和透明度分量
|
||||
func (p *planarTIFFImage) components(x, y int) [4]uint16 {
|
||||
if !image.Pt(x, y).In(p.bounds) {
|
||||
return [4]uint16{}
|
||||
}
|
||||
if p.cmyk {
|
||||
ink := p.inksAt(x, y)
|
||||
maximum := uint32(65535)
|
||||
if p.alpha == 1 {
|
||||
maximum = uint32(ink[4])
|
||||
if maximum == 0 {
|
||||
return [4]uint16{}
|
||||
}
|
||||
}
|
||||
var components [4]float64
|
||||
for i := range components {
|
||||
components[i] = float64(min(uint32(ink[i]), maximum)) / float64(maximum)
|
||||
}
|
||||
rgb, _ := (&pdfgo.ColorSpace{Model: "DeviceCMYK"}).SRGB(components[:], "")
|
||||
var values [4]uint16
|
||||
for i := range 3 {
|
||||
values[i] = uint16(math.Round(rgb[i] * float64(maximum)))
|
||||
}
|
||||
values[3] = ink[4]
|
||||
return values
|
||||
}
|
||||
values := [4]uint16{0, 0, 0, 65535}
|
||||
count := 3
|
||||
if p.gray || len(p.palette) != 0 {
|
||||
count = 1
|
||||
}
|
||||
if p.alpha != 0 {
|
||||
count++
|
||||
}
|
||||
for i := range count {
|
||||
value := p.sampleAt(i, x, y)
|
||||
if i == 0 && p.invert {
|
||||
value = 65535 - value
|
||||
}
|
||||
if len(p.palette) != 0 && i == 0 {
|
||||
c := p.palette[uint32(value)*uint32(len(p.palette)-1)/65535]
|
||||
values = [4]uint16{c.R, c.G, c.B, c.A}
|
||||
} else if p.gray && i == 0 {
|
||||
values[0], values[1], values[2] = value, value, value
|
||||
} else if p.gray || len(p.palette) != 0 {
|
||||
values[3] = value
|
||||
} else {
|
||||
values[i] = value
|
||||
}
|
||||
}
|
||||
if p.alpha == 1 && values[3] == 0 {
|
||||
return [4]uint16{}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// sampleAt 读取一个分量并扩展为十六位,交错布局复用同一像素缓冲
|
||||
// 入参: i 分量序号, x 横坐标, y 纵坐标
|
||||
// 返回: uint16 归一化采样值
|
||||
func (p *planarTIFFImage) sampleAt(i, x, y int) uint16 {
|
||||
plane, sx := p.planes[0], x
|
||||
if p.stride > 1 {
|
||||
sx = x*p.stride + i + p.offsets[i]
|
||||
} else {
|
||||
plane = p.planes[i]
|
||||
}
|
||||
switch plane := plane.(type) {
|
||||
case *image.Gray:
|
||||
if bits := p.packed[i]; bits != 0 {
|
||||
var value uint16
|
||||
for bit := range int(bits) {
|
||||
value = value<<1 | uint16(plane.GrayAt(sx*int(bits)+bit, y).Y>>7)
|
||||
}
|
||||
return value * (65535 / uint16(1<<bits-1))
|
||||
}
|
||||
return uint16(plane.GrayAt(sx, y).Y) * 257
|
||||
case *image.Gray16:
|
||||
return plane.Gray16At(sx, y).Y
|
||||
default:
|
||||
v, _, _, _ := plane.At(sx, y).RGBA()
|
||||
return uint16(v)
|
||||
}
|
||||
}
|
||||
|
||||
// inksAt 按网点范围读取CMYK覆盖量,范围外采样按端点截断,透明度独立归一化
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: [5]uint16 青、品红、黄、黑及透明度
|
||||
func (p *planarTIFFImage) inksAt(x, y int) [5]uint16 {
|
||||
values := [5]uint16{0, 0, 0, 0, 65535}
|
||||
for i := range 4 {
|
||||
value := int64(p.sampleAt(i, x, y))
|
||||
low, high := int64(p.dotRange[i][0]), int64(p.dotRange[i][1])
|
||||
values[i] = uint16(max(0, min(65535, (value-low)*65535/(high-low))))
|
||||
}
|
||||
if p.alpha != 0 {
|
||||
values[4] = p.sampleAt(4, x, y)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// At 返回组合后的颜色,关联透明度保留预乘语义
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.Color 像素颜色
|
||||
func (p *planarTIFFImage) At(x, y int) color.Color {
|
||||
values := p.components(x, y)
|
||||
if p.alpha == 1 {
|
||||
return color.RGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
|
||||
}
|
||||
return color.NRGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
|
||||
}
|
||||
|
||||
// NRGBA64At 读取独立透明度颜色,保留非预乘分量中的隐藏颜色
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.NRGBA64 像素颜色
|
||||
func (p *planarTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
|
||||
values := p.components(x, y)
|
||||
if p.alpha == 1 && values[3] != 65535 {
|
||||
if values[3] == 0 {
|
||||
return color.NRGBA64{}
|
||||
}
|
||||
for i := range 3 {
|
||||
values[i] = uint16(uint32(values[i]) * 65535 / uint32(values[3]))
|
||||
}
|
||||
}
|
||||
return color.NRGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
|
||||
}
|
||||
|
||||
// RGBA64At 返回预乘十六位颜色,不分配临时颜色对象
|
||||
// 入参: x 横坐标, y 纵坐标
|
||||
// 返回: color.RGBA64 像素颜色
|
||||
func (p *planarTIFFImage) RGBA64At(x, y int) color.RGBA64 {
|
||||
values := p.components(x, y)
|
||||
if p.alpha != 1 && values[3] != 65535 {
|
||||
for i := range 3 {
|
||||
values[i] = uint16(uint32(values[i]) * uint32(values[3]) / 65535)
|
||||
}
|
||||
}
|
||||
return color.RGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// parseTIFFColorProfile 校验ICC源模型,保留原有模型错误并复用浮点颜色变换
|
||||
// 入参: kind 源颜色模型, data ICC编码
|
||||
// 返回: *pdfgo.ColorSpace 颜色变换, error 模型或配置错误
|
||||
func parseTIFFColorProfile(kind string, data []byte) (*pdfgo.ColorSpace, error) {
|
||||
model := map[string]string{"GRAY": "GRAY", "RGB": "RGB ", "CMYK": "CMYK"}[strings.ToUpper(kind)]
|
||||
if len(data) < 132 || model == "" || string(data[16:20]) != model {
|
||||
return nil, fmt.Errorf("ICC source model does not match image color space %q", kind)
|
||||
}
|
||||
return pdfgo.NewICCColorSpace(data)
|
||||
}
|
||||
|
||||
// readTIFFColorProfile 读取主图的ICC配置,校验颜色模型和数据范围
|
||||
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 主图布局
|
||||
// 返回: *pdfgo.ColorSpace 颜色配置,未声明时为nil, error 配置或取消错误
|
||||
func readTIFFColorProfile(ctx context.Context, data []byte, layout TIFFLayout) (*pdfgo.ColorSpace, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if layout.profileSize == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
at, size := uint64(layout.profileOffset), uint64(layout.profileSize)
|
||||
if at+size > uint64(len(data)) {
|
||||
return nil, fmt.Errorf("truncated TIFF color profile")
|
||||
}
|
||||
kind := ""
|
||||
switch layout.photometric {
|
||||
case 0, 1:
|
||||
kind = "GRAY"
|
||||
case 2, 3:
|
||||
kind = "RGB"
|
||||
case 5:
|
||||
kind = "CMYK"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported TIFF profile color model")
|
||||
}
|
||||
profile, err := parseTIFFColorProfile(kind, data[at:at+size])
|
||||
if cancelErr := ctx.Err(); cancelErr != nil {
|
||||
return nil, cancelErr
|
||||
}
|
||||
return profile, err
|
||||
}
|
||||
|
||||
// applyTIFFColorProfile 将源颜色转换为十六位sRGB,保留独立透明度和隐藏颜色
|
||||
// 入参: ctx 取消上下文,可为nil, source 解码图像, profile 已校验的颜色配置
|
||||
// 返回: image.Image 独立透明度图像, error 颜色变换或取消错误
|
||||
func applyTIFFColorProfile(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
|
||||
return applyImageColorProfile(ctx, source, profile, false)
|
||||
}
|
||||
|
||||
// applyImageColorProfile 按源配置转换显示像素,精确复用连续同色结果
|
||||
// 入参: ctx 取消上下文, source 源像素, profile 颜色空间, linearSRGB 是否先解码sRGB传递函数
|
||||
// 返回: image.Image 十六位显示图像, error 变换或取消错误
|
||||
func applyImageColorProfile(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace, linearSRGB bool) (image.Image, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bounds := source.Bounds()
|
||||
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx() > math.MaxInt/8/bounds.Dy() {
|
||||
return nil, fmt.Errorf("TIFF profile image dimensions exceed capacity")
|
||||
}
|
||||
cmyk, _ := source.(*planarTIFFImage)
|
||||
if cmyk != nil && !cmyk.cmyk {
|
||||
cmyk = nil
|
||||
}
|
||||
model := source.ColorModel()
|
||||
premultiplied := model == color.RGBAModel || model == color.RGBA64Model
|
||||
table := newTIFFProfileTable(source)
|
||||
components, intent := profile.Components(), profile.DefaultIntent()
|
||||
result := image.NewNRGBA64(bounds)
|
||||
var previous [4]float64
|
||||
var previousPixel color.NRGBA64
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if (x-bounds.Min.X)&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
slot := -1
|
||||
if len(table) != 0 {
|
||||
index, alpha := tiffProfileSample(source, x, y, len(table))
|
||||
slot = index
|
||||
if pixel := table[index]; pixel.A != 0 {
|
||||
pixel.A = alpha
|
||||
result.SetNRGBA64(x, y, pixel)
|
||||
continue
|
||||
}
|
||||
}
|
||||
var values [4]float64
|
||||
var alpha uint16
|
||||
if cmyk != nil {
|
||||
ink := cmyk.inksAt(x, y)
|
||||
alpha = ink[4]
|
||||
if cmyk.alpha == 1 && alpha == 0 {
|
||||
continue
|
||||
}
|
||||
maximum := float64(65535)
|
||||
if cmyk.alpha == 1 {
|
||||
maximum = float64(alpha)
|
||||
}
|
||||
for i := range values {
|
||||
values[i] = min(1, float64(ink[i])/maximum)
|
||||
}
|
||||
} else if premultiplied {
|
||||
var pixel color.RGBA64
|
||||
if native, ok := source.(image.RGBA64Image); ok {
|
||||
pixel = native.RGBA64At(x, y)
|
||||
} else {
|
||||
pixel = color.RGBA64Model.Convert(source.At(x, y)).(color.RGBA64)
|
||||
}
|
||||
alpha = pixel.A
|
||||
if alpha == 0 {
|
||||
continue
|
||||
}
|
||||
for i, value := range [3]uint16{pixel.R, pixel.G, pixel.B} {
|
||||
values[i] = min(1, float64(value)/float64(alpha))
|
||||
}
|
||||
} else {
|
||||
pixel := NRGBA64At(source, x, y)
|
||||
alpha = pixel.A
|
||||
values = [4]float64{float64(pixel.R) / 65535, float64(pixel.G) / 65535, float64(pixel.B) / 65535}
|
||||
}
|
||||
if previousPixel.A != 0 && values == previous {
|
||||
pixel := previousPixel
|
||||
pixel.A = alpha
|
||||
result.SetNRGBA64(x, y, pixel)
|
||||
continue
|
||||
}
|
||||
previous = values
|
||||
if linearSRGB {
|
||||
for i := range 3 {
|
||||
if values[i] <= 0.04045 {
|
||||
values[i] /= 12.92
|
||||
} else {
|
||||
values[i] = math.Pow((values[i]+0.055)/1.055, 2.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
rgb, err := profile.SRGB(values[:components], intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var channels [3]uint16
|
||||
for i, value := range rgb {
|
||||
channels[i] = uint16(math.Round(max(0, min(1, value)) * 65535))
|
||||
}
|
||||
previousPixel = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535}
|
||||
if slot >= 0 {
|
||||
table[slot] = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535}
|
||||
}
|
||||
result.SetNRGBA64(x, y, color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: alpha})
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &colorImage{NRGBA64: result, source: source}, nil
|
||||
}
|
||||
|
||||
// newTIFFProfileTable 为离散颜色分配查找表,预乘颜色及小图保持逐像素变换
|
||||
// 入参: source 已解码且尺寸有效的图像
|
||||
// 返回: []color.NRGBA64 颜色表,Alpha非零表示已有结果,不适用时为nil
|
||||
func newTIFFProfileTable(source image.Image) []color.NRGBA64 {
|
||||
count := 0
|
||||
switch source := source.(type) {
|
||||
case *image.Gray:
|
||||
count = 256
|
||||
case *image.Gray16:
|
||||
count = 65536
|
||||
case *image.Paletted:
|
||||
count = len(source.Palette)
|
||||
case *planarTIFFImage:
|
||||
if source.alpha != 1 {
|
||||
if len(source.palette) != 0 {
|
||||
count = len(source.palette)
|
||||
} else if source.gray {
|
||||
count = 256
|
||||
if source.packed[0] != 0 {
|
||||
count = 1 << source.packed[0]
|
||||
} else if _, ok := source.planes[0].(*image.Gray16); ok {
|
||||
count = 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 || count >= source.Bounds().Dx()*source.Bounds().Dy() {
|
||||
return nil
|
||||
}
|
||||
return make([]color.NRGBA64, count)
|
||||
}
|
||||
|
||||
// tiffProfileSample 读取查找表索引与独立透明度,颜色键不包含透明度
|
||||
// 入参: source 适用查找表的图像, x 横坐标, y 纵坐标, count 颜色表长度
|
||||
// 返回: int 颜色索引, uint16 透明度
|
||||
func tiffProfileSample(source image.Image, x, y, count int) (int, uint16) {
|
||||
switch source := source.(type) {
|
||||
case *image.Gray:
|
||||
return int(source.GrayAt(x, y).Y), 65535
|
||||
case *image.Gray16:
|
||||
return int(source.Gray16At(x, y).Y), 65535
|
||||
case *image.Paletted:
|
||||
index := source.ColorIndexAt(x, y)
|
||||
_, _, _, alpha := source.Palette[index].RGBA()
|
||||
return int(index), uint16(alpha)
|
||||
case *planarTIFFImage:
|
||||
index := int(uint32(source.sampleAt(0, x, y)) * uint32(count-1) / 65535)
|
||||
alpha := uint16(65535)
|
||||
if source.alpha != 0 {
|
||||
alpha = source.sampleAt(1, x, y)
|
||||
}
|
||||
return index, alpha
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package threed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// DefaultBytes 限制默认解码数据规模,不包括调用方已有输入及运行时开销
|
||||
const DefaultBytes = 256 << 20
|
||||
|
||||
// U3DOptions 控制U3D解码规模,MaxDecodedBytes为0时采用256MiB上限
|
||||
type U3DOptions struct {
|
||||
MaxDecodedBytes int64
|
||||
}
|
||||
|
||||
// U3DModel 保存独立于PDF的U3D场景及动作数据,不执行动作或读取外部文件
|
||||
type U3DModel struct {
|
||||
Major, Minor int16
|
||||
DefinedUnits bool
|
||||
Units float64
|
||||
Meshes []U3DMesh
|
||||
Nodes []U3DNode
|
||||
Materials []U3DMaterial
|
||||
Shaders []U3DShader
|
||||
Lights []U3DLight
|
||||
Views []U3DViewResource
|
||||
Textures []U3DTexture
|
||||
Motions []U3DMotion
|
||||
Metadata []U3DMetadata
|
||||
}
|
||||
|
||||
// U3DMetadata 保留块所属名称与原始元数据,数据不与输入共享存储
|
||||
type U3DMetadata struct {
|
||||
Block uint32
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// U3DNode 保存组、模型、光源或视图节点,父节点矩阵按列存储
|
||||
type U3DNode struct {
|
||||
Name, Kind, Resource string
|
||||
Parents []U3DParent
|
||||
Visibility uint32
|
||||
Shaders [][]string
|
||||
LineShaders [][]string
|
||||
PointShaders [][]string
|
||||
View *U3DView
|
||||
BoneWeights []U3DBoneWeights
|
||||
Animations []U3DAnimation
|
||||
}
|
||||
|
||||
// U3DParent 保存父节点名称及局部到父节点的变换,空名称引用默认节点
|
||||
type U3DParent struct {
|
||||
Name string
|
||||
Transform [16]float32
|
||||
}
|
||||
|
||||
// U3DMaterial 保存材质参数,Attributes各位决定相应参数是否生效
|
||||
type U3DMaterial struct {
|
||||
Name string
|
||||
Attributes uint32
|
||||
Ambient, Diffuse, Specular, Emissive [3]float32
|
||||
Reflectivity, Opacity float32
|
||||
}
|
||||
|
||||
// U3DShader 保存材质与八个纹理通道,Textures中的空项表示未启用
|
||||
type U3DShader struct {
|
||||
Name, Material string
|
||||
Attributes, AlphaFunction, Blend uint32
|
||||
RenderPass uint32
|
||||
AlphaReference float32
|
||||
Textures [8]*U3DTextureLayer
|
||||
}
|
||||
|
||||
// U3DLight 保存环境、方向、点或聚光资源,Kind依次为0至3
|
||||
type U3DLight struct {
|
||||
Name string
|
||||
Attributes uint32
|
||||
Kind byte
|
||||
Color, Attenuation [3]float32
|
||||
SpotAngle, Intensity float32
|
||||
}
|
||||
|
||||
// u3dBlock 保存校验后的块边界,数据切片仅在解码期间引用输入
|
||||
type u3dBlock struct {
|
||||
kind uint32
|
||||
data, meta []byte
|
||||
}
|
||||
|
||||
// u3dDecoder 保存单次解码预算及场景索引,不跨调用缓存输入
|
||||
type u3dDecoder struct {
|
||||
ctx context.Context
|
||||
model U3DModel
|
||||
remaining uint64
|
||||
plain bool
|
||||
meshes map[string]*u3dMeshDeclaration
|
||||
meshOrder []string
|
||||
nodes map[string]int
|
||||
palettes map[uint32]map[string]bool
|
||||
textures map[string]int
|
||||
}
|
||||
|
||||
// DecodeU3D 按ECMA-363解码静态几何、骨架、场景节点及材质、纹理图像等资源
|
||||
// 保留动作轨道和动画修饰器,不在解码时求值;绘制能力由RenderU3D另行校验
|
||||
// 渐进网格尚未支持
|
||||
// 点集球面预测暂限不超过两个非对跖方向,不推测多方向合并次序
|
||||
// 入参: ctx 取消上下文, data U3D数据, options 解码限制
|
||||
// 返回: *U3DModel 独立场景数据, error 格式、能力、预算或取消错误
|
||||
func DecodeU3D(ctx context.Context, data []byte, options U3DOptions) (*U3DModel, error) {
|
||||
if ctx == nil || options.MaxDecodedBytes < 0 {
|
||||
return nil, fmt.Errorf("invalid U3D decode options")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := options.MaxDecodedBytes
|
||||
if limit == 0 {
|
||||
limit = DefaultBytes
|
||||
}
|
||||
d := u3dDecoder{ctx: ctx, remaining: uint64(limit), meshes: make(map[string]*u3dMeshDeclaration), nodes: make(map[string]int), palettes: make(map[uint32]map[string]bool)}
|
||||
header, next, err := u3dReadBlock(data, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.kind != 0x00443355 {
|
||||
return nil, fmt.Errorf("invalid U3D file header")
|
||||
}
|
||||
r := u3dValues{data: header.data}
|
||||
version, profile := r.u32(), r.u32()
|
||||
d.model.Major, d.model.Minor = int16(version), int16(version>>16)
|
||||
if d.model.Major > 0 || profile & ^uint32(14) != 0 {
|
||||
return nil, fmt.Errorf("unsupported U3D version or profile")
|
||||
}
|
||||
declarationEnd := uint64(r.u32())
|
||||
fileSize := uint64(r.u32())
|
||||
fileSize |= uint64(r.u32()) << 32
|
||||
if r.u32() != 106 {
|
||||
return nil, fmt.Errorf("unsupported U3D character encoding")
|
||||
}
|
||||
d.plain = profile&4 != 0
|
||||
d.model.DefinedUnits, d.model.Units = profile&8 != 0, 1
|
||||
if d.model.DefinedUnits {
|
||||
unitBytes := r.take(8)
|
||||
if r.err == nil {
|
||||
d.model.Units = math.Float64frombits(binary.LittleEndian.Uint64(unitBytes))
|
||||
if !finite(d.model.Units) || d.model.Units <= 0 {
|
||||
return nil, fmt.Errorf("invalid U3D units")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := r.done(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fileSize != uint64(len(data)) || declarationEnd < uint64(next) || declarationEnd > fileSize || declarationEnd%4 != 0 {
|
||||
return nil, fmt.Errorf("invalid U3D file size")
|
||||
}
|
||||
if err := d.metadata(header, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priority := uint32(0)
|
||||
for offset := next; offset < len(data); {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, end, err := u3dReadBlock(data, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("U3D block at %d: %w", offset, err)
|
||||
}
|
||||
continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c || block.kind == 0xffffff3e || block.kind == 0xffffff3f || block.kind == 0xffffff5c
|
||||
if uint64(offset) < declarationEnd && (uint64(end) > declarationEnd || continuation) || uint64(offset) >= declarationEnd && !continuation {
|
||||
return nil, fmt.Errorf("invalid U3D declaration boundary at %d", offset)
|
||||
}
|
||||
if block.kind == 0xffffff15 {
|
||||
r := u3dValues{data: block.data}
|
||||
value := r.u32()
|
||||
if err := r.done(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == 0 || value < priority || value > 0x7fffffff {
|
||||
return nil, fmt.Errorf("invalid U3D block priority")
|
||||
}
|
||||
priority = value
|
||||
err = d.metadata(block, "")
|
||||
} else if block.kind == 0xffffff14 {
|
||||
err = d.chain(block)
|
||||
} else {
|
||||
_, err = d.block(block, "", -1, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("U3D block %#x at %d: %w", block.kind, offset, err)
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
for _, name := range d.meshOrder {
|
||||
mesh := d.meshes[name]
|
||||
if !mesh.decoded && mesh.counts[1] != 0 {
|
||||
return nil, fmt.Errorf("missing U3D base mesh %q", name)
|
||||
}
|
||||
d.model.Meshes = append(d.model.Meshes, mesh.mesh)
|
||||
}
|
||||
if err := d.checkTextures(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.checkParents(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.checkBoneWeights(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d.model, nil
|
||||
}
|
||||
|
||||
// u3dReadBlock 按64位长度计算校验块数据与零填充,避免长度溢出
|
||||
// 入参: data 块序列, offset 块起点
|
||||
// 返回: u3dBlock 块, int 下一块起点, error 边界或填充错误
|
||||
func u3dReadBlock(data []byte, offset int) (u3dBlock, int, error) {
|
||||
if offset < 0 || offset > len(data) || len(data)-offset < 12 {
|
||||
return u3dBlock{}, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
header := data[offset : offset+12]
|
||||
size, meta := uint64(binary.LittleEndian.Uint32(header[4:])), uint64(binary.LittleEndian.Uint32(header[8:]))
|
||||
start := uint64(offset) + 12
|
||||
metaStart := start + ((size + 3) &^ 3)
|
||||
end := metaStart + ((meta + 3) &^ 3)
|
||||
if end > uint64(len(data)) {
|
||||
return u3dBlock{}, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
for _, padding := range [][]byte{data[start+size : metaStart], data[metaStart+meta : end]} {
|
||||
for _, value := range padding {
|
||||
if value != 0 {
|
||||
return u3dBlock{}, 0, fmt.Errorf("invalid U3D padding")
|
||||
}
|
||||
}
|
||||
}
|
||||
return u3dBlock{binary.LittleEndian.Uint32(header), data[start : start+size], data[metaStart : metaStart+meta]}, int(end), nil
|
||||
}
|
||||
|
||||
// reserve 在分配解码数组前检查总预算及取消状态
|
||||
// 入参: count 元素数量, size 元素规模上界
|
||||
// 返回: error 预算或取消错误
|
||||
func (d *u3dDecoder) reserve(count, size uint64) error {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if size == 0 || count > d.remaining/size || count > uint64(^uint(0)>>1)/size {
|
||||
return fmt.Errorf("U3D decoded data exceeds limit")
|
||||
}
|
||||
d.remaining -= count * size
|
||||
return nil
|
||||
}
|
||||
|
||||
// metadata 校验元数据结构并保留原始数据,不解释或执行其中的动作
|
||||
// 入参: block 数据块, name 所属资源名
|
||||
// 返回: error 结构、预算或取消错误
|
||||
func (d *u3dDecoder) metadata(block u3dBlock, name string) error {
|
||||
if len(block.meta) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := d.reserve(uint64(len(block.meta))+128, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
r := u3dValues{data: block.meta}
|
||||
count := r.u32()
|
||||
if uint64(count) > uint64(len(block.meta))/8 {
|
||||
return fmt.Errorf("invalid U3D metadata count")
|
||||
}
|
||||
for range count {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
flags := r.u32()
|
||||
r.text()
|
||||
if flags&1 != 0 {
|
||||
size := r.u32()
|
||||
if uint64(size) > uint64(len(r.data)-r.pos) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
r.take(int(size))
|
||||
} else {
|
||||
r.text()
|
||||
}
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
}
|
||||
if err := r.done(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.model.Metadata = append(d.model.Metadata, U3DMetadata{block.kind, name, bytes.Clone(block.meta)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// chain 解析单层修饰链并校验名称、链类型与修饰顺序
|
||||
// 入参: block 修饰链块
|
||||
// 返回: error 结构、能力或资源错误
|
||||
func (d *u3dDecoder) chain(block u3dBlock) error {
|
||||
if err := d.reserve(256, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
r := u3dValues{data: block.data}
|
||||
name, kind, flags := r.text(), r.u32(), r.u32()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("cannot redefine U3D default resource")
|
||||
}
|
||||
if kind > 2 || flags & ^uint32(3) != 0 {
|
||||
return fmt.Errorf("invalid U3D modifier chain")
|
||||
}
|
||||
if flags&1 != 0 {
|
||||
for range 4 {
|
||||
r.f32()
|
||||
}
|
||||
}
|
||||
if flags&2 != 0 {
|
||||
for range 6 {
|
||||
r.f32()
|
||||
}
|
||||
}
|
||||
for r.pos%4 != 0 && r.err == nil {
|
||||
if r.u8() != 0 {
|
||||
return fmt.Errorf("invalid U3D chain padding")
|
||||
}
|
||||
}
|
||||
count := r.u32()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if uint64(count) > uint64(len(r.data)-r.pos)/12 {
|
||||
return fmt.Errorf("invalid U3D modifier count")
|
||||
}
|
||||
for index := uint32(0); index < count; index++ {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
nested, next, err := u3dReadBlock(r.data, r.pos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actual, err := d.block(nested, name, int(kind), index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual != name {
|
||||
return fmt.Errorf("U3D modifier name mismatch")
|
||||
}
|
||||
r.pos = next
|
||||
}
|
||||
if err := r.done(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.metadata(block, name)
|
||||
}
|
||||
|
||||
// block 解析支持的静态场景块,不丢弃未实现的修饰器或几何
|
||||
// 入参: block 数据块, owner 所属链名, chain 链类别,负值表示独立块, index 修饰位置
|
||||
// 返回: string 资源名, error 格式、能力或资源错误
|
||||
func (d *u3dDecoder) block(block u3dBlock, owner string, chain int, index uint32) (string, error) {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := d.reserve(uint64(len(block.data))+256, 1); err != nil {
|
||||
return "", err
|
||||
}
|
||||
r := u3dValues{data: block.data}
|
||||
name := r.text()
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("cannot redefine U3D default resource")
|
||||
}
|
||||
var err error
|
||||
switch block.kind {
|
||||
case 0xffffff21, 0xffffff22, 0xffffff23, 0xffffff24:
|
||||
if chain >= 0 && (chain != 0 || index != 0) {
|
||||
return name, fmt.Errorf("invalid U3D node chain")
|
||||
}
|
||||
err = d.node(&r, block.kind, name)
|
||||
case 0xffffff31:
|
||||
if chain >= 0 && (chain != 1 || index != 0) {
|
||||
return name, fmt.Errorf("invalid U3D mesh chain")
|
||||
}
|
||||
err = d.meshDeclaration(&r, name)
|
||||
case 0xffffff3b:
|
||||
if chain >= 0 {
|
||||
return name, fmt.Errorf("invalid U3D mesh continuation location")
|
||||
}
|
||||
err = d.baseMesh(&r, name)
|
||||
case 0xffffff36, 0xffffff37:
|
||||
if chain >= 0 && (chain != 1 || index != 0) {
|
||||
return name, fmt.Errorf("invalid U3D primitive chain")
|
||||
}
|
||||
err = d.primitiveDeclaration(&r, name, block.kind == 0xffffff36)
|
||||
case 0xffffff3e, 0xffffff3f:
|
||||
if chain >= 0 {
|
||||
return name, fmt.Errorf("invalid U3D primitive continuation location")
|
||||
}
|
||||
err = d.primitiveContinuation(&r, name, block.kind == 0xffffff3e)
|
||||
case 0xffffff43, 0xffffff44:
|
||||
if (chain != 0 && chain != 1) || index == 0 || owner != name || r.u32() != index {
|
||||
return name, fmt.Errorf("invalid U3D animation or bone weight modifier chain")
|
||||
}
|
||||
if _, exists := d.nodes[name]; chain == 0 && !exists || chain == 1 && d.meshes[name] == nil {
|
||||
return name, fmt.Errorf("missing U3D animation or bone weight modifier target")
|
||||
}
|
||||
if block.kind == 0xffffff43 {
|
||||
err = d.animation(&r, name, chain, index)
|
||||
} else {
|
||||
err = d.boneWeights(&r, name, chain, index)
|
||||
}
|
||||
case 0xffffff45:
|
||||
if chain != 0 || index == 0 || owner != name || r.u32() != index {
|
||||
return name, fmt.Errorf("invalid U3D shading modifier chain")
|
||||
}
|
||||
err = d.shading(&r, name)
|
||||
case 0xffffff51, 0xffffff52, 0xffffff53, 0xffffff54, 0xffffff56:
|
||||
if chain >= 0 {
|
||||
return name, fmt.Errorf("invalid U3D palette resource location")
|
||||
}
|
||||
err = d.resource(&r, block.kind, name)
|
||||
case 0xffffff55:
|
||||
if chain >= 0 && (chain != 2 || index != 0) {
|
||||
return name, fmt.Errorf("invalid U3D texture chain")
|
||||
}
|
||||
err = d.textureDeclaration(&r, name)
|
||||
case 0xffffff5c:
|
||||
if chain >= 0 {
|
||||
return name, fmt.Errorf("invalid U3D texture continuation location")
|
||||
}
|
||||
err = d.textureContinuation(&r, name)
|
||||
default:
|
||||
return name, fmt.Errorf("unsupported U3D block %#x", block.kind)
|
||||
}
|
||||
if err != nil {
|
||||
return name, err
|
||||
}
|
||||
if err := r.done(); err != nil {
|
||||
return name, err
|
||||
}
|
||||
return name, d.metadata(block, name)
|
||||
}
|
||||
|
||||
// node 读取节点与父变换,允许引用后续声明的父节点
|
||||
// 入参: r 字段读取器, kind 节点类型, name 节点名
|
||||
// 返回: error 结构或预算错误
|
||||
func (d *u3dDecoder) node(r *u3dValues, kind uint32, name string) error {
|
||||
if _, exists := d.nodes[name]; exists {
|
||||
return fmt.Errorf("duplicate U3D node %q", name)
|
||||
}
|
||||
node := U3DNode{Name: name, Kind: "Group"}
|
||||
count := r.u32()
|
||||
if uint64(count) > uint64(len(r.data)-r.pos)/66 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if err := d.reserve(uint64(count), 96); err != nil {
|
||||
return err
|
||||
}
|
||||
node.Parents = make([]U3DParent, int(count))
|
||||
for index := range node.Parents {
|
||||
if index%256 == 0 {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
node.Parents[index].Name = r.text()
|
||||
for element := range node.Parents[index].Transform {
|
||||
node.Parents[index].Transform[element] = r.f32()
|
||||
}
|
||||
}
|
||||
if kind == 0xffffff22 {
|
||||
node.Kind, node.Resource, node.Visibility = "Model", r.text(), r.u32()
|
||||
if node.Visibility > 3 {
|
||||
return fmt.Errorf("invalid U3D model visibility")
|
||||
}
|
||||
} else if kind == 0xffffff23 {
|
||||
node.Kind, node.Resource = "Light", r.text()
|
||||
} else if kind == 0xffffff24 {
|
||||
node.Kind, node.Resource = "View", r.text()
|
||||
var err error
|
||||
node.View, err = d.view(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
d.nodes[name] = len(d.model.Nodes)
|
||||
d.model.Nodes = append(d.model.Nodes, node)
|
||||
return r.err
|
||||
}
|
||||
|
||||
// shading 分别读取网格、线集和点集着色列表,保留列表内各着色器的先后次序
|
||||
// 入参: r 字段读取器, name 目标节点名
|
||||
// 返回: error 结构、能力或预算错误
|
||||
func (d *u3dDecoder) shading(r *u3dValues, name string) error {
|
||||
index, exists := d.nodes[name]
|
||||
if !exists || d.model.Nodes[index].Kind != "Model" {
|
||||
return fmt.Errorf("missing U3D shading target")
|
||||
}
|
||||
flags := r.u32()
|
||||
if flags & ^uint32(7) != 0 {
|
||||
return fmt.Errorf("unsupported U3D shading modifier target")
|
||||
}
|
||||
count := r.u32()
|
||||
if uint64(count) > uint64(len(r.data)-r.pos)/4 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if err := d.reserve(uint64(count), 24); err != nil {
|
||||
return err
|
||||
}
|
||||
lists := make([][]string, int(count))
|
||||
for index := range lists {
|
||||
n := r.u32()
|
||||
if uint64(n) > uint64(len(r.data)-r.pos)/2 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if err := d.reserve(uint64(n), 16); err != nil {
|
||||
return err
|
||||
}
|
||||
lists[index] = make([]string, int(n))
|
||||
for j := range lists[index] {
|
||||
if j%256 == 0 {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
lists[index][j] = r.text()
|
||||
}
|
||||
}
|
||||
if flags&1 != 0 {
|
||||
d.model.Nodes[index].Shaders = lists
|
||||
}
|
||||
if flags&2 != 0 {
|
||||
d.model.Nodes[index].LineShaders = lists
|
||||
}
|
||||
if flags&4 != 0 {
|
||||
d.model.Nodes[index].PointShaders = lists
|
||||
}
|
||||
return r.err
|
||||
}
|
||||
|
||||
// resource 读取独立材质、着色器、光源、视图和动作资源
|
||||
// 入参: r 字段读取器, kind 资源类型, name 资源名
|
||||
// 返回: error 结构或能力错误
|
||||
func (d *u3dDecoder) resource(r *u3dValues, kind uint32, name string) error {
|
||||
palette := d.palettes[kind]
|
||||
if palette == nil {
|
||||
palette = make(map[string]bool)
|
||||
d.palettes[kind] = palette
|
||||
}
|
||||
if palette[name] {
|
||||
return fmt.Errorf("duplicate U3D resource %q", name)
|
||||
}
|
||||
palette[name] = true
|
||||
switch kind {
|
||||
case 0xffffff56:
|
||||
return d.motion(r, name)
|
||||
case 0xffffff52:
|
||||
return d.viewResource(r, name)
|
||||
case 0xffffff54:
|
||||
material := U3DMaterial{Name: name, Attributes: r.u32()}
|
||||
if material.Attributes & ^uint32(63) != 0 {
|
||||
return fmt.Errorf("invalid U3D material attributes")
|
||||
}
|
||||
for _, color := range []*[3]float32{&material.Ambient, &material.Diffuse, &material.Specular, &material.Emissive} {
|
||||
for index := range color {
|
||||
color[index] = r.f32()
|
||||
}
|
||||
}
|
||||
material.Reflectivity, material.Opacity = r.f32(), r.f32()
|
||||
d.model.Materials = append(d.model.Materials, material)
|
||||
case 0xffffff53:
|
||||
shader := U3DShader{Name: name, Attributes: r.u32(), AlphaReference: r.f32(), AlphaFunction: r.u32(), Blend: r.u32(), RenderPass: r.u32()}
|
||||
channels, alpha := r.u32(), r.u32()
|
||||
shader.Material = r.text()
|
||||
if shader.Attributes & ^uint32(7) != 0 || shader.AlphaFunction < 0x610 || shader.AlphaFunction > 0x617 || shader.Blend < 0x604 || shader.Blend > 0x607 || channels & ^uint32(255) != 0 || alpha & ^channels != 0 {
|
||||
return fmt.Errorf("invalid U3D shader parameters")
|
||||
}
|
||||
for channel := range shader.Textures {
|
||||
if channels&(1<<channel) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := d.reserve(1, 192); err != nil {
|
||||
return err
|
||||
}
|
||||
layer := &U3DTextureLayer{Texture: r.text(), Intensity: r.f32(), Blend: r.u8(), BlendSource: r.u8(), BlendConstant: r.f32(), Mode: r.u8(), Alpha: alpha&(1<<channel) != 0}
|
||||
for i := range layer.Transform {
|
||||
layer.Transform[i] = r.f32()
|
||||
}
|
||||
for i := range layer.WrapTransform {
|
||||
layer.WrapTransform[i] = r.f32()
|
||||
}
|
||||
layer.Repeat = r.u8()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if layer.Blend > 3 || layer.BlendSource > 1 || layer.Mode > 4 || layer.Repeat > 3 {
|
||||
return fmt.Errorf("invalid U3D texture layer")
|
||||
}
|
||||
shader.Textures[channel] = layer
|
||||
}
|
||||
d.model.Shaders = append(d.model.Shaders, shader)
|
||||
case 0xffffff51:
|
||||
light := U3DLight{Name: name, Attributes: r.u32(), Kind: r.u8()}
|
||||
if light.Attributes & ^uint32(7) != 0 || light.Kind > 3 {
|
||||
return fmt.Errorf("invalid U3D light parameters")
|
||||
}
|
||||
for index := range light.Color {
|
||||
light.Color[index] = r.f32()
|
||||
}
|
||||
if r.f32() != 1 {
|
||||
return fmt.Errorf("invalid U3D light reserved parameter")
|
||||
}
|
||||
for index := range light.Attenuation {
|
||||
light.Attenuation[index] = r.f32()
|
||||
}
|
||||
light.SpotAngle, light.Intensity = r.f32(), r.f32()
|
||||
d.model.Lights = append(d.model.Lights, light)
|
||||
}
|
||||
return r.err
|
||||
}
|
||||
|
||||
// checkParents 迭代检查场景父子环路,不递归展开多父节点的实例组合
|
||||
// 返回: error 环路或取消错误
|
||||
func (d *u3dDecoder) checkParents() error {
|
||||
state := make([]byte, len(d.model.Nodes))
|
||||
type frame struct{ node, parent int }
|
||||
var stack []frame
|
||||
for index := range d.model.Nodes {
|
||||
if state[index] != 0 {
|
||||
continue
|
||||
}
|
||||
stack = append(stack[:0], frame{node: index})
|
||||
state[index] = 1
|
||||
for len(stack) != 0 {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
current := &stack[len(stack)-1]
|
||||
parents := d.model.Nodes[current.node].Parents
|
||||
if current.parent == len(parents) {
|
||||
state[current.node] = 2
|
||||
stack = stack[:len(stack)-1]
|
||||
continue
|
||||
}
|
||||
parent, exists := d.nodes[parents[current.parent].Name]
|
||||
current.parent++
|
||||
if !exists || state[parent] == 2 {
|
||||
continue
|
||||
}
|
||||
if state[parent] == 1 {
|
||||
return fmt.Errorf("cyclic U3D node parents")
|
||||
}
|
||||
state[parent] = 1
|
||||
stack = append(stack, frame{node: parent})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package threed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// U3DAnimation 保存ECMA-363第9.7.3节的动画修饰器,Index为链内位置
|
||||
// Attributes的1、2、4、8分别表示启动、锁定根骨骼、单轨播放和动作间平滑过渡
|
||||
// TimeScale为整体时间倍率,BlendTime以毫秒计;Motions为空时使用无轨道的默认动作
|
||||
type U3DAnimation struct {
|
||||
Index, Attributes uint32
|
||||
TimeScale, BlendTime float32
|
||||
Motions []U3DAnimationMotion
|
||||
}
|
||||
|
||||
// U3DAnimationMotion 保存动画中的动作引用,空名称引用默认动作
|
||||
// Attributes的1、2分别表示循环和同步结束,TimeOffset以毫秒计,TimeScale为动作时间倍率
|
||||
type U3DAnimationMotion struct {
|
||||
Name string
|
||||
Attributes uint32
|
||||
TimeOffset, TimeScale float32
|
||||
}
|
||||
|
||||
// animation 读取动画参数及有序动作引用,不在解码时调度或求值
|
||||
// 入参: r 字段读取器, name 所属链名, chain 链类型, index 链内位置
|
||||
// 返回: error 结构、参数、预算或取消错误
|
||||
func (d *u3dDecoder) animation(r *u3dValues, name string, chain int, index uint32) error {
|
||||
animation := U3DAnimation{Index: index, Attributes: r.u32(), TimeScale: r.f32()}
|
||||
count := r.u32()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if len(r.data)-r.pos < 4 || uint64(count) > uint64(len(r.data)-r.pos-4)/14 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if err := d.reserve(uint64(count), 32); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
animation.Motions = make([]U3DAnimationMotion, int(count))
|
||||
}
|
||||
for i := range animation.Motions {
|
||||
if i%256 == 0 {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
motion := &animation.Motions[i]
|
||||
motion.Name, motion.Attributes = r.text(), r.u32()
|
||||
motion.TimeOffset, motion.TimeScale = r.f32(), r.f32()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
}
|
||||
animation.BlendTime = r.f32()
|
||||
if err := r.done(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := animation.validate(d.ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if chain == 1 {
|
||||
mesh := &d.meshes[name].mesh
|
||||
mesh.Animations = append(mesh.Animations, animation)
|
||||
} else {
|
||||
node := &d.model.Nodes[d.nodes[name]]
|
||||
node.Animations = append(node.Animations, animation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate 校验动画属性及有限数值,保留零倍率与反向播放倍率
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 参数或取消错误
|
||||
func (animation U3DAnimation) validate(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if animation.Index == 0 || animation.Attributes & ^uint32(15) != 0 || !finite(float64(animation.TimeScale)) || !finite(float64(animation.BlendTime)) || animation.BlendTime < 0 {
|
||||
return fmt.Errorf("invalid U3D animation parameters")
|
||||
}
|
||||
for i, motion := range animation.Motions {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if motion.Attributes & ^uint32(3) != 0 || !finite(float64(motion.TimeOffset)) || !finite(float64(motion.TimeScale)) {
|
||||
return fmt.Errorf("invalid U3D animation motion parameters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAnimations 校验全部动作引用,仅对接入场景的修饰器检查求值能力
|
||||
// 返回: error 引用、能力或取消错误
|
||||
func (r *Renderer) checkAnimations() error {
|
||||
check := func(animations []U3DAnimation, evaluate bool) error {
|
||||
for _, animation := range animations {
|
||||
if err := animation.validate(r.ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, motion := range animation.Motions {
|
||||
if i%256 == 0 {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if motion.Name == "" {
|
||||
continue
|
||||
}
|
||||
index, exists := r.motionIndex[motion.Name]
|
||||
if !exists {
|
||||
return fmt.Errorf("missing U3D animation motion %q", motion.Name)
|
||||
}
|
||||
if evaluate && len(animation.Motions) > 1 && len(r.model.Motions[index].Tracks) != 0 {
|
||||
return fmt.Errorf("unsupported U3D animation evaluation for motion %q", motion.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var active []bool
|
||||
if r.connected != nil {
|
||||
if err := r.Reserve(uint64(len(r.model.Meshes)), 1); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { r.remaining += uint64(len(active)) }()
|
||||
active = make([]bool, len(r.model.Meshes))
|
||||
for i, node := range r.model.Nodes {
|
||||
if i%256 == 0 {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if r.connected[i] && node.Kind == "Model" {
|
||||
if mesh, exists := r.meshes[node.Resource]; exists {
|
||||
active[mesh] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range r.model.Meshes {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := check(r.model.Meshes[i].Animations, active == nil || active[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range r.model.Nodes {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := check(r.model.Nodes[i].Animations, r.connected == nil || r.connected[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.ctx.Err()
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -46,6 +46,27 @@ type u3dHistogram struct {
|
||||
total uint32
|
||||
}
|
||||
|
||||
// finish 校验压缩块的冲刷字段及结束位置
|
||||
// 返回: error 截断、冲刷或多余数据错误
|
||||
func (r *u3dBits) finish() error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if r.pos > uint64(len(r.data))*8 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if r.u32() != 0 {
|
||||
return fmt.Errorf("invalid U3D compression flush")
|
||||
}
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if (r.pos-16+7)/8 != uint64(len(r.data)) {
|
||||
return fmt.Errorf("unexpected U3D compressed block data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// take 读取定长字段,失败后不继续推进位置
|
||||
// 入参: size 字节数
|
||||
// 返回: []byte 字段数据,失败时为空
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// DepthRange 计算可见模型几何的相机深度范围,不为自动裁剪复制顶点
|
||||
// 返回: float64 最近深度, float64 最远深度, bool 是否含几何, error 几何或取消错误
|
||||
func (r *Renderer) DepthRange() (float64, float64, bool, error) {
|
||||
minimum, maximum := math.Inf(1), math.Inf(-1)
|
||||
for i, instance := range r.scene {
|
||||
if i%256 == 0 {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
}
|
||||
node := r.model.Nodes[instance.node]
|
||||
index, ok := r.meshes[node.Resource]
|
||||
if node.Kind != "Model" || instance.modelVisibility(&node) == 0 || instance.opacity == 0 || !ok {
|
||||
continue
|
||||
}
|
||||
mesh := &r.model.Meshes[index]
|
||||
matrix := r.toCamera.multiply(instance.world)
|
||||
for j := range len(mesh.Faces) + len(mesh.Lines) + len(mesh.Points) {
|
||||
_, corners := mesh.primitive(j)
|
||||
if j%256 == 0 {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
}
|
||||
for _, corner := range corners {
|
||||
if uint64(corner.Position) >= uint64(len(mesh.Positions)) {
|
||||
return 0, 0, false, fmt.Errorf("invalid U3D position index")
|
||||
}
|
||||
p := mesh.Positions[corner.Position]
|
||||
z := matrix[2]*float64(p[0]) + matrix[6]*float64(p[1]) + matrix[10]*float64(p[2]) + matrix[14]
|
||||
if !finite(z) {
|
||||
return 0, 0, false, fmt.Errorf("invalid U3D camera depth")
|
||||
}
|
||||
minimum, maximum = min(minimum, z), max(maximum, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, section := range r.sections {
|
||||
if i%256 == 0 {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
}
|
||||
if section.opacity > 0 {
|
||||
for _, corner := range section.corners {
|
||||
minimum, maximum = min(minimum, corner.point[2]), max(maximum, corner.point[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsInf(minimum, 1) {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
return minimum, maximum, true, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package threed
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// finite 判断数值是否有限
|
||||
// 入参: value 数值
|
||||
// 返回: bool 是否有限
|
||||
func finite(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -47,7 +47,7 @@ type u3dRenderStyle struct {
|
||||
|
||||
// collectLights 收集本轮根节点下启用的光源实例
|
||||
// 返回: error 参数、预算或取消错误
|
||||
func (r *u3dRender) collectLights() error {
|
||||
func (r *Renderer) collectLights() error {
|
||||
if r.lighting != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -63,10 +63,13 @@ func (r *u3dRender) collectLights() error {
|
||||
continue
|
||||
}
|
||||
j, ok := r.lightIndex[node.Resource]
|
||||
if !ok {
|
||||
if !ok && node.Resource != "" {
|
||||
continue
|
||||
}
|
||||
light := r.model.Lights[j]
|
||||
light := U3DLight{Attributes: 1, Color: [3]float32{.75, .75, .75}}
|
||||
if ok {
|
||||
light = r.model.Lights[j]
|
||||
}
|
||||
if light.Attributes&1 == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -98,7 +101,7 @@ func (r *u3dRender) collectLights() error {
|
||||
}
|
||||
if len(r.lights) == cap(r.lights) {
|
||||
capacity := max(8, cap(r.lights)*2)
|
||||
if err := r.reserve(uint64(capacity), 128); err != nil {
|
||||
if err := r.Reserve(uint64(capacity), 128); err != nil {
|
||||
return err
|
||||
}
|
||||
values := make([]u3dRenderLight, len(r.lights), capacity)
|
||||
@@ -114,7 +117,7 @@ func (r *u3dRender) collectLights() error {
|
||||
// overrideLighting 校验并建立替代光源,不依赖模型光源节点
|
||||
// 入参: lighting 灯光覆盖,空值保留模型灯光
|
||||
// 返回: error 参数、预算或取消错误
|
||||
func (r *u3dRender) overrideLighting(lighting *U3DLighting) error {
|
||||
func (r *Renderer) overrideLighting(lighting *U3DLighting) error {
|
||||
if lighting == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -123,7 +126,7 @@ func (r *u3dRender) overrideLighting(lighting *U3DLighting) error {
|
||||
return fmt.Errorf("invalid U3D diffuse offset")
|
||||
}
|
||||
}
|
||||
if err := r.reserve(uint64(len(lighting.Lights)), 128); err != nil {
|
||||
if err := r.Reserve(uint64(len(lighting.Lights)), 128); err != nil {
|
||||
return err
|
||||
}
|
||||
r.lights = make([]u3dRenderLight, len(lighting.Lights))
|
||||
@@ -152,11 +155,21 @@ func (r *u3dRender) overrideLighting(lighting *U3DLighting) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// style 解析着色器与材质,未启用的材质项使用固定默认值
|
||||
// shaderPass 返回着色器的绘制轮次位图,默认着色器仅参与第一轮
|
||||
// 入参: name 着色器名
|
||||
// 返回: uint32 绘制轮次位图
|
||||
func (r *Renderer) shaderPass(name string) uint32 {
|
||||
if index, exists := r.shaders[name]; exists {
|
||||
return r.model.Shaders[index].RenderPass
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// style 解析着色器与材质,缺失资源采用ECMA-363第8.4节的默认资源
|
||||
// 入参: name 着色器名
|
||||
// 返回: u3dRenderStyle 有效绘制参数, error 参数错误
|
||||
func (r *u3dRender) style(name string) (u3dRenderStyle, error) {
|
||||
style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, AlphaFunction: 0x617, RenderPass: math.MaxUint32}, ambient: [3]float64{.2, .2, .2}, diffuse: [3]float64{.8, .8, .8}, opacity: 1}
|
||||
func (r *Renderer) style(name string) (u3dRenderStyle, error) {
|
||||
style := u3dRenderStyle{shader: U3DShader{Attributes: 1, Blend: 0x606, AlphaFunction: 0x617, RenderPass: 1}, ambient: [3]float64{.75, .75, .75}, opacity: 1}
|
||||
if index, ok := r.shaders[name]; ok {
|
||||
style.shader = r.model.Shaders[index]
|
||||
}
|
||||
@@ -166,6 +179,7 @@ func (r *u3dRender) style(name string) (u3dRenderStyle, error) {
|
||||
}
|
||||
if index, ok := r.materials[shader.Material]; ok {
|
||||
material := r.model.Materials[index]
|
||||
style.ambient, style.diffuse = [3]float64{.2, .2, .2}, [3]float64{.8, .8, .8}
|
||||
if material.Attributes > 63 {
|
||||
return style, fmt.Errorf("invalid U3D render material")
|
||||
}
|
||||
@@ -201,7 +215,7 @@ func (r *u3dRender) style(name string) (u3dRenderStyle, error) {
|
||||
// shade 按世界坐标计算逐顶点漫反射、镜面与环境光
|
||||
// 入参: position 顶点, normal 法线, style 材质, diffuse 顶点漫反射, specular 顶点镜面颜色
|
||||
// 返回: [4]float64 直通RGBA, error 参数或取消错误
|
||||
func (r *u3dRender) shade(position, normal u3dVector, style u3dRenderStyle, diffuse, specular *[4]float32) ([4]float64, error) {
|
||||
func (r *Renderer) shade(position, normal u3dVector, style u3dRenderStyle, diffuse, specular *[4]float32) ([4]float64, error) {
|
||||
color := [4]float64{style.diffuse[0], style.diffuse[1], style.diffuse[2], style.opacity}
|
||||
if style.shader.Attributes&4 != 0 {
|
||||
if diffuse != nil {
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// drawPrimitives 绘制作者点集及线集,独立着色且不参与面的背向剔除
|
||||
// 入参: instance 场景实例, node 模型节点, mesh 属性数组, positions 世界位置, normals 世界法线, pass 绘制轮次, passIndex 轮次索引
|
||||
// 返回: error 几何、资源或取消错误
|
||||
func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3DMesh, positions, normals []u3dVector, pass U3DViewPass, passIndex int) error {
|
||||
func (r *Renderer) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3DMesh, positions, normals []u3dVector, pass U3DViewPass, passIndex int) error {
|
||||
if r.depthOnly {
|
||||
return nil
|
||||
}
|
||||
@@ -68,15 +68,13 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: math.MaxUint32}}
|
||||
style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: r.shaderPass(name)}}
|
||||
var err error
|
||||
if shaded {
|
||||
style, err = r.style(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if index, ok := r.shaders[name]; ok {
|
||||
style.shader.RenderPass = r.model.Shaders[index].RenderPass
|
||||
}
|
||||
if style.shader.RenderPass&(1<<passIndex) == 0 {
|
||||
continue
|
||||
@@ -84,15 +82,22 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
|
||||
if instance.opacity >= 0 || r.opacity >= 0 {
|
||||
style.shader.Blend = 0x606
|
||||
}
|
||||
textures, err := r.shaderTextures(style.shader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, corner := range corners {
|
||||
var normal u3dVector
|
||||
if shaded && !mesh.ExcludeNormals {
|
||||
normal = normals[corner.Normal]
|
||||
}
|
||||
if err := textures.coordinates(&vertices[i], mesh, shading, corner, normal); err != nil {
|
||||
return err
|
||||
}
|
||||
vertices[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
|
||||
if !shaded {
|
||||
continue
|
||||
}
|
||||
var normal u3dVector
|
||||
if !mesh.ExcludeNormals {
|
||||
normal = normals[corner.Normal]
|
||||
}
|
||||
var diffuse, specular *[4]float32
|
||||
if shading.Attributes&1 != 0 {
|
||||
diffuse = &mesh.Diffuse[corner.Diffuse]
|
||||
@@ -106,8 +111,9 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
|
||||
}
|
||||
if instance.opacity >= 0 {
|
||||
vertices[i].color[3] = instance.opacity
|
||||
vertices[i].opacityOverride = true
|
||||
}
|
||||
if r.opacity >= 0 {
|
||||
if r.opacity >= 0 && textures == nil {
|
||||
vertices[i].color[3] *= r.opacity
|
||||
}
|
||||
}
|
||||
@@ -152,7 +158,7 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
|
||||
// lineIntersection 绘制作者线段与剖切面的交点,不绘制共面线段
|
||||
// 入参: line 相机空间线段, index 剖切面索引
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) lineIntersection(line [2]u3dVertex, index int) error {
|
||||
func (r *Renderer) lineIntersection(line [2]u3dVertex, index int) error {
|
||||
section := r.sections[index]
|
||||
a, b := u3dPlaneDistance(section.plane, line[0].point), u3dPlaneDistance(section.plane, line[1].point)
|
||||
if !finite(a) || !finite(b) {
|
||||
@@ -195,7 +201,7 @@ func (r *u3dRender) lineIntersection(line [2]u3dVertex, index int) error {
|
||||
|
||||
// frustumPlanes 返回保留正侧的六个相机视锥面
|
||||
// 返回: [6][4]float64 近、远、左右及上下平面
|
||||
func (r *u3dRender) frustumPlanes() [6][4]float64 {
|
||||
func (r *Renderer) frustumPlanes() [6][4]float64 {
|
||||
w, h := r.viewport[2]/2, r.viewport[3]/2
|
||||
planes := [6][4]float64{{0, 0, 1, -r.camera.Near}, {0, 0, -1, r.camera.Far}, {r.camera.ScaleX, 0, 0, w + r.camera.ShiftX}, {-r.camera.ScaleX, 0, 0, w - r.camera.ShiftX}, {0, r.camera.ScaleY, 0, h - r.camera.ShiftY}, {0, -r.camera.ScaleY, 0, h + r.camera.ShiftY}}
|
||||
if r.camera.Perspective {
|
||||
@@ -226,7 +232,7 @@ func u3dClipFraction(a, b float64) float64 {
|
||||
return a / (a + b)
|
||||
}
|
||||
|
||||
// u3dInterpolate 在相机空间插值坐标与未预乘颜色
|
||||
// u3dInterpolate 在相机空间插值位置、颜色与纹理输入
|
||||
// 入参: a 起点, b 终点, t 比例
|
||||
// 返回: u3dVertex 插值顶点
|
||||
func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex {
|
||||
@@ -237,13 +243,25 @@ func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex {
|
||||
for i := range result.color {
|
||||
result.color[i] = a.color[i]*(1-t) + b.color[i]*t
|
||||
}
|
||||
result.textureMask = a.textureMask | b.textureMask
|
||||
result.opacityOverride = a.opacityOverride
|
||||
if result.textureMask != 0 {
|
||||
for layer := range result.texture {
|
||||
if result.textureMask&(1<<layer) == 0 {
|
||||
continue
|
||||
}
|
||||
for c := range result.texture[layer] {
|
||||
result.texture[layer][c] = a.texture[layer][c]*(1-t) + b.texture[layer][c]*t
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// sectionIntersection 绘制原三角面与指定剖切面的交线,其他剖切面仍参与裁剪
|
||||
// 入参: triangle 原三角面, index 剖切面索引
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) sectionIntersection(triangle [3]u3dVertex, index int) error {
|
||||
func (r *Renderer) sectionIntersection(triangle [3]u3dVertex, index int) error {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -334,7 +352,7 @@ func u3dClipLine(a, b *u3dVertex, plane [4]float64) (bool, error) {
|
||||
// rasterSectionLine 绘制单像素不透明交线,透视深度使用倒数插值
|
||||
// 入参: a 已裁剪起点, b 已裁剪终点, rgb 交线颜色, normal 原面法线, origin 原面上一点
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, origin u3dVector) error {
|
||||
func (r *Renderer) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, origin u3dVector) error {
|
||||
a.color = [4]float64{rgb[0], rgb[1], rgb[2], 1}
|
||||
b.color = a.color
|
||||
return r.rasterLine(a, b, normal, origin, U3DShader{Blend: 0x606}, U3DViewPass{})
|
||||
@@ -343,7 +361,11 @@ func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, or
|
||||
// rasterLine 绘制单像素线段,颜色与深度按透视插值,面深度偏移防止自身遮挡
|
||||
// 入参: a 已裁剪起点, b 已裁剪终点, normal 面法线, origin 面上点, shader 着色器, pass 绘制轮次
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
|
||||
func (r *Renderer) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
|
||||
textures, err := r.shaderTextures(shader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cx := r.viewport[0] + r.viewport[2]/2 + r.camera.ShiftX
|
||||
cy := r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY
|
||||
planes := [4][4]float64{{r.camera.ScaleX, 0, 0, cx}, {-r.camera.ScaleX, 0, 0, float64(r.output.Rect.Dx()) - cx}, {0, -r.camera.ScaleY, 0, cy}, {0, r.camera.ScaleY, 0, float64(r.output.Rect.Dy()) - cy}}
|
||||
@@ -413,6 +435,9 @@ func (r *u3dRender) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader
|
||||
depth -= 8 * math.Abs(depth-math.Nextafter(depth, math.Inf(-1)))
|
||||
if depth <= r.frame[pixel].depth {
|
||||
vertex := u3dInterpolate(a, b, weight)
|
||||
if err := r.textureColor(textures, &vertex.color, &vertex.texture, vertex.point, vertex.opacityOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -12,11 +12,10 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// U3D线集增量编码的算术编码上下文
|
||||
@@ -94,9 +93,11 @@ func (d *u3dDecoder) primitiveDeclaration(r *u3dValues, name string, points bool
|
||||
return fmt.Errorf("invalid U3D reserved primitive parameter")
|
||||
}
|
||||
}
|
||||
if r.u32() != 0 {
|
||||
return fmt.Errorf("unsupported U3D skeletal primitive set")
|
||||
bones, err := d.skeleton(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decl.mesh.Bones = bones
|
||||
if err := r.done(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -210,11 +211,11 @@ func (s *u3dLineState) accumulateCorners(mesh *U3DMesh, shadingIndex uint32, cor
|
||||
}
|
||||
|
||||
// value 按指定上下文和位宽读取点集或线集字段
|
||||
// 入参: context 压缩上下文, small 是否为8位字段
|
||||
// 入参: ctx 压缩上下文, small 是否为8位字段
|
||||
// 返回: uint32 字段值
|
||||
func (r *u3dLineReader) value(context int, small bool) uint32 {
|
||||
func (r *u3dLineReader) value(ctx int, small bool) uint32 {
|
||||
if r.bits != nil {
|
||||
return r.bits.dynamicValue(&r.hist[context], small)
|
||||
return r.bits.dynamicValue(&r.hist[ctx], small)
|
||||
}
|
||||
if small {
|
||||
return uint32(r.values.u8())
|
||||
@@ -444,17 +445,8 @@ func (d *u3dDecoder) primitiveContinuation(r *u3dValues, name string, points boo
|
||||
}
|
||||
}
|
||||
if b := reader.bits; b != nil {
|
||||
if b.pos > uint64(len(b.data))*8 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if b.u32() != 0 {
|
||||
return fmt.Errorf("invalid U3D primitive compression flush")
|
||||
}
|
||||
if b.err != nil {
|
||||
return b.err
|
||||
}
|
||||
if (b.pos-16+7)/8 != uint64(len(b.data)) {
|
||||
return fmt.Errorf("unexpected U3D primitive compressed data")
|
||||
if err := b.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.pos = len(r.data)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// u3dTextureInverse 计算包裹变换的逆矩阵,保留非仿射齐次分量
|
||||
// 入参: matrix 按列排列的包裹变换
|
||||
// 返回: [16]float64 逆矩阵, error 奇异矩阵或非有限结果
|
||||
func u3dTextureInverse(matrix [16]float32) ([16]float64, error) {
|
||||
var rows [4][8]float64
|
||||
for row := range 4 {
|
||||
for col := range 4 {
|
||||
rows[row][col] = float64(matrix[col*4+row])
|
||||
if !finite(rows[row][col]) {
|
||||
return [16]float64{}, fmt.Errorf("invalid U3D texture wrap transform")
|
||||
}
|
||||
}
|
||||
rows[row][row+4] = 1
|
||||
}
|
||||
for col := range 4 {
|
||||
pivot := col
|
||||
for row := col + 1; row < 4; row++ {
|
||||
if math.Abs(rows[row][col]) > math.Abs(rows[pivot][col]) {
|
||||
pivot = row
|
||||
}
|
||||
}
|
||||
if rows[pivot][col] == 0 {
|
||||
return [16]float64{}, fmt.Errorf("singular U3D texture wrap transform")
|
||||
}
|
||||
rows[col], rows[pivot] = rows[pivot], rows[col]
|
||||
divisor := rows[col][col]
|
||||
for i := range 8 {
|
||||
rows[col][i] /= divisor
|
||||
}
|
||||
for row := range 4 {
|
||||
if row == col {
|
||||
continue
|
||||
}
|
||||
factor := rows[row][col]
|
||||
for i := range 8 {
|
||||
rows[row][i] -= factor * rows[col][i]
|
||||
}
|
||||
}
|
||||
}
|
||||
var result [16]float64
|
||||
for row := range 4 {
|
||||
for col := range 4 {
|
||||
value := rows[row][col+4]
|
||||
if !finite(value) {
|
||||
return [16]float64{}, fmt.Errorf("invalid U3D inverse texture wrap transform")
|
||||
}
|
||||
result[col*4+row] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// generated 将插值后的位置或法线映射到纹理,再应用纹理变换
|
||||
// 经线未定义时取0,球心纬度取1/2,反射方向奇点取图像中心
|
||||
// 入参: channel 通道索引, input 模型位置或相机法线, position 相机空间位置
|
||||
// 返回: [3]float64 齐次纹理坐标, error 非有限值或无效投影
|
||||
func (s *u3dTextureSet) generated(channel int, input [3]float64, position u3dVector) ([3]float64, error) {
|
||||
layer := s.layers[channel]
|
||||
for _, value := range input {
|
||||
if !finite(value) {
|
||||
return [3]float64{}, fmt.Errorf("invalid U3D texture mapping input")
|
||||
}
|
||||
}
|
||||
uv := [4]float64{0, 0, 0, 1}
|
||||
if layer.Mode == 4 {
|
||||
incident := u3dVector{0, 0, 1}
|
||||
if s.perspective {
|
||||
incident = position.unit()
|
||||
}
|
||||
normal := u3dVector(input).unit()
|
||||
factor := 2 * normal.dot(incident)
|
||||
reflected := incident.sub(u3dVector{factor * normal[0], factor * normal[1], factor * normal[2]})
|
||||
denominator := 2 * math.Hypot(math.Hypot(reflected[0], reflected[1]), 1-reflected[2])
|
||||
uv[0], uv[1] = .5, .5
|
||||
if denominator != 0 {
|
||||
uv[0] += reflected[0] / denominator
|
||||
uv[1] -= reflected[1] / denominator
|
||||
}
|
||||
} else {
|
||||
var point [4]float64
|
||||
for row := range 4 {
|
||||
point[row] = s.wrap[channel][12+row]
|
||||
for col, value := range input {
|
||||
point[row] += s.wrap[channel][col*4+row] * value
|
||||
}
|
||||
}
|
||||
for i := range 3 {
|
||||
point[i] /= point[3]
|
||||
if !finite(point[i]) {
|
||||
return [3]float64{}, fmt.Errorf("invalid U3D texture wrap projection")
|
||||
}
|
||||
}
|
||||
switch layer.Mode {
|
||||
case 1:
|
||||
uv[0], uv[1] = point[0], point[1]
|
||||
case 2, 3:
|
||||
if point[0] != 0 || point[1] != 0 {
|
||||
uv[0] = math.Atan2(point[1], point[0]) / (2 * math.Pi)
|
||||
if uv[0] < 0 {
|
||||
uv[0]++
|
||||
}
|
||||
}
|
||||
uv[1] = point[2]
|
||||
if layer.Mode == 3 {
|
||||
direction := (u3dVector{point[0], point[1], point[2]}).unit()
|
||||
uv[1] = math.Acos(min(1, max(-1, direction[2]))) / math.Pi
|
||||
}
|
||||
}
|
||||
}
|
||||
var result [3]float64
|
||||
for component, row := range [3]int{0, 1, 3} {
|
||||
for col, value := range uv {
|
||||
result[component] += float64(layer.Transform[col*4+row]) * value
|
||||
}
|
||||
if !finite(result[component]) {
|
||||
return [3]float64{}, fmt.Errorf("invalid U3D generated texture coordinate")
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -29,6 +29,9 @@ type U3DMesh struct {
|
||||
Faces []U3DFace
|
||||
Lines []U3DLine
|
||||
Points []U3DPoint
|
||||
Bones []U3DBone
|
||||
BoneWeights []U3DBoneWeights
|
||||
Animations []U3DAnimation
|
||||
Quality [3]uint32
|
||||
InverseQuantization [5]float32
|
||||
NormalCrease, NormalUpdate, NormalTolerance float32
|
||||
@@ -76,7 +79,7 @@ type u3dMeshDeclaration struct {
|
||||
|
||||
// primitive 访问三角面、线段或点的着色编号和角点,不复制属性数组
|
||||
// 入参: index 图元下标,依次为三角面、线段和点
|
||||
// 返回: uint32 着色编号, []U3DCorner 只读角点
|
||||
// 返回: uint32 着色编号, []U3DCorner 原数组中的角点切片
|
||||
func (m *U3DMesh) primitive(index int) (uint32, []U3DCorner) {
|
||||
if index < len(m.Faces) {
|
||||
return m.Faces[index].Shading, m.Faces[index].Corners[:]
|
||||
@@ -116,16 +119,20 @@ func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error {
|
||||
decl.mesh.InverseQuantization[index] = r.f32()
|
||||
}
|
||||
decl.mesh.NormalCrease, decl.mesh.NormalUpdate, decl.mesh.NormalTolerance = r.f32(), r.f32(), r.f32()
|
||||
bones := r.u32()
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
if minimum > maximum || maximum != decl.counts[1] {
|
||||
return fmt.Errorf("invalid U3D mesh resolution")
|
||||
}
|
||||
if minimum != maximum || bones != 0 {
|
||||
return fmt.Errorf("unsupported U3D progressive or skeletal mesh")
|
||||
if minimum != maximum {
|
||||
return fmt.Errorf("unsupported U3D progressive mesh")
|
||||
}
|
||||
bones, err := d.skeleton(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decl.mesh.Bones = bones
|
||||
if minimum == 0 {
|
||||
for _, count := range decl.counts[:6] {
|
||||
if count != 0 {
|
||||
@@ -247,10 +254,16 @@ func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error {
|
||||
var histogram u3dHistogram
|
||||
var compressed *u3dBits
|
||||
if !d.plain && counts[0] != 0 {
|
||||
if err := d.reserve(1, 1<<20); err != nil {
|
||||
return err
|
||||
}
|
||||
compressed = newU3DBits(r.data[r.pos:])
|
||||
var charged uint64
|
||||
compressed.reserve = func(count, size uint64) error {
|
||||
if err := d.reserve(count, size); err != nil {
|
||||
return err
|
||||
}
|
||||
charged += count * size
|
||||
return nil
|
||||
}
|
||||
defer func() { d.remaining += charged }()
|
||||
}
|
||||
readIndex := func(count uint32) (uint32, error) {
|
||||
var value uint32
|
||||
@@ -321,17 +334,8 @@ func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error {
|
||||
}
|
||||
}
|
||||
if compressed != nil {
|
||||
if compressed.pos > uint64(len(compressed.data))*8 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if compressed.u32() != 0 {
|
||||
return fmt.Errorf("invalid U3D compression flush")
|
||||
}
|
||||
if compressed.err != nil {
|
||||
return compressed.err
|
||||
}
|
||||
if (compressed.pos-16+7)/8 != uint64(len(compressed.data)) {
|
||||
return fmt.Errorf("unexpected U3D compressed block data: decoded %d bits, stored %d bytes", compressed.pos-16, len(compressed.data))
|
||||
if err := compressed.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.pos = len(r.data)
|
||||
}
|
||||
@@ -12,16 +12,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// U3DRenderMode 定义静态几何的呈现方式,边线和顶点使用单像素覆盖
|
||||
type U3DRenderMode uint8
|
||||
|
||||
const (
|
||||
U3DRenderSolid U3DRenderMode = iota // 实体
|
||||
U3DRenderSolidWireframe // 实体线框
|
||||
@@ -38,18 +35,29 @@ const (
|
||||
U3DRenderShadedIllustration // 着色插图
|
||||
)
|
||||
|
||||
// U3DRenderMode 定义静态几何的呈现方式,边线和顶点使用单像素覆盖
|
||||
type U3DRenderMode uint8
|
||||
|
||||
// u3dPrimitiveKey 区分共享点线的颜色及混合状态,避免透明边点重复叠加
|
||||
type u3dPrimitiveKey struct {
|
||||
a, b u3dVertex
|
||||
a, b [7]float64
|
||||
blend uint32
|
||||
function uint32
|
||||
reference float32
|
||||
}
|
||||
|
||||
// u3dTexturedPrimitiveKey 额外区分纹理坐标及纹理层,不扩大普通边点缓存
|
||||
type u3dTexturedPrimitiveKey struct {
|
||||
base u3dPrimitiveKey
|
||||
coordinates [2][8][3]float64
|
||||
layers [8]*U3DTextureLayer
|
||||
opacityOverride bool
|
||||
}
|
||||
|
||||
// uniquePrimitive 在当前网格内去重着色边点,缓存按条目计入绘制预算
|
||||
// 入参: a 起点或顶点, b 终点或相同顶点, shader 着色器
|
||||
// 返回: bool 是否首次绘制, error 预算或取消错误
|
||||
func (r *u3dRender) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, error) {
|
||||
func (r *Renderer) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, error) {
|
||||
if r.primitives == nil {
|
||||
return true, nil
|
||||
}
|
||||
@@ -59,18 +67,33 @@ func (r *u3dRender) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, err
|
||||
if left[i] != right[i] {
|
||||
if left[i] > right[i] {
|
||||
a, b = b, a
|
||||
left, right = right, left
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
key := u3dPrimitiveKey{a: a, b: b, blend: shader.Blend}
|
||||
key := u3dPrimitiveKey{a: left, b: right, blend: shader.Blend}
|
||||
if shader.Attributes&2 != 0 {
|
||||
key.function, key.reference = shader.AlphaFunction, shader.AlphaReference
|
||||
}
|
||||
if shader.Textures != ([8]*U3DTextureLayer{}) {
|
||||
textured := u3dTexturedPrimitiveKey{base: key, coordinates: [2][8][3]float64{a.texture, b.texture}, layers: shader.Textures, opacityOverride: a.opacityOverride}
|
||||
if _, ok := r.texturedPrimitives[textured]; ok {
|
||||
return false, nil
|
||||
}
|
||||
if err := r.Reserve(1, 1024); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if r.texturedPrimitives == nil {
|
||||
r.texturedPrimitives = make(map[u3dTexturedPrimitiveKey]struct{})
|
||||
}
|
||||
r.texturedPrimitives[textured] = struct{}{}
|
||||
return true, nil
|
||||
}
|
||||
if _, ok := r.primitives[key]; ok {
|
||||
return false, nil
|
||||
}
|
||||
if err := r.reserve(1, 512); err != nil {
|
||||
if err := r.Reserve(1, 512); err != nil {
|
||||
return false, err
|
||||
}
|
||||
r.primitives[key] = struct{}{}
|
||||
@@ -92,7 +115,7 @@ func (mode U3DRenderMode) silhouette() bool {
|
||||
// modeTriangle 根据当前模式绘制原三角面、原始边或顶点,不为裁剪边界生成网格边
|
||||
// 入参: triangle 相机空间顶点, shader 着色器, pass 绘制轮次
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error {
|
||||
func (r *Renderer) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error {
|
||||
if r.depthOnly || r.mode == U3DRenderSolid || r.mode.silhouette() {
|
||||
return r.sectionTriangle(triangle, shader, pass)
|
||||
}
|
||||
@@ -148,7 +171,7 @@ func (r *u3dRender) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U
|
||||
// modeLine 对原始网格边应用剖切与视锥裁剪,再按像素深度绘制
|
||||
// 入参: a 起点, b 终点, normal 面法线, origin 面上点, shader 着色器, pass 绘制轮次
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
|
||||
func (r *Renderer) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
|
||||
for i, section := range r.sections {
|
||||
if i%256 == 0 {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
@@ -175,7 +198,7 @@ func (r *u3dRender) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3
|
||||
// modePoint 绘制原始顶点,不在裁剪边界补充顶点
|
||||
// 入参: vertex 顶点, shader 着色器, pass 绘制轮次
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPass) error {
|
||||
func (r *Renderer) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPass) error {
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,6 +244,13 @@ func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPa
|
||||
if vertex.point[2] > r.frame[pixel].depth {
|
||||
return nil
|
||||
}
|
||||
textures, err := r.shaderTextures(shader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.textureColor(textures, &vertex.color, &vertex.texture, vertex.point, vertex.opacityOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass)
|
||||
if err != nil || !visible {
|
||||
return err
|
||||
@@ -232,7 +262,7 @@ func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPa
|
||||
// drawBounds 在模型局部空间计算包围盒,再应用实例变换,不以世界坐标轴重建包围盒
|
||||
// 入参: instance 场景实例, node 模型节点, mesh 网格, pass 绘制轮次, passIndex 轮次索引
|
||||
// 返回: error 几何、数值、预算或取消错误
|
||||
func (r *u3dRender) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error {
|
||||
func (r *Renderer) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error {
|
||||
minimum := u3dVector{math.Inf(1), math.Inf(1), math.Inf(1)}
|
||||
maximum := u3dVector{math.Inf(-1), math.Inf(-1), math.Inf(-1)}
|
||||
visible := false
|
||||
@@ -251,11 +281,10 @@ func (r *u3dRender) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMes
|
||||
}
|
||||
}
|
||||
if uint64(shading) >= uint64(len(shaders)) {
|
||||
visible = true
|
||||
visible = visible || passIndex == 0
|
||||
} else if !visible {
|
||||
for _, name := range shaders[shading] {
|
||||
index, ok := r.shaders[name]
|
||||
if !ok || r.model.Shaders[index].RenderPass&(1<<passIndex) != 0 {
|
||||
if r.shaderPass(name)&(1<<passIndex) != 0 {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// U3DMotion 保存骨骼或节点动作轨道,解码不执行动画
|
||||
type U3DMotion struct {
|
||||
Name string
|
||||
TimeInverseQuantization, RotationInverseQuantization float32
|
||||
Tracks []U3DMotionTrack
|
||||
}
|
||||
|
||||
// U3DMotionTrack 保存命名轨道及其关键帧,保持文件中的时间与帧次序
|
||||
type U3DMotionTrack struct {
|
||||
Name string
|
||||
DisplacementInverseQuantization, ScaleInverseQuantization float32
|
||||
Frames []U3DKeyFrame
|
||||
}
|
||||
|
||||
// U3DKeyFrame 保存关键帧时间及相对父项的变换,Rotation按W、X、Y、Z排列
|
||||
type U3DKeyFrame struct {
|
||||
Time float32
|
||||
Displacement, Scale [3]float32
|
||||
Rotation [4]float32
|
||||
}
|
||||
|
||||
// motion 读取ECMA-363第9.8.6节的动作资源,首尾帧保留未量化数值
|
||||
// 入参: values 字段读取器, name 资源名
|
||||
// 返回: error 结构、数值、预算或取消错误
|
||||
func (d *u3dDecoder) motion(values *u3dValues, name string) error {
|
||||
r := u3dResourceReader{values: values, decoder: d}
|
||||
defer r.release()
|
||||
count := r.u32()
|
||||
motion := U3DMotion{Name: name, TimeInverseQuantization: r.f32(), RotationInverseQuantization: r.f32()}
|
||||
if err := r.err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if motion.TimeInverseQuantization < 0 || motion.RotationInverseQuantization < 0 {
|
||||
return fmt.Errorf("invalid U3D motion quantization")
|
||||
}
|
||||
if err := d.reserve(uint64(count), 48); err != nil {
|
||||
return err
|
||||
}
|
||||
motion.Tracks = make([]U3DMotionTrack, int(count))
|
||||
for index := range motion.Tracks {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
track := &motion.Tracks[index]
|
||||
track.Name = r.text()
|
||||
frames := r.u32()
|
||||
track.DisplacementInverseQuantization, track.ScaleInverseQuantization = r.f32(), r.f32()
|
||||
if err := r.err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if track.DisplacementInverseQuantization < 0 || track.ScaleInverseQuantization < 0 {
|
||||
return fmt.Errorf("invalid U3D motion track quantization")
|
||||
}
|
||||
if err := d.reserve(uint64(frames), 44); err != nil {
|
||||
return err
|
||||
}
|
||||
track.Frames = make([]U3DKeyFrame, int(frames))
|
||||
for i := range track.Frames {
|
||||
if i%256 == 0 {
|
||||
if err := d.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
frame := &track.Frames[i]
|
||||
if i == 0 || i == len(track.Frames)-1 {
|
||||
frame.Time = r.f32()
|
||||
for component := range frame.Displacement {
|
||||
frame.Displacement[component] = r.f32()
|
||||
}
|
||||
for component := range frame.Rotation {
|
||||
frame.Rotation[component] = r.f32()
|
||||
}
|
||||
for component := range frame.Scale {
|
||||
frame.Scale[component] = r.f32()
|
||||
}
|
||||
} else if err := r.motionFrame(frame, track.Frames[i-1], &motion, track); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := r.done(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.model.Motions = append(d.model.Motions, motion)
|
||||
return nil
|
||||
}
|
||||
|
||||
// motionFrame 按前一帧预测时间、位移和缩放,并右乘旋转差分四元数
|
||||
// 入参: frame 目标帧, previous 前一帧, motion 动作量化参数, track 轨道量化参数
|
||||
// 返回: error 符号、数值或读取错误
|
||||
func (r *u3dResourceReader) motionFrame(frame *U3DKeyFrame, previous U3DKeyFrame, motion *U3DMotion, track *U3DMotionTrack) error {
|
||||
time, err := r.motionDifference([]float32{previous.Time}, motion.TimeInverseQuantization, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
frame.Time = time[0]
|
||||
position, err := r.motionDifference(previous.Displacement[:], track.DisplacementInverseQuantization, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
copy(frame.Displacement[:], position[:])
|
||||
signs := r.value(4, true)
|
||||
if signs > 15 {
|
||||
return fmt.Errorf("invalid U3D motion rotation signs")
|
||||
}
|
||||
var difference [4]float64
|
||||
var squared float64
|
||||
for component := 1; component < 4; component++ {
|
||||
value := float64(r.value(5, false)) * float64(motion.RotationInverseQuantization)
|
||||
if signs&(1<<component) != 0 {
|
||||
value = -value
|
||||
}
|
||||
difference[component] = value
|
||||
squared += value * value
|
||||
}
|
||||
if err := r.err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if squared > 1 {
|
||||
return fmt.Errorf("invalid U3D motion rotation difference")
|
||||
}
|
||||
difference[0] = math.Sqrt(1 - squared)
|
||||
if signs&1 != 0 {
|
||||
difference[0] = -difference[0]
|
||||
}
|
||||
p := previous.Rotation
|
||||
w, x, y, z := float64(p[0]), float64(p[1]), float64(p[2]), float64(p[3])
|
||||
a, b, c, e := difference[0], difference[1], difference[2], difference[3]
|
||||
frame.Rotation = [4]float32{
|
||||
float32(w*a - x*b - y*c - z*e),
|
||||
float32(w*b + x*a + y*e - z*c),
|
||||
float32(w*c - x*e + y*a + z*b),
|
||||
float32(w*e + x*c - y*b + z*a),
|
||||
}
|
||||
for _, value := range frame.Rotation {
|
||||
if !finite(float64(value)) {
|
||||
return fmt.Errorf("invalid U3D reconstructed rotation")
|
||||
}
|
||||
}
|
||||
scale, err := r.motionDifference(previous.Scale[:], track.ScaleInverseQuantization, 6)
|
||||
copy(frame.Scale[:], scale[:])
|
||||
return err
|
||||
}
|
||||
|
||||
// motionDifference 用共享幅值上下文恢复一至三个差分分量
|
||||
// 入参: previous 前一帧分量, scale 逆量化系数, ctx 符号上下文,下一项为幅值上下文
|
||||
// 返回: [3]float32 重建分量, error 符号、数值或读取错误
|
||||
func (r *u3dResourceReader) motionDifference(previous []float32, scale float32, ctx int) (values [3]float32, err error) {
|
||||
signs := r.value(ctx, true)
|
||||
if signs >= 1<<len(previous) {
|
||||
return values, fmt.Errorf("invalid U3D motion difference signs")
|
||||
}
|
||||
for index, prior := range previous {
|
||||
difference := float64(r.value(ctx+1, false)) * float64(scale)
|
||||
if signs&(1<<index) != 0 {
|
||||
difference = -difference
|
||||
}
|
||||
values[index] = float32(float64(prior) + difference)
|
||||
if !finite(float64(values[index])) {
|
||||
return values, fmt.Errorf("invalid U3D reconstructed motion value")
|
||||
}
|
||||
}
|
||||
return values, r.err()
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
package threed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -39,7 +39,7 @@ type u3dOutlineEdge struct {
|
||||
// outlineFace 收集当前绘制轮次中的原始网格边,正面法线在同一半圆内取角度极值
|
||||
// 入参: outline 共享边索引, world 世界坐标, vertices 相机顶点, front 是否正面, visible 是否绘制面, shaders 着色列表, passIndex 轮次
|
||||
// 返回: error 数值、预算或取消错误
|
||||
func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertices [3]u3dVertex, front, visible bool, shaders []string, passIndex int) error {
|
||||
func (r *Renderer) outlineFace(outline *u3dOutline, world [3]u3dVector, vertices [3]u3dVertex, front, visible bool, shaders []string, passIndex int) error {
|
||||
active := false
|
||||
for i, name := range shaders {
|
||||
if i%128 == 0 {
|
||||
@@ -47,8 +47,7 @@ func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertice
|
||||
return err
|
||||
}
|
||||
}
|
||||
index, ok := r.shaders[name]
|
||||
if !ok || r.model.Shaders[index].RenderPass&(1<<passIndex) != 0 {
|
||||
if r.shaderPass(name)&(1<<passIndex) != 0 {
|
||||
active = true
|
||||
break
|
||||
}
|
||||
@@ -76,7 +75,7 @@ func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertice
|
||||
key := [2]u3dVector{a, b}
|
||||
index, exists := outline.index[key]
|
||||
if !exists {
|
||||
if err := r.reserve(1, 1024); err != nil {
|
||||
if err := r.Reserve(1, 1024); err != nil {
|
||||
return err
|
||||
}
|
||||
index = len(outline.edges)
|
||||
@@ -117,7 +116,7 @@ func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertice
|
||||
// drawOutline 绘制外缘、正背面交界和达到夹角的共享边,沿用像素深度去除遮挡
|
||||
// 入参: outline 共享边索引, pass 绘制轮次
|
||||
// 返回: error 数值、资源或取消错误
|
||||
func (r *u3dRender) drawOutline(outline *u3dOutline, pass U3DViewPass) error {
|
||||
func (r *Renderer) drawOutline(outline *u3dOutline, pass U3DViewPass) error {
|
||||
shader := U3DShader{Blend: 0x606}
|
||||
color := [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
|
||||
for i, edge := range outline.edges {
|
||||
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user