Compare commits

...
126 Commits
Author SHA1 Message Date
xiaoqidun 385d7aa531 feat(编辑能力): 支持拖拽时画布自动跟随
build.yaml / build (push) Successful in 43s
2026-10-29 17:26:46 +08:00
xiaoqidun 94157b10c3 fix(编辑能力): 修复页外文字框拖拽
build.yaml / build (push) Successful in 45s
2026-10-29 16:59:05 +08:00
xiaoqidun aebcda51a3 fix(编辑能力): 修复旋转后纵向滚动回弹
build.yaml / build (push) Successful in 44s
2026-10-29 16:44:01 +08:00
xiaoqidun 19084bb40b fix(编辑能力): 修复横向滚动回弹 2026-10-29 16:37:35 +08:00
xiaoqidun 545b697310 fix(编辑能力): 修复文字编辑查找快捷键
build.yaml / build (push) Successful in 47s
2026-10-29 16:25:24 +08:00
xiaoqidun 1a25472b43 fix(编辑能力): 修复搜索焦点抢占问题 2026-10-29 16:17:41 +08:00
xiaoqidun af80fcc9ba fix(编辑能力): 修复全选及全局快捷键 2026-10-29 16:09:14 +08:00
xiaoqidun 838a769c5b fix(编辑能力): 修复输入状态下标签快捷键
build.yaml / build (push) Successful in 47s
2026-10-29 15:01:52 +08:00
xiaoqidun 5b4f2472f7 fix(编辑能力): 修复快捷键遗漏及跨文档干扰
build.yaml / build (push) Successful in 45s
2026-10-29 14:52:40 +08:00
xiaoqidun fb51fd3b07 fix(编辑能力): 修复工具栏复制剪切粘贴失效
build.yaml / build (push) Successful in 45s
2026-10-29 14:30:58 +08:00
xiaoqidun 80e548f93e fix(编辑能力): 修复工具栏撤销快捷键失效
build.yaml / build (push) Successful in 48s
2026-10-29 14:16:31 +08:00
xiaoqidun 9c3a04b7eb feat(编辑能力): 优化分布操作,提前禁用无效方向
build.yaml / build (push) Successful in 46s
2026-10-29 13:54:58 +08:00
xiaoqidun 9bb6c9141a feat(基础镜像): 升级到alpine:3.24.3
build.yaml / build (push) Successful in 44s
2026-10-29 08:46:31 +08:00
xiaoqidun 4320b53d4a fix(字体处理): 修复字体补全误配与共享释放
build.yaml / build (push) Successful in 49s
2026-10-28 22:07:10 +08:00
xiaoqidun e5d7f577c7 fix(字体处理): 修复字体续建、替换与复制异常 2026-10-28 21:59:44 +08:00
xiaoqidun 7a23dcc6dd feat(字体处理): 统一字体名称,优化粗斜体选择
build.yaml / build (push) Successful in 54s
2026-10-28 21:33:26 +08:00
xiaoqidun 12aaada982 fix(编辑能力): 修复字号间距字体等提交丢失
build.yaml / build (push) Successful in 45s
2026-10-28 20:00:15 +08:00
xiaoqidun 62631472ea fix(编辑能力): 修复底色及其他颜色提交异常
build.yaml / build (push) Successful in 47s
2026-10-28 19:23:09 +08:00
xiaoqidun 9d6bf3e87f feat(字体选择): 默认字体优先中文
build.yaml / build (push) Successful in 47s
2026-10-28 18:50:10 +08:00
xiaoqidun f8c8259502 feat(字体处理): 优化字体预加载与搜索选中行为
build.yaml / build (push) Successful in 47s
2026-10-28 16:43:04 +08:00
xiaoqidun f4429b126a fix(编辑能力): 修复字体引用隔离与资源释放
build.yaml / build (push) Successful in 49s
2026-10-28 16:07:41 +08:00
xiaoqidun 50898934dc fix(编辑能力): 修复混排字体选择与统一 2026-10-28 14:22:29 +08:00
xiaoqidun c2b0339fdf feat(编辑能力): 统一文字格式与段落编辑
build.yaml / build (push) Successful in 46s
2026-10-28 14:10:32 +08:00
xiaoqidun e5658de69d fix(编辑能力): 修正文字缩放、拖动及组合排版
build.yaml / build (push) Successful in 42s
2026-10-28 13:28:33 +08:00
xiaoqidun 057bcba868 fix(编辑能力): 修正文字编辑,统一元数据命名
build.yaml / build (push) Successful in 48s
2026-10-28 13:05:06 +08:00
xiaoqidun 89a7ef04c7 feat(编辑能力): 统一文字编辑,完善排版保存
build.yaml / build (push) Successful in 43s
2026-10-28 12:44:31 +08:00
xiaoqidun e35533859c fix(编辑能力): 修正文字边框与改宽手柄错位 2026-10-28 11:57:28 +08:00
xiaoqidun 1215f7aa08 fix(编辑能力): 修复文本框留白与缩放异常 2026-10-28 11:43:09 +08:00
xiaoqidun 48d1d29b7e fix(编辑能力): 修复编辑选区越框显示
build.yaml / build (push) Successful in 48s
2026-10-28 10:24:24 +08:00
xiaoqidun ba68acbc60 fix(编辑能力): 修复文本框对齐与改宽异常
build.yaml / build (push) Successful in 45s
2026-10-28 10:06:12 +08:00
xiaoqidun a75b90dffc feat(编辑能力): 统一文本框自适应与拖拽调整 2026-10-28 09:44:33 +08:00
xiaoqidun da3f64c74a fix(编辑能力): 修正文本框缩进与组合宽度
build.yaml / build (push) Successful in 47s
2026-10-28 08:19:47 +08:00
xiaoqidun 7e3f2c468a fix(编辑能力): 修正文本框自适应与对齐
build.yaml / build (push) Successful in 45s
2026-10-27 23:45:29 +08:00
xiaoqidun d384527371 fix(编辑能力): 修复对齐时文本框跳动变宽
build.yaml / build (push) Successful in 44s
2026-10-27 23:10:51 +08:00
xiaoqidun 2dcbafb70d fix(编辑能力): 修复文本对齐与转换预览
build.yaml / build (push) Successful in 44s
2026-10-27 22:47:46 +08:00
xiaoqidun f246b0f543 fix(编辑能力): 修复文本对齐与转换偏差
build.yaml / build (push) Successful in 48s
2026-10-27 22:38:16 +08:00
xiaoqidun dfea3d60fd fix(编辑能力): 修复段落对齐、继承与保存问题 2026-10-27 22:19:19 +08:00
xiaoqidun e91e146b50 feat(编辑能力): 补齐分散对齐与段落即时调整 2026-10-27 21:42:05 +08:00
xiaoqidun f2c71c5acf fix(编辑能力): 修复签名依赖与多文档显示
build.yaml / build (push) Successful in 46s
2026-10-27 20:26:23 +08:00
xiaoqidun b59d82a68a feat(编辑能力): 支持签名移除清空及撤销重做 2026-10-27 20:12:49 +08:00
xiaoqidun 44eb9e20b9 fix(修复问题): 修复注解重复等待与进度丢失
build.yaml / build (push) Successful in 46s
2026-10-27 18:25:56 +08:00
xiaoqidun bc71750c78 feat(体验提升): 优化注解加载与侧栏切换 2026-10-27 18:17:42 +08:00
xiaoqidun 143d5e7f92 fix(修复问题): 修复多文档资源与粘贴状态串用
build.yaml / build (push) Successful in 46s
2026-10-27 17:56:48 +08:00
xiaoqidun f76a1ecd1a fix(编辑能力): 修复续搜、撤销及字体隔离
build.yaml / build (push) Successful in 47s
2026-10-27 17:24:29 +08:00
xiaoqidun af8b64e748 feat(阅读编辑): 完善后台刷新、选取与文字编辑
build.yaml / build (push) Successful in 46s
2026-10-27 15:08:25 +08:00
xiaoqidun e67f329776 fix(问题修复): 修复复制隔离与快照释放
build.yaml / build (push) Successful in 47s
2026-10-27 14:35:23 +08:00
xiaoqidun 7bc31b6b84 feat(阅读编辑): 优化进度、框选、搜索与复制
build.yaml / build (push) Successful in 45s
2026-10-27 14:19:36 +08:00
xiaoqidun dfd4f733f9 fix(编辑能力): 修复复制粘贴与嵌套样式
build.yaml / build (push) Successful in 46s
2026-10-27 11:54:18 +08:00
xiaoqidun 88adea348b feat(编辑能力): 完善跨文档复制、样式与选取 2026-10-27 11:25:47 +08:00
xiaoqidun 20374f52f1 fix(编辑能力): 修复组合复制与跨标签编辑问题
build.yaml / build (push) Successful in 1m43s
2026-10-27 10:04:17 +08:00
xiaoqidun 6dae7bdc20 feat(编辑能力): 支持组合内容直接编辑 2026-10-27 09:47:49 +08:00
xiaoqidun 9ee47731c9 fix(编辑能力): 修复单字编辑框多余留白
build.yaml / build (push) Successful in 46s
2026-10-26 22:23:24 +08:00
xiaoqidun 4fee3dfd8e fix(编辑能力): 修复多版本字体裁剪与资源清理
build.yaml / build (push) Successful in 44s
2026-10-26 22:12:22 +08:00
xiaoqidun f322daa616 fix(编辑能力): 修复合字编辑显示异常 2026-10-26 21:47:19 +08:00
xiaoqidun 04efce42d8 fix(编辑能力): 修复文字编辑光标越界
build.yaml / build (push) Successful in 48s
2026-10-26 21:19:26 +08:00
xiaoqidun 41a50d9e96 feat(编辑能力): 完善搜索编辑打印与色彩处理
build.yaml / build (push) Successful in 4m37s
2026-10-26 21:01:38 +08:00
xiaoqidun f1864952ad feat(编辑能力): 优化编辑响应与资源复用
build.yaml / build (push) Successful in 45s
2026-10-26 19:28:10 +08:00
xiaoqidun 413d41c31d feat(加载取消): 统一文档准备与取消流程
build.yaml / build (push) Successful in 49s
2026-10-26 18:52:35 +08:00
xiaoqidun fb4f03465b feat(强化加载): 支持PDF按页加载与增量转换
build.yaml / build (push) Successful in 44s
2026-10-26 17:21:15 +08:00
xiaoqidun 04d7d2d672 perf(性能提升): 优化字体并发与复杂页渲染 2026-10-26 14:42:35 +08:00
xiaoqidun 15a532a1ca perf(性能提升): 优化字体加载与页面渲染调度 2026-10-26 14:17:22 +08:00
xiaoqidun 1061b07942 fix(问题修复): 修复页面刷新与缩略图竞态
build.yaml / build (push) Successful in 45s
2026-10-26 13:45:47 +08:00
xiaoqidun 01e29618f4 perf(性能提升): 优化快速翻页与分帧呈现 2026-10-26 13:38:14 +08:00
xiaoqidun 51ab8846e3 fix(阅读视图): 修复阅读视图跨标签异步干扰
build.yaml / build (push) Successful in 48s
2026-10-26 12:31:51 +08:00
xiaoqidun 05d9657c7c feat(前端界面): 拆分阅读视图,保持原有体验
build.yaml / build (push) Successful in 44s
2026-10-26 11:33:02 +08:00
xiaoqidun cc7858b536 fix(问题修复): 修复批量转换及文档打开异常
build.yaml / build (push) Successful in 49s
2026-10-26 10:51:06 +08:00
xiaoqidun 4fc468a27d fix(窗口居中): 修正初始窗口视觉居中
build.yaml / build (push) Successful in 44s
2026-10-26 09:16:47 +08:00
xiaoqidun 630306e871 feat(窗口居中): 优化初始窗口居中适屏
build.yaml / build (push) Successful in 45s
2026-10-26 08:58:54 +08:00
xiaoqidun c351b80344 fix(窗口闪烁): 修复新建界面闪烁
build.yaml / build (push) Successful in 48s
2026-10-26 08:44:04 +08:00
xiaoqidun e66afe6b07 feat(构建版本): 构建日期统一使用中国时区
build.yaml / build (push) Successful in 44s
2026-10-25 23:29:18 +08:00
xiaoqidun cc274bc571 feat(软件信息): 弹窗标题统一为“软件信息”
build.yaml / build (push) Successful in 43s
2026-10-25 23:25:54 +08:00
xiaoqidun 730586b98f feat(转换能力): 转换沿用源日期,缺失留空 2026-10-25 23:19:57 +08:00
xiaoqidun 6832554ea3 fix(打开文档): 修复文档加载进度偶发隐藏
build.yaml / build (push) Successful in 45s
2026-10-25 23:15:23 +08:00
xiaoqidun 6601340fcc feat(前端界面): 统一空标签左侧栏显示行为
build.yaml / build (push) Successful in 43s
2026-10-25 22:52:18 +08:00
xiaoqidun 47347e5364 feat(文档属性): 统一全属性编辑与后端字段顺序
build.yaml / build (push) Successful in 43s
2026-10-25 22:50:08 +08:00
xiaoqidun 7414a00305 feat(前端界面): 恢复原版界面,保留全属性编辑
build.yaml / build (push) Successful in 42s
2026-10-25 22:38:31 +08:00
xiaoqidun 4573cb942b feat(前端界面): 合并右侧分组,恢复连续滚动
build.yaml / build (push) Successful in 43s
2026-10-25 22:25:09 +08:00
xiaoqidun 15c64ed6f2 feat(前端界面): 统一右侧栏按需显示规则
build.yaml / build (push) Successful in 43s
2026-10-25 22:14:43 +08:00
xiaoqidun 3816226ba5 feat(文档属性): 统一属性读写及前后端字段顺序
build.yaml / build (push) Successful in 46s
2026-10-25 21:52:31 +08:00
xiaoqidun 3607c15162 feat(前端界面): 优化属性文案与列表对齐
build.yaml / build (push) Successful in 46s
2026-10-25 21:42:30 +08:00
xiaoqidun 8cbf0546ee feat(前端界面): 优化文档属性文案与标签对齐 2026-10-25 21:37:19 +08:00
xiaoqidun 411171bc60 feat(前端界面): 恢复签名信息标签对齐 2026-10-25 21:33:22 +08:00
xiaoqidun 2eaf5366bf feat(前端界面): 新增安全标签,集中加密与签名 2026-10-25 21:28:12 +08:00
xiaoqidun 17383adab6 feat(前端界面): 右侧栏分为文档与字体两页 2026-10-25 21:22:30 +08:00
xiaoqidun f77899e576 feat(前端界面): 恢复简短标签并分组展示属性 2026-10-25 21:12:09 +08:00
xiaoqidun 94983ee23c feat(前端界面): 完善文档信息文案与界面布局
build.yaml / build (push) Successful in 45s
2026-10-25 20:59:24 +08:00
xiaoqidun d9c334e8aa feat(前端界面): 统一元数据字段顺序与对齐 2026-10-25 20:52:16 +08:00
xiaoqidun 6063492bd7 feat(前端界面): 统一文档信息文案与标签对齐 2026-10-25 20:46:40 +08:00
xiaoqidun 2cec0b17d3 feat(编辑能力): 完善元数据全字段展示与编辑
build.yaml / build (push) Successful in 42s
2026-10-25 20:39:50 +08:00
xiaoqidun 986295ae57 docs(主要能力): 按照当前实际情况更新主要能力
build.yaml / build (push) Successful in 46s
2026-10-25 19:30:00 +08:00
xiaoqidun 92326d0a14 feat(桌面窗口): 按当前桌面复用窗口
build.yaml / build (push) Successful in 43s
2026-10-25 18:48:25 +08:00
xiaoqidun 0d6a21fe9a feat(窗口处理): 完善窗口恢复与启动时序
build.yaml / build (push) Successful in 43s
2026-10-25 18:11:52 +08:00
xiaoqidun 1de949abfc feat(原生桌面): 复用窗口新增标签
build.yaml / build (push) Successful in 45s
2026-10-25 17:33:09 +08:00
xiaoqidun 1c576bde18 fix(原生桌面): 修复任务栏重复图标
build.yaml / build (push) Successful in 44s
2026-10-25 16:33:44 +08:00
xiaoqidun 8774ac67f1 fix(原生桌面): 修复组合参数解析
build.yaml / build (push) Successful in 45s
2026-10-25 16:27:33 +08:00
xiaoqidun 3ef0e36026 feat(原生桌面): 统一桌面双横线参数
build.yaml / build (push) Successful in 44s
2026-10-25 16:20:27 +08:00
xiaoqidun 19aa5523f8 feat(文件关联): 统一关联状态提示
build.yaml / build (push) Successful in 45s
2026-10-25 15:59:11 +08:00
xiaoqidun f0c0b9db94 feat(文件关联): 新增Windows文件关联管理
build.yaml / build (push) Successful in 45s
2026-10-25 15:48:46 +08:00
xiaoqidun df81d41d95 feat(升级依赖): 升级GUIGo
build.yaml / build (push) Successful in 44s
2026-10-25 14:23:45 +08:00
xiaoqidun 82b5275ad7 feat(软件标识): 统一双端应用标识 2026-10-25 14:17:25 +08:00
xiaoqidun 299229c584 fix(打印能力): 修复后台打印状态串扰
build.yaml / build (push) Successful in 43s
2026-10-25 13:39:56 +08:00
xiaoqidun 75995cd8dc feat(打印能力): 打印后台运行,标签自由切换
build.yaml / build (push) Successful in 46s
2026-10-25 13:29:31 +08:00
xiaoqidun b875b885f1 feat(窗口标签): 空标签统一显示“新标签”
build.yaml / build (push) Successful in 1m0s
2026-10-25 13:08:40 +08:00
xiaoqidun c4345420d5 feat(阅读渲染): 阅读状态按标签独立并同步
build.yaml / build (push) Successful in 47s
2026-10-25 12:58:50 +08:00
xiaoqidun 3f8240d3c1 feat(打印能力): 统一设备属性按钮尺寸与对齐
build.yaml / build (push) Successful in 43s
2026-10-25 00:03:20 +08:00
xiaoqidun 410df7c3b0 feat(打印能力): 打印设置临时记忆
build.yaml / build (push) Successful in 44s
2026-10-24 23:47:34 +08:00
xiaoqidun 89e080a9fc feat(打印能力): DPI与上方选项同宽对齐
build.yaml / build (push) Successful in 46s
2026-10-24 23:12:26 +08:00
xiaoqidun 8e8aca399b feat(打印能力): 统一进度窗样式,适配窄屏 2026-10-24 23:05:28 +08:00
xiaoqidun a82d5858bd feat(打印能力): 统一DPI显示与勾选项对齐
build.yaml / build (push) Successful in 43s
2026-10-24 23:01:01 +08:00
xiaoqidun 297e093457 fix(打印能力): 修复所选页面打印 2026-10-24 22:58:25 +08:00
xiaoqidun 7b4eb5ff60 feat(打印能力): 统一打印进度,优化提示布局
build.yaml / build (push) Successful in 46s
2026-10-24 22:44:36 +08:00
xiaoqidun 2984b07c83 feat(打印能力): 统一打印右侧控件对齐
build.yaml / build (push) Successful in 45s
2026-10-24 22:04:19 +08:00
xiaoqidun 865ddd47f4 feat(打印能力): 统一打印精度与预览边距
build.yaml / build (push) Successful in 45s
2026-10-24 21:59:32 +08:00
xiaoqidun a022c3e636 feat(打印能力): 优化打印文案、选项与底栏布局
build.yaml / build (push) Successful in 43s
2026-10-24 21:50:22 +08:00
xiaoqidun 615f94bcac fix(打印能力): 修复打印预览适合缩放残留滚动条
build.yaml / build (push) Successful in 45s
2026-10-24 21:37:31 +08:00
xiaoqidun e73db10de1 fix(打印能力): 修复双端打印差异
build.yaml / build (push) Successful in 48s
2026-10-24 21:19:49 +08:00
xiaoqidun 0ae9ad6562 feat(打印能力): 新增打印并优化界面布局
build.yaml / build (push) Successful in 46s
2026-10-24 20:53:46 +08:00
xiaoqidun 799041171b feat(升级依赖): 升级gmsm、pdfgo
build.yaml / build (push) Successful in 2m46s
2026-10-24 01:18:22 +08:00
xiaoqidun 86f34bbdea fix(编辑能力): 修复嵌入字体编辑报错
build.yaml / build (push) Successful in 45s
2026-10-24 00:56:18 +08:00
xiaoqidun 0021228898 fix(编辑能力): 修复文字编辑与模式切换
build.yaml / build (push) Successful in 47s
2026-10-24 00:05:33 +08:00
xiaoqidun a9b26b3257 feat(前端界面): 微调软件信息文案
build.yaml / build (push) Successful in 49s
2026-10-23 23:25:20 +08:00
xiaoqidun 5eed2d4e07 feat(窗口标签): 标签栏增加细上边线
build.yaml / build (push) Successful in 43s
2026-10-23 23:10:10 +08:00
xiaoqidun f830bcefa7 feat(更新图标): 更新图标对齐工具栏
build.yaml / build (push) Successful in 45s
2026-10-23 22:57:03 +08:00
xiaoqidun 4c475e9b67 feat(窗口标签): 矩形标签选中优化
build.yaml / build (push) Successful in 47s
2026-10-23 22:48:33 +08:00
xiaoqidun c10aa4dda7 fix(更新图标): 修复首次更新按钮失效
build.yaml / build (push) Successful in 47s
2026-10-23 22:39:41 +08:00
xiaoqidun 69e2e52b96 feat(更新图标): 统一更新图标样式 2026-10-23 22:32:34 +08:00
84 changed files with 11192 additions and 3258 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# 基础镜像 # 基础镜像
FROM alpine:3.24.2 FROM alpine:3.24.3
# 作者信息 # 作者信息
LABEL authors="肖其顿 (XIAO QI DUN)" LABEL authors="肖其顿 (XIAO QI DUN)"
+16 -13
View File
@@ -3,25 +3,28 @@
# 主要能力 # 主要能力
- 文档处理:创建、读写、拆分、合并、压缩 - 文档处理:创建、读写、拆分、合并、压缩
- 渲染转换:矢量、位图、导入、导出、并行 - 文档阅读:搜索、缩放、旋转、双页、连续
- 文档编辑:选区、替换、重排、撤销、重做 - 文档管理:新建、导入、切换、排序、删除
- 电子发票:提取、创建、编辑、校验、同步 - 版本管理:创建、改名、切换、另存、删除
- 文档安全:签名、签章、验签、加密、解密
- 文档管理:添加、导入、切换、排序、删除
- 版本管理:创建、切换、改名、另存、删除
- 页面管理:添加、导入、复制、排序、删除 - 页面管理:添加、导入、复制、排序、删除
- 文字处理:插入、编辑、排版、搜索、提取 - 文档编辑:选区、重排、事务、撤销、重做
- 文字样式:选区、混排、装饰、阴影、底色 - 文字处理:插入、编辑、替换、排版、提取
- 文字样式:混排、粗体、斜体、阴影、底色
- 字体处理:匹配、内嵌、外置、子集、提取 - 字体处理:匹配、内嵌、外置、子集、提取
- 图片处理:插入、替换、缩放、裁剪、提取 - 图片处理:插入、替换、缩放、裁剪、提取
- 图形处理:绘制、变换、组合、对齐、分布 - 图形处理:绘制、变换、组合、对齐、分布
- 色彩管理:空间、配置、转换、渐变、透明 - 色彩管理:调色、渐变、图案、透明、转换
- 文档结构:目录、书签、模板、标引、扩展 - 文档结构:图层、模板、目录、书签、标引
- 资源管理:字体、图片、色彩、媒体、附件 - 资源管理:导入、绑定、替换、提取、清理
- 注解管理:批注、高亮、水印、印章、链接 - 注解管理:批注、高亮、水印、印章、链接
- 动作管理:跳转、链接、附件、音频、视频 - 交互动作:跳转、链接、附件、音频、视频
- 电子发票:提取、创建、编辑、校验、同步
- 文档安全:签名、签章、验签、加密、解密
- 三维处理:解析、材质、纹理、动画、渲染 - 三维处理:解析、材质、纹理、动画、渲染
- 数据读写:流式、缓存、事务、保存、另存 - 页面渲染:矢量、位图、裁剪、蒙版、合成
- 批量转换:选页、并行、进度、取消、打包
- 打印输出:预览、双面、合印、小册、海报
- 应用交互:标签、链接、关联、拖放、复用
# 在线使用 # 在线使用
[OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务 [OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务
+2 -2
View File
@@ -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_util.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_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 ofdgo_util.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_util.js ofdgo_view.js
var NativeFS embed.FS var NativeFS embed.FS
+64 -9
View File
@@ -31,6 +31,7 @@
<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>
@@ -81,7 +82,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>
@@ -140,7 +140,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>
@@ -195,12 +195,18 @@
</div> </div>
<dialog id="aboutPanel" class="form-dialog about-dialog"> <dialog id="aboutPanel" class="form-dialog about-dialog">
<form method="dialog"> <form method="dialog">
<h2 id="aboutTitle" class="form-title">OFDGo</h2> <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><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/" 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://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> <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> </form>
<section id="associationSection" class="about-associations" hidden>
<div class="association-heading">文件关联</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>
<dialog id="closePanel" class="form-dialog confirm-dialog"> <dialog id="closePanel" class="form-dialog confirm-dialog">
<form method="dialog"> <form method="dialog">
@@ -482,10 +488,21 @@
<dialog id="infoPanel" class="form-dialog"> <dialog id="infoPanel" class="form-dialog">
<form id="infoForm"> <form id="infoForm">
<h2 id="infoPanelTitle" class="form-title">文档信息</h2> <h2 id="infoPanelTitle" class="form-title">文档信息</h2>
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label> <label class="form-row"><span>文档标识</span><input id="infoDocId" type="text"></label>
<label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label> <label class="form-row"><span>标题</span><textarea id="infoTitle" rows="1" autofocus></textarea></label>
<label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label> <label class="form-row"><span>作者</span><textarea id="infoAuthor" rows="1"></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="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>
@@ -671,6 +688,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>
@@ -822,7 +876,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>
@@ -856,6 +910,7 @@
</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>
+219 -14
View File
@@ -26,6 +26,92 @@
--shadow-page: 0 14px 30px rgba(31, 36, 40, 0.16); --shadow-page: 0 14px 30px rgba(31, 36, 40, 0.16);
} }
.form-dialog.print-dialog {
width: 920px;
}
.print-content {
display: grid;
grid-template-columns: 310px minmax(0, 1fr);
height: min(62dvh, 660px);
min-height: 240px;
gap: 12px;
}
.print-settings {
flex: 1;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
overflow: auto;
border: 0;
}
.print-settings .form-row {
display: grid;
grid-template-columns: 4em minmax(0, 1fr);
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.print-settings [hidden] { display: none !important; }
.print-dialog :is(input, select, button):focus-visible { outline-offset: -2px; }
.print-field { display: flex; min-width: 0; align-items: center; gap: 6px; }
.print-dialog .print-field > :is(input, select) { flex: 1 1 0; min-width: 0; width: 0; }
.print-field > :is(.print-check, button) { flex: 0 0 calc(5em + 4px); }
.print-field > button { height: var(--control-height); font: inherit; }
.print-field:has(#printBackReverse) .print-check:first-child { flex: 1; justify-content: start; }
.print-field > span { flex: none; color: var(--text-muted); white-space: nowrap; }
.print-dialog .print-settings .print-margins input { padding: 0 3px; text-align: center; }
.print-check { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.print-options { display: grid; grid-template-columns: repeat(3, max-content); grid-auto-rows: var(--control-height); align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; }
.print-options label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.print-field > .print-check { justify-content: flex-end; }
.print-options .form-select select { flex: 1; width: 0; }
.print-check input, .print-options input { width: 1em; height: 1em; margin: 0; accent-color: var(--accent); }
.print-settings-panel, .print-preview { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
.print-dialog .form-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.print-preview-stage { position: relative; display: flex; flex: 1; min-height: 0; }
.print-preview-stage .print-progress { position: absolute; bottom: 12px; max-width: calc(100% - 24px); }
.print-progress:has(.progress-label:empty) { display: none; }
.print-progress:has(.progress-spinner[hidden]) { grid-template-columns: minmax(0, 1fr); }
.print-preview-page {
display: grid;
flex: 1;
min-width: 0;
min-height: 0;
justify-items: safe center;
align-items: safe center;
padding: 12px;
overflow: hidden;
background: #e1e6e9;
border: 1px solid var(--line);
}
.print-preview-page img { display: block; max-width: none; background: white; box-shadow: var(--shadow-soft); }
.print-preview-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 5px; }
#ofdgoPrintDocument { display: none; }
@media (max-width: 680px) {
.print-content { grid-template-columns: minmax(0, 1fr); height: auto; min-height: 0; }
.print-settings { flex: none; max-height: 34dvh; }
.print-preview-stage { flex: none; height: 32dvh; min-height: 160px; }
.print-preview-tools { justify-content: space-between; }
}
@media print {
html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; margin: 0 !important; padding: 0 !important; background: white !important; }
body > :not(#ofdgoPrintDocument) { display: none !important; }
dialog { display: none !important; }
#ofdgoPrintDocument { display: block !important; color: black; background: white; }
#ofdgoPrintDocument section { display: block; margin: 0; padding: 0; overflow: hidden; break-after: page; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
#ofdgoPrintDocument section:last-child { break-after: auto; }
#ofdgoPrintDocument img { display: block; width: 100%; height: 100%; }
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -39,7 +125,8 @@
min-height: 0; min-height: 0;
flex: 0 0 auto; flex: 0 0 auto;
padding-left: var(--titlebar-left, 0px); padding-left: var(--titlebar-left, 0px);
padding-right: var(--titlebar-right, 8px); padding-right: var(--titlebar-right, 10px);
border-top: 1px solid var(--line);
background: var(--titlebar-background); background: var(--titlebar-background);
color: var(--titlebar-foreground); color: var(--titlebar-foreground);
user-select: none; user-select: none;
@@ -74,7 +161,6 @@
.document-tab[data-selected="true"] { .document-tab[data-selected="true"] {
background: var(--bar-bg); background: var(--bar-bg);
color: var(--titlebar-foreground); color: var(--titlebar-foreground);
box-shadow: inset 0 2px var(--accent);
} }
.document-tab-name { .document-tab-name {
@@ -570,8 +656,6 @@ a:focus-visible {
.app-update { .app-update {
margin-left: var(--tab-action-gap); margin-left: var(--tab-action-gap);
margin-right: -6px;
color: var(--accent-strong);
} }
.app-update[hidden] { .app-update[hidden] {
@@ -656,6 +740,7 @@ button:not(:disabled):hover {
.save-as-icon, .save-as-icon,
.edit-icon, .edit-icon,
.convert-icon, .convert-icon,
.print-icon,
.pages-icon, .pages-icon,
.info-icon { .info-icon {
width: 16px; width: 16px;
@@ -665,7 +750,7 @@ button:not(:disabled):hover {
} }
.open-icon { .open-icon {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V4h6l3 3h9v13H3Z'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V3h6l3 4h9v14H3Z'/%3E%3C/svg%3E") center / contain no-repeat;
} }
.close-icon { .close-icon {
@@ -696,6 +781,10 @@ button:not(:disabled):hover {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7h18l-4-4M21 17H3l4 4'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7h18l-4-4M21 17H3l4 4'/%3E%3C/svg%3E") center / contain no-repeat;
} }
.print-icon {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9V3h12v6M6 17H3V9h18v8h-3M6 13h12v8H6Z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.pages-icon { .pages-icon {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3h18M3 12h18M3 21h18'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3h18M3 12h18M3 21h18'/%3E%3C/svg%3E") center / contain no-repeat;
} }
@@ -875,6 +964,44 @@ button:not(:disabled):hover {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.about-associations {
padding: 12px;
border-top: 1px solid var(--line);
}
.association-heading {
margin-bottom: 12px;
font-weight: 600;
white-space: nowrap;
}
.association-row {
display: grid;
grid-template-columns: 4em minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.association-row + .association-row {
margin-top: 12px;
}
.association-state {
color: var(--text-muted);
}
.association-actions {
display: flex;
gap: 8px;
}
#associationMessage {
margin-top: 12px;
white-space: nowrap;
overflow-x: auto;
}
.form-title { .form-title {
margin: -12px -12px 12px; margin: -12px -12px 12px;
padding: 10px 12px; padding: 10px 12px;
@@ -1394,9 +1521,10 @@ button:not(:disabled):hover {
outline-offset: 2px; outline-offset: 2px;
} }
.info-custom-row input, .info-rows textarea,
.gradient-stop-row input[type="number"], .gradient-stop-row input[type="number"],
.structure-attribute > input, .structure-attribute > input,
.print-options select,
.form-row input[type="text"], .form-row input[type="text"],
.form-row input[type="password"], .form-row input[type="password"],
.form-row input[type="number"], .form-row input[type="number"],
@@ -1413,7 +1541,7 @@ button:not(:disabled):hover {
color: #1f2428; color: #1f2428;
} }
.form-row .form-select select { .form-select select {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
color: transparent; color: transparent;
@@ -1439,19 +1567,71 @@ button:not(:disabled):hover {
margin-bottom: 12px; margin-bottom: 12px;
} }
#infoCustomRows { #infoPanel {
width: 480px;
}
#infoPanel .form-row,
#infoPanel summary {
grid-template-columns: 4em minmax(0, 1fr);
}
#infoPanel .form-row > span,
#infoPanel summary > span {
text-align-last: justify;
white-space: nowrap;
}
#infoPanel summary {
display: grid;
align-items: center;
gap: 12px;
min-height: var(--control-height);
}
#infoPanel summary::after {
content: "▸";
justify-self: end;
}
#infoPanel details[open] > summary::after {
content: "▾";
}
#infoPanel details > .form-row:last-child {
margin-bottom: 0;
}
#infoPanel textarea {
height: auto;
min-height: var(--control-height);
padding: 3px 6px;
line-height: calc(var(--control-height) - 8px);
resize: vertical;
}
#infoPanel .form-actions {
grid-template-columns: minmax(0, 1fr) repeat(2, 4em);
}
.info-rows {
max-height: 240px; max-height: 240px;
overflow-y: auto; overflow-y: auto;
} }
.info-custom-row { .info-custom-row,
.info-keyword-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) var(--control-height); grid-template-columns: repeat(2, minmax(0, 1fr)) var(--control-height);
gap: 8px; gap: 8px;
margin: 8px 0; margin: 8px 0;
} }
#infoCustomRows > :first-child { .info-keyword-row {
grid-template-columns: minmax(0, 1fr) var(--control-height);
}
.info-rows > :first-child {
margin-top: 0; margin-top: 0;
} }
@@ -1490,7 +1670,7 @@ button:not(:disabled):hover {
width: 100%; width: 100%;
} }
#infoCustomRows :focus-visible, .info-rows :focus-visible,
#gradientStops :focus-visible { #gradientStops :focus-visible {
outline-offset: -2px; outline-offset: -2px;
} }
@@ -3050,6 +3230,10 @@ button.outline-link:hover {
pointer-events: none; pointer-events: none;
} }
.edit-object.edit-parent {
outline: 1px dashed var(--line);
}
.edit-text-source { .edit-text-source {
visibility: hidden; visibility: hidden;
} }
@@ -3078,13 +3262,34 @@ button.outline-link:hover {
text-indent: inherit; text-indent: inherit;
} }
.edit-text div::after,
.edit-text p::after {
content: "";
font-size: 0;
}
.edit-text span[data-glyph] { .edit-text span[data-glyph] {
position: relative; position: relative;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
.edit-text span[data-glyph]::selection {
background: transparent;
color: transparent;
-webkit-text-fill-color: transparent;
}
.edit-text span[data-glyph][data-selected] {
background: Highlight;
}
.edit-text span[data-glyph][data-selected]::after {
color: HighlightText;
}
.edit-text span[data-glyph]::after { .edit-text span[data-glyph]::after {
content: attr(data-glyph); content: attr(data-glyph);
letter-spacing: 0;
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@@ -4348,8 +4553,8 @@ audio.page-media {
--bar-pad: 5px 8px; --bar-pad: 5px 8px;
} }
.app-update { .document-titlebar {
margin-right: -8px; padding-right: var(--titlebar-right, 8px);
} }
.workspace { .workspace {
+1479 -2229
View File
File diff suppressed because it is too large. Load diff
+578 -164
View File
File diff suppressed because it is too large. Load diff
+100 -23
View File
@@ -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) {
+100 -24
View File
@@ -35,7 +35,7 @@ class BrowserScheduler {
} }
priority(task) { priority(task) {
return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload); return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload || !!task.data.background);
} }
submit(worker, data, transfer) { submit(worker, data, transfer) {
@@ -64,10 +64,16 @@ class BrowserScheduler {
if (data.id == null || data.type) return; if (data.id == null || data.type) return;
const key = requestKey(worker, data); const key = requestKey(worker, data);
const started = worker.requests.get(key); 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.requests.delete(key);
worker.operations.delete(key); worker.operations.delete(key);
if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1); if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1);
else if (started !== undefined && data.ok && performance.now() - started > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, 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() { drain() {
@@ -86,8 +92,8 @@ class BrowserScheduler {
const busy = [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name !== "ofdgoSaveSnapshot"); 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 snapshot = task.data.name === "ofdgoSaveSnapshot" && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name === "ofdgoSaveSnapshot");
const active = this.active(); const active = this.active();
const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload; 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)); 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 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; 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; } if (busy || snapshot || !available || !task.worker.primary && active && this.memory + (64 << 20) > this.budget) { i++; continue; }
@@ -294,12 +300,15 @@ class BrowserEngine {
const data = event.data; const data = event.data;
if (data.memory) this.memory = data.memory; if (data.memory) this.memory = data.memory;
if (this.source) this.parallelism = engineScheduler.limit; if (this.source) this.parallelism = engineScheduler.limit;
if (data.id != null && !data.type) this.requests.delete(data.id); const rendered = data.id != null && !data.type && this.requests.delete(data.id);
this.onmessage?.(event); this.onmessage?.(event);
if (rendered && data.ok) this.dispatchPages();
}; };
this.primary.onerror = event => this.onerror?.(event); this.primary.onerror = event => this.onerror?.(event);
this.primary.onmessageerror = event => this.onmessageerror?.(event); this.primary.onmessageerror = event => this.onmessageerror?.(event);
this.requests = new Map(); this.requests = new Map();
this.fontRequests = new Map();
this.primaryPages = new Set();
this.slots = new Set(); this.slots = new Set();
this.sequence = 0; this.sequence = 0;
this.revision = 0; this.revision = 0;
@@ -316,8 +325,9 @@ class BrowserEngine {
configurePages(source) { configurePages(source) {
const current = this.pageSource; const current = this.pageSource;
const fonts = (source?.fonts || []).map(font => font?.data !== undefined ? {name:font.name, data:font.data} : font); 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); 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.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts) return; 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(); this.invalidatePages();
if (!source || this.closed) return; if (!source || this.closed) return;
this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""}; this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""};
@@ -340,21 +350,26 @@ class BrowserEngine {
invalidatePages() { invalidatePages() {
this.revision++; this.revision++;
this.primaryPages.clear();
this.source = null; this.source = null;
this.pageSource = null; this.pageSource = null;
this.parallelism = 1; this.parallelism = 1;
this.startupTime = 0;
this.primary.pageTime = 0;
for (const slot of this.slots) this.closeSlot(slot); for (const slot of this.slots) this.closeSlot(slot);
} }
closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) { closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) {
if (!this.slots.delete(slot)) return; if (!this.slots.delete(slot)) return;
slot.worker.terminate(); slot.worker.terminate();
for (const [id, request] of this.fontRequests) if (request.worker === slot.worker) this.fontRequests.delete(id);
slot.rejectReady(error); slot.rejectReady(error);
for (const pending of slot.pending.values()) pending.reject(error); for (const pending of slot.pending.values()) pending.reject(error);
slot.pending.clear(); slot.pending.clear();
} }
createSlot(source) { createSlot(source) {
const started = performance.now();
const worker = createEngineWorker({page:true, url:this.workerURL}); const worker = createEngineWorker({page:true, url:this.workerURL});
worker.setForeground(this.foreground); worker.setForeground(this.foreground);
worker.pausePages(this.pagesPaused); worker.pausePages(this.pagesPaused);
@@ -368,6 +383,12 @@ class BrowserEngine {
if (this.source) this.parallelism = engineScheduler.limit; if (this.source) this.parallelism = engineScheduler.limit;
if (data.type === "ready") { resolve(); return; } if (data.type === "ready") { resolve(); return; }
if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); 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; if (data.type) return;
const request = slot.pending.get(data.id); const request = slot.pending.get(data.id);
if (!request) return; if (!request) return;
@@ -379,9 +400,12 @@ class BrowserEngine {
}); });
this.slots.add(slot); this.slots.add(slot);
slot.ready = ready.then(async () => { slot.ready = ready.then(async () => {
let opened = await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]); 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.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 ((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(() => {}); slot.ready.catch(() => {});
return slot; return slot;
@@ -397,48 +421,92 @@ class BrowserEngine {
}); });
} }
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) { postMessage(data, transfer) {
if (this.closed) return; 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) { if (!data.type && data.preload && this.pagesPaused) {
this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}}); this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
return; return;
} }
if (data.type === "cancel") { if (data.type === "cancel") {
const slot = this.requests.get(data.id); const slot = this.requests.get(data.id);
if (slot) this.closeSlot(slot); if (slot && slot !== this.primary) this.closeSlot(slot);
else this.primary.postMessage(data); else this.primary.postMessage(data);
return; return;
} }
if (!data.type && data.name !== "ofdgoRenderPage" && ![ if (!data.type && data.name !== "ofdgoRenderPage" && ![
"ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData", "ofdgoLoadPDF", "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData",
"ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets", "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets",
"ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles", "ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles",
"ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures", "ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures",
"ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice", "ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice",
"ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal", "ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal",
"ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument",
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion" "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
].includes(data.name)) this.invalidatePages(); ].includes(data.name)) this.invalidatePages();
const preferred = data.name === "ofdgoRenderPage" ? [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0])) : null; const slot = data.name === "ofdgoRenderPage" && this.source ? this.pageSlot(data) : null;
if (data.name !== "ofdgoRenderPage" || !this.source || !preferred && ![...this.requests.values()].includes(null)) { if (!slot) {
if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null); if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null);
this.primary.postMessage(data, transfer); this.primary.postMessage(data, transfer);
return; return;
} }
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]; this.renderSlot(slot, data, transfer);
const memory = engineScheduler.memory;
if (!slot && this.foreground && !this.pagesPaused && this.slots.size < this.parallelism - 1 && memory + Math.max(128 << 20, this.memory) <= this.budget) slot = this.createSlot(this.source);
if (!slot) {
this.requests.set(data.id, null);
this.primary.postMessage(data, transfer);
return;
} }
renderSlot(slot, data, transfer) {
slot.busy = true; slot.busy = true;
slot.requestID = data.id;
this.requests.set(data.id, slot); this.requests.set(data.id, slot);
const revision = this.revision; const revision = this.revision;
let fallback = false; let fallback = false;
slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => { slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => {
if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError"); 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.lastPage = data.args[0];
slot.pages.delete(slot.lastPage); slot.pages.delete(slot.lastPage);
slot.pages.add(slot.lastPage); slot.pages.add(slot.lastPage);
@@ -448,7 +516,7 @@ class BrowserEngine {
if (!this.closed && revision === this.revision && error.name !== "AbortError") { if (!this.closed && revision === this.revision && error.name !== "AbortError") {
this.closeSlot(slot, error); this.closeSlot(slot, error);
this.parallelism = Math.max(1, this.slots.size + 1); this.parallelism = Math.max(1, this.slots.size + 1);
this.requests.set(data.id, null); this.requests.set(data.id, this.primary);
fallback = true; fallback = true;
this.primary.postMessage(data, transfer); this.primary.postMessage(data, transfer);
return; return;
@@ -460,6 +528,7 @@ class BrowserEngine {
slot.worker.reservation = 0; slot.worker.reservation = 0;
const memory = engineScheduler.memory; const memory = engineScheduler.memory;
if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); } if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); }
this.dispatchPages();
engineScheduler.drain(); engineScheduler.drain();
}); });
} }
@@ -573,13 +642,13 @@ class NativeHost {
} }
} }
async decode(value, name = "", key = "", signal) { async decode(value, signal) {
const reads = []; const reads = [];
const visit = (item, field, replace) => { const visit = (item, field, replace) => {
if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return; if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return;
if (item.nativeFile) { if (item.nativeFile) {
const file = new NativeFile(item); const file = new NativeFile(item);
if (field === "file" || name === "ofdgoConvertPDF" && field === "bytes") replace(file); 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)))); else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes))));
return; return;
} }
@@ -587,7 +656,7 @@ class NativeHost {
if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result); if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result);
} }
}; };
visit(value, key, result => value = result); visit(value, "", result => value = result);
const results = await Promise.allSettled(reads); const results = await Promise.allSettled(reads);
const failed = results.find(result => result.status === "rejected"); const failed = results.find(result => result.status === "rejected");
if (failed) throw failed.reason; if (failed) throw failed.reason;
@@ -649,7 +718,7 @@ class NativeHost {
return; return;
} }
if (data.type === "font") return; if (data.type === "font") return;
if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoConvertPDF"].includes(data.name)) { if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoOpenPDF"].includes(data.name)) {
const input = data.args?.[0]; const input = data.args?.[0];
if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile); if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile);
} }
@@ -682,7 +751,7 @@ 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; }
@@ -725,6 +794,13 @@ class NativeHost {
} }
async start(onFiles, onError) { async start(onFiles, onError) {
let launching = Promise.resolve();
this.bridge.on("ofdgo.launch", values => {
launching = launching.then(async () => {
try { await onFiles(values.map(value => new NativeFile(value)), true); }
finally { await this.call("launch.done", {}); }
}).catch(onError);
});
this.bridge.on("ofdgo.files", async values => { this.bridge.on("ofdgo.files", async values => {
try { await onFiles(values.map(value => new NativeFile(value))); } try { await onFiles(values.map(value => new NativeFile(value))); }
catch (error) { onError(error); } catch (error) { onError(error); }
+493
View File
@@ -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);
}
}
+21 -10
View File
@@ -30,9 +30,9 @@ export class DocumentDialogs {
root.defaultView.addEventListener("resize", () => this.paint()); root.defaultView.addEventListener("resize", () => this.paint());
} }
register(panel) { register(panel, { shared = true, persistent = false } = {}) {
if (this.records.has(panel)) return panel; 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, clicks: new WeakMap(), initial: this.snapshot(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); this.records.set(panel, record);
panel.showModal = panel.show = () => this.openFor(this.owner, panel); panel.showModal = panel.show = () => this.openFor(this.owner, panel);
panel.close = value => { panel.close = value => {
@@ -91,6 +91,7 @@ export class DocumentDialogs {
this.unpaint(); this.unpaint();
this.records.get(panel).owner = tab; this.records.get(panel).owner = tab;
this.prepare(entry, panel); this.prepare(entry, panel);
if (!this.records.get(panel).shared) this.document.body.append(panel);
if (!panel.open) this.records.get(panel).show(); if (!panel.open) this.records.get(panel).show();
this.paint(); this.paint();
this.focus(); this.focus();
@@ -124,9 +125,9 @@ export class DocumentDialogs {
this.unpaint(); this.unpaint();
if (value !== undefined) panel.returnValue = String(value); if (value !== undefined) panel.returnValue = String(value);
panel.removeAttribute("open"); panel.removeAttribute("open");
entry.snapshot = this.snapshot(panel); if (this.records.get(panel).initial) entry.snapshot = this.snapshot(panel);
} }
const result = value === undefined ? visible ? panel.returnValue : entry.snapshot.returnValue : String(value); const result = value === undefined ? visible ? panel.returnValue : entry.snapshot?.returnValue ?? panel.returnValue : String(value);
state.stack = state.stack.filter(value => value !== panel); state.stack = state.stack.filter(value => value !== panel);
const resolve = entry.resolve; const resolve = entry.resolve;
entry.resolve = entry.promise = null; entry.resolve = entry.promise = null;
@@ -150,7 +151,11 @@ export class DocumentDialogs {
for (const [panel, entry] of this.current(tab)?.entries || []) { for (const [panel, entry] of this.current(tab)?.entries || []) {
const record = this.records.get(panel); const record = this.records.get(panel);
if (record.application || record.owner !== tab) continue; if (record.application || record.owner !== tab) continue;
entry.snapshot = this.snapshot(panel); 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"); panel.removeAttribute("open");
} }
this.owner = null; this.owner = null;
@@ -167,7 +172,8 @@ export class DocumentDialogs {
for (const [panel, entry] of state.entries) { for (const [panel, entry] of state.entries) {
const record = this.records.get(panel); const record = this.records.get(panel);
if (record.application) continue; if (record.application) continue;
this.applySnapshot(entry.snapshot, panel); if (record.initial) this.applySnapshot(entry.snapshot, panel);
else if (!record.shared) this.document.body.append(panel);
record.owner = tab; record.owner = tab;
} }
for (const [panel, entry] of state.entries) { for (const [panel, entry] of state.entries) {
@@ -193,8 +199,13 @@ export class DocumentDialogs {
for (const entry of state?.entries.values() || []) entry.resolve?.(null); for (const entry of state?.entries.values() || []) entry.resolve?.(null);
for (const [panel] of state?.entries || []) { for (const [panel] of state?.entries || []) {
const record = this.records.get(panel); 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.application || record.owner !== tab) continue;
this.applySnapshot(record.initial, panel); if (record.initial) this.applySnapshot(record.initial, panel);
record.owner = null; record.owner = null;
} }
state?.entries.clear(); state?.entries.clear();
@@ -303,11 +314,11 @@ export class DocumentDialogs {
focus(restore = false) { focus(restore = false) {
const panel = this.current()?.stack.at(-1); const panel = this.current()?.stack.at(-1);
if (!panel || this.applicationOpen()) return; if (!panel || this.applicationOpen()) return;
const saved = restore ? this.current().entries.get(panel)?.snapshot.focus : null; 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; 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 }); if (restore || !panel.contains(this.document.activeElement)) target.focus({ preventScroll: true });
const selection = restore && this.current().entries.get(panel)?.snapshot.selection; 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 (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; } 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
+30 -9
View File
@@ -45,7 +45,7 @@ self.onmessage = ({ data }) => {
if (engine.closed) return; if (engine.closed) return;
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID)); const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
if (!opened.ok) throw new Error(opened.error); if (!opened.ok) throw new Error(opened.error);
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends")); const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends", null));
if (!backends.ok) throw new Error(backends.error); if (!backends.ok) throw new Error(backends.error);
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength }); if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
}).catch(error => { }).catch(error => {
@@ -141,7 +141,7 @@ async function handleBatchMessage(engine, { 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(await globalThis.ofdgoEngineCall(engine.id, "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;
@@ -178,7 +178,7 @@ async function handleBatchMessage(engine, { id, name, args }) {
chunks = []; chunks = [];
} }
}; };
payload = await globalThis.ofdgoEngineCall(engine.id, "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) => {
postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength }); postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
finish(done); finish(done);
@@ -188,7 +188,7 @@ async function handleBatchMessage(engine, { id, name, args }) {
} 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.ofdgoEngineCall(engine.id, "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) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); 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);
@@ -235,17 +235,21 @@ async function handleMessage(engine, { id, name, args }) {
let saveStamp; let saveStamp;
let channel; let channel;
const signal = operations.get(id)?.signal; const signal = operations.get(id)?.signal;
const cancellable = ["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name); 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", "ofdgoLoadImport", "ofdgoConvertPDF"].includes(name) && args[0] instanceof Blob) { if (["ofdgoOpen", "ofdgoOpenPDF", "ofdgoLoadImport"].includes(name) && args[0] instanceof Blob) {
const file = args[0], reader = new FileReaderSync(); const file = args[0], reader = new FileReaderSync();
args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))}; args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))};
} }
@@ -337,9 +341,26 @@ async function handleMessage(engine, { 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.ofdgoEngineCall(engine.id, 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 (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) { if (cancellable && !importing && !converting && !resources && result.ok) {
result.data.size = size; result.data.size = size;
if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" }); if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
+1 -1
View File
@@ -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_util.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_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;
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build (windows && amd64) || (linux && (amd64 || arm64))
package main
import (
"os"
"strings"
)
// commandLine 读取桌面参数并处理参数错误
// 返回: []string 文件路径, string 浏览器数据目录
func commandLine() ([]string, string) {
paths, dataDir, ok := parseArguments(os.Args[1:])
if !ok {
os.Exit(2)
}
return paths, dataDir
}
// parseArguments 解析双横线选项及后续文件路径
// 入参: args 启动参数
// 返回: []string 文件路径, string 浏览器数据目录, bool 参数是否有效
func parseArguments(args []string) ([]string, string, bool) {
var dataDir string
for len(args) > 0 {
arg := args[0]
switch {
case arg == "--":
return args[1:], dataDir, true
case arg == "--data-dir":
if len(args) < 2 || strings.HasPrefix(args[1], "-") {
return nil, dataDir, false
}
dataDir, args = args[1], args[1:]
case strings.HasPrefix(arg, "--data-dir="):
dataDir = strings.TrimPrefix(arg, "--data-dir=")
case strings.HasPrefix(arg, "-"):
return nil, dataDir, false
default:
return args, dataDir, true
}
args = args[1:]
}
return nil, dataDir, true
}
+2 -4
View File
@@ -18,7 +18,6 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"os" "os"
@@ -26,9 +25,8 @@ import (
) )
func main() { func main() {
dataDir := flag.String("data-dir", "", "browser data directory") paths, dataDir := commandLine()
flag.Parse() if err := webview.Run(context.Background(), paths, dataDir); err != nil {
if err := webview.Run(context.Background(), flag.Args(), *dataDir); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
+2 -4
View File
@@ -18,7 +18,6 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"os" "os"
"syscall" "syscall"
@@ -28,9 +27,8 @@ import (
) )
func main() { func main() {
dataDir := flag.String("data-dir", "", "browser data directory") paths, dataDir := commandLine()
flag.Parse() if err := webview.Run(context.Background(), paths, dataDir); err != nil {
if err := webview.Run(context.Background(), flag.Args(), *dataDir); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
message, _ := syscall.UTF16PtrFromString(err.Error()) message, _ := syscall.UTF16PtrFromString(err.Error())
title, _ := syscall.UTF16PtrFromString("OFDGo") title, _ := syscall.UTF16PtrFromString("OFDGo")
+5 -5
View File
@@ -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-20261022062419-599070546b62 github.com/xiaoqidun/guigo v0.0.0-20261026011429-36a970cd3fb6
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-20261021071541-3fdb0cab9d95 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
) )
@@ -35,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
+8 -8
View File
@@ -20,8 +20,8 @@ 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=
@@ -30,8 +30,8 @@ github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1Oleu
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/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/emmansun/gmsm v0.45.0 h1:PU/6ObyR3g05YN3jn1L5GZDowzyAi8Xhkq4svzYNyiA= github.com/emmansun/gmsm v0.45.1 h1:S77WOluBZ9JH2wh701H2QR03eQYNGLeN8IUkdhgGpRA=
github.com/emmansun/gmsm v0.45.0/go.mod h1:HgLSvmdePGLo+Fo2PNgdTQU6lqAepSyriSQJRpECY6w= 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=
@@ -60,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-20261022062419-599070546b62 h1:Uefcwqu9kZ47FtIbrGBY4zue71VsQt048usDnXDLq0A= github.com/xiaoqidun/guigo v0.0.0-20261026011429-36a970cd3fb6 h1:JqOQToWYKqyoIcvNx5MYfrdiuApjpBDnUjMHD7sCF6c=
github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls= github.com/xiaoqidun/guigo v0.0.0-20261026011429-36a970cd3fb6/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-20261021071541-3fdb0cab9d95 h1:Fcr2BVlFYc+BZ62OhrN55QIdkcH47ryxKWnFqCRcoE4= github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU=
github.com/xiaoqidun/pdfgo v0.0.0-20261021071541-3fdb0cab9d95/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=
+14 -8
View File
@@ -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}
@@ -331,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
@@ -494,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)
+43 -2
View File
@@ -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)
} }
+1 -1
View File
@@ -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
} }
+375
View File
@@ -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
}
+953 -338
View File
File diff suppressed because it is too large. Load diff
+17 -6
View File
@@ -191,7 +191,7 @@ 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 { if name == "ofdgoRenderPage" && e.currentSession != nil && e.pdf == nil {
defer func() { defer func() {
if failure := recover(); failure != nil { if failure := recover(); failure != nil {
result = nativeFailure(fmt.Errorf("%v", failure)) result = nativeFailure(fmt.Errorf("%v", failure))
@@ -227,8 +227,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
if result != nil && result["ok"] == true && ctx.Err() == nil { if result != nil && result["ok"] == true && ctx.Err() == nil {
e.currentSession.svgFonts = maps.Clone(worker.svgFonts) e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
clear(worker.pageCache) clear(worker.pageCache)
clear(worker.textCache) worker.textCache.Clear()
worker.textOrder = nil
clear(worker.svgFonts) clear(worker.svgFonts)
e.renderIdle = append(e.renderIdle, worker) e.renderIdle = append(e.renderIdle, worker)
} }
@@ -311,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 {
@@ -329,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)
@@ -357,6 +366,8 @@ func (e *NativeEngine) Close() error {
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.waitRenders(context.Background())
e.renderIdle = nil e.renderIdle = nil
e.saveSnapshot = nil e.saveSnapshot = nil
@@ -368,7 +379,7 @@ 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
} }
@@ -419,7 +430,7 @@ func (e *NativeEngine) renderSession(ctx context.Context) (*Session, error) {
} }
return &Session{Reader: renderer.Reader, Renderer: renderer, doc: source.doc, fontFS: source.fontFS, 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...), pageCache: make(map[int]*ofdgo.PageContent), boxCache: append([]pageBoxInfo(nil), source.boxCache...),
textCache: make(map[int]*ofdgo.PageText), svgFonts: make(map[string][]byte), editing: source.editing}, nil textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes), svgFonts: make(map[string][]byte), editing: source.editing}, nil
} }
// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存 // prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
@@ -954,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 {
+141
View File
@@ -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
}
+34 -6
View File
@@ -68,22 +68,48 @@ func (w *wasmEngines) open(args []js.Value) (any, error) {
return nil, nil return nil, nil
} }
// call 按会话标识分派业务命令,不提供默认会话 // call 按会话标识分派业务命令,统一绑定本次调用的取消上下文
// 入参: args 会话标识、命令及业务参数 // 入参: args 会话标识、命令、取消检查点及业务参数
// 返回: any 业务结果, error 调用错误 // 返回: any 业务结果, error 调用错误
func (w *wasmEngines) call(args []js.Value) (any, error) { func (w *wasmEngines) call(args []js.Value) (any, error) {
if len(args) < 2 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString { if len(args) < 3 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString {
return nil, fmt.Errorf("missing engine ID or operation") return nil, fmt.Errorf("missing engine ID or operation")
} }
e := w.engines[args[0].String()] e := w.engines[args[0].String()]
if e == nil { if e == nil {
return nil, fmt.Errorf("unknown engine: %s", args[0].String()) return nil, fmt.Errorf("unknown engine: %s", args[0].String())
} }
fn := e.businessCommands()[args[1].String()] name := args[1].String()
fn := e.businessCommands()[name]
if fn == nil { if fn == nil {
return nil, fmt.Errorf("unknown operation: %s", args[1].String()) return nil, fmt.Errorf("unknown operation: %s", name)
} }
return fn(args[2:]) 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 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源 // close 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源
@@ -99,6 +125,8 @@ func (w *wasmEngines) close(args []js.Value) (any, error) {
return nil, nil return nil, nil
} }
delete(w.engines, id) delete(w.engines, id)
_, _ = e.discardPrint(nil)
e.clearPDF()
e.clearImport() e.clearImport()
err := e.currentSession.Close() err := e.currentSession.Close()
*e = businessEngine{} *e = businessEngine{}
+249 -21
View File
@@ -20,6 +20,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -42,9 +43,6 @@ import (
"github.com/xiaoqidun/ofdgo/internal/webui" "github.com/xiaoqidun/ofdgo/internal/webui"
) )
// applicationID 标识原生应用,与版本及可执行文件路径无关
const applicationID = "me.aite.ofdgo"
const applicationVersionURL = "https://ofdgo.aite.me/" const applicationVersionURL = "https://ofdgo.aite.me/"
const applicationDownloadURL = "https://ofdgo.aite.me/releases/" const applicationDownloadURL = "https://ofdgo.aite.me/releases/"
@@ -65,10 +63,20 @@ 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 frontendReady bool
closeAllowed bool closeAllowed bool
} }
// associationState 保存单个范围的登记状态和查询错误
type associationState struct {
guigo.AssociationStatus
Error string `json:"error,omitempty"`
}
// engineClose 保存并发关闭共享的完成通知及结果 // engineClose 保存并发关闭共享的完成通知及结果
type engineClose struct { type engineClose struct {
done chan struct{} done chan struct{}
@@ -89,11 +97,39 @@ 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}
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.engines = make(map[string]*webui.NativeEngine)
h.cancels = make(map[string]context.CancelFunc)
h.fontFiles = make(map[string]string)
cacheBytes := int64(256 << 20) cacheBytes := int64(256 << 20)
if memory := conversionMemory; memory != nil { if memory := conversionMemory; memory != nil {
cacheBytes = max(32<<20, memory()/8) cacheBytes = max(32<<20, memory()/8)
@@ -106,7 +142,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.conversionPool = h.resources.ConversionPool() 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", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open"} { 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", "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)
@@ -132,6 +168,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()
@@ -274,6 +313,13 @@ 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 {
file, err := h.files.addDrop(name) file, err := h.files.addDrop(name)
@@ -286,12 +332,72 @@ func (h *Host) openPaths(names []string) {
h.mu.Lock() h.mu.Lock()
w := h.window w := h.window
h.mu.Unlock() h.mu.Unlock()
if w != nil && w.Emit("ofdgo.files", files) == nil { var err error
return if w == nil {
err = guigo.ErrClosed
} else {
err = w.Emit(event, files)
}
if err == nil {
return nil
} }
for _, file := range files { for _, file := range files {
h.files.discardDrop(file.(map[string]any)["nativeFile"].(string)) 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 执行宿主协议并保留业务结果结构
@@ -305,6 +411,44 @@ 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 "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) parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20)
@@ -334,6 +478,10 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
return applicationUpdate(ctx, build.Version, applicationVersionURL) return applicationUpdate(ctx, build.Version, applicationVersionURL)
case "update.open": case "update.open":
return nil, guigo.OpenURL(applicationDownloadURL) 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":
@@ -557,10 +705,16 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
return window.TitleBarInfo() return window.TitleBarInfo()
} }
if name == "window.close" { 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.mu.Lock()
h.closeAllowed = true h.closeAllowed = true
h.mu.Unlock() h.mu.Unlock()
return nil, window.Close() return true, window.Close()
} }
values, ok := args["regions"].([]any) values, ok := args["regions"].([]any)
if !ok { if !ok {
@@ -582,20 +736,89 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
regions = append(regions, guigo.Rect{X: x, Y: y, Width: width, Height: height}) regions = append(regions, guigo.Rect{X: x, Y: y, Width: width, Height: height})
} }
return nil, window.SetDragRegions(regions) return nil, window.SetDragRegions(regions)
case "launch.done":
h.launchMu.Lock()
if h.launchPending > 0 {
h.launchPending--
}
h.launchMu.Unlock()
return nil, nil
case "ready": case "ready":
h.launchMu.Lock()
defer h.launchMu.Unlock()
h.mu.Lock() h.mu.Lock()
paths := h.pendingPaths
h.pendingPaths = nil
h.frontendReady = true h.frontendReady = true
h.mu.Unlock() h.mu.Unlock()
if len(paths) != 0 { return nil, h.flushLaunch()
h.openPaths(paths)
}
return nil, nil
} }
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 比较官网和当前程序的完整构建版本 // applicationUpdate 比较官网和当前程序的完整构建版本
// 入参: ctx 请求上下文, version 当前版本, endpoint 官网地址 // 入参: ctx 请求上下文, version 当前版本, endpoint 官网地址
// 返回: bool 是否有更新, error 检查错误 // 返回: bool 是否有更新, error 检查错误
@@ -826,7 +1049,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)
@@ -908,6 +1131,9 @@ func (h *Host) closeEngine(id string) error {
// 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()
@@ -938,16 +1164,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
} }
name := "ofdgo" dataDir = filepath.Join(cache, cacheName)
if runtime.GOOS == "windows" {
name = "OFDGo"
}
dataDir = filepath.Join(cache, name)
} }
dataDir, err := filepath.Abs(dataDir) dataDir, err := filepath.Abs(dataDir)
if err != nil { if err != nil {
@@ -958,7 +1186,7 @@ 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, TitleBar: guigo.TitleBarContent,
TitleBarColors: guigo.TitleBarColors{ TitleBarColors: guigo.TitleBarColors{
+8
View File
@@ -16,12 +16,20 @@ package webview
import ( import (
"os" "os"
"path/filepath"
"strings" "strings"
) )
// 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 获取Linux区域设置对应的OpenType名称表标识 // fontNameLanguage 获取Linux区域设置对应的OpenType名称表标识
// 返回: uint16 名称表平台标识, uint16 显示语言标识 // 返回: uint16 名称表平台标识, uint16 显示语言标识
func fontNameLanguage() (uint16, uint16) { func fontNameLanguage() (uint16, uint16) {
+9
View File
@@ -16,9 +16,18 @@
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) {
+26
View File
@@ -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) {
+1
View File
@@ -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编码,不能缩小时终止本次压缩
+31 -18
View File
@@ -77,6 +77,9 @@ type Editor struct {
saveTarget Output saveTarget Output
snapshotArchive *editorSnapshotArchive snapshotArchive *editorSnapshotArchive
validatedPaths *renderCache[string, bool] validatedPaths *renderCache[string, bool]
changes EditorChanges
transactionChanges *EditorChanges
objectIndex *editorObjectIndex
richIndex *richTextIndex richIndex *richTextIndex
} }
@@ -223,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
} }
@@ -233,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)
@@ -618,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
} }
@@ -786,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 使用编辑器字体来源和完整后端配置创建资源度量器
@@ -820,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
} }
} }
@@ -872,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)
} }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }
+5 -1
View File
@@ -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
+24 -5
View File
@@ -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, &copy) selection.nodes = append(selection.nodes, &copy)
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...)
} }
+2
View File
@@ -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
+26
View File
@@ -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()
+16
View File
@@ -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
} }
+17 -5
View File
@@ -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
} }
+188
View File
@@ -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
}
+8 -1
View File
@@ -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)
+18 -8
View File
@@ -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
@@ -317,13 +318,13 @@ func (e *Editor) subsetSnapshotFonts(ctx context.Context, progress editorProgres
if e.source == nil || e.backends.FontResources == nil { if e.source == nil || e.backends.FontResources == nil {
return nil, ctx.Err() return nil, ctx.Err()
} }
candidates := make(map[string]bool) candidates := make(map[string]int)
for _, resource := range e.resources { for i, resource := range e.resources {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
} }
if resource.font != nil && resource.font.FontFile != "" && e.source.reader.fileNames[resource.name] { if resource.font != nil && resource.font.FontFile != "" && e.source.reader.fileNames[resource.name] {
candidates[resource.name] = true candidates[resource.name] = i
} }
} }
if len(candidates) == 0 { if len(candidates) == 0 {
@@ -399,15 +400,24 @@ func (e *Editor) subsetSnapshotFonts(ctx context.Context, progress editorProgres
if font.unsafe { if font.unsafe {
continue continue
} }
subset, err := e.backends.FontResources.SubsetFont(data, slices.Sorted(maps.Keys(glyphs)), preserve) 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 { if err != nil {
return nil, err return nil, err
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
} }
if len(subset) != 0 && len(subset) < len(data) { if len(subset) >= len(data) {
result[name] = subset 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)) return result, progress.report("fonts", len(candidates), len(candidates))
@@ -593,7 +603,7 @@ func (e *Editor) subsetNewFonts(progress editorProgress) (map[string][]byte, err
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]
+15 -3
View File
@@ -963,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)
} }
@@ -990,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()
+1 -1
View File
@@ -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
} }
} }
+5 -2
View File
@@ -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) }
} }
+67 -11
View File
@@ -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,6 +83,11 @@ 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()
@@ -62,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
@@ -108,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
} }
@@ -120,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
@@ -132,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)
@@ -213,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) {
@@ -230,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
View File
@@ -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 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
+1 -11
View File
@@ -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 解析错误
+5 -5
View File
@@ -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
} }
+44 -12
View File
@@ -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
@@ -394,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
} }
@@ -437,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 {
@@ -458,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") {
@@ -472,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()
@@ -497,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
@@ -504,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
} }
@@ -544,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 {
@@ -562,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)
@@ -680,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
+1 -1
View File
@@ -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
} }
+42 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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] = &copy
}
}
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{})
} }
+2 -1
View File
@@ -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
View File
@@ -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]
+15 -2
View File
@@ -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
View File
@@ -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
+98
View File
@@ -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渲染
+41 -1
View File
@@ -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
} }
} }
+6
View File
@@ -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
View File
@@ -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 相对于对象外接矩形的裁剪集合
+242 -33
View File
@@ -15,6 +15,7 @@
package ofdgo package ofdgo
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -69,6 +70,25 @@ 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 resources *Resources
@@ -212,21 +232,34 @@ 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 { if options.Resources == nil {
options.Resources, _ = NewResources(ResourceOptions{}) options.Resources, _ = NewResources(ResourceOptions{})
} }
ctx = options.Resources.context(ctx) 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{resources: options.Resources}, options.RendererOptions...) renderer := NewRenderer(&Reader{resources: options.Resources}, options.RendererOptions...)
if options.Backends != nil { if options.Backends != nil {
@@ -236,18 +269,25 @@ 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.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{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{}} 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}
if !options.Strict { if !options.Strict {
importer.warning = func(warning pdfgo.Diagnostic) { importer.warning = func(warning pdfgo.Diagnostic) {
@@ -256,35 +296,58 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
} }
readOptions.Warning = importer.warning readOptions.Warning = importer.warning
} }
reader, err := pdfgo.NewReaderWithOptions(convertReader{ctx: ctx, input: input}, input.Size(), readOptions) 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)
} }
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 preserved as metadata; not applied to rendering"})
} }
}
importer.pageIndexes = make(map[pdfgo.Reference]int) importer.pageIndexes = make(map[pdfgo.Reference]int)
importer.resolveFile = options.ResolveFile importer.resolveFile = options.ResolveFile
importer.resolveReference = options.ResolveReference importer.resolveReference = options.ResolveReference
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 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)
@@ -298,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, options.Strict); 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
@@ -341,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 {
@@ -354,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,转换阶段失败时不写入目标
+6
View File
@@ -331,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 {
+1 -1
View File
@@ -1155,12 +1155,12 @@ func (p *pdfImporter) documentMetadata(ctx context.Context, strict bool) error {
name string name string
text *string text *string
}{{"CreationDate", &projected.CreationDate}, {"ModDate", &projected.ModDate}} { }{{"CreationDate", &projected.CreationDate}, {"ModDate", &projected.ModDate}} {
*field.text = ""
if value, ok := values[field.name]; ok { if value, ok := values[field.name]; ok {
date, day, _, err := codecpdf.MetadataDate(value.text, value.xmp) date, day, _, err := codecpdf.MetadataDate(value.text, value.xmp)
if err != nil { if err != nil {
return fmt.Errorf("PDF metadata %s: %w", field.name, err) return fmt.Errorf("PDF metadata %s: %w", field.name, err)
} }
*field.text = ""
if day { if day {
*field.text = date.Format("2006-01-02") *field.text = date.Format("2006-01-02")
} }
+655
View File
@@ -0,0 +1,655 @@
// 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 ofdgo
import (
"context"
"errors"
"fmt"
"math"
"slices"
"sync"
)
// ErrPrintDenied 表示文档不允许打印或请求份数超出声明上限
var ErrPrintDenied = errors.New("document printing is not permitted")
// PrintOptions 设置内容排版,长度以毫米为单位,比例1表示100%
// Pages为零基有序页码,nil表示全部,非nil空列表表示不选择页面;与PageRange互斥
// Mode支持single、multiple、booklet、poster;Scaling支持fit、shrink、actual、custom
// PaperWidth和PaperHeight为实际纸面尺寸,显式提供时不自动交换方向
// 两者同时为零时,单页沿用源尺寸,其他模式采用A4,小册子默认横向
// Parity支持all、odd、even,按源页码过滤;Sides支持all、front、back,按输出纸面过滤
// BookletSheets为每册纸张数,0表示整份一册;Binding支持left、right
// Rows和Columns仅用于多页,Gap为单元间隔,Overlap为海报相邻纸面的重叠
// Duplex支持simplex、longEdge、shortEdge;小册子默认shortEdge,其他模式默认simplex
// PrintableArea为已选设备当前方向下的可打印区域,Margins为该区域内的额外留白
// Reverse反转纸张输出顺序,保留每张纸内部的正背面与拼版顺序
// Copies和Collate描述设备作业,不展开重复内容;SheetFrom/SheetTo为一基纸张范围
// Contents支持documentAndAnnotations、document、annotations、forms,document保留表单外观
// PageOrder支持horizontal、horizontalReverse、vertical、verticalReverse
// BackRotate将背面绕纸张中心旋转180度;海报Labels保留底部5毫米绘制拼接标签
// Scale用于自定义缩放及海报分块,零值采用1;Rotate允许自动旋转页面以适应单元
// Raster将纸面光栅化,Grayscale以光栅方式输出灰度;DPI默认300,上限2400
// Borders绘制单元边框,CropMarks绘制裁切标记,TileOnlyLarge仅分块超大页面
type PrintOptions struct {
Pages []int `json:"pages,omitempty"`
PageRange string `json:"pageRange,omitempty"`
Mode string `json:"mode,omitempty"`
Scaling string `json:"scaling,omitempty"`
PaperWidth float64 `json:"paperWidth,omitempty"`
PaperHeight float64 `json:"paperHeight,omitempty"`
Margins PrintMargins `json:"margins"`
PrintableArea *Box `json:"printableArea,omitempty"`
Duplex string `json:"duplex,omitempty"`
BackRotate bool `json:"backRotate,omitempty"`
Copies int `json:"copies,omitempty"`
Collate *bool `json:"collate,omitempty"`
SheetFrom int `json:"sheetFrom,omitempty"`
SheetTo int `json:"sheetTo,omitempty"`
Scale float64 `json:"scale,omitempty"`
Rotate bool `json:"rotate,omitempty"`
Reverse bool `json:"reverse,omitempty"`
Parity string `json:"parity,omitempty"`
Sides string `json:"sides,omitempty"`
Rows int `json:"rows,omitempty"`
Columns int `json:"columns,omitempty"`
// ColumnOrder 兼容纵向填充设置,仅在PageOrder为空时生效
ColumnOrder bool `json:"columnOrder,omitempty"`
PageOrder string `json:"pageOrder,omitempty"`
Gap float64 `json:"gap,omitempty"`
Overlap float64 `json:"overlap,omitempty"`
BookletSheets int `json:"bookletSheets,omitempty"`
Binding string `json:"binding,omitempty"`
// Annotations 兼容注解开关,仅在Contents为空时生效
Annotations *bool `json:"annotations,omitempty"`
Contents string `json:"contents,omitempty"`
Raster bool `json:"raster,omitempty"`
DPI float64 `json:"dpi,omitempty"`
Grayscale bool `json:"grayscale,omitempty"`
Borders bool `json:"borders,omitempty"`
CropMarks bool `json:"cropMarks,omitempty"`
TileOnlyLarge bool `json:"tileOnlyLarge,omitempty"`
Labels bool `json:"labels,omitempty"`
}
// PrintMargins 保存纸面四边的非负页边距
type PrintMargins struct {
Top float64 `json:"top"`
Right float64 `json:"right"`
Bottom float64 `json:"bottom"`
Left float64 `json:"left"`
}
// PrintPlacement 将归一化源页面坐标映射到纸面,Transform按a、b、c、d、e、f排列
// Clip为纸面坐标裁剪;Page从零开始,同一页面可以多次放置
// Width和Height为归一化源页尺寸;Clipped表示变换后的内容超出裁剪区域
type PrintPlacement struct {
Page int `json:"page"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Transform [6]float64 `json:"transform"`
Clip Box `json:"clip"`
Clipped bool `json:"clipped"`
}
// PrintSide 保存一个输出纸面,Number是过滤前的一基纸面序号,Sheet为一基纸张编号
// Back仅在双面作业中表示背面,TileRow/TileColumn为海报的一基拼接位置
type PrintSide struct {
Number int `json:"number"`
Sheet int `json:"sheet"`
Back bool `json:"back"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Placements []PrintPlacement `json:"placements"`
TileRow int `json:"tileRow,omitempty"`
TileColumn int `json:"tileColumn,omitempty"`
TileRows int `json:"tileRows,omitempty"`
TileColumns int `json:"tileColumns,omitempty"`
Label string `json:"label,omitempty"`
LabelX float64 `json:"labelX,omitempty"`
LabelY float64 `json:"labelY,omitempty"`
}
// PrintLayout 保存独立排版结果,SourcePages为选中源页数,CopiesLimit为文档声明的份数上限
// CopiesLimit为-1时无限制;纯布局计算不绑定文档,默认同样为-1
// Sheets为筛选后的纸张数;BackContent表示正背面筛选前存在含页面内容的背面
type PrintLayout struct {
SourcePages int `json:"sourcePages"`
CopiesLimit int `json:"copiesLimit"`
Copies int `json:"copies"`
Collate bool `json:"collate"`
Duplex string `json:"duplex"`
Sheets int `json:"sheets"`
BackContent bool `json:"backContent"`
Sides []PrintSide `json:"sides"`
}
// PrintDocument 固定排版和渲染配置,可串行输出多个纸面,Close后不可再渲染
// Renderer.PreparePrint借用输入,使用结束前不得关闭或修改源输入及外部字体
// Editor.PreparePrint创建独立编辑快照,不要求保留原编辑器;外部字体仍须可读
// 各方法以互斥锁串行执行,Layout返回独立副本;取消只中断当前输出,不销毁任务
type PrintDocument struct {
mu sync.Mutex
renderer *Renderer
layout PrintLayout
options PrintOptions
closed bool
}
// PlanPrint 仅根据源页面区域和选项计算打印布局,不读取文档或调用设备
// 入参: pages 按文档顺序排列的页面区域, options 打印内容选项
// 返回: PrintLayout 有序纸面, error 尺寸、页码或选项错误
func PlanPrint(pages []Box, options PrintOptions) (PrintLayout, error) {
o, err := normalizePrintOptions(options)
if err != nil {
return PrintLayout{CopiesLimit: -1}, err
}
return planPrint(pages, o)
}
// planPrint 按已校验的选项计算纸面,供独立布局和打印任务共用
// 入参: pages 按文档顺序排列的页面区域, o 规范排版选项
// 返回: PrintLayout 有序纸面, error 尺寸、页码或排版错误
func planPrint(pages []Box, o PrintOptions) (PrintLayout, error) {
result := PrintLayout{CopiesLimit: -1}
if len(pages) == 0 {
return result, fmt.Errorf("no pages to print")
}
for _, page := range pages {
if !printPositive(page.W) || !printPositive(page.H) {
return result, fmt.Errorf("invalid print page size")
}
}
indices := o.Pages
if o.PageRange != "" {
var err error
indices, err = ParsePageRange(o.PageRange, len(pages))
if err != nil {
return result, err
}
} else if o.Pages == nil {
indices = make([]int, len(pages))
for i := range indices {
indices[i] = i
}
}
selected := make([]int, 0, len(indices))
for _, index := range indices {
if index < 0 || index >= len(pages) {
return result, fmt.Errorf("print page index %d outside document", index)
}
if o.Parity == "odd" && index%2 != 0 || o.Parity == "even" && index%2 == 0 {
continue
}
selected = append(selected, index)
}
if len(selected) == 0 {
return result, fmt.Errorf("no pages selected for printing")
}
result.SourcePages = len(selected)
result.Copies, result.Collate, result.Duplex = o.Copies, *o.Collate, o.Duplex
addSide := func(width, height float64, order []int, rows, columns int) error {
area := printPageArea(width, height, o)
cw, ch := (area.W-float64(columns-1)*o.Gap)/float64(columns), (area.H-float64(rows-1)*o.Gap)/float64(rows)
if !printPositive(cw) || !printPositive(ch) {
return fmt.Errorf("print margins or gap exceed paper size")
}
side := PrintSide{Width: width, Height: height, Placements: []PrintPlacement{}}
for cell, index := range order {
if index < 0 {
continue
}
x, y := cell%columns, cell/columns
if o.PageOrder == "vertical" || o.PageOrder == "verticalReverse" {
x, y = cell/rows, cell%rows
}
if o.PageOrder == "horizontalReverse" || o.PageOrder == "verticalReverse" {
x = columns - 1 - x
}
box := Box{X: area.X + float64(x)*(cw+o.Gap), Y: area.Y + float64(y)*(ch+o.Gap), W: cw, H: ch}
placement := printPlacement(index, pages[index], box, o)
for _, value := range placement.Transform {
if math.IsNaN(value) || math.IsInf(value, 0) {
return fmt.Errorf("invalid print placement")
}
}
side.Placements = append(side.Placements, placement)
}
if o.Mode == "poster" && o.Labels && len(order) > 0 {
side.Label = fmt.Sprintf("Page %d Row 1/1 Column 1/1", order[0]+1)
side.LabelX, side.LabelY = area.X+1, area.Y+area.H+3.5
}
result.Sides = append(result.Sides, side)
return nil
}
w, h := o.PaperWidth, o.PaperHeight
if w == 0 && o.Mode != "single" {
w, h = 210, 297
if o.Mode == "booklet" {
w, h = h, w
}
}
switch o.Mode {
case "single":
for _, index := range selected {
pw, ph := w, h
if pw == 0 {
pw, ph = pages[index].W, pages[index].H
}
if err := addSide(pw, ph, []int{index}, 1, 1); err != nil {
return PrintLayout{}, err
}
}
case "multiple":
count := o.Rows * o.Columns
for start := 0; start < len(selected); start += count {
if err := addSide(w, h, selected[start:min(start+count, len(selected))], o.Rows, o.Columns); err != nil {
return PrintLayout{}, err
}
}
case "booklet":
chunk := ((len(selected) + 3) / 4) * 4
if o.BookletSheets > 0 {
chunk = o.BookletSheets * 4
}
for start := 0; start < len(selected); start += chunk {
end := min(start+chunk, len(selected))
order := make([]int, ((end-start+3)/4)*4)
for i := range order {
order[i] = -1
}
copy(order, selected[start:end])
for i := 0; i < len(order)/4; i++ {
for _, pair := range [][]int{{order[len(order)-1-i*2], order[i*2]}, {order[i*2+1], order[len(order)-2-i*2]}} {
if o.Binding == "right" {
slices.Reverse(pair)
}
if err := addSide(w, h, pair, 1, 2); err != nil {
return PrintLayout{}, err
}
}
}
}
case "poster":
area := printPageArea(w, h, o)
if area.W <= o.Overlap || area.H <= o.Overlap {
return result, fmt.Errorf("poster overlap or margins exceed paper size")
}
for _, index := range selected {
pw, ph := pages[index].W*o.Scale, pages[index].H*o.Scale
if o.TileOnlyLarge && pw <= area.W && ph <= area.H {
if err := addSide(w, h, []int{index}, 1, 1); err != nil {
return PrintLayout{}, err
}
continue
}
columns, rows := max(1, math.Ceil((pw-o.Overlap)/(area.W-o.Overlap))), max(1, math.Ceil((ph-o.Overlap)/(area.H-o.Overlap)))
if !printPositive(columns*rows) || columns*rows > 10000 || len(result.Sides)+int(columns*rows) > 10000 {
return result, fmt.Errorf("too many poster sides")
}
for row := 0; row < int(rows); row++ {
for column := 0; column < int(columns); column++ {
m := [6]float64{o.Scale, 0, 0, o.Scale, area.X - float64(column)*(area.W-o.Overlap), area.Y - float64(row)*(area.H-o.Overlap)}
side := PrintSide{Width: w, Height: h, TileRow: row + 1, TileColumn: column + 1, TileRows: int(rows), TileColumns: int(columns), Placements: []PrintPlacement{{Page: index, Width: pages[index].W, Height: pages[index].H, Transform: m, Clip: area}}}
if o.Labels {
side.Label = fmt.Sprintf("Page %d Row %d/%d Column %d/%d", index+1, row+1, int(rows), column+1, int(columns))
side.LabelX, side.LabelY = area.X+1, area.Y+area.H+3.5
}
result.Sides = append(result.Sides, side)
}
}
}
}
if o.Duplex != "simplex" {
paired := make([]PrintSide, 0, len(result.Sides)+1)
for _, side := range result.Sides {
if len(paired)%2 == 1 {
previous := paired[len(paired)-1]
if previous.Width != side.Width || previous.Height != side.Height {
paired = append(paired, PrintSide{Width: previous.Width, Height: previous.Height, Placements: []PrintPlacement{}})
}
}
paired = append(paired, side)
}
if len(paired)%2 == 1 {
previous := paired[len(paired)-1]
paired = append(paired, PrintSide{Width: previous.Width, Height: previous.Height, Placements: []PrintPlacement{}})
}
result.Sides = paired
}
for i := range result.Sides {
result.Sides[i].Number = i + 1
result.Sides[i].Sheet = i + 1
if o.Duplex != "simplex" {
result.Sides[i].Sheet = i/2 + 1
result.Sides[i].Back = i%2 == 1
}
if o.BackRotate && result.Sides[i].Back {
for j := range result.Sides[i].Placements {
placement := &result.Sides[i].Placements[j]
m := placement.Transform
placement.Transform = [6]float64{-m[0], -m[1], -m[2], -m[3], result.Sides[i].Width - m[4], result.Sides[i].Height - m[5]}
placement.Clip.X = result.Sides[i].Width - placement.Clip.X - placement.Clip.W
placement.Clip.Y = result.Sides[i].Height - placement.Clip.Y - placement.Clip.H
area := printPageArea(result.Sides[i].Width, result.Sides[i].Height, o)
x, y := math.Max(area.X, placement.Clip.X), math.Max(area.Y, placement.Clip.Y)
right, bottom := math.Min(area.X+area.W, placement.Clip.X+placement.Clip.W), math.Min(area.Y+area.H, placement.Clip.Y+placement.Clip.H)
if right-x < placement.Clip.W-1e-6 || bottom-y < placement.Clip.H-1e-6 {
placement.Clipped = true
}
placement.Clip = Box{X: x, Y: y, W: math.Max(0, right-x), H: math.Max(0, bottom-y)}
}
}
}
lastSheet := result.Sides[len(result.Sides)-1].Sheet
if o.SheetFrom > lastSheet || o.SheetTo > lastSheet {
return result, fmt.Errorf("print sheet outside layout")
}
for _, side := range result.Sides {
if side.Back && len(side.Placements) > 0 && (o.SheetFrom == 0 || side.Sheet >= o.SheetFrom) && (o.SheetTo == 0 || side.Sheet <= o.SheetTo) {
result.BackContent = true
break
}
}
if o.Sides != "all" || o.SheetFrom != 0 || o.SheetTo != 0 {
filtered := make([]PrintSide, 0, len(result.Sides))
for _, side := range result.Sides {
if o.SheetFrom > 0 && side.Sheet < o.SheetFrom || o.SheetTo > 0 && side.Sheet > o.SheetTo {
continue
}
if o.Sides == "all" || o.Sides == "front" && !side.Back || o.Sides == "back" && side.Back {
filtered = append(filtered, side)
}
}
result.Sides = filtered
}
if len(result.Sides) == 0 {
return result, fmt.Errorf("no print sides selected")
}
if o.Reverse {
slices.Reverse(result.Sides)
for i := 0; i+1 < len(result.Sides); i++ {
if result.Sides[i].Sheet == result.Sides[i+1].Sheet {
result.Sides[i], result.Sides[i+1] = result.Sides[i+1], result.Sides[i]
i++
}
}
}
previous := 0
for _, side := range result.Sides {
if side.Sheet != previous {
result.Sheets++
previous = side.Sheet
}
}
return result, nil
}
// normalizePrintOptions 校验有限尺寸并填充公共默认值
// 入参: o 原始排版选项
// 返回: PrintOptions 独立规范选项, error 配置错误
func normalizePrintOptions(o PrintOptions) (PrintOptions, error) {
if o.Mode == "" {
o.Mode = "single"
}
if o.Scaling == "" {
o.Scaling = "shrink"
}
if o.Parity == "" {
o.Parity = "all"
}
if o.Sides == "" {
o.Sides = "all"
}
if o.Binding == "" {
o.Binding = "left"
}
if o.Duplex == "" {
o.Duplex = "simplex"
if o.Mode == "booklet" {
o.Duplex = "shortEdge"
}
}
if o.Copies == 0 {
o.Copies = 1
}
if o.Collate == nil {
collate := true
o.Collate = &collate
} else {
collate := *o.Collate
o.Collate = &collate
}
if o.PageOrder == "" {
o.PageOrder = "horizontal"
if o.ColumnOrder {
o.PageOrder = "vertical"
}
}
if o.Contents == "" {
o.Contents = "documentAndAnnotations"
if o.Annotations != nil && !*o.Annotations {
o.Contents = "document"
}
}
o.ColumnOrder, o.Annotations = false, nil
if !slices.Contains([]string{"document", "documentAndAnnotations", "annotations", "forms"}, o.Contents) {
return o, fmt.Errorf("invalid print contents")
}
if o.Scale == 0 {
o.Scale = 1
}
if o.DPI == 0 {
o.DPI = 300
}
if o.Rows == 0 {
o.Rows = 1
}
if o.Columns == 0 {
o.Columns = 2
}
if !slices.Contains([]string{"single", "multiple", "booklet", "poster"}, o.Mode) || !slices.Contains([]string{"fit", "shrink", "actual", "custom"}, o.Scaling) || !slices.Contains([]string{"all", "odd", "even"}, o.Parity) || !slices.Contains([]string{"all", "front", "back"}, o.Sides) || !slices.Contains([]string{"left", "right"}, o.Binding) || !slices.Contains([]string{"simplex", "longEdge", "shortEdge"}, o.Duplex) || !slices.Contains([]string{"horizontal", "horizontalReverse", "vertical", "verticalReverse"}, o.PageOrder) {
return o, fmt.Errorf("invalid print mode")
}
if o.Copies < 1 || o.Copies > 999 || o.SheetFrom < 0 || o.SheetTo < 0 || o.SheetTo > 0 && o.SheetTo < o.SheetFrom || o.Duplex == "simplex" && (o.Mode == "booklet" || o.Sides != "all") {
return o, fmt.Errorf("invalid print job options")
}
if o.PrintableArea != nil {
area := *o.PrintableArea
if !printPositive(area.W) || !printPositive(area.H) || !printPositive(area.X+1) || !printPositive(area.Y+1) || area.X < 0 || area.Y < 0 || o.PaperWidth <= 0 || area.X+area.W > o.PaperWidth+1e-6 || area.Y+area.H > o.PaperHeight+1e-6 {
return o, fmt.Errorf("invalid printer printable area")
}
o.PrintableArea = &area
}
for _, v := range []float64{o.PaperWidth, o.PaperHeight, o.Margins.Top, o.Margins.Right, o.Margins.Bottom, o.Margins.Left, o.Gap, o.Overlap} {
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return o, fmt.Errorf("invalid print dimensions")
}
}
if (o.PaperWidth == 0) != (o.PaperHeight == 0) || !printPositive(o.Scale) || o.Scale > 100 || !printPositive(o.DPI) || o.DPI > 2400 || o.Rows < 1 || o.Columns < 1 || o.Rows > 16 || o.Columns > 16 || o.BookletSheets < 0 || o.BookletSheets > 2500 || o.Pages != nil && o.PageRange != "" {
return o, fmt.Errorf("invalid print options")
}
o.Pages = slices.Clone(o.Pages)
return o, nil
}
// printPositive 检查正有限数
// 入参: v 待校验数值
// 返回: bool 是否为正有限数
func printPositive(v float64) bool {
return v > 0 && !math.IsInf(v, 0) && !math.IsNaN(v)
}
// printPageArea 在设备可打印区域内应用额外留白,未知设备时使用整张纸
// 入参: w 纸面宽度, h 纸面高度, o 排版选项
// 返回: Box 应用额外边距后的毫米区域
func printPageArea(w, h float64, o PrintOptions) Box {
area := Box{W: w, H: h}
if o.PrintableArea != nil {
area = *o.PrintableArea
}
area.X += o.Margins.Left
area.Y += o.Margins.Top
area.W -= o.Margins.Left + o.Margins.Right
area.H -= o.Margins.Top + o.Margins.Bottom
if o.Mode == "poster" && o.Labels {
area.H -= 5
}
return area
}
// printPlacement 计算居中及自动旋转后的单元变换
// 入参: index 零基源页码, source 源页面区域, cell 纸面单元区域, o 排版选项
// 返回: PrintPlacement 页面放置及裁剪信息
func printPlacement(index int, source, cell Box, o PrintOptions) PrintPlacement {
w, h := source.W, source.H
rotated := o.Rotate && math.Min(cell.W/h, cell.H/w) > math.Min(cell.W/w, cell.H/h)
if rotated {
w, h = h, w
}
scale := 1.0
switch o.Scaling {
case "fit":
scale = math.Min(cell.W/w, cell.H/h)
case "shrink":
scale = math.Min(1, math.Min(cell.W/w, cell.H/h))
case "custom":
scale = o.Scale
}
x, y := cell.X+(cell.W-w*scale)/2, cell.Y+(cell.H-h*scale)/2
m := [6]float64{scale, 0, 0, scale, x, y}
if rotated {
m = [6]float64{0, scale, -scale, 0, x + source.H*scale, y}
}
clipped := w*scale > cell.W+1e-6 || h*scale > cell.H+1e-6
return PrintPlacement{Page: index, Width: source.W, Height: source.H, Transform: m, Clip: cell, Clipped: clipped}
}
// PreparePrint 固定当前阅读入口、版本及配置,底层输入由调用方保持存活
// 入参: ctx 准备上下文, options 内容排版选项
// 返回: *PrintDocument 独立打印任务, error 权限、布局或读取错误
func (r *Renderer) PreparePrint(ctx context.Context, options PrintOptions) (*PrintDocument, error) {
if ctx == nil {
return nil, fmt.Errorf("nil print context")
}
if r == nil || r.Reader == nil {
return nil, fmt.Errorf("missing print renderer")
}
o, err := normalizePrintOptions(options)
if err != nil {
return nil, err
}
view, err := r.Fork(ctx)
if err != nil {
return nil, err
}
doc, err := view.Reader.DocContext(ctx)
if err != nil {
return nil, err
}
if !doc.Permissions.Print || doc.Permissions.Copies == 0 {
return nil, ErrPrintDenied
}
if doc.Permissions.Copies > 0 && o.Copies > doc.Permissions.Copies {
return nil, ErrPrintDenied
}
boxes := make([]Box, len(doc.Pages.Page))
for i, page := range doc.Pages.Page {
if err := ctx.Err(); err != nil {
return nil, err
}
area, err := view.Reader.readPageHeaderContext(ctx, page)
if err != nil {
return nil, err
}
boxes[i], err = view.GetPageBox(&area)
if err != nil {
return nil, err
}
}
layout, err := planPrint(boxes, o)
if err != nil {
return nil, err
}
layout.CopiesLimit = doc.Permissions.Copies
view.printing = true
view.printContents = o.Contents
view.RenderAnnotations = true
return &PrintDocument{renderer: view, layout: layout, options: o}, nil
}
// PreparePrint 从独立编辑快照创建打印任务,不修改源编辑器或撤销记录
// 入参: ctx 准备上下文, options 排版选项, rendererOptions 字体及后端配置
// 返回: *PrintDocument 打印任务, error 快照或准备错误
func (e *Editor) PreparePrint(ctx context.Context, options PrintOptions, rendererOptions ...RendererOption) (*PrintDocument, error) {
if ctx == nil || e == nil {
return nil, fmt.Errorf("missing print editor or context")
}
snapshot, err := e.Snapshot(ctx)
if err != nil {
return nil, err
}
reader, err := snapshot.Reader(ctx)
if err != nil {
return nil, err
}
defer reader.Close()
renderer := NewRenderer(reader, rendererOptions...)
return renderer.PreparePrint(ctx, options)
}
// Layout 返回可供第三方预览和检查的独立布局副本
// 返回: PrintLayout 独立布局副本
func (p *PrintDocument) Layout() PrintLayout {
p.mu.Lock()
defer p.mu.Unlock()
result := p.layout
result.Sides = slices.Clone(result.Sides)
for i := range result.Sides {
result.Sides[i].Placements = slices.Clone(result.Sides[i].Placements)
}
return result
}
// Reconfigure 在相同文档快照上创建新的打印任务,不改变原任务的布局
// 入参: ctx 准备上下文, options 新排版选项
// 返回: *PrintDocument 独立打印任务, error 权限、布局或读取错误
func (p *PrintDocument) Reconfigure(ctx context.Context, options PrintOptions) (*PrintDocument, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil, fmt.Errorf("print document is closed")
}
return p.renderer.PreparePrint(ctx, options)
}
// Close 释放任务持有的派生缓存和快照,不关闭调用方借入的输入
// 返回: error 资源释放错误
func (p *PrintDocument) Close() error {
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil
}
p.closed = true
p.renderer.ClearCache()
err := p.renderer.Reader.Close()
p.renderer = nil
return err
}
+365
View File
@@ -0,0 +1,365 @@
// 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 ofdgo
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"math"
"slices"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/f64"
"golang.org/x/image/math/fixed"
)
// printSide 校验任务及纸面索引,并设置当前输出上下文
// 入参: ctx 输出上下文, index 零基纸面索引
// 返回: PrintSide 当前纸面, error 状态、索引或取消错误
func (p *PrintDocument) printSide(ctx context.Context, index int) (PrintSide, error) {
if ctx == nil {
return PrintSide{}, fmt.Errorf("nil print context")
}
if err := ctx.Err(); err != nil {
return PrintSide{}, err
}
if p.closed {
return PrintSide{}, fmt.Errorf("print document is closed")
}
if index < 0 || index >= len(p.layout.Sides) {
return PrintSide{}, fmt.Errorf("print side outside layout")
}
p.renderer.OutputContext, p.renderer.pageContext = ctx, ctx
return p.layout.Sides[index], nil
}
// RenderSVG 输出一个完整纸面的独立SVG,字体和图片内嵌,不依赖界面资源
// 入参: ctx 输出上下文, side 零基纸面索引, writer 输出流
// 返回: error 读取、后端、写入或取消错误;失败时调用方丢弃部分输出
func (p *PrintDocument) RenderSVG(ctx context.Context, side int, writer io.Writer) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.renderPrintSVG(ctx, side, writer)
}
// renderPrintSVG 按统一布局输出矢量或显式光栅纸面
// 入参: ctx 输出上下文, index 零基纸面索引, writer 输出流
// 返回: error 渲染、写入或取消错误
func (p *PrintDocument) renderPrintSVG(ctx context.Context, index int, writer io.Writer) error {
side, err := p.printSide(ctx, index)
if err != nil {
return err
}
if writer == nil {
return fmt.Errorf("missing print writer")
}
out := bufio.NewWriter(convertWriter{ctx: ctx, writer: writer, count: new(int64)})
fmt.Fprintf(out, `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><rect width="100%%" height="100%%" fill="white"/>`, side.Width, side.Height, side.Width, side.Height)
if p.options.Raster || p.options.Grayscale {
img, err := p.renderPrintImage(ctx, index, p.options.DPI)
if err != nil {
return err
}
fmt.Fprintf(out, `<image width="%g" height="%g" xlink:href="data:image/png;base64,`, side.Width, side.Height)
encoder := base64.NewEncoder(base64.StdEncoding, out)
if err := png.Encode(encoder, img); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
out.WriteString(`"/>`)
} else {
for i, placement := range side.Placements {
if err := ctx.Err(); err != nil {
return err
}
page, err := p.renderer.Reader.PageContentByIndex(placement.Page)
if err != nil {
return err
}
c, m := placement.Clip, placement.Transform
fmt.Fprintf(out, `<defs><clipPath id="print%d"><rect x="%g" y="%g" width="%g" height="%g"/></clipPath></defs><g clip-path="url(#print%d)"><image width="%g" height="%g" transform="matrix(%g %g %g %g %g %g)" xlink:href="data:image/svg+xml;base64,`, i, c.X, c.Y, c.W, c.H, i, placement.Width, placement.Height, m[0], m[1], m[2], m[3], m[4], m[5])
encoder := base64.NewEncoder(base64.StdEncoding, out)
if err := p.renderer.RenderToSVG(page, encoder); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
out.WriteString(`"/></g>`)
}
for _, line := range p.printLines(side) {
fmt.Fprintf(out, `<path d="M %g %g L %g %g" fill="none" stroke="black" stroke-width="0.15"/>`, line[0], line[1], line[2], line[3])
}
if side.Label != "" {
fmt.Fprintf(out, `<text x="%g" y="%g" font-family="sans-serif" font-size="2.8" fill="black">%s</text>`, side.LabelX, side.LabelY, html.EscapeString(side.Label))
}
}
out.WriteString(`</svg>`)
return out.Flush()
}
// RenderImage 按指定DPI输出一个纸面的独立白底图像,dpi为0时采用任务配置
// 入参: ctx 输出上下文, side 零基纸面索引, dpi 输出分辨率
// 返回: image.Image 纸面图像, error 配置、读取、后端或取消错误
func (p *PrintDocument) RenderImage(ctx context.Context, side int, dpi float64) (image.Image, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.renderPrintImage(ctx, side, dpi)
}
// renderPrintImage 仅保留当前纸面及单个源页图像,不积累整份文档像素
// 入参: ctx 输出上下文, index 零基纸面索引, dpi 输出分辨率
// 返回: image.Image 白底纸面图像, error 渲染或取消错误
func (p *PrintDocument) renderPrintImage(ctx context.Context, index int, dpi float64) (image.Image, error) {
side, err := p.printSide(ctx, index)
if err != nil {
return nil, err
}
if dpi == 0 {
dpi = p.options.DPI
}
if !printPositive(dpi) || dpi > 2400 {
return nil, fmt.Errorf("invalid print DPI")
}
scale := dpi / 25.4
w, h, err := (&RasterPage{Width: side.Width, Height: side.Height, DPI: dpi}).PixelSize()
if err != nil {
return nil, err
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(dst, dst.Bounds(), image.White, image.Point{}, draw.Src)
for _, placement := range side.Placements {
if err := ctx.Err(); err != nil {
return nil, err
}
page, err := p.renderer.Reader.PageContentByIndex(placement.Page)
if err != nil {
return nil, err
}
view := *p.renderer
m := placement.Transform
view.DPI = dpi * math.Hypot(m[0], m[1])
img, err := view.RenderToImage(page)
if err != nil {
return nil, err
}
px, py := placement.Width/float64(img.Bounds().Dx()), placement.Height/float64(img.Bounds().Dy())
matrix := f64.Aff3{m[0] * px * scale, m[2] * py * scale, m[4] * scale, m[1] * px * scale, m[3] * py * scale, m[5] * scale}
clip := placement.Clip
rect := image.Rect(int(math.Ceil(clip.X*scale)), int(math.Ceil(clip.Y*scale)), int(math.Floor((clip.X+clip.W)*scale)), int(math.Floor((clip.Y+clip.H)*scale))).Intersect(dst.Bounds())
if !rect.Empty() {
for top := rect.Min.Y; top < rect.Max.Y; top += 128 {
if err := ctx.Err(); err != nil {
return nil, err
}
tile := image.Rect(rect.Min.X, top, rect.Max.X, min(top+128, rect.Max.Y))
xdraw.CatmullRom.Transform(dst.SubImage(tile).(*image.RGBA), matrix, img, img.Bounds(), draw.Over, nil)
}
}
}
for _, line := range p.printLines(side) {
x0, y0, x1, y1 := line[0]*scale, line[1]*scale, line[2]*scale, line[3]*scale
steps := max(1, int(math.Ceil(math.Max(math.Abs(x1-x0), math.Abs(y1-y0)))))
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
dst.Set(int(x0+(x1-x0)*t), int(y0+(y1-y0)*t), color.Black)
}
}
if side.Label != "" {
label := image.NewRGBA(image.Rect(0, 0, len(side.Label)*7, 13))
(&font.Drawer{Dst: label, Src: image.Black, Face: basicfont.Face7x13, Dot: fixed.P(0, 11)}).DrawString(side.Label)
x, y := int(side.LabelX*scale), int((side.LabelY-2.8)*scale)
width, height := int(math.Round(float64(label.Bounds().Dx())*2.8/13*scale)), max(1, int(math.Round(2.8*scale)))
xdraw.CatmullRom.Scale(dst, image.Rect(x, y, x+max(1, width), y+height), label, label.Bounds(), draw.Over, nil)
}
if p.options.Grayscale {
for y := 0; y < h; y++ {
if err := ctx.Err(); err != nil {
return nil, err
}
for x := 0; x < w; x++ {
i := dst.PixOffset(x, y)
v := uint8((uint32(dst.Pix[i])*19595 + uint32(dst.Pix[i+1])*38470 + uint32(dst.Pix[i+2])*7471 + 32768) >> 16)
dst.Pix[i], dst.Pix[i+1], dst.Pix[i+2] = v, v, v
}
}
}
return dst, ctx.Err()
}
// printLines 生成纸面边框和位于可打印区域内的裁切标记
// 入参: side 当前输出纸面
// 返回: [][4]float64 毫米坐标线段
func (p *PrintDocument) printLines(side PrintSide) [][4]float64 {
var result [][4]float64
for _, placement := range side.Placements {
b := placement.Clip
if p.options.Borders {
result = append(result, [4]float64{b.X, b.Y, b.X + b.W, b.Y}, [4]float64{b.X, b.Y, b.X, b.Y + b.H}, [4]float64{b.X + b.W, b.Y, b.X + b.W, b.Y + b.H}, [4]float64{b.X, b.Y + b.H, b.X + b.W, b.Y + b.H})
}
if p.options.CropMarks {
limits := b
m := placement.Transform
x0, y0, x1, y1 := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
for _, point := range [][2]float64{{0, 0}, {placement.Width, 0}, {0, placement.Height}, {placement.Width, placement.Height}} {
x, y := m[0]*point[0]+m[2]*point[1]+m[4], m[1]*point[0]+m[3]*point[1]+m[5]
x0, y0, x1, y1 = math.Min(x0, x), math.Min(y0, y), math.Max(x1, x), math.Max(y1, y)
}
if side.TileColumns > 0 {
if side.TileColumn > 1 {
b.X += p.options.Overlap / 2
b.W -= p.options.Overlap / 2
}
if side.TileColumn < side.TileColumns {
b.W -= p.options.Overlap / 2
}
if side.TileRow > 1 {
b.Y += p.options.Overlap / 2
b.H -= p.options.Overlap / 2
}
if side.TileRow < side.TileRows {
b.H -= p.options.Overlap / 2
}
}
x0, y0, x1, y1 = math.Max(x0, b.X), math.Max(y0, b.Y), math.Min(x1, b.X+b.W), math.Min(y1, b.Y+b.H)
b = Box{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
if b.W <= 0 || b.H <= 0 {
continue
}
for _, x := range []float64{b.X, b.X + b.W} {
for _, y := range []float64{b.Y, b.Y + b.H} {
dx, dy, roomX, roomY := -1.0, -1.0, x-limits.X, y-limits.Y
if x > b.X {
dx = 1
roomX = limits.X + limits.W - x
}
if y > b.Y {
dy = 1
roomY = limits.Y + limits.H - y
}
if roomX > 0.7 {
result = append(result, [4]float64{x + dx*0.5, y, x + dx*math.Min(3.5, roomX-0.1), y})
}
if roomY > 0.7 {
result = append(result, [4]float64{x, y + dy*0.5, x, y + dy*math.Min(3.5, roomY-0.1)})
}
}
}
}
}
return result
}
// WriteHTML 输出不含应用脚本的独立打印文档,每个纸面按毫米尺寸分页
// 入参: ctx 输出上下文, writer 输出流, title 文档标题
// 返回: error 输出或取消错误;调用方在字体和图片加载后触发打印
func (p *PrintDocument) WriteHTML(ctx context.Context, writer io.Writer, title string) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, err := p.printSide(ctx, 0); err != nil {
return err
}
if writer == nil {
return fmt.Errorf("missing print writer")
}
out := convertWriter{ctx: ctx, writer: writer, count: new(int64)}
if _, err := fmt.Fprintf(out, "<!doctype html><html><head><meta charset=\"utf-8\"><title>%s</title><style>html,body{margin:0;padding:0;background:white}*{box-sizing:border-box}section{break-after:page;overflow:hidden;print-color-adjust:exact;-webkit-print-color-adjust:exact}section:last-child{break-after:auto}img{display:block;width:100%%;height:100%%}@media screen{section{margin:12px auto;box-shadow:0 0 4px #888}}</style><style>", html.EscapeString(title)); err != nil {
return err
}
for i, side := range p.layout.Sides {
if _, err := fmt.Fprintf(out, "@page s%d{size:%gmm %gmm;margin:0}.s%d{page:s%d;width:%gmm;height:%gmm}", i, side.Width, side.Height, i, i, side.Width, side.Height); err != nil {
return err
}
}
if _, err := io.WriteString(out, "</style></head><body>"); err != nil {
return err
}
for i := range p.layout.Sides {
var svg bytes.Buffer
if err := p.renderPrintSVG(ctx, i, &svg); err != nil {
return err
}
if _, err := fmt.Fprintf(out, `<section class="s%d"><img src="data:image/svg+xml;base64,%s"></section>`, i, base64.StdEncoding.EncodeToString(svg.Bytes())); err != nil {
return err
}
}
_, err := io.WriteString(out, "</body></html>")
return err
}
// WritePDF 将相同打印布局写成矢量PDF;显式图像或灰度模式按任务DPI输出
// 入参: ctx 输出上下文, writer 输出流
// 返回: error 后端、读取、写入或取消错误;失败时调用方丢弃部分输出
func (p *PrintDocument) WritePDF(ctx context.Context, writer io.Writer) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, err := p.printSide(ctx, 0); err != nil {
return err
}
if writer == nil {
return fmt.Errorf("missing print writer")
}
sheets := make([]codecpdf.PrintSheet, len(p.layout.Sides))
raster := p.options.Raster || p.options.Grayscale
for i, side := range p.layout.Sides {
sheets[i] = codecpdf.PrintSheet{Width: side.Width, Height: side.Height}
if raster {
continue
}
sheets[i].Lines = p.printLines(side)
sheets[i].Label, sheets[i].LabelX, sheets[i].LabelY = side.Label, side.LabelX, side.LabelY
for _, placement := range side.Placements {
sheets[i].Placements = append(sheets[i].Placements, codecpdf.PrintPlacement{Page: placement.Page, Matrix: placement.Transform, Clip: [4]float64{placement.Clip.X, placement.Clip.Y, placement.Clip.W, placement.Clip.H}})
}
}
if raster {
return codecpdf.WritePrintImages(ctx, writer, sheets, func(i int) (image.Image, error) { return p.renderPrintImage(ctx, i, p.options.DPI) })
}
var indices []int
for _, side := range p.layout.Sides {
for _, placement := range side.Placements {
indices = append(indices, placement.Page)
}
}
slices.Sort(indices)
indices = slices.Compact(indices)
if len(indices) == 0 {
return codecpdf.WritePrintPDF(ctx, writer, nil, sheets)
}
var source bytes.Buffer
if err := p.renderer.RenderToMultiPagePDF(&source, indices...); err != nil {
return err
}
for i := range sheets {
for j := range sheets[i].Placements {
sheets[i].Placements[j].Page, _ = slices.BinarySearch(indices, sheets[i].Placements[j].Page)
}
}
return codecpdf.WritePrintPDF(ctx, writer, source.Bytes(), sheets)
}
+9 -1
View File
@@ -20,6 +20,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"maps" "maps"
@@ -28,6 +29,9 @@ import (
"strings" "strings"
) )
// ErrIncompleteDocument 表示渐进阅读快照尚未包含请求的页面或完整文档
var ErrIncompleteDocument = errors.New("document content is incomplete")
// Reader OFD文件阅读器 // Reader OFD文件阅读器
// Reader及共享它的Renderer需串行调用 // Reader及共享它的Renderer需串行调用
type Reader struct { type Reader struct {
@@ -50,6 +54,7 @@ type Reader struct {
drawParamCache map[string]*DrawParam drawParamCache map[string]*DrawParam
compositeGraphicUnitCache map[string]*CompositeGraphicUnit compositeGraphicUnitCache map[string]*CompositeGraphicUnit
pageHeaderCache map[string]PageContent pageHeaderCache map[string]PageContent
pageReady map[string]bool
pageContents *renderCache[pageContentKey, PageContent] pageContents *renderCache[pageContentKey, PageContent]
doc *Document doc *Document
relatedPending bool relatedPending bool
@@ -211,7 +216,7 @@ func (r *Reader) DocumentContext(ctx context.Context, index int) (*Reader, error
if index < 0 || index >= r.DocumentCount() { if index < 0 || index >= r.DocumentCount() {
return nil, fmt.Errorf("document index %d out of range", index) return nil, fmt.Errorf("document index %d out of range", index)
} }
next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources} next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources, pageReady: r.pageReady}
if err := next.initRootContext(ctx); err != nil { if err := next.initRootContext(ctx); err != nil {
return nil, err return nil, err
} }
@@ -1098,6 +1103,9 @@ func resolveResourcePath(resPath, baseLoc, filePath string) string {
// 入参: ctx 取消上下文, page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取 // 入参: ctx 取消上下文, page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取
// 返回: *PageContent 页面内容, error 错误信息 // 返回: *PageContent 页面内容, error 错误信息
func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, template bool) (*PageContent, error) { func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, template bool) (*PageContent, error) {
if !template && r.pageReady != nil && !r.pageReady[page.ID] {
return nil, ErrIncompleteDocument
}
fullPath := r.ResPath(page.BaseLoc) fullPath := r.ResPath(page.BaseLoc)
data, err := r.readFileViewContext(ctx, fullPath) data, err := r.readFileViewContext(ctx, fullPath)
if err != nil { if err != nil {
+18
View File
@@ -77,6 +77,9 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
} }
} }
for order := range 3 { for order := range 3 {
if r.printing && (r.printContents == "annotations" || r.printContents == "forms") {
break
}
if r.Reader.doc != nil { if r.Reader.doc != nil {
for _, ref := range page.Template { for _, ref := range page.Template {
kind := ref.ZOrder kind := ref.ZOrder
@@ -103,6 +106,18 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
} }
if r.RenderAnnotations { if r.RenderAnnotations {
for _, annotation := range r.Reader.Annots[page.ID] { for _, annotation := range r.Reader.Annots[page.ID] {
if r.printing && (r.printContents == "document" && annotation.Subtype != "Widget" || r.printContents == "forms" && annotation.Subtype != "Widget" || r.printContents == "annotations" && annotation.Subtype == "Widget") {
continue
}
if r.printing && annotation.Parameters != nil {
for _, parameter := range annotation.Parameters.Parameter {
if parameter.Name == "PDF.Printable" && (parameter.Value == "true" || parameter.Value == "false") {
visible := parameter.Value == "true"
annotation.Visible = &visible
break
}
}
}
if annotation.Visible != nil && !*annotation.Visible && !pageIncludesHidden(visitor) { if annotation.Visible != nil && !*annotation.Visible && !pageIncludesHidden(visitor) {
continue continue
} }
@@ -111,6 +126,9 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
} }
} }
} }
if r.printing && (r.printContents == "annotations" || r.printContents == "forms") {
return nil
}
for _, stamp := range r.Reader.Stamps[page.ID] { for _, stamp := range r.Reader.Stamps[page.ID] {
if r.pageContext != nil { if r.pageContext != nil {
if err := r.pageContext.Err(); err != nil { if err := r.pageContext.Err(); err != nil {
+5
View File
@@ -47,6 +47,8 @@ type Renderer struct {
pageText *PageText pageText *PageText
pageContext context.Context pageContext context.Context
textOnly bool textOnly bool
printing bool
printContents string
textContentOnly bool textContentOnly bool
backends RenderBackends backends RenderBackends
backendStates map[any]any backendStates map[any]any
@@ -84,6 +86,8 @@ func (r *Renderer) Fork(ctx context.Context) (*Renderer, error) {
next.OutputContext = ctx next.OutputContext = ctx
next.TransparentBackground = r.TransparentBackground next.TransparentBackground = r.TransparentBackground
next.decodeImages = r.decodeImages next.decodeImages = r.decodeImages
next.printing = r.printing
next.printContents = r.printContents
next.imageCache = r.imageCache next.imageCache = r.imageCache
next.fontDigests = r.fontDigests next.fontDigests = r.fontDigests
next.fontPreparations = r.fontPreparations next.fontPreparations = r.fontPreparations
@@ -150,6 +154,7 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer {
opts = append(opts, WithFontFS(r.fontFS...)) opts = append(opts, WithFontFS(r.fontFS...))
} }
renderer := NewRenderer(reader, opts...) renderer := NewRenderer(reader, opts...)
renderer.printing = r.printing
renderer.sharedBackendStates = r.sharedBackendStates renderer.sharedBackendStates = r.sharedBackendStates
renderer.fontPreparations = r.fontPreparations renderer.fontPreparations = r.fontPreparations
renderer.fontDigests = r.fontDigests renderer.fontDigests = r.fontDigests
+10 -2
View File
@@ -138,10 +138,14 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
return fmt.Errorf("no pages found") return fmt.Errorf("no pages found")
} }
doc := r.Reader.doc doc := r.Reader.doc
navigation, err := newPDFNavigation(r, doc, pages) navigation := &pdfNavigation{}
if !r.printing {
var err error
navigation, err = newPDFNavigation(r, doc, pages)
if err != nil { if err != nil {
return err return err
} }
}
buf, direct := writer.(*bytes.Buffer) buf, direct := writer.(*bytes.Buffer)
if r.Compression.Mode != CompressionUnchanged { if r.Compression.Mode != CompressionUnchanged {
direct = false direct = false
@@ -182,16 +186,20 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
buf.Truncate(start) buf.Truncate(start)
return fmt.Errorf("failed to read page %d: %w", i+1, err) return fmt.Errorf("failed to read page %d: %w", i+1, err)
} }
if !r.printing {
if err := navigation.addPage(r, i, content, pages); err != nil { if err := navigation.addPage(r, i, content, pages); err != nil {
buf.Truncate(start) buf.Truncate(start)
return fmt.Errorf("failed to read page %d navigation: %w", i+1, err) return fmt.Errorf("failed to read page %d navigation: %w", i+1, err)
} }
}
if i > 0 { if i > 0 {
p.NewPage(page.Box.W, page.Box.H) p.NewPage(page.Box.W, page.Box.H)
renderer.images = append(renderer.images, nil) renderer.images = append(renderer.images, nil)
renderer.text = append(renderer.text, codecpdf.TextPage{}) renderer.text = append(renderer.text, codecpdf.TextPage{})
} }
if !r.printing {
navigation.apply(p, i) navigation.apply(p, i)
}
if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), content, !r.TransparentBackground); err != nil { if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), content, !r.TransparentBackground); err != nil {
buf.Truncate(start) buf.Truncate(start)
return fmt.Errorf("failed to render page %d: %w", i+1, err) return fmt.Errorf("failed to render page %d: %w", i+1, err)
@@ -216,7 +224,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
return err return err
} }
} }
if doc != nil { if doc != nil && !r.printing {
if err := navigation.addOutlines(doc.Outlines.OutlineElem, 0, navigation.bookmarks, navigation.pageIndex, pages); err != nil { if err := navigation.addOutlines(doc.Outlines.OutlineElem, 0, navigation.bookmarks, navigation.pageIndex, pages); err != nil {
buf.Truncate(start) buf.Truncate(start)
return err return err
+1 -1
View File
@@ -403,7 +403,7 @@ func scanResourcePage(ctx context.Context, file *editorFontXML, pages map[string
return false, false, nil, nil return false, false, nil, nil
} }
for _, attr := range token.Attr { for _, attr := range token.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(token.Name, attr) {
return false, false, nil, nil return false, false, nil, nil
} }
} }
+53 -8
View File
@@ -31,6 +31,7 @@ import (
// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号 // richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号
type richLayout struct { type richLayout struct {
width float64
objects []GraphicObject objects []GraphicObject
bounds Box bounds Box
anchors []int anchors []int
@@ -91,8 +92,7 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
return richLayout{}, err return richLayout{}, err
} }
options := value.Layout options := value.Layout
available := width - options.LeftIndent - options.RightIndent if !finite(width) || width <= 0 {
if !finite(width) || width <= 0 || !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 {
return richLayout{}, fmt.Errorf("invalid rich text width or indents") return richLayout{}, fmt.Errorf("invalid rich text width or indents")
} }
geometry, err := e.Geometry() geometry, err := e.Geometry()
@@ -124,16 +124,52 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
} }
chars := []rune(value.Text()) chars := []rune(value.Text())
var face *typefont.Face var face *typefont.Face
var underlays []GraphicObject if value.AutoWidth && !options.Wrap {
var shadows []GraphicObject width = 1
var y float64 for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
for start := 0; start <= len(chars); {
end := start end := start
for end < len(chars) && chars[end] != '\n' { for end < len(chars) && chars[end] != '\n' {
end++ end++
} }
paragraph := chars[start:end] paragraph := chars[start:end]
local := richParagraphOptions(paragraph, options) local := richParagraphOptions(paragraph, value.ParagraphLayout(paragraphIndex))
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
if err != nil {
return richLayout{}, err
}
}
_, advances, err := richShapeLine(paragraph, owners[start:end], value, metrics, local, face)
if err != nil {
return richLayout{}, err
}
length := 0.0
for _, advance := range advances {
length += advance
}
width = max(width, local.LeftIndent+local.RightIndent+max(0, local.FirstLineIndent)+1, local.LeftIndent+local.RightIndent+local.FirstLineIndent+length)
if end == len(chars) {
break
}
start = end + 1
}
}
result.width = width
var underlays []GraphicObject
var shadows []GraphicObject
var y float64
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
end := start
for end < len(chars) && chars[end] != '\n' {
end++
}
paragraph := chars[start:end]
local := value.ParagraphLayout(paragraphIndex)
available := width - local.LeftIndent - local.RightIndent
if !finite(available) || available <= 0 || !finite(available-local.FirstLineIndent) || available-local.FirstLineIndent <= 0 {
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
}
local = richParagraphOptions(paragraph, local)
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 { if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write())) face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
if err != nil { if err != nil {
@@ -194,7 +230,16 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height) ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height)
} }
height = max(height, ascent+descent) height = max(height, ascent+descent)
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing) var clusters map[int]bool
if local.Align == "distributed" {
clusters = make(map[int]bool)
for _, span := range spans {
for _, glyph := range span.glyphs {
clusters[span.start+glyph.Cluster] = true
}
}
}
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing, clusters)
copy(steps, parseFloats(delta)) copy(steps, parseFloats(delta))
if local.Shaping.Direction == "rtl" { if local.Shaping.Direction == "rtl" {
for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- { for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- {
+187 -4
View File
@@ -17,6 +17,8 @@ package ofdgo
import ( import (
"errors" "errors"
"fmt" "fmt"
"maps"
"slices"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -27,9 +29,43 @@ import (
var ErrNotRichText = errors.New("object is not rich text") var ErrNotRichText = errors.New("object is not rich text")
// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节 // RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节
// Paragraphs为空时所有段落沿用Layout,否则数量必须与LF分隔的段落一致
// AutoWidth在不折行时按各段自然宽度确定文本框,指定宽度调整后关闭
type RichText struct { type RichText struct {
AutoWidth bool `json:"autoWidth,omitempty"`
Runs []RichTextRun `json:"runs"` Runs []RichTextRun `json:"runs"`
Layout TextLayout `json:"layout"` Layout TextLayout `json:"layout"`
Paragraphs []RichTextParagraph `json:"paragraphs,omitempty"`
}
// RichTextParagraph 保存显式段落选项,空值沿用Layout,按LF分隔的段落顺序排列
type RichTextParagraph struct {
Align string `json:"align"`
LineHeight *float64 `json:"lineHeight,omitempty"`
LetterSpacing *float64 `json:"letterSpacing,omitempty"`
LeftIndent *float64 `json:"leftIndent,omitempty"`
RightIndent *float64 `json:"rightIndent,omitempty"`
FirstLineIndent *float64 `json:"firstLineIndent,omitempty"`
}
// ParagraphLayout 返回指定段落覆盖后的排版选项
// 入参: index 段落序号
// 返回: TextLayout 有效段落选项
func (v RichText) ParagraphLayout(index int) TextLayout {
options := v.Layout
if index < 0 || index >= len(v.Paragraphs) {
return options
}
p := v.Paragraphs[index]
if p.Align != "" {
options.Align = p.Align
}
for _, pair := range []struct{ source, target *float64 }{{p.LineHeight, &options.LineHeight}, {p.LetterSpacing, &options.LetterSpacing}, {p.LeftIndent, &options.LeftIndent}, {p.RightIndent, &options.RightIndent}, {p.FirstLineIndent, &options.FirstLineIndent}} {
if pair.source != nil {
*pair.target = *pair.source
}
}
return options
} }
// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米 // RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米
@@ -64,6 +100,59 @@ type TextFormatPatch struct {
Background *string `json:"background,omitempty"` Background *string `json:"background,omitempty"`
} }
// RichTextFonts 提取富文本实际引用的独立字体数据,供跨文档保存和复制使用
// 入参: value 富文本内容
// 返回: map[string][]byte 字体标识及数据, error 字体读取错误
func (e *Editor) RichTextFonts(value RichText) (map[string][]byte, error) {
fonts := make(map[string][]byte)
for _, run := range value.Runs {
if run.Text == "" || fonts[run.Format.Font] != nil {
continue
}
data, err := e.FontData(run.Format.Font)
if err != nil {
return nil, err
}
fonts[run.Format.Font] = data
}
return fonts, nil
}
// ImportRichText 注册富文本引用的外来字体并返回独立内容,失败时不保留新增资源
// 未提供数据的字体标识保留为当前文档引用,调用方可在外层事务中同时创建或更新对象
// 入参: value 富文本内容, fonts 外来字体标识及数据
// 返回: RichText 已映射字体标识的内容, error 字体注册错误
func (e *Editor) ImportRichText(value RichText, fonts map[string][]byte) (RichText, error) {
value = cloneEditorData(value)
used := make(map[string]bool)
for _, run := range value.Runs {
used[run.Format.Font] = true
}
err := e.Transaction(func(editor *Editor) error {
registered := make(map[string]string)
for _, key := range slices.Sorted(maps.Keys(fonts)) {
if !used[key] {
continue
}
id, err := editor.AddFont(FontFile{Data: fonts[key]}, 0)
if err != nil {
return err
}
registered[key] = id
}
for i := range value.Runs {
if id := registered[value.Runs[i].Format.Font]; id != "" {
value.Runs[i].Format.Font = id
}
}
return nil
})
if err != nil {
return RichText{}, err
}
return value, nil
}
// Text 返回包含显式换行的原文,不包含排版产生的软换行 // Text 返回包含显式换行的原文,不包含排版产生的软换行
// 返回: string 完整原文 // 返回: string 完整原文
func (v RichText) Text() string { func (v RichText) Text() string {
@@ -87,6 +176,14 @@ func (v RichText) Validate() error {
if err := validateRichLayout(v.Layout); err != nil { if err := validateRichLayout(v.Layout); err != nil {
return err return err
} }
if len(v.Paragraphs) != 0 && len(v.Paragraphs) != strings.Count(v.Text(), "\n")+1 {
return fmt.Errorf("rich text paragraph count does not match text")
}
for index := range v.Paragraphs {
if err := validateRichLayout(v.ParagraphLayout(index)); err != nil {
return err
}
}
bounds := richTextBoundaries(v.Text()) bounds := richTextBoundaries(v.Text())
position := 0 position := 0
for _, run := range v.Runs { for _, run := range v.Runs {
@@ -134,7 +231,7 @@ func validateRichLayout(v TextLayout) error {
return fmt.Errorf("invalid rich text layout dimensions") return fmt.Errorf("invalid rich text layout dimensions")
} }
switch v.Align { switch v.Align {
case "", "left", "center", "right", "justify": case "", "left", "center", "right", "justify", "distributed":
default: default:
return fmt.Errorf("invalid rich text alignment") return fmt.Errorf("invalid rich text alignment")
} }
@@ -177,7 +274,13 @@ func (v RichText) Slice(start, end int) (RichText, error) {
if err := v.checkRange(start, end); err != nil { if err := v.checkRange(start, end); err != nil {
return RichText{}, err return RichText{}, err
} }
result := RichText{Layout: v.Layout, Runs: v.sliceRuns(start, end)} result := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(start, end)}
if len(v.Paragraphs) > 0 {
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
count := strings.Count(string(chars[start:end]), "\n") + 1
result.Paragraphs = cloneEditorData(v.Paragraphs[first : first+count])
}
if len(result.Runs) == 0 { if len(result.Runs) == 0 {
position := 0 position := 0
format := v.Runs[len(v.Runs)-1].Format format := v.Runs[len(v.Runs)-1].Format
@@ -220,13 +323,28 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error {
if err := v.checkRange(start, end); err != nil { if err := v.checkRange(start, end); err != nil {
return err return err
} }
next := RichText{Layout: v.Layout, Runs: v.sliceRuns(0, start)} next := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(0, start)}
next.Runs = append(next.Runs, runs...) next.Runs = append(next.Runs, runs...)
next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...) next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...)
if len(next.Runs) == 0 { if len(next.Runs) == 0 {
caret, _ := v.Slice(start, start) caret, _ := v.Slice(start, start)
next.Runs = caret.Runs next.Runs = caret.Runs
} }
if len(v.Paragraphs) > 0 {
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
last := first + strings.Count(string(chars[start:end]), "\n")
next.Paragraphs = slices.Clone(v.Paragraphs[:first])
count := 1
for _, run := range runs {
count += strings.Count(run.Text, "\n")
}
for range count {
next.Paragraphs = append(next.Paragraphs, v.Paragraphs[first])
}
next.Paragraphs = append(next.Paragraphs, v.Paragraphs[last+1:]...)
next.Paragraphs = cloneEditorData(next.Paragraphs)
}
next = normalizeRichText(next) next = normalizeRichText(next)
if err := next.Validate(); err != nil { if err := next.Validate(); err != nil {
return err return err
@@ -255,7 +373,72 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error {
if start == end { if start == end {
return nil return nil
} }
return v.Replace(start, end, part.Runs) paragraphs := v.Paragraphs
if err := v.Replace(start, end, part.Runs); err != nil {
return err
}
v.Paragraphs = paragraphs
return nil
}
// AlignParagraphs 修改光标所在或半开选区涉及的段落,选区末端不包含下一段首
// 入参: start 起点, end 终点, align 对齐方式
// 返回: error 无效范围或对齐方式
func (v *RichText) AlignParagraphs(start, end int, align string) error {
if err := v.FormatParagraphs(start, end, RichTextParagraph{Align: align}); err != nil {
return err
}
if align == "" {
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
last := strings.Count(string(chars[:max(start, end-1)]), "\n")
for i := first; i <= last; i++ {
v.Paragraphs[i].Align = ""
}
}
return nil
}
// FormatParagraphs 修改当前或选中段落的显式选项,未提供的字段保持原值
// 入参: start 起点, end 终点, patch 段落增量
// 返回: error 范围或排版错误
func (v *RichText) FormatParagraphs(start, end int, patch RichTextParagraph) error {
if v == nil {
return fmt.Errorf("nil rich text")
}
if err := v.checkRange(start, end); err != nil {
return err
}
if err := validateRichLayout((RichText{Paragraphs: []RichTextParagraph{patch}}).ParagraphLayout(0)); err != nil {
return err
}
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
last := first
if end > start {
last = strings.Count(string(chars[:end-1]), "\n")
}
paragraphs := slices.Clone(v.Paragraphs)
if len(paragraphs) == 0 {
paragraphs = make([]RichTextParagraph, strings.Count(v.Text(), "\n")+1)
}
for i := first; i <= last; i++ {
p := &paragraphs[i]
if patch.Align != "" {
p.Align = patch.Align
}
for _, pair := range []struct {
source *float64
target **float64
}{{patch.LineHeight, &p.LineHeight}, {patch.LetterSpacing, &p.LetterSpacing}, {patch.LeftIndent, &p.LeftIndent}, {patch.RightIndent, &p.RightIndent}, {patch.FirstLineIndent, &p.FirstLineIndent}} {
if pair.source != nil {
value := *pair.source
*pair.target = &value
}
}
}
v.Paragraphs = paragraphs
return nil
} }
// apply 应用显式给出的格式字段 // apply 应用显式给出的格式字段
+163 -15
View File
@@ -20,11 +20,19 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"sort"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"unsafe"
) )
// PageTextCache 按内存预算保留最近访问的页面文字索引
// 缓存后不得修改PageText,页面或字体变化时须删除对应条目
type PageTextCache struct {
cache *renderCache[int, *PageText]
}
// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片 // textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片
type textPageTokens struct { type textPageTokens struct {
*xml.Decoder *xml.Decoder
@@ -53,7 +61,8 @@ type TextSpan struct {
Matrix [6]float64 `json:"matrix"` Matrix [6]float64 `json:"matrix"`
} }
// TextMatch 文字匹配结果,ID为对象标识,Run为文本对象索引,Start和End为原文Unicode字符区间 // TextMatch 文字匹配结果,ID和Run定位首个对象,Start和End为该对象内的Unicode字符区间
// 跨对象匹配通过Parts保留各对象区间,Text和Boxes包含完整命中
type TextMatch struct { type TextMatch struct {
ID string `json:"id"` ID string `json:"id"`
Run int `json:"run"` Run int `json:"run"`
@@ -63,6 +72,92 @@ type TextMatch struct {
Text string `json:"text"` Text string `json:"text"`
After string `json:"after"` After string `json:"after"`
Boxes []Box `json:"boxes"` Boxes []Box `json:"boxes"`
Parts []TextMatchPart `json:"parts,omitempty"`
}
// TextMatchPart 跨对象匹配中的原文区间,Start和End为对象内Unicode字符偏移
type TextMatchPart struct {
ID string `json:"id"`
Run int `json:"run"`
Start int `json:"start"`
End int `json:"end"`
}
// NewPageTextCache 创建文字索引缓存,预算包含字符区域和条目开销
// 入参: bytes 内存预算,非正数禁用缓存
// 返回: *PageTextCache 独立缓存
func NewPageTextCache(bytes int) *PageTextCache {
return &PageTextCache{cache: resourceCache[int, *PageText](nil, max(0, bytes))}
}
// MemorySize 估算文字索引占用,包含切片容量和字符串内容
// 返回: int64 估算字节数,共享字符串按各次引用计费
func (p *PageText) MemorySize() int64 {
size := int64(unsafe.Sizeof(*p)) + int64(cap(p.Runs))*int64(unsafe.Sizeof(TextRun{}))
for _, run := range p.Runs {
size += int64(len(run.ID)) + int64(len(run.Text))
size += int64(cap(run.Boxes))*int64(unsafe.Sizeof(Box{})) + int64(cap(run.Spans))*int64(unsafe.Sizeof(TextSpan{}))
}
return size
}
// Get 获取文字索引并更新最近访问顺序
// 入参: index 页面索引
// 返回: *PageText 文字索引,未命中时为nil
func (c *PageTextCache) Get(index int) *PageText {
text, _ := c.cache.get(index)
return text
}
// Put 缓存完整文字索引,超出预算的单页不驱逐其他页面
// 入参: index 页面索引, text 文字索引,nil删除已有条目
func (c *PageTextCache) Put(index int, text *PageText) {
if text == nil {
c.Delete(index)
return
}
size := text.MemorySize() + 128
if size > int64(c.cache.limit) {
c.Delete(index)
return
}
c.cache.put(index, text, int(size))
}
// Delete 删除页面索引并释放预算
// 入参: index 页面索引
func (c *PageTextCache) Delete(index int) {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
if entry := c.cache.entries[index]; entry != nil {
c.cache.remove(entry)
}
}
// Clear 清空缓存和计费状态
func (c *PageTextCache) Clear() {
c.cache.clear()
}
// Len 返回缓存页面数
// 返回: int 已缓存页面数
func (c *PageTextCache) Len() int {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
return len(c.cache.entries)
}
// Range 按访问时间从旧到新遍历,遍历期间不得访问当前缓存
// 入参: visit 访问函数,返回false停止遍历
func (c *PageTextCache) Range(visit func(int, *PageText) bool) {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
for entry := c.cache.order.Back(); entry != nil; entry = entry.Prev() {
value := entry.Value.(renderCacheEntry[int, *PageText])
if !visit(value.key, value.value) {
return
}
}
} }
// Token 读取文字页面所需的XML节点 // Token 读取文字页面所需的XML节点
@@ -172,7 +267,7 @@ func (p *PageText) WriteTo(writer io.Writer) (int64, error) {
return p.writeTo(writer, false) return p.writeTo(writer, false)
} }
// Search 按原文进行忽略大小写的字面匹配,不跨文本对象拼接或识别图像 // Search 按原文进行忽略大小写的字面匹配,拼接同一行紧邻的文本对象,不识别图像
// 入参: query 搜索文字 // 入参: query 搜索文字
// 返回: []TextMatch 匹配结果及上下文 // 返回: []TextMatch 匹配结果及上下文
func (p *PageText) Search(query string) []TextMatch { func (p *PageText) Search(query string) []TextMatch {
@@ -184,7 +279,7 @@ func (p *PageText) Search(query string) []TextMatch {
return matches return matches
} }
// SearchEach 逐项返回忽略大小写的字面匹配,不累积全部结果,在对象与匹配之间检查取消 // SearchEach 逐项返回忽略大小写的字面匹配,拼接同一行紧邻对象,在索引构建及匹配之间检查取消
// 入参: ctx 取消上下文, query 搜索文字, visit 匹配访问函数,可返回错误停止 // 入参: ctx 取消上下文, query 搜索文字, visit 匹配访问函数,可返回错误停止
// 返回: error 取消或访问错误 // 返回: error 取消或访问错误
func (p *PageText) SearchEach(ctx context.Context, query string, visit func(TextMatch) error) error { func (p *PageText) SearchEach(ctx context.Context, query string, visit func(TextMatch) error) error {
@@ -268,11 +363,39 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
return ctx.Err() return ctx.Err()
} }
length := utf8.RuneCountInString(query) length := utf8.RuneCountInString(query)
for index, run := range p.Runs { for first := 0; first < len(p.Runs); {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
text := foldText(run.Text) last := first + 1
for last < len(p.Runs) && adjacentTextRuns(p.Runs[last-1], p.Runs[last]) {
if err := ctx.Err(); err != nil {
return err
}
last++
}
var source strings.Builder
offsets := make([]int, last-first+1)
for index := first; index < last; index++ {
if err := ctx.Err(); err != nil {
return err
}
source.WriteString(p.Runs[index].Text)
offsets[index-first+1] = offsets[index-first] + utf8.RuneCountInString(p.Runs[index].Text)
}
var folded strings.Builder
folded.Grow(source.Len())
polled := 0
for _, char := range source.String() {
if polled%256 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
polled++
folded.WriteRune(foldRune(char))
}
text := folded.String()
var runes []rune var runes []rune
codeOffset := 0 codeOffset := 0
for offset := 0; offset < len(text); { for offset := 0; offset < len(text); {
@@ -284,29 +407,33 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
break break
} }
if runes == nil { if runes == nil {
runes = []rune(run.Text) runes = []rune(source.String())
} }
start := codeOffset + utf8.RuneCountInString(text[offset:offset+found]) start := codeOffset + utf8.RuneCountInString(text[offset:offset+found])
offset += found offset += found
end := start + length end := start + length
match := TextMatch{ begin := sort.Search(len(offsets)-1, func(i int) bool { return offsets[i+1] > start })
ID: run.ID, match := TextMatch{ID: p.Runs[first+begin].ID, Run: first + begin, Start: start - offsets[begin], End: min(end, offsets[begin+1]) - offsets[begin],
Run: index, Start: start, End: end, Before: string(runes[max(0, start-16):start]), Text: string(runes[start:end]), After: string(runes[end:min(len(runes), end+16)])}
Before: string(runes[max(0, start-16):start]), for index := begin; index < last-first && offsets[index] < end; index++ {
Text: string(runes[start:end]), run := p.Runs[first+index]
After: string(runes[end:min(len(runes), end+16)]), from, to := max(start, offsets[index])-offsets[index], min(end, offsets[index+1])-offsets[index]
if end > offsets[begin+1] {
match.Parts = append(match.Parts, TextMatchPart{ID: run.ID, Run: first + index, Start: from, End: to})
} }
for _, box := range run.Boxes[min(start, len(run.Boxes)):min(end, len(run.Boxes))] { for _, box := range run.Boxes[min(from, len(run.Boxes)):min(to, len(run.Boxes))] {
if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) { if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) {
match.Boxes = append(match.Boxes, box) match.Boxes = append(match.Boxes, box)
} }
} }
}
if err := visit(match); err != nil { if err := visit(match); err != nil {
return err return err
} }
offset += len(query) offset += len(query)
codeOffset = end codeOffset = end
} }
first = last
} }
return ctx.Err() return ctx.Err()
} }
@@ -315,7 +442,13 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
// 入参: text 原文 // 入参: text 原文
// 返回: string 匹配用文字 // 返回: string 匹配用文字
func foldText(text string) string { func foldText(text string) string {
return strings.Map(func(char rune) rune { return strings.Map(foldRune, text)
}
// foldRune 统一单个Unicode大小写等价字符
// 入参: char 原字符
// 返回: rune 匹配字符
func foldRune(char rune) rune {
if char < utf8.RuneSelf { if char < utf8.RuneSelf {
if char >= 'a' && char <= 'z' { if char >= 'a' && char <= 'z' {
return char - ('a' - 'A') return char - ('a' - 'A')
@@ -327,7 +460,22 @@ func foldText(text string) string {
folded = min(folded, next) folded = min(folded, next)
} }
return folded return folded
}, text) }
// adjacentTextRuns 判断绘制顺序相邻的文字是否在同一行连续,缺少定位时保持独立
// 入参: before 前对象, after 后对象
// 返回: bool 是否可连续搜索
func adjacentTextRuns(before, after TextRun) bool {
if before.Text == "" || after.Text == "" || len(before.Boxes) != utf8.RuneCountInString(before.Text) || len(after.Boxes) == 0 {
return false
}
a, b := before.Boxes[len(before.Boxes)-1], after.Boxes[0]
size := math.Min(a.H, b.H)
if size <= 0 || a.W <= 0 || b.W <= 0 {
return false
}
gap := b.X - a.X - a.W
return math.Abs(a.Y+a.H/2-b.Y-b.H/2) <= size*.25 && gap >= -size*.15 && gap <= size*.35 && b.X > a.X
} }
// addRun 收集原始文本,字形索引不替代Unicode原文 // addRun 收集原始文本,字形索引不替代Unicode原文
+257
View File
@@ -21,8 +21,10 @@ import (
"crypto" "crypto"
"encoding/base64" "encoding/base64"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"maps"
"path" "path"
"slices" "slices"
"strconv" "strconv"
@@ -40,6 +42,261 @@ const (
// SignatureWriteMode 签署方式 // SignatureWriteMode 签署方式
type SignatureWriteMode uint8 type SignatureWriteMode uint8
// ErrSignatureDependency 表示移除操作会改变保留签名或其他版本使用的入口和保护文件
var ErrSignatureDependency = errors.New("signature dependency conflict")
// RemoveSignature 移除当前文档版本的指定签名及外观,支持撤销重做
// 仅清理无其他引用的签名文件,依赖冲突时不修改文档
// 入参: id 签名标识
// 返回: error 标识、依赖或读取错误
func (e *Editor) RemoveSignature(id string) error {
return e.RemoveSignatureContext(context.Background(), id)
}
// RemoveSignatureContext 移除指定签名,取消或失败时不改变文档和历史
// 入参: ctx 取消上下文, id 签名标识
// 返回: error 标识、依赖、读取或取消错误
func (e *Editor) RemoveSignatureContext(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("empty signature ID")
}
return e.removeSignatures(ctx, id)
}
// ClearSignatures 清空当前文档版本的签名及外观,保留其他文档和版本的引用
// 无签名时不产生历史记录,依赖冲突时不修改文档
// 返回: error 依赖或读取错误
func (e *Editor) ClearSignatures() error {
return e.ClearSignaturesContext(context.Background())
}
// ClearSignaturesContext 清空当前签名,取消或失败时不改变文档和历史
// 入参: ctx 取消上下文
// 返回: error 依赖、读取或取消错误
func (e *Editor) ClearSignaturesContext(ctx context.Context) error {
return e.removeSignatures(ctx, "")
}
// removeSignatures 在独立快照中移除签名入口,校验保留依赖后提交历史
// 入参: ctx 取消上下文, id 签名标识,空值清空
// 返回: error 标识、依赖、结构或取消错误
func (e *Editor) removeSignatures(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
reader, err := e.readerContext(ctx, nil)
if err != nil {
return err
}
doc, err := reader.DocContext(ctx)
if err != nil {
return err
}
if doc.Signatures == "" {
if id != "" {
return fmt.Errorf("signature not found: %s", id)
}
return nil
}
name := reader.ResPath(doc.Signatures)
data, err := reader.readFileViewContext(ctx, name)
if err != nil {
return err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return err
}
removed := make(map[string]bool)
candidates := make(map[string]bool)
var patches []editorXMLPatch
remaining := 0
for _, node := range root.children {
if !node.matchesOFD("Signature") {
continue
}
if id != "" && node.attr("ID") != id {
remaining++
continue
}
location := signatureRefPath(name, node.attr("BaseLoc"))
files, _, err := reader.signatureRemovalFiles(ctx, location)
if err != nil {
return err
}
maps.Copy(candidates, files)
removed[node.attr("ID")] = true
patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
}
if id != "" && !removed[id] {
return fmt.Errorf("signature not found: %s", id)
}
protected, dependencies, err := reader.signatureRemovalProtected(ctx, name, removed)
if err != nil {
return err
}
parts := make(map[string][]byte)
if reader.versionInfo != nil {
if remaining != 0 {
return fmt.Errorf("%w: version shares signature list %s", ErrSignatureDependency, name)
}
candidates[name] = true
used, err := reader.versionFileDependencies(ctx, reader.documentRoot, nil, nil)
if err != nil {
return err
}
removedFiles := maps.Clone(candidates)
for file := range used {
delete(removedFiles, file)
}
shared, err := reader.versionSharedFiles(ctx)
if err != nil {
return err
}
if err := reader.versionManifest(ctx, parts, nil, used, shared, removedFiles); err != nil {
return err
}
} else {
if dependencies[name] {
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, name)
}
for file := range candidates {
if dependencies[file] {
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, file)
}
}
versions, err := reader.DocumentVersionsContext(ctx)
if err != nil {
return err
}
for _, version := range versions {
for _, file := range version.Files {
if file.Location == name {
return fmt.Errorf("%w: version %s uses %s", ErrSignatureDependency, version.ID, name)
}
}
}
entry, err := reader.readFileViewContext(ctx, "OFD.xml")
if err != nil {
return err
}
ofd, err := parseEditorXMLContext(ctx, entry)
if err != nil {
return err
}
node := ofd.childAt("DocBody", reader.documentIndex).child("Signatures")
if remaining == 0 {
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{{start: node.start, end: node.end}})
candidates[name] = true
} else {
target := name
shared := false
for index, body := range reader.OFD.DocBody {
shared = shared || index != reader.documentIndex && body.Signatures != "" && signatureRefPath("OFD.xml", body.Signatures) == name
}
if shared {
target = packageAvailableName(reader, parts, name)
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{editorXMLContent(entry, node, editorFontXMLText("/"+target))})
}
parts[target] = editorPatchXML(data, patches)
}
}
for file, updated := range parts {
if !protected[file] {
continue
}
same, err := packageDataEqual(ctx, reader, file, updated)
if err != nil {
return err
}
if !same {
return fmt.Errorf("%w: protected file %s", ErrSignatureDependency, file)
}
}
for file, data := range reader.files {
if _, changed := parts[file]; !changed {
parts[file] = data
}
}
reader.files = parts
for file := range protected {
delete(candidates, file)
}
if err := reader.retireVersionFiles(ctx, candidates); err != nil {
return err
}
return e.commitResourceSnapshot(ctx, reader)
}
// signatureRemovalFiles 区分签名自有文件和摘要保护内容,正文不作为删除候选
// 入参: ctx 取消上下文, name 签名描述路径
// 返回: map[string]bool 自有文件, map[string]bool 保护文件, error 读取或解析错误
func (r *Reader) signatureRemovalFiles(ctx context.Context, name string) (map[string]bool, map[string]bool, error) {
data, err := r.readFileViewContext(ctx, name)
if err != nil {
return nil, nil, err
}
var signature SignatureFile
if err := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}).Decode(&signature); err != nil {
return nil, nil, err
}
owned := map[string]bool{name: true}
for _, location := range []string{signature.SignedValue, signature.SignedInfo.Seal.BaseLoc} {
if location != "" {
owned[signatureRefPath(name, location)] = true
}
}
protected := make(map[string]bool)
for _, reference := range signature.SignedInfo.References.Reference {
protected[signatureRefPath(name, reference.FileRef)] = true
}
return owned, protected, ctx.Err()
}
// signatureRemovalProtected 汇总保留签名的原文及其他文档的签名入口,不要求证书或私钥
// 入参: ctx 取消上下文, selected 当前签名列表, removed 待移除标识
// 返回: map[string]bool 保留路径, map[string]bool 非共享的摘要保护路径, error 读取、结构或取消错误
func (r *Reader) signatureRemovalProtected(ctx context.Context, selected string, removed map[string]bool) (map[string]bool, map[string]bool, error) {
protected := make(map[string]bool)
dependencies := make(map[string]bool)
retained := make(map[string]bool)
for index, body := range r.OFD.DocBody {
if body.Signatures == "" {
continue
}
name := signatureRefPath("OFD.xml", body.Signatures)
current := index == r.documentIndex && r.versionInfo == nil
if !current {
protected[name] = true
}
data, err := r.readFileViewContext(ctx, name)
if err != nil {
return nil, nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, nil, err
}
for _, node := range root.children {
if !node.matchesOFD("Signature") || current && name == selected && removed[node.attr("ID")] {
continue
}
owned, refs, err := r.signatureRemovalFiles(ctx, signatureRefPath(name, node.attr("BaseLoc")))
if err != nil {
return nil, nil, err
}
maps.Copy(protected, owned)
maps.Copy(retained, owned)
maps.Copy(protected, refs)
maps.Copy(dependencies, refs)
}
}
for name := range retained {
delete(dependencies, name)
}
return protected, dependencies, ctx.Err()
}
// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件 // SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert // SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容 // Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容
+9
View File
@@ -16,6 +16,7 @@ package ofdgo
import ( import (
"context" "context"
"encoding/json"
"encoding/xml" "encoding/xml"
"math" "math"
"strconv" "strconv"
@@ -430,6 +431,14 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement
return err return err
} }
*obj = TextObject(value) *obj = TextObject(value)
for _, attr := range start.Attr {
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
var meta textMetadata
if json.Unmarshal([]byte(attr.Value), &meta) == nil && validateRichLayout(meta.Layout) == nil && structureXMLText(meta.Text) && meta.Digest == obj.textDigest() {
obj.layout = &textLayout{value: meta.Text, options: meta.Layout, autoWidth: meta.AutoWidth}
}
}
}
for _, attr := range ofdXMLStart(start).Attr { for _, attr := range ofdXMLStart(start).Attr {
if attr.Name.Local == "LineWidth" { if attr.Name.Local == "LineWidth" {
obj.LineWidthSet = obj.LineWidth == 0 obj.LineWidthSet = obj.LineWidth == 0
+15 -7
View File
@@ -33,7 +33,7 @@ type configuredTextShaper struct {
} }
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形 // LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位 // 不修改文档,通过AddObject或UpdateObject提交;选项随标准文字定位保存,重新打开时恢复
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量 // 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
// 返回: error 错误信息 // 返回: error 错误信息
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error { func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error {
@@ -63,7 +63,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) { if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) {
return fmt.Errorf("paragraph indents must be finite") return fmt.Errorf("paragraph indents must be finite")
} }
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) { if !slices.Contains([]string{"", "left", "center", "right", "justify", "distributed"}, options.Align) {
return fmt.Errorf("invalid text alignment %q", options.Align) return fmt.Errorf("invalid text alignment %q", options.Align)
} }
var width float64 var width float64
@@ -129,7 +129,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if i == 0 { if i == 0 {
indent = options.FirstLineIndent indent = options.FirstLineIndent
} }
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing) x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing, nil)
x += options.LeftIndent + indent x += options.LeftIndent + indent
y := float64(ascender)*unit + float64(len(codes))*lineHeight y := float64(ascender)*unit + float64(len(codes))*lineHeight
if !finite(x) || !finite(y) { if !finite(x) || !finite(y) {
@@ -143,10 +143,11 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
} }
obj.TextCode = codes obj.TextCode = codes
obj.CGTransform = nil obj.CGTransform = nil
obj.layout = nil autoWidth := options == (TextLayout{})
if options != (TextLayout{}) { if obj.layout != nil && obj.layout.options == options {
obj.layout = &textLayout{value: value, options: options} autoWidth = obj.layout.autoWidth
} }
obj.layout = &textLayout{value: value, options: options, autoWidth: autoWidth}
return nil return nil
} }
@@ -220,7 +221,14 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
} }
} }
chars := runes[line[0]:line[1]] chars := runes[line[0]:line[1]]
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing) var clusters map[int]bool
if options.Align == "distributed" {
clusters = make(map[int]bool)
for _, glyph := range glyphs {
clusters[glyph.Cluster] = true
}
}
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing, clusters)
if options.Shaping.Direction == "rtl" { if options.Shaping.Direction == "rtl" {
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- { for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
x -= lineAdvances[j] x -= lineAdvances[j]
+1 -1
View File
@@ -113,7 +113,7 @@ func (r *Reader) DocumentVersion(id string) (*Reader, error) {
// 入参: ctx 取消上下文, id 版本标识,空值选择主入口 // 入参: ctx 取消上下文, id 版本标识,空值选择主入口
// 返回: *Reader 版本阅读器, error 读取或取消错误 // 返回: *Reader 版本阅读器, error 读取或取消错误
func (r *Reader) DocumentVersionContext(ctx context.Context, id string) (*Reader, error) { func (r *Reader) DocumentVersionContext(ctx context.Context, id string) (*Reader, error) {
next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id} next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id, pageReady: r.pageReady}
if err := next.initRootContext(ctx); err != nil { if err := next.initRootContext(ctx); err != nil {
return nil, err return nil, err
} }
+30 -1
View File
@@ -680,6 +680,7 @@ func (e *Editor) writePartsWithPages(ctx context.Context, write func(string, []b
{"DocID", e.Info.DocID}, {"Title", e.Info.Title}, {"Author", e.Info.Author}, {"DocID", e.Info.DocID}, {"Title", e.Info.Title}, {"Author", e.Info.Author},
{"Subject", e.Info.Subject}, {"Abstract", e.Info.Abstract}, {"Subject", e.Info.Subject}, {"Abstract", e.Info.Abstract},
{"CreationDate", e.Info.CreationDate}, {"ModDate", e.Info.ModDate}, {"CreationDate", e.Info.CreationDate}, {"ModDate", e.Info.ModDate},
{"DocUsage", e.Info.DocUsage}, {"Cover", e.Info.Cover},
} { } {
if field[1] != "" { if field[1] != "" {
x.text(field[0], field[1]) x.text(field[0], field[1])
@@ -692,7 +693,11 @@ func (e *Editor) writePartsWithPages(ctx context.Context, write func(string, []b
} }
x.end("Keywords") x.end("Keywords")
} }
x.text("Creator", ofdCreator) for _, field := range [][2]string{{"Creator", e.Info.Creator}, {"CreatorVersion", e.Info.CreatorVersion}} {
if field[1] != "" {
x.text(field[0], field[1])
}
}
if e.Info.CustomDatas != nil && len(e.Info.CustomDatas.CustomData) != 0 { if e.Info.CustomDatas != nil && len(e.Info.CustomDatas.CustomData) != 0 {
x.start("CustomDatas", nil) x.start("CustomDatas", nil)
for _, item := range e.Info.CustomDatas.CustomData { for _, item := range e.Info.CustomDatas.CustomData {
@@ -786,6 +791,14 @@ func (e *Editor) writePartsWithPages(ctx context.Context, write func(string, []b
return nil return nil
} }
// editorResourceSelection 保存单次输出中同一编辑快照的资源筛选结果
type editorResourceSelection struct {
owner *Editor
fonts, images []editorResource
spaces []ColorSpace
files map[string]bool
}
// usedResources 沿图层、对象及资源依赖筛选新增资源和共享文件,不修改资源池 // usedResources 沿图层、对象及资源依赖筛选新增资源和共享文件,不修改资源池
// 入参: ctx 取消上下文 // 入参: ctx 取消上下文
// 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, map[string]bool 独立资源文件集合, error 取消错误 // 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, map[string]bool 独立资源文件集合, error 取消错误
@@ -793,6 +806,10 @@ func (e *Editor) usedResources(ctx context.Context) (fonts, images []editorResou
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
if e.output != nil && e.output.resources != nil && e.output.resources.owner == e {
cached := e.output.resources
return slices.Clone(cached.fonts), slices.Clone(cached.images), slices.Clone(cached.spaces), maps.Clone(cached.files), nil
}
used := make(map[string]bool) used := make(map[string]bool)
promoted := make(map[string]bool) promoted := make(map[string]bool)
files := make(map[string]bool) files := make(map[string]bool)
@@ -940,6 +957,9 @@ func (e *Editor) usedResources(ctx context.Context) (fonts, images []editorResou
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
if e.output != nil {
e.output.resources = &editorResourceSelection{owner: e, fonts: slices.Clone(fonts), images: slices.Clone(images), spaces: slices.Clone(spaces), files: maps.Clone(files)}
}
return fonts, images, spaces, files, nil return fonts, images, spaces, files, nil
} }
@@ -1222,6 +1242,15 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
switch object.Type { switch object.Type {
case "TextObject", "Text": case "TextObject", "Text":
obj := object.TextObject obj := object.TextObject
metadata, err := obj.textMetadata()
if err != nil {
x.err = err
return
}
if metadata != "" {
attrs.add("xmlns:ofdgo", textNamespace)
attrs.add("ofdgo:Text", metadata)
}
attrs.add("ID", obj.ID) attrs.add("ID", obj.ID)
attrs.add("DrawParam", obj.DrawParam) attrs.add("DrawParam", obj.DrawParam)
attrs.add("Boundary", obj.Boundary) attrs.add("Boundary", obj.Boundary)
+4 -1
View File
@@ -93,7 +93,10 @@ func decodeOFDChildren(d *xml.Decoder, start xml.StartElement, decode func(*xml.
} }
continue continue
} }
if err := decode(d, ofdXMLStart(node)); err != nil { if node.Name.Local != "TextObject" && node.Name.Local != "Text" {
node = ofdXMLStart(node)
}
if err := decode(d, node); err != nil {
return err return err
} }
case xml.EndElement: case xml.EndElement: