mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 16:20:33 +08:00
Compare commits
215
Commits
f3cc35e78c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
export TZ=Asia/Shanghai
|
export TZ=Asia/Shanghai
|
||||||
|
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=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=" ./cmd/webui/webui.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/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.devcontainer/
|
.devcontainer/
|
||||||
|
releases/
|
||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
assets/webui/wasm/ofdgo.wasm
|
|
||||||
*.exe
|
*.exe
|
||||||
*.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)"
|
LABEL authors="肖其顿 (XIAO QI DUN)"
|
||||||
|
|||||||
@@ -3,23 +3,28 @@
|
|||||||
|
|
||||||
# 主要能力
|
# 主要能力
|
||||||
- 文档处理:创建、读写、拆分、合并、压缩
|
- 文档处理:创建、读写、拆分、合并、压缩
|
||||||
- 渲染转换:矢量、位图、导入、导出、批量
|
- 文档阅读:搜索、缩放、旋转、双页、连续
|
||||||
- 文档安全:签名、签章、验签、加密、解密
|
- 文档管理:新建、导入、切换、排序、删除
|
||||||
- 电子发票:提取、创建、编辑、校验、同步
|
- 版本管理:创建、改名、切换、另存、删除
|
||||||
- 页面管理:添加、导入、复制、排序、删除
|
- 页面管理:添加、导入、复制、排序、删除
|
||||||
- 文字处理:插入、编辑、排版、搜索、提取
|
- 文档编辑:选区、重排、事务、撤销、重做
|
||||||
- 文字样式:选区、混排、装饰、阴影、底色
|
- 文字处理:插入、编辑、替换、排版、提取
|
||||||
|
- 文字样式:混排、粗体、斜体、阴影、底色
|
||||||
- 字体处理:匹配、内嵌、外置、子集、提取
|
- 字体处理:匹配、内嵌、外置、子集、提取
|
||||||
- 图片处理:插入、替换、缩放、裁剪、提取
|
- 图片处理:插入、替换、缩放、裁剪、提取
|
||||||
- 图形处理:绘制、变换、组合、对齐、分布
|
- 图形处理:绘制、变换、组合、对齐、分布
|
||||||
- 色彩管理:空间、配置、转换、渐变、透明
|
- 色彩管理:调色、渐变、图案、透明、转换
|
||||||
- 文档管理:添加、切换、改名、排序、删除
|
- 文档结构:图层、模板、目录、书签、标引
|
||||||
- 版本管理:创建、切换、改名、另存、删除
|
- 资源管理:导入、绑定、替换、提取、清理
|
||||||
- 文档结构:目录、书签、模板、标引、扩展
|
|
||||||
- 资源管理:字体、图片、色彩、媒体、附件
|
|
||||||
- 注解管理:批注、高亮、水印、印章、链接
|
- 注解管理:批注、高亮、水印、印章、链接
|
||||||
- 动作管理:跳转、链接、附件、音频、视频
|
- 交互动作:跳转、链接、附件、音频、视频
|
||||||
|
- 电子发票:提取、创建、编辑、校验、同步
|
||||||
|
- 文档安全:签名、签章、验签、加密、解密
|
||||||
- 三维处理:解析、材质、纹理、动画、渲染
|
- 三维处理:解析、材质、纹理、动画、渲染
|
||||||
|
- 页面渲染:矢量、位图、裁剪、蒙版、合成
|
||||||
|
- 批量转换:选页、并行、进度、取消、打包
|
||||||
|
- 打印输出:预览、双面、合印、小册、海报
|
||||||
|
- 应用交互:标签、链接、关联、拖放、复用
|
||||||
|
|
||||||
# 在线使用
|
# 在线使用
|
||||||
[OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务
|
[OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务
|
||||||
@@ -61,7 +66,22 @@ go build -o ofdgo_webui.exe -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/web
|
|||||||
```
|
```
|
||||||
|
|
||||||
# 原生桌面
|
# 原生桌面
|
||||||
|
基于 [GUIGo](https://github.com/xiaoqidun/guigo) 实现,运行前需参照其说明安装系统依赖
|
||||||
|
|
||||||
```batch
|
```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 GOOS=windows
|
||||||
set GOARCH=amd64
|
set GOARCH=amd64
|
||||||
go build -o ofdgo_windows_amd64.exe -trimpath -ldflags "-H windowsgui -s -w -buildid=" ./cmd/webview
|
go build -o ofdgo_windows_amd64.exe -trimpath -ldflags "-H windowsgui -s -w -buildid=" ./cmd/webview
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import "embed"
|
|||||||
|
|
||||||
// FS WebUI嵌入静态文件
|
// FS WebUI嵌入静态文件
|
||||||
//
|
//
|
||||||
//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_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
|
var FS embed.FS
|
||||||
|
|
||||||
// NativeFS WebView嵌入静态文件
|
// 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
|
//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
|
var NativeFS embed.FS
|
||||||
+119
-23
@@ -8,23 +8,32 @@
|
|||||||
<link rel="manifest" href="./ofdgo.json">
|
<link rel="manifest" href="./ofdgo.json">
|
||||||
<link rel="icon" type="image/x-icon" sizes="16x16 32x32 48x48 64x64 128x128 256x256" href="./ofdgo.ico">
|
<link rel="icon" type="image/x-icon" sizes="16x16 32x32 48x48 64x64 128x128 256x256" href="./ofdgo.ico">
|
||||||
<link rel="icon" type="image/svg+xml" href="./ofdgo.svg">
|
<link rel="icon" type="image/svg+xml" href="./ofdgo.svg">
|
||||||
|
<script src="./ofdgo_theme.js"></script>
|
||||||
<link rel="stylesheet" href="./ofdgo.css">
|
<link rel="stylesheet" href="./ofdgo.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
<div id="documentTitleBar" class="document-titlebar">
|
||||||
|
<div id="documentTabs" class="document-tabs"></div>
|
||||||
|
<button id="documentNewTab" class="document-new-tab" type="button" title="新建"><span class="new-icon"></span></button>
|
||||||
|
<div class="document-title-drag"></div>
|
||||||
|
<button id="appearanceButton" class="app-appearance" type="button" title="设置"><span class="settings-icon"></span></button>
|
||||||
|
<button id="refreshAppButton" class="app-update" type="button" title="更新" hidden><span class="update-icon"></span></button>
|
||||||
|
</div>
|
||||||
<header class="toolbar-frame">
|
<header class="toolbar-frame">
|
||||||
<div class="toolbar app-bar">
|
<div class="toolbar app-bar">
|
||||||
<input id="ofdInput" class="file-input" type="file" accept=".ofd,.pdf">
|
<input id="ofdInput" class="file-input" type="file" accept=".ofd,.pdf" multiple>
|
||||||
<input id="fontInput" class="file-input" type="file" accept=".ttf,.otf,.ttc,font/*" multiple>
|
<input id="fontInput" class="file-input" type="file" accept=".ttf,.otf,.ttc,font/*" multiple>
|
||||||
<input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple>
|
<input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple>
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
<button id="ofdButton" class="icon-button" type="button" title="打开"><span class="open-icon"></span></button>
|
<button id="ofdButton" class="icon-button" type="button" title="打开"><span class="open-icon"></span></button>
|
||||||
<button id="closeButton" class="icon-button" type="button" title="关闭" disabled><span class="close-icon"></span></button>
|
<button id="closeButton" class="icon-button" type="button" title="关闭文档" disabled><span class="close-icon"></span></button>
|
||||||
<button id="newButton" class="icon-button" type="button" title="新建"><span class="new-icon"></span></button>
|
<button id="newButton" class="icon-button" type="button" title="新建"><span class="new-icon"></span></button>
|
||||||
<button id="saveButton" class="icon-button" type="button" title="保存" disabled><span class="save-icon"></span></button>
|
<button id="saveButton" class="icon-button" type="button" title="保存" disabled><span class="save-icon"></span></button>
|
||||||
<button id="saveAsButton" class="icon-button" type="button" title="另存" disabled><span class="save-as-icon"></span></button>
|
<button id="saveAsButton" class="icon-button" type="button" title="另存" disabled><span class="save-as-icon"></span></button>
|
||||||
<button id="editButton" class="icon-button" type="button" title="编辑" data-pressed="false" disabled><span class="edit-icon"></span></button>
|
<button id="editButton" class="icon-button" type="button" title="编辑" data-pressed="false" disabled><span class="edit-icon"></span></button>
|
||||||
<button id="convertButton" class="icon-button" type="button" title="转换"><span class="convert-icon"></span></button>
|
<button id="convertButton" class="icon-button" type="button" title="转换"><span class="convert-icon"></span></button>
|
||||||
|
<button id="printButton" class="icon-button" type="button" title="打印" disabled><span class="print-icon"></span></button>
|
||||||
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" data-pressed="true"><span class="pages-icon"></span></button>
|
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" data-pressed="true"><span class="pages-icon"></span></button>
|
||||||
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" data-pressed="true"><span class="info-icon"></span></button>
|
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" data-pressed="true"><span class="info-icon"></span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,10 +55,11 @@
|
|||||||
<button id="fitHeightButton" class="button" type="button" data-short="高" disabled>适高</button>
|
<button id="fitHeightButton" class="button" type="button" data-short="高" disabled>适高</button>
|
||||||
<button id="rotateButton" class="button" type="button" data-short="转" disabled>旋转</button>
|
<button id="rotateButton" class="button" type="button" data-short="转" disabled>旋转</button>
|
||||||
<button id="panButton" class="button" type="button" data-short="拖" data-pressed="false" disabled>拖动</button>
|
<button id="panButton" class="button" type="button" data-short="拖" data-pressed="false" disabled>拖动</button>
|
||||||
<button id="continuousButton" class="button" type="button" data-short="连" data-pressed="false" disabled>连续</button>
|
|
||||||
<button id="annotationButton" class="button" type="button" data-short="注" data-pressed="true">注解</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
|
<button id="pageLayoutButton" class="button" type="button" data-short="单" disabled>单页</button>
|
||||||
|
<button id="continuousButton" class="button" type="button" data-short="连" data-pressed="false" disabled>连续</button>
|
||||||
|
<button id="annotationButton" class="button" type="button" data-short="注" data-pressed="true">注解</button>
|
||||||
<button id="renderVectorButton" class="button" type="button" data-short="矢" data-pressed="true" disabled>矢量</button>
|
<button id="renderVectorButton" class="button" type="button" data-short="矢" data-pressed="true" disabled>矢量</button>
|
||||||
<button id="renderRasterButton" class="button" type="button" data-short="位" data-pressed="false" disabled>位图</button>
|
<button id="renderRasterButton" class="button" type="button" data-short="位" data-pressed="false" disabled>位图</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +76,6 @@
|
|||||||
<button id="exportButton" class="button" type="button" data-short="多" disabled>文档</button>
|
<button id="exportButton" class="button" type="button" data-short="多" disabled>文档</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="refreshAppButton" class="button app-update" type="button" data-short="更" hidden>更新</button>
|
|
||||||
</header>
|
</header>
|
||||||
<div id="editorTools" class="editor-toolbar" hidden>
|
<div id="editorTools" class="editor-toolbar" hidden>
|
||||||
<div class="editor-group editor-actions">
|
<div class="editor-group editor-actions">
|
||||||
@@ -75,7 +84,6 @@
|
|||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group editor-objects">
|
<div class="editor-group editor-objects">
|
||||||
<button id="compositeBackButton" class="button" type="button" hidden>返回</button>
|
|
||||||
<button id="selectObjectButton" class="button" type="button" data-pressed="true">选择</button>
|
<button id="selectObjectButton" class="button" type="button" data-pressed="true">选择</button>
|
||||||
<button id="multiSelectButton" class="button" type="button" data-pressed="false">多选</button>
|
<button id="multiSelectButton" class="button" type="button" data-pressed="false">多选</button>
|
||||||
<button id="editObjectButton" class="button" type="button" disabled>修改</button>
|
<button id="editObjectButton" class="button" type="button" disabled>修改</button>
|
||||||
@@ -116,9 +124,8 @@
|
|||||||
<span>插入</span>
|
<span>插入</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<div class="text-font"><div class="font-picker"><input id="textFont" type="text" title="字体" autocomplete="off" spellcheck="false" disabled><button id="textFontToggle" type="button" title="选择字体" tabindex="-1" disabled></button></div><button id="textFontAdd" class="icon-button" type="button" title="添加字体">+</button></div>
|
<div class="text-font"><div class="font-picker"><input id="textFont" type="text" title="字体" autocomplete="off" spellcheck="false" disabled><button id="textFontToggle" type="button" title="选择字体" tabindex="-1" disabled></button></div><span class="editor-number"><input id="textSize" type="number" title="字号(pt)" min="1" step="any" required disabled></span><button id="textFontAdd" class="icon-button" type="button" title="添加字体">+</button></div>
|
||||||
<div id="textFontList" class="font-listbox" popover="manual"></div>
|
<div id="textFontList" class="font-listbox" popover="manual"></div>
|
||||||
<label class="editor-field"><span>字号</span><span class="editor-number"><input id="textSize" type="number" min="1" step="any" required disabled><span>pt</span></span></label>
|
|
||||||
<button id="textBold" class="button text-format-bold" data-text-format="bold" type="button" title="粗体" data-pressed="false" disabled>B</button>
|
<button id="textBold" class="button text-format-bold" data-text-format="bold" type="button" title="粗体" data-pressed="false" disabled>B</button>
|
||||||
<button id="textItalic" class="button text-format-italic" data-text-format="italic" type="button" title="斜体" data-pressed="false" disabled>I</button>
|
<button id="textItalic" class="button text-format-italic" data-text-format="italic" type="button" title="斜体" data-pressed="false" disabled>I</button>
|
||||||
<button id="textUnderline" class="button text-format-underline" data-text-format="underline" type="button" title="下划线" data-pressed="false" disabled>U</button>
|
<button id="textUnderline" class="button text-format-underline" data-text-format="underline" type="button" title="下划线" data-pressed="false" disabled>U</button>
|
||||||
@@ -134,7 +141,7 @@
|
|||||||
<option value="left">左齐</option>
|
<option value="left">左齐</option>
|
||||||
<option value="center">居中</option>
|
<option value="center">居中</option>
|
||||||
<option value="right">右齐</option>
|
<option value="right">右齐</option>
|
||||||
<option value="justify">两端</option>
|
<option value="justify">两端</option><option value="distributed">分散</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" title="行距,0为自动" disabled><span>pt</span></span></label>
|
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" title="行距,0为自动" disabled><span>pt</span></span></label>
|
||||||
<button id="textWrap" class="button" type="button" data-pressed="false" disabled>折行</button>
|
<button id="textWrap" class="button" type="button" data-pressed="false" disabled>折行</button>
|
||||||
@@ -187,9 +194,18 @@
|
|||||||
<button id="batchPagesButton" class="editor-group-action" type="button">页面</button>
|
<button id="batchPagesButton" class="editor-group-action" type="button">页面</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<dialog id="aboutPanel" class="form-dialog about-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<h2 id="aboutTitle" class="form-title">软件信息</h2>
|
||||||
|
<div class="about-row"><span>构建版本</span><span id="aboutVersion">develop</span></div>
|
||||||
|
<div class="about-row"><span>项目官网</span><a href="https://ofdgo.aite.me/" target="_blank" rel="noreferrer">https://ofdgo.aite.me/</a></div>
|
||||||
|
<div class="about-row"><span>桌面软件</span><a href="https://ofdgo.aite.me/releases/" target="_blank" rel="noreferrer">https://ofdgo.aite.me/releases/</a></div>
|
||||||
|
<div class="about-row"><span>项目仓库</span><a href="https://github.com/xiaoqidun/ofdgo" target="_blank" rel="noreferrer">https://github.com/xiaoqidun/ofdgo</a></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<dialog id="closePanel" class="form-dialog confirm-dialog">
|
<dialog id="closePanel" class="form-dialog confirm-dialog">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<h2 class="form-title">关闭文档</h2>
|
<h2 id="closeTitle" class="form-title">关闭文档</h2>
|
||||||
<p class="confirm-message">文档尚未保存</p>
|
<p class="confirm-message">文档尚未保存</p>
|
||||||
<div class="form-actions"><button class="button" value="cancel" autofocus>取消</button><button class="button" value="discard">放弃</button><button class="button" value="save">保存</button></div>
|
<div class="form-actions"><button class="button" value="cancel" autofocus>取消</button><button class="button" value="discard">放弃</button><button class="button" value="save">保存</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -208,7 +224,6 @@
|
|||||||
<div id="convertFontRow" class="form-row" hidden><label for="convertFontMode">字体</label><select id="convertFontMode"><option value="0">保持</option><option value="1">内嵌</option><option value="2">外部</option></select></div>
|
<div id="convertFontRow" class="form-row" hidden><label for="convertFontMode">字体</label><select id="convertFontMode"><option value="0">保持</option><option value="1">内嵌</option><option value="2">外部</option></select></div>
|
||||||
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><div class="form-select"><select id="convertDPI"><option value="96">96 DPI</option><option value="150">150 DPI</option><option value="300" selected>300 DPI</option><option value="600">600 DPI</option></select><span id="convertDPIValue">300</span></div></div>
|
<div id="convertDPIRow" class="form-row" hidden><label for="convertDPI">精度</label><div class="form-select"><select id="convertDPI"><option value="96">96 DPI</option><option value="150">150 DPI</option><option value="300" selected>300 DPI</option><option value="600">600 DPI</option></select><span id="convertDPIValue">300</span></div></div>
|
||||||
<div class="form-row"><label for="convertDestination">输出</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
|
<div class="form-row"><label for="convertDestination">输出</label><select id="convertDestination"><option value="directory">目录</option><option value="archive">打包</option></select></div>
|
||||||
<div class="form-row"><label for="convertParallel">并行</label><input id="convertParallel" type="number" min="1" step="1" placeholder="自动"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="convertClose" class="button" type="button">关闭</button><button id="convertCancel" class="button" type="button" hidden>取消</button><button id="convertStart" class="button" type="submit" disabled>转换</button>
|
<button id="convertClose" class="button" type="button">关闭</button><button id="convertCancel" class="button" type="button" hidden>取消</button><button id="convertStart" class="button" type="submit" disabled>转换</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +231,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="batchPagesPanel" class="form-dialog">
|
<dialog id="batchPagesPanel" class="form-dialog">
|
||||||
<form id="batchPagesForm">
|
<form id="batchPagesForm">
|
||||||
|
<h2 id="batchPagesTitle" class="form-title">复制页面</h2>
|
||||||
<div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</option><option value="delete">删除</option><option value="save">另存</option></select></div>
|
<div class="form-row"><label for="batchPageAction">操作</label><select id="batchPageAction"><option value="copy">复制</option><option value="move">移动</option><option value="delete">删除</option><option value="save">另存</option></select></div>
|
||||||
<div class="form-row"><label for="batchPageRange">页码</label><input id="batchPageRange" type="text" required autocomplete="off" spellcheck="false" autofocus></div>
|
<div class="form-row"><label for="batchPageRange">页码</label><input id="batchPageRange" type="text" required autocomplete="off" spellcheck="false" autofocus></div>
|
||||||
<div id="batchPagePositionRow" class="form-row" hidden><label for="batchPagePosition">位置</label><select id="batchPagePosition"><option value="before">页前</option><option value="after">页后</option><option value="first">开头</option><option value="last">末尾</option></select></div>
|
<div id="batchPagePositionRow" class="form-row" hidden><label for="batchPagePosition">位置</label><select id="batchPagePosition"><option value="before">页前</option><option value="after">页后</option><option value="first">开头</option><option value="last">末尾</option></select></div>
|
||||||
@@ -224,6 +240,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="objectStylePanel" class="form-dialog">
|
<dialog id="objectStylePanel" class="form-dialog">
|
||||||
<form id="objectStyleForm">
|
<form id="objectStyleForm">
|
||||||
|
<h2 id="objectStyleTitle" class="form-title">对象样式</h2>
|
||||||
<div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div>
|
<div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div>
|
||||||
<div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button">复制</button><button id="pasteStyleButton" class="button" type="button">粘贴</button></div></div>
|
<div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button">复制</button><button id="pasteStyleButton" class="button" type="button">粘贴</button></div></div>
|
||||||
<details id="objectLinkFields" hidden>
|
<details id="objectLinkFields" hidden>
|
||||||
@@ -285,6 +302,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="structureDialog" class="form-dialog structure-dialog">
|
<dialog id="structureDialog" class="form-dialog structure-dialog">
|
||||||
<form id="structureForm">
|
<form id="structureForm">
|
||||||
|
<h2 id="structureTitle" class="form-title">结构标引</h2>
|
||||||
<div class="form-row"><label for="structureEntry">标引</label><select id="structureEntry"></select></div>
|
<div class="form-row"><label for="structureEntry">标引</label><select id="structureEntry"></select></div>
|
||||||
<div id="structureNodeRow" class="form-row"><label for="structureNodeSelect">节点</label><select id="structureNodeSelect"></select></div>
|
<div id="structureNodeRow" class="form-row"><label for="structureNodeSelect">节点</label><select id="structureNodeSelect"></select></div>
|
||||||
<div id="structureActionRow" class="form-row"><label for="structureAction">操作</label><select id="structureAction"><option value="update">修改</option><option value="add">新增</option><option value="bind">绑定</option><option value="delete">删除</option></select></div>
|
<div id="structureActionRow" class="form-row"><label for="structureAction">操作</label><select id="structureAction"><option value="update">修改</option><option value="add">新增</option><option value="bind">绑定</option><option value="delete">删除</option></select></div>
|
||||||
@@ -305,6 +323,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="outlinePanel" class="form-dialog">
|
<dialog id="outlinePanel" class="form-dialog">
|
||||||
<form id="outlineForm">
|
<form id="outlineForm">
|
||||||
|
<h2 id="outlinePanelTitle" class="form-title">新增目录</h2>
|
||||||
<div class="form-row"><label for="outlineTitle">标题</label><input id="outlineTitle" type="text" required autofocus></div>
|
<div class="form-row"><label for="outlineTitle">标题</label><input id="outlineTitle" type="text" required autofocus></div>
|
||||||
<div class="form-row"><label for="outlineParent">上级</label><select id="outlineParent"></select></div>
|
<div class="form-row"><label for="outlineParent">上级</label><select id="outlineParent"></select></div>
|
||||||
<div class="form-row"><label for="outlinePage">页码</label><input id="outlinePage" type="number" min="1" step="1" placeholder="无"></div>
|
<div class="form-row"><label for="outlinePage">页码</label><input id="outlinePage" type="number" min="1" step="1" placeholder="无"></div>
|
||||||
@@ -314,6 +333,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="objectBoundsPanel" class="form-dialog">
|
<dialog id="objectBoundsPanel" class="form-dialog">
|
||||||
<form id="objectBoundsForm">
|
<form id="objectBoundsForm">
|
||||||
|
<h2 id="objectBoundsTitle" class="form-title">位置尺寸</h2>
|
||||||
<div class="form-pair">
|
<div class="form-pair">
|
||||||
<div class="form-row"><label for="objectX">横向</label><div class="form-unit"><input id="objectX" type="number" step="any" required autofocus><span>mm</span></div></div>
|
<div class="form-row"><label for="objectX">横向</label><div class="form-unit"><input id="objectX" type="number" step="any" required autofocus><span>mm</span></div></div>
|
||||||
<div class="form-row"><label for="objectY">纵向</label><div class="form-unit"><input id="objectY" type="number" step="any" required><span>mm</span></div></div>
|
<div class="form-row"><label for="objectY">纵向</label><div class="form-unit"><input id="objectY" type="number" step="any" required><span>mm</span></div></div>
|
||||||
@@ -328,6 +348,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="createPanel" class="form-dialog">
|
<dialog id="createPanel" class="form-dialog">
|
||||||
<form id="createForm">
|
<form id="createForm">
|
||||||
|
<h2 id="createTitle" class="form-title">新建文档</h2>
|
||||||
<div id="createModeRow" class="form-row"><label for="createMode">来源</label><select id="createMode"><option value="blank">空白</option><option value="images">图片</option></select></div>
|
<div id="createModeRow" class="form-row"><label for="createMode">来源</label><select id="createMode"><option value="blank">空白</option><option value="images">图片</option></select></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="createName">名称</label>
|
<label for="createName">名称</label>
|
||||||
@@ -356,6 +377,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="importPanel" class="form-dialog">
|
<dialog id="importPanel" class="form-dialog">
|
||||||
<form id="importForm">
|
<form id="importForm">
|
||||||
|
<h2 id="importTitle" class="form-title">插入页面</h2>
|
||||||
<div class="form-row"><label for="importFile">文档</label><input id="importFile" type="file" accept=".ofd" required autofocus></div>
|
<div class="form-row"><label for="importFile">文档</label><input id="importFile" type="file" accept=".ofd" required autofocus></div>
|
||||||
<div class="form-row"><label for="importRange">范围</label><select id="importRange"><option value="all">全部</option><option value="custom">指定</option></select></div>
|
<div class="form-row"><label for="importRange">范围</label><select id="importRange"><option value="all">全部</option><option value="custom">指定</option></select></div>
|
||||||
<div id="importDocuments" class="document-list" hidden></div>
|
<div id="importDocuments" class="document-list" hidden></div>
|
||||||
@@ -367,6 +389,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="paragraphPanel" class="form-dialog">
|
<dialog id="paragraphPanel" class="form-dialog">
|
||||||
<form id="paragraphForm">
|
<form id="paragraphForm">
|
||||||
|
<h2 id="paragraphTitle" class="form-title">段落设置</h2>
|
||||||
<div class="form-row"><label for="firstLineIndent">首行</label><div class="form-unit"><input id="firstLineIndent" type="number" step="any" required autofocus><span>mm</span></div></div>
|
<div class="form-row"><label for="firstLineIndent">首行</label><div class="form-unit"><input id="firstLineIndent" type="number" step="any" required autofocus><span>mm</span></div></div>
|
||||||
<div class="form-pair">
|
<div class="form-pair">
|
||||||
<div class="form-row"><label for="leftIndent">左侧</label><div class="form-unit"><input id="leftIndent" type="number" step="any" required><span>mm</span></div></div>
|
<div class="form-row"><label for="leftIndent">左侧</label><div class="form-unit"><input id="leftIndent" type="number" step="any" required><span>mm</span></div></div>
|
||||||
@@ -378,7 +401,7 @@
|
|||||||
<dialog id="resourcePanel" class="form-dialog resource-dialog">
|
<dialog id="resourcePanel" class="form-dialog resource-dialog">
|
||||||
<form id="resourceForm">
|
<form id="resourceForm">
|
||||||
<h2 id="resourceTitle" class="form-title">资源管理</h2>
|
<h2 id="resourceTitle" class="form-title">资源管理</h2>
|
||||||
<div class="form-row"><label for="resourceKind">类型</label><select id="resourceKind"><option value="Font">字体</option><option value="Image">图片</option><option value="Audio">音频</option><option value="Video">视频</option><option value="Attachment">附件</option><option value="ColorSpace">色彩</option><option value="DrawParam">样式</option></select></div>
|
<div id="resourceTabs" class="panel-tabs"></div>
|
||||||
<div class="resource-list-container">
|
<div class="resource-list-container">
|
||||||
<div class="resource-heading"><span>名称</span><span>大小</span><span>引用</span></div>
|
<div class="resource-heading"><span>名称</span><span>大小</span><span>引用</span></div>
|
||||||
<div class="resource-list-body">
|
<div class="resource-list-body">
|
||||||
@@ -422,13 +445,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="appearancePanel" class="form-dialog appearance-dialog">
|
||||||
|
<form method="dialog">
|
||||||
|
<div class="appearance-heading"><h2 id="appearanceTitle" class="form-title">设置</h2><button class="appearance-close" type="submit" title="关闭"><span class="close-icon"></span></button></div>
|
||||||
|
<div class="appearance-label">外观主题</div>
|
||||||
|
<div class="appearance-themes">
|
||||||
|
<button type="button" data-theme-choice="system"><span class="theme-system-icon"></span><span>系统</span></button>
|
||||||
|
<button type="button" data-theme-choice="light"><span class="theme-light-icon"></span><span>浅色</span></button>
|
||||||
|
<button type="button" data-theme-choice="dark"><span class="theme-dark-icon"></span><span>深色</span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<section id="associationSection" class="appearance-associations" hidden>
|
||||||
|
<div class="appearance-label">文件关联</div>
|
||||||
|
<div class="association-row" data-scope="user"><span>当前用户</span><span class="association-state"></span><div class="association-actions"><button class="small-button" type="button" data-action="register">注册</button><button class="small-button" type="button" data-action="remove">撤销</button></div></div>
|
||||||
|
<div class="association-row" data-scope="machine"><span>所有用户</span><span class="association-state"></span><div class="association-actions"><button class="small-button" type="button" data-action="register">注册</button><button class="small-button" type="button" data-action="remove">撤销</button></div></div>
|
||||||
|
<div id="associationMessage" class="form-status" hidden></div>
|
||||||
|
</section>
|
||||||
|
</dialog>
|
||||||
<dialog id="settingsPanel" class="form-dialog settings-dialog">
|
<dialog id="settingsPanel" class="form-dialog settings-dialog">
|
||||||
<form id="settingsForm">
|
<form id="settingsForm">
|
||||||
<header class="settings-header">
|
<header class="settings-header">
|
||||||
<h2 id="settingsTitle" class="form-title">文档配置</h2>
|
<h2 id="settingsTitle" class="form-title">文档配置</h2>
|
||||||
<span id="settingsLocation" class="settings-location"></span>
|
<span id="settingsLocation" class="settings-location"></span>
|
||||||
</header>
|
</header>
|
||||||
<label class="form-row"><span>类型</span><select id="settingsKind"><option value="bookmarks">书签</option><option value="actions">动作</option><option value="templates">模板</option><option value="layers">图层</option><option value="extensions">扩展</option><option value="ColorSpace" hidden>色彩</option><option value="DrawParam" hidden>样式</option></select></label>
|
<div id="settingsTabs" class="panel-tabs"></div>
|
||||||
<div id="settingsBody" class="settings-body">
|
<div id="settingsBody" class="settings-body">
|
||||||
<div id="settingsContext"></div>
|
<div id="settingsContext"></div>
|
||||||
<div id="settingsContent"></div>
|
<div id="settingsContent"></div>
|
||||||
@@ -459,15 +499,28 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="infoPanel" class="form-dialog">
|
<dialog id="infoPanel" class="form-dialog">
|
||||||
<form id="infoForm">
|
<form id="infoForm">
|
||||||
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label>
|
<h2 id="infoPanelTitle" class="form-title">文档信息</h2>
|
||||||
<label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label>
|
<label class="form-row"><span>文档标识</span><input id="infoDocId" type="text"></label>
|
||||||
<label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label>
|
<label class="form-row"><span>标题</span><textarea id="infoTitle" rows="1" autofocus></textarea></label>
|
||||||
<details class="info-custom"><summary>扩展</summary><div id="infoCustomRows"></div><button id="infoCustomAdd" class="icon-button" type="button" title="添加字段">+</button></details>
|
<label class="form-row"><span>作者</span><textarea id="infoAuthor" rows="1"></textarea></label>
|
||||||
|
<label class="form-row"><span>主题</span><textarea id="infoSubject" rows="1"></textarea></label>
|
||||||
|
<label class="form-row"><span>摘要</span><textarea id="infoAbstract" rows="2"></textarea></label>
|
||||||
|
<details><summary><span>文档属性</span></summary>
|
||||||
|
<label class="form-row"><span>创建日期</span><input id="infoCreationDate" type="text"></label>
|
||||||
|
<label class="form-row"><span>修改日期</span><input id="infoModDate" type="text"></label>
|
||||||
|
<label class="form-row"><span>文档用途</span><input id="infoDocUsage" type="text"></label>
|
||||||
|
<label class="form-row"><span>封面路径</span><input id="infoCover" type="text"></label>
|
||||||
|
<details><summary><span>关键词</span></summary><div id="infoKeywordRows" class="info-rows"></div><button id="infoKeywordAdd" class="icon-button" type="button" title="添加词条">+</button></details>
|
||||||
|
<label class="form-row"><span>制作软件</span><input id="infoCreator" type="text"></label>
|
||||||
|
<label class="form-row"><span>软件版本</span><input id="infoCreatorVersion" type="text"></label>
|
||||||
|
</details>
|
||||||
|
<details><summary><span>扩展属性</span></summary><div id="infoCustomRows" class="info-rows"></div><button id="infoCustomAdd" class="icon-button" type="button" title="添加属性">+</button></details>
|
||||||
<div class="form-actions"><div id="infoStatus" class="form-status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
|
<div class="form-actions"><div id="infoStatus" class="form-status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="annotationCreate" class="form-dialog">
|
<dialog id="annotationCreate" class="form-dialog">
|
||||||
<form id="annotationCreateForm">
|
<form id="annotationCreateForm">
|
||||||
|
<h2 id="annotationCreateTitle" class="form-title">添加批注</h2>
|
||||||
<label class="form-row" data-annotation="watermark"><span>类型</span><select id="annotationContent"><option value="text">文字</option><option value="image">图片</option></select></label>
|
<label class="form-row" data-annotation="watermark"><span>类型</span><select id="annotationContent"><option value="text">文字</option><option value="image">图片</option></select></label>
|
||||||
<label class="form-row" data-annotation="replace"><span>原文</span><select id="annotationOld"></select></label>
|
<label class="form-row" data-annotation="replace"><span>原文</span><select id="annotationOld"></select></label>
|
||||||
<label class="form-row" data-annotation="replace"><span>字体</span><select id="annotationFont"><option value="keep">原有</option><option value="current">当前</option></select></label>
|
<label class="form-row" data-annotation="replace"><span>字体</span><select id="annotationFont"><option value="keep">原有</option><option value="current">当前</option></select></label>
|
||||||
@@ -497,10 +550,15 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="objectPicker" class="form-dialog">
|
<dialog id="objectPicker" class="form-dialog">
|
||||||
<form method="dialog"><div id="objectPickerList" class="object-picker-list"></div><div class="form-actions"><button class="button" type="submit">关闭</button></div></form>
|
<form method="dialog">
|
||||||
|
<h2 id="objectPickerTitle" class="form-title">选择对象</h2>
|
||||||
|
<div id="objectPickerList" class="object-picker-list"></div>
|
||||||
|
<div class="form-actions"><button class="button" type="submit">关闭</button></div>
|
||||||
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="annotationNote" class="form-dialog">
|
<dialog id="annotationNote" class="form-dialog">
|
||||||
<form id="annotationForm" method="dialog">
|
<form id="annotationForm" method="dialog">
|
||||||
|
<h2 id="annotationNoteTitle" class="form-title">注解详情</h2>
|
||||||
<div id="annotationDetail" class="annotation-detail"></div>
|
<div id="annotationDetail" class="annotation-detail"></div>
|
||||||
<div id="annotationFields" hidden>
|
<div id="annotationFields" hidden>
|
||||||
<div class="form-row"><label for="annotationRemark">说明</label><textarea id="annotationRemark" rows="4"></textarea></div>
|
<div class="form-row"><label for="annotationRemark">说明</label><textarea id="annotationRemark" rows="4"></textarea></div>
|
||||||
@@ -511,6 +569,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="pagePanel" class="form-dialog">
|
<dialog id="pagePanel" class="form-dialog">
|
||||||
<form id="pageForm">
|
<form id="pageForm">
|
||||||
|
<h2 id="pageTitle" class="form-title">页面设置</h2>
|
||||||
<div id="pageRangeRow" class="form-row" hidden><span>范围</span><output id="pageRange"></output></div>
|
<div id="pageRangeRow" class="form-row" hidden><span>范围</span><output id="pageRange"></output></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<span id="pageDirectionLabel">方向</span>
|
<span id="pageDirectionLabel">方向</span>
|
||||||
@@ -548,6 +607,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="insertPanel" class="form-dialog">
|
<dialog id="insertPanel" class="form-dialog">
|
||||||
<form id="insertForm">
|
<form id="insertForm">
|
||||||
|
<h2 id="insertTitle" class="form-title">添加图片</h2>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="insertImage">图片</label>
|
<label for="insertImage">图片</label>
|
||||||
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,.bmp,.tif,.tiff,image/png,image/jpeg,image/bmp,image/tiff" required autofocus>
|
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,.bmp,.tif,.tiff,image/png,image/jpeg,image/bmp,image/tiff" required autofocus>
|
||||||
@@ -640,6 +700,43 @@
|
|||||||
<div class="form-actions"><div id="encryptionStatus" class="form-status"></div><button id="encryptionCancel" class="button" type="button">取消</button><button id="encryptionSubmit" class="button" type="submit">确定</button></div>
|
<div class="form-actions"><div id="encryptionStatus" class="form-status"></div><button id="encryptionCancel" class="button" type="button">取消</button><button id="encryptionSubmit" class="button" type="submit">确定</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="printPanel" class="form-dialog print-dialog">
|
||||||
|
<form id="printForm">
|
||||||
|
<h2 id="printTitle" class="form-title">文档打印</h2>
|
||||||
|
<div class="print-content">
|
||||||
|
<div class="print-settings-panel">
|
||||||
|
<fieldset id="printSettings" class="print-settings">
|
||||||
|
<div class="form-row"><label for="printPrinter">打印设备</label><div class="print-field"><select id="printPrinter"><option value="browser">系统打印</option><option value="pdf">另存输出</option></select><button id="printProperties" class="small-button" type="button" hidden>设备属性</button></div></div>
|
||||||
|
<div class="form-row" data-print-row="device" hidden><label for="printCopies">打印份数</label><div class="print-field"><input id="printCopies" type="number" min="1" max="999" value="1"><label class="print-check"><input id="printCollation" type="checkbox" checked>逐份打印</label></div></div>
|
||||||
|
<div class="form-row"><label for="printScope">打印范围</label><select id="printScope"><option value="all">全部页面</option><option value="current">当前页面</option><option value="selected">所选页面</option><option value="range">指定页码</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="range" hidden><label for="printRange">指定页码</label><input id="printRange" type="text" placeholder="1-3,5" autocomplete="off" spellcheck="false"></div>
|
||||||
|
<div class="form-row"><label for="printParity">奇偶筛选</label><div class="print-field"><select id="printParity"><option value="all">全部页面</option><option value="odd">奇数页面</option><option value="even">偶数页面</option></select><label class="print-check"><input id="printReverse" type="checkbox">逆序打印</label></div></div>
|
||||||
|
<div class="form-row"><label for="printMode">页面排版</label><select id="printMode"><option value="single">单页打印</option><option value="multiple">多页合印</option><option value="booklet">小册打印</option><option value="poster">海报拼接</option></select></div>
|
||||||
|
<div class="form-row"><label for="printPaper">纸张大小</label><select id="printPaper"><option value="210,297">A4</option><option value="297,420">A3</option><option value="148,210">A5</option><option value="176,250">B5</option><option value="215.9,279.4">Letter</option><option value="215.9,355.6">Legal</option><option value="source">原页尺寸</option><option value="custom">自定尺寸</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="paper" hidden><label for="printWidth">纸张尺寸</label><div class="print-field"><input id="printWidth" type="number" value="210" min="1" max="5000" step="any" title="纸张宽度"><span>×</span><input id="printHeight" type="number" value="297" min="1" max="5000" step="any" title="纸张高度"><span>mm</span></div></div>
|
||||||
|
<div class="form-row"><label for="printOrientation">纸张方向</label><select id="printOrientation"><option value="portrait">纵向纸张</option><option value="landscape">横向纸张</option></select></div>
|
||||||
|
<div class="form-row"><label for="printScaling">页面缩放</label><select id="printScaling"><option value="shrink">缩小大页</option><option value="fit">适合纸张</option><option value="actual">实际大小</option><option value="custom">自定比例</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="scale" hidden><label for="printScale">缩放比例</label><div class="print-field"><input id="printScale" type="number" min="1" max="10000" value="100" step="1"><span>%</span></div></div>
|
||||||
|
<div class="form-row"><span>额外边距</span><div class="print-field print-margins"><input id="printMarginTop" type="number" min="0" value="0" step="0.1" title="上边距"><input id="printMarginRight" type="number" min="0" value="0" step="0.1" title="右边距"><input id="printMarginBottom" type="number" min="0" value="0" step="0.1" title="下边距"><input id="printMarginLeft" type="number" min="0" value="0" step="0.1" title="左边距"><span>mm</span></div></div>
|
||||||
|
<div class="form-row" data-print-row="multiple" hidden><label for="printGrid">每面页数</label><select id="printGrid"><option value="1,2">2 页</option><option value="2,2">4 页</option><option value="2,3">6 页</option><option value="3,3">9 页</option><option value="4,4">16 页</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="multiple" hidden><label for="printPageOrder">排列顺序</label><select id="printPageOrder"><option value="horizontal">横向顺序</option><option value="horizontalReverse">横向逆序</option><option value="vertical">纵向顺序</option><option value="verticalReverse">纵向逆序</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="gap" hidden><label for="printGap">页面间距</label><div class="print-field"><input id="printGap" type="number" min="0" value="4" step="0.1"><span>mm</span></div></div>
|
||||||
|
<div class="form-row" data-print-row="booklet" hidden><label for="printBinding">装订方向</label><select id="printBinding"><option value="left">左侧装订</option><option value="right">右侧装订</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="booklet" hidden><label for="printBookletSheets">每册纸数</label><input id="printBookletSheets" type="number" min="0" max="2500" placeholder="不分册" title="0表示不分册"></div>
|
||||||
|
<div class="form-row" data-print-row="booklet" hidden><label for="printSheetFrom">纸张范围</label><div class="print-field"><input id="printSheetFrom" type="number" min="1" placeholder="起始" title="起始纸张编号"><span>至</span><input id="printSheetTo" type="number" min="1" placeholder="结束" title="结束纸张编号"></div></div>
|
||||||
|
<div class="form-row" data-print-row="poster" hidden><label for="printOverlap">重叠宽度</label><div class="print-field"><input id="printOverlap" type="number" min="0" value="5" step="0.1"><span>mm</span></div></div>
|
||||||
|
<div class="form-row"><label for="printDuplex">双面方式</label><select id="printDuplex"><option value="simplex">单面打印</option><option value="longEdge">自动长边</option><option value="shortEdge">自动短边</option><option value="manualLongEdge">手动长边</option><option value="manualShortEdge">手动短边</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="sides" hidden><label for="printSides">打印纸面</label><select id="printSides"><option value="all">正反两面</option><option value="front">只印正面</option><option value="back">只印背面</option></select></div>
|
||||||
|
<div class="form-row" data-print-row="manual" hidden><span>背面处理</span><div class="print-field"><label class="print-check"><input id="printBackReverse" type="checkbox">倒序送纸</label><label class="print-check" title="背面旋转180°"><input id="printBackRotate" type="checkbox">背面旋转</label></div></div>
|
||||||
|
<div class="form-row"><label for="printContents">打印内容</label><select id="printContents"><option value="documentAndAnnotations">文档注解</option><option value="document">文档表单</option><option value="annotations">仅限注解</option><option value="forms">仅限表单</option></select></div>
|
||||||
|
<div class="print-options"><label><input id="printRotate" type="checkbox" checked>自动旋转</label><label><input id="printGrayscale" type="checkbox">灰度打印</label><label><input id="printBorders" type="checkbox">页面边框</label><label><input id="printCropMarks" type="checkbox">裁切标记</label><label><input id="printRaster" type="checkbox">图像打印</label><div class="form-select"><select id="printDPI" title="图像精度"><option value="96">96 DPI</option><option value="150">150 DPI</option><option value="300" selected>300 DPI</option><option value="600">600 DPI</option></select><span id="printDPIValue">300</span></div><label data-print-row="poster" title="仅拼接超出纸面的页面" hidden><input id="printTileOnlyLarge" type="checkbox" checked>仅拼大页</label><label data-print-row="poster" hidden><input id="printLabels" type="checkbox" checked>拼接标签</label></div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions"><button id="printCancel" class="button" type="button">关闭</button><button id="printSubmit" class="button" type="submit" disabled>打印</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="print-preview"><div class="print-preview-stage"><div id="printPreview" class="print-preview-page"></div><div id="printProgress" class="progress-panel print-progress"><div id="printSpinner" class="progress-spinner" hidden></div><div id="printStatus" class="progress-label"></div><div id="printProgressTrack" class="progress-track" hidden><div id="printProgressBar" class="progress-bar"></div></div></div></div><div class="print-preview-tools"><button id="printPrevious" class="icon-button arrow-prev" type="button" title="上一页"></button><label id="printPageControl" class="page-control"><input id="printPreviewNumber" type="number" min="1" value="1" title="预览纸面"><span>/</span><span id="printPageCount">0</span></label><button id="printNext" class="icon-button arrow-next" type="button" title="下一页"></button></div></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<dialog id="exportPanel" class="form-dialog">
|
<dialog id="exportPanel" class="form-dialog">
|
||||||
<form id="exportForm">
|
<form id="exportForm">
|
||||||
<h2 id="exportTitle" class="form-title">导出文档</h2>
|
<h2 id="exportTitle" class="form-title">导出文档</h2>
|
||||||
@@ -791,7 +888,7 @@
|
|||||||
<section id="signaturePanel" class="signature-panel" hidden>
|
<section id="signaturePanel" class="signature-panel" hidden>
|
||||||
<div class="panel-row">
|
<div class="panel-row">
|
||||||
<div id="signaturePanelTitle" class="panel-title compact">签名</div>
|
<div id="signaturePanelTitle" class="panel-title compact">签名</div>
|
||||||
<span id="signatureSummary" class="count-pill">0</span>
|
<div class="panel-actions"><span id="signatureSummary" class="count-pill">0</span><button id="signatureClear" class="small-button" type="button" hidden>清空</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="signatureList" class="signature-list"></div>
|
<div id="signatureList" class="signature-list"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -819,14 +916,13 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="app-footer app-bar">
|
<footer class="app-footer app-bar">
|
||||||
<a class="footer-brand" href="https://github.com/xiaoqidun/ofdgo" target="_blank" rel="noreferrer">OFDGo</a>
|
<div class="footer-side"><a class="footer-action" href="https://aite.me/" target="_blank" rel="noreferrer">官方网站</a></div>
|
||||||
<div id="statusText" class="status-text">正在准备引擎</div>
|
<div id="statusText" class="status-text">正在准备引擎</div>
|
||||||
<nav class="footer-links">
|
<div class="footer-side footer-side-right"><button id="aboutButton" class="footer-action" type="button">软件信息</button></div>
|
||||||
<a href="https://aite.me/" target="_blank" rel="noreferrer">官方网站</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="ofdgoPrintDocument">请先在OFDGo中打开打印预览,准备完整文档后打印。</div>
|
||||||
<script type="module" src="./ofdgo.js"></script>
|
<script type="module" src="./ofdgo.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+1007
-300
File diff suppressed because it is too large.
Load diff
+3448
-2727
File diff suppressed because it is too large.
Load diff
+586
-170
File diff suppressed because it is too large.
Load diff
+100
-23
@@ -1,17 +1,18 @@
|
|||||||
const DATABASE = "ofdgo";
|
const DATABASE = "ofdgo";
|
||||||
|
|
||||||
export class FontPicker {
|
export class FontPicker {
|
||||||
constructor(input, toggle, list, onChange, onOpen) {
|
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
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("input", () => this.show(input.value));
|
||||||
input.addEventListener("focus", () => input.select());
|
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("keydown", event => this.keyDown(event));
|
||||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||||
toggle.addEventListener("click", () => {
|
toggle.addEventListener("click", () => {
|
||||||
if (this.open) this.close();
|
if (this.open) this.close();
|
||||||
else { input.focus(); this.show(); }
|
else { input.focus(); input.select(); this.show(); }
|
||||||
});
|
});
|
||||||
list.addEventListener("pointerdown", event => {
|
list.addEventListener("pointerdown", event => {
|
||||||
if (event.target.closest(".font-option")) event.preventDefault();
|
if (event.target.closest(".font-option")) event.preventDefault();
|
||||||
@@ -23,17 +24,40 @@ export class FontPicker {
|
|||||||
window.addEventListener("resize", () => this.close());
|
window.addEventListener("resize", () => this.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
setFonts(fonts, selected) {
|
setFonts(fonts, selected, preserve = false) {
|
||||||
this.close();
|
const open = preserve && this.open;
|
||||||
|
if (!open) this.close();
|
||||||
this.fonts = fonts;
|
this.fonts = fonts;
|
||||||
|
this.groups = fonts.map((_, index) => [index]);
|
||||||
this.names = new Map();
|
this.names = new Map();
|
||||||
for (const font of fonts) {
|
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);
|
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.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) {
|
setDisabled(disabled) {
|
||||||
@@ -43,23 +67,34 @@ export class FontPicker {
|
|||||||
|
|
||||||
restore() {
|
restore() {
|
||||||
const font = this.fonts[Number(this.value)];
|
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.scrollLeft = 0;
|
||||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceLabel(font) {
|
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 "";
|
if (this.names.get(name) < 2) return "";
|
||||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||||
}
|
}
|
||||||
|
|
||||||
show(query = "", load = true) {
|
show(query = "", load = true) {
|
||||||
if (this.input.disabled) return;
|
if (this.input.disabled) return;
|
||||||
|
this.query = query;
|
||||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||||
const terms = normalize(query).split(" ").filter(Boolean);
|
const terms = normalize(query).split(" ").filter(Boolean);
|
||||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
this.matches = this.groups.map(group => {
|
||||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
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));
|
return terms.every(term => name.includes(term));
|
||||||
});
|
});
|
||||||
this.list.replaceChildren();
|
this.list.replaceChildren();
|
||||||
@@ -70,7 +105,7 @@ export class FontPicker {
|
|||||||
option.dataset.index = String(index);
|
option.dataset.index = String(index);
|
||||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.textContent = font.fullName || font.name;
|
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||||
option.append(name);
|
option.append(name);
|
||||||
const source = this.sourceLabel(font);
|
const source = this.sourceLabel(font);
|
||||||
if (source) {
|
if (source) {
|
||||||
@@ -91,7 +126,7 @@ export class FontPicker {
|
|||||||
if (!this.open) this.list.showPopover();
|
if (!this.open) this.list.showPopover();
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.position();
|
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);
|
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||||
if (load) this.onOpen?.();
|
if (load) this.onOpen?.();
|
||||||
}
|
}
|
||||||
@@ -119,16 +154,27 @@ export class FontPicker {
|
|||||||
const changed = this.value !== String(index);
|
const changed = this.value !== String(index);
|
||||||
this.value = String(index);
|
this.value = String(index);
|
||||||
this.close();
|
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() {
|
commitExact() {
|
||||||
const value = this.input.value.trim().toLocaleLowerCase();
|
const value = this.input.value.trim().toLocaleLowerCase();
|
||||||
const matches = font => font && !font.disabled
|
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 selected = Number(this.value);
|
||||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
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() {
|
close() {
|
||||||
@@ -137,7 +183,7 @@ export class FontPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyDown(event) {
|
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;
|
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -314,13 +360,44 @@ export class FontManager {
|
|||||||
}), ...this.catalog];
|
}), ...this.catalog];
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFaces(inspect) {
|
async loadFaces(inspect, current = () => true) {
|
||||||
while (true) {
|
while (current()) {
|
||||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
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)); }
|
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) {
|
async add(fonts) {
|
||||||
|
|||||||
+617
-28
@@ -1,19 +1,545 @@
|
|||||||
let engineModule = null;
|
let engineModule = null;
|
||||||
|
let engineScheduler = null;
|
||||||
|
let engineSequence = 0;
|
||||||
|
const engineRuntimes = new Map();
|
||||||
|
|
||||||
export function createEngineWorker() {
|
function requestKey(worker, data) {
|
||||||
if (nativeHost) return nativeHost.worker();
|
return worker.shared ? JSON.stringify([data.engineID, data.id]) : data.id;
|
||||||
const worker = new Worker("./ofdgo_wasm.js");
|
}
|
||||||
worker.addEventListener("message", event => {
|
|
||||||
|
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;
|
if (event.data.type !== "module") return;
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; });
|
engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; });
|
||||||
engineModule.then(module => worker.postMessage({ type: "module", module }), error => worker.postMessage({ type: "module", error: error.message }));
|
engineModule.then(module => { if (!this.closed) this.worker.postMessage({ type:"module", module }); }, error => { if (!this.closed) this.worker.postMessage({ type:"module", error:error.message }); });
|
||||||
});
|
});
|
||||||
return worker;
|
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) {
|
export async function documentBytes(file) {
|
||||||
return file?.nativeFile ? file : new Uint8Array(await file.arrayBuffer());
|
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) {
|
export function saveFilePicker(options) {
|
||||||
@@ -87,6 +613,10 @@ class NativeHandle {
|
|||||||
class NativeHost {
|
class NativeHost {
|
||||||
constructor(bridge) {
|
constructor(bridge) {
|
||||||
this.bridge = 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.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => {
|
||||||
this.call("release", {file}).catch(() => {});
|
this.call("release", {file}).catch(() => {});
|
||||||
}) : null;
|
}) : null;
|
||||||
@@ -96,25 +626,40 @@ class NativeHost {
|
|||||||
return this.bridge.call(`ofdgo.host.${name}`, value, {signal});
|
return this.bridge.call(`ofdgo.host.${name}`, value, {signal});
|
||||||
}
|
}
|
||||||
|
|
||||||
async decode(value, name = "", key = "", signal) {
|
async readResource(file, signal) {
|
||||||
if (value?.nativeFile) {
|
if (this.reading >= this.parallelism) await new Promise(resolve => this.readQueue.push(resolve));
|
||||||
const file = new NativeFile(value);
|
else this.reading++;
|
||||||
if (key === "file" || name === "ofdgoConvertPDF" && key === "bytes") return file;
|
|
||||||
try {
|
try {
|
||||||
const bytes = await file.arrayBuffer(signal);
|
signal?.throwIfAborted();
|
||||||
return key === "blob" ? new Blob([bytes], {type:value.type}) : new Uint8Array(bytes);
|
return await file.arrayBuffer(signal);
|
||||||
} finally {
|
} finally {
|
||||||
await this.call("release", {file:value.nativeFile});
|
try { await this.call("release", {file:file.nativeFile}); }
|
||||||
|
finally {
|
||||||
|
const next = this.readQueue.shift();
|
||||||
|
if (next) next();
|
||||||
|
else this.reading--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (let i = 0; i < value.length; i++) value[i] = await this.decode(value[i], name, "", signal);
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
if (value && typeof value === "object") {
|
|
||||||
for (const [field, item] of Object.entries(value)) value[field] = await this.decode(item, name, field, signal);
|
async decode(value, signal) {
|
||||||
return value;
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,21 +679,38 @@ class NativeHost {
|
|||||||
let closed = false;
|
let closed = false;
|
||||||
let unsubscribe;
|
let unsubscribe;
|
||||||
let pending = Promise.resolve();
|
let pending = Promise.resolve();
|
||||||
|
let termination = null;
|
||||||
|
const readers = new Set();
|
||||||
|
const inputs = new Set();
|
||||||
const requests = new Map();
|
const requests = new Map();
|
||||||
const completions = new Map();
|
const completions = new Map();
|
||||||
const emit = data => {
|
const emit = data => {
|
||||||
if (data.type === "result") { completions.get(data.id)?.(data.result); return; }
|
if (data.type === "result") { completions.get(data.id)?.(data.result); return; }
|
||||||
if (!closed) worker.onmessage?.({data});
|
if (!closed) worker.onmessage?.({data:{...data, engineID:worker.engineID}});
|
||||||
};
|
};
|
||||||
const ready = host.call("engine.open", {}).then(({engine}) => {
|
const ready = host.call("engine.open", {}).then(({engine, parallelism, cacheBytes}) => {
|
||||||
|
worker.engineID = engine;
|
||||||
if (closed) return 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);
|
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
|
||||||
emit({type:"ready"});
|
emit({type:"ready"});
|
||||||
return engine;
|
return engine;
|
||||||
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
|
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
|
||||||
const worker = {
|
const worker = {
|
||||||
|
engineID:null,
|
||||||
|
foreground:true,
|
||||||
|
pagesPaused:false,
|
||||||
onmessage:null,
|
onmessage:null,
|
||||||
onerror:null,
|
onerror:null,
|
||||||
|
configurePages() {},
|
||||||
|
setForeground(foreground) {
|
||||||
|
worker.foreground = !!foreground;
|
||||||
|
worker.pausePages(!worker.foreground);
|
||||||
|
},
|
||||||
|
pausePages(paused = true) {
|
||||||
|
worker.pagesPaused = !!paused;
|
||||||
|
},
|
||||||
postMessage(data) {
|
postMessage(data) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
if (data.type === "cancel") {
|
if (data.type === "cancel") {
|
||||||
@@ -156,11 +718,18 @@ class NativeHost {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.type === "font") 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();
|
const controller = new AbortController();
|
||||||
requests.set(data.id, controller);
|
requests.set(data.id, controller);
|
||||||
const operation = pending.then(async () => {
|
const parallel = data.name === "ofdgoRenderPage";
|
||||||
|
const barrier = parallel ? pending : Promise.all([pending, ...readers]);
|
||||||
|
const operation = barrier.then(async () => {
|
||||||
const engine = await ready;
|
const engine = await ready;
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
if (data.preload && worker.pagesPaused) throw new DOMException("预加载已暂停", "AbortError");
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
const args = {engine, id:data.id, name:data.name, args:data.args};
|
const args = {engine, id:data.id, name:data.name, args:data.args};
|
||||||
let result;
|
let result;
|
||||||
@@ -182,24 +751,30 @@ class NativeHost {
|
|||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
decoded = await host.decode(result, data.name, "", controller.signal);
|
decoded = await host.decode(result, controller.signal);
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
}
|
}
|
||||||
catch (error) { await host.release(result); throw error; }
|
catch (error) { await host.release(result); throw error; }
|
||||||
if (closed) { await host.release(result); return; }
|
if (closed) { await host.release(result); return; }
|
||||||
emit({id:data.id, ...decoded});
|
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));
|
}).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => requests.delete(data.id));
|
||||||
if (data.name !== "ofdgoSaveSnapshot") pending = operation;
|
if (parallel) {
|
||||||
|
readers.add(operation);
|
||||||
|
operation.finally(() => readers.delete(operation));
|
||||||
|
} else if (data.name !== "ofdgoSaveSnapshot") pending = operation;
|
||||||
},
|
},
|
||||||
terminate() {
|
terminate() {
|
||||||
if (closed) return;
|
if (closed) return termination;
|
||||||
closed = true;
|
closed = true;
|
||||||
for (const controller of requests.values()) controller.abort();
|
for (const controller of requests.values()) controller.abort();
|
||||||
ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(() => {
|
termination = ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(async () => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"});
|
for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"});
|
||||||
completions.clear();
|
completions.clear();
|
||||||
|
await Promise.all([...inputs].map(file => host.call("release", {file}).catch(() => {})));
|
||||||
|
inputs.clear();
|
||||||
});
|
});
|
||||||
|
return termination;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ready.catch(() => {});
|
ready.catch(() => {});
|
||||||
@@ -219,13 +794,27 @@ class NativeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(onFiles, onError) {
|
async start(onFiles, onError) {
|
||||||
this.bridge.on("ofdgo.files", values => onFiles(values.map(value => new NativeFile(value))));
|
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)));
|
this.bridge.on("ofdgo.error", message => onError(new Error(message)));
|
||||||
const title = document.querySelector("title");
|
const title = document.querySelector("title");
|
||||||
if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true});
|
if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true});
|
||||||
await this.call("ready", {});
|
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) {
|
installFileInputs(onError) {
|
||||||
document.addEventListener("click", async event => {
|
document.addEventListener("click", async event => {
|
||||||
const input = event.target;
|
const input = event.target;
|
||||||
|
|||||||
@@ -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,324 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+126
-47
@@ -1,12 +1,15 @@
|
|||||||
importScripts("./wasm_exec.js");
|
importScripts("./wasm_exec.js");
|
||||||
|
|
||||||
let pending = Promise.resolve();
|
const engines = new Map();
|
||||||
const operations = new Map();
|
let resolveRuntime;
|
||||||
const fontRequests = new Map();
|
let rejectRuntime;
|
||||||
|
const runtimeReady = new Promise((resolve, reject) => { resolveRuntime = resolve; rejectRuntime = reject; });
|
||||||
|
runtimeReady.catch(() => {});
|
||||||
let fontSequence = 0;
|
let fontSequence = 0;
|
||||||
let engineMemory = null;
|
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; }
|
if (signal.aborted) { done(null, "", true); return; }
|
||||||
const request = ++fontSequence;
|
const request = ++fontSequence;
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -18,7 +21,7 @@ function requestFont(id, signal, name, done) {
|
|||||||
signal.removeEventListener("abort", cancel);
|
signal.removeEventListener("abort", cancel);
|
||||||
done(result.bytes, result.error || "", signal.aborted);
|
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 }) => {
|
self.onmessage = ({ data }) => {
|
||||||
if (data.type === "module") {
|
if (data.type === "module") {
|
||||||
@@ -26,24 +29,71 @@ self.onmessage = ({ data }) => {
|
|||||||
else loadWASM(data.module).catch(exitWASM);
|
else loadWASM(data.module).catch(exitWASM);
|
||||||
return;
|
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") {
|
if (data.type === "font") {
|
||||||
fontRequests.get(data.request)?.(data);
|
engine.fontRequests.get(data.request)?.(data);
|
||||||
fontRequests.delete(data.request);
|
engine.fontRequests.delete(data.request);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.type === "cancel") {
|
if (data.type === "cancel") {
|
||||||
operations.get(data.id)?.abort();
|
engine.operations.get(data.id)?.abort();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot"].includes(data.name)) operations.set(data.id, new AbortController());
|
engine.operations.set(data.id, new AbortController());
|
||||||
if (data.name === "ofdgoSaveSnapshot") {
|
engine.pending = engine.pending.then(() => {
|
||||||
pending = pending.then(() => { void handleMessage(data); });
|
const operation = (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(engine, data) : handleMessage(engine, data)).catch(error => {
|
||||||
return;
|
engine.operations.delete(data.id);
|
||||||
}
|
self.postMessage({ engineID, id: data.id, ok: false, error: error.message });
|
||||||
if (data.name === "ofdgoImportDocuments" || 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 === "ofdgoExportResource" || data.name === "ofdgoExportMedia" || data.name === "ofdgoExportVersion" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
|
});
|
||||||
operations.set(data.id, new AbortController());
|
engine.active.add(operation);
|
||||||
}
|
operation.finally(() => engine.active.delete(operation));
|
||||||
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data));
|
if (data.name !== "ofdgoSaveSnapshot") return operation;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
async function availableOutput(directory, base, format) {
|
async function availableOutput(directory, base, format) {
|
||||||
@@ -59,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 signal = operations.get(id).signal;
|
||||||
const channel = new MessageChannel();
|
const channel = new MessageChannel();
|
||||||
const finish = (done, err) => {
|
const finish = (done, err) => {
|
||||||
@@ -89,7 +141,7 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
if (name === "ofdgoConvertFile") {
|
if (name === "ofdgoConvertFile") {
|
||||||
const [file, options, fonts, destination] = args;
|
const [file, options, fonts, destination] = args;
|
||||||
credentials = options.credentials;
|
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);
|
const format = formats.find(item => item.value === options.format);
|
||||||
if (!format) throw new Error("不支持此格式");
|
if (!format) throw new Error("不支持此格式");
|
||||||
root = destination;
|
root = destination;
|
||||||
@@ -100,7 +152,7 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
|
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
|
||||||
else { await root.getFileHandle(target.name, { create: true }); directory = root; }
|
else { await root.getFileHandle(target.name, { create: true }); directory = root; }
|
||||||
created = target.name;
|
created = target.name;
|
||||||
self.postMessage({ id, type: "output", name: created });
|
postMessage({ id, type: "output", name: created });
|
||||||
return target;
|
return target;
|
||||||
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
|
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
|
||||||
const boundary = async (action, pageName) => {
|
const boundary = async (action, pageName) => {
|
||||||
@@ -113,7 +165,7 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
const handle = await directory.getFileHandle(fileName, { create: true });
|
const handle = await directory.getFileHandle(fileName, { create: true });
|
||||||
if (!format.paged) {
|
if (!format.paged) {
|
||||||
created = fileName;
|
created = fileName;
|
||||||
self.postMessage({ id, type: "output", name: created });
|
postMessage({ id, type: "output", name: created });
|
||||||
}
|
}
|
||||||
current.handle = handle;
|
current.handle = handle;
|
||||||
output = await handle.createWritable();
|
output = await handle.createWritable();
|
||||||
@@ -126,19 +178,19 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
chunks = [];
|
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) => {
|
(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);
|
finish(done);
|
||||||
},
|
},
|
||||||
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
|
(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 {
|
} else {
|
||||||
const [entries, handle, compression] = args;
|
const [entries, handle, compression] = args;
|
||||||
if (handle) output = await handle.createWritable();
|
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,
|
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);
|
done => finish(done), compression);
|
||||||
}
|
}
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
@@ -157,7 +209,7 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
}
|
}
|
||||||
result.data.size = size;
|
result.data.size = size;
|
||||||
operations.delete(id);
|
operations.delete(id);
|
||||||
self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
|
postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await output?.abort().catch(() => {});
|
await output?.abort().catch(() => {});
|
||||||
let message = signal.aborted ? "转换已取消" : err.message;
|
let message = signal.aborted ? "转换已取消" : err.message;
|
||||||
@@ -165,7 +217,7 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
try { await root.removeEntry(created, { recursive: true }); }
|
try { await root.removeEntry(created, { recursive: true }); }
|
||||||
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
|
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
|
||||||
}
|
}
|
||||||
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
|
postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
|
||||||
} finally {
|
} finally {
|
||||||
credentials?.password?.fill(0);
|
credentials?.password?.fill(0);
|
||||||
credentials?.key?.fill(0);
|
credentials?.key?.fill(0);
|
||||||
@@ -175,21 +227,32 @@ async function handleBatchMessage({ id, name, args }) {
|
|||||||
operations.delete(id);
|
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 output;
|
||||||
let saveFile;
|
let saveFile;
|
||||||
let saveStamp;
|
let saveStamp;
|
||||||
let channel;
|
let channel;
|
||||||
const signal = operations.get(id)?.signal;
|
const signal = operations.get(id)?.signal;
|
||||||
|
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 importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
|
||||||
const converting = name === "ofdgoConvertPDF";
|
const converting = name === "ofdgoOpenPDF";
|
||||||
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
|
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
|
||||||
const resources = name === "ofdgoResources" || name === "ofdgoPrepareSave";
|
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 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 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;
|
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
|
||||||
try {
|
try {
|
||||||
signal?.throwIfAborted();
|
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") {
|
if (name === "ofdgoDetectFormat") {
|
||||||
const file = args[0], reader = new FileReaderSync();
|
const file = args[0], reader = new FileReaderSync();
|
||||||
channel = new MessageChannel();
|
channel = new MessageChannel();
|
||||||
@@ -218,7 +281,7 @@ async function handleMessage({ id, name, args }) {
|
|||||||
}
|
}
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
if (signal) {
|
if (cancellable) {
|
||||||
channel = new MessageChannel();
|
channel = new MessageChannel();
|
||||||
const finish = (done, err, commit = false) => {
|
const finish = (done, err, commit = false) => {
|
||||||
channel.port1.onmessage = () => {
|
channel.port1.onmessage = () => {
|
||||||
@@ -231,10 +294,10 @@ async function handleMessage({ id, name, args }) {
|
|||||||
args.push(done => finish(done));
|
args.push(done => finish(done));
|
||||||
} else if (importing || converting) {
|
} else if (importing || converting) {
|
||||||
args.push((phase, completed, total, done) => {
|
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");
|
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 {
|
} else {
|
||||||
const destination = args.pop();
|
const destination = args.pop();
|
||||||
const file = destination?.fileHandle || destination;
|
const file = destination?.fileHandle || destination;
|
||||||
@@ -259,17 +322,17 @@ async function handleMessage({ id, name, args }) {
|
|||||||
});
|
});
|
||||||
if (name === "ofdgoExportDocument") {
|
if (name === "ofdgoExportDocument") {
|
||||||
args.push((completed, total, done) => {
|
args.push((completed, total, done) => {
|
||||||
self.postMessage({ id, type: "export", stage: "pages", completed, total });
|
postMessage({ id, type: "export", stage: "pages", completed, total });
|
||||||
finish(done);
|
finish(done);
|
||||||
});
|
});
|
||||||
} else if (name === "ofdgoExportVersion") {
|
} else if (name === "ofdgoExportVersion") {
|
||||||
args.push((phase, completed, total, done) => {
|
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);
|
finish(done);
|
||||||
}, done => finish(done));
|
}, done => finish(done));
|
||||||
} else if (saving) {
|
} else if (saving) {
|
||||||
args.push((phase, completed, total, done) => {
|
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);
|
finish(done);
|
||||||
});
|
});
|
||||||
if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done));
|
if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done));
|
||||||
@@ -278,12 +341,29 @@ async function handleMessage({ id, name, args }) {
|
|||||||
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
|
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();
|
signal?.throwIfAborted();
|
||||||
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||||
if (signal && !importing && !converting && !resources && result.ok) {
|
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;
|
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 (output) {
|
||||||
if (saveStamp) {
|
if (saveStamp) {
|
||||||
const current = await saveFile.getFile();
|
const current = await saveFile.getFile();
|
||||||
@@ -299,16 +379,16 @@ async function handleMessage({ id, name, args }) {
|
|||||||
await output.abort();
|
await output.abort();
|
||||||
}
|
}
|
||||||
output = null;
|
output = null;
|
||||||
const transfer = result.data?.bytes ? [result.data.bytes.buffer] : (result.data?.images || []).map((image) => image.bytes.buffer);
|
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))];
|
||||||
self.postMessage({ id, ...result }, transfer);
|
postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await output?.abort().catch(() => {});
|
await output?.abort().catch(() => {});
|
||||||
const result = { id, ok: false, error: err.message, errorName: err.name };
|
const result = { id, ok: false, error: err.message, errorName: err.name };
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消";
|
result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : cancellable ? "导出已取消" : "操作已取消";
|
||||||
result.canceled = true;
|
result.canceled = true;
|
||||||
}
|
}
|
||||||
self.postMessage(result);
|
postMessage(result);
|
||||||
} finally {
|
} finally {
|
||||||
password?.fill(0);
|
password?.fill(0);
|
||||||
key?.fill(0);
|
key?.fill(0);
|
||||||
@@ -320,6 +400,7 @@ async function handleMessage({ id, name, args }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exitWASM(err) {
|
function exitWASM(err) {
|
||||||
|
rejectRuntime(err || new Error("引擎运行中断"));
|
||||||
self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" });
|
self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" });
|
||||||
self.close();
|
self.close();
|
||||||
}
|
}
|
||||||
@@ -331,9 +412,7 @@ async function loadWASM(module) {
|
|||||||
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
|
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
|
||||||
go.run(instance).then(exitWASM, exitWASM);
|
go.run(instance).then(exitWASM, exitWASM);
|
||||||
if (!go.exited) {
|
if (!go.exited) {
|
||||||
const backends = JSON.parse(globalThis.ofdgoRenderBackends());
|
resolveRuntime();
|
||||||
if (!backends.ok) throw new Error(backends.error);
|
|
||||||
self.postMessage({ type: "ready", backends: backends.data });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
||||||
const META_CACHE = `${CACHE_PREFIX}meta`;
|
const META_CACHE = `${CACHE_PREFIX}meta`;
|
||||||
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_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);
|
.map((path) => new URL(path, self.registration.scope).href);
|
||||||
const CLIENT_PREFIX = new URL("__client/", 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);
|
bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId);
|
||||||
}
|
}
|
||||||
if (!bundle || !await caches.has(bundle.name)) {
|
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();
|
return Response.error();
|
||||||
}
|
}
|
||||||
if (event.resultingClientId) {
|
if (event.resultingClientId) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
|
|
||||||
"github.com/xiaoqidun/ofdgo/assets/webui"
|
"github.com/xiaoqidun/ofdgo/assets/webui"
|
||||||
"github.com/xiaoqidun/ofdgo/assets/webui/wasm"
|
"github.com/xiaoqidun/ofdgo/assets/webui/wasm"
|
||||||
|
"github.com/xiaoqidun/ofdgo/internal/build"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -50,6 +51,7 @@ func serveWebUI() http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("X-OFDGo-Checksum", checksum)
|
w.Header().Set("X-OFDGo-Checksum", checksum)
|
||||||
|
w.Header().Set("X-OFDGo-Build-Version", build.Version)
|
||||||
if r.URL.Path == "/ofdgo.wasm" {
|
if r.URL.Path == "/ofdgo.wasm" {
|
||||||
wasm.ServeHTTP(w, r)
|
wasm.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
+36
-17
@@ -12,29 +12,48 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//go:build windows && amd64
|
//go:build (windows && amd64) || (linux && (amd64 || arm64))
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"strings"
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/xiaoqidun/ofdgo/internal/webview"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
// commandLine 读取桌面参数并处理参数错误
|
||||||
dataDir := flag.String("data-dir", "", "browser data directory")
|
// 返回: []string 文件路径, string 浏览器数据目录
|
||||||
flag.Parse()
|
func commandLine() ([]string, string) {
|
||||||
if err := webview.Run(context.Background(), flag.Args(), *dataDir); err != nil {
|
paths, dataDir, ok := parseArguments(os.Args[1:])
|
||||||
fmt.Fprintln(os.Stderr, err)
|
if !ok {
|
||||||
message, _ := syscall.UTF16PtrFromString(err.Error())
|
os.Exit(2)
|
||||||
title, _ := syscall.UTF16PtrFromString("OFDGo")
|
|
||||||
syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//go:build !windows || !amd64
|
//go:build !(windows && amd64) && !(linux && (amd64 || arm64))
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,18 @@ module github.com/xiaoqidun/ofdgo
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.6
|
github.com/andybalholm/brotli v1.2.7
|
||||||
github.com/emmansun/gmsm v0.45.0
|
github.com/emmansun/gmsm v0.45.1
|
||||||
github.com/go-text/typesetting v0.3.5
|
github.com/go-text/typesetting v0.3.5
|
||||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||||
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
|
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
|
||||||
github.com/xiaoqidun/guigo v0.0.0-20261017165144-57ca5ff5f547
|
github.com/xiaoqidun/guigo v0.0.0-20261030045545-8b12eb7ed706
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2
|
github.com/xiaoqidun/j2kgo v1.0.2
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8
|
github.com/xiaoqidun/jbig2 v1.1.8
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261018110224-069b0fe96ea8
|
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/image v0.47.0
|
golang.org/x/image v0.47.0
|
||||||
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/text v0.43.0
|
golang.org/x/text v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ require (
|
|||||||
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
|
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
|
||||||
github.com/benoitkugler/textlayout v0.3.2 // indirect
|
github.com/benoitkugler/textlayout v0.3.2 // indirect
|
||||||
github.com/benoitkugler/textprocessing v0.0.6 // 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/go-fonts/latin-modern v0.3.3 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
@@ -34,7 +36,6 @@ require (
|
|||||||
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
||||||
github.com/yuin/goldmark v1.8.5 // indirect
|
github.com/yuin/goldmark v1.8.5 // indirect
|
||||||
golang.org/x/net v0.60.0 // 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/knuth v0.6.0 // indirect
|
||||||
modernc.org/token v1.1.0 // indirect
|
modernc.org/token v1.1.0 // indirect
|
||||||
star-tex.org/x/tex v0.7.1 // 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/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 h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||||
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
|
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
|
||||||
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
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/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 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
|
||||||
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
|
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/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 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
|
||||||
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
|
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
|
||||||
github.com/emmansun/gmsm v0.45.0 h1:PU/6ObyR3g05YN3jn1L5GZDowzyAi8Xhkq4svzYNyiA=
|
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
||||||
github.com/emmansun/gmsm v0.45.0/go.mod h1:HgLSvmdePGLo+Fo2PNgdTQU6lqAepSyriSQJRpECY6w=
|
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||||
|
github.com/emmansun/gmsm v0.45.1 h1:S77WOluBZ9JH2wh701H2QR03eQYNGLeN8IUkdhgGpRA=
|
||||||
|
github.com/emmansun/gmsm v0.45.1/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 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
|
||||||
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
|
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=
|
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
|
||||||
@@ -58,14 +60,14 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8
|
|||||||
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
|
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 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
github.com/xiaoqidun/guigo v0.0.0-20261017165144-57ca5ff5f547 h1:BEybx66cn0nugBoWwUNOnAWgpcRhPUWPxO3fyizebO0=
|
github.com/xiaoqidun/guigo v0.0.0-20261030045545-8b12eb7ed706 h1:0QmFYCnRMw9mQ2MZLuFV1NMItMRcYJJQgGrxvPv0fg4=
|
||||||
github.com/xiaoqidun/guigo v0.0.0-20261017165144-57ca5ff5f547/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE=
|
github.com/xiaoqidun/guigo v0.0.0-20261030045545-8b12eb7ed706/go.mod h1:bXz+dPMVntPIVwePTTuMXt8Q0z1YoUGuROQSsXyUEfY=
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
|
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/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 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261018110224-069b0fe96ea8 h1:vEcUgzXL2Qbk8iXTYEauKGFgyqfv2Ns9ZSy7UMBh3tk=
|
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU=
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261018110224-069b0fe96ea8/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4=
|
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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||||
|
|||||||
+39
-14
@@ -131,6 +131,14 @@ func NormalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if transform != nil {
|
||||||
scale := 1 / float64(unitsPerEm)
|
scale := 1 / float64(unitsPerEm)
|
||||||
dict[1207] = []float64{scale, 0, 0, scale, 0, 0}
|
dict[1207] = []float64{scale, 0, 0, scale, 0, 0}
|
||||||
@@ -260,6 +268,21 @@ func SanitizeCFF(data []byte) ([]byte, error) {
|
|||||||
if kind == 1 {
|
if kind == 1 {
|
||||||
return normalizeCFFType1(data, topDict)
|
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
|
return data, nil
|
||||||
}
|
}
|
||||||
fdArrOff, err := cffDictOffset(data, topDict, 1236)
|
fdArrOff, err := cffDictOffset(data, topDict, 1236)
|
||||||
@@ -316,10 +339,9 @@ func SanitizeCFF(data []byte) ([]byte, error) {
|
|||||||
localSubrData = data[start:end]
|
localSubrData = data[start:end]
|
||||||
}
|
}
|
||||||
charStringsData := data[charStringsOff:charEnd]
|
charStringsData := data[charStringsOff:charEnd]
|
||||||
delete(topDict, 1230)
|
for op := 1230; op <= 1238; op++ {
|
||||||
delete(topDict, 1236)
|
delete(topDict, op)
|
||||||
delete(topDict, 1237)
|
}
|
||||||
delete(topDict, 1234)
|
|
||||||
delete(topDict, 15)
|
delete(topDict, 15)
|
||||||
delete(topDict, 16)
|
delete(topDict, 16)
|
||||||
var finalPrivData []byte
|
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
|
return encodeSanitizedCFF(data[:hdrSize], nameIndexData, topDict, stringIndexData, globalSubrIndexData, charStringsData, finalPrivData, localSubrData), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeCFFPrivate 移除旧版CFF中不生效的缺省字段,拒绝需要额外解释的取值
|
// normalizeCFFPrivate 移除废弃的加粗阈值及未加密标记,拒绝加密字形程序
|
||||||
// 入参: dict 私有字典
|
// 入参: dict 私有字典
|
||||||
// 返回: error 不支持的旧版行为
|
// 返回: error 不支持的旧版行为
|
||||||
func normalizeCFFPrivate(dict cffDict) error {
|
func normalizeCFFPrivate(dict cffDict) error {
|
||||||
for op, value := range map[int]float64{1215: 0, 1216: -1} {
|
if values, ok := dict[1215]; ok {
|
||||||
if values, ok := dict[op]; ok {
|
if len(values) != 1 || !finite(values[0]) {
|
||||||
if len(values) != 1 || values[0] != value {
|
return fmt.Errorf("invalid legacy CFF bold threshold")
|
||||||
return fmt.Errorf("unsupported legacy CFF private operator %d", op)
|
|
||||||
}
|
}
|
||||||
delete(dict, op)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -475,10 +501,9 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict
|
|||||||
}
|
}
|
||||||
inlined[gid] = output.Bytes()
|
inlined[gid] = output.Bytes()
|
||||||
}
|
}
|
||||||
delete(topDict, 1230)
|
for op := 1230; op <= 1238; op++ {
|
||||||
delete(topDict, 1236)
|
delete(topDict, op)
|
||||||
delete(topDict, 1237)
|
}
|
||||||
delete(topDict, 1234)
|
|
||||||
delete(topDict, 15)
|
delete(topDict, 15)
|
||||||
delete(topDict, 16)
|
delete(topDict, 16)
|
||||||
delete(topDict, 1206)
|
delete(topDict, 1206)
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte, ids []uint16)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := normalizeCFFPrivate(private); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
privateData, err := encodeCFFSubsetPrivate(private)
|
privateData, err := encodeCFFSubsetPrivate(private)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package font
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,6 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||||
|
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
|
||||||
|
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
|
||||||
type Face struct {
|
type Face struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
@@ -30,6 +33,11 @@ type Face struct {
|
|||||||
Style string `json:"style"`
|
Style string `json:"style"`
|
||||||
PostScriptName string `json:"postscriptName"`
|
PostScriptName string `json:"postscriptName"`
|
||||||
Names []string `json:"names"`
|
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 从名称表读取字体信息
|
// FaceInfo 从名称表读取字体信息
|
||||||
@@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
|
|||||||
}
|
}
|
||||||
return name(ids...)
|
return name(ids...)
|
||||||
}
|
}
|
||||||
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)}
|
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 校验字体或集合头并读取字体数量
|
// FileCount 校验字体或集合头并读取字体数量
|
||||||
@@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
face := TableFaceInfo(tables, index, platform, language)
|
||||||
if face.FullName == "" {
|
if face.FullName == "" {
|
||||||
return nil, fmt.Errorf("font %d has no usable name", index)
|
return nil, fmt.Errorf("font %d has no usable name", index)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ func (f *Outliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
|
|||||||
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 指令错误
|
// 入参: err 指令错误
|
||||||
// 返回: bool 是否可恢复
|
// 返回: bool 是否可恢复
|
||||||
func ttRecoverableHint(err error) bool {
|
func ttRecoverableHint(err error) bool {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
// errTTStackUnderflow 表示字形程序缺少必需的栈参数
|
// errTTStackUnderflow 表示字形程序缺少必需的栈参数
|
||||||
var errTTStackUnderflow = errors.New("TrueType instruction stack underflow")
|
var errTTStackUnderflow = errors.New("TrueType instruction stack underflow")
|
||||||
|
|
||||||
// errTTInvalidReference 表示字形指令引用不存在的点或函数
|
// errTTInvalidReference 表示字形指令引用不存在的区、点、轮廓或函数
|
||||||
var errTTInvalidReference = errors.New("invalid TrueType instruction reference")
|
var errTTInvalidReference = errors.New("invalid TrueType instruction reference")
|
||||||
|
|
||||||
// errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围
|
// errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围
|
||||||
@@ -380,7 +380,8 @@ func (v *ttInterpreter) execute(code []byte, depth int) error {
|
|||||||
case op >= 0x13 && op <= 0x16:
|
case op >= 0x13 && op <= 0x16:
|
||||||
z := v.pop()
|
z := v.pop()
|
||||||
if z < 0 || z > 1 {
|
if z < 0 || z > 1 {
|
||||||
return fmt.Errorf("invalid TrueType zone")
|
v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, z)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if op == 0x16 {
|
if op == 0x16 {
|
||||||
g.zone = [3]int32{z, z, z}
|
g.zone = [3]int32{z, z, z}
|
||||||
@@ -854,7 +855,7 @@ func (v *ttInterpreter) shift(op byte) {
|
|||||||
if op <= 0x35 {
|
if op <= 0x35 {
|
||||||
contour := v.pop()
|
contour := v.pop()
|
||||||
if contour < 0 || int64(contour) >= int64(len(v.ends)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if contour > 0 {
|
if contour > 0 {
|
||||||
@@ -864,7 +865,7 @@ func (v *ttInterpreter) shift(op byte) {
|
|||||||
} else {
|
} else {
|
||||||
target = v.pop()
|
target = v.pop()
|
||||||
if target < 0 || target > 1 {
|
if target < 0 || target > 1 {
|
||||||
v.err = fmt.Errorf("invalid TrueType zone")
|
v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
to = len(v.zones[target])
|
to = len(v.zones[target])
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
|
|||||||
values := make(map[[4]uint16]string)
|
values := make(map[[4]uint16]string)
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
switch record.Name {
|
switch record.Name {
|
||||||
case 1, 2, 4, 6, 16, 17:
|
case 1, 2, 4, 5, 6, 8, 16, 17:
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-18
@@ -17,9 +17,11 @@ package font
|
|||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-text/typesetting/font/opentype/tables"
|
"github.com/go-text/typesetting/font/opentype/tables"
|
||||||
)
|
)
|
||||||
@@ -34,10 +36,12 @@ type SFNT struct {
|
|||||||
reverse map[uint16][]rune
|
reverse map[uint16][]rune
|
||||||
}
|
}
|
||||||
|
|
||||||
// sfntCmap 保存编码记录及对应字符映射,标记已解析的共享子表
|
// sfntCmap 保存编码记录及字符映射,连续区间与共享子表不重复展开
|
||||||
type sfntCmap struct {
|
type sfntCmap struct {
|
||||||
platform, encoding, format uint16
|
platform, encoding, format uint16
|
||||||
mapping map[rune]uint16
|
mapping map[rune]uint16
|
||||||
|
groups []byte
|
||||||
|
entries uint64
|
||||||
duplicate bool
|
duplicate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ func ParseSFNTTables(table map[string][]byte) (*SFNT, error) {
|
|||||||
if cmap.duplicate {
|
if cmap.duplicate {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, glyph := range cmap.mapping {
|
for _, glyph := range cmap.pairs() {
|
||||||
if glyph >= f.count {
|
if glyph >= f.count {
|
||||||
return nil, fmt.Errorf("cmap glyph index out of range")
|
return nil, fmt.Errorf("cmap glyph index out of range")
|
||||||
}
|
}
|
||||||
@@ -119,7 +123,7 @@ func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
|
|||||||
}
|
}
|
||||||
count := int(binary.BigEndian.Uint16(data[2:]))
|
count := int(binary.BigEndian.Uint16(data[2:]))
|
||||||
result := make([]sfntCmap, 0, count)
|
result := make([]sfntCmap, 0, count)
|
||||||
parsed := make(map[uint32]map[rune]uint16)
|
parsed := make(map[uint32]sfntCmap)
|
||||||
for i := range count {
|
for i := range count {
|
||||||
record := data[4+8*i:]
|
record := data[4+8*i:]
|
||||||
offset := binary.BigEndian.Uint32(record[4:])
|
offset := binary.BigEndian.Uint32(record[4:])
|
||||||
@@ -128,23 +132,44 @@ func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
|
|||||||
}
|
}
|
||||||
sub := data[offset:]
|
sub := data[offset:]
|
||||||
format := binary.BigEndian.Uint16(sub)
|
format := binary.BigEndian.Uint16(sub)
|
||||||
mapping, found := parsed[offset]
|
cmap, found := parsed[offset]
|
||||||
if !found {
|
if !found {
|
||||||
mapping = make(map[rune]uint16)
|
cmap.mapping = make(map[rune]uint16)
|
||||||
var err error
|
var err error
|
||||||
switch format {
|
switch format {
|
||||||
case 0:
|
case 0:
|
||||||
parseCmapFormat0(sub, mapping)
|
parseCmapFormat0(sub, cmap.mapping)
|
||||||
case 4:
|
case 4:
|
||||||
parseCmapFormat4(sub, mapping)
|
parseCmapFormat4(sub, cmap.mapping)
|
||||||
case 6:
|
case 6:
|
||||||
parseCmapFormat6(sub, mapping)
|
parseCmapFormat6(sub, cmap.mapping)
|
||||||
case 12:
|
case 12:
|
||||||
parseCmapFormat12(sub, mapping)
|
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:
|
case 2:
|
||||||
mapping, err = parseCmapFormat2(sub)
|
cmap.mapping, err = parseCmapFormat2(sub)
|
||||||
case 8, 10, 13:
|
case 8, 10, 13:
|
||||||
mapping, err = parseCmapExtended(sub)
|
cmap.mapping, err = parseCmapExtended(sub)
|
||||||
case 14:
|
case 14:
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported cmap format %d", format)
|
return nil, fmt.Errorf("unsupported cmap format %d", format)
|
||||||
@@ -152,13 +177,67 @@ func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
parsed[offset] = mapping
|
parsed[offset] = cmap
|
||||||
}
|
}
|
||||||
result = append(result, sfntCmap{platform: binary.BigEndian.Uint16(record), encoding: binary.BigEndian.Uint16(record[2:]), format: format, mapping: mapping, duplicate: found})
|
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
|
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 返回字体设计单位
|
// UnitsPerEm 返回字体设计单位
|
||||||
// 返回: uint16 每em单位数
|
// 返回: uint16 每em单位数
|
||||||
func (f *SFNT) UnitsPerEm() uint16 { return f.units }
|
func (f *SFNT) UnitsPerEm() uint16 { return f.units }
|
||||||
@@ -172,7 +251,7 @@ func (f *SFNT) NumGlyphs() uint16 { return f.count }
|
|||||||
func (f *SFNT) CmapEntries() uint64 {
|
func (f *SFNT) CmapEntries() uint64 {
|
||||||
var count uint64
|
var count uint64
|
||||||
for _, cmap := range f.cmaps {
|
for _, cmap := range f.cmaps {
|
||||||
count += uint64(len(cmap.mapping))
|
count += uint64(len(cmap.mapping)) + cmap.entries
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
@@ -185,14 +264,14 @@ func (f *SFNT) GlyphIndex(char rune) uint16 {
|
|||||||
if cmap.duplicate {
|
if cmap.duplicate {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if gid := cmap.mapping[char]; gid != 0 {
|
if gid, ok := cmap.lookup(char); ok && gid != 0 {
|
||||||
return gid
|
return gid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// glyphCharacters 返回指向指定字形的全部字符
|
// glyphCharacters 返回指向指定字形的全部字符,连续区间按需反查
|
||||||
// 入参: gid 字形编号
|
// 入参: gid 字形编号
|
||||||
// 返回: []rune 排序后的字符
|
// 返回: []rune 排序后的字符
|
||||||
func (f *SFNT) glyphCharacters(gid uint16) []rune {
|
func (f *SFNT) glyphCharacters(gid uint16) []rune {
|
||||||
@@ -204,7 +283,7 @@ func (f *SFNT) glyphCharacters(gid uint16) []rune {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for char, glyph := range cmap.mapping {
|
for char, glyph := range cmap.mapping {
|
||||||
if glyph != 0 && !seen[char] {
|
if glyph != 0 && !seen[char] && f.GlyphIndex(char) == glyph {
|
||||||
f.reverse[glyph] = append(f.reverse[glyph], char)
|
f.reverse[glyph] = append(f.reverse[glyph], char)
|
||||||
seen[char] = true
|
seen[char] = true
|
||||||
}
|
}
|
||||||
@@ -214,7 +293,28 @@ func (f *SFNT) glyphCharacters(gid uint16) []rune {
|
|||||||
slices.Sort(chars)
|
slices.Sort(chars)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return f.reverse[gid]
|
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 返回水平字宽
|
// GlyphAdvance 返回水平字宽
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func subsetSourceSFNT(sfnt *SFNT, usage *Usage, size int) []byte {
|
|||||||
for char := range usage.Characters {
|
for char := range usage.Characters {
|
||||||
id := sfnt.GlyphIndex(char)
|
id := sfnt.GlyphIndex(char)
|
||||||
for _, cmap := range unicodeTables {
|
for _, cmap := range unicodeTables {
|
||||||
if alternate, ok := cmap.mapping[char]; ok && alternate != id {
|
if alternate, ok := cmap.lookup(char); ok && alternate != id {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ func ParseType1Program(data []byte) (Type1Program, error) {
|
|||||||
return Type1Program{}, fmt.Errorf("Type1 font has no glyphs")
|
return Type1Program{}, fmt.Errorf("Type1 font has no glyphs")
|
||||||
}
|
}
|
||||||
for index := range program.subrs {
|
for index := range program.subrs {
|
||||||
|
if program.subrs[index] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
program.subrs[index], err = type1CharString(program.subrs[index], program.lenIV)
|
program.subrs[index], err = type1CharString(program.subrs[index], program.lenIV)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Type1Program{}, err
|
return Type1Program{}, err
|
||||||
@@ -212,6 +215,61 @@ func ParseType1Program(data []byte) (Type1Program, error) {
|
|||||||
return program, nil
|
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 读取明文字体矩阵及加密数据起点
|
// readType1Header 读取明文字体矩阵及加密数据起点
|
||||||
// 入参: data Type1程序
|
// 入参: data Type1程序
|
||||||
// 返回: []float64 字体矩阵, int 加密起点, error 头部错误
|
// 返回: []float64 字体矩阵, int 加密起点, error 头部错误
|
||||||
|
|||||||
@@ -87,6 +87,108 @@ func FileTables(data []byte, index int) (map[string][]byte, error) {
|
|||||||
return tables, nil
|
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 计算字体表校验和
|
// calcTableChecksum 计算字体表校验和
|
||||||
// 入参: data 表数据
|
// 入参: data 表数据
|
||||||
// 返回: uint32 校验和
|
// 返回: uint32 校验和
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (s rasterImageSource) RGBA64At(x, y int) color.RGBA64 {
|
|||||||
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawRasterAxisImage 按像素中心进行轴向缩放,逐行复用横向采样并保留镜像与小数坐标
|
// drawRasterAxisImage 轴向缩放复用逐行采样,细轴按面积覆盖并保留镜像与小数坐标
|
||||||
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作
|
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作
|
||||||
// 返回: bool 是否使用轴向采样
|
// 返回: bool 是否使用轴向采样
|
||||||
func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool {
|
func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool {
|
||||||
@@ -78,8 +78,14 @@ func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if float64(r.Dx())*math.Min(float64(r.Dy()), math.Ceil(4*math.Max(1, sy))+2) > rasterScaleBufferLimit/32 {
|
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
|
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])
|
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])
|
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 {
|
if len(xs) == 0 || len(ys) == 0 {
|
||||||
@@ -139,10 +145,13 @@ func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// rasterImageAxis 按连续源坐标生成CatmullRom权重,不对图像几何边界取整
|
// rasterImageAxis 按连续源坐标生成CatmullRom权重,亚像素细轴使用面积覆盖权重
|
||||||
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
|
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
|
||||||
// 返回: []rasterAxisSample 轴向采样表
|
// 返回: []rasterAxisSample 轴向采样表
|
||||||
func rasterImageAxis(first, end, source, limit int, scale, offset float64) []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)
|
result := make([]rasterAxisSample, 0, end-first)
|
||||||
argument := math.Min(1, math.Abs(scale))
|
argument := math.Min(1, math.Abs(scale))
|
||||||
support := draw.CatmullRom.Support / argument
|
support := draw.CatmullRom.Support / argument
|
||||||
@@ -183,6 +192,142 @@ func rasterImageAxis(first, end, source, limit int, scale, offset float64) []ras
|
|||||||
return result
|
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 按源列复用像素,保留各目标像素的权重累加顺序与中间精度
|
// rasterImageHorizontal 按源列复用像素,保留各目标像素的权重累加顺序与中间精度
|
||||||
// 入参: row 横向缓冲, src 源像素, samples 采样表, y 源行坐标
|
// 入参: row 横向缓冲, src 源像素, samples 采样表, y 源行坐标
|
||||||
func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, samples []rasterAxisSample, y int) {
|
func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, samples []rasterAxisSample, y int) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 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 build
|
||||||
|
|
||||||
|
var Version string
|
||||||
+12
-12
@@ -28,6 +28,18 @@ import (
|
|||||||
"github.com/xiaoqidun/pdfgo"
|
"github.com/xiaoqidun/pdfgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StencilImage 按当前填充色读取模板图像,不分配整幅彩色样本
|
||||||
|
type StencilImage struct {
|
||||||
|
Source image.Image
|
||||||
|
Fill color.NRGBA64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeImage 按源像素输出原生过程色,不分配完整显示颜色缓冲
|
||||||
|
type NativeImage struct {
|
||||||
|
Source *pdfgo.ImageProcess
|
||||||
|
Space *pdfgo.ColorSpace
|
||||||
|
}
|
||||||
|
|
||||||
// EncodeCMYKTIFF 将直接四色图像保存为十六位无损TIFF,不经过显示颜色转换
|
// EncodeCMYKTIFF 将直接四色图像保存为十六位无损TIFF,不经过显示颜色转换
|
||||||
// 入参: ctx 取消上下文, source PDF源图像
|
// 入参: ctx 取消上下文, source PDF源图像
|
||||||
// 返回: []byte 含ICC和非预乘透明度的TIFF,非四色图像为nil, error 解码或编码错误
|
// 返回: []byte 含ICC和非预乘透明度的TIFF,非四色图像为nil, error 解码或编码错误
|
||||||
@@ -143,18 +155,6 @@ func EncodeCMYKTIFF(ctx context.Context, source *pdfgo.Image) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StencilImage 按当前填充色读取模板图像,不分配整幅彩色样本
|
|
||||||
type StencilImage struct {
|
|
||||||
Source image.Image
|
|
||||||
Fill color.NRGBA64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NativeImage 按源像素输出原生过程色,不分配完整显示颜色缓冲
|
|
||||||
type NativeImage struct {
|
|
||||||
Source *pdfgo.ImageProcess
|
|
||||||
Space *pdfgo.ColorSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
// ColorModel 保留填充色和模板的有效采样精度
|
// ColorModel 保留填充色和模板的有效采样精度
|
||||||
// 返回: color.Model 非预乘颜色模型
|
// 返回: color.Model 非预乘颜色模型
|
||||||
func (s *StencilImage) ColorModel() color.Model {
|
func (s *StencilImage) ColorModel() color.Model {
|
||||||
|
|||||||
@@ -22,6 +22,40 @@ import (
|
|||||||
"github.com/xiaoqidun/pdfgo"
|
"github.com/xiaoqidun/pdfgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NormalizeDate 校验PDF日期,非严格模式修正单层外括号及完整时区偏移的分隔符
|
||||||
|
// 入参: text PDF日期字符串, strict 是否严格校验
|
||||||
|
// 返回: string 标准日期字符串, error 日期错误
|
||||||
|
func NormalizeDate(text string, strict bool) (string, error) {
|
||||||
|
if text == "" {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
_, err := pdfgo.ParseDate(text)
|
||||||
|
if err == nil || strict {
|
||||||
|
return text, err
|
||||||
|
}
|
||||||
|
normalized := text
|
||||||
|
if strings.HasPrefix(normalized, "(") && strings.HasSuffix(normalized, ")") {
|
||||||
|
normalized = normalized[1 : len(normalized)-1]
|
||||||
|
if _, parseErr := pdfgo.ParseDate(normalized); parseErr == nil {
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset := len(normalized) - 5
|
||||||
|
if offset < 6 || normalized[offset] != '+' && normalized[offset] != '-' {
|
||||||
|
return text, err
|
||||||
|
}
|
||||||
|
for _, digit := range normalized[offset+1:] {
|
||||||
|
if digit < '0' || digit > '9' {
|
||||||
|
return text, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized = normalized[:offset+3] + "'" + normalized[offset+3:] + "'"
|
||||||
|
if _, parseErr := pdfgo.ParseDate(normalized); parseErr != nil {
|
||||||
|
return text, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MetadataDate 保留当地日历日期,PDF省略时区按GMT,XMP省略日或时区时不推算
|
// MetadataDate 保留当地日历日期,PDF省略时区按GMT,XMP省略日或时区时不推算
|
||||||
// 入参: text 来源日期, xmp 是否为XMP日期
|
// 入参: text 来源日期, xmp 是否为XMP日期
|
||||||
// 返回: time.Time 解析值, bool 是否精确到日, bool 是否可比较绝对时间, error 日期错误
|
// 返回: time.Time 解析值, bool 是否精确到日, bool 是否可比较绝对时间, error 日期错误
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// 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 pdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xiaoqidun/pdfgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrintPlacement 保存源PDF页索引及毫米纸面上的变换和裁剪
|
||||||
|
type PrintPlacement struct {
|
||||||
|
Page int
|
||||||
|
Matrix [6]float64
|
||||||
|
Clip [4]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintSheet 保存一个纸面的物理尺寸、放置内容和辅助线
|
||||||
|
type PrintSheet struct {
|
||||||
|
Width, Height float64
|
||||||
|
Placements []PrintPlacement
|
||||||
|
Lines [][4]float64
|
||||||
|
Label string
|
||||||
|
LabelX, LabelY float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPDFObjects 写出自有打印对象,原PDF资源仅复制实际引用的部分
|
||||||
|
type printPDFObjects struct {
|
||||||
|
ctx context.Context
|
||||||
|
objects []pdfgo.Object
|
||||||
|
copied map[pdfgo.Reference]pdfgo.Reference
|
||||||
|
source *pdfgo.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPDFNumbers 编码内容流数值,不使用PDF语法不接受的科学记数法
|
||||||
|
// 入参: out 内容流缓冲区, values 待编码数值
|
||||||
|
func printPDFNumbers(out *bytes.Buffer, values ...float64) {
|
||||||
|
for _, value := range values {
|
||||||
|
out.WriteString(strconv.FormatFloat(value, 'f', -1, 64))
|
||||||
|
out.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add 登记一个独立输出对象
|
||||||
|
// 入参: object 待登记对象
|
||||||
|
// 返回: pdfgo.Reference 输出对象引用
|
||||||
|
func (p *printPDFObjects) add(object pdfgo.Object) pdfgo.Reference {
|
||||||
|
p.objects = append(p.objects, object)
|
||||||
|
return pdfgo.Reference{Number: int64(len(p.objects))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy 递归复制资源并改写引用,保留流编码及资源共享
|
||||||
|
// 入参: object 待复制对象, depth 当前递归深度
|
||||||
|
// 返回: pdfgo.Object 改写引用后的对象, error 资源、嵌套或取消错误
|
||||||
|
func (p *printPDFObjects) copy(object pdfgo.Object, depth int) (pdfgo.Object, error) {
|
||||||
|
if err := p.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if depth > 256 {
|
||||||
|
return nil, fmt.Errorf("print PDF resource nesting too deep")
|
||||||
|
}
|
||||||
|
switch value := object.(type) {
|
||||||
|
case pdfgo.Reference:
|
||||||
|
if next, ok := p.copied[value]; ok {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
next := p.add(nil)
|
||||||
|
p.copied[value] = next
|
||||||
|
resolved, err := p.source.Object(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
copied, err := p.copy(resolved, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.objects[next.Number-1] = copied
|
||||||
|
return next, nil
|
||||||
|
case pdfgo.Dictionary:
|
||||||
|
result := make(pdfgo.Dictionary, len(value))
|
||||||
|
for key, item := range value {
|
||||||
|
copied, err := p.copy(item, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[key] = copied
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case pdfgo.Array:
|
||||||
|
result := make(pdfgo.Array, len(value))
|
||||||
|
for i, item := range value {
|
||||||
|
copied, err := p.copy(item, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i] = copied
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case *pdfgo.Stream:
|
||||||
|
dict := maps.Clone(value.Dictionary)
|
||||||
|
delete(dict, "Length")
|
||||||
|
copied, err := p.copy(dict, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pdfgo.Stream{Dictionary: copied.(pdfgo.Dictionary), Data: value.Data}, nil
|
||||||
|
default:
|
||||||
|
return object, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePrintPDF 将源页面作为独立表单放置,保持矢量、文字、资源及透明组
|
||||||
|
// 入参: ctx 输出上下文, writer 输出流, source 源PDF数据, sheets 有序纸面
|
||||||
|
// 返回: error 资源、编码、写入或取消错误
|
||||||
|
func WritePrintPDF(ctx context.Context, writer io.Writer, source []byte, sheets []PrintSheet) error {
|
||||||
|
var reader *pdfgo.Reader
|
||||||
|
if len(source) > 0 {
|
||||||
|
var err error
|
||||||
|
reader, err = pdfgo.NewReader(bytes.NewReader(source), int64(len(source)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
}
|
||||||
|
p := &printPDFObjects{ctx: ctx, objects: make([]pdfgo.Object, 2), copied: make(map[pdfgo.Reference]pdfgo.Reference), source: reader}
|
||||||
|
var pages []*pdfgo.Page
|
||||||
|
if reader != nil {
|
||||||
|
if err := reader.WalkPages(ctx, func(_ int, page *pdfgo.Page) error { pages = append(pages, page); return nil }); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forms := make(map[int]pdfgo.Reference)
|
||||||
|
var children pdfgo.Array
|
||||||
|
const pt = 72.0 / 25.4
|
||||||
|
for _, sheet := range sheets {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var content bytes.Buffer
|
||||||
|
objects := make(pdfgo.Dictionary)
|
||||||
|
for _, placement := range sheet.Placements {
|
||||||
|
if placement.Page < 0 || placement.Page >= len(pages) {
|
||||||
|
return fmt.Errorf("print PDF page outside source")
|
||||||
|
}
|
||||||
|
page := pages[placement.Page]
|
||||||
|
if page.Rotation != 0 || page.UserUnit != 1 {
|
||||||
|
return fmt.Errorf("print PDF backend must normalize page rotation and units")
|
||||||
|
}
|
||||||
|
form, ok := forms[placement.Page]
|
||||||
|
if !ok {
|
||||||
|
resources, err := p.copy(page.Resources, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var data bytes.Buffer
|
||||||
|
if _, err := page.WriteContent(ctx, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
box := page.MediaBox
|
||||||
|
dict := pdfgo.Dictionary{"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Form"), "FormType": pdfgo.Integer(1), "BBox": pdfgo.Array{pdfgo.Real(box.XMin), pdfgo.Real(box.YMin), pdfgo.Real(box.XMax), pdfgo.Real(box.YMax)}, "Resources": resources}
|
||||||
|
if group := page.Dictionary["Group"]; group != nil {
|
||||||
|
copied, err := p.copy(group, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dict["Group"] = copied
|
||||||
|
}
|
||||||
|
form = p.add(&pdfgo.Stream{Dictionary: dict, Data: data.Bytes()})
|
||||||
|
forms[placement.Page] = form
|
||||||
|
}
|
||||||
|
name := pdfgo.Name(fmt.Sprintf("P%d", placement.Page))
|
||||||
|
objects[name] = form
|
||||||
|
c, m := placement.Clip, placement.Matrix
|
||||||
|
hs := (page.MediaBox.YMax - page.MediaBox.YMin) / pt
|
||||||
|
x0, y0 := page.MediaBox.XMin, page.MediaBox.YMin
|
||||||
|
content.WriteString("q ")
|
||||||
|
printPDFNumbers(&content, c[0]*pt, (sheet.Height-c[1]-c[3])*pt, c[2]*pt, c[3]*pt)
|
||||||
|
content.WriteString("re W n ")
|
||||||
|
printPDFNumbers(&content, m[0], -m[1], -m[2], m[3], (m[2]*hs+m[4])*pt-m[0]*x0+m[2]*y0, (sheet.Height-m[3]*hs-m[5])*pt+m[1]*x0-m[3]*y0)
|
||||||
|
fmt.Fprintf(&content, "cm /%s Do Q\n", name)
|
||||||
|
}
|
||||||
|
for _, line := range sheet.Lines {
|
||||||
|
content.WriteString("q 0 G ")
|
||||||
|
printPDFNumbers(&content, 0.15*pt)
|
||||||
|
content.WriteString("w ")
|
||||||
|
printPDFNumbers(&content, line[0]*pt, (sheet.Height-line[1])*pt)
|
||||||
|
content.WriteString("m ")
|
||||||
|
printPDFNumbers(&content, line[2]*pt, (sheet.Height-line[3])*pt)
|
||||||
|
content.WriteString("l S Q\n")
|
||||||
|
}
|
||||||
|
resources := pdfgo.Dictionary{"XObject": objects}
|
||||||
|
if sheet.Label != "" {
|
||||||
|
content.WriteString("q 0 g BT /Label 7.94 Tf 1 0 0 1 ")
|
||||||
|
printPDFNumbers(&content, sheet.LabelX*pt, (sheet.Height-sheet.LabelY)*pt)
|
||||||
|
content.WriteString("Tm ")
|
||||||
|
if err := printPDFObject(&content, pdfgo.String(sheet.Label), 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content.WriteString(" Tj ET Q\n")
|
||||||
|
resources["Font"] = pdfgo.Dictionary{"Label": pdfgo.Dictionary{"Type": pdfgo.Name("Font"), "Subtype": pdfgo.Name("Type1"), "BaseFont": pdfgo.Name("Helvetica")}}
|
||||||
|
}
|
||||||
|
stream := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{}, Data: content.Bytes()})
|
||||||
|
children = append(children, p.add(pdfgo.Dictionary{"Type": pdfgo.Name("Page"), "Parent": pdfgo.Reference{Number: 2}, "MediaBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Real(sheet.Width * pt), pdfgo.Real(sheet.Height * pt)}, "Resources": resources, "Contents": stream}))
|
||||||
|
}
|
||||||
|
return p.write(writer, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePrintImages 逐纸面消费光栅结果,不在输出对象中保留未压缩像素
|
||||||
|
// 入参: ctx 输出上下文, writer 输出流, sheets 有序纸面, render 按索引渲染纸面
|
||||||
|
// 返回: error 渲染、编码、写入或取消错误
|
||||||
|
func WritePrintImages(ctx context.Context, writer io.Writer, sheets []PrintSheet, render func(int) (image.Image, error)) error {
|
||||||
|
p := &printPDFObjects{ctx: ctx, objects: make([]pdfgo.Object, 2)}
|
||||||
|
var children pdfgo.Array
|
||||||
|
const pt = 72.0 / 25.4
|
||||||
|
for i, sheet := range sheets {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
img, err := render(i)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var data bytes.Buffer
|
||||||
|
compressed := zlib.NewWriter(&data)
|
||||||
|
b := img.Bounds()
|
||||||
|
row := make([]byte, b.Dx()*3)
|
||||||
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for x := b.Min.X; x < b.Max.X; x++ {
|
||||||
|
r, g, blue, _ := img.At(x, y).RGBA()
|
||||||
|
offset := (x - b.Min.X) * 3
|
||||||
|
row[offset], row[offset+1], row[offset+2] = byte(r>>8), byte(g>>8), byte(blue>>8)
|
||||||
|
}
|
||||||
|
if _, err := compressed.Write(row); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := compressed.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
picture := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Image"), "Width": pdfgo.Integer(b.Dx()), "Height": pdfgo.Integer(b.Dy()), "ColorSpace": pdfgo.Name("DeviceRGB"), "BitsPerComponent": pdfgo.Integer(8), "Filter": pdfgo.Name("FlateDecode")}, Data: data.Bytes()})
|
||||||
|
var content bytes.Buffer
|
||||||
|
content.WriteString("q ")
|
||||||
|
printPDFNumbers(&content, sheet.Width*pt, 0, 0, sheet.Height*pt, 0, 0)
|
||||||
|
content.WriteString("cm /I Do Q\n")
|
||||||
|
stream := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{}, Data: content.Bytes()})
|
||||||
|
children = append(children, p.add(pdfgo.Dictionary{"Type": pdfgo.Name("Page"), "Parent": pdfgo.Reference{Number: 2}, "MediaBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Real(sheet.Width * pt), pdfgo.Real(sheet.Height * pt)}, "Resources": pdfgo.Dictionary{"XObject": pdfgo.Dictionary{"I": picture}}, "Contents": stream}))
|
||||||
|
}
|
||||||
|
return p.write(writer, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write 写出完整对象表和交叉引用,失败时由调用方丢弃输出
|
||||||
|
// 入参: writer 输出流, children 页面对象引用
|
||||||
|
// 返回: error 编码、写入或取消错误
|
||||||
|
func (p *printPDFObjects) write(writer io.Writer, children pdfgo.Array) error {
|
||||||
|
p.objects[0] = pdfgo.Dictionary{"Type": pdfgo.Name("Catalog"), "Pages": pdfgo.Reference{Number: 2}}
|
||||||
|
p.objects[1] = pdfgo.Dictionary{"Type": pdfgo.Name("Pages"), "Count": pdfgo.Integer(len(children)), "Kids": children}
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
|
||||||
|
offsets := make([]int, len(p.objects))
|
||||||
|
for i, object := range p.objects {
|
||||||
|
if err := p.ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
offsets[i] = out.Len()
|
||||||
|
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
|
||||||
|
if err := printPDFObject(&out, object, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.WriteString("\nendobj\n")
|
||||||
|
}
|
||||||
|
xref := out.Len()
|
||||||
|
fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
|
||||||
|
for _, offset := range offsets {
|
||||||
|
fmt.Fprintf(&out, "%010d 00000 n \n", offset)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&out, "trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
|
||||||
|
return Write(p.ctx, out.Bytes(), writer, WriteOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPDFObject 编码已复制的标准PDF对象,名称和字符串按字节转义
|
||||||
|
// 入参: out 对象缓冲区, object 待编码对象, depth 当前递归深度
|
||||||
|
// 返回: error 类型或嵌套错误
|
||||||
|
func printPDFObject(out *bytes.Buffer, object pdfgo.Object, depth int) error {
|
||||||
|
if depth > 256 {
|
||||||
|
return fmt.Errorf("print PDF object nesting too deep")
|
||||||
|
}
|
||||||
|
switch value := object.(type) {
|
||||||
|
case nil:
|
||||||
|
out.WriteString("null")
|
||||||
|
case pdfgo.Boolean:
|
||||||
|
fmt.Fprintf(out, "%t", value)
|
||||||
|
case pdfgo.Integer:
|
||||||
|
fmt.Fprintf(out, "%d", value)
|
||||||
|
case pdfgo.Real:
|
||||||
|
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||||
|
return fmt.Errorf("invalid print PDF number")
|
||||||
|
}
|
||||||
|
out.WriteString(strconv.FormatFloat(float64(value), 'f', -1, 64))
|
||||||
|
case pdfgo.Reference:
|
||||||
|
fmt.Fprintf(out, "%d %d R", value.Number, value.Generation)
|
||||||
|
case pdfgo.Name:
|
||||||
|
out.WriteByte('/')
|
||||||
|
for _, b := range []byte(value) {
|
||||||
|
if b <= 32 || b >= 127 || strings.ContainsRune("()<>[]{}/%#", rune(b)) {
|
||||||
|
fmt.Fprintf(out, "#%02X", b)
|
||||||
|
} else {
|
||||||
|
out.WriteByte(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case pdfgo.String:
|
||||||
|
out.WriteByte('<')
|
||||||
|
out.WriteString(hex.EncodeToString(value))
|
||||||
|
out.WriteByte('>')
|
||||||
|
case pdfgo.Array:
|
||||||
|
out.WriteByte('[')
|
||||||
|
for _, item := range value {
|
||||||
|
if err := printPDFObject(out, item, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.WriteByte(' ')
|
||||||
|
}
|
||||||
|
out.WriteByte(']')
|
||||||
|
case pdfgo.Dictionary:
|
||||||
|
out.WriteString("<<")
|
||||||
|
for _, key := range slices.Sorted(maps.Keys(value)) {
|
||||||
|
if err := printPDFObject(out, key, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.WriteByte(' ')
|
||||||
|
if err := printPDFObject(out, value[key], depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.WriteByte('\n')
|
||||||
|
}
|
||||||
|
out.WriteString(">>")
|
||||||
|
case *pdfgo.Stream:
|
||||||
|
dict := maps.Clone(value.Dictionary)
|
||||||
|
dict["Length"] = pdfgo.Integer(len(value.Data))
|
||||||
|
if err := printPDFObject(out, dict, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.WriteString("\nstream\n")
|
||||||
|
out.Write(value.Data)
|
||||||
|
out.WriteString("\nendstream")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported print PDF object %T", object)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+1044
-370
File diff suppressed because it is too large.
Load diff
@@ -24,8 +24,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -63,12 +65,17 @@ type NativeOutputTarget struct {
|
|||||||
Base, Name string
|
Base, Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NativeEngine 保存独立会话,不同实例可并行,同一实例的回调不可重入Call或Close
|
// NativeEngine 保存独立会话,页面渲染可并行,修改等待已开始的只读任务完成
|
||||||
|
// 同一实例的回调不可重入Call或Close
|
||||||
// hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭
|
// hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭
|
||||||
type NativeEngine struct {
|
type NativeEngine struct {
|
||||||
businessEngine
|
businessEngine
|
||||||
|
// Resources 协调会话的计算与缓存,需在首次Call前配置
|
||||||
|
Resources *ofdgo.Resources
|
||||||
// ConversionPool 协调不同会话的批量转换资源
|
// ConversionPool 协调不同会话的批量转换资源
|
||||||
ConversionPool *ofdgo.ConversionPool
|
ConversionPool *ofdgo.ConversionPool
|
||||||
|
// RenderParallelism 限制同时渲染的页面数,零值使用可用处理器数
|
||||||
|
RenderParallelism int
|
||||||
// Resolve 按授权标识提供资源读取源
|
// Resolve 按授权标识提供资源读取源
|
||||||
Resolve NativeResolver
|
Resolve NativeResolver
|
||||||
// LoadFont 按名称读取系统字体数据
|
// LoadFont 按名称读取系统字体数据
|
||||||
@@ -84,6 +91,11 @@ type NativeEngine struct {
|
|||||||
once sync.Once
|
once sync.Once
|
||||||
saves sync.WaitGroup
|
saves sync.WaitGroup
|
||||||
closed bool
|
closed bool
|
||||||
|
renderMu sync.Mutex
|
||||||
|
renderCount int
|
||||||
|
renderDone chan struct{}
|
||||||
|
renderSlots chan struct{}
|
||||||
|
renderIdle []*Session
|
||||||
}
|
}
|
||||||
|
|
||||||
// nativeReader 在会话当前操作上下文中分块读取,不捕获已结束请求的上下文
|
// nativeReader 在会话当前操作上下文中分块读取,不捕获已结束请求的上下文
|
||||||
@@ -139,12 +151,29 @@ func NewNativeEngine(resolve NativeResolver) *NativeEngine { return &NativeEngin
|
|||||||
func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit func(any)) (result map[string]any) {
|
func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit func(any)) (result map[string]any) {
|
||||||
e.once.Do(func() {
|
e.once.Do(func() {
|
||||||
e.gate = make(chan struct{}, 1)
|
e.gate = make(chan struct{}, 1)
|
||||||
|
e.renderSlots = make(chan struct{}, e.renderParallelism())
|
||||||
e.resolveInput = e.input
|
e.resolveInput = e.input
|
||||||
e.conversionPool = e.ConversionPool
|
e.conversionPool = e.ConversionPool
|
||||||
|
e.resources = e.Resources
|
||||||
})
|
})
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
if name == "ofdgoRenderPage" {
|
||||||
|
select {
|
||||||
|
case e.renderSlots <- struct{}{}:
|
||||||
|
defer func() { <-e.renderSlots }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nativeFailure(ctx.Err())
|
||||||
|
}
|
||||||
|
if e.resources != nil {
|
||||||
|
release, err := e.resources.Acquire(ctx, 64<<20)
|
||||||
|
if err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case e.gate <- struct{}{}:
|
case e.gate <- struct{}{}:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -162,6 +191,89 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
if e.closed {
|
if e.closed {
|
||||||
return nativeFailure(fmt.Errorf("native engine is closed"))
|
return nativeFailure(fmt.Errorf("native engine is closed"))
|
||||||
}
|
}
|
||||||
|
if name == "ofdgoRenderPage" && e.currentSession != nil && e.pdf == nil {
|
||||||
|
defer func() {
|
||||||
|
if failure := recover(); failure != nil {
|
||||||
|
result = nativeFailure(fmt.Errorf("%v", failure))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
worker, err := e.renderSession(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
operation := &businessEngine{currentSession: worker, currentEditor: e.currentEditor, ctx: ctx}
|
||||||
|
values := make([]platformValue, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
values[i] = platformValueOf(arg)
|
||||||
|
}
|
||||||
|
if len(values) > 0 && operation.currentEditor != nil {
|
||||||
|
pageErr := e.prepareRender(operation, values[0].Int())
|
||||||
|
if pageErr != nil {
|
||||||
|
return nativeFailure(pageErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operation.currentEditor = nil
|
||||||
|
e.renderMu.Lock()
|
||||||
|
if e.renderCount == 0 {
|
||||||
|
e.renderDone = make(chan struct{})
|
||||||
|
}
|
||||||
|
e.renderCount++
|
||||||
|
e.renderMu.Unlock()
|
||||||
|
<-e.gate
|
||||||
|
locked = false
|
||||||
|
defer func() {
|
||||||
|
worker.Renderer.OutputContext = context.Background()
|
||||||
|
e.renderMu.Lock()
|
||||||
|
if result != nil && result["ok"] == true && ctx.Err() == nil {
|
||||||
|
e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
|
||||||
|
clear(worker.pageCache)
|
||||||
|
worker.textCache.Clear()
|
||||||
|
clear(worker.svgFonts)
|
||||||
|
e.renderIdle = append(e.renderIdle, worker)
|
||||||
|
}
|
||||||
|
e.renderCount--
|
||||||
|
if e.renderCount == 0 {
|
||||||
|
close(e.renderDone)
|
||||||
|
}
|
||||||
|
e.renderMu.Unlock()
|
||||||
|
}()
|
||||||
|
data, err := operation.safeCall(operation.renderPage, values)
|
||||||
|
if err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
result = nativePlain(data).(map[string]any)
|
||||||
|
if result["ok"] != true {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
page := result["data"].(map[string]any)
|
||||||
|
known := make(map[string]bool)
|
||||||
|
if len(args) > 4 {
|
||||||
|
for i := 0; i < values[4].Length(); i++ {
|
||||||
|
known[values[4].Index(i).String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range page["fonts"].([]any) {
|
||||||
|
font := value.(map[string]any)
|
||||||
|
if name := font["name"].(string); !known[name] {
|
||||||
|
font["data"] = worker.svgFonts[name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.currentEditor != nil && !(len(values) > 6 && values[6].Bool()) {
|
||||||
|
objects, textErr := e.renderObjects(operation, values[0].Int())
|
||||||
|
if textErr != nil {
|
||||||
|
return nativeFailure(textErr)
|
||||||
|
}
|
||||||
|
page["objects"] = nativePlain(objects)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if err := e.waitRenders(ctx); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
|
e.renderIdle = nil
|
||||||
if name == "ofdgoSaveSnapshot" {
|
if name == "ofdgoSaveSnapshot" {
|
||||||
snapshot := e.saveSnapshot
|
snapshot := e.saveSnapshot
|
||||||
e.saveSnapshot = nil
|
e.saveSnapshot = nil
|
||||||
@@ -198,7 +310,14 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
if fn == nil {
|
if fn == nil {
|
||||||
return nativeFailure(fmt.Errorf("unknown operation: %s", name))
|
return nativeFailure(fmt.Errorf("unknown operation: %s", name))
|
||||||
}
|
}
|
||||||
|
if err := e.checkDocument(name); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
call := &nativeCall{engine: e, ctx: ctx, emit: emit}
|
call := &nativeCall{engine: e, ctx: ctx, emit: emit}
|
||||||
|
if e.pdf != nil {
|
||||||
|
e.pdf.progress = platformValueOf(call.progress("conversion", "", true))
|
||||||
|
e.pdf.font = platformValueOf(nativeCallback(call.font))
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := call.abort(); err != nil {
|
if err := call.abort(); err != nil {
|
||||||
if result == nil || result["ok"] == true {
|
if result == nil || result["ok"] == true {
|
||||||
@@ -216,6 +335,9 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
for i, arg := range prepared {
|
for i, arg := range prepared {
|
||||||
values[i] = platformValueOf(arg)
|
values[i] = platformValueOf(arg)
|
||||||
}
|
}
|
||||||
|
if err := e.preparePDF(name, values); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
data, err := e.safeCall(fn, values)
|
data, err := e.safeCall(fn, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeFailure(err)
|
return nativeFailure(err)
|
||||||
@@ -236,10 +358,18 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
|
|
||||||
// Close 释放会话,不关闭宿主持有的ReaderAt资源
|
// Close 释放会话,不关闭宿主持有的ReaderAt资源
|
||||||
func (e *NativeEngine) Close() error {
|
func (e *NativeEngine) Close() error {
|
||||||
e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveInput = e.input })
|
e.once.Do(func() {
|
||||||
|
e.gate = make(chan struct{}, 1)
|
||||||
|
e.renderSlots = make(chan struct{}, e.renderParallelism())
|
||||||
|
e.resolveInput = e.input
|
||||||
|
})
|
||||||
e.gate <- struct{}{}
|
e.gate <- struct{}{}
|
||||||
defer func() { <-e.gate }()
|
defer func() { <-e.gate }()
|
||||||
e.closed = true
|
e.closed = true
|
||||||
|
e.clearPDF()
|
||||||
|
_, _ = e.discardPrint(nil)
|
||||||
|
_ = e.waitRenders(context.Background())
|
||||||
|
e.renderIdle = nil
|
||||||
e.saveSnapshot = nil
|
e.saveSnapshot = nil
|
||||||
e.saves.Wait()
|
e.saves.Wait()
|
||||||
var err error
|
var err error
|
||||||
@@ -249,11 +379,91 @@ func (e *NativeEngine) Close() error {
|
|||||||
if e.pendingImport != nil {
|
if e.pendingImport != nil {
|
||||||
err = errors.Join(err, e.pendingImport.Close())
|
err = errors.Join(err, e.pendingImport.Close())
|
||||||
}
|
}
|
||||||
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.copiedStyle = nil, nil, nil, nil, nil
|
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.portableObjects, e.copiedStyle = nil, nil, nil, nil, nil, nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input 读取授权源并以宿主报告的大小为准
|
// waitRenders 等待已开始的页面任务,取消等待不修改当前会话
|
||||||
|
// 入参: ctx 等待上下文
|
||||||
|
// 返回: error 取消错误
|
||||||
|
func (e *NativeEngine) waitRenders(ctx context.Context) error {
|
||||||
|
e.renderMu.Lock()
|
||||||
|
done, count := e.renderDone, e.renderCount
|
||||||
|
e.renderMu.Unlock()
|
||||||
|
if count == 0 {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderParallelism 返回首次调用时采用的页面并行数
|
||||||
|
// 返回: int 同时运行的页面任务数
|
||||||
|
func (e *NativeEngine) renderParallelism() int {
|
||||||
|
if e.RenderParallelism > 0 {
|
||||||
|
return e.RenderParallelism
|
||||||
|
}
|
||||||
|
return runtime.GOMAXPROCS(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSession 复用独立只读会话,调用时已持有引擎门控
|
||||||
|
// 入参: ctx 渲染上下文
|
||||||
|
// 返回: *Session 渲染会话, error 准备错误
|
||||||
|
func (e *NativeEngine) renderSession(ctx context.Context) (*Session, error) {
|
||||||
|
e.renderMu.Lock()
|
||||||
|
defer e.renderMu.Unlock()
|
||||||
|
if count := len(e.renderIdle); count > 0 {
|
||||||
|
worker := e.renderIdle[count-1]
|
||||||
|
e.renderIdle[count-1] = nil
|
||||||
|
e.renderIdle = e.renderIdle[:count-1]
|
||||||
|
worker.Renderer.OutputContext = ctx
|
||||||
|
return worker, nil
|
||||||
|
}
|
||||||
|
source := e.currentSession
|
||||||
|
renderer, err := source.Renderer.Fork(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Session{Reader: renderer.Reader, Renderer: renderer, doc: source.doc, fontFS: source.fontFS,
|
||||||
|
pageCache: make(map[int]*ofdgo.PageContent), boxCache: append([]pageBoxInfo(nil), source.boxCache...),
|
||||||
|
textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes), svgFonts: make(map[string][]byte), editing: source.editing}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
|
||||||
|
// 入参: operation 只读任务, index 页面索引
|
||||||
|
// 返回: error 页面或取消错误
|
||||||
|
func (e *NativeEngine) prepareRender(operation *businessEngine, index int) error {
|
||||||
|
e.renderMu.Lock()
|
||||||
|
defer e.renderMu.Unlock()
|
||||||
|
page, err := operation.currentEditor.Page(index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return operation.syncEditorPageIDs(index, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderObjects 读取编辑图元信息,渲染状态由当前任务独立持有
|
||||||
|
// 入参: operation 只读任务, index 页面索引
|
||||||
|
// 返回: []any 图元信息, error 提取错误
|
||||||
|
func (e *NativeEngine) renderObjects(operation *businessEngine, index int) ([]any, error) {
|
||||||
|
e.renderMu.Lock()
|
||||||
|
defer e.renderMu.Unlock()
|
||||||
|
operation.currentEditor = e.currentEditor
|
||||||
|
if !reflect.DeepEqual(e.currentEditor.Backends(), operation.currentSession.Renderer.Backends()) {
|
||||||
|
e.currentEditor.SetRenderBackends(operation.currentSession.Renderer.Backends())
|
||||||
|
}
|
||||||
|
text, err := operation.currentSession.PageText(index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return operation.editorObjects(index, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// input 读取授权源并以宿主报告的大小为准
|
||||||
func (e *NativeEngine) input(ctx context.Context, value any) (io.ReaderAt, int64, error) {
|
func (e *NativeEngine) input(ctx context.Context, value any) (io.ReaderAt, int64, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -755,7 +965,7 @@ func (c *nativeCall) prepare(name string, args []any) ([]any, error) {
|
|||||||
return append(args, checkpoint), nil
|
return append(args, checkpoint), nil
|
||||||
case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages":
|
case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages":
|
||||||
return append(args, c.progress("import", "", true)), nil
|
return append(args, c.progress("import", "", true)), nil
|
||||||
case "ofdgoConvertPDF":
|
case "ofdgoOpenPDF":
|
||||||
return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil
|
return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil
|
||||||
case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned":
|
case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned":
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
|
|||||||
@@ -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 webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xiaoqidun/ofdgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// preparePrint 从当前编辑入口创建独立打印快照,业务选项由公开库统一校验
|
||||||
|
// 入参: args 排版选项及可选输出任务标识
|
||||||
|
// 返回: any 纸面布局, error 快照、权限或排版错误
|
||||||
|
func (e *businessEngine) preparePrint(args []platformValue) (any, error) {
|
||||||
|
if e.currentSession == nil || len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("ofd document is not opened")
|
||||||
|
}
|
||||||
|
var options ofdgo.PrintOptions
|
||||||
|
if err := json.Unmarshal([]byte(platformJSON(args[0])), &options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx := e.operationContext()
|
||||||
|
if len(args) > 1 {
|
||||||
|
ctx = e.newOutputContext(args[1])
|
||||||
|
}
|
||||||
|
editor := e.currentEditor
|
||||||
|
if editor == nil {
|
||||||
|
var err error
|
||||||
|
editor, err = e.currentSession.Reader.EditorContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source := e.currentSession.Renderer
|
||||||
|
dirs, fonts := source.FontSources()
|
||||||
|
document, err := editor.PreparePrint(ctx, options, ofdgo.WithRenderBackends(source.Backends()), ofdgo.WithFontDirs(dirs...), ofdgo.WithFontFS(fonts...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.printDocument != nil {
|
||||||
|
_ = e.printDocument.Close()
|
||||||
|
}
|
||||||
|
e.printDocument = document
|
||||||
|
data, err := json.Marshal(document.Layout())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return successResult(platformParseJSON(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// layoutPrint 在既有快照上调整排版,预览期间不重复复制源包
|
||||||
|
// 入参: args 新排版选项及可选输出任务标识
|
||||||
|
// 返回: any 新纸面布局, error 状态、权限或排版错误
|
||||||
|
func (e *businessEngine) layoutPrint(args []platformValue) (any, error) {
|
||||||
|
if e.printDocument == nil || len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("print document is not prepared")
|
||||||
|
}
|
||||||
|
var options ofdgo.PrintOptions
|
||||||
|
if err := json.Unmarshal([]byte(platformJSON(args[0])), &options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx := e.operationContext()
|
||||||
|
if len(args) > 1 {
|
||||||
|
ctx = e.newOutputContext(args[1])
|
||||||
|
}
|
||||||
|
next, err := e.printDocument.Reconfigure(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = e.printDocument.Close()
|
||||||
|
e.printDocument = next
|
||||||
|
data, err := json.Marshal(next.Layout())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return successResult(platformParseJSON(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSide 按需输出一个纸面,不使用阅读区缓存或缩略图
|
||||||
|
// 入参: args 零基纸面索引及可选输出任务标识
|
||||||
|
// 返回: any 内嵌资源的SVG纸面, error 状态或渲染错误
|
||||||
|
func (e *businessEngine) printSide(args []platformValue) (any, error) {
|
||||||
|
if e.printDocument == nil || len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("print document is not prepared")
|
||||||
|
}
|
||||||
|
ctx := e.operationContext()
|
||||||
|
if len(args) > 1 {
|
||||||
|
ctx = e.newOutputContext(args[1])
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := e.printDocument.RenderSVG(ctx, args[0].Int(), &output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return successResult(map[string]any{"svg": output.String()}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPDF 输出和预览相同的打印布局,便于另存或交给第三方打印系统
|
||||||
|
// 入参: args 可选输出任务标识
|
||||||
|
// 返回: any PDF数据及媒体类型, error 状态或输出错误
|
||||||
|
func (e *businessEngine) printPDF(args []platformValue) (any, error) {
|
||||||
|
if e.printDocument == nil {
|
||||||
|
return nil, fmt.Errorf("print document is not prepared")
|
||||||
|
}
|
||||||
|
ctx := e.operationContext()
|
||||||
|
if len(args) > 0 {
|
||||||
|
ctx = e.newOutputContext(args[0])
|
||||||
|
}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := e.printDocument.WritePDF(ctx, &output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "mime": "application/pdf"}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discardPrint 释放当前打印快照,可重复调用
|
||||||
|
// 入参: _ 保留的调用参数
|
||||||
|
// 返回: any 空结果, error 资源释放错误
|
||||||
|
func (e *businessEngine) discardPrint(_ []platformValue) (any, error) {
|
||||||
|
if e.printDocument != nil {
|
||||||
|
err := e.printDocument.Close()
|
||||||
|
e.printDocument = nil
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return successResult(nil), nil
|
||||||
|
}
|
||||||
@@ -37,41 +37,102 @@ const (
|
|||||||
// platformValue 浏览器参数值
|
// platformValue 浏览器参数值
|
||||||
type platformValue = js.Value
|
type platformValue = js.Value
|
||||||
|
|
||||||
|
// wasmEngines 管理共享资源的独立浏览器业务会话
|
||||||
|
type wasmEngines struct {
|
||||||
|
resources *ofdgo.Resources
|
||||||
|
engines map[string]*businessEngine
|
||||||
|
}
|
||||||
|
|
||||||
// RunWASM 注册浏览器WASM接口并阻塞运行
|
// RunWASM 注册浏览器WASM接口并阻塞运行
|
||||||
func RunWASM() {
|
func RunWASM() {
|
||||||
e := &businessEngine{}
|
resources, _ := ofdgo.NewResources(ofdgo.ResourceOptions{Parallelism: 1, MemoryLimit: 64 << 20, CacheBytes: 48 << 20, SnapshotBytes: 32 << 20})
|
||||||
asynchronous := map[string]bool{
|
engines := &wasmEngines{resources: resources, engines: make(map[string]*businessEngine)}
|
||||||
"ofdgoDetectFormat": true,
|
registerCallback("ofdgoEngineOpen", engines.open)
|
||||||
"ofdgoExportVersion": true,
|
registerAsyncCallback("ofdgoEngineCall", engines.call)
|
||||||
"ofdgoConvertPDF": true,
|
registerCallback("ofdgoEngineClose", engines.close)
|
||||||
"ofdgoConvertFile": true,
|
|
||||||
"ofdgoPackFiles": true,
|
|
||||||
"ofdgoExportPage": true,
|
|
||||||
"ofdgoExportDocument": true,
|
|
||||||
"ofdgoExportAttachment": true,
|
|
||||||
"ofdgoExportMedia": true,
|
|
||||||
"ofdgoResources": true,
|
|
||||||
"ofdgoExportResource": true,
|
|
||||||
"ofdgoExportImages": true,
|
|
||||||
"ofdgoImportImages": true,
|
|
||||||
"ofdgoImportPages": true,
|
|
||||||
"ofdgoImportDocuments": true,
|
|
||||||
"ofdgoSaveDocument": true,
|
|
||||||
"ofdgoPrepareSave": true,
|
|
||||||
"ofdgoSaveSnapshot": true,
|
|
||||||
"ofdgoSetEncryption": true,
|
|
||||||
"ofdgoSaveSigned": true,
|
|
||||||
}
|
|
||||||
for name, callback := range e.businessCommands() {
|
|
||||||
if asynchronous[name] {
|
|
||||||
registerAsyncCallback(name, callback)
|
|
||||||
} else {
|
|
||||||
registerCallback(name, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {}
|
select {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// open 创建具有稳定标识的独立业务会话
|
||||||
|
// 入参: args 会话标识
|
||||||
|
// 返回: any 空结果, error 参数或会话错误
|
||||||
|
func (w *wasmEngines) open(args []js.Value) (any, error) {
|
||||||
|
if len(args) != 1 || args[0].Type() != js.TypeString || args[0].String() == "" {
|
||||||
|
return nil, fmt.Errorf("missing engine ID")
|
||||||
|
}
|
||||||
|
id := args[0].String()
|
||||||
|
if w.engines[id] != nil {
|
||||||
|
return nil, fmt.Errorf("engine already exists: %s", id)
|
||||||
|
}
|
||||||
|
w.engines[id] = &businessEngine{resources: w.resources}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// call 按会话标识分派业务命令,统一绑定本次调用的取消上下文
|
||||||
|
// 入参: args 会话标识、命令、取消检查点及业务参数
|
||||||
|
// 返回: any 业务结果, error 调用错误
|
||||||
|
func (w *wasmEngines) call(args []js.Value) (any, error) {
|
||||||
|
if len(args) < 3 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString {
|
||||||
|
return nil, fmt.Errorf("missing engine ID or operation")
|
||||||
|
}
|
||||||
|
e := w.engines[args[0].String()]
|
||||||
|
if e == nil {
|
||||||
|
return nil, fmt.Errorf("unknown engine: %s", args[0].String())
|
||||||
|
}
|
||||||
|
name := args[1].String()
|
||||||
|
fn := e.businessCommands()[name]
|
||||||
|
if fn == nil {
|
||||||
|
return nil, fmt.Errorf("unknown operation: %s", name)
|
||||||
|
}
|
||||||
|
if err := e.checkDocument(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.ctx = context.Background()
|
||||||
|
if args[2].Type() == js.TypeFunction {
|
||||||
|
e.ctx = e.newOutputContext(args[2])
|
||||||
|
}
|
||||||
|
ctx := e.ctx
|
||||||
|
if name != "ofdgoSaveSnapshot" && e.currentSession != nil {
|
||||||
|
e.currentSession.Renderer.OutputContext = ctx
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if e.ctx == ctx {
|
||||||
|
e.ctx = nil
|
||||||
|
}
|
||||||
|
if name != "ofdgoSaveSnapshot" && e.currentSession != nil {
|
||||||
|
e.currentSession.Renderer.OutputContext = context.Background()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := e.preparePDF(name, args[3:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fn(args[3:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// close 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源
|
||||||
|
// 入参: args 会话标识
|
||||||
|
// 返回: any 空结果, error 参数或关闭错误
|
||||||
|
func (w *wasmEngines) close(args []js.Value) (any, error) {
|
||||||
|
if len(args) != 1 || args[0].Type() != js.TypeString || args[0].String() == "" {
|
||||||
|
return nil, fmt.Errorf("missing engine ID")
|
||||||
|
}
|
||||||
|
id := args[0].String()
|
||||||
|
e := w.engines[id]
|
||||||
|
if e == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
delete(w.engines, id)
|
||||||
|
_, _ = e.discardPrint(nil)
|
||||||
|
e.clearPDF()
|
||||||
|
e.clearImport()
|
||||||
|
err := e.currentSession.Close()
|
||||||
|
*e = businessEngine{}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// awaitExport 等待浏览器处理导出检查点
|
// awaitExport 等待浏览器处理导出检查点
|
||||||
// 入参: fn 浏览器回调, args 回调参数
|
// 入参: fn 浏览器回调, args 回调参数
|
||||||
// 返回: error 写入错误或取消原因
|
// 返回: error 写入错误或取消原因
|
||||||
@@ -221,8 +282,11 @@ func platformParseJSON(data []byte) map[string]any {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputFromValue 读取浏览器二进制源
|
// inputFromValue 读取浏览器随机输入或二进制数据
|
||||||
func (e *businessEngine) inputFromValue(value platformValue) (*ofdgo.Input, error) {
|
func (e *businessEngine) inputFromValue(value platformValue) (*ofdgo.Input, error) {
|
||||||
|
if value.Type() == platformTypeObject && !value.IsNull() && value.Get("read").Type() == platformTypeFunction {
|
||||||
|
return ofdgo.NewInput(&browserReaderAt{read: value.Get("read"), size: int64(value.Get("size").Float())}, int64(value.Get("size").Float()))
|
||||||
|
}
|
||||||
data, err := e.bytesFromJS(value)
|
data, err := e.bytesFromJS(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+433
-31
@@ -20,6 +20,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -28,28 +29,36 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/xiaoqidun/guigo"
|
"github.com/xiaoqidun/guigo"
|
||||||
"github.com/xiaoqidun/ofdgo"
|
"github.com/xiaoqidun/ofdgo"
|
||||||
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
|
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
|
||||||
|
"github.com/xiaoqidun/ofdgo/internal/build"
|
||||||
"github.com/xiaoqidun/ofdgo/internal/webui"
|
"github.com/xiaoqidun/ofdgo/internal/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// applicationID 标识原生应用,与版本及可执行文件路径无关
|
const applicationVersionURL = "https://ofdgo.aite.me/"
|
||||||
const applicationID = "me.aite.ofdgo"
|
const applicationDownloadURL = "https://ofdgo.aite.me/releases/"
|
||||||
|
|
||||||
// Host 连接桌面系统能力和共用文档业务
|
// Host 连接桌面系统能力和共用文档业务
|
||||||
type Host struct {
|
type Host struct {
|
||||||
|
themeMu sync.Mutex
|
||||||
|
theme applicationTheme
|
||||||
|
themePath string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
window *guigo.Window
|
window *guigo.Window
|
||||||
files *fileStore
|
files *fileStore
|
||||||
engines map[string]*webui.NativeEngine
|
engines map[string]*webui.NativeEngine
|
||||||
engineStops map[string]func() bool
|
engineStops map[string]func() bool
|
||||||
|
closingEngines map[string]*engineClose
|
||||||
conversionPool *ofdgo.ConversionPool
|
conversionPool *ofdgo.ConversionPool
|
||||||
|
resources *ofdgo.Resources
|
||||||
cancels map[string]context.CancelFunc
|
cancels map[string]context.CancelFunc
|
||||||
batchResults map[string]any
|
batchResults map[string]any
|
||||||
fontOnce sync.Once
|
fontOnce sync.Once
|
||||||
@@ -57,6 +66,24 @@ type Host struct {
|
|||||||
fontFiles map[string]string
|
fontFiles map[string]string
|
||||||
fontError error
|
fontError error
|
||||||
pendingPaths []string
|
pendingPaths []string
|
||||||
|
launchMu sync.Mutex
|
||||||
|
launchActivate bool
|
||||||
|
launchClosed bool
|
||||||
|
launchPending int
|
||||||
|
frontendReady bool
|
||||||
|
closeAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// associationState 保存单个范围的登记状态和查询错误
|
||||||
|
type associationState struct {
|
||||||
|
guigo.AssociationStatus
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineClose 保存并发关闭共享的完成通知及结果
|
||||||
|
type engineClose struct {
|
||||||
|
done chan struct{}
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// resourceReader 保存资源读取位置及关闭操作
|
// resourceReader 保存资源读取位置及关闭操作
|
||||||
@@ -73,19 +100,56 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
paths = append([]string(nil), paths...)
|
||||||
|
for index, path := range paths {
|
||||||
|
paths[index], err = filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := &Host{pendingPaths: paths, themePath: filepath.Join(options.UserDataDir, "appearance.json")}
|
||||||
|
if err := h.loadTheme(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.TitleBarColors = themeColors(h.theme.Dark)
|
||||||
|
data, err := json.Marshal(paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(options.UserDataDir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, err := instanceDirectory(options.UserDataDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
instance, err := guigo.OpenWindowInstance(ctx, "OFDGo:"+key, data, h.receiveLaunch)
|
||||||
|
if err != nil || instance == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer instance.Close()
|
||||||
|
options.Instance = instance
|
||||||
files, err := newFileStore()
|
files, err := newFileStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h := &Host{files: files, engines: make(map[string]*webui.NativeEngine), cancels: make(map[string]context.CancelFunc), fontFiles: make(map[string]string), pendingPaths: append([]string(nil), paths...)}
|
h.files = files
|
||||||
h.conversionPool, err = ofdgo.NewConversionPool(ofdgo.ConversionPoolOptions{AvailableMemory: conversionMemory})
|
h.engines = make(map[string]*webui.NativeEngine)
|
||||||
|
h.cancels = make(map[string]context.CancelFunc)
|
||||||
|
h.fontFiles = make(map[string]string)
|
||||||
|
cacheBytes := int64(256 << 20)
|
||||||
|
if memory := conversionMemory; memory != nil {
|
||||||
|
cacheBytes = max(32<<20, memory()/8)
|
||||||
|
}
|
||||||
|
h.resources, err = ofdgo.NewResources(ofdgo.ResourceOptions{CacheBytes: cacheBytes, AvailableMemory: conversionMemory})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
files.close()
|
files.close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
h.conversionPool = h.resources.ConversionPool()
|
||||||
defer h.close()
|
defer h.close()
|
||||||
bindings := make(map[string]guigo.HandlerFunc)
|
bindings := make(map[string]guigo.HandlerFunc)
|
||||||
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} {
|
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "launch.done", "title", "window.info", "window.drag", "window.close", "appearance", "update.check", "update.open", "association.query", "association.register", "association.remove", "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print"} {
|
||||||
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
|
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
|
||||||
}
|
}
|
||||||
static := http.FileServerFS(webuiassets.NativeFS)
|
static := http.FileServerFS(webuiassets.NativeFS)
|
||||||
@@ -98,7 +162,20 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
|
data, err := h.themeIndex()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("X-OFDGo-Build-Version", build.Version)
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-OFDGo-Build-Version", build.Version)
|
||||||
static.ServeHTTP(w, r)
|
static.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
options.Handler = handler
|
options.Handler = handler
|
||||||
@@ -110,6 +187,9 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
options.OnClose = func() {
|
options.OnClose = func() {
|
||||||
|
h.launchMu.Lock()
|
||||||
|
h.launchClosed = true
|
||||||
|
h.launchMu.Unlock()
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
for _, cancel := range h.cancels {
|
for _, cancel := range h.cancels {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -117,6 +197,18 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
options.OnDrop = h.openPaths
|
options.OnDrop = h.openPaths
|
||||||
|
options.OnTitleBarChanged = func(info guigo.TitleBarInfo) {
|
||||||
|
h.emit("ofdgo.window", info)
|
||||||
|
}
|
||||||
|
options.OnClosing = func(ctx context.Context) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
allowed := h.closeAllowed || !h.frontendReady
|
||||||
|
h.mu.Unlock()
|
||||||
|
if !allowed {
|
||||||
|
h.emit("ofdgo.closing", nil)
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
options.OnError = func(err error) {
|
options.OnError = func(err error) {
|
||||||
h.emit("ofdgo.error", err.Error())
|
h.emit("ofdgo.error", err.Error())
|
||||||
}
|
}
|
||||||
@@ -164,7 +256,13 @@ func (h *Host) resource(r *http.Request) (*guigo.Resource, error) {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = "index.html"
|
name = "index.html"
|
||||||
}
|
}
|
||||||
data, err := webuiassets.NativeFS.ReadFile(name)
|
var data []byte
|
||||||
|
var err error
|
||||||
|
if name == "index.html" {
|
||||||
|
data, err = h.themeIndex()
|
||||||
|
} else {
|
||||||
|
data, err = webuiassets.NativeFS.ReadFile(name)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -180,6 +278,7 @@ func (h *Host) resource(r *http.Request) (*guigo.Resource, error) {
|
|||||||
contentType = http.DetectContentType(header)
|
contentType = http.DetectContentType(header)
|
||||||
}
|
}
|
||||||
headers := http.Header{"Content-Type": {contentType}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}, "Accept-Ranges": {"bytes"}}
|
headers := http.Header{"Content-Type": {contentType}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}, "Accept-Ranges": {"bytes"}}
|
||||||
|
headers.Set("X-OFDGo-Build-Version", build.Version)
|
||||||
start, length, status := int64(0), size, http.StatusOK
|
start, length, status := int64(0), size, http.StatusOK
|
||||||
if value := r.Header.Get("Range"); value != "" && r.Method == http.MethodGet {
|
if value := r.Header.Get("Range"); value != "" && r.Method == http.MethodGet {
|
||||||
if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") {
|
if !strings.HasPrefix(value, "bytes=") || strings.Contains(value, ",") {
|
||||||
@@ -239,19 +338,91 @@ func (h *Host) emit(name string, value any) {
|
|||||||
// openPaths 将操作系统提供的文件登记后交给界面
|
// openPaths 将操作系统提供的文件登记后交给界面
|
||||||
// 入参: names 文件路径列表
|
// 入参: names 文件路径列表
|
||||||
func (h *Host) openPaths(names []string) {
|
func (h *Host) openPaths(names []string) {
|
||||||
|
_ = h.sendPaths(names, "ofdgo.files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPaths 将文件登记后发送到指定界面入口
|
||||||
|
// 入参: names 文件路径列表, event 界面事件名称
|
||||||
|
// 返回: error 投递错误
|
||||||
|
func (h *Host) sendPaths(names []string, event string) error {
|
||||||
files := make([]any, 0, len(names))
|
files := make([]any, 0, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
handle, err := h.files.add(name, false)
|
file, err := h.files.addDrop(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.emit("ofdgo.error", err.Error())
|
h.emit("ofdgo.error", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
file, err := h.files.file(handle["nativeHandle"].(string))
|
|
||||||
if err == nil {
|
|
||||||
files = append(files, file)
|
files = append(files, file)
|
||||||
}
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
w := h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
var err error
|
||||||
|
if w == nil {
|
||||||
|
err = guigo.ErrClosed
|
||||||
|
} else {
|
||||||
|
err = w.Emit(event, files)
|
||||||
}
|
}
|
||||||
h.emit("ofdgo.files", files)
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
h.files.discardDrop(file.(map[string]any)["nativeFile"].(string))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiveLaunch 接收已有进程转交的文件及激活请求
|
||||||
|
// 入参: ctx 请求上下文, data 文件路径消息
|
||||||
|
// 返回: error 接收错误
|
||||||
|
func (h *Host) receiveLaunch(ctx context.Context, data []byte) error {
|
||||||
|
var paths []string
|
||||||
|
if err := json.Unmarshal(data, &paths); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return errors.New("file path must be absolute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.launchMu.Lock()
|
||||||
|
defer h.launchMu.Unlock()
|
||||||
|
if h.launchClosed {
|
||||||
|
return guigo.ErrClosed
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.pendingPaths = append(h.pendingPaths, paths...)
|
||||||
|
h.launchActivate = true
|
||||||
|
return h.flushLaunch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushLaunch 在界面就绪后按接收顺序投递外部打开请求,调用方持有launchMu
|
||||||
|
// 返回: error 投递错误
|
||||||
|
func (h *Host) flushLaunch() error {
|
||||||
|
h.mu.Lock()
|
||||||
|
ready, window := h.frontendReady, h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
if !ready || window == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if h.launchActivate {
|
||||||
|
if err := window.Activate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.launchActivate = false
|
||||||
|
}
|
||||||
|
if len(h.pendingPaths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
paths := h.pendingPaths
|
||||||
|
h.pendingPaths = nil
|
||||||
|
if err := h.sendPaths(paths, "ofdgo.launch"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.launchPending++
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// call 执行宿主协议并保留业务结果结构
|
// call 执行宿主协议并保留业务结果结构
|
||||||
@@ -265,9 +436,56 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
|
|||||||
text := func(key string) string { value, _ := args[key].(string); return value }
|
text := func(key string) string { value, _ := args[key].(string); return value }
|
||||||
flag := func(key string) bool { value, _ := args[key].(bool); return value }
|
flag := func(key string) bool { value, _ := args[key].(bool); return value }
|
||||||
switch name {
|
switch name {
|
||||||
|
case "appearance":
|
||||||
|
return nil, h.setTheme(applicationTheme{Mode: text("mode"), Dark: flag("dark")})
|
||||||
|
case "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print":
|
||||||
|
h.mu.Lock()
|
||||||
|
window := h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
if window == nil {
|
||||||
|
return nil, fmt.Errorf("窗口尚未就绪")
|
||||||
|
}
|
||||||
|
if name == "print.capabilities" {
|
||||||
|
return window.PrintCapabilities(ctx)
|
||||||
|
}
|
||||||
|
if name == "print.dialog" {
|
||||||
|
return nil, window.ShowPrintUI(ctx, text("kind"))
|
||||||
|
}
|
||||||
|
if name == "print.printers" {
|
||||||
|
return window.ListPrinters(ctx)
|
||||||
|
}
|
||||||
|
if name == "print.properties" {
|
||||||
|
return nil, window.ShowPrinterProperties(ctx, text("printer"))
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var options guigo.PrintOptions
|
||||||
|
if err := json.Unmarshal(data, &options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profile, err := window.QueryPrinter(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if name == "print.query" {
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
if text("fingerprint") == "" || text("fingerprint") != profile.Fingerprint {
|
||||||
|
return nil, fmt.Errorf("打印机设置已变化,请重新预览")
|
||||||
|
}
|
||||||
|
return window.Print(ctx, options)
|
||||||
case "engine.open":
|
case "engine.open":
|
||||||
engine := webui.NewNativeEngine(h.files.resolve)
|
engine := webui.NewNativeEngine(h.files.resolve)
|
||||||
|
parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20)
|
||||||
|
if memory := conversionMemory; memory != nil {
|
||||||
|
available := memory()
|
||||||
|
cacheBytes = max(32<<20, available/32)
|
||||||
|
}
|
||||||
|
engine.RenderParallelism = parallelism
|
||||||
engine.ConversionPool = h.conversionPool
|
engine.ConversionPool = h.conversionPool
|
||||||
|
engine.Resources = h.resources
|
||||||
engine.OpenWriter = h.openWriter
|
engine.OpenWriter = h.openWriter
|
||||||
engine.ReserveOutput = h.reserveOutput
|
engine.ReserveOutput = h.reserveOutput
|
||||||
engine.CleanupOutput = h.cleanupOutput
|
engine.CleanupOutput = h.cleanupOutput
|
||||||
@@ -280,9 +498,17 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
|
|||||||
}
|
}
|
||||||
h.engineStops[id] = context.AfterFunc(guigo.PageContext(ctx), func() { _ = h.closeEngine(id) })
|
h.engineStops[id] = context.AfterFunc(guigo.PageContext(ctx), func() { _ = h.closeEngine(id) })
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
return guigo.Result{Value: map[string]any{"engine": id}, Discard: func() { _ = h.closeEngine(id) }}, nil
|
return guigo.Result{Value: map[string]any{"engine": id, "parallelism": parallelism, "cacheBytes": cacheBytes}, Discard: func() { _ = h.closeEngine(id) }}, nil
|
||||||
case "batch":
|
case "batch":
|
||||||
return map[string]any{"parallelism": h.conversionPool.Parallelism()}, nil
|
return map[string]any{"parallelism": h.conversionPool.Parallelism()}, nil
|
||||||
|
case "update.check":
|
||||||
|
return applicationUpdate(ctx, build.Version, applicationVersionURL)
|
||||||
|
case "update.open":
|
||||||
|
return nil, guigo.OpenURL(applicationDownloadURL)
|
||||||
|
case "association.query":
|
||||||
|
return queryAssociations()
|
||||||
|
case "association.register", "association.remove":
|
||||||
|
return nil, changeAssociations(text("scope"), name == "association.remove")
|
||||||
case "engine.close":
|
case "engine.close":
|
||||||
return nil, h.closeEngine(text("engine"))
|
return nil, h.closeEngine(text("engine"))
|
||||||
case "engine.finish":
|
case "engine.finish":
|
||||||
@@ -441,6 +667,9 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
|
|||||||
case "release":
|
case "release":
|
||||||
h.files.release(text("file"))
|
h.files.release(text("file"))
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
case "drop.release":
|
||||||
|
h.files.discardDrop(text("file"))
|
||||||
|
return nil, nil
|
||||||
case "child":
|
case "child":
|
||||||
value, err := h.files.child(text("handle"), text("name"), flag("directory"), flag("create"))
|
value, err := h.files.child(text("handle"), text("name"), flag("directory"), flag("create"))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -492,19 +721,160 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, window.SetTitle(text("title"))
|
return nil, window.SetTitle(text("title"))
|
||||||
case "ready":
|
case "window.info", "window.drag", "window.close":
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
paths := h.pendingPaths
|
window := h.window
|
||||||
h.pendingPaths = nil
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if len(paths) != 0 {
|
if window == nil {
|
||||||
h.openPaths(paths)
|
return nil, fmt.Errorf("窗口尚未就绪")
|
||||||
}
|
}
|
||||||
|
if name == "window.info" {
|
||||||
|
return window.TitleBarInfo()
|
||||||
|
}
|
||||||
|
if name == "window.close" {
|
||||||
|
h.launchMu.Lock()
|
||||||
|
defer h.launchMu.Unlock()
|
||||||
|
if h.launchPending != 0 || len(h.pendingPaths) != 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
h.launchClosed = true
|
||||||
|
h.mu.Lock()
|
||||||
|
h.closeAllowed = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
return true, window.Close()
|
||||||
|
}
|
||||||
|
values, ok := args["regions"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("拖动区域无效")
|
||||||
|
}
|
||||||
|
regions := make([]guigo.Rect, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
item, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("拖动区域无效")
|
||||||
|
}
|
||||||
|
x, xOK := item["x"].(float64)
|
||||||
|
y, yOK := item["y"].(float64)
|
||||||
|
width, widthOK := item["width"].(float64)
|
||||||
|
height, heightOK := item["height"].(float64)
|
||||||
|
if !xOK || !yOK || !widthOK || !heightOK {
|
||||||
|
return nil, fmt.Errorf("拖动区域无效")
|
||||||
|
}
|
||||||
|
regions = append(regions, guigo.Rect{X: x, Y: y, Width: width, Height: height})
|
||||||
|
}
|
||||||
|
return nil, window.SetDragRegions(regions)
|
||||||
|
case "launch.done":
|
||||||
|
h.launchMu.Lock()
|
||||||
|
if h.launchPending > 0 {
|
||||||
|
h.launchPending--
|
||||||
|
}
|
||||||
|
h.launchMu.Unlock()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
case "ready":
|
||||||
|
h.launchMu.Lock()
|
||||||
|
defer h.launchMu.Unlock()
|
||||||
|
h.mu.Lock()
|
||||||
|
h.frontendReady = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil, h.flushLaunch()
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("未知宿主操作")
|
return nil, fmt.Errorf("未知宿主操作")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// associationOptions 提供Windows文件关联的固定应用配置
|
||||||
|
// 返回: guigo.AssociationOptions 应用配置, error 路径错误
|
||||||
|
func associationOptions() (guigo.AssociationOptions, error) {
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return guigo.AssociationOptions{}, err
|
||||||
|
}
|
||||||
|
return guigo.AssociationOptions{
|
||||||
|
AppID: "OFDGo", Name: "OFDGo", Description: "OFD、PDF阅读与编辑工具",
|
||||||
|
Executable: executable, Arguments: []string{"--"},
|
||||||
|
Types: []guigo.FileAssociation{
|
||||||
|
{ID: "OFD", Name: "OFD文档", Extensions: []string{".ofd"}},
|
||||||
|
{ID: "PDF", Name: "PDF文档", Extensions: []string{".pdf"}},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAssociations 分别查询两个范围,不因其中一个失败而隐藏另一个
|
||||||
|
// 返回: any 两个范围的状态, error 配置错误
|
||||||
|
func queryAssociations() (any, error) {
|
||||||
|
options, err := associationOptions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make(map[string]associationState)
|
||||||
|
for _, scope := range []guigo.AssociationScope{guigo.AssociationUser, guigo.AssociationMachine} {
|
||||||
|
state, err := guigo.QueryFileAssociations(options, scope)
|
||||||
|
row := associationState{AssociationStatus: state}
|
||||||
|
if err != nil {
|
||||||
|
row.Error = associationError(err).Error()
|
||||||
|
}
|
||||||
|
result[string(scope)] = row
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeAssociations 执行固定应用配置的单范围注册或撤销
|
||||||
|
// 入参: scope 作用范围, remove 是否撤销
|
||||||
|
// 返回: error 操作错误
|
||||||
|
func changeAssociations(scope string, remove bool) error {
|
||||||
|
options, err := associationOptions()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
err = guigo.UnregisterFileAssociations(options, guigo.AssociationScope(scope))
|
||||||
|
} else {
|
||||||
|
err = guigo.RegisterFileAssociations(options, guigo.AssociationScope(scope))
|
||||||
|
}
|
||||||
|
return associationError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// associationError 区分未提升权限和实际访问失败,提供简短提示
|
||||||
|
// 入参: err 操作错误
|
||||||
|
// 返回: error 用户提示或原始错误
|
||||||
|
func associationError(err error) error {
|
||||||
|
if errors.Is(err, guigo.ErrAssociationElevationRequired) {
|
||||||
|
return errors.New("需管理员权限")
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrPermission) {
|
||||||
|
return errors.New("无权访问")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// applicationUpdate 比较官网和当前程序的完整构建版本
|
||||||
|
// 入参: ctx 请求上下文, version 当前版本, endpoint 官网地址
|
||||||
|
// 返回: bool 是否有更新, error 检查错误
|
||||||
|
func applicationUpdate(ctx context.Context, version, endpoint string) (bool, error) {
|
||||||
|
if version == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Cache-Control", "no-store")
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("检查更新失败: %s", response.Status)
|
||||||
|
}
|
||||||
|
latest := response.Header.Get("X-OFDGo-Build-Version")
|
||||||
|
if latest == "" {
|
||||||
|
return false, errors.New("缺少构建版本")
|
||||||
|
}
|
||||||
|
return latest != version, nil
|
||||||
|
}
|
||||||
|
|
||||||
// dialogFilters 将共用界面的文件类型映射到系统对话框
|
// dialogFilters 将共用界面的文件类型映射到系统对话框
|
||||||
// 入参: args 对话框参数
|
// 入参: args 对话框参数
|
||||||
// 返回: []guigo.FileFilter 文件类型列表
|
// 返回: []guigo.FileFilter 文件类型列表
|
||||||
@@ -706,7 +1076,7 @@ func (h *Host) readFonts() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, face := range faces {
|
for _, face := range faces {
|
||||||
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "file": file})
|
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "menuFamily": face.MenuFamily, "menuStyle": face.MenuStyle, "familyKey": face.FamilyKey, "bold": face.Bold, "italic": face.Italic, "file": file})
|
||||||
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
|
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
|
||||||
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
||||||
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
||||||
@@ -742,7 +1112,21 @@ func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) {
|
|||||||
func (h *Host) closeEngine(id string) error {
|
func (h *Host) closeEngine(id string) error {
|
||||||
var results []any
|
var results []any
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
if closing := h.closingEngines[id]; closing != nil {
|
||||||
|
h.mu.Unlock()
|
||||||
|
<-closing.done
|
||||||
|
return closing.err
|
||||||
|
}
|
||||||
engine := h.engines[id]
|
engine := h.engines[id]
|
||||||
|
if engine == nil {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
closing := &engineClose{done: make(chan struct{})}
|
||||||
|
if h.closingEngines == nil {
|
||||||
|
h.closingEngines = make(map[string]*engineClose)
|
||||||
|
}
|
||||||
|
h.closingEngines[id] = closing
|
||||||
delete(h.engines, id)
|
delete(h.engines, id)
|
||||||
if stop := h.engineStops[id]; stop != nil {
|
if stop := h.engineStops[id]; stop != nil {
|
||||||
stop()
|
stop()
|
||||||
@@ -763,33 +1147,43 @@ func (h *Host) closeEngine(id string) error {
|
|||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
h.discard(result)
|
h.discard(result)
|
||||||
}
|
}
|
||||||
if engine != nil {
|
err := engine.Close()
|
||||||
return engine.Close()
|
h.mu.Lock()
|
||||||
}
|
closing.err = err
|
||||||
return nil
|
delete(h.closingEngines, id)
|
||||||
|
close(closing.done)
|
||||||
|
h.mu.Unlock()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// close 取消任务并释放当前窗口持有的会话
|
// close 取消任务并释放当前窗口持有的会话
|
||||||
func (h *Host) close() {
|
func (h *Host) close() {
|
||||||
|
h.launchMu.Lock()
|
||||||
|
h.launchClosed = true
|
||||||
|
h.launchMu.Unlock()
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
for _, cancel := range h.cancels {
|
for _, cancel := range h.cancels {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
engines := h.engines
|
engines := make([]string, 0, len(h.engines)+len(h.closingEngines))
|
||||||
|
for id := range h.engines {
|
||||||
|
engines = append(engines, id)
|
||||||
|
}
|
||||||
|
for id := range h.closingEngines {
|
||||||
|
engines = append(engines, id)
|
||||||
|
}
|
||||||
results := h.batchResults
|
results := h.batchResults
|
||||||
h.batchResults = nil
|
h.batchResults = nil
|
||||||
for _, stop := range h.engineStops {
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
h.engineStops = nil
|
|
||||||
h.engines = make(map[string]*webui.NativeEngine)
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
for _, engine := range engines {
|
for _, id := range engines {
|
||||||
_ = engine.Close()
|
_ = h.closeEngine(id)
|
||||||
}
|
}
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
h.discard(result)
|
h.discard(result)
|
||||||
}
|
}
|
||||||
|
if h.resources != nil {
|
||||||
|
h.resources.Clear()
|
||||||
|
}
|
||||||
_ = h.files.close()
|
_ = h.files.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,12 +1191,18 @@ func (h *Host) close() {
|
|||||||
// 入参: dataDir 自定义浏览器数据目录
|
// 入参: dataDir 自定义浏览器数据目录
|
||||||
// 返回: guigo.Options 窗口配置, error 配置错误
|
// 返回: guigo.Options 窗口配置, error 配置错误
|
||||||
func windowOptions(dataDir string) (guigo.Options, error) {
|
func windowOptions(dataDir string) (guigo.Options, error) {
|
||||||
|
appID := "ofdgo"
|
||||||
|
cacheName := appID
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
appID = ""
|
||||||
|
cacheName = "OFDGo"
|
||||||
|
}
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
cache, err := os.UserCacheDir()
|
cache, err := os.UserCacheDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return guigo.Options{}, err
|
return guigo.Options{}, err
|
||||||
}
|
}
|
||||||
dataDir = filepath.Join(cache, "OFDGo")
|
dataDir = filepath.Join(cache, cacheName)
|
||||||
}
|
}
|
||||||
dataDir, err := filepath.Abs(dataDir)
|
dataDir, err := filepath.Abs(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -813,8 +1213,10 @@ func windowOptions(dataDir string) (guigo.Options, error) {
|
|||||||
return guigo.Options{}, err
|
return guigo.Options{}, err
|
||||||
}
|
}
|
||||||
return guigo.Options{
|
return guigo.Options{
|
||||||
AppID: applicationID,
|
AppID: appID,
|
||||||
Title: "OFDGo - OFD编辑器",
|
Title: "OFDGo - OFD编辑器",
|
||||||
|
TitleBar: guigo.TitleBarContent,
|
||||||
|
TitleBarColors: themeColors(false),
|
||||||
Icon: icon,
|
Icon: icon,
|
||||||
Width: 1440,
|
Width: 1440,
|
||||||
Height: 900,
|
Height: 900,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type fileEntry struct {
|
|||||||
saveTarget ofdgo.Output
|
saveTarget ofdgo.Output
|
||||||
readers int
|
readers int
|
||||||
retired bool
|
retired bool
|
||||||
|
dropHandle string
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileStore 管理窗口范围内的文件授权和临时数据
|
// fileStore 管理窗口范围内的文件授权和临时数据
|
||||||
@@ -137,6 +138,59 @@ func (s *fileStore) add(name string, directory bool) (map[string]any, error) {
|
|||||||
return s.descriptor(id), nil
|
return s.descriptor(id), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addDrop 登记拖放文件及读取快照,失败时撤销本次授权
|
||||||
|
// 入参: name 拖放文件路径
|
||||||
|
// 返回: map[string]any 文件描述, error 登记错误
|
||||||
|
func (s *fileStore) addDrop(name string) (map[string]any, error) {
|
||||||
|
handle, err := s.add(name, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := handle["nativeHandle"].(string)
|
||||||
|
file, err := s.file(id)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err == nil && !s.closed {
|
||||||
|
if entry := s.entries[file["nativeFile"].(string)]; entry != nil && s.entries[id] != nil {
|
||||||
|
entry.dropHandle = id
|
||||||
|
file["drop"] = true
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
err = os.ErrNotExist
|
||||||
|
}
|
||||||
|
delete(s.entries, id)
|
||||||
|
if file != nil {
|
||||||
|
key := file["nativeFile"].(string)
|
||||||
|
if entry := s.entries[key]; entry != nil {
|
||||||
|
delete(s.entries, key)
|
||||||
|
if entry.file != nil {
|
||||||
|
entry.file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = os.ErrClosed
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// discardDrop 撤销未接收的拖放文件及其授权,不删除源文件
|
||||||
|
// 入参: id 拖放读取快照标识
|
||||||
|
func (s *fileStore) discardDrop(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry := s.entries[id]
|
||||||
|
if entry == nil || entry.dropHandle == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(s.entries, entry.dropHandle)
|
||||||
|
delete(s.entries, id)
|
||||||
|
entry.retired = true
|
||||||
|
if entry.file != nil && entry.readers == 0 {
|
||||||
|
entry.file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// save 登记用户选择的输出路径
|
// save 登记用户选择的输出路径
|
||||||
// 入参: name 输出文件路径
|
// 入参: name 输出文件路径
|
||||||
// 返回: map[string]any 句柄描述
|
// 返回: map[string]any 句柄描述
|
||||||
@@ -279,17 +333,18 @@ func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error
|
|||||||
return file, info.Size(), file.Close, nil
|
return file, info.Size(), file.Close, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// release 释放内存结果或临时文件并取消页面清理回调
|
// release 释放读取快照、内存结果或临时文件,保留在途读取及源文件
|
||||||
// 入参: id 资源标识
|
// 入参: id 资源标识
|
||||||
func (s *fileStore) release(id string) {
|
func (s *fileStore) release(id string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary) {
|
if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary || entry.file != nil) {
|
||||||
delete(s.entries, id)
|
delete(s.entries, id)
|
||||||
|
entry.retired = true
|
||||||
if entry.stop != nil {
|
if entry.stop != nil {
|
||||||
entry.stop()
|
entry.stop()
|
||||||
}
|
}
|
||||||
if entry.file != nil {
|
if entry.file != nil && entry.readers == 0 {
|
||||||
entry.file.Close()
|
entry.file.Close()
|
||||||
}
|
}
|
||||||
if entry.temporary {
|
if entry.temporary {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 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 webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// conversionMemory 未提供系统内存探测时仅按并行数调度
|
||||||
|
var conversionMemory func() int64
|
||||||
|
|
||||||
|
// instanceDirectory 解析数据目录的实际路径,使符号链接复用同一实例
|
||||||
|
// 入参: path 数据目录路径
|
||||||
|
// 返回: string 规范路径, error 路径解析错误
|
||||||
|
func instanceDirectory(path string) (string, error) {
|
||||||
|
return filepath.EvalSymlinks(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fontNameLanguage 获取Linux区域设置对应的OpenType名称表标识
|
||||||
|
// 返回: uint16 名称表平台标识, uint16 显示语言标识
|
||||||
|
func fontNameLanguage() (uint16, uint16) {
|
||||||
|
locale := ""
|
||||||
|
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||||
|
if locale = os.Getenv(name); locale != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locale = strings.ToLower(strings.ReplaceAll(locale, "-", "_"))
|
||||||
|
locale, _, _ = strings.Cut(locale, ".")
|
||||||
|
locale, _, _ = strings.Cut(locale, "@")
|
||||||
|
switch locale {
|
||||||
|
case "zh", "zh_cn", "zh_hans":
|
||||||
|
return 3, 0x0804
|
||||||
|
case "zh_hk":
|
||||||
|
return 3, 0x0c04
|
||||||
|
case "zh_mo":
|
||||||
|
return 3, 0x1404
|
||||||
|
case "zh_tw", "zh_hant":
|
||||||
|
return 3, 0x0404
|
||||||
|
case "zh_sg":
|
||||||
|
return 3, 0x1004
|
||||||
|
case "ja", "ja_jp":
|
||||||
|
return 3, 0x0411
|
||||||
|
case "ko", "ko_kr":
|
||||||
|
return 3, 0x0412
|
||||||
|
case "c", "posix", "en", "en_us":
|
||||||
|
return 3, 0x0409
|
||||||
|
}
|
||||||
|
return 0xffff, 0xffff
|
||||||
|
}
|
||||||
@@ -12,13 +12,22 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//go:build !windows
|
//go:build !windows && !linux
|
||||||
|
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
// conversionMemory 未提供系统内存探测时仅按并行数调度
|
// conversionMemory 未提供系统内存探测时仅按并行数调度
|
||||||
var conversionMemory func() int64
|
var conversionMemory func() int64
|
||||||
|
|
||||||
|
// instanceDirectory 解析数据目录的实际路径
|
||||||
|
// 入参: path 数据目录路径
|
||||||
|
// 返回: string 规范路径, error 路径解析错误
|
||||||
|
func instanceDirectory(path string) (string, error) {
|
||||||
|
return filepath.EvalSymlinks(path)
|
||||||
|
}
|
||||||
|
|
||||||
// fontNameLanguage 未指定系统显示语言时沿用字体名称表顺序
|
// fontNameLanguage 未指定系统显示语言时沿用字体名称表顺序
|
||||||
// 返回: uint16 未指定平台标识, uint16 未指定语言标识
|
// 返回: uint16 未指定平台标识, uint16 未指定语言标识
|
||||||
func fontNameLanguage() (uint16, uint16) {
|
func fontNameLanguage() (uint16, uint16) {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// 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 !js || !wasm
|
||||||
|
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/xiaoqidun/guigo"
|
||||||
|
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applicationTheme 保存应用主题偏好及最后一次解析的系统外观
|
||||||
|
type applicationTheme struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Dark bool `json:"dark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// themeColors 返回应用对应外观的原生标题栏配色
|
||||||
|
// 入参: dark 是否使用深色
|
||||||
|
// 返回: guigo.TitleBarColors 标题栏配色
|
||||||
|
func themeColors(dark bool) guigo.TitleBarColors {
|
||||||
|
if dark {
|
||||||
|
return guigo.TitleBarColors{Background: "#20272b", Foreground: "#e0e7eb", InactiveBackground: "#242b30", InactiveForeground: "#9aa8b1"}
|
||||||
|
}
|
||||||
|
return guigo.TitleBarColors{Background: "#e8edee", Foreground: "#263238", InactiveBackground: "#eef1f3", InactiveForeground: "#728087"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTheme 在创建窗口前读取用户目录中的主题偏好
|
||||||
|
// 返回: error 读取或解析错误
|
||||||
|
func (h *Host) loadTheme() error {
|
||||||
|
h.theme = applicationTheme{Mode: "system"}
|
||||||
|
data, err := os.ReadFile(h.themePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &h.theme); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.theme.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate 检查应用主题及解析后的配色是否一致
|
||||||
|
// 返回: error 偏好错误
|
||||||
|
func (a applicationTheme) validate() error {
|
||||||
|
if a.Mode != "system" && a.Mode != "light" && a.Mode != "dark" {
|
||||||
|
return fmt.Errorf("主题无效")
|
||||||
|
}
|
||||||
|
if a.Mode == "light" && a.Dark || a.Mode == "dark" && !a.Dark {
|
||||||
|
return fmt.Errorf("主题配色无效")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// themeIndex 在页面首次绘制前提供当前主题偏好
|
||||||
|
// 返回: []byte 页面内容, error 资源读取错误
|
||||||
|
func (h *Host) themeIndex() ([]byte, error) {
|
||||||
|
data, err := webuiassets.NativeFS.ReadFile("index.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.themeMu.Lock()
|
||||||
|
mode := h.theme.Mode
|
||||||
|
h.themeMu.Unlock()
|
||||||
|
if mode == "" {
|
||||||
|
mode = "system"
|
||||||
|
}
|
||||||
|
return bytes.Replace(data, []byte(`<html lang="zh-CN">`), []byte(`<html lang="zh-CN" data-theme-mode="`+mode+`">`), 1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTheme 串行保存应用偏好并同步当前窗口配色
|
||||||
|
// 入参: value 已解析的主题偏好
|
||||||
|
// 返回: error 校验、持久化或窗口更新错误
|
||||||
|
func (h *Host) setTheme(value applicationTheme) error {
|
||||||
|
if err := value.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.themeMu.Lock()
|
||||||
|
defer h.themeMu.Unlock()
|
||||||
|
h.mu.Lock()
|
||||||
|
window := h.window
|
||||||
|
h.mu.Unlock()
|
||||||
|
if window == nil {
|
||||||
|
return fmt.Errorf("窗口尚未就绪")
|
||||||
|
}
|
||||||
|
if h.theme != value {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp(filepath.Dir(h.themePath), "appearance-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(file.Name())
|
||||||
|
_, writeErr := file.Write(data)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
if err := os.Rename(file.Name(), h.themePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.theme = value
|
||||||
|
}
|
||||||
|
return window.SetTitleBarColors(themeColors(value.Dark))
|
||||||
|
}
|
||||||
@@ -15,13 +15,39 @@
|
|||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
// memoryStatusProc 查询系统物理内存和提交额度
|
// memoryStatusProc 查询系统物理内存和提交额度
|
||||||
var memoryStatusProc = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
var memoryStatusProc = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
||||||
|
|
||||||
|
// instanceDirectory 解析数据目录的实际路径,使目录联接及大小写别名复用同一实例
|
||||||
|
// 入参: path 数据目录路径
|
||||||
|
// 返回: string 规范路径, error 路径解析错误
|
||||||
|
func instanceDirectory(path string) (string, error) {
|
||||||
|
dir, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
buffer := make([]uint16, 256)
|
||||||
|
for {
|
||||||
|
n, err := windows.GetFinalPathNameByHandle(windows.Handle(dir.Fd()), &buffer[0], uint32(len(buffer)), 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if n < uint32(len(buffer)) {
|
||||||
|
return strings.ToLower(windows.UTF16ToString(buffer[:n])), nil
|
||||||
|
}
|
||||||
|
buffer = make([]uint16, n+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fontNameLanguage 获取系统显示语言对应的OpenType名称表标识
|
// fontNameLanguage 获取系统显示语言对应的OpenType名称表标识
|
||||||
// 返回: uint16 名称表平台标识, uint16 系统显示语言标识
|
// 返回: uint16 名称表平台标识, uint16 系统显示语言标识
|
||||||
func fontNameLanguage() (uint16, uint16) {
|
func fontNameLanguage() (uint16, uint16) {
|
||||||
|
|||||||
@@ -290,6 +290,9 @@ func NewReader(input *Input, options ...ReaderOption) (*Reader, error) {
|
|||||||
// 入参: reader 阅读器, opts 渲染选项
|
// 入参: reader 阅读器, opts 渲染选项
|
||||||
// 返回: *Renderer 渲染器实例
|
// 返回: *Renderer 渲染器实例
|
||||||
func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer {
|
func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer {
|
||||||
|
if reader.resources == nil {
|
||||||
|
reader.resources, _ = NewResources(ResourceOptions{})
|
||||||
|
}
|
||||||
r := &Renderer{
|
r := &Renderer{
|
||||||
Reader: reader,
|
Reader: reader,
|
||||||
DPI: 300.0,
|
DPI: 300.0,
|
||||||
@@ -297,7 +300,7 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer {
|
|||||||
DrawParams: reader.drawParamCache,
|
DrawParams: reader.drawParamCache,
|
||||||
CompositeGraphicUnits: reader.compositeGraphicUnitCache,
|
CompositeGraphicUnits: reader.compositeGraphicUnitCache,
|
||||||
templatePageCache: make(map[string]*PageContent),
|
templatePageCache: make(map[string]*PageContent),
|
||||||
imageCache: &renderCache[string, image.Image]{limit: imageCacheLimit},
|
imageCache: resourceCache[string, image.Image](reader.resources, imageCacheLimit),
|
||||||
backends: defaultRenderBackends(),
|
backends: defaultRenderBackends(),
|
||||||
backendStates: make(map[any]any),
|
backendStates: make(map[any]any),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type outputOptimization struct {
|
|||||||
protected bool
|
protected bool
|
||||||
completed int
|
completed int
|
||||||
deflater *flate.Writer
|
deflater *flate.Writer
|
||||||
|
resources *editorResourceSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
// outputCompressionBuffer 限制候选ZIP编码,不能缩小时终止本次压缩
|
// outputCompressionBuffer 限制候选ZIP编码,不能缩小时终止本次压缩
|
||||||
@@ -108,6 +109,7 @@ func (e *Editor) WriteToWithOptions(ctx context.Context, writer io.Writer, optio
|
|||||||
// 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置
|
// 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置
|
||||||
// 返回: int64 已写入字节数, error 错误信息
|
// 返回: int64 已写入字节数, error 错误信息
|
||||||
func (e *Editor) WritePagesTo(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) {
|
func (e *Editor) WritePagesTo(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) {
|
||||||
|
ctx = e.runtimeResources.context(ctx)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -125,6 +127,7 @@ func (e *Editor) WritePagesTo(ctx context.Context, writer io.Writer, indexes []i
|
|||||||
// 入参: ctx 取消上下文, options 输出配置
|
// 入参: ctx 取消上下文, options 输出配置
|
||||||
// 返回: *Editor 输出快照, error 配置或资源错误
|
// 返回: *Editor 输出快照, error 配置或资源错误
|
||||||
func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) {
|
func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) {
|
||||||
|
ctx = e.runtimeResources.context(ctx)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,12 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
|
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
|
||||||
)
|
)
|
||||||
|
|
||||||
// outputImageBatchLimit 限制并行任务持有的原始编码,超限图片单独处理
|
// outputImageWorkingSetLimit 指定未提供任务预算时的图像成本估算上限
|
||||||
const outputImageBatchLimit = 16 << 20
|
|
||||||
|
|
||||||
// outputImageWorkingSetLimit 限制64位并行任务的像素工作量总估算,32位使用64MiB,超限图片单独处理
|
|
||||||
const outputImageWorkingSetLimit = 256 << 20
|
const outputImageWorkingSetLimit = 256 << 20
|
||||||
|
|
||||||
// outputImageJob 保存独立图片任务,不访问编辑文档或调用进度回调
|
// outputImageJob 保存独立图片任务,不访问编辑文档或调用进度回调
|
||||||
@@ -82,20 +78,22 @@ func (e *Editor) outputImageOptions(key string) (CompressionOptions, image.Point
|
|||||||
// 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空
|
// 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空
|
||||||
// 返回: error 读取、进度或取消错误
|
// 返回: error 读取、进度或取消错误
|
||||||
func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) error {
|
func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) error {
|
||||||
workers := conversionWorkers(e.output.ctx, 4)
|
workers := conversionWorkers(e.output.ctx)
|
||||||
if workers < 2 || len(keys) < 2 {
|
if workers < 2 || len(keys) < 2 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
limit, encodedLimit := outputImageWorkingSetLimit, outputImageBatchLimit
|
limit := int(min(int64(workers)*(64<<20), int64(int(^uint(0)>>1)-1)))
|
||||||
|
encodedLimit := limit
|
||||||
if pool, _ := e.output.ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil {
|
if pool, _ := e.output.ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil {
|
||||||
if available := pool.memoryLimit(); available > 0 {
|
available := pool.memoryLimit()
|
||||||
|
if available == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if available > 0 {
|
||||||
limit = int(min(available, int64(int(^uint(0)>>1))))
|
limit = int(min(available, int64(int(^uint(0)>>1))))
|
||||||
encodedLimit = limit
|
encodedLimit = limit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strconv.IntSize == 32 {
|
|
||||||
limit = 64 << 20
|
|
||||||
}
|
|
||||||
base, cancel := context.WithCancel(e.output.ctx)
|
base, cancel := context.WithCancel(e.output.ctx)
|
||||||
ctx := outputImageContext{Context: base, parent: e.output.ctx}
|
ctx := outputImageContext{Context: base, parent: e.output.ctx}
|
||||||
var group sync.WaitGroup
|
var group sync.WaitGroup
|
||||||
|
|||||||
+14
-5
@@ -52,6 +52,7 @@ type ConvertProgress struct {
|
|||||||
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
|
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
|
||||||
// SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
|
// SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
|
||||||
// Pool可由多个独立转换共享,nil时不限制文件间并发
|
// Pool可由多个独立转换共享,nil时不限制文件间并发
|
||||||
|
// Resources提供共享计算与缓存策略,显式Pool优先控制计算调度
|
||||||
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
|
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
|
||||||
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
|
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
|
||||||
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
|
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
|
||||||
@@ -60,6 +61,7 @@ type ConvertProgress struct {
|
|||||||
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
|
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
|
||||||
// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换
|
// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换
|
||||||
type ConvertOptions struct {
|
type ConvertOptions struct {
|
||||||
|
Resources *Resources
|
||||||
Input string
|
Input string
|
||||||
Format string
|
Format string
|
||||||
Pages []int
|
Pages []int
|
||||||
@@ -145,6 +147,13 @@ func OutputFormats() []OutputFormat {
|
|||||||
// 入参: ctx 取消上下文, input 输入源, output 输出流, options 转换选项
|
// 入参: ctx 取消上下文, input 输入源, output 输出流, options 转换选项
|
||||||
// 返回: ConvertReport 转换报告, error 错误信息
|
// 返回: ConvertReport 转换报告, error 错误信息
|
||||||
func Convert(ctx context.Context, input *Input, output io.Writer, options ConvertOptions) (report ConvertReport, err error) {
|
func Convert(ctx context.Context, input *Input, output io.Writer, options ConvertOptions) (report ConvertReport, err error) {
|
||||||
|
if options.Resources != nil {
|
||||||
|
if options.Pool == nil {
|
||||||
|
options.Pool = options.Resources.pool
|
||||||
|
}
|
||||||
|
options.ReaderOptions = append(slices.Clone(options.ReaderOptions), WithResources(options.Resources))
|
||||||
|
options.PDF.Resources = options.Resources
|
||||||
|
}
|
||||||
if options.Pool != nil {
|
if options.Pool != nil {
|
||||||
if err = options.Pool.acquire(ctx, false, 0); err != nil {
|
if err = options.Pool.acquire(ctx, false, 0); err != nil {
|
||||||
return report, err
|
return report, err
|
||||||
@@ -534,7 +543,7 @@ func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) er
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0)
|
allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0)
|
||||||
if image {
|
if image {
|
||||||
allowed = p.images < runtime.GOMAXPROCS(0) && (p.images == 0 || limit < 0 || size <= limit-p.memory)
|
allowed = p.images < p.options.Parallelism && (p.images == 0 || limit < 0 || size <= limit-p.memory)
|
||||||
}
|
}
|
||||||
if allowed {
|
if allowed {
|
||||||
if image {
|
if image {
|
||||||
@@ -584,15 +593,15 @@ func (p *ConversionPool) release(image bool, size int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// conversionWorkers 按当前文件数量分配内部计算任务
|
// conversionWorkers 按当前文件数量分配内部计算任务
|
||||||
// 入参: ctx 转换上下文, fallback 非共享模式并行上限
|
// 入参: ctx 转换上下文
|
||||||
// 返回: int 内部并行数
|
// 返回: int 内部并行数
|
||||||
func conversionWorkers(ctx context.Context, fallback int) int {
|
func conversionWorkers(ctx context.Context) int {
|
||||||
p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool)
|
p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return min(fallback, runtime.GOMAXPROCS(0))
|
return runtime.GOMAXPROCS(0)
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
active := p.active
|
active := p.active
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
return max(1, runtime.GOMAXPROCS(0)/max(1, active))
|
return max(1, p.options.Parallelism/max(1, active))
|
||||||
}
|
}
|
||||||
+10
-9
@@ -31,6 +31,14 @@ import (
|
|||||||
"github.com/emmansun/gmsm/smx509"
|
"github.com/emmansun/gmsm/smx509"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 加密错误可通过errors.Is识别,不包含口令或私钥
|
||||||
|
var (
|
||||||
|
ErrCredentialsRequired = errors.New("document credentials required")
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials or damaged encrypted document")
|
||||||
|
ErrUnsupportedEncryption = errors.New("unsupported document encryption")
|
||||||
|
ErrEncryptionPolicyRequired = errors.New("explicit encryption policy required")
|
||||||
|
)
|
||||||
|
|
||||||
// Credentials 解密凭据,UserName用于筛选接收者,留空自动匹配,不参与口令派生;证书采用DER编码
|
// Credentials 解密凭据,UserName用于筛选接收者,留空自动匹配,不参与口令派生;证书采用DER编码
|
||||||
// Password与Decrypter分别用于口令和证书方案,私钥可由外部密码设备持有
|
// Password与Decrypter分别用于口令和证书方案,私钥可由外部密码设备持有
|
||||||
type Credentials struct {
|
type Credentials struct {
|
||||||
@@ -51,7 +59,7 @@ type CryptoProvider interface {
|
|||||||
EncryptKey(certificate, key []byte) ([]byte, error)
|
EncryptKey(certificate, key []byte) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReaderOption 阅读器的凭据与密码提供者选项
|
// ReaderOption 阅读器的凭据、密码提供者与资源选项
|
||||||
type ReaderOption func(*readerOptions)
|
type ReaderOption func(*readerOptions)
|
||||||
|
|
||||||
// EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码
|
// EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码
|
||||||
@@ -84,6 +92,7 @@ type EncryptionInfo struct {
|
|||||||
|
|
||||||
// readerOptions 保存打开文档时的会话参数
|
// readerOptions 保存打开文档时的会话参数
|
||||||
type readerOptions struct {
|
type readerOptions struct {
|
||||||
|
resources *Resources
|
||||||
credentials []Credentials
|
credentials []Credentials
|
||||||
provider CryptoProvider
|
provider CryptoProvider
|
||||||
}
|
}
|
||||||
@@ -97,14 +106,6 @@ type encryptionState struct {
|
|||||||
// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用
|
// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用
|
||||||
type GMCryptoProvider struct{}
|
type GMCryptoProvider struct{}
|
||||||
|
|
||||||
// 加密错误可通过errors.Is识别,不包含口令或私钥
|
|
||||||
var (
|
|
||||||
ErrCredentialsRequired = errors.New("document credentials required")
|
|
||||||
ErrInvalidCredentials = errors.New("invalid credentials or damaged encrypted document")
|
|
||||||
ErrUnsupportedEncryption = errors.New("unsupported document encryption")
|
|
||||||
ErrEncryptionPolicyRequired = errors.New("explicit encryption policy required")
|
|
||||||
)
|
|
||||||
|
|
||||||
// WithCredentials 添加解密凭据,多次调用可解开多层加密
|
// WithCredentials 添加解密凭据,多次调用可解开多层加密
|
||||||
// 入参: credentials 解密凭据
|
// 入参: credentials 解密凭据
|
||||||
// 返回: ReaderOption 阅读选项
|
// 返回: ReaderOption 阅读选项
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func (r *Reader) open(options []ReaderOption) error {
|
|||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
option(&settings)
|
option(&settings)
|
||||||
}
|
}
|
||||||
|
r.resources = settings.resources
|
||||||
if settings.provider == nil {
|
if settings.provider == nil {
|
||||||
settings.provider = GMCryptoProvider{}
|
settings.provider = GMCryptoProvider{}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-20
@@ -50,6 +50,7 @@ type Editor struct {
|
|||||||
Info DocInfo
|
Info DocInfo
|
||||||
pages []PageContent
|
pages []PageContent
|
||||||
resources []editorResource
|
resources []editorResource
|
||||||
|
runtimeResources *Resources
|
||||||
fonts map[string]*FontResource
|
fonts map[string]*FontResource
|
||||||
fontRegistration editorFontRegistration
|
fontRegistration editorFontRegistration
|
||||||
fontDirs []string
|
fontDirs []string
|
||||||
@@ -74,7 +75,11 @@ type Editor struct {
|
|||||||
encryption *encryptionState
|
encryption *encryptionState
|
||||||
output *outputOptimization
|
output *outputOptimization
|
||||||
saveTarget Output
|
saveTarget Output
|
||||||
|
snapshotArchive *editorSnapshotArchive
|
||||||
validatedPaths *renderCache[string, bool]
|
validatedPaths *renderCache[string, bool]
|
||||||
|
changes EditorChanges
|
||||||
|
transactionChanges *EditorChanges
|
||||||
|
objectIndex *editorObjectIndex
|
||||||
richIndex *richTextIndex
|
richIndex *richTextIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +158,13 @@ func (e *Editor) Backends() RenderBackends {
|
|||||||
// NewEditor 创建空白文档,保存前至少添加一页
|
// NewEditor 创建空白文档,保存前至少添加一页
|
||||||
// 返回: *Editor 文档编辑器
|
// 返回: *Editor 文档编辑器
|
||||||
func NewEditor() *Editor {
|
func NewEditor() *Editor {
|
||||||
|
resources, _ := NewResources(ResourceOptions{})
|
||||||
var id [16]byte
|
var id [16]byte
|
||||||
rand.Read(id[:])
|
rand.Read(id[:])
|
||||||
id[6] = id[6]&0x0f | 0x40
|
id[6] = id[6]&0x0f | 0x40
|
||||||
id[8] = id[8]&0x3f | 0x80
|
id[8] = id[8]&0x3f | 0x80
|
||||||
return &Editor{
|
return &Editor{
|
||||||
|
runtimeResources: resources,
|
||||||
Info: DocInfo{
|
Info: DocInfo{
|
||||||
DocID: hex.EncodeToString(id[:]),
|
DocID: hex.EncodeToString(id[:]),
|
||||||
CreationDate: time.Now().Format("2006-01-02"),
|
CreationDate: time.Now().Format("2006-01-02"),
|
||||||
@@ -171,6 +178,30 @@ func NewEditor() *Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetResources 替换后续操作的资源策略,需与编辑操作串行调用
|
||||||
|
// 已创建的阅读器和快照继续使用原策略,不修改文档或撤销记录
|
||||||
|
// 入参: resources 共享策略,nil使用默认策略
|
||||||
|
func (e *Editor) SetResources(resources *Resources) {
|
||||||
|
if resources == nil {
|
||||||
|
resources, _ = NewResources(ResourceOptions{})
|
||||||
|
}
|
||||||
|
if resources == e.runtimeResources {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.runtimeResources = resources
|
||||||
|
e.snapshotArchive = nil
|
||||||
|
e.fontRenderer = nil
|
||||||
|
e.fontSourcesCache = newFontSourceCache(resources)
|
||||||
|
e.validatedPaths = nil
|
||||||
|
if e.source != nil {
|
||||||
|
source := *e.source
|
||||||
|
e.source = &source
|
||||||
|
e.source.reader = cloneEditorReader(e.source.reader)
|
||||||
|
e.source.reader.resources = resources
|
||||||
|
e.source.reader.pageContents = resourceCache[pageContentKey, PageContent](resources, 16<<20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PageCount 获取当前页数
|
// PageCount 获取当前页数
|
||||||
// 返回: int 页数
|
// 返回: int 页数
|
||||||
func (e *Editor) PageCount() int {
|
func (e *Editor) PageCount() int {
|
||||||
@@ -195,7 +226,14 @@ func (e *Editor) SetInfo(info DocInfo) {
|
|||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: *PageContent 页面内容, error 错误信息
|
// 返回: *PageContent 页面内容, error 错误信息
|
||||||
func (e *Editor) Page(index int) (*PageContent, error) {
|
func (e *Editor) Page(index int) (*PageContent, error) {
|
||||||
page, err := e.page(index)
|
return e.PageContext(context.Background(), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageContext 获取独立页面副本,读取、解析及整理对象时响应取消
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: *PageContent 页面内容, error 读取或取消错误
|
||||||
|
func (e *Editor) PageContext(ctx context.Context, index int) (*PageContent, error) {
|
||||||
|
page, err := e.pageContext(ctx, index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -205,9 +243,11 @@ func (e *Editor) Page(index int) (*PageContent, error) {
|
|||||||
layer := &result.Content.Layer[i]
|
layer := &result.Content.Layer[i]
|
||||||
layer.TextObject, layer.PathObject, layer.ImageObject, layer.CompositeGraphicUnit = nil, nil, nil, nil
|
layer.TextObject, layer.PathObject, layer.ImageObject, layer.CompositeGraphicUnit = nil, nil, nil, nil
|
||||||
for _, object := range layer.Objects {
|
for _, object := range layer.Objects {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
switch object.Type {
|
switch object.Type {
|
||||||
case "TextObject":
|
case "TextObject":
|
||||||
object.TextObject.layout = nil
|
|
||||||
layer.TextObject = append(layer.TextObject, object.TextObject)
|
layer.TextObject = append(layer.TextObject, object.TextObject)
|
||||||
case "PathObject":
|
case "PathObject":
|
||||||
layer.PathObject = append(layer.PathObject, object.PathObject)
|
layer.PathObject = append(layer.PathObject, object.PathObject)
|
||||||
@@ -590,7 +630,9 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) {
|
|||||||
}
|
}
|
||||||
object, err := cloneEditorObject(layer.Objects[index])
|
object, err := cloneEditorObject(layer.Objects[index])
|
||||||
object.origin = e.objectOrigin(id)
|
object.origin = e.objectOrigin(id)
|
||||||
e.attachRichTextSnapshot(page, &object)
|
if err := e.attachRichTextSnapshot(page, &object); err != nil {
|
||||||
|
return GraphicObject{}, err
|
||||||
|
}
|
||||||
return object, err
|
return object, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,13 +800,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := LayoutText(obj, value, options, metrics); err != nil {
|
return LayoutText(obj, value, options, metrics)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if obj.layout == nil && e.objectOrigin(obj.ID) != nil {
|
|
||||||
obj.layout = &textLayout{value: obj.Text(), options: options}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
||||||
@@ -772,7 +808,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
// 返回: *Renderer 度量器
|
// 返回: *Renderer 度量器
|
||||||
func (e *Editor) newRenderer(reader *Reader) *Renderer {
|
func (e *Editor) newRenderer(reader *Reader) *Renderer {
|
||||||
if e.fontSourcesCache == nil {
|
if e.fontSourcesCache == nil {
|
||||||
e.fontSourcesCache = newFontSourceCache()
|
e.fontSourcesCache = newFontSourceCache(e.runtimeResources)
|
||||||
}
|
}
|
||||||
renderer := NewRenderer(reader, WithFontDirs(e.fontDirs...), WithFontFS(e.fontFS...), WithRenderBackends(e.backends))
|
renderer := NewRenderer(reader, WithFontDirs(e.fontDirs...), WithFontFS(e.fontFS...), WithRenderBackends(e.backends))
|
||||||
renderer.fontSourcesCache = e.fontSourcesCache.child()
|
renderer.fontSourcesCache = e.fontSourcesCache.child()
|
||||||
@@ -792,11 +828,21 @@ func (r editorResource) definition() string {
|
|||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: *PageContent 页面内容, error 错误信息
|
// 返回: *PageContent 页面内容, error 错误信息
|
||||||
func (e *Editor) page(index int) (*PageContent, error) {
|
func (e *Editor) page(index int) (*PageContent, error) {
|
||||||
|
return e.pageContext(context.Background(), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageContext 按索引加载内部页面并响应取消
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: *PageContent 页面内容, error 读取或取消错误
|
||||||
|
func (e *Editor) pageContext(ctx context.Context, index int) (*PageContent, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if index < 0 || index >= len(e.pages) {
|
if index < 0 || index >= len(e.pages) {
|
||||||
return nil, fmt.Errorf("page index %d out of range", index)
|
return nil, fmt.Errorf("page index %d out of range", index)
|
||||||
}
|
}
|
||||||
if e.source != nil {
|
if e.source != nil {
|
||||||
if err := e.loadSourcePage(index); err != nil {
|
if err := e.loadSourcePageContext(ctx, index); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,13 +890,8 @@ func (e *Editor) findObject(page int, id string) (*Layer, int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
for i := range content.Content.Layer {
|
if position, ok := e.objectPositions(content)[id]; ok {
|
||||||
layer := &content.Content.Layer[i]
|
return &content.Content.Layer[position.layer], position.index, nil
|
||||||
for index, object := range layer.Objects {
|
|
||||||
if editorObjectID(object) == id && id != "" {
|
|
||||||
return layer, index, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, 0, fmt.Errorf("object %q not found on page %d", id, page)
|
return nil, 0, fmt.Errorf("object %q not found on page %d", id, page)
|
||||||
}
|
}
|
||||||
@@ -1324,7 +1365,7 @@ func (v *editorValidation) creationPath(value string) error {
|
|||||||
// 返回: *renderCache[string, bool] 校验缓存
|
// 返回: *renderCache[string, bool] 校验缓存
|
||||||
func (e *Editor) pathValidationCache() *renderCache[string, bool] {
|
func (e *Editor) pathValidationCache() *renderCache[string, bool] {
|
||||||
if e.validatedPaths == nil {
|
if e.validatedPaths == nil {
|
||||||
e.validatedPaths = &renderCache[string, bool]{limit: 8 << 20}
|
e.validatedPaths = resourceCache[string, bool](e.runtimeResources, 8<<20)
|
||||||
}
|
}
|
||||||
return e.validatedPaths
|
return e.validatedPaths
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (e *Editor) SetPageActions(index int, actions []Action) error {
|
|||||||
}
|
}
|
||||||
before, after := cloneEditorData(page.Actions), cloneEditorData(actions)
|
before, after := cloneEditorData(page.Actions), cloneEditorData(actions)
|
||||||
page.Actions = cloneEditorData(after)
|
page.Actions = cloneEditorData(after)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(index); change != nil {
|
||||||
change.undo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(before) }
|
change.undo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(before) }
|
||||||
change.redo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(after) }
|
change.redo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(after) }
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -32,6 +32,7 @@ type AnnotationSelection struct {
|
|||||||
data [][]byte
|
data [][]byte
|
||||||
resources []string
|
resources []string
|
||||||
states map[string]map[string]editorCompositeState
|
states map[string]map[string]editorCompositeState
|
||||||
|
richText map[int]*richTextSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
|
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
|
||||||
@@ -317,7 +318,7 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState)}
|
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState), richText: make(map[int]*richTextSnapshot)}
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
key := editorResourceID(id)
|
key := editorResourceID(id)
|
||||||
@@ -336,6 +337,17 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
|
|||||||
if !editorXMLCopyable(root) {
|
if !editorXMLCopyable(root) {
|
||||||
return nil, fmt.Errorf("annotation contains unsupported identifiers")
|
return nil, fmt.Errorf("annotation contains unsupported identifiers")
|
||||||
}
|
}
|
||||||
|
annotation, err := e.Annotation(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshot, err := e.captureRichTextSnapshot(page, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: annotation.Appearance.Objects}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snapshot != nil {
|
||||||
|
selection.richText[len(selection.data)] = snapshot
|
||||||
|
}
|
||||||
selection.data = append(selection.data, data)
|
selection.data = append(selection.data, data)
|
||||||
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
|
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
|
||||||
}
|
}
|
||||||
@@ -452,7 +464,20 @@ func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx,
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})})
|
if err := edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, id := range result {
|
||||||
|
if snapshot := selection.richText[i]; snapshot != nil {
|
||||||
|
annotation, err := edit.Annotation(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := edit.putRichTextSnapshot(page, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: annotation.Appearance.Objects}}, snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+58
-24
@@ -30,6 +30,36 @@ type editorObjectPosition struct {
|
|||||||
index int
|
index int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editorObjectIndex 复用最近页面的只读对象位置,图层容器替换后重新建立
|
||||||
|
type editorObjectIndex struct {
|
||||||
|
layers []Layer
|
||||||
|
positions map[string]editorObjectPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectPositions 获取页面的对象位置索引,重复编号保留第一个匹配
|
||||||
|
// 入参: page 内部只读页面
|
||||||
|
// 返回: map[string]editorObjectPosition 只读索引
|
||||||
|
func (e *Editor) objectPositions(page *PageContent) map[string]editorObjectPosition {
|
||||||
|
layers := page.Content.Layer
|
||||||
|
if len(layers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cached := e.objectIndex; cached != nil && len(cached.layers) == len(layers) && &cached.layers[0] == &layers[0] {
|
||||||
|
return cached.positions
|
||||||
|
}
|
||||||
|
positions := make(map[string]editorObjectPosition)
|
||||||
|
for i, layer := range layers {
|
||||||
|
for j, object := range layer.Objects {
|
||||||
|
id := editorObjectID(object)
|
||||||
|
if _, exists := positions[id]; id != "" && !exists {
|
||||||
|
positions[id] = editorObjectPosition{i, j}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.objectIndex = &editorObjectIndex{layers: layers, positions: positions}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象
|
// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象
|
||||||
type ObjectPosition struct {
|
type ObjectPosition struct {
|
||||||
Layer string
|
Layer string
|
||||||
@@ -66,7 +96,9 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
objects[i].origin = e.objectOrigin(editorObjectID(object))
|
objects[i].origin = e.objectOrigin(editorObjectID(object))
|
||||||
e.attachRichTextSnapshot(page, &objects[i])
|
if err := e.attachRichTextSnapshot(page, &objects[i]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return objects, nil
|
return objects, nil
|
||||||
}
|
}
|
||||||
@@ -105,7 +137,11 @@ func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []Gra
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := e.putRichTextMetadata(page, ids[i], meta, -1); err != nil {
|
object, err := e.Object(page, ids[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.putRichTextSnapshot(page, object, meta); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,7 +745,7 @@ func (e *Editor) replaceLayers(page int, layers []Layer) {
|
|||||||
func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
||||||
if e.historyLimit == 0 {
|
if e.historyLimit == 0 {
|
||||||
e.pages[page].Content.Layer = after
|
e.pages[page].Content.Layer = after
|
||||||
e.recordChange()
|
e.recordChange(page)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
before := copyEditorPage(e.pages[page])
|
before := copyEditorPage(e.pages[page])
|
||||||
@@ -738,7 +774,7 @@ func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
|||||||
e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
|
e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
|
||||||
}
|
}
|
||||||
apply(e, after)
|
apply(e, after)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(page); change != nil {
|
||||||
change.undo = func(e *Editor) { apply(e, before.Content.Layer) }
|
change.undo = func(e *Editor) { apply(e, before.Content.Layer) }
|
||||||
change.redo = func(e *Editor) { apply(e, after) }
|
change.redo = func(e *Editor) { apply(e, after) }
|
||||||
}
|
}
|
||||||
@@ -835,6 +871,15 @@ func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserve
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
apply := func(e *Editor, objects []GraphicObject, origins map[string]*editorObjectOrigin) {
|
apply := func(e *Editor, objects []GraphicObject, origins map[string]*editorObjectOrigin) {
|
||||||
|
layers := slices.Clone(e.pages[page].Content.Layer)
|
||||||
|
cloned := make(map[int]bool)
|
||||||
|
for _, index := range indexes {
|
||||||
|
if !cloned[index.layer] {
|
||||||
|
layers[index.layer].Objects = slices.Clone(layers[index.layer].Objects)
|
||||||
|
cloned[index.layer] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.pages[page].Content.Layer = layers
|
||||||
for i, index := range indexes {
|
for i, index := range indexes {
|
||||||
e.pages[page].Content.Layer[index.layer].Objects[index.index] = objects[i]
|
e.pages[page].Content.Layer[index.layer].Objects[index.index] = objects[i]
|
||||||
}
|
}
|
||||||
@@ -843,7 +888,7 @@ func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply(e, after, afterOrigins)
|
apply(e, after, afterOrigins)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(page); change != nil {
|
||||||
change.undo = func(e *Editor) { apply(e, before, beforeOrigins) }
|
change.undo = func(e *Editor) { apply(e, before, beforeOrigins) }
|
||||||
change.redo = func(e *Editor) { apply(e, after, afterOrigins) }
|
change.redo = func(e *Editor) { apply(e, after, afterOrigins) }
|
||||||
}
|
}
|
||||||
@@ -891,30 +936,19 @@ func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []edi
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return objects, indexes, nil
|
return objects, indexes, nil
|
||||||
}
|
}
|
||||||
selected := make(map[string]int, len(ids))
|
positions := e.objectPositions(content)
|
||||||
|
selected := make(map[string]bool, len(ids))
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
if _, exists := selected[id]; exists {
|
if selected[id] {
|
||||||
return nil, nil, fmt.Errorf("duplicate object %q", id)
|
return nil, nil, fmt.Errorf("duplicate object %q", id)
|
||||||
}
|
}
|
||||||
selected[id] = i
|
selected[id] = true
|
||||||
}
|
position, ok := positions[id]
|
||||||
for i, layer := range content.Content.Layer {
|
if !ok {
|
||||||
for j, object := range layer.Objects {
|
|
||||||
id := editorObjectID(object)
|
|
||||||
if index, ok := selected[id]; ok && id != "" {
|
|
||||||
objects[index] = object
|
|
||||||
indexes[index] = editorObjectPosition{i, j}
|
|
||||||
delete(selected, id)
|
|
||||||
if len(selected) == 0 {
|
|
||||||
return objects, indexes, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
if _, missing := selected[id]; missing {
|
|
||||||
return nil, nil, fmt.Errorf("object %q not found on page %d", id, page)
|
return nil, nil, fmt.Errorf("object %q not found on page %d", id, page)
|
||||||
}
|
}
|
||||||
|
indexes[i] = position
|
||||||
|
objects[i] = content.Content.Layer[position.layer].Objects[position.index]
|
||||||
}
|
}
|
||||||
return objects, indexes, nil
|
return objects, indexes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,7 +334,9 @@ func (n *editorCompositeNode) setStates(states map[string]editorCompositeState)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(n.node)
|
visit(n.node)
|
||||||
n.object.TextObject.layout = n.states[editorObjectID(n.object)].layout
|
if state, ok := n.states[editorObjectID(n.object)]; ok {
|
||||||
|
n.object.TextObject.layout = state.layout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setState 替换当前对象的会话记录并计入本次事务
|
// setState 替换当前对象的会话记录并计入本次事务
|
||||||
@@ -418,6 +420,7 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, err
|
return nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
root.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, attrs: []xml.Attr{{Name: xml.Name{Local: "ID"}, Value: layer.ID}, {Name: xml.Name{Local: "DrawParam"}, Value: layer.DrawParam}}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
|
||||||
root.setStates(object.CompositeGraphicUnit.states)
|
root.setStates(object.CompositeGraphicUnit.states)
|
||||||
reader, err := e.reader(nil)
|
reader, err := e.reader(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -848,6 +851,7 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render
|
|||||||
next.setStates(root.states)
|
next.setStates(root.states)
|
||||||
next.object.CompositeGraphicUnit.states = next.states
|
next.object.CompositeGraphicUnit.states = next.states
|
||||||
origin := e.objectOrigin(path.ID)
|
origin := e.objectOrigin(path.ID)
|
||||||
|
next.node.parent = root.node.parent
|
||||||
updated := &editorObjectOrigin{data: data, node: next.node, object: next.object}
|
updated := &editorObjectOrigin{data: data, node: next.node, object: next.object}
|
||||||
if origin != nil {
|
if origin != nil {
|
||||||
next.node.parent = origin.node.parent
|
next.node.parent = origin.node.parent
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ package ofdgo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源
|
// CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源
|
||||||
@@ -36,6 +36,7 @@ type CompositeSelection struct {
|
|||||||
containers []int
|
containers []int
|
||||||
nodes []*editorCompositeNode
|
nodes []*editorCompositeNode
|
||||||
source *editorSourcePage
|
source *editorSourcePage
|
||||||
|
richText map[int]*richTextSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪
|
// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪
|
||||||
@@ -73,9 +74,11 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
|||||||
slices.Sort(indexes)
|
slices.Sort(indexes)
|
||||||
var owner *editorCompositeNode
|
var owner *editorCompositeNode
|
||||||
var container *editorXML
|
var container *editorXML
|
||||||
selection := &CompositeSelection{editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
|
selection := &CompositeSelection{richText: make(map[int]*richTextSnapshot), editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
|
||||||
if path.Annotation == "" {
|
if path.Annotation == "" {
|
||||||
selection.source = e.objectOrigin(path.ID).page
|
if origin := e.objectOrigin(path.ID); origin != nil {
|
||||||
|
selection.source = origin.page
|
||||||
|
}
|
||||||
} else if e.source != nil {
|
} else if e.source != nil {
|
||||||
selection.source = e.source.pages[e.pages[page].ID]
|
selection.source = e.source.pages[e.pages[page].ID]
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,13 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
|||||||
copy := *node
|
copy := *node
|
||||||
copy.owner, copy.span = nil, nil
|
copy.owner, copy.span = nil, nil
|
||||||
selection.nodes = append(selection.nodes, ©)
|
selection.nodes = append(selection.nodes, ©)
|
||||||
|
snapshot, err := e.captureRichTextSnapshot(page, node.object)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if snapshot != nil {
|
||||||
|
selection.richText[j] = snapshot
|
||||||
|
}
|
||||||
}
|
}
|
||||||
scope := root.loadedScope(path.Children)
|
scope := root.loadedScope(path.Children)
|
||||||
for scope != owner {
|
for scope != owner {
|
||||||
@@ -134,6 +144,7 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
var result []int
|
var result []int
|
||||||
|
err := e.Transaction(func(e *Editor) error {
|
||||||
err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error {
|
err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error {
|
||||||
scope := root.loadedScope(path.Children)
|
scope := root.loadedScope(path.Children)
|
||||||
owner := scope
|
owner := scope
|
||||||
@@ -191,6 +202,11 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextCopies(page, path, result, selection.richText)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -226,7 +242,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
|
|||||||
renderer := e.newRenderer(reader)
|
renderer := e.newRenderer(reader)
|
||||||
var objects []GraphicObject
|
var objects []GraphicObject
|
||||||
var remaps []map[string]string
|
var remaps []map[string]string
|
||||||
for _, source := range selection.nodes {
|
for i, source := range selection.nodes {
|
||||||
copy, err := newEditorCompositeNode(source.data)
|
copy, err := newEditorCompositeNode(source.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -322,13 +338,16 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object.state = copy.states[editorObjectID(copy.object)]
|
object.state = copy.states[editorObjectID(copy.object)]
|
||||||
|
if object.state.layout != nil {
|
||||||
object.TextObject.layout = object.state.layout
|
object.TextObject.layout = object.state.layout
|
||||||
|
}
|
||||||
copy.object.TextObject.layout = object.TextObject.layout
|
copy.object.TextObject.layout = object.TextObject.layout
|
||||||
if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
|
if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
|
||||||
object.CompositeGraphicUnit.states = maps.Clone(copy.states)
|
object.CompositeGraphicUnit.states = maps.Clone(copy.states)
|
||||||
copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states
|
copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states
|
||||||
}
|
}
|
||||||
object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), page: selection.source, data: copy.data, node: copy.node, object: copy.object}
|
object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), page: selection.source, data: copy.data, node: copy.node, object: copy.object}
|
||||||
|
object.richText = selection.richText[i]
|
||||||
objects = append(objects, object)
|
objects = append(objects, object)
|
||||||
remaps = append(remaps, copy.remaps...)
|
remaps = append(remaps, copy.remaps...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,7 +251,9 @@ func (e *Editor) ungroupCompositeNode(renderer *Renderer, node *editorCompositeN
|
|||||||
}
|
}
|
||||||
member.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
|
member.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
|
||||||
member.object.state = states[editorObjectID(member.object)]
|
member.object.state = states[editorObjectID(member.object)]
|
||||||
|
if member.object.state.layout != nil {
|
||||||
member.object.TextObject.layout = member.object.state.layout
|
member.object.TextObject.layout = member.object.state.layout
|
||||||
|
}
|
||||||
member.object.CompositeGraphicUnit.states = states
|
member.object.CompositeGraphicUnit.states = states
|
||||||
member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object}
|
member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object}
|
||||||
objects[i] = member.object
|
objects[i] = member.object
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ func (e *Editor) AddCompositeObject(page int, path ObjectPath, object GraphicObj
|
|||||||
// 入参: page 页面索引, path 父路径, objects 当前文档快照或基本对象, dx 页面横向位移, dy 页面纵向位移
|
// 入参: page 页面索引, path 父路径, objects 当前文档快照或基本对象, dx 页面横向位移, dy 页面纵向位移
|
||||||
// 返回: []int 新成员序号, error 错误信息
|
// 返回: []int 新成员序号, error 错误信息
|
||||||
func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) {
|
func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) {
|
||||||
|
metadata, err := e.copiedRichTextMetadata(objects)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return e.copyObjectsToComposite(page, path, objects, dx, dy)
|
||||||
|
}
|
||||||
|
var indexes []int
|
||||||
|
err = e.Transaction(func(e *Editor) error {
|
||||||
|
var err error
|
||||||
|
indexes, err = e.copyObjectsToComposite(page, path, objects, dx, dy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextCopies(page, path, indexes, metadata)
|
||||||
|
})
|
||||||
|
return indexes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyObjectsToComposite 插入对象原文,由调用方同步富文本语义
|
||||||
|
// 入参: page 页面索引, path 父路径, objects 对象快照, dx 横向位移, dy 纵向位移
|
||||||
|
// 返回: []int 成员序号, error 校验或提交错误
|
||||||
|
func (e *Editor) copyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) {
|
||||||
if !finite(dx) || !finite(dy) {
|
if !finite(dx) || !finite(dy) {
|
||||||
return nil, fmt.Errorf("copy requires finite offsets")
|
return nil, fmt.Errorf("copy requires finite offsets")
|
||||||
}
|
}
|
||||||
@@ -49,6 +72,9 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
owner := root.loadedScope(path.Children)
|
owner := root.loadedScope(path.Children)
|
||||||
|
if owner.ref != nil {
|
||||||
|
owner = owner.ref
|
||||||
|
}
|
||||||
box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary)
|
box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary)
|
||||||
parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM))
|
parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM))
|
||||||
inverse, ok := parent.Invert()
|
inverse, ok := parent.Invert()
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
|
|||||||
return nil, fmt.Errorf("copy requires finite offsets")
|
return nil, fmt.Errorf("copy requires finite offsets")
|
||||||
}
|
}
|
||||||
var result []int
|
var result []int
|
||||||
|
metadata := make(map[int]*richTextSnapshot)
|
||||||
|
err := e.Transaction(func(e *Editor) error {
|
||||||
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||||
container := nodes[0].span.parent
|
container := nodes[0].span.parent
|
||||||
for i, node := range nodes {
|
for i, node := range nodes {
|
||||||
@@ -53,6 +55,15 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
|
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
|
||||||
|
for i, node := range nodes {
|
||||||
|
snapshot, err := e.captureRichTextSnapshot(page, node.object)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if snapshot != nil {
|
||||||
|
metadata[i] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
|
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -71,6 +82,11 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextCopies(page, path, result, metadata)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按会话选项重排
|
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按排版选项重排
|
||||||
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
||||||
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
||||||
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
||||||
@@ -29,7 +29,7 @@ func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value
|
|||||||
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
|
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StyleCompositeText 批量修改内部文字字体、字号或颜色,换字体保留原文字定位
|
// StyleCompositeText 批量修改内部文字字体、字号或颜色,已知段落重排,原文保留定位
|
||||||
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
||||||
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -38,7 +38,7 @@ func (e *Editor) StyleCompositeText(page int, path ObjectPath, indexes []int, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
||||||
// 段落选项仅保留在编辑会话中,撤销重做随对象恢复,保存为标准TextCode定位
|
// 段落选项随标准TextCode定位保存,撤销重做及重新打开时恢复
|
||||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) error {
|
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) error {
|
||||||
@@ -80,6 +80,14 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
object := cloneEditorData(node.object)
|
object := cloneEditorData(node.object)
|
||||||
text := cloneEditorData(member.Object.TextObject)
|
text := cloneEditorData(member.Object.TextObject)
|
||||||
state := node.states[editorObjectID(node.object)]
|
state := node.states[editorObjectID(node.object)]
|
||||||
|
if state.layout == nil && text.layout != nil {
|
||||||
|
x, _, err := e.richPlainOffset(text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
y, _ := strconv.ParseFloat(text.TextCode[0].Y, 64)
|
||||||
|
state.layout, state.origin = text.layout, [2]float64{x, y}
|
||||||
|
}
|
||||||
content, layout := text.TextLayout()
|
content, layout := text.TextLayout()
|
||||||
original := content
|
original := content
|
||||||
if style.Font != "" {
|
if style.Font != "" {
|
||||||
@@ -121,7 +129,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.TextCode = text.TextCode
|
object.TextObject.TextCode = text.TextCode
|
||||||
object.TextObject.CGTransform = text.CGTransform
|
object.TextObject.CGTransform = text.CGTransform
|
||||||
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && state.layout.options.Shape && style.Font != "" && style.Font != object.TextObject.Font {
|
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && style.Font != "" && style.Font != object.TextObject.Font {
|
||||||
if style.Size != 0 {
|
if style.Size != 0 {
|
||||||
text.Size = style.Size
|
text.Size = style.Size
|
||||||
}
|
}
|
||||||
@@ -140,7 +148,10 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
|
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
|
||||||
object.TextObject.CGTransform = text.CGTransform
|
object.TextObject.CGTransform = text.CGTransform
|
||||||
state.layout = &textLayout{value: content, options: layout}
|
if frame != nil {
|
||||||
|
text.layout.autoWidth = false
|
||||||
|
}
|
||||||
|
state.layout = text.layout
|
||||||
}
|
}
|
||||||
if style.Font != "" && style.Font != object.TextObject.Font {
|
if style.Font != "" && style.Font != object.TextObject.Font {
|
||||||
if err := e.prepareText(&text); err != nil {
|
if err := e.prepareText(&text); err != nil {
|
||||||
@@ -161,6 +172,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor)
|
object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor)
|
||||||
}
|
}
|
||||||
|
object.TextObject.layout = state.layout
|
||||||
if err := node.update(object); err != nil {
|
if err := node.update(object); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -241,6 +242,22 @@ func (e *Editor) copiedLayerStyle(object GraphicObject) string {
|
|||||||
// 入参: value 原值
|
// 入参: value 原值
|
||||||
// 返回: T 独立副本
|
// 返回: T 独立副本
|
||||||
func cloneEditorData[T any](value T) T {
|
func cloneEditorData[T any](value T) T {
|
||||||
|
switch source := any(value).(type) {
|
||||||
|
case GraphicObject:
|
||||||
|
return any(copyEditorGraphicObject(source)).(T)
|
||||||
|
case TextObject:
|
||||||
|
return any(copyEditorTextObject(source)).(T)
|
||||||
|
case PathObject:
|
||||||
|
return any(copyEditorPathObject(source)).(T)
|
||||||
|
case ImageObject:
|
||||||
|
return any(copyEditorImageObject(source)).(T)
|
||||||
|
case CompositeGraphicUnit:
|
||||||
|
return any(copyEditorCompositeGraphicUnit(source)).(T)
|
||||||
|
case PageContent:
|
||||||
|
return any(copyEditorPageContent(source)).(T)
|
||||||
|
case Layer:
|
||||||
|
return any(copyEditorLayer(source)).(T)
|
||||||
|
}
|
||||||
source := reflect.ValueOf(value)
|
source := reflect.ValueOf(value)
|
||||||
if !source.IsValid() {
|
if !source.IsValid() {
|
||||||
return value
|
return value
|
||||||
@@ -336,3 +353,174 @@ func editorCloneFields(kind reflect.Type) []int {
|
|||||||
actual, _ := editorClonePlans.LoadOrStore(kind, fields)
|
actual, _ := editorClonePlans.LoadOrStore(kind, fields)
|
||||||
return actual.([]int)
|
return actual.([]int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyEditorGraphicObject 复制GraphicObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: GraphicObject 独立副本
|
||||||
|
func copyEditorGraphicObject(value GraphicObject) GraphicObject {
|
||||||
|
value.TextObject = copyEditorTextObject(value.TextObject)
|
||||||
|
value.PathObject = copyEditorPathObject(value.PathObject)
|
||||||
|
value.ImageObject = copyEditorImageObject(value.ImageObject)
|
||||||
|
value.CompositeGraphicUnit = copyEditorCompositeGraphicUnit(value.CompositeGraphicUnit)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorTextObject 复制TextObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: TextObject 独立副本
|
||||||
|
func copyEditorTextObject(value TextObject) TextObject {
|
||||||
|
if value.DashOffset != nil {
|
||||||
|
copied := *value.DashOffset
|
||||||
|
value.DashOffset = &copied
|
||||||
|
}
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
if value.Fill != nil {
|
||||||
|
copied := *value.Fill
|
||||||
|
value.Fill = &copied
|
||||||
|
}
|
||||||
|
if value.Stroke != nil {
|
||||||
|
copied := *value.Stroke
|
||||||
|
value.Stroke = &copied
|
||||||
|
}
|
||||||
|
value.StrokeColor = cloneEditorData(value.StrokeColor)
|
||||||
|
value.FillColor = cloneEditorData(value.FillColor)
|
||||||
|
value.CGTransform = slices.Clone(value.CGTransform)
|
||||||
|
value.TextCode = slices.Clone(value.TextCode)
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorPathObject 复制PathObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: PathObject 独立副本
|
||||||
|
func copyEditorPathObject(value PathObject) PathObject {
|
||||||
|
if value.DashOffset != nil {
|
||||||
|
copied := *value.DashOffset
|
||||||
|
value.DashOffset = &copied
|
||||||
|
}
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
if value.Stroke != nil {
|
||||||
|
copied := *value.Stroke
|
||||||
|
value.Stroke = &copied
|
||||||
|
}
|
||||||
|
if value.Fill != nil {
|
||||||
|
copied := *value.Fill
|
||||||
|
value.Fill = &copied
|
||||||
|
}
|
||||||
|
value.StrokeColor = cloneEditorData(value.StrokeColor)
|
||||||
|
value.FillColor = cloneEditorData(value.FillColor)
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorImageObject 复制ImageObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: ImageObject 独立副本
|
||||||
|
func copyEditorImageObject(value ImageObject) ImageObject {
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
value.Border = cloneEditorData(value.Border)
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorCompositeGraphicUnit 复制CompositeGraphicUnit的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: CompositeGraphicUnit 独立副本
|
||||||
|
func copyEditorCompositeGraphicUnit(value CompositeGraphicUnit) CompositeGraphicUnit {
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
value.Objects = slices.Clone(value.Objects)
|
||||||
|
for i := range value.Objects {
|
||||||
|
value.Objects[i] = copyEditorGraphicObject(value.Objects[i])
|
||||||
|
}
|
||||||
|
value.TextObject = slices.Clone(value.TextObject)
|
||||||
|
for i := range value.TextObject {
|
||||||
|
value.TextObject[i] = copyEditorTextObject(value.TextObject[i])
|
||||||
|
}
|
||||||
|
value.PathObject = slices.Clone(value.PathObject)
|
||||||
|
for i := range value.PathObject {
|
||||||
|
value.PathObject[i] = copyEditorPathObject(value.PathObject[i])
|
||||||
|
}
|
||||||
|
value.ImageObject = slices.Clone(value.ImageObject)
|
||||||
|
for i := range value.ImageObject {
|
||||||
|
value.ImageObject[i] = copyEditorImageObject(value.ImageObject[i])
|
||||||
|
}
|
||||||
|
value.CompositeGraphicUnit = slices.Clone(value.CompositeGraphicUnit)
|
||||||
|
for i := range value.CompositeGraphicUnit {
|
||||||
|
value.CompositeGraphicUnit[i] = copyEditorCompositeGraphicUnit(value.CompositeGraphicUnit[i])
|
||||||
|
}
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorPageContent 复制PageContent的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: PageContent 独立副本
|
||||||
|
func copyEditorPageContent(value PageContent) PageContent {
|
||||||
|
value.Template = slices.Clone(value.Template)
|
||||||
|
value.PageRes = slices.Clone(value.PageRes)
|
||||||
|
value.Content.Layer = slices.Clone(value.Content.Layer)
|
||||||
|
for i := range value.Content.Layer {
|
||||||
|
value.Content.Layer[i] = copyEditorLayer(value.Content.Layer[i])
|
||||||
|
}
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorLayer 复制Layer的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: Layer 独立副本
|
||||||
|
func copyEditorLayer(value Layer) Layer {
|
||||||
|
value.Objects = slices.Clone(value.Objects)
|
||||||
|
for i := range value.Objects {
|
||||||
|
value.Objects[i] = copyEditorGraphicObject(value.Objects[i])
|
||||||
|
}
|
||||||
|
value.TextObject = slices.Clone(value.TextObject)
|
||||||
|
for i := range value.TextObject {
|
||||||
|
value.TextObject[i] = copyEditorTextObject(value.TextObject[i])
|
||||||
|
}
|
||||||
|
value.PathObject = slices.Clone(value.PathObject)
|
||||||
|
for i := range value.PathObject {
|
||||||
|
value.PathObject[i] = copyEditorPathObject(value.PathObject[i])
|
||||||
|
}
|
||||||
|
value.ImageObject = slices.Clone(value.ImageObject)
|
||||||
|
for i := range value.ImageObject {
|
||||||
|
value.ImageObject[i] = copyEditorImageObject(value.ImageObject[i])
|
||||||
|
}
|
||||||
|
value.CompositeGraphicUnit = slices.Clone(value.CompositeGraphicUnit)
|
||||||
|
for i := range value.CompositeGraphicUnit {
|
||||||
|
value.CompositeGraphicUnit[i] = copyEditorCompositeGraphicUnit(value.CompositeGraphicUnit[i])
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -415,7 +415,7 @@ func (e *Editor) editDocumentSnapshot(ctx context.Context, reader *Reader, edit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptDocumentReader 接入已加载的编辑入口,跨文档或版本时重建页面与资源状态
|
// adoptDocumentReader 接入已加载的编辑入口,同文档重建自定义字段来源,跨文档或版本时重建页面与资源状态
|
||||||
// 入参: ctx 取消上下文, reader 不拥有输入文件的独立阅读器
|
// 入参: ctx 取消上下文, reader 不拥有输入文件的独立阅读器
|
||||||
// 返回: error 编辑器初始化错误
|
// 返回: error 编辑器初始化错误
|
||||||
func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error {
|
func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error {
|
||||||
@@ -441,6 +441,13 @@ func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error
|
|||||||
source.structureStarts[index] = starts
|
source.structureStarts[index] = starts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
source.info.CustomDatas = cloneEditorData(reader.OFD.DocBody[reader.documentIndex].DocInfo.CustomDatas)
|
||||||
|
if source.info.CustomDatas != nil {
|
||||||
|
for i := range source.info.CustomDatas.CustomData {
|
||||||
|
source.info.CustomDatas.CustomData[i].sourceIndex = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Info.CustomDatas = cloneEditorData(source.info.CustomDatas)
|
||||||
e.source = &source
|
e.source = &source
|
||||||
} else {
|
} else {
|
||||||
next, err := reader.EditorContext(ctx)
|
next, err := reader.EditorContext(ctx)
|
||||||
@@ -453,6 +460,7 @@ func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error
|
|||||||
next.encryption = e.encryption
|
next.encryption = e.encryption
|
||||||
next.saveTarget = e.saveTarget
|
next.saveTarget = e.saveTarget
|
||||||
next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS
|
next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS
|
||||||
|
next.SetResources(e.runtimeResources)
|
||||||
next.validatedPaths = e.validatedPaths
|
next.validatedPaths = e.validatedPaths
|
||||||
*e = *next
|
*e = *next
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-4
@@ -34,8 +34,9 @@ type editorFontUsage struct {
|
|||||||
unsafe bool
|
unsafe bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合
|
// editorFontSubset 保存单个新增字体及其快照最近一次裁剪结果,不保留历史字形集合
|
||||||
type editorFontSubset struct {
|
type editorFontSubset struct {
|
||||||
|
source [32]byte
|
||||||
glyphs []uint16
|
glyphs []uint16
|
||||||
data []byte
|
data []byte
|
||||||
mapped bool
|
mapped bool
|
||||||
@@ -291,6 +292,141 @@ func editorObjectFontScan(object GraphicObject) bool {
|
|||||||
// 入参: progress 保存进度回调
|
// 入参: progress 保存进度回调
|
||||||
// 返回: map[string][]byte 包内字体子集, error 错误信息
|
// 返回: map[string][]byte 包内字体子集, error 错误信息
|
||||||
func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) {
|
func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) {
|
||||||
|
result, err := e.subsetNewFonts(progress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if e.output != nil {
|
||||||
|
ctx = e.output.ctx
|
||||||
|
}
|
||||||
|
source, err := e.subsetSnapshotFonts(ctx, progress.withContext(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
maps.Copy(result, source)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetSnapshotFonts 在写出时扫描快照中新增字体的完整包引用,不改动既有字体
|
||||||
|
// 入参: ctx 取消上下文, progress 写出进度
|
||||||
|
// 返回: map[string][]byte 新增字体的安全子集, error 扫描或裁剪错误
|
||||||
|
func (e *Editor) subsetSnapshotFonts(ctx context.Context, progress editorProgress) (map[string][]byte, error) {
|
||||||
|
if e.source == nil || e.backends.FontResources == nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
candidates := make(map[string]int)
|
||||||
|
for i, resource := range e.resources {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resource.font != nil && resource.font.FontFile != "" && e.source.reader.fileNames[resource.name] {
|
||||||
|
candidates[resource.name] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
view, err := e.readerContext(ctx, progress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer view.Close()
|
||||||
|
refs, resources, references, safe, err := scanEditorResources(ctx, view, view.files, nil, maps.Clone(view.fileNames), progress, true)
|
||||||
|
if err != nil || !safe {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
scoped, err := resourceReferenceScopes(ctx, refs.documents, resources, references, "")
|
||||||
|
if err != nil || scoped == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
read := func(name string) ([]byte, error) { return view.readFileViewContext(ctx, name) }
|
||||||
|
usage, err := resourceFontUsage(ctx, resources, scoped, read)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, source := range references {
|
||||||
|
for name := range source.files {
|
||||||
|
if font := usage[name]; font != nil && !source.versions[name] {
|
||||||
|
font.unsafe = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, definition := range source.definitions {
|
||||||
|
for name := range definition.files {
|
||||||
|
if font := usage[name]; font != nil {
|
||||||
|
font.unsafe = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make(map[string][]byte)
|
||||||
|
for i, name := range slices.Sorted(maps.Keys(candidates)) {
|
||||||
|
if err := progress.report("fonts", i, len(candidates)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
font := usage[name]
|
||||||
|
if font == nil || font.unsafe || len(font.chars)+len(font.glyphs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := read(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsed, err := e.backends.FontResources.OpenFontResource(FontFile{Data: data}, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !parsed.CanSubset {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preserve := len(font.glyphs) != 0
|
||||||
|
glyphs := maps.Clone(font.glyphs)
|
||||||
|
for char := range font.chars {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
glyphs[parsed.GlyphIndex(char)] = true
|
||||||
|
}
|
||||||
|
glyphs[0] = true
|
||||||
|
for id := range glyphs {
|
||||||
|
if id >= parsed.NumGlyphs() {
|
||||||
|
font.unsafe = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if font.unsafe {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids := slices.Sorted(maps.Keys(glyphs))
|
||||||
|
checksum := sha256.Sum256(data)
|
||||||
|
resource := &e.resources[candidates[name]]
|
||||||
|
if resource.subset == nil || resource.subset.source != checksum || resource.subset.mapped != preserve || !slices.Equal(resource.subset.glyphs, ids) {
|
||||||
|
subset, err := e.backends.FontResources.SubsetFont(data, ids, preserve)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(subset) >= len(data) {
|
||||||
|
subset = nil
|
||||||
|
}
|
||||||
|
resource.subset = &editorFontSubset{source: checksum, glyphs: ids, data: subset, mapped: preserve}
|
||||||
|
}
|
||||||
|
if len(resource.subset.data) != 0 {
|
||||||
|
result[name] = resource.subset.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, progress.report("fonts", len(candidates), len(candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetNewFonts 解析尚未物化为源资源的新增字体,保持编辑字体完整
|
||||||
|
// 入参: progress 写出进度
|
||||||
|
// 返回: map[string][]byte 包内字体子集, error 读取或裁剪错误
|
||||||
|
func (e *Editor) subsetNewFonts(progress editorProgress) (map[string][]byte, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if e.output != nil {
|
if e.output != nil {
|
||||||
ctx = e.output.ctx
|
ctx = e.output.ctx
|
||||||
@@ -303,7 +439,7 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !slices.ContainsFunc(e.resources, func(resource editorResource) bool {
|
if !slices.ContainsFunc(e.resources, func(resource editorResource) bool {
|
||||||
return resource.font != nil && resource.font.FontFile != ""
|
return resource.font != nil && resource.font.FontFile != "" && (e.source == nil || !e.source.reader.fileNames[resource.name])
|
||||||
}) {
|
}) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -318,7 +454,7 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
|
|||||||
if err := progress.report("fonts", i, total); err != nil {
|
if err := progress.report("fonts", i, total); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resource.font.FontFile == "" {
|
if resource.font.FontFile == "" || e.source != nil && e.source.reader.fileNames[resource.name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resourceFont := e.fonts[resource.font.ID]
|
resourceFont := e.fonts[resource.font.ID]
|
||||||
@@ -467,7 +603,7 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resource := &e.resources[i]
|
resource := &e.resources[i]
|
||||||
if resource.font == nil {
|
if resource.font == nil || e.source != nil && e.source.reader.fileNames[resource.name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
usage := used[resource.font.ID]
|
usage := used[resource.font.ID]
|
||||||
|
|||||||
@@ -907,6 +907,7 @@ func (e *Editor) restoreResourceSnapshot(state Editor) {
|
|||||||
state.clearFontResourceCache()
|
state.clearFontResourceCache()
|
||||||
}
|
}
|
||||||
state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS
|
state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS
|
||||||
|
state.SetResources(e.runtimeResources)
|
||||||
state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress
|
state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress
|
||||||
state.saveTarget = e.saveTarget
|
state.saveTarget = e.saveTarget
|
||||||
state.fontRenderer, state.fontSourcesCache = nil, nil
|
state.fontRenderer, state.fontSourcesCache = nil, nil
|
||||||
@@ -962,12 +963,12 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
|
|||||||
if root == "" {
|
if root == "" {
|
||||||
root = node.Name.Local
|
root = node.Name.Local
|
||||||
}
|
}
|
||||||
if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.Name.Local) {
|
if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || root == "CustomTags" || slices.Contains([]string{"Extensions", "ExtendData", "Data"}, node.Name.Local) {
|
||||||
uncertain = true
|
uncertain = true
|
||||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||||
}
|
}
|
||||||
for _, attr := range node.Attr {
|
for _, attr := range node.Attr {
|
||||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.Name, attr) {
|
||||||
uncertain = true
|
uncertain = true
|
||||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||||
}
|
}
|
||||||
@@ -989,7 +990,19 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
|
|||||||
versions[name] = append(versions[name], versionEditLink{node: node, target: target})
|
versions[name] = append(versions[name], versionEditLink{node: node, target: target})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := reader.scanDocumentFileReferences(ctx, name, true, add, record, inspect); err != nil {
|
err := reader.scanDocumentFileReferences(ctx, name, true, add, record, inspect)
|
||||||
|
if uncertain && (root == "CustomTags" || root == "RichText") {
|
||||||
|
data, readErr := reader.readFileViewContext(ctx, name)
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
refs := &editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)}
|
||||||
|
if _, safe := refs.scanContext(ctx, bytes.NewReader(data), name); safe {
|
||||||
|
uncertain = false
|
||||||
|
err = reader.scanDocumentFileReferences(ctx, name, true, add, record, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
var syntax *xml.SyntaxError
|
var syntax *xml.SyntaxError
|
||||||
if uncertain || errors.As(err, &syntax) {
|
if uncertain || errors.As(err, &syntax) {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func scanEditorFontFilesMode(ctx context.Context, reader *Reader, index int, ins
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, attr := range node.attrs {
|
for _, attr := range node.attrs {
|
||||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.name, attr) {
|
||||||
scan.unsafe = true
|
scan.unsafe = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ func (e *Editor) GroupObjects(page int, ids []string) (id string, err error) {
|
|||||||
state := object.state
|
state := object.state
|
||||||
if object.Type == "TextObject" && object.TextObject.layout != nil {
|
if object.Type == "TextObject" && object.TextObject.layout != nil {
|
||||||
state.layout = object.TextObject.layout
|
state.layout = object.TextObject.layout
|
||||||
state.origin[0], _ = strconv.ParseFloat(object.TextObject.TextCode[0].X, 64)
|
state.origin[0], _, err = e.richPlainOffset(object.TextObject)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64)
|
state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64)
|
||||||
}
|
}
|
||||||
states[editorObjectID(object)] = state
|
states[editorObjectID(object)] = state
|
||||||
@@ -253,7 +256,7 @@ func (e *Editor) replaceGroup(page int, indexes []editorObjectPosition, objects
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply(e, after, origins)
|
apply(e, after, origins)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(page); change != nil {
|
||||||
change.undo = func(e *Editor) { apply(e, before, previous) }
|
change.undo = func(e *Editor) { apply(e, before, previous) }
|
||||||
change.redo = func(e *Editor) { apply(e, after, origins) }
|
change.redo = func(e *Editor) { apply(e, after, origins) }
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-11
@@ -25,6 +25,39 @@ type editorChange struct {
|
|||||||
redo func(*Editor)
|
redo func(*Editor)
|
||||||
before uint64
|
before uint64
|
||||||
after uint64
|
after uint64
|
||||||
|
changes EditorChanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditorChanges 描述最近一次修改或历史回放的刷新范围
|
||||||
|
// All表示文档结构或共享资源变化;否则Pages为受影响的零基页码
|
||||||
|
// 返回值只覆盖最近一次操作,调用方应在每次操作后读取
|
||||||
|
type EditorChanges struct {
|
||||||
|
All bool `json:"all"`
|
||||||
|
Pages []int `json:"pages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes 获取最近一次有效修改的独立刷新范围
|
||||||
|
// 返回: EditorChanges 变化范围;未发生修改时为空
|
||||||
|
func (e *Editor) Changes() EditorChanges {
|
||||||
|
return EditorChanges{All: e.changes.All, Pages: slices.Clone(e.changes.Pages)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge 合并同一事务中的变化范围
|
||||||
|
// 入参: change 待合并的变化
|
||||||
|
func (c *EditorChanges) merge(change EditorChanges) {
|
||||||
|
if c.All {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if change.All {
|
||||||
|
c.All = true
|
||||||
|
c.Pages = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, page := range change.Pages {
|
||||||
|
if !slices.Contains(c.Pages, page) {
|
||||||
|
c.Pages = append(c.Pages, page)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction 将多项修改作为一次原子操作提交,失败时保留原文档及历史
|
// Transaction 将多项修改作为一次原子操作提交,失败时保留原文档及历史
|
||||||
@@ -37,6 +70,8 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
before.Info = cloneEditorData(e.Info)
|
before.Info = cloneEditorData(e.Info)
|
||||||
next := e.transactionSnapshot()
|
next := e.transactionSnapshot()
|
||||||
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
|
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
|
||||||
|
pending := &EditorChanges{}
|
||||||
|
next.transactionChanges = pending
|
||||||
*e = next
|
*e = next
|
||||||
committed := false
|
committed := false
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -48,11 +83,17 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
changed := e.revision != before.revision
|
changed := e.revision != before.revision
|
||||||
|
changes := *pending
|
||||||
|
if e.transactionChanges != pending {
|
||||||
|
changes = EditorChanges{All: true}
|
||||||
|
}
|
||||||
|
e.transactionChanges = before.transactionChanges
|
||||||
e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
|
e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
|
||||||
if !sameBackend(e.backends.FontResources, before.backends.FontResources) {
|
if !sameBackend(e.backends.FontResources, before.backends.FontResources) {
|
||||||
e.clearFontResourceCache()
|
e.clearFontResourceCache()
|
||||||
}
|
}
|
||||||
e.backends, e.fontDirs, e.fontFS = before.backends, before.fontDirs, before.fontFS
|
e.backends, e.fontDirs, e.fontFS = before.backends, before.fontDirs, before.fontFS
|
||||||
|
e.SetResources(before.runtimeResources)
|
||||||
e.fontSourcesCache = before.fontSourcesCache
|
e.fontSourcesCache = before.fontSourcesCache
|
||||||
if e.sameResourceView(&before) {
|
if e.sameResourceView(&before) {
|
||||||
e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics
|
e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics
|
||||||
@@ -61,7 +102,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
e.serial, e.revision = before.serial, before.revision
|
e.serial, e.revision = before.serial, before.revision
|
||||||
e.recordTransaction(before)
|
e.recordTransaction(before, changes)
|
||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
return nil
|
return nil
|
||||||
@@ -107,6 +148,7 @@ func (e *Editor) Undo() bool {
|
|||||||
e.historyIndex--
|
e.historyIndex--
|
||||||
change := e.history[e.historyIndex]
|
change := e.history[e.historyIndex]
|
||||||
change.undo(e)
|
change.undo(e)
|
||||||
|
e.changes, e.objectIndex = change.changes, nil
|
||||||
e.revision = change.before
|
e.revision = change.before
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -119,6 +161,7 @@ func (e *Editor) Redo() bool {
|
|||||||
}
|
}
|
||||||
change := e.history[e.historyIndex]
|
change := e.history[e.historyIndex]
|
||||||
change.redo(e)
|
change.redo(e)
|
||||||
|
e.changes, e.objectIndex = change.changes, nil
|
||||||
e.historyIndex++
|
e.historyIndex++
|
||||||
e.revision = change.after
|
e.revision = change.after
|
||||||
return true
|
return true
|
||||||
@@ -131,15 +174,12 @@ func (e *Editor) Revision() uint64 {
|
|||||||
return e.revision
|
return e.revision
|
||||||
}
|
}
|
||||||
|
|
||||||
// transactionSnapshot 复制可变容器,保留只读XML、图元及二进制资源的共享
|
// transactionSnapshot 复制顶层容器,共享按替换方式更新的页面图层、图元和二进制资源
|
||||||
// 返回: Editor 独立事务状态
|
// 返回: Editor 独立事务状态
|
||||||
func (e *Editor) transactionSnapshot() Editor {
|
func (e *Editor) transactionSnapshot() Editor {
|
||||||
next := *e
|
next := *e
|
||||||
next.Info = cloneEditorData(e.Info)
|
next.Info = cloneEditorData(e.Info)
|
||||||
next.pages = make([]PageContent, len(e.pages))
|
next.pages = slices.Clone(e.pages)
|
||||||
for i, page := range e.pages {
|
|
||||||
next.pages[i] = copyEditorPage(page)
|
|
||||||
}
|
|
||||||
next.resources = slices.Clone(e.resources)
|
next.resources = slices.Clone(e.resources)
|
||||||
next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID)
|
next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID)
|
||||||
next.origins = maps.Clone(e.origins)
|
next.origins = maps.Clone(e.origins)
|
||||||
@@ -168,6 +208,7 @@ func (e *Editor) restoreTransaction(state Editor) {
|
|||||||
state.clearFontResourceCache()
|
state.clearFontResourceCache()
|
||||||
}
|
}
|
||||||
state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS
|
state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS
|
||||||
|
state.SetResources(e.runtimeResources)
|
||||||
state.fontSourcesCache = e.fontSourcesCache
|
state.fontSourcesCache = e.fontSourcesCache
|
||||||
state.validatedPaths = e.validatedPaths
|
state.validatedPaths = e.validatedPaths
|
||||||
state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress
|
state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress
|
||||||
@@ -211,9 +252,13 @@ func (e *Editor) retainDocumentResources(source *Editor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recordTransaction 记录事务快照,在各自资源视图中保留后续注册的资源
|
// recordTransaction 记录事务快照,在各自资源视图中保留后续注册的资源
|
||||||
// 入参: before 修改前的文档状态
|
// 入参: before 修改前的文档状态, changes 可选的事务变化范围
|
||||||
func (e *Editor) recordTransaction(before Editor) {
|
func (e *Editor) recordTransaction(before Editor, changes ...EditorChanges) {
|
||||||
if change := e.recordChange(); change != nil {
|
scope := EditorChanges{All: true}
|
||||||
|
if len(changes) != 0 {
|
||||||
|
scope = changes[0]
|
||||||
|
}
|
||||||
|
if change := e.recordChangeScope(scope); change != nil {
|
||||||
after := e.transactionSnapshot()
|
after := e.transactionSnapshot()
|
||||||
before.history, after.history = nil, nil
|
before.history, after.history = nil, nil
|
||||||
change.undo = func(e *Editor) {
|
change.undo = func(e *Editor) {
|
||||||
@@ -228,10 +273,23 @@ func (e *Editor) recordTransaction(before Editor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recordChange 分配修订标识并为已完成的有效修改预留记录
|
// recordChange 分配修订标识并为已完成的有效修改预留记录
|
||||||
|
// 入参: pages 受影响页码,省略表示全局变化
|
||||||
// 返回: *editorChange 记录,未启用历史时为nil
|
// 返回: *editorChange 记录,未启用历史时为nil
|
||||||
func (e *Editor) recordChange() *editorChange {
|
func (e *Editor) recordChange(pages ...int) *editorChange {
|
||||||
|
return e.recordChangeScope(EditorChanges{All: len(pages) == 0, Pages: slices.Clone(pages)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordChangeScope 分配修订并记录变更范围,事务中同步合并
|
||||||
|
// 入参: scope 变更范围
|
||||||
|
// 返回: *editorChange 可补充回放操作的记录
|
||||||
|
func (e *Editor) recordChangeScope(scope EditorChanges) *editorChange {
|
||||||
|
e.objectIndex = nil
|
||||||
|
e.changes = scope
|
||||||
|
if e.transactionChanges != nil {
|
||||||
|
e.transactionChanges.merge(scope)
|
||||||
|
}
|
||||||
e.serial++
|
e.serial++
|
||||||
change := editorChange{before: e.revision, after: e.serial}
|
change := editorChange{before: e.revision, after: e.serial, changes: scope}
|
||||||
e.revision = e.serial
|
e.revision = e.serial
|
||||||
if e.historyLimit == 0 {
|
if e.historyLimit == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+94
-29
@@ -112,63 +112,107 @@ func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, pag
|
|||||||
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
|
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
|
||||||
// 返回: []string 新对象标识, error 校验、迁移或取消错误
|
// 返回: []string 新对象标识, error 校验、迁移或取消错误
|
||||||
func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]string, error) {
|
func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]string, error) {
|
||||||
|
objects, _, err := e.importSelection(ctx, source, sourcePage, ids, nil, page, options, true)
|
||||||
|
return objects, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportSelection 导入独立单页选区包,重映射对象和资源标识,一次撤销恢复全部
|
||||||
|
// 入参: ctx 取消上下文, source 单页选区阅读器, page 目标页, options 位移及导入配置
|
||||||
|
// 返回: []string 正文标识, []string 注解标识, error 校验或迁移错误
|
||||||
|
func (e *Editor) ImportSelection(ctx context.Context, source *Reader, page int, options ObjectImportOptions) ([]string, []string, error) {
|
||||||
|
doc, err := source.DocContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if len(doc.Pages.Page) != 1 {
|
||||||
|
return nil, nil, fmt.Errorf("selection requires exactly one page")
|
||||||
|
}
|
||||||
|
origin, err := source.EditorContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
content, err := origin.page(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var objects, annotations []string
|
||||||
|
for _, layer := range content.Content.Layer {
|
||||||
|
for _, object := range layer.Objects {
|
||||||
|
objects = append(objects, editorObjectID(object))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notes, err := origin.Annotations(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, note := range notes {
|
||||||
|
annotations = append(annotations, note.ID)
|
||||||
|
}
|
||||||
|
return e.importSelection(ctx, origin, 0, objects, annotations, page, options, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importSelection 迁移正文与注解,共用资源映射并保持来源绘制顺序
|
||||||
|
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, options 导入配置, strictLinks 拒绝跨页链接
|
||||||
|
// 返回: []string 正文标识, []string 注解标识, error 迁移或提交错误
|
||||||
|
func (e *Editor) importSelection(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, options ObjectImportOptions, strictLinks bool) ([]string, []string, error) {
|
||||||
progress := editorProgress(options.OnProgress).withContext(ctx)
|
progress := editorProgress(options.OnProgress).withContext(ctx)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if !finite(options.DX) || !finite(options.DY) {
|
if !finite(options.DX) || !finite(options.DY) {
|
||||||
return nil, fmt.Errorf("import requires finite offsets")
|
return nil, nil, fmt.Errorf("import requires finite offsets")
|
||||||
}
|
}
|
||||||
if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted {
|
if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted {
|
||||||
return nil, ErrEncryptionPolicyRequired
|
return nil, nil, ErrEncryptionPolicyRequired
|
||||||
}
|
}
|
||||||
objects, err := source.Objects(sourcePage, ids)
|
objects, err := source.Objects(sourcePage, ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
target, err := e.page(page)
|
target, err := e.page(page)
|
||||||
if err != nil || len(objects) == 0 {
|
if err != nil || len(objects) == 0 && len(annotations) == 0 {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if source == e {
|
if source == e {
|
||||||
if err := progress.report("commit", 0, 0); err != nil {
|
if err := progress.report("commit", 0, 0); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY)
|
copied, err := e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY)
|
||||||
|
return copied, nil, err
|
||||||
}
|
}
|
||||||
reader, err := source.readerContext(ctx, progress)
|
reader, err := source.readerContext(ctx, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
doc, err := reader.Doc()
|
doc, err := reader.Doc()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
ref := doc.Pages.Page[sourcePage]
|
ref := doc.Pages.Page[sourcePage]
|
||||||
base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress)
|
base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, true
|
migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, strictLinks
|
||||||
pageName := reader.ResPath(ref.BaseLoc)
|
pageName := reader.ResPath(ref.BaseLoc)
|
||||||
content, err := reader.PageContentByIndex(sourcePage)
|
content, err := reader.PageContentByIndex(sourcePage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, value := range content.PageRes {
|
for _, value := range content.PageRes {
|
||||||
name, err := migration.resolve(pageName, "", value)
|
name, err := migration.resolve(pageName, "", value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := migration.resourceIndex(name); err != nil {
|
if err := migration.resourceIndex(name); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
layers := make([][]byte, len(objects))
|
layers := make([][]byte, len(objects))
|
||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
if err := progress.report("objects", i, len(objects)); err != nil {
|
if err := progress.report("objects", i, len(objects)); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
var attrs ofdAttrs
|
var attrs ofdAttrs
|
||||||
@@ -187,32 +231,48 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
|||||||
data, err = editorObjectXML(object)
|
data, err = editorObjectXML(object)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
data, err = editorXMLContainer("Layer", attrs, data)
|
data, err = editorXMLContainer("Layer", attrs, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
root, err := parseEditorXMLContext(ctx, data)
|
root, err := parseEditorXMLContext(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notes := &AnnotationSelection{editor: e, document: e.documentIdentity()}
|
||||||
|
for _, id := range annotations {
|
||||||
|
data, err := source.annotationXML(sourcePage, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
root, err := parseEditorXMLContext(ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
data, err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
notes.data = append(notes.data, data)
|
||||||
|
}
|
||||||
if err := migration.encodeResources(); err != nil {
|
if err := migration.encodeResources(); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, resource := range e.resources {
|
for _, resource := range e.resources {
|
||||||
delete(migration.files, resource.name)
|
delete(migration.files, resource.name)
|
||||||
}
|
}
|
||||||
next, err := migration.merge(base, nil, nil, nil)
|
next, err := migration.merge(base, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var copied []string
|
var copied, copiedNotes []string
|
||||||
err = e.Transaction(func(editor *Editor) error {
|
err = e.Transaction(func(editor *Editor) error {
|
||||||
if err := progress.report("commit", 0, 0); err != nil {
|
if err := progress.report("commit", 0, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -236,13 +296,13 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object := layer.Objects[0]
|
object := layer.Objects[0]
|
||||||
|
object.TextObject.layout = objects[i].TextObject.layout
|
||||||
|
object.state = objects[i].state
|
||||||
|
object.CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
|
||||||
imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam)
|
imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
imported[i].TextObject.layout = objects[i].TextObject.layout
|
|
||||||
imported[i].state = objects[i].state
|
|
||||||
imported[i].CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
|
|
||||||
imported[i].origin = &editorObjectOrigin{editor: editor, document: editor.documentIdentity(), data: data, node: root.child("Layer").children[0], object: object}
|
imported[i].origin = &editorObjectOrigin{editor: editor, document: editor.documentIdentity(), data: data, node: root.child("Layer").children[0], object: object}
|
||||||
}
|
}
|
||||||
copied, err = editor.CopyObjectsContext(ctx, page, imported, options.DX, options.DY)
|
copied, err = editor.CopyObjectsContext(ctx, page, imported, options.DX, options.DY)
|
||||||
@@ -253,12 +313,17 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
|||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
orderedIDs[i] = editorObjectID(object)
|
orderedIDs[i] = editorObjectID(object)
|
||||||
}
|
}
|
||||||
return editor.copyRichTextMetadata(source, sourcePage, orderedIDs, page, copied)
|
notes.document = editor.documentIdentity()
|
||||||
|
copiedNotes, err = editor.PasteAnnotations(page, notes, options.DX, options.DY)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return editor.copyRichTextMetadata(ctx, source, sourcePage, orderedIDs, annotations, page, copied, copiedNotes)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return copied, err
|
return copied, copiedNotes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func editorUpdateInfoXML(data []byte, docID string, index int, before, after Doc
|
|||||||
{"DocID", before.DocID, after.DocID}, {"Title", before.Title, after.Title}, {"Author", before.Author, after.Author},
|
{"DocID", before.DocID, after.DocID}, {"Title", before.Title, after.Title}, {"Author", before.Author, after.Author},
|
||||||
{"Subject", before.Subject, after.Subject}, {"Abstract", before.Abstract, after.Abstract},
|
{"Subject", before.Subject, after.Subject}, {"Abstract", before.Abstract, after.Abstract},
|
||||||
{"CreationDate", before.CreationDate, after.CreationDate}, {"ModDate", before.ModDate, after.ModDate},
|
{"CreationDate", before.CreationDate, after.CreationDate}, {"ModDate", before.ModDate, after.ModDate},
|
||||||
|
{"DocUsage", before.DocUsage, after.DocUsage}, {"Cover", before.Cover, after.Cover},
|
||||||
{"Creator", before.Creator, after.Creator}, {"CreatorVersion", before.CreatorVersion, after.CreatorVersion},
|
{"Creator", before.Creator, after.Creator}, {"CreatorVersion", before.CreatorVersion, after.CreatorVersion},
|
||||||
} {
|
} {
|
||||||
if field[1] != field[2] {
|
if field[1] != field[2] {
|
||||||
@@ -138,17 +139,6 @@ func editorKeywords(data []byte, info *editorXML, values []string) ([]byte, erro
|
|||||||
return editorPatchXML(data, patches), nil
|
return editorPatchXML(data, patches), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorCreatorXML 更新指定文档的制作软件,移除原软件版本,保留其余根索引内容
|
|
||||||
// 入参: data 当前根索引XML, docID 缺少文档描述时使用的标识, index 文档索引
|
|
||||||
// 返回: []byte 修改后的XML, error 解析错误
|
|
||||||
func editorCreatorXML(data []byte, docID string, index int) ([]byte, error) {
|
|
||||||
data, info, err := editorDocInfoXML(data, docID, index)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return editorXMLSetText(data, info, [][2]string{{"Creator", ofdCreator}, {"CreatorVersion", ""}})
|
|
||||||
}
|
|
||||||
|
|
||||||
// editorDocInfoXML 获取文档描述,修改缺少描述的源文件时补建标准容器及标识
|
// editorDocInfoXML 获取文档描述,修改缺少描述的源文件时补建标准容器及标识
|
||||||
// 入参: data 根索引XML, docID 缺少文档描述时使用的标识, index 文档索引
|
// 入参: data 根索引XML, docID 缺少文档描述时使用的标识, index 文档索引
|
||||||
// 返回: []byte 根索引XML, *editorXML 描述节点, error 解析错误
|
// 返回: []byte 根索引XML, *editorXML 描述节点, error 解析错误
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
prefix := editorXMLNamespacePrefix(root)
|
prefix := editorXMLNamespacePrefix(root, "ofd")
|
||||||
var patches []editorXMLPatch
|
var patches []editorXMLPatch
|
||||||
var visit func(*editorXML, bool) error
|
var visit func(*editorXML, bool) error
|
||||||
visit = func(node *editorXML, declare bool) error {
|
visit = func(node *editorXML, declare bool) error {
|
||||||
@@ -152,9 +152,9 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀
|
// editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀
|
||||||
// 入参: node 节点
|
// 入参: node 节点, base 首选前缀
|
||||||
// 返回: string 可用前缀
|
// 返回: string 可用前缀
|
||||||
func editorXMLNamespacePrefix(node *editorXML) string {
|
func editorXMLNamespacePrefix(node *editorXML, base string) string {
|
||||||
used := make(map[string]bool)
|
used := make(map[string]bool)
|
||||||
collect := func(current *editorXML) {
|
collect := func(current *editorXML) {
|
||||||
for _, attr := range current.attrs {
|
for _, attr := range current.attrs {
|
||||||
@@ -174,9 +174,9 @@ func editorXMLNamespacePrefix(node *editorXML) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(node)
|
visit(node)
|
||||||
prefix := "ofd"
|
prefix := base
|
||||||
for index := 1; used[prefix]; index++ {
|
for index := 1; used[prefix]; index++ {
|
||||||
prefix = "ofd" + strconv.Itoa(index)
|
prefix = base + strconv.Itoa(index)
|
||||||
}
|
}
|
||||||
return prefix
|
return prefix
|
||||||
}
|
}
|
||||||
+45
-12
@@ -131,6 +131,9 @@ func (r *Reader) Editor() (*Editor, error) {
|
|||||||
// 入参: ctx 取消上下文
|
// 入参: ctx 取消上下文
|
||||||
// 返回: *Editor 编辑器, error 初始化或取消错误
|
// 返回: *Editor 编辑器, error 初始化或取消错误
|
||||||
func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) {
|
func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) {
|
||||||
|
if r.pageReady != nil {
|
||||||
|
return nil, ErrIncompleteDocument
|
||||||
|
}
|
||||||
_, err := r.DocContext(ctx)
|
_, err := r.DocContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -151,6 +154,7 @@ func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
e := NewEditor()
|
e := NewEditor()
|
||||||
|
e.runtimeResources = r.resources
|
||||||
e.encryption = r.encryption
|
e.encryption = r.encryption
|
||||||
directory := cleanPackagePath(reader.RootDir)
|
directory := cleanPackagePath(reader.RootDir)
|
||||||
e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), fallbackDocID: e.Info.DocID, directory: directory, pages: make(map[string]*editorSourcePage, len(doc.Pages.Page)), origins: make(map[string]*editorObjectOrigin)}
|
e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), fallbackDocID: e.Info.DocID, directory: directory, pages: make(map[string]*editorSourcePage, len(doc.Pages.Page)), origins: make(map[string]*editorObjectOrigin)}
|
||||||
@@ -393,15 +397,18 @@ func (e *Editor) sourceRGB() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// repairSourceObjectIDs 按实际节点区分重复对象,只修复没有引用歧义的编号
|
// repairSourceObjectIDs 按实际节点区分重复对象,只修复没有引用歧义的编号
|
||||||
// 入参: data 页面原文, root 页面节点
|
// 入参: ctx 取消上下文, data 页面原文, root 页面节点
|
||||||
// 返回: []byte 编辑副本, bool 是否修复, error 错误信息
|
// 返回: []byte 编辑副本, bool 是否修复, error 错误信息
|
||||||
func (e *Editor) repairSourceObjectIDs(data []byte, root *editorXML) ([]byte, bool, error) {
|
func (e *Editor) repairSourceObjectIDs(ctx context.Context, data []byte, root *editorXML) ([]byte, bool, error) {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
duplicates := make(map[string]bool)
|
duplicates := make(map[string]bool)
|
||||||
var nodes []*editorXML
|
var nodes []*editorXML
|
||||||
var visit func(*editorXML) error
|
var visit func(*editorXML) error
|
||||||
visit = func(parent *editorXML) error {
|
visit = func(parent *editorXML) error {
|
||||||
for _, node := range parent.children {
|
for _, node := range parent.children {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !ofdXMLChild(parent.name, node.name) {
|
if !ofdXMLChild(parent.name, node.name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -436,15 +443,18 @@ func (e *Editor) repairSourceObjectIDs(data []byte, root *editorXML) ([]byte, bo
|
|||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return data, false, nil
|
return data, false, nil
|
||||||
}
|
}
|
||||||
if err := e.checkDuplicateObjectReferences(duplicates); err != nil {
|
if err := e.checkDuplicateObjectReferences(ctx, duplicates); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
if err := e.prepareSourceIDs(); err != nil {
|
if err := e.prepareSourceIDsContext(ctx); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
maximum := e.maxID
|
maximum := e.maxID
|
||||||
var patches []editorXMLPatch
|
var patches []editorXMLPatch
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
maximum++
|
maximum++
|
||||||
encoded, err := editorXMLAttribute(data, node, "ID", strconv.Itoa(maximum))
|
encoded, err := editorXMLAttribute(data, node, "ID", strconv.Itoa(maximum))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -457,9 +467,9 @@ func (e *Editor) repairSourceObjectIDs(data []byte, root *editorXML) ([]byte, bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkDuplicateObjectReferences 检查编号引用的目标歧义
|
// checkDuplicateObjectReferences 检查编号引用的目标歧义
|
||||||
// 入参: duplicates 重复编号
|
// 入参: ctx 取消上下文, duplicates 重复编号
|
||||||
// 返回: error 引用歧义或读取错误
|
// 返回: error 引用歧义或读取错误
|
||||||
func (e *Editor) checkDuplicateObjectReferences(duplicates map[string]bool) error {
|
func (e *Editor) checkDuplicateObjectReferences(ctx context.Context, duplicates map[string]bool) error {
|
||||||
reader := e.source.reader
|
reader := e.source.reader
|
||||||
for name := range reader.fileNames {
|
for name := range reader.fileNames {
|
||||||
if !strings.EqualFold(path.Ext(name), ".xml") {
|
if !strings.EqualFold(path.Ext(name), ".xml") {
|
||||||
@@ -471,6 +481,10 @@ func (e *Editor) checkDuplicateObjectReferences(duplicates map[string]bool) erro
|
|||||||
}
|
}
|
||||||
decoder := xml.NewDecoder(input)
|
decoder := xml.NewDecoder(input)
|
||||||
for {
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
input.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
token, readErr := decoder.Token()
|
token, readErr := decoder.Token()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
input.Close()
|
input.Close()
|
||||||
@@ -496,6 +510,16 @@ func (e *Editor) checkDuplicateObjectReferences(duplicates map[string]bool) erro
|
|||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) loadSourcePage(index int) error {
|
func (e *Editor) loadSourcePage(index int) error {
|
||||||
|
return e.loadSourcePageContext(context.Background(), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSourcePageContext 解析原始页面,取消时不提交未完成页面
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: error 读取、解析或取消错误
|
||||||
|
func (e *Editor) loadSourcePageContext(ctx context.Context, index int) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
source := e.source.pages[e.pages[index].ID]
|
source := e.source.pages[e.pages[index].ID]
|
||||||
if source == nil || source.original != nil {
|
if source == nil || source.original != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -503,24 +527,24 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
loaded := *source
|
loaded := *source
|
||||||
source = &loaded
|
source = &loaded
|
||||||
reader := e.source.reader
|
reader := e.source.reader
|
||||||
data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc))
|
data, err := reader.readFileViewContext(ctx, reader.ResPath(source.ref.BaseLoc))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
root, err := parseEditorXML(data)
|
root, err := parseEditorXMLContext(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
page, err := reader.PageContent(source.ref)
|
page, err := reader.PageContentContext(ctx, source.ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, repaired, err := e.repairSourceObjectIDs(data, root)
|
data, repaired, err := e.repairSourceObjectIDs(ctx, data, root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if repaired {
|
if repaired {
|
||||||
root, err = parseEditorXML(data)
|
root, err = parseEditorXMLContext(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -543,13 +567,17 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
}
|
}
|
||||||
source.data, source.root, source.nodes = data, root, nodes
|
source.data, source.root, source.nodes = data, root, nodes
|
||||||
source.repaired = repaired
|
source.repaired = repaired
|
||||||
|
origins := make(map[string]*editorObjectOrigin)
|
||||||
for i := range page.Content.Layer {
|
for i := range page.Content.Layer {
|
||||||
layer := &page.Content.Layer[i]
|
layer := &page.Content.Layer[i]
|
||||||
for j, object := range layer.Objects {
|
for j, object := range layer.Objects {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
id := editorObjectID(object)
|
id := editorObjectID(object)
|
||||||
if node := nodes[id]; node != nil {
|
if node := nodes[id]; node != nil {
|
||||||
origin := &editorObjectOrigin{page: source, data: data, node: node, object: object}
|
origin := &editorObjectOrigin{page: source, data: data, node: node, object: object}
|
||||||
e.source.origins[id] = origin
|
origins[id] = origin
|
||||||
if editorXMLTransformable(node) {
|
if editorXMLTransformable(node) {
|
||||||
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -561,6 +589,10 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maps.Copy(e.source.origins, origins)
|
||||||
source.original = page
|
source.original = page
|
||||||
e.source.pages[source.ref.ID] = source
|
e.source.pages[source.ref.ID] = source
|
||||||
e.pages[index] = copyEditorPage(*page)
|
e.pages[index] = copyEditorPage(*page)
|
||||||
@@ -679,6 +711,7 @@ func editorPreservedObject(before, after GraphicObject) bool {
|
|||||||
}
|
}
|
||||||
before = cloneEditorData(before)
|
before = cloneEditorData(before)
|
||||||
before.state = after.state
|
before.state = after.state
|
||||||
|
before.richText = after.richText
|
||||||
switch before.Type {
|
switch before.Type {
|
||||||
case "TextObject":
|
case "TextObject":
|
||||||
a, b := &before.TextObject, after.TextObject
|
a, b := &before.TextObject, after.TextObject
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
data, err = editorCreatorXML(data, e.source.fallbackDocID, reader.documentIndex)
|
data, _, err = editorDocInfoXML(data, e.source.fallbackDocID, reader.documentIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -817,7 +817,7 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re
|
|||||||
files = make(map[string][]byte)
|
files = make(map[string][]byte)
|
||||||
}
|
}
|
||||||
maps.Copy(files, parts)
|
maps.Copy(files, parts)
|
||||||
reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex, selectedVersion: e.source.reader.selectedVersion}
|
reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex, selectedVersion: e.source.reader.selectedVersion, pageContents: e.source.reader.pageContents, resources: e.runtimeResources}
|
||||||
if err := reader.initRootContext(ctx); err != nil {
|
if err := reader.initRootContext(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type editorReferenceScan struct {
|
|||||||
stack []string
|
stack []string
|
||||||
fonts []*editorFontUsage
|
fonts []*editorFontUsage
|
||||||
text strings.Builder
|
text strings.Builder
|
||||||
|
richText strings.Builder
|
||||||
resource bool
|
resource bool
|
||||||
capture bool
|
capture bool
|
||||||
safe bool
|
safe bool
|
||||||
@@ -140,14 +141,39 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
}
|
}
|
||||||
switch token := token.(type) {
|
switch token := token.(type) {
|
||||||
case xml.StartElement:
|
case xml.StartElement:
|
||||||
if classifyOFDNamespace(token.Name.Space) == ofdXMLUnknown {
|
rich := token.Name.Space == richTextNamespace
|
||||||
|
known := classifyOFDNamespace(token.Name.Space) != ofdXMLUnknown
|
||||||
|
if rich {
|
||||||
|
known = len(s.stack) == 0 && token.Name.Local == "RichText" || len(s.stack) == 1 && s.stack[0] == "RichText" && token.Name.Local == "ObjectRef"
|
||||||
|
} else if len(s.stack) > 0 && s.stack[0] == "RichText" {
|
||||||
|
known = false
|
||||||
|
}
|
||||||
|
if len(s.stack) > 0 && s.stack[0] == "CustomTags" {
|
||||||
|
known = known && (len(s.stack) == 1 && token.Name.Local == "CustomTag" || len(s.stack) == 2 && s.stack[1] == "CustomTag" && (token.Name.Local == "FileLoc" || token.Name.Local == "SchemaLoc"))
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if token.Name.Local == "CustomTags" || token.Name.Local == "Extensions" || token.Name.Local == "ExtendData" || token.Name.Local == "Data" {
|
if token.Name.Local == "Extensions" || token.Name.Local == "ExtendData" || token.Name.Local == "Data" {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if token.Name.Local == "CustomTag" {
|
||||||
|
var kind, namespace string
|
||||||
|
for _, attr := range token.Attr {
|
||||||
|
if attr.Name.Space == "" && attr.Name.Local == "TypeID" {
|
||||||
|
kind = attr.Value
|
||||||
|
}
|
||||||
|
if attr.Name.Space == "" && attr.Name.Local == "NameSpace" {
|
||||||
|
namespace = attr.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind != richTextTagType || namespace != richTextNamespace {
|
||||||
|
s.safe = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(s.stack) == 0 {
|
if len(s.stack) == 0 {
|
||||||
if s.seen {
|
if s.seen {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
@@ -181,10 +207,11 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, attr := range token.Attr {
|
for _, attr := range token.Attr {
|
||||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace {
|
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace || textMetadataAttribute(token.Name, attr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if attr.Name.Space != "" {
|
custom := len(s.stack) == 0 && token.Name.Local == "CustomTags" || len(s.stack) > 0 && s.stack[0] == "CustomTags"
|
||||||
|
if attr.Name.Space != "" || rich && (token.Name.Local != "ObjectRef" || attr.Name.Local != "PageRef") || custom && (token.Name.Local != "CustomTag" || attr.Name.Local != "TypeID" && attr.Name.Local != "NameSpace") {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -225,6 +252,9 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
s.capture = true
|
s.capture = true
|
||||||
}
|
}
|
||||||
case xml.CharData:
|
case xml.CharData:
|
||||||
|
if len(s.stack) == 1 && s.stack[0] == "RichText" {
|
||||||
|
s.richText.Write(token)
|
||||||
|
}
|
||||||
if len(s.stack) == 0 && len(bytes.Trim(token, " \t\r\n")) != 0 {
|
if len(s.stack) == 0 && len(bytes.Trim(token, " \t\r\n")) != 0 {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
@@ -233,6 +263,14 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
s.text.Write(token)
|
s.text.Write(token)
|
||||||
}
|
}
|
||||||
case xml.EndElement:
|
case xml.EndElement:
|
||||||
|
if len(s.stack) == 3 && s.stack[0] == "CustomTags" && token.Name.Local == "SchemaLoc" && strings.TrimSpace(s.text.String()) != "" {
|
||||||
|
s.safe = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s.stack) == 1 && s.stack[0] == "RichText" && !richTextMetadataFontSafe(s.richText.String()) {
|
||||||
|
s.safe = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
if len(s.stack) == 0 || s.stack[len(s.stack)-1] != token.Name.Local {
|
if len(s.stack) == 0 || s.stack[len(s.stack)-1] != token.Name.Local {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
|
|||||||
+653
-119
@@ -25,6 +25,7 @@ import (
|
|||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ type richTextMetadata struct {
|
|||||||
Digest string `json:"digest"`
|
Digest string `json:"digest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// richTextSnapshot 保存对象及嵌套成员的不可变编辑语义,成员按绘制顺序定位
|
||||||
|
type richTextSnapshot struct {
|
||||||
|
metadata *richTextMetadata
|
||||||
|
children map[int]*richTextSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
// richTextEntry 保存标引入口及延迟报告的损坏诊断
|
// richTextEntry 保存标引入口及延迟报告的损坏诊断
|
||||||
type richTextEntry struct {
|
type richTextEntry struct {
|
||||||
index int
|
index int
|
||||||
@@ -56,11 +63,13 @@ type richTextEntry struct {
|
|||||||
type richTextIndex struct {
|
type richTextIndex struct {
|
||||||
source *editorSource
|
source *editorSource
|
||||||
revision uint64
|
revision uint64
|
||||||
|
remaps map[string]int
|
||||||
entries map[string]richTextEntry
|
entries map[string]richTextEntry
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
|
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
|
||||||
|
// 按给定宽度排版,高度随内容自适应
|
||||||
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
|
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
|
||||||
// 返回: string 组合对象标识, error 排版、资源或持久化错误
|
// 返回: string 组合对象标识, error 排版、资源或持久化错误
|
||||||
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
|
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
|
||||||
@@ -88,12 +97,30 @@ func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error)
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换
|
// UpdateRichText 原子替换富文本内容,保留对象标识、段落宽度及页面变换,高度随内容调整
|
||||||
// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源
|
// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源
|
||||||
// 入参: page 页面索引, id 对象标识, value 完整富文本
|
// 入参: page 页面索引, id 对象标识, value 完整富文本
|
||||||
// 返回: error 识别、排版或提交错误
|
// 返回: error 识别、排版或提交错误
|
||||||
func (e *Editor) UpdateRichText(page int, id string, value RichText) error {
|
func (e *Editor) UpdateRichText(page int, id string, value RichText) error {
|
||||||
return e.updateRichTextFrame(page, id, value, 0, 0)
|
return e.updateRichTextFrame(page, id, value, 0, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTargetRichText 一次提交正文或组合内富文本的内容和局部宽度,保留页面变换
|
||||||
|
// 入参: page 页面索引, path 完整对象路径, value 富文本, offset 局部偏移, width 宽度,零值保留宽度模式
|
||||||
|
// 返回: error 路径、尺寸或排版错误
|
||||||
|
func (e *Editor) UpdateTargetRichText(page int, path ObjectPath, value RichText, offset, width float64) error {
|
||||||
|
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||||
|
return fmt.Errorf("invalid text frame")
|
||||||
|
}
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
return ErrNotRichText
|
||||||
|
}
|
||||||
|
return e.updateRichTextFrame(page, path.ID, value, offset, width, false)
|
||||||
|
}
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
||||||
@@ -107,29 +134,28 @@ func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return e.updateRichTextFrame(page, id, value, offset, width)
|
return e.updateRichTextFrame(page, id, value, offset, width, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度
|
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时沿用原宽度模式
|
||||||
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度
|
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排原文
|
||||||
// 返回: error 校验或提交错误
|
// 返回: error 校验或提交错误
|
||||||
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64) error {
|
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64, reflow bool) error {
|
||||||
return e.Transaction(func(e *Editor) error {
|
return e.Transaction(func(e *Editor) error {
|
||||||
old, err := e.Object(page, id)
|
old, err := e.Object(page, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current, err := e.RichText(page, id)
|
current, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
frame, matrix, err := e.RichTextFrame(page, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := value.Validate(); err != nil {
|
if err := value.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if width > 0 {
|
||||||
|
value.AutoWidth = false
|
||||||
|
}
|
||||||
if old.Type == "CompositeObject" && offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
|
if old.Type == "CompositeObject" && offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -204,13 +230,20 @@ func (e *Editor) RichText(page int, id string) (RichText, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RichText{}, err
|
return RichText{}, err
|
||||||
}
|
}
|
||||||
|
return e.richTextObject(page, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// richTextObject 读取对象的编辑标引并校验标准内容
|
||||||
|
// 入参: page 页面索引, object 当前对象
|
||||||
|
// 返回: RichText 编辑内容, error 识别或持久化错误
|
||||||
|
func (e *Editor) richTextObject(page int, object GraphicObject) (RichText, error) {
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
return e.promoteRichText(page, object)
|
return e.promoteRichText(page, object)
|
||||||
}
|
}
|
||||||
if object.Type != "CompositeObject" {
|
if object.Type != "CompositeObject" {
|
||||||
return RichText{}, ErrNotRichText
|
return RichText{}, ErrNotRichText
|
||||||
}
|
}
|
||||||
entry, err := e.richTextEntry(page, id)
|
entry, err := e.richTextEntry(page, editorObjectID(object))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RichText{}, err
|
return RichText{}, err
|
||||||
}
|
}
|
||||||
@@ -247,19 +280,8 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
|
|||||||
return Box{}, Matrix{}, err
|
return Box{}, Matrix{}, err
|
||||||
}
|
}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
if _, err := e.promoteRichText(page, object); err != nil {
|
_, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, false)
|
||||||
return Box{}, Matrix{}, err
|
return frame, matrix, err
|
||||||
}
|
|
||||||
frame, err := object.TextObject.TextFrame()
|
|
||||||
if err != nil {
|
|
||||||
return Box{}, Matrix{}, err
|
|
||||||
}
|
|
||||||
box, _ := creationBox(object.TextObject.Boundary)
|
|
||||||
dx, dy, err := e.richPlainOffset(object.TextObject)
|
|
||||||
if err != nil {
|
|
||||||
return Box{}, Matrix{}, err
|
|
||||||
}
|
|
||||||
return Box{W: frame.W, H: frame.H}, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(frame.X+dx, frame.Y+dy)), nil
|
|
||||||
}
|
}
|
||||||
if object.Type != "CompositeObject" {
|
if object.Type != "CompositeObject" {
|
||||||
return Box{}, Matrix{}, ErrNotRichText
|
return Box{}, Matrix{}, ErrNotRichText
|
||||||
@@ -275,7 +297,181 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
|
|||||||
return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil
|
return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引
|
// AddCompositeRichText 向内部范围添加富文本,使用页面坐标并保存编辑标引
|
||||||
|
// 入参: page 页面索引, path 父路径, box 页面编辑框, value 富文本
|
||||||
|
// 返回: int 新成员序号, error 排版或提交错误
|
||||||
|
func (e *Editor) AddCompositeRichText(page int, path ObjectPath, box Box, value RichText) (int, error) {
|
||||||
|
var index int
|
||||||
|
err := e.Transaction(func(e *Editor) error {
|
||||||
|
if _, err := creationBox(editorBoxString(box)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
object, meta, err := e.prepareRichText(box, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
index, err = e.AddCompositeObject(page, path, object)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member, err := e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, member.Object, meta, -1)
|
||||||
|
})
|
||||||
|
return index, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositeRichText 读取内部富文本值及局部编辑框,不提升未标引的普通文字
|
||||||
|
// 入参: page 页面索引, member 当前内部成员快照
|
||||||
|
// 返回: RichText 编辑内容, Box 局部编辑框, Matrix 页面变换, error 识别或持久化错误
|
||||||
|
func (e *Editor) CompositeRichText(page int, member CompositeMember) (RichText, Box, Matrix, error) {
|
||||||
|
if _, err := e.page(page); err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
if member.Object.Type != "CompositeObject" {
|
||||||
|
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||||
|
}
|
||||||
|
value, err := e.richTextObject(page, member.Object)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
entry, err := e.richTextEntry(page, editorObjectID(member.Object))
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
return value, entry.metadata.Frame, member.Matrix.Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCompositeRichText 原子重排内部富文本,保留父变换并隔离共享实例
|
||||||
|
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值沿用原宽度模式
|
||||||
|
// 返回: error 校验或提交错误
|
||||||
|
func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error {
|
||||||
|
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCompositeRichText 统一处理内部文字的安全提升与显式重排
|
||||||
|
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部偏移, width 可选宽度, reflow 是否显式重排
|
||||||
|
// 返回: error 校验或提交错误
|
||||||
|
func (e *Editor) updateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
|
||||||
|
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||||
|
return fmt.Errorf("invalid rich text frame")
|
||||||
|
}
|
||||||
|
if width > 0 {
|
||||||
|
value.AutoWidth = false
|
||||||
|
}
|
||||||
|
return e.Transaction(func(e *Editor) error {
|
||||||
|
member, err := e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if member.Object.Type == "TextObject" {
|
||||||
|
return e.updateCompositePlainRichText(page, path, index, value, offset, width, reflow)
|
||||||
|
}
|
||||||
|
var metadata richTextMetadata
|
||||||
|
entryIndex := -1
|
||||||
|
changed := false
|
||||||
|
err = e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||||
|
node := nodes[0]
|
||||||
|
current, frame, _, err := e.CompositeRichText(page, members[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read composite member %s: %w", editorObjectID(members[0].Object), err)
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if node.object.CompositeGraphicUnit.Clips != nil {
|
||||||
|
return fmt.Errorf("clipped rich text requires explicit clip removal before reflow")
|
||||||
|
}
|
||||||
|
entry, err := e.richTextEntry(page, editorObjectID(node.object))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read composite node %s: %w", editorObjectID(node.object), err)
|
||||||
|
}
|
||||||
|
object := cloneEditorData(node.object)
|
||||||
|
if !node.boundaryInCTM {
|
||||||
|
object = compositeBoundary(object, node.parent, true)
|
||||||
|
}
|
||||||
|
unit := &object.CompositeGraphicUnit
|
||||||
|
box, err := creationBox(unit.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matrix := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(unit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0]+offset, entry.metadata.Offset[1]))
|
||||||
|
if width > 0 {
|
||||||
|
frame.W = width
|
||||||
|
}
|
||||||
|
next, meta, err := e.prepareRichText(frame, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
placement := richTextPlacement(meta, matrix)
|
||||||
|
unit.ResourceID, unit.Boundary, unit.CTM = next.CompositeGraphicUnit.ResourceID, placement.Boundary, placement.CTM
|
||||||
|
if !node.boundaryInCTM {
|
||||||
|
object = compositeBoundary(object, node.parent, false)
|
||||||
|
}
|
||||||
|
metadata, entryIndex, changed = meta, entry.index, true
|
||||||
|
return node.update(object)
|
||||||
|
})
|
||||||
|
if err != nil || !changed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member, err = e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry, err := e.richTextEntry(page, editorObjectID(member.Object)); err == nil {
|
||||||
|
entryIndex = entry.index
|
||||||
|
} else if !errors.Is(err, ErrNotRichText) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, member.Object, metadata, entryIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// putCompositeRichTextMetadata 在容器坐标规范化后更新标准内容摘要和编辑标引
|
||||||
|
// 入参: page 页面索引, object 当前成员, meta 编辑信息, index 原入口或-1
|
||||||
|
// 返回: error 资源或提交错误
|
||||||
|
func (e *Editor) putCompositeRichTextMetadata(page int, object GraphicObject, meta richTextMetadata, index int) error {
|
||||||
|
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
meta.Digest = richTextDigest(unit.object.CompositeGraphicUnit.Objects)
|
||||||
|
return e.putRichTextMetadata(page, editorObjectID(object), meta, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// putCompositeRichTextCopies 一次读取复制后的选区,按对象顺序恢复富文本标引
|
||||||
|
// 入参: page 页面索引, path 父路径, indexes 新成员序号, metadata 选区位置对应的编辑语义
|
||||||
|
// 返回: error 读取或提交错误
|
||||||
|
func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes []int, metadata map[int]*richTextSnapshot) error {
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
selected := make([]int, 0, len(metadata))
|
||||||
|
values := make([]*richTextSnapshot, 0, len(metadata))
|
||||||
|
for i, index := range indexes {
|
||||||
|
if meta, ok := metadata[i]; ok {
|
||||||
|
selected = append(selected, index)
|
||||||
|
values = append(values, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
members, err := e.CompositeObjectsAt(page, path, selected)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, member := range members {
|
||||||
|
if err := e.putRichTextSnapshot(page, member.Object, values[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引,高度随排版内容自适应
|
||||||
// 入参: box 页面或局部编辑框, value 富文本
|
// 入参: box 页面或局部编辑框, value 富文本
|
||||||
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
|
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
|
||||||
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
|
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
|
||||||
@@ -289,8 +485,9 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphicObject{}, richTextMetadata{}, err
|
return GraphicObject{}, richTextMetadata{}, err
|
||||||
}
|
}
|
||||||
|
box.W, box.H = result.width, result.bounds.Y+result.bounds.H
|
||||||
padX, padY := -min(0, result.bounds.X), -min(0, result.bounds.Y)
|
padX, padY := -min(0, result.bounds.X), -min(0, result.bounds.Y)
|
||||||
extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: max(box.H, result.bounds.Y+result.bounds.H) + padY}
|
extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: box.H + padY}
|
||||||
if !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 {
|
if !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 {
|
||||||
return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range")
|
return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range")
|
||||||
}
|
}
|
||||||
@@ -313,7 +510,7 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphicObject{}, richTextMetadata{}, err
|
return GraphicObject{}, richTextMetadata{}, err
|
||||||
}
|
}
|
||||||
meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: max(box.H, result.bounds.Y+result.bounds.H)}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)}
|
meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: box.H}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)}
|
||||||
for i := range meta.Value.Runs {
|
for i := range meta.Value.Runs {
|
||||||
meta.Value.Runs[i].Format.Font = ""
|
meta.Value.Runs[i].Format.Font = ""
|
||||||
}
|
}
|
||||||
@@ -385,9 +582,15 @@ func (e *Editor) richTextEntry(page int, id string) (richTextEntry, error) {
|
|||||||
// 返回: *richTextIndex 只读缓存
|
// 返回: *richTextIndex 只读缓存
|
||||||
func (e *Editor) richTextMetadataIndex() *richTextIndex {
|
func (e *Editor) richTextMetadataIndex() *richTextIndex {
|
||||||
if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision {
|
if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision {
|
||||||
|
valid := len(index.remaps) == len(e.structureRemaps)
|
||||||
|
for page, remaps := range e.structureRemaps {
|
||||||
|
valid = valid && index.remaps[page] == len(remaps)
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
index := &richTextIndex{source: e.source, revision: e.revision, entries: make(map[string]richTextEntry)}
|
}
|
||||||
|
index := &richTextIndex{source: e.source, revision: e.revision, remaps: e.structureRemapStarts(), entries: make(map[string]richTextEntry)}
|
||||||
e.richIndex = index
|
e.richIndex = index
|
||||||
if e.source == nil {
|
if e.source == nil {
|
||||||
return index
|
return index
|
||||||
@@ -434,6 +637,17 @@ func (e *Editor) richTextMetadataIndex() *richTextIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for page, remaps := range e.structureRemaps {
|
||||||
|
for _, ids := range remaps {
|
||||||
|
for from, to := range ids {
|
||||||
|
if _, exists := index.entries[page+"/"+to]; !exists {
|
||||||
|
if entry, ok := index.entries[page+"/"+from]; ok {
|
||||||
|
index.entries[page+"/"+to] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +661,26 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata,
|
|||||||
}
|
}
|
||||||
root := StructureNode{Name: "RichText", Namespace: richTextNamespace, Text: string(data)}
|
root := StructureNode{Name: "RichText", Namespace: richTextNamespace, Text: string(data)}
|
||||||
if index >= 0 {
|
if index >= 0 {
|
||||||
return e.UpdateStructureNode(index, nil, root)
|
separate := false
|
||||||
|
err := e.editStructure(index, func(data []byte, node *editorXML, tag CustomTag) ([]byte, error) {
|
||||||
|
updated, err := rewriteStructureReferences(context.Background(), data, tag, func(pageID string, ids []string) (string, []string, error) {
|
||||||
|
var remaining []string
|
||||||
|
for _, objectID := range ids {
|
||||||
|
if editorResourceID(pageID) != editorResourceID(e.pages[page].ID) || editorResourceID(objectID) != editorResourceID(id) {
|
||||||
|
remaining = append(remaining, objectID)
|
||||||
|
separate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pageID, remaining, nil
|
||||||
|
})
|
||||||
|
if err != nil || separate {
|
||||||
|
return updated, err
|
||||||
|
}
|
||||||
|
return updateStructureNodeXML(data, node, root)
|
||||||
|
})
|
||||||
|
if err != nil || !separate {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
root.Children = []StructureNode{{Name: "ObjectRef", Namespace: richTextNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: e.pages[page].ID}}, Text: id}}
|
root.Children = []StructureNode{{Name: "ObjectRef", Namespace: richTextNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: e.pages[page].ID}}, Text: id}}
|
||||||
_, err = e.AddStructure(CustomTag{TypeID: richTextTagType, NameSpace: richTextNamespace}, root)
|
_, err = e.AddStructure(CustomTag{TypeID: richTextTagType, NameSpace: richTextNamespace}, root)
|
||||||
@@ -458,25 +691,31 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata,
|
|||||||
// 入参: page 页面索引, object 普通文字快照
|
// 入参: page 页面索引, object 普通文字快照
|
||||||
// 返回: RichText 单片段, error 不安全提升错误
|
// 返回: RichText 单片段, error 不安全提升错误
|
||||||
func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) {
|
func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) {
|
||||||
t := object.TextObject
|
layer, _, err := e.findObject(page, object.TextObject.ID)
|
||||||
unsupported := func() (RichText, error) {
|
|
||||||
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
|
||||||
}
|
|
||||||
layer, _, err := e.findObject(page, t.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RichText{}, err
|
return RichText{}, err
|
||||||
}
|
}
|
||||||
if layer.DrawParam != "" || t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
if origin := e.objectOrigin(object.TextObject.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||||
return unsupported()
|
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
||||||
}
|
}
|
||||||
if origin := e.objectOrigin(t.ID); origin != nil && !editorXMLSupported(origin.node) {
|
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||||
return unsupported()
|
|
||||||
}
|
|
||||||
frame, err := t.TextFrame()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return RichText{}, err
|
||||||
|
}
|
||||||
|
return e.promoteTextValue(object.TextObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promoteTextValue 校验普通文字是否可无损提升,保留明确段落或可验证单行布局
|
||||||
|
// 入参: t 有效文字样式与局部定位
|
||||||
|
// 返回: RichText 单片段, error 布局或格式错误
|
||||||
|
func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||||
|
unsupported := func() (RichText, error) {
|
||||||
|
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
||||||
|
}
|
||||||
|
if t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
if frame.X != 0 || frame.Y != 0 || t.CTM != "" && NewMatrix(t.CTM) != (IdentityMatrix) {
|
if _, err := t.TextFrame(); err != nil {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
value, layout := t.TextLayout()
|
value, layout := t.TextLayout()
|
||||||
@@ -492,7 +731,7 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
|||||||
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
|
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
if actual.DeltaX != "" && !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
if !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
for _, dy := range parseFloatsWithG(actual.DeltaY) {
|
for _, dy := range parseFloatsWithG(actual.DeltaY) {
|
||||||
@@ -520,22 +759,20 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
|||||||
}
|
}
|
||||||
color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8)
|
color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8)
|
||||||
}
|
}
|
||||||
result := RichText{Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout}
|
result := RichText{AutoWidth: t.layout == nil || t.layout.autoWidth, Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout}
|
||||||
return result, result.Validate()
|
return result, result.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局
|
// richPlainOffset 计算原首行相对标准排版的位移,保留既有段落和组合内基线
|
||||||
// 入参: text 已校验的普通文字
|
// 入参: text 已校验的普通文字
|
||||||
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
|
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
|
||||||
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
||||||
if text.layout != nil {
|
if len(text.TextCode) == 0 {
|
||||||
return 0, 0, nil
|
return 0, 0, fmt.Errorf("text codes are empty")
|
||||||
}
|
|
||||||
if len(text.TextCode) != 1 {
|
|
||||||
return 0, 0, fmt.Errorf("positioned text requires explicit layout")
|
|
||||||
}
|
}
|
||||||
|
value, layout := text.TextLayout()
|
||||||
check := text
|
check := text
|
||||||
if err := e.LayoutText(&check, text.Text(), TextLayout{}); err != nil {
|
if err := e.LayoutText(&check, value, layout); err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
||||||
@@ -550,32 +787,60 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
return x, y - baseline, nil
|
origin, err := strconv.ParseFloat(check.TextCode[0].X, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return x - origin, y - baseline, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyRichTextMetadata 复制已识别组合的语义,使用目标标准子对象的字体引用
|
// copyRichTextMetadata 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用
|
||||||
// 入参: source 来源编辑器, sourcePage 来源页, ids 来源标识, page 目标页, copied 目标标识
|
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, copied 新正文标识, copiedNotes 新注解标识
|
||||||
// 返回: error 元数据损坏或提交错误
|
// 返回: error 元数据损坏、结构不一致或提交错误
|
||||||
func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []string, page int, copied []string) error {
|
func (e *Editor) copyRichTextMetadata(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, copied, copiedNotes []string) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
metadata := source.richTextMetadataIndex()
|
||||||
|
if metadata.err != nil || len(metadata.entries) == 0 {
|
||||||
|
return metadata.err
|
||||||
|
}
|
||||||
|
copyObject := func(before, after GraphicObject) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot, err := source.captureRichTextSnapshot(sourcePage, before)
|
||||||
|
if err != nil || snapshot == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.putRichTextSnapshot(page, after, snapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
object, err := source.Object(sourcePage, id)
|
before, err := source.Object(sourcePage, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if object.Type != "CompositeObject" {
|
after, err := e.Object(page, copied[i])
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry, err := source.richTextEntry(sourcePage, id)
|
|
||||||
if errors.Is(err, ErrNotRichText) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := source.RichText(sourcePage, id); err != nil {
|
if err := copyObject(before, after); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := e.putRichTextMetadata(page, copied[i], entry.metadata, -1); err != nil {
|
}
|
||||||
|
for i, id := range annotations {
|
||||||
|
before, err := source.Annotation(sourcePage, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after, err := e.Annotation(page, copiedNotes[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := copyObject(GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: before.Appearance.Objects}}, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: after.Appearance.Objects}}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,25 +879,18 @@ func (e *Editor) richTextColors(object *GraphicObject) error {
|
|||||||
|
|
||||||
// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象
|
// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象
|
||||||
// 入参: objects 待复制快照
|
// 入参: objects 待复制快照
|
||||||
// 返回: map[int]richTextMetadata 需要复制的语义, error 损坏诊断
|
// 返回: map[int]*richTextSnapshot 需要复制的语义, error 损坏诊断
|
||||||
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTextMetadata, error) {
|
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]*richTextSnapshot, error) {
|
||||||
result := make(map[int]richTextMetadata)
|
result := make(map[int]*richTextSnapshot)
|
||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
if object.Type != "CompositeObject" {
|
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if object.origin != nil && e.snapshotOrigin(object) == nil {
|
if object.origin != nil && e.snapshotOrigin(object) == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if object.richText != nil {
|
if object.richText != nil {
|
||||||
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
|
result[i] = object.richText
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if richTextDigest(unit.object.CompositeGraphicUnit.Objects) != object.richText.Digest {
|
|
||||||
return nil, fmt.Errorf("rich text snapshot resource has changed")
|
|
||||||
}
|
|
||||||
result[i] = cloneEditorData(*object.richText)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
index := e.richTextMetadataIndex()
|
index := e.richTextMetadataIndex()
|
||||||
@@ -642,45 +900,125 @@ func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTe
|
|||||||
if len(index.entries) == 0 {
|
if len(index.entries) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := editorObjectID(object)
|
|
||||||
for page := range e.pages {
|
for page := range e.pages {
|
||||||
entry, err := e.richTextEntry(page, id)
|
snapshot, err := e.captureRichTextSnapshot(page, object)
|
||||||
if errors.Is(err, ErrNotRichText) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
current, err := e.Object(page, id)
|
if snapshot != nil {
|
||||||
if err != nil {
|
result[i] = snapshot
|
||||||
continue
|
|
||||||
}
|
|
||||||
if current.CompositeGraphicUnit.ResourceID != object.CompositeGraphicUnit.ResourceID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := e.RichText(page, id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result[i] = entry.metadata
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在
|
// attachRichTextSnapshot 为对象快照附加嵌套编辑语义,来源删除后仍可跨页粘贴
|
||||||
// 入参: page 页面索引, object 对象快照
|
// 入参: page 页面索引, object 对象快照
|
||||||
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) {
|
// 返回: error 标引或资源错误
|
||||||
object.richText = nil
|
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) error {
|
||||||
if object.Type != "CompositeObject" {
|
snapshot, err := e.captureRichTextSnapshot(page, *object)
|
||||||
return
|
object.richText = snapshot
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
entry, err := e.richTextEntry(page, editorObjectID(*object))
|
|
||||||
if err != nil {
|
// captureRichTextSnapshot 捕获组合树内的富文本标引,不复制共享二进制资源
|
||||||
return
|
// 入参: page 页面索引, object 根对象
|
||||||
|
// 返回: *richTextSnapshot 不可变快照,无标引时为空, error 标引或资源错误
|
||||||
|
func (e *Editor) captureRichTextSnapshot(page int, object GraphicObject) (*richTextSnapshot, error) {
|
||||||
|
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
index := e.richTextMetadataIndex()
|
||||||
|
if index.err != nil || len(index.entries) == 0 {
|
||||||
|
return nil, index.err
|
||||||
|
}
|
||||||
|
active := make(map[string]bool)
|
||||||
|
var capture func(GraphicObject) (*richTextSnapshot, error)
|
||||||
|
capture = func(object GraphicObject) (*richTextSnapshot, error) {
|
||||||
|
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
entry, err := e.richTextEntry(page, editorObjectID(object))
|
||||||
|
if err == nil {
|
||||||
|
if _, err := e.richTextObject(page, object); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
meta := cloneEditorData(entry.metadata)
|
meta := cloneEditorData(entry.metadata)
|
||||||
object.richText = &meta
|
return &richTextSnapshot{metadata: &meta}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotRichText) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
unit := object.CompositeGraphicUnit
|
||||||
|
if unit.ResourceID != "" {
|
||||||
|
if active[unit.ResourceID] {
|
||||||
|
return nil, fmt.Errorf("cyclic composite resource")
|
||||||
|
}
|
||||||
|
active[unit.ResourceID] = true
|
||||||
|
defer delete(active, unit.ResourceID)
|
||||||
|
definition, err := e.compositeDefinition(unit.ResourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
unit = definition.object.CompositeGraphicUnit
|
||||||
|
}
|
||||||
|
var snapshot *richTextSnapshot
|
||||||
|
i := 0
|
||||||
|
for child := range unit.objects() {
|
||||||
|
value, err := capture(child)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if value != nil {
|
||||||
|
if snapshot == nil {
|
||||||
|
snapshot = &richTextSnapshot{children: make(map[int]*richTextSnapshot)}
|
||||||
|
}
|
||||||
|
snapshot.children[i] = value
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
return capture(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// putRichTextSnapshot 恢复复制后的嵌套标引,按目标标准内容更新摘要和字体映射
|
||||||
|
// 入参: page 目标页, object 复制后的对象, snapshot 来源不可变语义
|
||||||
|
// 返回: error 结构或标引错误
|
||||||
|
func (e *Editor) putRichTextSnapshot(page int, object GraphicObject, snapshot *richTextSnapshot) error {
|
||||||
|
if snapshot.metadata != nil {
|
||||||
|
index := -1
|
||||||
|
if entry, err := e.richTextEntry(page, editorObjectID(object)); err == nil {
|
||||||
|
index = entry.index
|
||||||
|
} else if !errors.Is(err, ErrNotRichText) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, object, *snapshot.metadata, index)
|
||||||
|
}
|
||||||
|
unit := object.CompositeGraphicUnit
|
||||||
|
if unit.ResourceID != "" {
|
||||||
|
definition, err := e.compositeDefinition(unit.ResourceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unit = definition.object.CompositeGraphicUnit
|
||||||
|
}
|
||||||
|
i, count := 0, 0
|
||||||
|
for child := range unit.objects() {
|
||||||
|
if value := snapshot.children[i]; value != nil {
|
||||||
|
if err := e.putRichTextSnapshot(page, child, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if count != len(snapshot.children) {
|
||||||
|
return fmt.Errorf("copied composite structure differs")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查
|
// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查
|
||||||
@@ -708,22 +1046,7 @@ func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool,
|
|||||||
if root.Name != "RichText" || root.Namespace != richTextNamespace || len(root.Attributes) != 0 {
|
if root.Name != "RichText" || root.Namespace != richTextNamespace || len(root.Attributes) != 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var meta richTextMetadata
|
if !richTextMetadataFontSafe(root.Text) {
|
||||||
decoder := json.NewDecoder(bytes.NewBufferString(root.Text))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&meta); err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if decoder.Decode(new(any)) != io.EOF || meta.Version != 1 {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
for i := range meta.Value.Runs {
|
|
||||||
if meta.Value.Runs[i].Format.Font != "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
meta.Value.Runs[i].Format.Font = "1"
|
|
||||||
}
|
|
||||||
if err := meta.Value.Validate(); err != nil {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
for _, child := range root.Children {
|
for _, child := range root.Children {
|
||||||
@@ -734,3 +1057,214 @@ func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool,
|
|||||||
}
|
}
|
||||||
return true, ctx.Err()
|
return true, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// richTextMetadataFontSafe 核对本库富文本元数据不包含字体资源编号或未知字段
|
||||||
|
// 入参: value 元数据JSON
|
||||||
|
// 返回: bool 是否可由标准图元完整统计字体引用
|
||||||
|
func richTextMetadataFontSafe(value string) bool {
|
||||||
|
var meta richTextMetadata
|
||||||
|
decoder := json.NewDecoder(bytes.NewBufferString(value))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&meta); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if decoder.Decode(new(any)) != io.EOF || meta.Version != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range meta.Value.Runs {
|
||||||
|
if meta.Value.Runs[i].Format.Font != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
meta.Value.Runs[i].Format.Font = "1"
|
||||||
|
}
|
||||||
|
if err := meta.Value.Validate(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCompositeText 修改内部富文本格式,普通文字安全提升后保留原实例变换
|
||||||
|
// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量, reflow 显式重排
|
||||||
|
// 返回: error 识别、排版或提交错误
|
||||||
|
func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch TextFormatPatch, reflow bool) error {
|
||||||
|
value, _, _, err := e.PrepareRichText(page, ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}, reflow)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.updateCompositeRichText(page, path, index, value, 0, 0, reflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标
|
||||||
|
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排
|
||||||
|
// 返回: error 校验或提交错误
|
||||||
|
func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
|
||||||
|
target := ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}
|
||||||
|
_, frame, matrix, err := e.PrepareRichText(page, target, reflow)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matrix = matrix.Multiply(TranslationMatrix(offset, 0))
|
||||||
|
if width > 0 {
|
||||||
|
frame.W = width
|
||||||
|
}
|
||||||
|
var metadata richTextMetadata
|
||||||
|
if err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||||
|
node := nodes[0]
|
||||||
|
if node.object.Type != "TextObject" || !editorXMLSupported(node.span) {
|
||||||
|
return ErrNotRichText
|
||||||
|
}
|
||||||
|
inverse, ok := node.parent.Invert()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid composite parent transform")
|
||||||
|
}
|
||||||
|
next, meta, err := e.prepareRichText(Box{W: frame.W, H: frame.H}, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unit := richTextPlacement(meta, inverse.Multiply(matrix))
|
||||||
|
unit.ID, unit.ResourceID = node.object.TextObject.ID, next.CompositeGraphicUnit.ResourceID
|
||||||
|
next.CompositeGraphicUnit = unit
|
||||||
|
if !node.boundaryInCTM {
|
||||||
|
next = compositeBoundary(next, node.parent, false)
|
||||||
|
}
|
||||||
|
metadata = meta
|
||||||
|
return node.update(next)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member, err := e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, member.Object, metadata, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetRichText 读取完整路径文字的富文本模型,普通文字仅在可验证布局时提升
|
||||||
|
// 入参: page 页面索引, path 完整对象路径
|
||||||
|
// 返回: RichText 独立模型, error 路径或不安全提升错误
|
||||||
|
func (e *Editor) TargetRichText(page int, path ObjectPath) (RichText, error) {
|
||||||
|
value, _, _, err := e.PrepareRichText(page, path, false)
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareRichText 读取独立富文本模型和局部编辑框,定位文字采用自然宽度,已知段落保留宽度
|
||||||
|
// 显式重排保留原首行起点及页面变换,不从原始Boundary推断段落宽度
|
||||||
|
// 入参: page 页面索引, path 完整对象路径, reflow 是否允许重排普通文字
|
||||||
|
// 返回: RichText 内容, Box 局部编辑框, Matrix 页面变换, error 路径、外观或排版错误
|
||||||
|
func (e *Editor) PrepareRichText(page int, path ObjectPath, reflow bool) (RichText, Box, Matrix, error) {
|
||||||
|
var text TextObject
|
||||||
|
var matrix Matrix
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||||
|
}
|
||||||
|
object, err := e.Object(page, path.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
if object.Type != "TextObject" {
|
||||||
|
value, err := e.RichText(page, path.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
frame, matrix, err := e.RichTextFrame(page, path.ID)
|
||||||
|
return value, frame, matrix, err
|
||||||
|
}
|
||||||
|
if origin := e.objectOrigin(path.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||||
|
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||||
|
}
|
||||||
|
layer, _, err := e.findObject(page, path.ID)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
text = object.TextObject
|
||||||
|
box, err := creationBox(text.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
matrix = TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(text.CTM))
|
||||||
|
} else {
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
member, err := e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
if member.Object.Type != "TextObject" {
|
||||||
|
return e.CompositeRichText(page, member)
|
||||||
|
}
|
||||||
|
text, matrix = member.Object.TextObject, member.Matrix
|
||||||
|
}
|
||||||
|
frame, err := text.TextFrame()
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
dx, dy, err := e.richPlainOffset(text)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
autoWidth := text.layout == nil || text.layout.autoWidth
|
||||||
|
if reflow {
|
||||||
|
value, layout := text.TextLayout()
|
||||||
|
layout.AutoBidi = true
|
||||||
|
if err := e.LayoutText(&text, value, layout); err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, err := e.promoteTextValue(text)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
value.AutoWidth = autoWidth
|
||||||
|
if autoWidth {
|
||||||
|
result, err := e.layoutRichText(value, frame.W)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
frame.W, frame.H = result.width, result.bounds.Y+result.bounds.H
|
||||||
|
}
|
||||||
|
return value, Box{W: frame.W, H: frame.H}, matrix.Multiply(TranslationMatrix(dx, dy)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReflowRichText 原子重排完整路径文字,保留局部宽度、页面变换及共享实例隔离
|
||||||
|
// 入参: page 页面索引, path 完整对象路径, value 新的富文本内容
|
||||||
|
// 返回: error 路径、排版或提交错误
|
||||||
|
func (e *Editor) ReflowRichText(page int, path ObjectPath, value RichText) error {
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
return ErrNotRichText
|
||||||
|
}
|
||||||
|
return e.updateRichTextFrame(page, path.ID, value, 0, 0, true)
|
||||||
|
}
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
return e.updateCompositeRichText(page, path, index, value, 0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LayoutTextFrame 统一调整普通文字及富文本的段落选项和局部宽度,保留原首行起点与页面变换
|
||||||
|
// 对齐应用于所有段落;定位文字沿用自然宽度,显式改宽后采用固定宽度,提交一条撤销记录
|
||||||
|
// 入参: page 页面索引, path 完整对象路径, options 段落选项, offset 左边界局部偏移, width 新宽度,零值保留宽度模式
|
||||||
|
// 返回: error 路径、尺寸或排版错误
|
||||||
|
func (e *Editor) LayoutTextFrame(page int, path ObjectPath, options TextLayout, offset, width float64) error {
|
||||||
|
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||||
|
return fmt.Errorf("invalid text frame")
|
||||||
|
}
|
||||||
|
value, _, _, err := e.PrepareRichText(page, path, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value.Layout, value.Paragraphs = options, nil
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
return e.updateRichTextFrame(page, path.ID, value, offset, width, true)
|
||||||
|
}
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
return e.updateCompositeRichText(page, path, index, value, offset, width, true)
|
||||||
|
}
|
||||||
+790
-2
@@ -15,18 +15,29 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ObjectSelection 保存正文与注解的独立快照,共享当前编辑器的不可变资源
|
// ObjectSelection 保存正文、注解及组合成员的独立快照,共享当前编辑器的不可变资源
|
||||||
// 来源修改、删除或撤销不改变快照,不用于跨文档传输
|
// 来源修改、删除或撤销不改变快照,不用于跨文档传输
|
||||||
type ObjectSelection struct {
|
type ObjectSelection struct {
|
||||||
editor *Editor
|
editor *Editor
|
||||||
document editorDocumentIdentity
|
document editorDocumentIdentity
|
||||||
objects []GraphicObject
|
objects []GraphicObject
|
||||||
annotations *AnnotationSelection
|
annotations *AnnotationSelection
|
||||||
|
parts []editorSelectionPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorSelectionPart 保存按绘制顺序排列的顶层或内部对象快照
|
||||||
|
type editorSelectionPart struct {
|
||||||
|
objects []GraphicObject
|
||||||
|
composite *CompositeSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectionBounds 获取正文与注解混合选区的实际范围,复用同一次度量的资源缓存
|
// SelectionBounds 获取正文与注解混合选区的实际范围,复用同一次度量的资源缓存
|
||||||
@@ -400,6 +411,18 @@ func (e *Editor) PasteSelection(page int, selection *ObjectSelection, dx, dy flo
|
|||||||
if err == nil && selection.annotations != nil {
|
if err == nil && selection.annotations != nil {
|
||||||
annotations, err = edit.PasteAnnotations(page, selection.annotations, dx, dy)
|
annotations, err = edit.PasteAnnotations(page, selection.annotations, dx, dy)
|
||||||
}
|
}
|
||||||
|
for _, part := range selection.parts {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
if part.composite != nil {
|
||||||
|
ids, err = edit.PasteCompositeSelection(page, part.composite, dx, dy)
|
||||||
|
} else {
|
||||||
|
ids, err = edit.CopyObjects(page, part.objects, dx, dy)
|
||||||
|
}
|
||||||
|
objects = append(objects, ids...)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -418,5 +441,770 @@ func (e *Editor) PasteSelectionToComposite(page int, path ObjectPath, selection
|
|||||||
if selection.annotations != nil {
|
if selection.annotations != nil {
|
||||||
return nil, fmt.Errorf("annotations cannot be nested in a page object")
|
return nil, fmt.Errorf("annotations cannot be nested in a page object")
|
||||||
}
|
}
|
||||||
return e.CopyObjectsToComposite(page, path, selection.objects, dx, dy)
|
var indexes []int
|
||||||
|
err := e.Transaction(func(edit *Editor) error {
|
||||||
|
var err error
|
||||||
|
indexes, err = edit.CopyObjectsToComposite(page, path, selection.objects, dx, dy)
|
||||||
|
for _, part := range selection.parts {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var next []int
|
||||||
|
if part.composite != nil {
|
||||||
|
next, err = edit.PasteCompositeObjects(page, path, part.composite, dx, dy)
|
||||||
|
} else {
|
||||||
|
next, err = edit.CopyObjectsToComposite(page, path, part.objects, dx, dy)
|
||||||
|
}
|
||||||
|
indexes = append(indexes, next...)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return indexes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorTargetGroup 保存同一父路径的直接成员,结构操作按逆序处理父路径
|
||||||
|
type editorTargetGroup struct {
|
||||||
|
path ObjectPath
|
||||||
|
indexes []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorTargetGroups 分离顶层与内部选区,拒绝重复或相互包含的路径
|
||||||
|
// 入参: paths 完整对象路径
|
||||||
|
// 返回: []string 正文标识, []string 注解标识, []editorTargetGroup 内部成员, error 路径错误
|
||||||
|
func editorTargetGroups(paths []ObjectPath) ([]string, []string, []editorTargetGroup, error) {
|
||||||
|
var objects, annotations []string
|
||||||
|
var groups []editorTargetGroup
|
||||||
|
for i, path := range paths {
|
||||||
|
if (path.ID == "") == (path.Annotation == "") || slices.ContainsFunc(path.Children, func(index int) bool { return index < 0 }) {
|
||||||
|
return nil, nil, nil, fmt.Errorf("invalid object path")
|
||||||
|
}
|
||||||
|
for _, other := range paths[:i] {
|
||||||
|
length := min(len(path.Children), len(other.Children))
|
||||||
|
if path.ID == other.ID && path.Annotation == other.Annotation && slices.Equal(path.Children[:length], other.Children[:length]) {
|
||||||
|
return nil, nil, nil, fmt.Errorf("selection paths overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
annotations = append(annotations, path.Annotation)
|
||||||
|
} else {
|
||||||
|
objects = append(objects, path.ID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = slices.Clone(path.Children[:len(path.Children)-1])
|
||||||
|
group := slices.IndexFunc(groups, func(group editorTargetGroup) bool {
|
||||||
|
return path.ID == group.path.ID && path.Annotation == group.path.Annotation && slices.Equal(path.Children, group.path.Children)
|
||||||
|
})
|
||||||
|
if group < 0 {
|
||||||
|
groups = append(groups, editorTargetGroup{path: path, indexes: []int{index}})
|
||||||
|
} else {
|
||||||
|
groups[group].indexes = append(groups[group].indexes, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(groups, func(a, b editorTargetGroup) int {
|
||||||
|
if order := cmp.Compare(a.path.ID, b.path.ID); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
if order := cmp.Compare(a.path.Annotation, b.path.Annotation); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
return -slices.Compare(a.path.Children, b.path.Children)
|
||||||
|
})
|
||||||
|
return objects, annotations, groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// editTargets 原子处理跨容器选区,结构变化从后向前避免路径位移
|
||||||
|
// 入参: paths 完整对象路径, root 顶层处理, member 内部处理
|
||||||
|
// 返回: error 路径或编辑错误
|
||||||
|
func (e *Editor) editTargets(paths []ObjectPath, root func([]string, []string) error, member func(ObjectPath, []int) error) error {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.Transaction(func(*Editor) error {
|
||||||
|
for _, group := range groups {
|
||||||
|
if err := member(group.path, group.indexes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(objects)+len(annotations) != 0 {
|
||||||
|
return root(objects, annotations)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureTargets 捕获跨容器选区,内部快照保留源变换与裁剪,源删除不影响粘贴
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径
|
||||||
|
// 返回: *ObjectSelection 独立快照, error 路径或资源错误
|
||||||
|
func (e *Editor) CaptureTargets(page int, paths []ObjectPath) (*ObjectSelection, error) {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return e.CaptureSelection(page, objects, annotations)
|
||||||
|
}
|
||||||
|
selection, err := e.CaptureSelection(page, nil, annotations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content, err := e.page(page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
positions := e.objectPositions(content)
|
||||||
|
paths = slices.Clone(paths)
|
||||||
|
slices.SortStableFunc(paths, func(a, b ObjectPath) int {
|
||||||
|
if a.ID != b.ID {
|
||||||
|
if a.ID == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if b.ID == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return compareEditorPosition(positions[a.ID], positions[b.ID])
|
||||||
|
}
|
||||||
|
if a.Annotation != b.Annotation {
|
||||||
|
return cmp.Compare(a.Annotation, b.Annotation)
|
||||||
|
}
|
||||||
|
return slices.Compare(a.Children, b.Children)
|
||||||
|
})
|
||||||
|
for _, path := range paths {
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items, err := e.Objects(page, []string{path.ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selection.parts = append(selection.parts, editorSelectionPart{objects: items})
|
||||||
|
} else {
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
composite, err := e.CaptureCompositeObjects(page, path, []int{index})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selection.parts = append(selection.parts, editorSelectionPart{composite: composite})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selection, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransformTargets 按页面坐标原子变换跨容器选区,保留组合结构
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, matrix 有限可逆变换
|
||||||
|
// 返回: error 路径、变换或编辑错误
|
||||||
|
func (e *Editor) TransformTargets(page int, paths []ObjectPath, matrix Matrix) error {
|
||||||
|
if _, ok := matrix.Invert(); !ok {
|
||||||
|
return fmt.Errorf("selection transform must be finite and invertible")
|
||||||
|
}
|
||||||
|
return e.editTargets(paths, func(objects, annotations []string) error {
|
||||||
|
return e.TransformSelection(page, objects, annotations, matrix)
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return matrix })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTargets 原子删除跨容器选区,一次撤销恢复全部
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径
|
||||||
|
// 返回: error 路径或编辑错误
|
||||||
|
func (e *Editor) DeleteTargets(page int, paths []ObjectPath) error {
|
||||||
|
return e.editTargets(paths, func(objects, annotations []string) error {
|
||||||
|
return e.DeleteSelection(page, objects, annotations)
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.DeleteCompositeObjects(page, path, indexes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StyleTargets 原子设置跨容器选区样式,一次撤销恢复全部
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, style 样式增量
|
||||||
|
// 返回: error 路径或编辑错误
|
||||||
|
func (e *Editor) StyleTargets(page int, paths []ObjectPath, style ObjectStyle) error {
|
||||||
|
return e.editTargets(paths, func(objects, annotations []string) error {
|
||||||
|
return e.StyleSelection(page, objects, annotations, style)
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.StyleCompositeObjects(page, path, indexes, style)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EraseTargets 按页面矩形原子擦除跨容器选区,一次撤销恢复全部
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, box 页面毫米范围
|
||||||
|
// 返回: error 路径或编辑错误
|
||||||
|
func (e *Editor) EraseTargets(page int, paths []ObjectPath, box Box) error {
|
||||||
|
return e.editTargets(paths, func(objects, annotations []string) error {
|
||||||
|
return e.EraseSelection(page, objects, annotations, box)
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.EraseCompositeObjects(page, path, indexes, box)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EraseTargetsPath 按页面多边形原子擦除跨容器选区,一次撤销恢复全部
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, points 页面毫米多边形
|
||||||
|
// 返回: error 路径或编辑错误
|
||||||
|
func (e *Editor) EraseTargetsPath(page int, paths []ObjectPath, points []Point) error {
|
||||||
|
return e.editTargets(paths, func(objects, annotations []string) error {
|
||||||
|
return e.EraseSelectionPath(page, objects, annotations, points)
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.EraseCompositeObjectsPath(page, path, indexes, points)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyTargetsStyle 原子复制样式至跨容器选区,保留各对象内容及位置
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, source 样式来源
|
||||||
|
// 返回: error 路径或样式错误
|
||||||
|
func (e *Editor) CopyTargetsStyle(page int, paths []ObjectPath, source GraphicObject) error {
|
||||||
|
return e.editTargets(paths, func(objects, annotations []string) error {
|
||||||
|
if err := e.CopyStyle(page, objects, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.editSelectionAnnotations(page, annotations, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.CopyCompositeStyle(page, path, indexes, source)
|
||||||
|
})
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
return e.CopyCompositeStyle(page, path, indexes, source)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetGeometry 度量跨容器选区,保留输入顺序及独立宽高能力
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径
|
||||||
|
// 返回: []Box 对象范围, bool 可独立缩放宽高, Box 页面范围, error 度量错误
|
||||||
|
func (e *Editor) targetGeometry(page int, paths []ObjectPath) ([]Box, bool, Box, error) {
|
||||||
|
if _, _, _, err := editorTargetGroups(paths); err != nil {
|
||||||
|
return nil, false, Box{}, err
|
||||||
|
}
|
||||||
|
_, _, paper, err := e.selectionGeometry(page, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, Box{}, err
|
||||||
|
}
|
||||||
|
boxes := make([]Box, len(paths))
|
||||||
|
stretch := true
|
||||||
|
for i, path := range paths {
|
||||||
|
if len(path.Children) != 0 {
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
member, err := e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, Box{}, err
|
||||||
|
}
|
||||||
|
boxes[i] = member.Bounds
|
||||||
|
stretch = stretch && member.Capabilities.Stretch
|
||||||
|
} else {
|
||||||
|
var objects, annotations []string
|
||||||
|
if path.Annotation != "" {
|
||||||
|
annotations = []string{path.Annotation}
|
||||||
|
} else {
|
||||||
|
objects = []string{path.ID}
|
||||||
|
capability, err := e.ObjectCapabilities(page, path.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, Box{}, err
|
||||||
|
}
|
||||||
|
stretch = stretch && capability.Stretch
|
||||||
|
}
|
||||||
|
boxes[i], err = e.SelectionBounds(page, objects, annotations)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, Box{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return boxes, stretch, paper, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetsBounds 获取跨容器选区的共同可见范围
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径
|
||||||
|
// 返回: Box 页面毫米范围, error 路径或度量错误
|
||||||
|
func (e *Editor) TargetsBounds(page int, paths []ObjectPath) (Box, error) {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return e.SelectionBounds(page, objects, annotations)
|
||||||
|
}
|
||||||
|
boxes, _, _, err := e.targetGeometry(page, paths)
|
||||||
|
if err != nil {
|
||||||
|
return Box{}, err
|
||||||
|
}
|
||||||
|
var bounds Box
|
||||||
|
for _, box := range boxes {
|
||||||
|
bounds = unionTextBox(bounds, box)
|
||||||
|
}
|
||||||
|
if bounds.W <= 0 || bounds.H <= 0 {
|
||||||
|
return Box{}, fmt.Errorf("selection has no visible bounds")
|
||||||
|
}
|
||||||
|
return bounds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// orientTargets 绕跨容器选区中心应用旋转或镜像
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, matrix 方向变换
|
||||||
|
// 返回: error 路径或变换错误
|
||||||
|
func (e *Editor) orientTargets(page int, paths []ObjectPath, matrix Matrix) error {
|
||||||
|
bounds, err := e.TargetsBounds(page, paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
x, y := bounds.X+bounds.W/2, bounds.Y+bounds.H/2
|
||||||
|
return e.TransformTargets(page, paths, TranslationMatrix(x, y).Multiply(matrix).Multiply(TranslationMatrix(-x, -y)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateTargets 绕共同中心旋转跨容器选区
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, degrees 顺时针直角角度
|
||||||
|
// 返回: error 路径或变换错误
|
||||||
|
func (e *Editor) RotateTargets(page int, paths []ObjectPath, degrees int) error {
|
||||||
|
matrix, err := editorRotation(degrees)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.orientTargets(page, paths, matrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlipTargets 绕共同中心镜像跨容器选区
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, axis 为horizontal或vertical
|
||||||
|
// 返回: error 路径或变换错误
|
||||||
|
func (e *Editor) FlipTargets(page int, paths []ObjectPath, axis string) error {
|
||||||
|
matrix, err := editorFlip(axis)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.orientTargets(page, paths, matrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeTargets 按共同范围调整跨容器选区,不重排文字
|
||||||
|
// 入参: page 页面索引, paths 完整对象路径, box 页面毫米目标范围
|
||||||
|
// 返回: error 路径、范围或编辑错误
|
||||||
|
func (e *Editor) ResizeTargets(page int, paths []ObjectPath, box Box) error {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return e.ResizeSelection(page, objects, annotations, box)
|
||||||
|
}
|
||||||
|
if err := validateCreationBox(box); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
boxes, stretch, _, err := e.targetGeometry(page, paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var bounds Box
|
||||||
|
for _, member := range boxes {
|
||||||
|
bounds = unionTextBox(bounds, member)
|
||||||
|
}
|
||||||
|
if bounds.W <= 0 || bounds.H <= 0 {
|
||||||
|
return fmt.Errorf("selection has no visible bounds")
|
||||||
|
}
|
||||||
|
sx, sy := box.W/bounds.W, box.H/bounds.H
|
||||||
|
if math.Abs(sx-sy) > 1e-9*math.Max(sx, sy) {
|
||||||
|
if !stretch {
|
||||||
|
return fmt.Errorf("nonuniform resizing requires images or image-only groups")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sy = sx
|
||||||
|
}
|
||||||
|
return e.TransformTargets(page, paths, Matrix{a: sx, d: sy, e: box.X - bounds.X*sx, f: box.Y - bounds.Y*sy})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistributionAxes 获取选区可保持位置顺序的等间距分布方向,不修改文档或历史
|
||||||
|
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径
|
||||||
|
// 返回: []string 可用的horizontal和vertical方向, error 路径或度量错误
|
||||||
|
func (e *Editor) DistributionAxes(page int, paths []ObjectPath) ([]string, error) {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var boxes []Box
|
||||||
|
var positions []editorObjectPosition
|
||||||
|
if len(groups) == 0 {
|
||||||
|
boxes, positions, _, err = e.selectionGeometry(page, objects, annotations)
|
||||||
|
} else {
|
||||||
|
boxes, _, _, err = e.targetGeometry(page, paths)
|
||||||
|
positions = make([]editorObjectPosition, len(paths))
|
||||||
|
for i := range positions {
|
||||||
|
positions[i].index = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
axes := []string{}
|
||||||
|
if len(boxes) < 3 {
|
||||||
|
return axes, nil
|
||||||
|
}
|
||||||
|
for _, axis := range []string{"horizontal", "vertical"} {
|
||||||
|
if _, err := editorDistribution(boxes, positions, axis); err == nil {
|
||||||
|
axes = append(axes, axis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return axes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrangeTargets 对齐或等距分布跨容器选区,单选对齐页面
|
||||||
|
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径, distribute 是否分布, direction 对齐方向或分布轴
|
||||||
|
// 返回: error 路径、方向或编辑错误
|
||||||
|
func (e *Editor) ArrangeTargets(page int, paths []ObjectPath, distribute bool, direction string) error {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
if distribute {
|
||||||
|
return e.DistributeSelection(page, objects, annotations, direction)
|
||||||
|
}
|
||||||
|
return e.AlignSelection(page, objects, annotations, direction)
|
||||||
|
}
|
||||||
|
if distribute && direction != "horizontal" && direction != "vertical" || !distribute && !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, direction) {
|
||||||
|
return fmt.Errorf("invalid arrangement direction %q", direction)
|
||||||
|
}
|
||||||
|
boxes, _, target, err := e.targetGeometry(page, paths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matrices := make([]Matrix, len(paths))
|
||||||
|
if distribute {
|
||||||
|
positions := make([]editorObjectPosition, len(paths))
|
||||||
|
for i := range positions {
|
||||||
|
positions[i].index = i
|
||||||
|
}
|
||||||
|
matrices, err = editorDistribution(boxes, positions, direction)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(paths) > 1 {
|
||||||
|
target = Box{}
|
||||||
|
for _, box := range boxes {
|
||||||
|
target = unionTextBox(target, box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, box := range boxes {
|
||||||
|
matrices[i] = editorAlignment(box, target, direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.Transaction(func(edit *Editor) error {
|
||||||
|
for i, matrix := range matrices {
|
||||||
|
if matrix != IdentityMatrix {
|
||||||
|
if err := edit.TransformTargets(page, paths[i:i+1], matrix); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StyleTargetsText 原子修改跨容器文字样式,保留各自定位及组合结构
|
||||||
|
// 入参: page 页面索引, paths 完整文字路径, style 字体、字号或颜色增量
|
||||||
|
// 返回: error 路径、文字或样式错误
|
||||||
|
func (e *Editor) StyleTargetsText(page int, paths []ObjectPath, style TextStyle) error {
|
||||||
|
patch := TextFormatPatch{}
|
||||||
|
if style.Font != "" {
|
||||||
|
patch.Font = &style.Font
|
||||||
|
}
|
||||||
|
if style.Size != 0 {
|
||||||
|
patch.Size = &style.Size
|
||||||
|
}
|
||||||
|
if style.Color != "" {
|
||||||
|
patch.Color = &style.Color
|
||||||
|
}
|
||||||
|
return e.formatTargetsText(page, paths, patch, style.Reflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTargetsText 原子修改普通文字和富文本的指定格式,保留未指定的混排属性
|
||||||
|
// 入参: page 页面索引, paths 完整文字路径, patch 格式增量
|
||||||
|
// 返回: error 路径、格式或不安全重排错误
|
||||||
|
func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextFormatPatch) error {
|
||||||
|
return e.formatTargetsText(page, paths, patch, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextFontStyle 标识文字引用的字体资源及显式粗斜体样式
|
||||||
|
type TextFontStyle struct {
|
||||||
|
Font string `json:"font"`
|
||||||
|
Bold bool `json:"bold"`
|
||||||
|
Italic bool `json:"italic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextFontChange 描述一种原字体款式到目标字体款式的替换
|
||||||
|
type TextFontChange struct {
|
||||||
|
From TextFontStyle `json:"from"`
|
||||||
|
To TextFontStyle `json:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StyleTargetsFonts 原子替换跨容器文字的各款式,保留混排属性及对象变换
|
||||||
|
// 目标字体须已注册,可用Transaction将AddFont与本操作合并为一条历史记录
|
||||||
|
// 入参: page 页面索引, paths 完整文字路径, changes 字体款式映射, reflow 允许显式重排
|
||||||
|
// 返回: error 字体、路径或排版错误,失败时回滚全部修改
|
||||||
|
func (e *Editor) StyleTargetsFonts(page int, paths []ObjectPath, changes []TextFontChange, reflow bool) error {
|
||||||
|
mapping := make(map[TextFontStyle]TextFontStyle, len(changes))
|
||||||
|
for _, change := range changes {
|
||||||
|
if change.To.Font == "" {
|
||||||
|
return fmt.Errorf("text requires a font")
|
||||||
|
}
|
||||||
|
mapping[change.From] = change.To
|
||||||
|
}
|
||||||
|
return e.Transaction(func(e *Editor) error {
|
||||||
|
for _, path := range paths {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
return ErrNotRichText
|
||||||
|
}
|
||||||
|
var object GraphicObject
|
||||||
|
var err error
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
object, err = e.Object(page, path.ID)
|
||||||
|
} else {
|
||||||
|
parent := path
|
||||||
|
parent.Children = path.Children[:len(path.Children)-1]
|
||||||
|
var members []CompositeMember
|
||||||
|
members, err = e.CompositeObjectsAt(page, parent, path.Children[len(path.Children)-1:])
|
||||||
|
if err == nil {
|
||||||
|
object = members[0].Object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if object.Type == "TextObject" && !reflow {
|
||||||
|
text := object.TextObject
|
||||||
|
from := TextFontStyle{Font: text.Font, Bold: text.Weight >= 700, Italic: text.Italic}
|
||||||
|
to, ok := mapping[from]
|
||||||
|
if !ok || to == from {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if to.Bold == from.Bold && to.Italic == from.Italic {
|
||||||
|
if err := e.StyleTargetsText(page, []ObjectPath{path}, TextStyle{Font: to.Font}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, _, _, err := e.PrepareRichText(page, path, reflow)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for index := range value.Runs {
|
||||||
|
format := &value.Runs[index].Format
|
||||||
|
from := TextFontStyle{Font: format.Font, Bold: format.Bold, Italic: format.Italic}
|
||||||
|
if to, ok := mapping[from]; ok && to != from {
|
||||||
|
format.Font, format.Bold, format.Italic = to.Font, to.Bold, to.Italic
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if reflow {
|
||||||
|
err = e.ReflowRichText(page, path, value)
|
||||||
|
} else {
|
||||||
|
err = e.UpdateTargetRichText(page, path, value, 0, 0)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReflowTargetsText 显式重排跨容器文字并应用格式增量,保留对象位置及组合结构
|
||||||
|
// 入参: page 页面索引, paths 完整文字路径, patch 格式增量
|
||||||
|
// 返回: error 路径、格式或排版错误
|
||||||
|
func (e *Editor) ReflowTargetsText(page int, paths []ObjectPath, patch TextFormatPatch) error {
|
||||||
|
return e.formatTargetsText(page, paths, patch, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LayoutTargetsText 原子修改跨容器文字的段落增量,保留各自宽度、变换及未指定的样式
|
||||||
|
// 定位文字显式重排为富文本,任一对象失败时回滚全部修改及新增资源
|
||||||
|
// 入参: page 页面索引, paths 完整文字路径, patch 段落增量, wrap 折行选项,nil保持原值
|
||||||
|
// 返回: error 路径、段落或排版错误
|
||||||
|
func (e *Editor) LayoutTargetsText(page int, paths []ObjectPath, patch RichTextParagraph, wrap *bool) error {
|
||||||
|
if patch == (RichTextParagraph{}) && wrap == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
apply := func(path ObjectPath) error {
|
||||||
|
value, _, _, err := e.PrepareRichText(page, path, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wrap != nil {
|
||||||
|
value.Layout.Wrap = *wrap
|
||||||
|
}
|
||||||
|
if err := value.FormatParagraphs(0, value.Len(), patch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(value.Text(), "\n") {
|
||||||
|
if err := value.FormatParagraphs(value.Len(), value.Len(), patch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.ReflowRichText(page, path, value)
|
||||||
|
}
|
||||||
|
return e.editTargets(paths, func(ids, annotations []string) error {
|
||||||
|
if len(annotations) != 0 {
|
||||||
|
return ErrNotRichText
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := apply(ObjectPath{ID: id}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
for _, index := range indexes {
|
||||||
|
target := path
|
||||||
|
target.Children = append(slices.Clone(path.Children), index)
|
||||||
|
if err := apply(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTargetsText 统一文字选区格式事务,普通字体和颜色修改沿用定位保留路径
|
||||||
|
// 入参: page 页面索引, paths 完整路径, patch 格式增量, reflow 显式重排
|
||||||
|
// 返回: error 校验或编辑错误
|
||||||
|
func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextFormatPatch, reflow bool) error {
|
||||||
|
style := TextStyle{Reflow: reflow}
|
||||||
|
if patch.Font != nil {
|
||||||
|
if strings.TrimSpace(*patch.Font) == "" {
|
||||||
|
return fmt.Errorf("text requires a font")
|
||||||
|
}
|
||||||
|
style.Font = *patch.Font
|
||||||
|
}
|
||||||
|
if patch.Size != nil {
|
||||||
|
if !finite(*patch.Size) || *patch.Size <= 0 {
|
||||||
|
return fmt.Errorf("invalid text size")
|
||||||
|
}
|
||||||
|
style.Size = *patch.Size
|
||||||
|
}
|
||||||
|
if patch.Color != nil {
|
||||||
|
style.Color = *patch.Color
|
||||||
|
if style.Color == "" {
|
||||||
|
style.Color = "0 0 0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decorated := patch.Bold != nil || patch.Italic != nil || patch.Underline != nil || patch.Strikeout != nil || patch.Shadow != nil || patch.Background != nil
|
||||||
|
return e.editTargets(paths, func(ids, annotations []string) error {
|
||||||
|
if len(annotations) != 0 {
|
||||||
|
return fmt.Errorf("annotation is not a text object")
|
||||||
|
}
|
||||||
|
objects, _, err := e.selectedObjects(page, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var plain []string
|
||||||
|
for _, object := range objects {
|
||||||
|
id := editorObjectID(object)
|
||||||
|
if object.Type == "TextObject" && !decorated {
|
||||||
|
plain = append(plain, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, _, _, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.updateRichTextFrame(page, id, value, 0, 0, reflow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(plain) != 0 {
|
||||||
|
return e.StyleText(page, plain, style)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, func(path ObjectPath, indexes []int) error {
|
||||||
|
members, err := e.CompositeObjectsAt(page, path, indexes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var plain []int
|
||||||
|
for i, member := range members {
|
||||||
|
if member.Object.Type == "TextObject" && !decorated {
|
||||||
|
plain = append(plain, indexes[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := e.formatCompositeText(page, path, indexes[i], patch, reflow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(plain) != 0 {
|
||||||
|
return e.StyleCompositeText(page, path, plain, style)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareTargets 将选区及其关联资源复制为独立单页编辑器,来源关闭后仍可使用
|
||||||
|
// 不修改来源、历史或组合结构,加密来源须显式允许明文导出
|
||||||
|
// 不包含页面模板和签名,指向其他页面的跳转随单页导出移除
|
||||||
|
// 入参: ctx 取消上下文, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
|
||||||
|
// 返回: *Editor 独立选区,可直接导出或继续编辑, error 路径或资源错误
|
||||||
|
func (e *Editor) PrepareTargets(ctx context.Context, page int, paths []ObjectPath, allowDecrypted bool) (*Editor, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.encryption != nil && !allowDecrypted {
|
||||||
|
return nil, ErrEncryptionPolicyRequired
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil, fmt.Errorf("no objects selected")
|
||||||
|
}
|
||||||
|
snapshot := e.transactionSnapshot()
|
||||||
|
snapshot.history, snapshot.historyIndex, snapshot.historyLimit = nil, 0, 0
|
||||||
|
snapshot.transactionChanges = nil
|
||||||
|
for id, origin := range snapshot.origins {
|
||||||
|
if origin != nil && origin.editor == e {
|
||||||
|
copy := *origin
|
||||||
|
copy.editor = &snapshot
|
||||||
|
snapshot.origins[id] = ©
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selection, err := snapshot.CaptureTargets(page, paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content, err := snapshot.page(page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
objects, annotations, err := snapshot.PasteSelection(page, selection, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
box, err := creationBox(content.Area.PhysicalBox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
packet := NewEditor()
|
||||||
|
packet.SetRenderBackends(e.backends)
|
||||||
|
packet.SetResources(e.runtimeResources)
|
||||||
|
packet.SetFontDirs(e.fontDirs...)
|
||||||
|
packet.SetFontFS(e.fontFS...)
|
||||||
|
packet.OnWriteProgress = e.OnWriteProgress
|
||||||
|
if _, err := packet.AddPage(box.W, box.H); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
packet.pages[0].Area = content.Area
|
||||||
|
if _, _, err := packet.importSelection(ctx, &snapshot, page, objects, annotations, 0, ObjectImportOptions{AllowDecrypted: true, OnProgress: e.OnWriteProgress}, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
packet.history, packet.historyIndex = nil, 0
|
||||||
|
return packet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTargetsTo 将选区及关联资源写成独立单页OFD,不修改来源与历史
|
||||||
|
// 入参: ctx 取消上下文, writer 输出流, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
|
||||||
|
// 返回: int64 写入字节数, error 路径、资源或写入错误
|
||||||
|
func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int, paths []ObjectPath, allowDecrypted bool) (int64, error) {
|
||||||
|
packet, err := e.PrepareTargets(ctx, page, paths, allowDecrypted)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return packet.WriteToWithOptions(ctx, writer, WriteOptions{})
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -337,7 +338,7 @@ func editorStructureIndexContext(ctx context.Context, base *editorSource, parts
|
|||||||
if base.document.CustomTags.Path == "" {
|
if base.document.CustomTags.Path == "" {
|
||||||
container := root.child("CustomTags")
|
container := root.child("CustomTags")
|
||||||
if container == nil || len(container.children) == 0 {
|
if container == nil || len(container.children) == 0 {
|
||||||
name = packageAvailableName(base.reader, nil, base.reader.RootDir+"/CustomTags.xml")
|
name = packageAvailableName(base.reader, nil, path.Join(base.reader.RootDir, "CustomTags.xml"))
|
||||||
data, err = root.containerXML("CustomTags", nil, nil)
|
data, err = root.containerXML("CustomTags", nil, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
root, err = parseEditorXMLContext(ctx, data)
|
root, err = parseEditorXMLContext(ctx, data)
|
||||||
|
|||||||
+69
-9
@@ -15,6 +15,9 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -34,8 +37,8 @@ type MissingGlyphError struct {
|
|||||||
|
|
||||||
// TextLayout 本地横向段落选项
|
// TextLayout 本地横向段落选项
|
||||||
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
|
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
|
||||||
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留
|
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随排版元数据保存
|
||||||
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed
|
||||||
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
||||||
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
|
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
|
||||||
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
|
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
|
||||||
@@ -63,10 +66,50 @@ type TextStyle struct {
|
|||||||
Reflow bool
|
Reflow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染
|
// textLayout 保存可恢复的原文与排版选项,不参与阅读渲染
|
||||||
type textLayout struct {
|
type textLayout struct {
|
||||||
value string
|
value string
|
||||||
options TextLayout
|
options TextLayout
|
||||||
|
autoWidth bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// textNamespace 标识普通文字编辑语义的命名空间,版本仅随不兼容的语义变更升级
|
||||||
|
const textNamespace = "https://ofdgo.aite.me/ns/text/1"
|
||||||
|
|
||||||
|
// textMetadataAttribute 判断无资源引用的普通文字编辑元数据属性
|
||||||
|
// 入参: node 所属节点, attr 属性
|
||||||
|
// 返回: bool 是否为本库普通文字元数据
|
||||||
|
func textMetadataAttribute(node xml.Name, attr xml.Attr) bool {
|
||||||
|
return classifyOFDNamespace(node.Space) != ofdXMLUnknown && (node.Local == "TextObject" || node.Local == "Text") && attr.Name.Space == textNamespace && attr.Name.Local == "Text"
|
||||||
|
}
|
||||||
|
|
||||||
|
// textMetadata 保存原文、选项与标准定位摘要
|
||||||
|
type textMetadata struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Layout TextLayout `json:"layout"`
|
||||||
|
AutoWidth bool `json:"autoWidth"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// textDigest 计算与字体编号无关的文字定位摘要
|
||||||
|
// 返回: string 摘要
|
||||||
|
func (obj TextObject) textDigest() string {
|
||||||
|
codes := append([]TextCode(nil), obj.TextCode...)
|
||||||
|
for i := range codes {
|
||||||
|
codes[i].Value = string(textCodeRunes(codes[i].Value))
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(codes)
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// textMetadata 编码普通文字的排版语义,与标准定位一起保存
|
||||||
|
// 返回: string 元数据, error 编码错误
|
||||||
|
func (obj TextObject) textMetadata() (string, error) {
|
||||||
|
if obj.layout == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(textMetadata{Text: obj.layout.value, Layout: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.textDigest()})
|
||||||
|
return string(data), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error 返回缺失字符的Unicode码点
|
// Error 返回缺失字符的Unicode码点
|
||||||
@@ -106,7 +149,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) (err error)
|
|||||||
return e.UpdateObjects(page, updates)
|
return e.UpdateObjects(page, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按会话选项重排
|
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按排版选项重排
|
||||||
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
|
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
|
||||||
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
|
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -187,7 +230,7 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle)
|
|||||||
|
|
||||||
// RewriteText 保留原文的字形定位,不修改对象样式或文档
|
// RewriteText 保留原文的字形定位,不修改对象样式或文档
|
||||||
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
|
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
|
||||||
// 方向、缩放和修饰原样保留;复杂布局仅支持等长替换,合并字形需显式重排
|
// 方向、缩放和修饰原样保留;单段纵排沿用现有纵向步进,其他复杂布局仅支持等长替换,合并字形需显式重排
|
||||||
// 首段缺失坐标仅在临时校验中恢复,不改写源坐标
|
// 首段缺失坐标仅在临时校验中恢复,不改写源坐标
|
||||||
// 入参: obj 原文字对象, value 新内容
|
// 入参: obj 原文字对象, value 新内容
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -210,7 +253,8 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
old, next := []rune(obj.Text()), []rune(value)
|
old, next := []rune(obj.Text()), []rune(value)
|
||||||
complex := obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != ""
|
vertical := (obj.ReadDirection == 90 || obj.ReadDirection == 270) && obj.CharDirection == 0 && len(obj.TextCode) == 1 && obj.TextCode[0].DeltaY != ""
|
||||||
|
complex := obj.ReadDirection != 0 && !vertical || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != ""
|
||||||
if complex && len(old) != len(next) {
|
if complex && len(old) != len(next) {
|
||||||
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")}
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")}
|
||||||
}
|
}
|
||||||
@@ -279,7 +323,12 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
|
|||||||
if obj.HScale != 0 {
|
if obj.HScale != 0 {
|
||||||
unit *= obj.HScale
|
unit *= obj.HScale
|
||||||
}
|
}
|
||||||
advance := func(char rune) float64 { return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit }
|
advance := func(char rune) float64 {
|
||||||
|
if vertical {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit
|
||||||
|
}
|
||||||
if codes[i].DeltaX != "" {
|
if codes[i].DeltaX != "" {
|
||||||
codes[i].DeltaX = rewriteTextDeltas(codes[i].DeltaX, runes, replacement, from, to, advance)
|
codes[i].DeltaX = rewriteTextDeltas(codes[i].DeltaX, runes, replacement, from, to, advance)
|
||||||
}
|
}
|
||||||
@@ -627,9 +676,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐
|
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐
|
||||||
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距
|
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距, clusters 可选塑形簇起点
|
||||||
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
|
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
|
||||||
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64) (float64, string) {
|
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64, clusters map[int]bool) (float64, string) {
|
||||||
end := len(runes)
|
end := len(runes)
|
||||||
for end > 0 && runes[end-1] == ' ' {
|
for end > 0 && runes[end-1] == ' ' {
|
||||||
end--
|
end--
|
||||||
@@ -659,6 +708,17 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if alignment == "distributed" && extra > 0 {
|
||||||
|
var seg segmenter.Segmenter
|
||||||
|
seg.Init(runes[:end])
|
||||||
|
for it := seg.GraphemeIterator(); it.Next(); {
|
||||||
|
cluster := it.Grapheme()
|
||||||
|
at := cluster.Offset + len(cluster.Text)
|
||||||
|
if at < end && (clusters == nil || clusters[at]) {
|
||||||
|
gaps[at-1] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
deltas := make([]string, max(0, len(runes)-1))
|
deltas := make([]string, max(0, len(runes)-1))
|
||||||
for i := range deltas {
|
for i := range deltas {
|
||||||
advance := advances[i]
|
advance := advances[i]
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
|||||||
}
|
}
|
||||||
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
||||||
}
|
}
|
||||||
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
if err := e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
||||||
for i, node := range nodes {
|
for i, node := range nodes {
|
||||||
if selection.Indexes[i] != first.Index {
|
if selection.Indexes[i] != first.Index {
|
||||||
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
||||||
@@ -838,5 +838,18 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
|||||||
node.setState(editorCompositeState{})
|
node.setState(editorCompositeState{})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
index := first.Index
|
||||||
|
for _, part := range preview.analysis.Parts[1:] {
|
||||||
|
if part.Index < first.Index {
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
member, err := e.CompositeObject(page, *selection.Root, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, member.Object, meta, -1)
|
||||||
}
|
}
|
||||||
+35
-2
@@ -397,6 +397,9 @@ func editorXMLAttributes(node *editorXML, allowed string) bool {
|
|||||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if textMetadataAttribute(node.name, attr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -569,6 +572,15 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes := make(map[string]string)
|
changes := make(map[string]string)
|
||||||
|
textValue := func(n *editorXML) string {
|
||||||
|
for _, attr := range n.attrs {
|
||||||
|
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
|
||||||
|
return attr.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
textChanged := textValue(oldNode) != textValue(newNode)
|
||||||
if legacyClip {
|
if legacyClip {
|
||||||
changes["TransFlag"] = ""
|
changes["TransFlag"] = ""
|
||||||
}
|
}
|
||||||
@@ -578,7 +590,7 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
|
contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
|
||||||
if len(changes) == 0 && !contentChanged && namespace == node.name.Space {
|
if len(changes) == 0 && !textChanged && !contentChanged && namespace == node.name.Space {
|
||||||
return bytes.Clone(data[node.start:node.end]), nil
|
return bytes.Clone(data[node.start:node.end]), nil
|
||||||
}
|
}
|
||||||
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
|
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
|
||||||
@@ -587,8 +599,29 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
start := token.(xml.StartElement)
|
start := token.(xml.StartElement)
|
||||||
|
if textChanged {
|
||||||
|
value, found := textValue(newNode), false
|
||||||
|
attrs := start.Attr[:0]
|
||||||
|
for i, attr := range start.Attr {
|
||||||
|
resolved := node.attrs[i]
|
||||||
|
if resolved.Name.Space == textNamespace && resolved.Name.Local == "Text" {
|
||||||
|
found = true
|
||||||
|
if value != "" {
|
||||||
|
attr.Value = value
|
||||||
|
attrs = append(attrs, attr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attrs = append(attrs, attr)
|
||||||
|
}
|
||||||
|
start.Attr = attrs
|
||||||
|
if !found && value != "" {
|
||||||
|
prefix := editorXMLNamespacePrefix(node, "ofdgo")
|
||||||
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: prefix}, Value: textNamespace}, xml.Attr{Name: xml.Name{Space: prefix, Local: "Text"}, Value: value})
|
||||||
|
}
|
||||||
|
}
|
||||||
if legacyClip || namespace != node.name.Space {
|
if legacyClip || namespace != node.name.Space {
|
||||||
start.Name.Space = editorXMLNamespacePrefix(node)
|
start.Name.Space = editorXMLNamespacePrefix(node, "ofd")
|
||||||
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: start.Name.Space}, Value: namespace})
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: start.Name.Space}, Value: namespace})
|
||||||
}
|
}
|
||||||
var result bytes.Buffer
|
var result bytes.Buffer
|
||||||
|
|||||||
@@ -17,14 +17,112 @@ package ofdgo
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||||
|
// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称
|
||||||
|
// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式
|
||||||
type FontFace = basefont.Face
|
type FontFace = basefont.Face
|
||||||
|
|
||||||
|
// FontFamilyIndexes 按兼容字体族、版本及来源分组,同款式重复时整族保留独立入口
|
||||||
|
// 入参: faces 字体信息, sources 每项的来源标识,空列表表示同一来源
|
||||||
|
// 返回: [][]int 按首次出现顺序分组的候选索引
|
||||||
|
func FontFamilyIndexes(faces []FontFace, sources []string) [][]int {
|
||||||
|
groups := make([][]int, 0, len(faces))
|
||||||
|
keys := make([]string, len(faces))
|
||||||
|
styles := make(map[string]map[[2]bool]bool)
|
||||||
|
ambiguous := make(map[string]bool)
|
||||||
|
for index, face := range faces {
|
||||||
|
key := face.FamilyKey
|
||||||
|
if len(sources) > index {
|
||||||
|
key += "\x00" + sources[index]
|
||||||
|
}
|
||||||
|
keys[index] = key
|
||||||
|
if styles[key] == nil {
|
||||||
|
styles[key] = make(map[[2]bool]bool)
|
||||||
|
}
|
||||||
|
style := [2]bool{face.Bold, face.Italic}
|
||||||
|
ambiguous[key] = ambiguous[key] || styles[key][style]
|
||||||
|
styles[key][style] = true
|
||||||
|
}
|
||||||
|
indexes := make(map[string]int)
|
||||||
|
for index, face := range faces {
|
||||||
|
key := keys[index]
|
||||||
|
if face.FamilyKey == "" || ambiguous[key] {
|
||||||
|
groups = append(groups, []int{index})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group, exists := indexes[key]
|
||||||
|
if !exists {
|
||||||
|
group, indexes[key] = len(groups), len(groups)
|
||||||
|
groups = append(groups, nil)
|
||||||
|
}
|
||||||
|
groups[group] = append(groups[group], index)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontStyleIndex 选择同族候选中最接近的真实款式,允许补充合成样式但不抵消固有样式
|
||||||
|
// 入参: faces 同来源同族候选, bold 粗体, italic 斜体
|
||||||
|
// 返回: int 候选索引,无可用款式时为-1
|
||||||
|
func FontStyleIndex(faces []FontFace, bold, italic bool) int {
|
||||||
|
selected, best := -1, -1
|
||||||
|
for index, face := range faces {
|
||||||
|
if face.Bold && !bold || face.Italic && !italic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
if face.Bold == bold {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if face.Italic == italic {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if score > best {
|
||||||
|
selected, best = index, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontPreferenceOrder 按字体族偏好排列候选索引,同族优先常规样式,其余保持原顺序
|
||||||
|
// 入参: faces 候选字体, families 按优先级排列的字体族别名组
|
||||||
|
// 返回: []int 候选字体的零起始索引,不修改输入列表
|
||||||
|
func FontPreferenceOrder(faces []FontFace, families [][]string) []int {
|
||||||
|
priorities := make(map[string]int)
|
||||||
|
for priority, names := range families {
|
||||||
|
for _, name := range names {
|
||||||
|
key := FontNormalizeName(name)
|
||||||
|
if _, exists := priorities[key]; key != "" && !exists {
|
||||||
|
priorities[key] = priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order, ranks := make([]int, len(faces)), make([]int, len(faces))
|
||||||
|
for i, face := range faces {
|
||||||
|
order[i], ranks[i] = i, len(families)*2
|
||||||
|
names := append([]string{face.Family, face.FullName, face.PostScriptName}, face.Names...)
|
||||||
|
for _, name := range names {
|
||||||
|
if priority, exists := priorities[FontNormalizeName(name)]; exists && priority*2 < ranks[i] {
|
||||||
|
ranks[i] = priority * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ranks[i] < len(families)*2 {
|
||||||
|
switch FontNormalizeName(face.Style) {
|
||||||
|
case "", "regular", "normal", "roman", "常规", "正常", "标准":
|
||||||
|
default:
|
||||||
|
ranks[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(order, func(i, j int) bool { return ranks[order[i]] < ranks[order[j]] })
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
// FixFontDataAggressive 激进修复字体数据
|
// FixFontDataAggressive 激进修复字体数据
|
||||||
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
||||||
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
||||||
|
|||||||
+17
-5
@@ -269,23 +269,35 @@ func (r *Renderer) fontDigest(data []byte) [32]byte {
|
|||||||
|
|
||||||
// resetFontCache 重置字体解析及字形缓存,保留图片和非字体后端状态
|
// resetFontCache 重置字体解析及字形缓存,保留图片和非字体后端状态
|
||||||
func (r *Renderer) resetFontCache() {
|
func (r *Renderer) resetFontCache() {
|
||||||
r.fontSourcesCache = newFontSourceCache()
|
if r.fontSourcesCache != nil {
|
||||||
|
r.fontSourcesCache.collections.clear()
|
||||||
|
}
|
||||||
|
r.fontSourcesCache = newFontSourceCache(r.Reader.resources)
|
||||||
r.resetFontBackendCache()
|
r.resetFontBackendCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetFontBackendCache 清理后端字体对象,保留字体文件和图片缓存
|
// resetFontBackendCache 清理后端字体对象,保留字体文件和图片缓存
|
||||||
func (r *Renderer) resetFontBackendCache() {
|
func (r *Renderer) resetFontBackendCache() {
|
||||||
|
r.fontDigests.clear()
|
||||||
|
r.fontPreparations.clear()
|
||||||
|
r.fontMetrics.clear()
|
||||||
|
r.glyphOutlines.clear()
|
||||||
|
for _, state := range r.sharedBackendStates {
|
||||||
|
if cache, ok := state.(interface{ clear() }); ok {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
r.sharedBackendStates = make(map[any]any)
|
r.sharedBackendStates = make(map[any]any)
|
||||||
r.preparedFonts = make(map[string]*PreparedFont)
|
r.preparedFonts = make(map[string]*PreparedFont)
|
||||||
r.fontDigests = &renderCache[fontDataKey, [32]byte]{limit: 4 << 20}
|
r.fontDigests = resourceCache[fontDataKey, [32]byte](r.Reader.resources, 4<<20)
|
||||||
r.fontPreparations = &renderCache[[32]byte, PreparedFont]{limit: 16 << 20}
|
r.fontPreparations = resourceCache[[32]byte, PreparedFont](r.Reader.resources, 16<<20)
|
||||||
r.resolvedFonts = make(map[resolvedFontKey]resolvedFontResult)
|
r.resolvedFonts = make(map[resolvedFontKey]resolvedFontResult)
|
||||||
if r.fontSourcesCache != nil {
|
if r.fontSourcesCache != nil {
|
||||||
r.fontSourcesCache.fallback = ResolvedFont{}
|
r.fontSourcesCache.fallback = ResolvedFont{}
|
||||||
r.fontSourcesCache.fallbackRead = false
|
r.fontSourcesCache.fallbackRead = false
|
||||||
}
|
}
|
||||||
r.fontMetrics = &renderCache[[32]byte, FontMetrics]{limit: 32 << 20}
|
r.fontMetrics = resourceCache[[32]byte, FontMetrics](r.Reader.resources, 32<<20)
|
||||||
r.glyphOutlines = &renderCache[glyphOutlineKey, GeometryPath]{limit: 16 << 20}
|
r.glyphOutlines = resourceCache[glyphOutlineKey, GeometryPath](r.Reader.resources, 16<<20)
|
||||||
for _, state := range r.backendStates {
|
for _, state := range r.backendStates {
|
||||||
if fonts, ok := state.(interface{ resetFonts() }); ok {
|
if fonts, ok := state.(interface{ resetFonts() }); ok {
|
||||||
fonts.resetFonts()
|
fonts.resetFonts()
|
||||||
|
|||||||
+41
-1
@@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) {
|
|||||||
return r.fontData(id, false)
|
return r.fontData(id, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FontFace 读取指定内嵌资源的本地化字体信息,集合按资源声明选取,不依赖系统字体
|
||||||
|
// 入参: id 字体资源标识, platform 名称表平台标识, language 名称表语言标识
|
||||||
|
// 返回: *FontFace 独立字体信息, error 资源或解析错误
|
||||||
|
func (r *Reader) FontFace(id string, platform, language uint16) (*FontFace, error) {
|
||||||
|
if r.fontDefinition(id) == nil {
|
||||||
|
if _, err := r.Fonts(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
font := r.fontDefinition(id)
|
||||||
|
if font == nil || font.FontFile == "" {
|
||||||
|
return nil, fmt.Errorf("embedded font %q not found", id)
|
||||||
|
}
|
||||||
|
name := r.ResPath(font.FontFile)
|
||||||
|
key := fmt.Sprintf("%s\x00%d:%d", name, platform, language)
|
||||||
|
faces, ok := r.fontFaces[key]
|
||||||
|
if !ok {
|
||||||
|
data, err := r.readFileView(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, err := (FontFile{Data: data}).LocalizedFaces(platform, language)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
faces = make([]*FontFace, len(values))
|
||||||
|
for index := range values {
|
||||||
|
faces[index] = &values[index]
|
||||||
|
}
|
||||||
|
r.fontFaces[key] = faces
|
||||||
|
}
|
||||||
|
names := make([][]string, len(faces))
|
||||||
|
for index, face := range faces {
|
||||||
|
names[index] = face.Names
|
||||||
|
}
|
||||||
|
face := *faces[basefont.NameIndex(names, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic)]
|
||||||
|
face.Names = slices.Clone(face.Names)
|
||||||
|
return &face, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
||||||
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
||||||
// 返回: []byte 字体数据, error 资源或集合错误
|
// 返回: []byte 字体数据, error 资源或集合错误
|
||||||
@@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) {
|
|||||||
faces = make([]*FontFace, count)
|
faces = make([]*FontFace, count)
|
||||||
for index := range faces {
|
for index := range faces {
|
||||||
if tables, err := basefont.FileTables(data, index); err == nil {
|
if tables, err := basefont.FileTables(data, index); err == nil {
|
||||||
info := basefont.FaceInfo(tables["name"], index)
|
info := basefont.TableFaceInfo(tables, index, 0xffff, 0xffff)
|
||||||
faces[index] = &info
|
faces[index] = &info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,15 +79,16 @@ type fontSourceData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newFontSourceCache 创建独立字体来源缓存
|
// newFontSourceCache 创建独立字体来源缓存
|
||||||
|
// 入参: resources 共享资源策略
|
||||||
// 返回: *fontSourceCache 字体来源缓存
|
// 返回: *fontSourceCache 字体来源缓存
|
||||||
func newFontSourceCache() *fontSourceCache {
|
func newFontSourceCache(resources *Resources) *fontSourceCache {
|
||||||
return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]basefont.FileCandidate), filesystems: make(map[int][]basefont.FileCandidate), data: make(map[fontSourceKey]fontSourceData), collections: &renderCache[fontSourceKey, []byte]{limit: 32 << 20}}
|
return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]basefont.FileCandidate), filesystems: make(map[int][]basefont.FileCandidate), data: make(map[fontSourceKey]fontSourceData), collections: resourceCache[fontSourceKey, []byte](resources, 32<<20)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// child 复用相同来源的文件索引与数据,匹配和回退状态保持独立
|
// child 复用相同来源的文件索引与数据,匹配和回退状态保持独立
|
||||||
// 返回: *fontSourceCache 独立文档缓存
|
// 返回: *fontSourceCache 独立文档缓存
|
||||||
func (c *fontSourceCache) child() *fontSourceCache {
|
func (c *fontSourceCache) child() *fontSourceCache {
|
||||||
cache := newFontSourceCache()
|
cache := newFontSourceCache(nil)
|
||||||
cache.directories, cache.filesystems, cache.data = c.directories, c.filesystems, c.data
|
cache.directories, cache.filesystems, cache.data = c.directories, c.filesystems, c.data
|
||||||
cache.collections = c.collections
|
cache.collections = c.collections
|
||||||
return cache
|
return cache
|
||||||
|
|||||||
+5
-8
@@ -320,16 +320,13 @@ func (r *Renderer) cachedImageResource(resPath string) (image.Image, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resPath = cleanPackagePath(r.Reader.ResPath(resPath))
|
resPath = cleanPackagePath(r.Reader.ResPath(resPath))
|
||||||
img, ok := r.imageCache.get(resPath)
|
return r.imageCache.load(r.pageContext, resPath, func() (image.Image, int, error) {
|
||||||
if !ok {
|
img, err := r.readImageResource(resPath)
|
||||||
var err error
|
|
||||||
img, err = r.readImageResource(resPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
r.imageCache.put(resPath, img, baseimage.StorageCost(img, r.imageCache.limit))
|
return img, baseimage.StorageCost(img, r.imageCache.limit), nil
|
||||||
}
|
})
|
||||||
return img, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeImageResource 读取或复用图片资源,按输出需要解码像素
|
// decodeImageResource 读取或复用图片资源,按输出需要解码像素
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ type DocInfo struct {
|
|||||||
Abstract string `xml:"Abstract"`
|
Abstract string `xml:"Abstract"`
|
||||||
CreationDate string `xml:"CreationDate"`
|
CreationDate string `xml:"CreationDate"`
|
||||||
ModDate string `xml:"ModDate"`
|
ModDate string `xml:"ModDate"`
|
||||||
|
DocUsage string `xml:"DocUsage"`
|
||||||
|
Cover string `xml:"Cover"`
|
||||||
Keywords Keywords `xml:"Keywords,omitempty"`
|
Keywords Keywords `xml:"Keywords,omitempty"`
|
||||||
Creator string `xml:"Creator"`
|
Creator string `xml:"Creator"`
|
||||||
CreatorVersion string `xml:"CreatorVersion"`
|
CreatorVersion string `xml:"CreatorVersion"`
|
||||||
@@ -133,6 +135,10 @@ func (info *DocInfo) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
|
|||||||
target = &info.CreationDate
|
target = &info.CreationDate
|
||||||
case "ModDate":
|
case "ModDate":
|
||||||
target = &info.ModDate
|
target = &info.ModDate
|
||||||
|
case "DocUsage":
|
||||||
|
target = &info.DocUsage
|
||||||
|
case "Cover":
|
||||||
|
target = &info.Cover
|
||||||
case "Keywords":
|
case "Keywords":
|
||||||
target = &info.Keywords
|
target = &info.Keywords
|
||||||
case "Creator":
|
case "Creator":
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ type GraphicObject struct {
|
|||||||
CompositeGraphicUnit CompositeGraphicUnit
|
CompositeGraphicUnit CompositeGraphicUnit
|
||||||
origin *editorObjectOrigin
|
origin *editorObjectOrigin
|
||||||
state editorCompositeState
|
state editorCompositeState
|
||||||
richText *richTextMetadata
|
richText *richTextSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clips 相对于对象外接矩形的裁剪集合
|
// Clips 相对于对象外接矩形的裁剪集合
|
||||||
|
|||||||
+322
-49
@@ -15,9 +15,11 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ var ErrPDFPassword = pdfgo.ErrPassword
|
|||||||
|
|
||||||
// PDFImportOptions 指定PDF转换的密码、运行时后端、进度通知与检查策略
|
// PDFImportOptions 指定PDF转换的密码、运行时后端、进度通知与检查策略
|
||||||
// Password默认使用PDF安全处理器编码,PasswordUTF8启用UTF-8输入及标准密码准备,空值尝试空密码
|
// Password默认使用PDF安全处理器编码,PasswordUTF8启用UTF-8输入及标准密码准备,空值尝试空密码
|
||||||
// Strict禁止恢复缺失资源或丢失内容语义,默认在报告中记录警告
|
// Strict禁止日期兼容、恢复缺失资源或丢失内容语义,默认在报告中记录警告
|
||||||
// RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置
|
// RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置
|
||||||
// RasterDPI指定局部合成与三维绘制精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成
|
// RasterDPI指定局部合成与三维绘制精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成
|
||||||
// OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知
|
// OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知
|
||||||
@@ -41,7 +43,9 @@ var ErrPDFPassword = pdfgo.ErrPassword
|
|||||||
// ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护
|
// ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护
|
||||||
// Halftones提供只读设备命名网屏,所属阅读器由调用方维护,连续色调输出不网屏化
|
// Halftones提供只读设备命名网屏,所属阅读器由调用方维护,连续色调输出不网屏化
|
||||||
// Compression配置转换输出,有损模式按图片实际尺寸降采样,原始及无损模式保持完整采样
|
// Compression配置转换输出,有损模式按图片实际尺寸降采样,原始及无损模式保持完整采样
|
||||||
|
// Resources用于导入、局部合成与返回编辑器的共享资源预算
|
||||||
type PDFImportOptions struct {
|
type PDFImportOptions struct {
|
||||||
|
Resources *Resources
|
||||||
Password []byte
|
Password []byte
|
||||||
PasswordUTF8 bool
|
PasswordUTF8 bool
|
||||||
Backends *RenderBackends
|
Backends *RenderBackends
|
||||||
@@ -66,8 +70,28 @@ type PDFImportReport struct {
|
|||||||
Warnings []pdfgo.Diagnostic
|
Warnings []pdfgo.Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PDFImport 按页转换PDF,实例需串行使用,输入源由调用方持有至Close
|
||||||
|
// 页面阅读快照独立于后续转换,Finish完成全局结构后交付独立编辑文档
|
||||||
|
// 转换失败后可用Reset重建,输入及回调在会话存续期间须保持有效
|
||||||
|
type PDFImport struct {
|
||||||
|
input *Input
|
||||||
|
importer *pdfImporter
|
||||||
|
options PDFImportOptions
|
||||||
|
pages []PageContent
|
||||||
|
ready []bool
|
||||||
|
err error
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfImportInput 将输入读取绑定到当前转换上下文
|
||||||
|
type pdfImportInput struct {
|
||||||
|
input *Input
|
||||||
|
importer *pdfImporter
|
||||||
|
}
|
||||||
|
|
||||||
// pdfImporter 保存单次转换的对象与资源状态
|
// pdfImporter 保存单次转换的对象与资源状态
|
||||||
type pdfImporter struct {
|
type pdfImporter struct {
|
||||||
|
resources *Resources
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
reader *pdfgo.Reader
|
reader *pdfgo.Reader
|
||||||
editor *Editor
|
editor *Editor
|
||||||
@@ -78,6 +102,7 @@ type pdfImporter struct {
|
|||||||
pageActionPage int
|
pageActionPage int
|
||||||
fontIDs map[*pdfgo.Font]string
|
fontIDs map[*pdfgo.Font]string
|
||||||
fonts map[*pdfgo.Font]*pdfImportedFont
|
fonts map[*pdfgo.Font]*pdfImportedFont
|
||||||
|
fontPrograms map[pdfFontProgramKey]*pdfImportedFont
|
||||||
symbolFonts map[*pdfgo.Font]*pdfImportedFont
|
symbolFonts map[*pdfgo.Font]*pdfImportedFont
|
||||||
fontWarnings map[string]bool
|
fontWarnings map[string]bool
|
||||||
clipTexts map[*pdfgo.TextClip][]TextObject
|
clipTexts map[*pdfgo.TextClip][]TextObject
|
||||||
@@ -207,19 +232,36 @@ func (o pdfObjectOrigin) merge(other pdfObjectOrigin) pdfObjectOrigin {
|
|||||||
// 入参: ctx 取消上下文, input 输入源, options 转换选项
|
// 入参: ctx 取消上下文, input 输入源, options 转换选项
|
||||||
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息
|
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息
|
||||||
func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) {
|
func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) {
|
||||||
|
pending, err := NewPDFImport(ctx, input, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, PDFImportReport{}, err
|
||||||
|
}
|
||||||
|
defer pending.Close()
|
||||||
|
return pending.Finish(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPDFImport 建立PDF页面索引和文档信息,不转换页面图元
|
||||||
|
// 内部复制输入密码供Reset使用,Close时清除;其他选项引用需保持有效
|
||||||
|
// 入参: ctx 取消上下文, input 调用方持有的输入源, options 转换选项
|
||||||
|
// 返回: *PDFImport 增量转换会话, error 读取错误
|
||||||
|
func NewPDFImport(ctx context.Context, input *Input, options PDFImportOptions) (*PDFImport, error) {
|
||||||
|
if options.Resources == nil {
|
||||||
|
options.Resources, _ = NewResources(ResourceOptions{})
|
||||||
|
}
|
||||||
|
ctx = options.Resources.context(ctx)
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("missing source")
|
return nil, fmt.Errorf("missing source")
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := options.Compression.Validate(); err != nil {
|
if err := options.Compression.Validate(); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !finite(options.RasterDPI) || options.RasterDPI < 0 {
|
if !finite(options.RasterDPI) || options.RasterDPI < 0 {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
|
return nil, fmt.Errorf("invalid PDF raster DPI")
|
||||||
}
|
}
|
||||||
renderer := NewRenderer(&Reader{}, options.RendererOptions...)
|
renderer := NewRenderer(&Reader{resources: options.Resources}, options.RendererOptions...)
|
||||||
if options.Backends != nil {
|
if options.Backends != nil {
|
||||||
WithRenderBackends(*options.Backends)(renderer)
|
WithRenderBackends(*options.Backends)(renderer)
|
||||||
}
|
}
|
||||||
@@ -227,43 +269,70 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
renderer.DPI = options.RasterDPI
|
renderer.DPI = options.RasterDPI
|
||||||
}
|
}
|
||||||
if !finite(renderer.DPI) || renderer.DPI <= 0 {
|
if !finite(renderer.DPI) || renderer.DPI <= 0 {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
|
return nil, fmt.Errorf("invalid PDF raster DPI")
|
||||||
}
|
}
|
||||||
|
keep := false
|
||||||
|
options.Password = slices.Clone(options.Password)
|
||||||
|
defer func() {
|
||||||
|
if !keep {
|
||||||
|
clear(options.Password)
|
||||||
|
}
|
||||||
|
}()
|
||||||
progress := editorProgress(options.OnProgress).withContext(ctx)
|
progress := editorProgress(options.OnProgress).withContext(ctx)
|
||||||
if err := progress.report("open", 0, 0); err != nil {
|
if err := progress.report("open", 0, 0); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
editor := NewEditor()
|
editor := NewEditor()
|
||||||
|
editor.runtimeResources = options.Resources
|
||||||
editor.SetRenderBackends(renderer.Backends())
|
editor.SetRenderBackends(renderer.Backends())
|
||||||
editor.fontDirs, editor.fontFS = renderer.FontSources()
|
editor.fontDirs, editor.fontFS = renderer.FontSources()
|
||||||
editor.fontSourcesCache = renderer.fontSourcesCache
|
editor.fontSourcesCache = renderer.fontSourcesCache
|
||||||
importer := pdfImporter{ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}}
|
importer := &pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, page: -1, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}}
|
||||||
readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText}
|
readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText}
|
||||||
var diagnostics []pdfgo.Diagnostic
|
|
||||||
var readWarning func(pdfgo.Diagnostic)
|
|
||||||
if !options.Strict {
|
if !options.Strict {
|
||||||
readWarning = func(diagnostic pdfgo.Diagnostic) { diagnostics = append(diagnostics, diagnostic) }
|
importer.warning = func(warning pdfgo.Diagnostic) {
|
||||||
readOptions.Warning = func(diagnostic pdfgo.Diagnostic) { readWarning(diagnostic) }
|
warning.Page = importer.page + 1
|
||||||
|
importer.report.Warnings = append(importer.report.Warnings, warning)
|
||||||
}
|
}
|
||||||
reader, err := pdfgo.NewReaderWithOptions(convertReader{ctx: ctx, input: input}, input.Size(), readOptions)
|
readOptions.Warning = importer.warning
|
||||||
|
}
|
||||||
|
reader, err := pdfgo.NewReaderWithOptions(&pdfImportInput{input: input, importer: importer}, input.Size(), readOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer func() {
|
||||||
|
if !keep {
|
||||||
|
reader.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
importer.reader = reader
|
importer.reader = reader
|
||||||
if err := importer.prepareStructure(ctx); err != nil {
|
if err := importer.prepareStructure(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
|
return nil, fmt.Errorf("import PDF structure: %w", err)
|
||||||
}
|
}
|
||||||
outputIntents, err := reader.OutputIntents()
|
outputIntents, err := reader.OutputIntents()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF output intents: %w", err)
|
return nil, fmt.Errorf("import PDF output intents: %w", err)
|
||||||
}
|
}
|
||||||
importer.report.Warnings = diagnostics
|
|
||||||
if len(outputIntents) != 0 {
|
if len(outputIntents) != 0 {
|
||||||
|
var profile []byte
|
||||||
|
applicable := true
|
||||||
|
for _, intent := range outputIntents {
|
||||||
|
if intent.Subtype != "GTS_PDFA1" && intent.Subtype != "GTS_PDFX" || intent.Space == nil || len(intent.Profile) == 0 || profile != nil && !bytes.Equal(profile, intent.Profile) {
|
||||||
|
applicable = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
profile = intent.Profile
|
||||||
|
}
|
||||||
|
if applicable {
|
||||||
|
if err := reader.SetDeviceColorProfile(profile); err != nil {
|
||||||
|
return nil, fmt.Errorf("import PDF device color profile: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if options.Strict {
|
if options.Strict {
|
||||||
return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"}
|
return nil, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"}
|
||||||
|
}
|
||||||
|
importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"})
|
||||||
}
|
}
|
||||||
importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents not transferred to OFD"})
|
|
||||||
}
|
}
|
||||||
importer.pageIndexes = make(map[pdfgo.Reference]int)
|
importer.pageIndexes = make(map[pdfgo.Reference]int)
|
||||||
importer.resolveFile = options.ResolveFile
|
importer.resolveFile = options.ResolveFile
|
||||||
@@ -271,17 +340,14 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
importer.halftones = options.Halftones
|
importer.halftones = options.Halftones
|
||||||
importer.compression = options.Compression
|
importer.compression = options.Compression
|
||||||
importer.references = &codecpdf.ReferenceCache{}
|
importer.references = &codecpdf.ReferenceCache{}
|
||||||
defer importer.references.Close()
|
defer func() {
|
||||||
|
if !keep {
|
||||||
|
importer.references.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
||||||
if !options.Strict {
|
|
||||||
importer.warning = func(warning pdfgo.Diagnostic) {
|
|
||||||
warning.Page = importer.page + 1
|
|
||||||
importer.report.Warnings = append(importer.report.Warnings, warning)
|
|
||||||
}
|
|
||||||
readWarning = importer.warning
|
|
||||||
}
|
|
||||||
if err := progress.report("pages", 0, 0); err != nil {
|
if err := progress.report("pages", 0, 0); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
||||||
_, width, height := codecpdf.PageMatrix(page)
|
_, width, height := codecpdf.PageMatrix(page)
|
||||||
@@ -295,28 +361,94 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
return progress.report("pages", index+1, 0)
|
return progress.report("pages", index+1, 0)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(editor.pages) != 0 {
|
if len(editor.pages) != 0 {
|
||||||
importer.page = -1
|
importer.page = -1
|
||||||
if err := importer.documentMetadata(ctx); err != nil {
|
if err := importer.documentMetadata(ctx, options.Strict); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF metadata: %w", err)
|
return nil, fmt.Errorf("import PDF metadata: %w", err)
|
||||||
}
|
}
|
||||||
if err := importer.documentAttachments(ctx); err != nil {
|
if err := importer.documentAttachments(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF attachments: %w", err)
|
return nil, fmt.Errorf("import PDF attachments: %w", err)
|
||||||
}
|
}
|
||||||
if err := importer.outlines(ctx); err != nil {
|
if err := importer.outlines(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF outlines: %w", err)
|
return nil, fmt.Errorf("import PDF outlines: %w", err)
|
||||||
}
|
}
|
||||||
if err := importer.documentOpenActions(ctx); err != nil {
|
if err := importer.documentOpenActions(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF document actions: %w", err)
|
return nil, fmt.Errorf("import PDF document actions: %w", err)
|
||||||
}
|
}
|
||||||
importer.page = 0
|
importer.page = 0
|
||||||
}
|
}
|
||||||
if err := progress.report("convert", 0, len(editor.pages)); err != nil {
|
if err := progress.report("convert", 0, len(editor.pages)); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
if len(editor.pages) == 0 {
|
||||||
|
return nil, fmt.Errorf("PDF contains no pages")
|
||||||
|
}
|
||||||
|
keep = true
|
||||||
|
return &PDFImport{input: input, importer: importer, options: options, pages: cloneEditorData(editor.pages), ready: make([]bool, len(editor.pages))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAt 读取当前操作需要的数据,不取得输入所有权
|
||||||
|
// 入参: data 目标缓冲, offset 文件偏移
|
||||||
|
// 返回: int 读取字节数, error 读取或取消错误
|
||||||
|
func (r *pdfImportInput) ReadAt(data []byte, offset int64) (int, error) {
|
||||||
|
if err := r.importer.ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.input.ReadAt(data, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageCount 返回文档页数
|
||||||
|
// 返回: int 页面总数
|
||||||
|
func (p *PDFImport) PageCount() int { return len(p.pages) }
|
||||||
|
|
||||||
|
// Ready 返回指定页是否转换完成
|
||||||
|
// 入参: index 页面索引,从0开始
|
||||||
|
// 返回: bool 页面是否就绪
|
||||||
|
func (p *PDFImport) Ready(index int) bool {
|
||||||
|
return index >= 0 && index < len(p.ready) && p.ready[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEncryption 设置转换文档的输出加密方式,不改变PDF读取凭据
|
||||||
|
// 入参: options 加密选项,nil取消输出加密
|
||||||
|
// 返回: error 加密配置错误
|
||||||
|
func (p *PDFImport) SetEncryption(options *EncryptionOptions) error {
|
||||||
|
if p.importer == nil {
|
||||||
|
return fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
return p.importer.editor.SetEncryption(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertPage 转换指定页面,已完成的页面不重复处理
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引,从0开始
|
||||||
|
// 返回: error 转换或取消错误
|
||||||
|
func (p *PDFImport) ConvertPage(ctx context.Context, index int) (err error) {
|
||||||
|
if p.importer == nil {
|
||||||
|
return fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
if p.err != nil {
|
||||||
|
return p.err
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(p.pages) {
|
||||||
|
return fmt.Errorf("invalid page index: %d", index)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.ready[index] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
p.err = err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
importer := p.importer
|
||||||
|
importer.ctx = importer.resources.context(ctx)
|
||||||
|
ctx = importer.ctx
|
||||||
|
importer.renderer.OutputContext = ctx
|
||||||
|
page := importer.pageOrder[index]
|
||||||
matrix, width, height := codecpdf.PageMatrix(page)
|
matrix, width, height := codecpdf.PageMatrix(page)
|
||||||
importer.matrix = matrix
|
importer.matrix = matrix
|
||||||
importer.page = index
|
importer.page = index
|
||||||
@@ -338,10 +470,10 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
if err := importer.pageOpenActions(ctx, page); err != nil {
|
if err := importer.pageOpenActions(ctx, page); err != nil {
|
||||||
return fmt.Errorf("import PDF page %d actions: %w", index+1, err)
|
return fmt.Errorf("import PDF page %d actions: %w", index+1, err)
|
||||||
}
|
}
|
||||||
if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil {
|
if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return importer.reader.WalkPage(ctx, page, visitor) }); err != nil {
|
||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
}
|
}
|
||||||
if err := importer.annotations(ctx, page, options.Strict); err != nil {
|
if err := importer.annotations(ctx, page, p.options.Strict); err != nil {
|
||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
}
|
}
|
||||||
if err := importer.commitAnnotations(); err != nil {
|
if err := importer.commitAnnotations(); err != nil {
|
||||||
@@ -351,26 +483,106 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
}
|
}
|
||||||
importer.report.Pages++
|
importer.report.Pages++
|
||||||
if err := progress.report("convert", index+1, len(editor.pages)); err != nil {
|
if err := editorProgress(p.options.OnProgress).withContext(ctx).report("convert", importer.report.Pages, len(p.pages)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if options.Progress != nil {
|
if p.options.Progress != nil {
|
||||||
if err := options.Progress(index + 1); err != nil {
|
if err := p.options.Progress(importer.report.Pages); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ctx.Err()
|
p.ready[index] = true
|
||||||
})
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader 创建指定页的独立阅读快照,其他页仅保留尺寸和标识
|
||||||
|
// 未包含的页面返回ErrIncompleteDocument;index为-1时仅返回索引,完整编辑或导出须使用Finish
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: *Reader 阅读快照, error 转换或构建错误
|
||||||
|
func (p *PDFImport) Reader(ctx context.Context, index int) (*Reader, error) {
|
||||||
|
if p.importer == nil {
|
||||||
|
return nil, fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
if p.err != nil {
|
||||||
|
return nil, p.err
|
||||||
|
}
|
||||||
|
if index != -1 {
|
||||||
|
if err := p.ConvertPage(ctx, index); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot := *p.importer.editor
|
||||||
|
snapshot.pages = slices.Clone(p.pages)
|
||||||
|
if index >= 0 {
|
||||||
|
snapshot.pages[index] = p.importer.editor.pages[index]
|
||||||
|
}
|
||||||
|
reader, err := snapshot.Reader(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader.pageReady = make(map[string]bool)
|
||||||
|
if index >= 0 {
|
||||||
|
reader.pageReady[reader.doc.Pages.Page[index].ID] = true
|
||||||
|
}
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish 转换尚未就绪的页面并补全文档结构,返回独立编辑文档
|
||||||
|
// 入参: ctx 取消上下文
|
||||||
|
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 转换错误
|
||||||
|
func (p *PDFImport) Finish(ctx context.Context) (*Editor, PDFImportReport, error) {
|
||||||
|
if p.importer == nil {
|
||||||
|
return nil, PDFImportReport{}, fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
for index := range p.pages {
|
||||||
|
if err := p.ConvertPage(ctx, index); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, PDFImportReport{}, err
|
||||||
}
|
}
|
||||||
if importer.report.Pages == 0 {
|
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("PDF contains no pages")
|
|
||||||
}
|
}
|
||||||
if err := importer.documentStructures(ctx); err != nil {
|
if !p.finished {
|
||||||
|
p.importer.ctx = p.importer.resources.context(ctx)
|
||||||
|
if err := p.importer.documentStructures(p.importer.ctx); err != nil {
|
||||||
|
p.err = err
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
|
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
|
||||||
}
|
}
|
||||||
return editor, importer.report, nil
|
p.finished = true
|
||||||
|
}
|
||||||
|
return p.importer.editor, p.importer.report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset 放弃已转换内容并重建索引,保留文档标识及输出加密策略
|
||||||
|
// 失败时保留原会话,已交付的阅读快照不受影响;输入源仍由调用方持有
|
||||||
|
// 入参: ctx 本次重建的取消上下文
|
||||||
|
// 返回: error 重建错误
|
||||||
|
func (p *PDFImport) Reset(ctx context.Context) error {
|
||||||
|
if p.importer == nil {
|
||||||
|
return fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
next, err := NewPDFImport(ctx, p.input, p.options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next.importer.editor.Info.DocID = p.importer.editor.Info.DocID
|
||||||
|
next.importer.editor.encryption = p.importer.editor.encryption
|
||||||
|
p.Close()
|
||||||
|
*p = *next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放PDF索引、引用缓存和转换状态,不关闭调用方的输入源
|
||||||
|
// 返回: error 关闭错误
|
||||||
|
func (p *PDFImport) Close() error {
|
||||||
|
if p.importer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.importer.references.Close()
|
||||||
|
err := p.importer.reader.Close()
|
||||||
|
p.importer = nil
|
||||||
|
p.pages, p.ready = nil, nil
|
||||||
|
clear(p.options.Password)
|
||||||
|
p.options = PDFImportOptions{}
|
||||||
|
p.input = nil
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertPDF 将PDF转换并写入OFD,转换阶段失败时不写入目标
|
// ConvertPDF 将PDF转换并写入OFD,转换阶段失败时不写入目标
|
||||||
@@ -412,6 +624,67 @@ func (p *pdfImporter) referencePage(ctx context.Context, source *pdfgo.Reader, r
|
|||||||
return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning)
|
return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageAnnotations 保留不可显示的源注解声明,其他注解仍按原顺序读取
|
||||||
|
// 入参: ctx 取消上下文, page PDF页面
|
||||||
|
// 返回: []pdfgo.Annotation 可显示的注解, error 输入、取消或严格校验错误
|
||||||
|
func (p *pdfImporter) pageAnnotations(ctx context.Context, page *pdfgo.Page) ([]pdfgo.Annotation, error) {
|
||||||
|
annotations, err := page.AnnotationsContext(ctx)
|
||||||
|
if err == nil || p.warning == nil || !pdfAuxiliaryError(err) {
|
||||||
|
return annotations, err
|
||||||
|
}
|
||||||
|
object := page.Dictionary["Annots"]
|
||||||
|
if err := p.recoverPDFObject(ctx, "Page.Annots", object, err); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, err := p.reader.Resolve(object)
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.recoverPDFObject(ctx, "Page.Annots", object, err)
|
||||||
|
}
|
||||||
|
array, ok := value.(pdfgo.Array)
|
||||||
|
if !ok {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
for index, object := range array {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidate := *page
|
||||||
|
candidate.Dictionary = maps.Clone(page.Dictionary)
|
||||||
|
candidate.Dictionary["Annots"] = pdfgo.Array{object}
|
||||||
|
items, err := candidate.AnnotationsContext(ctx)
|
||||||
|
if err != nil && pdfAuxiliaryError(err) {
|
||||||
|
value, resolveErr := p.reader.Resolve(object)
|
||||||
|
if resolveErr != nil && !pdfAuxiliaryError(resolveErr) {
|
||||||
|
return nil, resolveErr
|
||||||
|
}
|
||||||
|
if dict, ok := value.(pdfgo.Dictionary); ok && dict["AP"] != nil {
|
||||||
|
subtype, resolveErr := p.reader.Resolve(dict["Subtype"])
|
||||||
|
if resolveErr != nil && !pdfAuxiliaryError(resolveErr) {
|
||||||
|
return nil, resolveErr
|
||||||
|
}
|
||||||
|
if _, named := subtype.(pdfgo.Name); !named && resolveErr == nil {
|
||||||
|
dict = maps.Clone(dict)
|
||||||
|
dict["Subtype"] = pdfgo.Name("Stamp")
|
||||||
|
candidate.Dictionary["Annots"] = pdfgo.Array{dict}
|
||||||
|
items, err = candidate.AnnotationsContext(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !pdfAuxiliaryError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF annotation %d retained without appearance: %v", index+1, err)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(items) == 1 && items[0].Reference == (pdfgo.Reference{}) {
|
||||||
|
items[0].Reference, _ = object.(pdfgo.Reference)
|
||||||
|
}
|
||||||
|
annotations = append(annotations, items...)
|
||||||
|
}
|
||||||
|
return annotations, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器
|
// objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器
|
||||||
// 返回: *editorValidation 当前编辑器的构建校验会话
|
// 返回: *editorValidation 当前编辑器的构建校验会话
|
||||||
func (p *pdfImporter) objectValidation() *editorValidation {
|
func (p *pdfImporter) objectValidation() *editorValidation {
|
||||||
|
|||||||
+95
-67
@@ -18,7 +18,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -36,12 +35,15 @@ type pdfDestinationFontBackend struct {
|
|||||||
// 入参: ctx 取消上下文
|
// 入参: ctx 取消上下文
|
||||||
// 返回: error 动作、编码或取消错误
|
// 返回: error 动作、编码或取消错误
|
||||||
func (p *pdfImporter) documentOpenActions(ctx context.Context) error {
|
func (p *pdfImporter) documentOpenActions(ctx context.Context) error {
|
||||||
opened, err := p.reader.ReadOpenAction(ctx)
|
raw, err := p.pdfCatalogObject("OpenAction")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if p.warning == nil || !errors.Is(err, pdfgo.ErrInvalidDestination) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF initial destination ignored: %v", err)})
|
opened, err := p.reader.ReadOpenAction(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err := p.recoverPDFObject(ctx, "OpenAction", raw, err); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var object pdfgo.Object
|
var object pdfgo.Object
|
||||||
if opened.Action != nil {
|
if opened.Action != nil {
|
||||||
@@ -52,7 +54,7 @@ func (p *pdfImporter) documentOpenActions(ctx context.Context) error {
|
|||||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": array}
|
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": array}
|
||||||
}
|
}
|
||||||
currentPage := 0
|
currentPage := 0
|
||||||
actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, ¤tPage)
|
actions, err := p.eventActions(ctx, object, "DO", pdfgo.Annotation{}, ¤tPage, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -61,12 +63,19 @@ func (p *pdfImporter) documentOpenActions(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
triggers, err := p.reader.ReadDocumentTriggers(ctx)
|
triggers, err := p.reader.ReadDocumentTriggers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
object, readErr := p.pdfCatalogObject("AA")
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
return p.recoverPDFObject(ctx, "Document.AA", object, err)
|
||||||
}
|
}
|
||||||
for _, trigger := range triggers {
|
for _, trigger := range triggers {
|
||||||
if _, err := p.reader.ReadJavaScriptAction(ctx, trigger.Action); err != nil {
|
if _, err := p.reader.ReadJavaScriptAction(ctx, trigger.Action); err != nil {
|
||||||
|
if err := p.recoverPDFObject(ctx, "Document.AA."+string(trigger.Event), trigger.Action, err); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := p.unmappedTrigger(ctx, "document", trigger); err != nil {
|
if err := p.unmappedTrigger(ctx, "document", trigger); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,7 +90,7 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err
|
|||||||
p.pageActionPage = p.page
|
p.pageActionPage = p.page
|
||||||
triggers, err := p.reader.ReadPageTriggers(ctx, page)
|
triggers, err := p.reader.ReadPageTriggers(ctx, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return p.recoverPDFObject(ctx, "Page.AA", page.Dictionary["AA"], err)
|
||||||
}
|
}
|
||||||
for _, trigger := range triggers {
|
for _, trigger := range triggers {
|
||||||
if trigger.Event != "O" {
|
if trigger.Event != "O" {
|
||||||
@@ -102,32 +111,59 @@ func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eventActions 按原顺序转换动作链,保留事件类型与链内跳转页状态
|
// eventActions 按原顺序转换动作链,保留事件类型与链内跳转页状态
|
||||||
// 入参: ctx 取消上下文, object 动作对象, event OFD事件, source 触发注解,零值表示非图元动作, currentPage 当前页面
|
// 入参: ctx 取消上下文, object 动作对象, event OFD事件, source 触发注解,零值表示非图元动作, currentPage 当前页面, original 可选原始来源
|
||||||
// 返回: []Action 转换动作, error 动作或取消错误
|
// 返回: []Action 转换动作, error 动作或取消错误
|
||||||
func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, source pdfgo.Annotation, currentPage *int) ([]Action, error) {
|
func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, source pdfgo.Annotation, currentPage *int, original ...pdfgo.Object) ([]Action, error) {
|
||||||
|
raw := object
|
||||||
|
if len(original) != 0 {
|
||||||
|
raw = original[0]
|
||||||
|
}
|
||||||
|
retained, warned := false, false
|
||||||
|
warning := p.warning
|
||||||
|
if warning != nil {
|
||||||
|
p.warning = func(diagnostic pdfgo.Diagnostic) {
|
||||||
|
warned = true
|
||||||
|
warning(diagnostic)
|
||||||
|
}
|
||||||
|
defer func() { p.warning = warning }()
|
||||||
|
}
|
||||||
|
retain := func(cause error) error {
|
||||||
|
if cause != nil && (p.warning == nil || !pdfAuxiliaryError(cause)) {
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
if retained {
|
||||||
|
if cause != nil {
|
||||||
|
p.warning(pdfgo.Diagnostic{Message: "PDF Action." + event + " retained as metadata: " + cause.Error()})
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if cause != nil {
|
||||||
|
err = p.recoverPDFObject(ctx, "Action."+event, raw, cause)
|
||||||
|
} else if p.warning != nil {
|
||||||
|
err = p.preservePDFObject(ctx, "Action."+event, raw)
|
||||||
|
}
|
||||||
|
retained = err == nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
var actions []Action
|
var actions []Action
|
||||||
|
var conversionErr error
|
||||||
err := p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
err := p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||||
annotation := source
|
annotation := source
|
||||||
annotation.Dictionary = pdfgo.Dictionary{"A": info.Dictionary}
|
annotation.Dictionary = pdfgo.Dictionary{"A": info.Dictionary}
|
||||||
converted, err := p.linkActions(annotation, p.warning == nil, currentPage)
|
warned = false
|
||||||
|
warnings := len(p.report.Warnings)
|
||||||
|
converted, err := p.convertLinkActions(annotation, p.warning == nil, currentPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errPDFMediaRead) {
|
conversionErr = retain(err)
|
||||||
|
*currentPage = -1
|
||||||
|
return conversionErr
|
||||||
|
}
|
||||||
|
if p.warning != nil && (converted == nil || warned || len(p.report.Warnings) != warnings) {
|
||||||
|
if err := retain(nil); err != nil {
|
||||||
|
conversionErr = err
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var unsupported *pdfgo.UnsupportedError
|
|
||||||
if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidAction) {
|
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s action ignored: %v", event, err)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidDestination) {
|
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s destination ignored: %v", event, err)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event != "CLICK" && p.warning != nil && errors.As(err, &unsupported) {
|
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF %s action not transferred to OFD: %s", event, unsupported.Feature)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
for _, action := range converted {
|
for _, action := range converted {
|
||||||
action.Event = event
|
action.Event = event
|
||||||
@@ -135,9 +171,15 @@ func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, eve
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if conversionErr != nil {
|
||||||
|
return nil, conversionErr
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err := retain(err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
*currentPage = -1
|
||||||
|
}
|
||||||
return actions, ctx.Err()
|
return actions, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +188,10 @@ func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, eve
|
|||||||
// 返回: error 动作链、取消或严格模式错误
|
// 返回: error 动作链、取消或严格模式错误
|
||||||
func (p *pdfImporter) unmappedTrigger(ctx context.Context, owner string, trigger pdfgo.ActionTrigger) error {
|
func (p *pdfImporter) unmappedTrigger(ctx context.Context, owner string, trigger pdfgo.ActionTrigger) error {
|
||||||
if err := p.reader.WalkActions(ctx, trigger.Action, func(pdfgo.ActionInfo) error { return nil }); err != nil {
|
if err := p.reader.WalkActions(ctx, trigger.Action, func(pdfgo.ActionInfo) error { return nil }); err != nil {
|
||||||
return err
|
return p.recoverPDFObject(ctx, owner+".AA."+string(trigger.Event), trigger.Action, err)
|
||||||
}
|
}
|
||||||
feature := owner + " trigger " + string(trigger.Event)
|
feature := owner + " trigger " + string(trigger.Event)
|
||||||
if p.warning == nil {
|
return p.recoverPDFObject(ctx, owner+".AA."+string(trigger.Event), trigger.Action, &pdfgo.UnsupportedError{Feature: feature})
|
||||||
return &pdfgo.UnsupportedError{Feature: feature}
|
|
||||||
}
|
|
||||||
p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD"})
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观
|
// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观
|
||||||
@@ -165,7 +203,7 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p
|
|||||||
}
|
}
|
||||||
triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation)
|
triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, p.recoverPDFObject(ctx, "Annotation.AA", annotation.Dictionary["AA"], err)
|
||||||
}
|
}
|
||||||
if len(triggers) == 0 {
|
if len(triggers) == 0 {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
@@ -193,7 +231,10 @@ func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation p
|
|||||||
switch trigger.Event {
|
switch trigger.Event {
|
||||||
case "U", "PO":
|
case "U", "PO":
|
||||||
default:
|
default:
|
||||||
return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)}
|
if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
currentPage := p.page
|
currentPage := p.page
|
||||||
if event == "PO" {
|
if event == "PO" {
|
||||||
@@ -246,6 +287,7 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
|||||||
}
|
}
|
||||||
matrix, width, height := codecpdf.PageMatrix(page)
|
matrix, width, height := codecpdf.PageMatrix(page)
|
||||||
editor := NewEditor()
|
editor := NewEditor()
|
||||||
|
editor.SetResources(p.resources)
|
||||||
editor.SetRenderBackends(p.editor.Backends())
|
editor.SetRenderBackends(p.editor.Backends())
|
||||||
editor.SetFontDirs(p.editor.fontDirs...)
|
editor.SetFontDirs(p.editor.fontDirs...)
|
||||||
editor.SetFontFS(p.editor.fontFS...)
|
editor.SetFontFS(p.editor.fontFS...)
|
||||||
@@ -255,7 +297,9 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
|||||||
return Box{}, err
|
return Box{}, err
|
||||||
}
|
}
|
||||||
local := pdfImporter{
|
local := pdfImporter{
|
||||||
|
resources: p.resources,
|
||||||
ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer,
|
ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer,
|
||||||
|
warning: p.warning,
|
||||||
matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height,
|
matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height,
|
||||||
rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string),
|
rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string),
|
||||||
resolveFile: p.resolveFile, resolveReference: p.resolveReference,
|
resolveFile: p.resolveFile, resolveReference: p.resolveReference,
|
||||||
@@ -271,7 +315,7 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
|||||||
if err := local.commitObjects(); err != nil {
|
if err := local.commitObjects(); err != nil {
|
||||||
return Box{}, err
|
return Box{}, err
|
||||||
}
|
}
|
||||||
reader, err := editor.resourceReader()
|
reader, err := local.temporaryReader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Box{}, err
|
return Box{}, err
|
||||||
}
|
}
|
||||||
@@ -318,18 +362,16 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
|||||||
// 返回: error 严格模式转换错误或取消错误
|
// 返回: error 严格模式转换错误或取消错误
|
||||||
func (p *pdfImporter) outlines(ctx context.Context) error {
|
func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||||
count := 0
|
count := 0
|
||||||
|
var conversionErr error
|
||||||
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
||||||
x.root("Outlines", nil)
|
x.root("Outlines", nil)
|
||||||
depth := 0
|
depth := 0
|
||||||
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
||||||
actions, err := p.outlineActions(ctx, item)
|
actions, err := p.outlineActions(ctx, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
conversionErr = err
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)})
|
|
||||||
actions = nil
|
|
||||||
}
|
|
||||||
for depth > level {
|
for depth > level {
|
||||||
x.end("OutlineElem")
|
x.end("OutlineElem")
|
||||||
depth--
|
depth--
|
||||||
@@ -352,15 +394,21 @@ func (p *pdfImporter) outlines(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
x.end("Outlines")
|
x.end("Outlines")
|
||||||
})
|
})
|
||||||
|
if conversionErr != nil {
|
||||||
|
return conversionErr
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
if p.warning == nil || !pdfAuxiliaryError(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)})
|
object, readErr := p.pdfCatalogObject("Outlines")
|
||||||
return nil
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
return p.recoverPDFObject(ctx, "Outlines", object, err)
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header))
|
p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header))
|
||||||
@@ -372,42 +420,22 @@ func (p *pdfImporter) outlines(ctx context.Context) error {
|
|||||||
// 入参: ctx 取消上下文, item PDF目录节点
|
// 入参: ctx 取消上下文, item PDF目录节点
|
||||||
// 返回: []Action OFD动作序列, error 动作或取消错误
|
// 返回: []Action OFD动作序列, error 动作或取消错误
|
||||||
func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) {
|
func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) {
|
||||||
|
raw := item.Action
|
||||||
object, err := p.reader.Resolve(item.Action)
|
object, err := p.reader.Resolve(item.Action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, p.recoverPDFObject(ctx, "Outline.A", item.Action, err)
|
||||||
}
|
}
|
||||||
dest, err := p.reader.Resolve(item.Destination)
|
dest, err := p.reader.Resolve(item.Destination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, p.recoverPDFObject(ctx, "Outline.Dest", item.Destination, err)
|
||||||
}
|
}
|
||||||
if dest != nil {
|
if dest != nil {
|
||||||
if object != nil {
|
if object != nil {
|
||||||
return nil, fmt.Errorf("PDF outline has both destination and action")
|
return nil, p.recoverPDFObject(ctx, "Outline", pdfgo.Dictionary{"A": item.Action, "Dest": item.Destination}, fmt.Errorf("PDF outline has both destination and action"))
|
||||||
}
|
}
|
||||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest}
|
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest}
|
||||||
|
raw = item.Destination
|
||||||
}
|
}
|
||||||
currentPage := -1
|
currentPage := -1
|
||||||
var actions []Action
|
return p.eventActions(ctx, object, "CLICK", pdfgo.Annotation{}, ¤tPage, raw)
|
||||||
err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
|
||||||
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, errPDFMediaRead) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var unsupported *pdfgo.UnsupportedError
|
|
||||||
if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) {
|
|
||||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)})
|
|
||||||
currentPage = -1
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if converted != nil {
|
|
||||||
actions = append(actions, converted...)
|
|
||||||
} else {
|
|
||||||
currentPage = -1
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return actions, err
|
|
||||||
}
|
}
|
||||||
+170
-10
@@ -19,6 +19,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
@@ -27,10 +28,145 @@ import (
|
|||||||
"github.com/xiaoqidun/pdfgo"
|
"github.com/xiaoqidun/pdfgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// recoverAnnotationAuxiliary 仅在存在有效正常外观时保留辅助错误,外观仍由调用方实际绘制
|
||||||
|
// 入参: ctx 取消上下文, annotation 原始注解, name 辅助字段名, object 原始对象, cause 辅助错误
|
||||||
|
// 返回: error 严格模式、缺少有效外观或不可恢复错误
|
||||||
|
func (p *pdfImporter) recoverAnnotationAuxiliary(ctx context.Context, annotation pdfgo.Annotation, name string, object pdfgo.Object, cause error) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.warning == nil || !pdfAuxiliaryError(cause) {
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
appearance, err := p.reader.ReadAnnotationAppearance(annotation)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if appearance == nil {
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
return p.recoverPDFObject(ctx, name, object, cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// popupAnnotation 单独恢复弹窗的打开状态和父引用,不忽略外观或几何错误
|
||||||
|
// 入参: ctx 取消上下文, object 原始弹窗对象
|
||||||
|
// 返回: pdfgo.PopupAnnotation 弹窗信息, error 读取或不可恢复错误
|
||||||
|
func (p *pdfImporter) popupAnnotation(ctx context.Context, object pdfgo.Object) (pdfgo.PopupAnnotation, error) {
|
||||||
|
value, err := p.reader.Resolve(object)
|
||||||
|
if err != nil {
|
||||||
|
return pdfgo.PopupAnnotation{}, p.recoverPDFObject(ctx, "Annotation.Popup", object, err)
|
||||||
|
}
|
||||||
|
if value == nil {
|
||||||
|
return pdfgo.PopupAnnotation{}, nil
|
||||||
|
}
|
||||||
|
dict, ok := value.(pdfgo.Dictionary)
|
||||||
|
if !ok {
|
||||||
|
return pdfgo.PopupAnnotation{}, p.recoverPDFObject(ctx, "Annotation.Popup", object, fmt.Errorf("invalid popup annotation"))
|
||||||
|
}
|
||||||
|
dict = maps.Clone(dict)
|
||||||
|
open, err := p.reader.Resolve(dict["Open"])
|
||||||
|
if err == nil && open != nil && open != pdfgo.Boolean(false) && open != pdfgo.Boolean(true) {
|
||||||
|
err = fmt.Errorf("invalid popup open state")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err := p.recoverPDFObject(ctx, "Annotation.Popup.Open", dict["Open"], err); err != nil {
|
||||||
|
return pdfgo.PopupAnnotation{}, err
|
||||||
|
}
|
||||||
|
delete(dict, "Open")
|
||||||
|
}
|
||||||
|
if object := dict["Parent"]; object != nil {
|
||||||
|
parent, reference := object.(pdfgo.Reference)
|
||||||
|
value, err := p.reader.Resolve(object)
|
||||||
|
if err == nil && value != nil {
|
||||||
|
parentDict, ok := value.(pdfgo.Dictionary)
|
||||||
|
if !reference || !ok {
|
||||||
|
err = fmt.Errorf("invalid popup parent annotation")
|
||||||
|
} else {
|
||||||
|
subtype, resolveErr := p.reader.Resolve(parentDict["Subtype"])
|
||||||
|
err = resolveErr
|
||||||
|
if kind, ok := subtype.(pdfgo.Name); err == nil && (!ok || kind == "Popup") {
|
||||||
|
err = fmt.Errorf("invalid popup parent subtype")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err == nil && !reference {
|
||||||
|
err = fmt.Errorf("invalid popup parent reference")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err := p.recoverPDFObject(ctx, "Annotation.Popup.Parent", object, err); err != nil {
|
||||||
|
return pdfgo.PopupAnnotation{}, err
|
||||||
|
}
|
||||||
|
delete(dict, "Parent")
|
||||||
|
} else {
|
||||||
|
dict["Parent"] = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.reader.ReadPopupAnnotation(dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
// annotationOpen 读取文本注解初始打开状态,单独保留无效辅助字段
|
||||||
|
// 入参: ctx 取消上下文, dict 注解字典
|
||||||
|
// 返回: pdfgo.Object 打开状态, error 不可恢复错误
|
||||||
|
func (p *pdfImporter) annotationOpen(ctx context.Context, dict pdfgo.Dictionary) (pdfgo.Object, error) {
|
||||||
|
value, err := p.reader.Resolve(dict["Open"])
|
||||||
|
if err == nil && value != nil && value != pdfgo.Boolean(false) && value != pdfgo.Boolean(true) {
|
||||||
|
err = fmt.Errorf("invalid PDF text annotation open state")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.recoverPDFObject(ctx, "Annotation.Open", dict["Open"], err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
// appearanceAnnotation 将PDF静态外观转换为对应类型的OFD注解
|
// appearanceAnnotation 将PDF静态外观转换为对应类型的OFD注解
|
||||||
// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, actions 外观区域的动作
|
// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, actions 外观区域的动作
|
||||||
// 返回: error 外观或转换错误
|
// 返回: error 外观或转换错误
|
||||||
func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, actions ...Action) error {
|
func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, actions ...Action) error {
|
||||||
|
if annotation.Rect.XMin == annotation.Rect.XMax || annotation.Rect.YMin == annotation.Rect.YMax {
|
||||||
|
return p.paintedAnnotation(ctx, page, annotation, nil, actions...)
|
||||||
|
}
|
||||||
|
if !annotation.IsStandard() {
|
||||||
|
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags&1 != 0 {
|
||||||
|
return p.paintedAnnotation(ctx, page, annotation, nil, actions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.warning != nil && annotation.Dictionary["AP"] == nil && annotation.Dictionary["BS"] == nil && annotation.Dictionary["Border"] != nil {
|
||||||
|
border, err := p.reader.Resolve(annotation.Dictionary["Border"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if array, ok := border.(pdfgo.Array); ok && len(array) == 0 {
|
||||||
|
if err := p.preservePDFObject(ctx, "Annotation.Border", annotation.Dictionary["Border"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if annotation.Dictionary["AP"] != nil {
|
||||||
|
if _, err := p.reader.ReadAnnotationAppearance(annotation); err != nil {
|
||||||
|
if !errors.Is(err, pdfgo.ErrInvalidAnnotationAppearance) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.warning == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.preservePDFObject(ctx, "Annotation.Appearance", annotation.Dictionary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch annotation.Subtype {
|
||||||
|
case "PrinterMark", "TrapNet", "3D", "RichMedia", "Watermark":
|
||||||
|
appearance, err := p.reader.Resolve(annotation.Dictionary["AP"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if appearance == nil {
|
||||||
|
return p.recoverPDFObject(ctx, "Annotation.Appearance", annotation.Dictionary, &pdfgo.UnsupportedError{Feature: "annotation without static appearance"})
|
||||||
|
}
|
||||||
|
}
|
||||||
return p.paintedAnnotation(ctx, page, annotation, nil, actions...)
|
return p.paintedAnnotation(ctx, page, annotation, nil, actions...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +174,21 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page
|
|||||||
// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, paint 绘制回调,空值使用原外观, actions 外观区域的动作
|
// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, paint 绘制回调,空值使用原外观, actions 外观区域的动作
|
||||||
// 返回: error 外观或转换错误
|
// 返回: error 外观或转换错误
|
||||||
func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, paint func(*pdfImporter) error, actions ...Action) error {
|
func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, paint func(*pdfImporter) error, actions ...Action) error {
|
||||||
|
if annotation.Subtype == "Projection" {
|
||||||
|
if _, err := p.reader.ReadProjectionAnnotation(annotation); err != nil {
|
||||||
|
if p.warning == nil || !pdfAuxiliaryError(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := p.reader.ReadAnnotationAppearance(annotation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.recoverPDFObject(ctx, "Annotation.Projection.ExData", annotation.Dictionary["ExData"], err); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
annotation.Dictionary = maps.Clone(annotation.Dictionary)
|
||||||
|
delete(annotation.Dictionary, "ExData")
|
||||||
|
}
|
||||||
|
}
|
||||||
clicks, err := p.annotationTriggerActions(ctx, annotation, "CLICK")
|
clicks, err := p.annotationTriggerActions(ctx, annotation, "CLICK")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -50,11 +201,13 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value, err := p.reader.Resolve(annotation.Dictionary[key])
|
value, err := p.reader.Resolve(annotation.Dictionary[key])
|
||||||
if err != nil {
|
if value != nil || err != nil {
|
||||||
|
if err == nil {
|
||||||
|
err = &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)}
|
||||||
|
}
|
||||||
|
if err := p.recoverPDFObject(ctx, "Annotation."+string(key), annotation.Dictionary[key], err); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if value != nil {
|
|
||||||
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
remark, err := p.reader.ReadAnnotationTextContext(ctx, annotation, "Contents", p.warning)
|
remark, err := p.reader.ReadAnnotationTextContext(ctx, annotation, "Contents", p.warning)
|
||||||
@@ -74,7 +227,9 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
|||||||
}
|
}
|
||||||
invisible := flags&1 != 0 && !annotation.IsStandard()
|
invisible := flags&1 != 0 && !annotation.IsStandard()
|
||||||
if flags&256 != 0 {
|
if flags&256 != 0 {
|
||||||
return &pdfgo.UnsupportedError{Feature: "annotation ToggleNoView flag"}
|
if err := p.recoverPDFObject(ctx, "Annotation.F", annotation.Dictionary["F"], &pdfgo.UnsupportedError{Feature: "annotation ToggleNoView flag"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
box := pdfBounds([]pdfgo.Point{
|
box := pdfBounds([]pdfgo.Point{
|
||||||
p.matrix.Apply(pdfgo.Point{X: annotation.Rect.XMin, Y: annotation.Rect.YMin}),
|
p.matrix.Apply(pdfgo.Point{X: annotation.Rect.XMin, Y: annotation.Rect.YMin}),
|
||||||
@@ -85,8 +240,10 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
|||||||
if len(clicks) != 0 && annotation.Subtype == "Link" {
|
if len(clicks) != 0 && annotation.Subtype == "Link" {
|
||||||
region, err := p.linkRegion(annotation, box)
|
region, err := p.linkRegion(annotation, box)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err := p.recoverPDFObject(ctx, "Annotation.QuadPoints", annotation.Dictionary["QuadPoints"], err); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for i := range clicks {
|
for i := range clicks {
|
||||||
clicks[i].Region = region
|
clicks[i].Region = region
|
||||||
}
|
}
|
||||||
@@ -151,21 +308,18 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
|||||||
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Redact.Pending", Value: "true"}}}
|
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Redact.Pending", Value: "true"}}}
|
||||||
}
|
}
|
||||||
if annotation.Subtype == "Text" {
|
if annotation.Subtype == "Text" {
|
||||||
value, err := p.reader.Resolve(annotation.Dictionary["Open"])
|
value, err := p.annotationOpen(ctx, annotation.Dictionary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Text.Open", Value: strconv.FormatBool(value == pdfgo.Boolean(true))}}}
|
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Text.Open", Value: strconv.FormatBool(value == pdfgo.Boolean(true))}}}
|
||||||
}
|
}
|
||||||
popupObject, err := p.reader.Resolve(annotation.Dictionary["Popup"])
|
popupObject := annotation.Dictionary["Popup"]
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if annotation.Subtype == "Popup" {
|
if annotation.Subtype == "Popup" {
|
||||||
popupObject = annotation.Dictionary
|
popupObject = annotation.Dictionary
|
||||||
}
|
}
|
||||||
if popupObject != nil {
|
if popupObject != nil {
|
||||||
popup, err := p.reader.ReadPopupAnnotation(popupObject)
|
popup, err := p.popupAnnotation(ctx, popupObject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -177,6 +331,12 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
|
|||||||
{Name: "PDF.Popup.Rect", Value: fmt.Sprintf("%g %g %g %g", popup.Rect.XMin, popup.Rect.YMin, popup.Rect.XMax, popup.Rect.YMax)},
|
{Name: "PDF.Popup.Rect", Value: fmt.Sprintf("%g %g %g %g", popup.Rect.XMin, popup.Rect.YMin, popup.Rect.XMax, popup.Rect.YMax)},
|
||||||
}...)
|
}...)
|
||||||
}
|
}
|
||||||
|
if annotation.Subtype != "Popup" {
|
||||||
|
if converted.Parameters == nil {
|
||||||
|
converted.Parameters = &AnnotationParameters{}
|
||||||
|
}
|
||||||
|
converted.Parameters.Parameter = append(converted.Parameters.Parameter, AnnotationParameter{Name: "PDF.Printable", Value: strconv.FormatBool(flags&4 != 0 && flags&2 == 0 && !invisible)})
|
||||||
|
}
|
||||||
if len(actions) != 0 {
|
if len(actions) != 0 {
|
||||||
region, err := NewShape(ShapeRectangle, Box{X: playback.X - box.X, Y: playback.Y - box.Y, W: playback.W, H: playback.H})
|
region, err := NewShape(ShapeRectangle, Box{X: playback.X - box.X, Y: playback.Y - box.Y, W: playback.W, H: playback.H})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const (
|
|||||||
pdfTransferShape uint8 = 16
|
pdfTransferShape uint8 = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pdfCompositeTileSize 限定单次合成块的像素边长
|
||||||
|
const pdfCompositeTileSize = codecpdf.CompositeTileSize
|
||||||
|
|
||||||
// pdfCompositeNode 保留局部合成所需的原始图元和透明组层级
|
// pdfCompositeNode 保留局部合成所需的原始图元和透明组层级
|
||||||
type pdfCompositeNode struct {
|
type pdfCompositeNode struct {
|
||||||
path *pdfgo.PathMark
|
path *pdfgo.PathMark
|
||||||
@@ -123,6 +126,7 @@ type pdfCompositeImageColor struct {
|
|||||||
|
|
||||||
// pdfCompositeCache 在单页内复用几何、图像分量及蒙版内容
|
// pdfCompositeCache 在单页内复用几何、图像分量及蒙版内容
|
||||||
type pdfCompositeCache struct {
|
type pdfCompositeCache struct {
|
||||||
|
resources *Resources
|
||||||
geometry *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry]
|
geometry *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry]
|
||||||
clips *renderCache[[32]byte, GeometryPath]
|
clips *renderCache[[32]byte, GeometryPath]
|
||||||
bounds map[pdfCompositeKey]Box
|
bounds map[pdfCompositeKey]Box
|
||||||
@@ -142,9 +146,6 @@ type pdfCompositeCache struct {
|
|||||||
groups map[*pdfgo.GroupMark]Box
|
groups map[*pdfgo.GroupMark]Box
|
||||||
}
|
}
|
||||||
|
|
||||||
// pdfCompositeTileSize 限定单次合成块的像素边长
|
|
||||||
const pdfCompositeTileSize = codecpdf.CompositeTileSize
|
|
||||||
|
|
||||||
// pdfCompositeScratch 复用像素及蒙版缓冲,不跨页持有活动内容
|
// pdfCompositeScratch 复用像素及蒙版缓冲,不跨页持有活动内容
|
||||||
type pdfCompositeScratch struct {
|
type pdfCompositeScratch struct {
|
||||||
pixels codecpdf.CompositeBuffers[pdfCompositePixel]
|
pixels codecpdf.CompositeBuffers[pdfCompositePixel]
|
||||||
@@ -157,7 +158,7 @@ type pdfCompositeScratch struct {
|
|||||||
// 返回: *pdfCompositeCache 当前页面缓存
|
// 返回: *pdfCompositeCache 当前页面缓存
|
||||||
func (p *pdfImporter) compositingCache() *pdfCompositeCache {
|
func (p *pdfImporter) compositingCache() *pdfCompositeCache {
|
||||||
if p.compositeCache == nil {
|
if p.compositeCache == nil {
|
||||||
p.compositeCache = &pdfCompositeCache{images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]codecpdf.MeshTriangle{}}
|
p.compositeCache = &pdfCompositeCache{resources: p.resources, images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]codecpdf.MeshTriangle{}}
|
||||||
}
|
}
|
||||||
return p.compositeCache
|
return p.compositeCache
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,7 @@ func (p *pdfImporter) compositingCache() *pdfCompositeCache {
|
|||||||
// 返回: *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] 单页场景缓存
|
// 返回: *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] 单页场景缓存
|
||||||
func (c *pdfCompositeCache) geometryCache() *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] {
|
func (c *pdfCompositeCache) geometryCache() *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] {
|
||||||
if c.geometry == nil {
|
if c.geometry == nil {
|
||||||
c.geometry = &renderCache[pdfCompositeSceneKey, pdfCompositeGeometry]{limit: 64 << 20}
|
c.geometry = resourceCache[pdfCompositeSceneKey, pdfCompositeGeometry](c.resources, 64<<20)
|
||||||
}
|
}
|
||||||
return c.geometry
|
return c.geometry
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ func (c *pdfCompositeCache) geometryCache() *renderCache[pdfCompositeSceneKey, p
|
|||||||
// 返回: *renderCache[[32]byte, GeometryPath] 裁剪缓存
|
// 返回: *renderCache[[32]byte, GeometryPath] 裁剪缓存
|
||||||
func (c *pdfCompositeCache) clipCache() *renderCache[[32]byte, GeometryPath] {
|
func (c *pdfCompositeCache) clipCache() *renderCache[[32]byte, GeometryPath] {
|
||||||
if c.clips == nil {
|
if c.clips == nil {
|
||||||
c.clips = &renderCache[[32]byte, GeometryPath]{limit: 8 << 20}
|
c.clips = resourceCache[[32]byte, GeometryPath](c.resources, 8<<20)
|
||||||
}
|
}
|
||||||
return c.clips
|
return c.clips
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
if p.formResources == nil || p.formResources.editor != p.editor {
|
if p.formResources == nil || p.formResources.editor != p.editor {
|
||||||
p.formResources = &pdfFormCache{editor: p.editor, entries: renderCache[[32]byte, pdfFormResource]{limit: 32 << 20}}
|
p.formResources = &pdfFormCache{editor: p.editor, entries: *resourceCache[[32]byte, pdfFormResource](p.resources, 32<<20)}
|
||||||
}
|
}
|
||||||
cache := p.formResources
|
cache := p.formResources
|
||||||
defer cache.reset()
|
defer cache.reset()
|
||||||
|
|||||||
+39
-5
@@ -54,6 +54,9 @@ func pdfGeometryPath(ctx context.Context, source pdfgo.Path, matrix pdfgo.Matrix
|
|||||||
// 入参: node 图元, stroke 是否描边
|
// 入参: node 图元, stroke 是否描边
|
||||||
// 返回: *RasterPage 覆盖场景, Box 页面区域, error 编译错误
|
// 返回: *RasterPage 覆盖场景, Box 页面区域, error 编译错误
|
||||||
func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*RasterPage, Box, error) {
|
func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*RasterPage, Box, error) {
|
||||||
|
if node.path != nil && pdfPaintedPathEmpty(node.path.Path) {
|
||||||
|
return nil, Box{}, p.emptyPaintedPath()
|
||||||
|
}
|
||||||
fallback := func() (*RasterPage, Box, error) {
|
fallback := func() (*RasterPage, Box, error) {
|
||||||
return p.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) })
|
return p.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) })
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,7 @@ func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*Rast
|
|||||||
return nil, Box{}, err
|
return nil, Box{}, err
|
||||||
}
|
}
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return nil, Box{}, fmt.Errorf("empty PDF painted path")
|
return nil, Box{}, p.emptyPaintedPath()
|
||||||
}
|
}
|
||||||
path = GeometryPath(basegeom.Close(basegeom.Path(path)))
|
path = GeometryPath(basegeom.Close(basegeom.Path(path)))
|
||||||
clip, err := p.coverageClip(style.Clips, geometry)
|
clip, err := p.coverageClip(style.Clips, geometry)
|
||||||
@@ -298,6 +301,12 @@ func (b *pdfPointBounds) box() Box {
|
|||||||
// 入参: mark PDF路径绘制信息
|
// 入参: mark PDF路径绘制信息
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (p *pdfImporter) path(mark pdfgo.PathMark) error {
|
func (p *pdfImporter) path(mark pdfgo.PathMark) error {
|
||||||
|
if err := p.ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pdfPaintedPathEmpty(mark.Path) {
|
||||||
|
return p.emptyPaintedPath()
|
||||||
|
}
|
||||||
if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" {
|
if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" {
|
||||||
return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)}
|
return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)}
|
||||||
}
|
}
|
||||||
@@ -308,6 +317,9 @@ func (p *pdfImporter) path(mark pdfgo.PathMark) error {
|
|||||||
// 入参: mark PDF路径绘制信息
|
// 入参: mark PDF路径绘制信息
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
||||||
|
if pdfPaintedPathEmpty(mark.Path) {
|
||||||
|
return p.emptyPaintedPath()
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
mark.Style, err = p.maskStyle(mark.Style)
|
mark.Style, err = p.maskStyle(mark.Style)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -331,9 +343,6 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if box == (Box{}) {
|
|
||||||
return fmt.Errorf("empty PDF painted path")
|
|
||||||
}
|
|
||||||
scale := math.Hypot(p.matrix[0], p.matrix[1])
|
scale := math.Hypot(p.matrix[0], p.matrix[1])
|
||||||
drawing, origin, ctm := *p, box, ""
|
drawing, origin, ctm := *p, box, ""
|
||||||
marginScale := scale
|
marginScale := scale
|
||||||
@@ -410,6 +419,31 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pdfPaintedPathEmpty 判断无坐标路径,不排除具有坐标的退化描边
|
||||||
|
// 入参: path PDF路径
|
||||||
|
// 返回: bool 是否没有坐标
|
||||||
|
func pdfPaintedPathEmpty(path pdfgo.Path) bool {
|
||||||
|
for _, segment := range path.Segments {
|
||||||
|
if segment.Operator != "C" || len(segment.Points) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyPaintedPath 按诊断策略忽略不绘制内容的空路径,严格模式仍拒绝
|
||||||
|
// 返回: error 严格模式或取消错误
|
||||||
|
func (p *pdfImporter) emptyPaintedPath() error {
|
||||||
|
if err := p.ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.warning == nil {
|
||||||
|
return fmt.Errorf("empty PDF painted path")
|
||||||
|
}
|
||||||
|
p.warning(pdfgo.Diagnostic{Message: "empty PDF painted path ignored"})
|
||||||
|
return p.ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误
|
// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误
|
||||||
// 入参: path 裁剪路径
|
// 入参: path 裁剪路径
|
||||||
// 返回: pdfClipPath 变换后的边界及编码, error 取消错误
|
// 返回: pdfClipPath 变换后的边界及编码, error 取消错误
|
||||||
@@ -419,7 +453,7 @@ func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) {
|
|||||||
return pdfClipPath{}, err
|
return pdfClipPath{}, err
|
||||||
}
|
}
|
||||||
if p.clipPaths == nil {
|
if p.clipPaths == nil {
|
||||||
p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20}
|
p.clipPaths = resourceCache[[32]byte, pdfClipPath](p.resources, 8<<20)
|
||||||
}
|
}
|
||||||
if cached, ok := p.clipPaths.get(key); ok {
|
if cached, ok := p.clipPaths.get(key); ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
|
|||||||
+20
-4
@@ -383,6 +383,7 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage,
|
|||||||
return nil, box, err
|
return nil, box, err
|
||||||
}
|
}
|
||||||
editor := NewEditor()
|
editor := NewEditor()
|
||||||
|
editor.SetResources(p.resources)
|
||||||
editor.SetRenderBackends(p.editor.Backends())
|
editor.SetRenderBackends(p.editor.Backends())
|
||||||
editor.SetFontDirs(p.editor.fontDirs...)
|
editor.SetFontDirs(p.editor.fontDirs...)
|
||||||
editor.SetFontFS(p.editor.fontFS...)
|
editor.SetFontFS(p.editor.fontFS...)
|
||||||
@@ -397,10 +398,13 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage,
|
|||||||
if p.symbolFonts == nil {
|
if p.symbolFonts == nil {
|
||||||
p.symbolFonts = make(map[*pdfgo.Font]*pdfImportedFont)
|
p.symbolFonts = make(map[*pdfgo.Font]*pdfImportedFont)
|
||||||
}
|
}
|
||||||
if p.clipPaths == nil {
|
if p.fontPrograms == nil {
|
||||||
p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20}
|
p.fontPrograms = make(map[pdfFontProgramKey]*pdfImportedFont)
|
||||||
}
|
}
|
||||||
local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, symbolFonts: p.symbolFonts, clipPaths: p.clipPaths, textOrigins: p.textOrigins}
|
if p.clipPaths == nil {
|
||||||
|
p.clipPaths = resourceCache[[32]byte, pdfClipPath](p.resources, 8<<20)
|
||||||
|
}
|
||||||
|
local := pdfImporter{resources: p.resources, ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, fontPrograms: p.fontPrograms, symbolFonts: p.symbolFonts, clipPaths: p.clipPaths, textOrigins: p.textOrigins}
|
||||||
if err := build(&local); err != nil {
|
if err := build(&local); err != nil {
|
||||||
return nil, box, err
|
return nil, box, err
|
||||||
}
|
}
|
||||||
@@ -413,7 +417,7 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage,
|
|||||||
if err := local.commitObjects(); err != nil {
|
if err := local.commitObjects(); err != nil {
|
||||||
return nil, box, err
|
return nil, box, err
|
||||||
}
|
}
|
||||||
reader, err := editor.resourceReader()
|
reader, err := local.temporaryReader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, box, err
|
return nil, box, err
|
||||||
}
|
}
|
||||||
@@ -434,6 +438,18 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage,
|
|||||||
return p.groupSceneBounds(scene)
|
return p.groupSceneBounds(scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// temporaryReader 为局部编译准备资源,带有保留信息时使用完整快照
|
||||||
|
// 返回: *Reader 独立阅读器, error 准备或取消错误
|
||||||
|
func (p *pdfImporter) temporaryReader() (*Reader, error) {
|
||||||
|
if err := p.ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.editor.source != nil || len(p.editor.origins) != 0 {
|
||||||
|
return p.editor.readerContext(p.ctx, nil)
|
||||||
|
}
|
||||||
|
return p.editor.resourceReader()
|
||||||
|
}
|
||||||
|
|
||||||
// groupSceneBounds 计算已编译场景在页面内的像素对齐区域
|
// groupSceneBounds 计算已编译场景在页面内的像素对齐区域
|
||||||
// 入参: scene 中立绘制场景
|
// 入参: scene 中立绘制场景
|
||||||
// 返回: *RasterPage 非空场景, Box 页面区域, error 度量错误
|
// 返回: *RasterPage 非空场景, Box 页面区域, error 度量错误
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
|||||||
}
|
}
|
||||||
if cacheable {
|
if cacheable {
|
||||||
if p.imageContents == nil {
|
if p.imageContents == nil {
|
||||||
p.imageContents = &renderCache[pdfImageContentKey, pdfImageContent]{limit: 8 << 20}
|
p.imageContents = resourceCache[pdfImageContentKey, pdfImageContent](p.resources, 8<<20)
|
||||||
}
|
}
|
||||||
if cached, found := p.imageContents.get(contentKey); found && cached.matches(source) {
|
if cached, found := p.imageContents.get(contentKey); found && cached.matches(source) {
|
||||||
id = cached.id
|
id = cached.id
|
||||||
|
|||||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user