Compare commits

...
324 Commits
Author SHA1 Message Date
xiaoqidun ff61c5ddeb feat(更新依赖): 更新guigo、gmsm
build.yaml / build (push) Successful in 45s
2026-10-30 23:41:45 +08:00
xiaoqidun 47f1a3b77c feat(界面设计): 优化标题栏按钮反馈
build.yaml / build (push) Successful in 50s
2026-10-30 23:22:02 +08:00
xiaoqidun 951a947bbe feat(界面设计): 优化标题栏按钮与图标 2026-10-30 22:47:16 +08:00
xiaoqidun 5e98ec2789 feat(界面设计): 统一字体行控件高度 2026-10-30 22:39:59 +08:00
xiaoqidun e7a44bfa1a feat(界面设计): 统一删除按钮样式
build.yaml / build (push) Successful in 51s
2026-10-30 22:34:38 +08:00
xiaoqidun 8936d98590 feat(界面设计): 统一标签栏按钮样式
build.yaml / build (push) Successful in 45s
2026-10-30 22:24:52 +08:00
xiaoqidun d4274f48c2 feat(界面设计): 启用云母标题栏
build.yaml / build (push) Successful in 47s
2026-10-30 22:06:59 +08:00
xiaoqidun 26bad97aab feat(界面设计): 精简设置布局,修正标题栏遮罩
build.yaml / build (push) Successful in 43s
2026-10-30 21:23:48 +08:00
xiaoqidun f7233616f7 feat(界面设计): 系统设置改为分类对话框
build.yaml / build (push) Successful in 45s
2026-10-30 21:17:28 +08:00
xiaoqidun 8a4967c064 feat(界面设计): 优化侧边栏计数框主题配色
build.yaml / build (push) Successful in 45s
2026-10-30 21:02:21 +08:00
xiaoqidun cffc78626b feat(界面设计): 侧边栏采用轻量数字框
build.yaml / build (push) Successful in 44s
2026-10-30 20:48:12 +08:00
xiaoqidun 2350504cfe feat(界面设计): 统一全部下拉框样式与交互
build.yaml / build (push) Successful in 46s
2026-10-30 20:35:34 +08:00
xiaoqidun 86e4d7d6ae feat(界面设计): 简化选中样式,修正下拉箭头
build.yaml / build (push) Successful in 44s
2026-10-30 16:20:15 +08:00
xiaoqidun f05275c6b8 feat(界面设计): 统一下拉框状态与配色
build.yaml / build (push) Successful in 45s
2026-10-30 16:06:22 +08:00
xiaoqidun ad23f0f8b0 feat(界面设计): 统一颜色按钮尺寸及上下对齐
build.yaml / build (push) Successful in 47s
2026-10-30 15:31:36 +08:00
xiaoqidun f4a7516985 feat(界面设计): 优化字体与字号布局
build.yaml / build (push) Successful in 45s
2026-10-30 15:22:37 +08:00
xiaoqidun c8aaaca773 feat(界面设计): 统一控件圆角与主题层次 2026-10-30 15:08:48 +08:00
xiaoqidun 035da09334 feat(界面设计): 统一弹窗、按钮及分组样式
build.yaml / build (push) Successful in 55s
2026-10-30 14:41:14 +08:00
xiaoqidun 1abeba6f99 feat(文件关联): 文件关联移至设置面板
build.yaml / build (push) Successful in 45s
2026-10-30 14:17:25 +08:00
xiaoqidun 12b04c184c feat(窗口图标): 优化设置图标,统一视觉尺寸
build.yaml / build (push) Successful in 47s
2026-10-30 14:08:26 +08:00
xiaoqidun 47c593286d fix(编辑能力): 修正底色选中效果与字号显示
build.yaml / build (push) Successful in 48s
2026-10-30 13:48:21 +08:00
xiaoqidun e519bb0527 fix(窗口标签): 修正窗口分隔线与深色配色
build.yaml / build (push) Successful in 43s
2026-10-30 13:33:11 +08:00
xiaoqidun b9f3274967 feat(窗口标签): 完善标签栏分隔线
build.yaml / build (push) Successful in 45s
2026-10-30 13:16:03 +08:00
xiaoqidun 37b3e59282 feat(标签图标): 统一标签栏图标尺寸与对齐
build.yaml / build (push) Successful in 43s
2026-10-30 12:57:29 +08:00
xiaoqidun f284a40897 feat(界面布局): 优化工具栏布局与按钮样式
build.yaml / build (push) Successful in 45s
2026-10-30 12:32:27 +08:00
xiaoqidun 8d6db4f91d feat(窗口标签): 统一标题栏按钮间距
build.yaml / build (push) Successful in 44s
2026-10-30 12:21:24 +08:00
xiaoqidun 3404677b15 feat(工具布局): 优化工具栏布局,适配默认窗口
build.yaml / build (push) Successful in 46s
2026-10-30 12:14:13 +08:00
xiaoqidun 8d435fe109 refactor(界面重构): 支持三种主题
build.yaml / build (push) Successful in 46s
2026-10-30 12:00:42 +08:00
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
xiaoqidun 571f8426fc feat(原生桌面): 优化更新错误文案
build.yaml / build (push) Successful in 47s
2026-10-23 22:26:30 +08:00
xiaoqidun 7682a7dd5d feat(更新提醒): 原生更新入口接入
build.yaml / build (push) Successful in 47s
2026-10-23 22:21:03 +08:00
xiaoqidun 68c58ef8c9 feat(更新入口): 更新入口图标化
build.yaml / build (push) Successful in 47s
2026-10-23 21:59:40 +08:00
xiaoqidun 62206c7a4b feat(更新入口): 更新入口移至标签栏
build.yaml / build (push) Successful in 48s
2026-10-23 21:50:15 +08:00
xiaoqidun 850057f46f feat(下载入口): 新增桌面版下载入口
build.yaml / build (push) Successful in 46s
2026-10-23 21:36:37 +08:00
xiaoqidun 3a39fb293f feat(前端界面): 统一底栏入口文案
build.yaml / build (push) Successful in 46s
2026-10-23 20:29:44 +08:00
xiaoqidun bafc3e399f feat(前端界面): 统一底栏文字边距
build.yaml / build (push) Successful in 45s
2026-10-23 20:12:01 +08:00
xiaoqidun f6338784e1 feat(忽略文件): 排除releases目录
build.yaml / build (push) Successful in 45s
2026-10-23 20:00:43 +08:00
xiaoqidun 2016b85256 feat(前端界面): 统一页脚焦点样式
build.yaml / build (push) Successful in 47s
2026-10-23 19:28:28 +08:00
xiaoqidun 629ff60efd feat(前端界面): 统一页脚官网与关于
build.yaml / build (push) Successful in 43s
2026-10-23 19:16:24 +08:00
xiaoqidun 7ddb67ea41 feat(原生桌面): 添加build.bat,双击即可完成桌面版构建
build.yaml / build (push) Successful in 46s
2026-10-23 19:08:13 +08:00
xiaoqidun d717b704d8 feat(关于弹出): 优化弹窗左右间距
build.yaml / build (push) Successful in 48s
2026-10-23 14:17:02 +08:00
xiaoqidun 76125e19d8 feat(优化逻辑): 资源校验仅随内容变化
build.yaml / build (push) Successful in 44s
2026-10-23 14:09:39 +08:00
xiaoqidun ac2eb2b5c7 feat(构建版本): 给WebUI和原生桌面添加版本
build.yaml / build (push) Successful in 1m38s
2026-10-23 13:20:12 +08:00
xiaoqidun 85baeda7f9 fix(注解切换): 修复注解切换布局
build.yaml / build (push) Successful in 48s
2026-10-22 23:55:32 +08:00
xiaoqidun 49e5445ccd fix(新建文档): 修复新建标签闪现
build.yaml / build (push) Successful in 44s
2026-10-22 23:23:24 +08:00
xiaoqidun 5b6dbd4afc fix(窗口标签): 修正标签按钮留白
build.yaml / build (push) Successful in 43s
2026-10-22 23:00:08 +08:00
xiaoqidun de03bdfc6a feat(窗口标签): 统一标签按钮边距
build.yaml / build (push) Successful in 44s
2026-10-22 22:54:47 +08:00
xiaoqidun f15b40545a feat(关闭文档): 区分关闭文档与标签
build.yaml / build (push) Successful in 48s
2026-10-22 22:35:11 +08:00
xiaoqidun a73d08d328 feat(窗口标签): 优化多标签关闭与异常提示
build.yaml / build (push) Successful in 45s
2026-10-22 22:12:22 +08:00
xiaoqidun dacdf0b3c9 feat(窗口标签): 统一标签加号与关闭间距
build.yaml / build (push) Successful in 46s
2026-10-22 21:53:21 +08:00
xiaoqidun c2d525e77f feat(关闭标签): 末标签关闭行为统一
build.yaml / build (push) Successful in 48s
2026-10-22 21:37:26 +08:00
xiaoqidun bcac3673f6 feat(新建标签): 统一标签按钮区域与间距
build.yaml / build (push) Successful in 44s
2026-10-22 20:31:26 +08:00
xiaoqidun 0aa7575980 feat(新建标签): 微调新建标签文案
build.yaml / build (push) Successful in 46s
2026-10-22 20:21:16 +08:00
xiaoqidun 361a2b3fd3 fix(前端界面): 修复标签切换状态残留
build.yaml / build (push) Successful in 45s
2026-10-22 20:16:32 +08:00
xiaoqidun fb23d92d08 feat(前端界面): 统一弹窗标题与用途切换
build.yaml / build (push) Successful in 47s
2026-10-22 20:06:26 +08:00
xiaoqidun 89e7f09de8 feat(标签布局): 统一标签栏左侧对齐
build.yaml / build (push) Successful in 46s
2026-10-22 19:35:22 +08:00
xiaoqidun 9cb3a898c4 feat(标签布局): 统一新建标签高亮比例
build.yaml / build (push) Successful in 45s
2026-10-22 19:25:47 +08:00
xiaoqidun b680f0bc27 perf(标签性能): 优化多标签并发体验
build.yaml / build (push) Successful in 47s
2026-10-22 18:57:06 +08:00
xiaoqidun 3bed2bfe77 feat(窗口布局): 统一文件标签高度
build.yaml / build (push) Successful in 49s
2026-10-22 14:25:57 +08:00
xiaoqidun 3d3220a0ad fix(窗口处理): 修复窗口还原裁剪
build.yaml / build (push) Successful in 52s
2026-10-22 13:45:48 +08:00
xiaoqidun 50da1d9144 feat(功能特性): 新增多文件标签编辑
build.yaml / build (push) Successful in 44s
2026-10-22 13:15:00 +08:00
xiaoqidun 5183ba2ab0 feat(生命周期): 完善文档状态生命周期
build.yaml / build (push) Successful in 46s
2026-10-21 22:55:34 +08:00
xiaoqidun 994988d425 feat(生命周期): 统一文档状态生命周期
build.yaml / build (push) Successful in 52s
2026-10-21 22:14:23 +08:00
xiaoqidun 72a85e26cc feat(前端界面): 统一分类标签切换体验
build.yaml / build (push) Successful in 45s
2026-10-21 21:38:01 +08:00
xiaoqidun 2063745224 style(代码风格): 整理PDF标引常量顺序
build.yaml / build (push) Successful in 55s
2026-10-21 19:49:43 +08:00
xiaoqidun 9b362d2f02 fix(前端界面): 修复关闭文件后缩略图滚动残留
build.yaml / build (push) Successful in 49s
2026-10-21 19:16:29 +08:00
xiaoqidun 67f4cf9d2c fix(回归优化): 修复浏览器回归并优化内存
build.yaml / build (push) Successful in 49s
2026-10-21 18:28:57 +08:00
xiaoqidun 974b515374 fix(写入能力): 修复保存快照附件清理
build.yaml / build (push) Successful in 49s
2026-10-21 16:18:37 +08:00
xiaoqidun c250ca914e fix(转换能力): 修复PDF字体及细线渲染
build.yaml / build (push) Successful in 2m4s
2026-10-21 15:18:52 +08:00
xiaoqidun 201082daa0 feat(转换能力): 增强PDF转换与保存稳定性
build.yaml / build (push) Successful in 3m52s
2026-10-21 13:03:03 +08:00
xiaoqidun b4d566019d fix(转换能力): 修复PDF转换回归
build.yaml / build (push) Successful in 4m39s
2026-10-21 10:35:24 +08:00
xiaoqidun 580592bbf8 fix(转换能力): 修复PDF日期解析兼容问题
build.yaml / build (push) Successful in 46s
2026-10-20 23:19:34 +08:00
xiaoqidun 764e8a750a fix(目录跳转): 修复目录跳转及缩略图联动
build.yaml / build (push) Successful in 44s
2026-10-20 21:26:02 +08:00
xiaoqidun 3f774056da fix(双页跳转): 修复双页目录跳转错页
build.yaml / build (push) Successful in 47s
2026-10-20 21:12:55 +08:00
xiaoqidun 3227134bfc feat(转换能力): 有限兼容日期,保留严格校验
build.yaml / build (push) Successful in 44s
2026-10-20 18:45:00 +08:00
xiaoqidun 6ca44cd9a5 feat(批量转换): 统一自动并行调度
build.yaml / build (push) Successful in 1m5s
2026-10-20 16:34:08 +08:00
xiaoqidun 753149260f docs(构建说明): 更新原生桌面构建说明
build.yaml / build (push) Successful in 52s
2026-10-20 16:04:12 +08:00
xiaoqidun 7c870d09e7 feat(更新依赖): 更新GUIGo linux arm64适配 2026-10-20 16:01:17 +08:00
xiaoqidun 5f1a4e7dcc feat(新增平台): 新增linux arm64桌面支持 2026-10-20 15:59:47 +08:00
xiaoqidun 92a0957de2 fix(前端界面): 修复下拉箭头遮挡文字
build.yaml / build (push) Successful in 44s
2026-10-20 12:02:52 +08:00
xiaoqidun 8cf8303088 docs(原生桌面): 添加GUIGo说明 2026-10-20 11:54:43 +08:00
xiaoqidun d9fb144e35 docs(构建指南): 新增原生桌面linux amd64构建说明 2026-10-20 10:57:49 +08:00
xiaoqidun 6b0332224f feat(新增平台): 新增linux amd64桌面支持
build.yaml / build (push) Successful in 45s
2026-10-20 10:39:11 +08:00
xiaoqidun eac5a659e6 feat(前端界面): 修复混合尺寸双页横向溢出
build.yaml / build (push) Successful in 44s
2026-10-20 00:04:40 +08:00
xiaoqidun 1abfdaf431 feat(前端界面): 精简顶部工具栏分组
build.yaml / build (push) Successful in 46s
2026-10-19 23:27:24 +08:00
xiaoqidun 90514f2e18 feat(前端界面): 优化顶部工具栏宽度适配
build.yaml / build (push) Successful in 44s
2026-10-19 23:23:40 +08:00
xiaoqidun 075797402d perf(优化提升): 提升双端性能并优化内存占用
build.yaml / build (push) Successful in 46s
2026-10-19 23:17:39 +08:00
xiaoqidun eb20f66ffb perf(性能优化): 统一调度优化并行与内存
build.yaml / build (push) Successful in 47s
2026-10-19 21:39:40 +08:00
xiaoqidun 4f530eb5b8 perf(性能优化): 优化保存快照与页面缓存
build.yaml / build (push) Successful in 46s
2026-10-19 20:57:44 +08:00
xiaoqidun 66aa87304b perf(双端优化): 优化双端加载与资源复用
build.yaml / build (push) Successful in 47s
2026-10-19 20:34:17 +08:00
xiaoqidun 010ab9f314 feat(原生桌面): 优化并发缓存与取消释放
build.yaml / build (push) Successful in 47s
2026-10-19 19:07:34 +08:00
xiaoqidun 83d936e9f8 feat(原生桌面): 优化原生页面与资源加载
build.yaml / build (push) Successful in 47s
2026-10-19 18:57:56 +08:00
xiaoqidun ea55e997ee feat(阅读编辑): 统一单双页缩略图间距
build.yaml / build (push) Successful in 45s
2026-10-19 18:12:12 +08:00
xiaoqidun 5fd3854af8 feat(阅读编辑): 修复双页编辑横向溢出
build.yaml / build (push) Successful in 45s
2026-10-19 17:05:07 +08:00
xiaoqidun a882e3e137 feat(阅读编辑): 完善单双页布局与工具栏交互
build.yaml / build (push) Successful in 44s
2026-10-19 16:17:59 +08:00
xiaoqidun a29b37ddef feat(阅读编辑): 修正双页布局重复留白
build.yaml / build (push) Successful in 50s
2026-10-19 16:06:02 +08:00
xiaoqidun 010baf3370 feat(阅读编辑): 连续布局去页缝并添加细分隔线
build.yaml / build (push) Successful in 47s
2026-10-19 15:54:59 +08:00
xiaoqidun 13b074c922 feat(阅读编辑): 移除四页布局,保留单双页
build.yaml / build (push) Successful in 46s
2026-10-19 15:41:54 +08:00
xiaoqidun 20d8dcebde feat(阅读编辑): 新增双页四页阅读编辑
build.yaml / build (push) Successful in 46s
2026-10-19 15:20:41 +08:00
xiaoqidun f0094e2af6 docs(更新文档): 更新主要能力部分
build.yaml / build (push) Successful in 46s
2026-10-19 11:38:37 +08:00
xiaoqidun e8ac15b0f9 feat(升级依赖): 升级GUIGo
build.yaml / build (push) Successful in 45s
2026-10-19 11:32:16 +08:00
xiaoqidun 83159a475c style(代码风格): 统一常量变量类型声明顺序
build.yaml / build (push) Successful in 50s
2026-10-19 11:26:37 +08:00
xiaoqidun f3cc35e78c feat(归并文件): 归并标引与栅格文件
build.yaml / build (push) Successful in 44s
2026-10-19 10:45:46 +08:00
xiaoqidun bfecdeb0d6 feat(归并文件): 归并根包文件 2026-10-19 10:25:11 +08:00
xiaoqidun 1a8de5a3f1 feat(归并文件): 归并根包文件 2026-10-19 10:17:59 +08:00
xiaoqidun ec24b6a17d feat(文件归并): 归并PDF封装并下沉计算逻辑
build.yaml / build (push) Successful in 45s
2026-10-19 10:10:27 +08:00
xiaoqidun 84808cda61 feat(归并文件): 归并根包文件,保持接口不变
build.yaml / build (push) Successful in 43s
2026-10-19 09:43:06 +08:00
xiaoqidun aab5d40f93 feat(清理代码): 精简快照与页面导出接口
build.yaml / build (push) Successful in 45s
2026-10-19 09:36:17 +08:00
xiaoqidun e222cddabb feat(输入输出): 统一输入输出与保存目标
build.yaml / build (push) Successful in 46s
2026-10-19 09:29:36 +08:00
xiaoqidun b06df462ea fix(修复问题): 修复保存边界并统一工具图标
build.yaml / build (push) Successful in 43s
2026-10-19 09:07:51 +08:00
xiaoqidun 73889edefb feat(数据接口): 统一数据源接口及全库命名
build.yaml / build (push) Successful in 46s
2026-10-19 08:59:17 +08:00
xiaoqidun d97c6f4e6f feat(统一输入): 统一流式输入及接口命名
build.yaml / build (push) Successful in 43s
2026-10-19 08:43:50 +08:00
xiaoqidun 151ddb0a20 feat(编辑能力): 完善保存另存与后台编辑
build.yaml / build (push) Successful in 46s
2026-10-18 23:46:02 +08:00
xiaoqidun 6a4b322e2c feat(资源管理): 资源列表改用虚拟滚动
build.yaml / build (push) Successful in 44s
2026-10-18 22:36:47 +08:00
xiaoqidun b7d852d879 feat(资源管理): 资源列表改用虚拟滚动 2026-10-18 22:36:29 +08:00
xiaoqidun dd0b14be77 fix(问题修复): 修复报告转换与结构保存
build.yaml / build (push) Successful in 2m10s
2026-10-18 19:04:00 +08:00
xiaoqidun f952c5d91d feat(拆分文件): 下沉PDF计算并精简根包
build.yaml / build (push) Successful in 50s
2026-10-18 18:35:13 +08:00
xiaoqidun a2de650b52 feat(转换渲染): 保留PDF结构并优化渲染
build.yaml / build (push) Successful in 2m37s
2026-10-18 18:08:52 +08:00
xiaoqidun 627a06cda3 refactor(性能提升): 优化转换调度与联合编辑性能
build.yaml / build (push) Successful in 45s
2026-10-18 11:57:53 +08:00
xiaoqidun 460701f4d9 feat(原生桌面): 统一原生字体中文名称显示
build.yaml / build (push) Successful in 44s
2026-10-18 03:36:27 +08:00
xiaoqidun a4f979ed2f feat(原生桌面): 完善字体别名与集合匹配 2026-10-18 03:26:20 +08:00
xiaoqidun 92785cea4f feat(原生桌面): 修复原生文字创建与字体识别
build.yaml / build (push) Successful in 43s
2026-10-18 03:20:03 +08:00
xiaoqidun ffb92b9f2d feat(前端界面): 统一图标与批量转换布局
build.yaml / build (push) Successful in 43s
2026-10-18 02:57:35 +08:00
xiaoqidun 975a831954 feat(前端界面): 统一批量转换行高与间距
build.yaml / build (push) Successful in 46s
2026-10-18 02:52:46 +08:00
xiaoqidun fe77e8fa37 feat(批量转换): 统一批量转换进度换行布局
build.yaml / build (push) Successful in 45s
2026-10-18 02:44:52 +08:00
xiaoqidun a665d5c11b feat(前端界面): 统一DPI选项与显示样式
build.yaml / build (push) Successful in 42s
2026-10-18 02:40:02 +08:00
xiaoqidun 7126568b47 feat(工具图标): 统一工具栏图标轮廓与对齐
build.yaml / build (push) Successful in 43s
2026-10-18 02:33:18 +08:00
xiaoqidun 4c65552909 feat(关闭图标): 优化关闭图标
build.yaml / build (push) Successful in 44s
2026-10-18 02:24:30 +08:00
xiaoqidun a8d7699337 feat(前端界面): 优化确认框布局与按钮对齐
build.yaml / build (push) Successful in 44s
2026-10-18 02:17:50 +08:00
xiaoqidun c2751ae186 feat(关闭文档): 新增文档关闭并完善未保存提示
build.yaml / build (push) Successful in 44s
2026-10-18 02:12:23 +08:00
xiaoqidun 0d2e1ccdf2 feat(资源释放): 修复并行取消与资源释放
build.yaml / build (push) Successful in 43s
2026-10-18 01:56:18 +08:00
xiaoqidun a0899fa150 feat(转换能力): 支持原生与网页批量并行
build.yaml / build (push) Successful in 44s
2026-10-18 01:48:36 +08:00
xiaoqidun 0eae7c72d0 feat(升级依赖): 升级GUIGo
build.yaml / build (push) Successful in 42s
2026-10-18 00:52:57 +08:00
xiaoqidun abfe4c2287 feat(窗口消失): 修复窗口切换闪退隐患
build.yaml / build (push) Successful in 45s
2026-10-18 00:32:50 +08:00
xiaoqidun 16b09e52e4 feat(无损保存): 优化原生无损保存
build.yaml / build (push) Successful in 2m24s
2026-10-18 00:16:22 +08:00
xiaoqidun 331e26b71e feat(更新检查): 完善事件更新检测
build.yaml / build (push) Successful in 43s
2026-10-17 22:40:41 +08:00
xiaoqidun 7a39cb3d29 feat(更新提醒): 修复更新提醒漏检
build.yaml / build (push) Successful in 42s
2026-10-17 22:34:05 +08:00
xiaoqidun 829d59e652 feat(升级依赖): 升级GUIGo
build.yaml / build (push) Successful in 48s
2026-10-17 22:24:44 +08:00
xiaoqidun 0dafb2eb48 feat(原生桌面): 精简原生交互与资源释放
build.yaml / build (push) Successful in 45s
2026-10-17 22:10:20 +08:00
xiaoqidun 44c6087dc5 feat(更新依赖): 更新GUIGo
build.yaml / build (push) Successful in 43s
2026-10-17 21:00:56 +08:00
xiaoqidun c386acedab feat(忽略文件): 调整wasm位置
build.yaml / build (push) Successful in 45s
2026-10-17 20:36:59 +08:00
xiaoqidun e261287baa perf(优化性能): 优化原生资源传输与释放
build.yaml / build (push) Successful in 45s
2026-10-17 20:31:26 +08:00
xiaoqidun 253debb3f9 fix(窗口标题): 修正标题拼接及原生名称
build.yaml / build (push) Successful in 48s
2026-10-17 20:04:25 +08:00
xiaoqidun d44d096972 feat(窗口文案): 统一窗口标题及空状态文案
build.yaml / build (push) Successful in 45s
2026-10-17 19:56:49 +08:00
xiaoqidun e8c3f355d7 feat(临时文件): 统一输出与输入暂存命名
build.yaml / build (push) Successful in 45s
2026-10-17 19:49:21 +08:00
xiaoqidun dc59aefeb7 chore(构建变动): 隔离WASM资源并完善构建
build.yaml / build (push) Successful in 45s
2026-10-17 19:26:09 +08:00
xiaoqidun eedfa72721 feat(更新应用): 精简应用区并统一更新分组
build.yaml / build (push) Successful in 45s
2026-10-17 19:02:54 +08:00
xiaoqidun ec2602746c feat(更新应用): 统一更新按钮主题样式
build.yaml / build (push) Successful in 44s
2026-10-17 18:54:25 +08:00
xiaoqidun eaefd730ae feat(更新应用): 优化更新应用提示
build.yaml / build (push) Successful in 42s
2026-10-17 18:47:24 +08:00
xiaoqidun 2e5241733a feat(原生桌面): 精简原生适配冗余封装
build.yaml / build (push) Successful in 43s
2026-10-17 18:37:46 +08:00
xiaoqidun bd8add89f3 feat(原生桌面): 优化启动图标加载
build.yaml / build (push) Successful in 45s
2026-10-17 18:35:57 +08:00
xiaoqidun 84ca546d54 feat(原生桌面): 优化启动白屏与显示时序
build.yaml / build (push) Successful in 43s
2026-10-17 18:25:55 +08:00
xiaoqidun c5224c54fb docs(构建指南): 新增原生桌面构建
build.yaml / build (push) Successful in 46s
2026-10-17 18:20:40 +08:00
xiaoqidun 0466276704 feat(窗口大小): 默认最大化打开窗口
build.yaml / build (push) Successful in 46s
2026-10-17 18:14:34 +08:00
xiaoqidun c1f85c04df feat(原生桌面): 简化原生数据目录 2026-10-17 18:10:00 +08:00
xiaoqidun 640d2d68f2 feat(原生桌面): 启用原生窗口居中
build.yaml / build (push) Successful in 45s
2026-10-17 18:07:18 +08:00
xiaoqidun 1021726b4d feat(软件图标): 添加windows amd64图标
build.yaml / build (push) Successful in 43s
2026-10-17 17:57:15 +08:00
xiaoqidun 2d16fc833d feat(软件图标): 新增PNG和ICO图标支持
build.yaml / build (push) Successful in 45s
2026-10-17 17:48:20 +08:00
xiaoqidun 8e31f888d3 feat(原生桌面): 新增原生桌面与字体适配
build.yaml / build (push) Successful in 44s
2026-10-17 17:28:36 +08:00
xiaoqidun 0ab785ebc3 feat(文档能力): 优化文档导入选择体验
build.yaml / build (push) Successful in 47s
2026-10-17 10:13:23 +08:00
xiaoqidun 87334eb84c feat(文档能力): 支持整文档导入合并
build.yaml / build (push) Successful in 45s
2026-10-17 10:00:49 +08:00
xiaoqidun be8465884a feat(规范代码): 统一命名空间定义,精简注释
build.yaml / build (push) Successful in 44s
2026-10-16 23:25:33 +08:00
xiaoqidun 78bb70d5a2 feat(前端界面): 统一字体选择与按钮同行布局
build.yaml / build (push) Successful in 43s
2026-10-16 23:06:57 +08:00
xiaoqidun 745f39a614 feat(电子发票): 支持发票变长编辑与票面同步
build.yaml / build (push) Successful in 44s
2026-10-16 22:58:26 +08:00
xiaoqidun 1a9059429e feat(电子发票): 完善发票创建编辑与票面同步
build.yaml / build (push) Successful in 44s
2026-10-16 22:31:22 +08:00
xiaoqidun 3709a62c24 feat(电子发票): 补齐2019版发票编辑与同步
build.yaml / build (push) Successful in 44s
2026-10-16 22:03:16 +08:00
xiaoqidun 2904a0d914 feat(前端界面): 优化发票同步与绑定引导
build.yaml / build (push) Successful in 44s
2026-10-16 21:42:40 +08:00
xiaoqidun eb8e17efa8 feat(前端界面): 保持前端界面干净清爽
build.yaml / build (push) Successful in 43s
2026-10-16 21:19:50 +08:00
xiaoqidun 896d624c3e feat(编辑能力): 支持多图元文字联合编辑
build.yaml / build (push) Successful in 46s
2026-10-16 21:02:22 +08:00
xiaoqidun 64997b6c39 feat(编辑能力): 优化编辑文字体验
build.yaml / build (push) Successful in 45s
2026-10-16 20:23:49 +08:00
xiaoqidun a0dff862f1 feat(编辑能力): 修复文字删空报错,支持撤销
build.yaml / build (push) Successful in 42s
2026-10-16 20:17:53 +08:00
xiaoqidun 0aed2a5609 feat(编辑能力): 完善画布保留排版与字体替换
build.yaml / build (push) Successful in 45s
2026-10-16 20:08:45 +08:00
xiaoqidun 8a1caf7952 feat(前端界面): 优化前端界面文案
build.yaml / build (push) Successful in 43s
2026-10-16 19:40:48 +08:00
xiaoqidun adc5c5a3c5 feat(前端界面): 优化提示信息文案
build.yaml / build (push) Successful in 44s
2026-10-16 19:36:01 +08:00
xiaoqidun e7a8bf89de feat(电子发票): 优化发票应用状态提示
build.yaml / build (push) Successful in 44s
2026-10-16 19:30:52 +08:00
xiaoqidun 06714b7150 feat(前端界面): 优化弹窗状态与操作布局
build.yaml / build (push) Successful in 44s
2026-10-16 16:31:41 +08:00
xiaoqidun 274e132b88 docs(主要能力): 更新主要能力说明
build.yaml / build (push) Successful in 49s
2026-10-16 15:31:01 +08:00
xiaoqidun c97e5c9de4 feat(前端界面): 统一表单选择文案
build.yaml / build (push) Successful in 52s
2026-10-16 15:19:40 +08:00
xiaoqidun 6639653843 feat(电子发票): 完善发票识别并统一命名空间
build.yaml / build (push) Successful in 53s
2026-10-16 15:12:42 +08:00
xiaoqidun 68d6a19656 feat(电子发票): 修复发票空值校验与标引读取
build.yaml / build (push) Successful in 52s
2026-10-16 15:00:25 +08:00
xiaoqidun b8d90174a0 feat(电子发票): 修复旧版发票读取与重复提示
build.yaml / build (push) Successful in 52s
2026-10-16 14:42:59 +08:00
xiaoqidun 7a0bbbe8df fix(渲染能力): 修复色彩校验导致的渲染回归
build.yaml / build (push) Successful in 51s
2026-10-16 14:21:50 +08:00
xiaoqidun 45b9e93041 feat(前端界面): 优化发票间距与按钮定位
build.yaml / build (push) Successful in 53s
2026-10-16 14:08:03 +08:00
xiaoqidun 9688031dad feat(前端界面): 优化文档按钮顺序
build.yaml / build (push) Successful in 54s
2026-10-16 14:02:39 +08:00
xiaoqidun c3a4b161d8 feat(前端界面): 发票入口移至右侧文档区
build.yaml / build (push) Successful in 51s
2026-10-16 13:54:37 +08:00
xiaoqidun c47793d4de feat(电子发票): 未绑定不代表数据错误
build.yaml / build (push) Successful in 54s
2026-10-16 13:24:33 +08:00
xiaoqidun 2daae08a0f feat(电子发票): 完善发票编辑与同步
build.yaml / build (push) Successful in 52s
2026-10-16 13:15:45 +08:00
xiaoqidun 30f4f76260 feat(电子发票): 补齐发票编辑与票面同步
build.yaml / build (push) Successful in 57s
2026-10-16 13:00:41 +08:00
xiaoqidun 528162a276 feat(电子发票): 补齐发票读写校验与票面同步
build.yaml / build (push) Successful in 55s
2026-10-16 12:07:42 +08:00
xiaoqidun d3a713f504 feat(电子发票): 补齐发票结构化提取
build.yaml / build (push) Successful in 50s
2026-10-16 10:26:33 +08:00
xiaoqidun 2cf96a872a feat(图像颜色): 优化混合图像颜色转换
build.yaml / build (push) Successful in 52s
2026-10-16 09:31:07 +08:00
xiaoqidun 365f3ba8f9 feat(图像颜色): 优化图像颜色转换性能
build.yaml / build (push) Successful in 51s
2026-10-16 09:25:35 +08:00
xiaoqidun 33ae0b1a98 feat(前端界面): 修复资源选择闪烁
build.yaml / build (push) Successful in 54s
2026-10-16 09:04:54 +08:00
xiaoqidun bb3abdea1d feat(字体图像): 优化字体图像与导出稳定性
build.yaml / build (push) Successful in 6m36s
2026-10-16 08:39:54 +08:00
xiaoqidun 8f9e194c0c feat(字体处理): 优化字体索引编码性能
build.yaml / build (push) Successful in 51s
2026-10-16 00:52:15 +08:00
xiaoqidun 1432562c6f feat(图像处理): 优化图像重采样性能 2026-10-16 00:48:56 +08:00
xiaoqidun 2573053a82 feat(图像处理): 修正图像缓存预算 2026-10-16 00:44:16 +08:00
xiaoqidun dc48962eb3 feat(字体处理): 提升字体封装校验性能 2026-10-16 00:40:22 +08:00
xiaoqidun 239baf9ce8 feat(图像处理): 优化图像透明度扫描 2026-10-16 00:34:31 +08:00
263 changed files with 48980 additions and 17797 deletions

No files matched your search

+3 -2
View File
@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
export TZ=Asia/Shanghai export TZ=Asia/Shanghai
GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -o assets/webui/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go export Version="$(git show -s --format='%ct-%H' HEAD)"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/webui.go GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -o assets/webui/wasm/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main -trimpath -ldflags "-s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=$Version" ./cmd/webui/webui.go
+2 -1
View File
@@ -1,8 +1,9 @@
.idea/ .idea/
.vscode/ .vscode/
.devcontainer/ .devcontainer/
releases/
go.work go.work
go.work.sum go.work.sum
assets/webui/ofdgo.wasm
*.exe *.exe
*.exe~ *.exe~
assets/webui/wasm/ofdgo.wasm
+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)"
+105 -28
View File
@@ -3,20 +3,28 @@
# 主要能力 # 主要能力
- 文档处理:创建、读写、拆分、合并、压缩 - 文档处理:创建、读写、拆分、合并、压缩
- 渲染转换:矢量、位图、导入、导出、批量 - 文档阅读:搜索、缩放、旋转、双页、连续
- 文档安全:签名、签章、验签、加密、解密 - 文档管理:新建、导入、切换、排序、删除
- 版本管理:创建、改名、切换、另存、删除
- 页面管理:添加、导入、复制、排序、删除 - 页面管理:添加、导入、复制、排序、删除
- 文字处理:插入、编辑、排版、搜索、提取 - 文档编辑:选区、重排、事务、撤销、重做
- 字体处理:内嵌、外置、裁剪、匹配、提取 - 文字处理:插入、编辑、替换、排版、提取
- 文字样式:混排、粗体、斜体、阴影、底色
- 字体处理:匹配、内嵌、外置、子集、提取
- 图片处理:插入、替换、缩放、裁剪、提取 - 图片处理:插入、替换、缩放、裁剪、提取
- 图形处理:绘制、变换、组合、对齐、分布 - 图形处理:绘制、变换、组合、对齐、分布
- 文档管理:添加、切换、改名、排序、删除 - 色彩管理:调色、渐变、图案、透明、转换
- 版本管理:创建、切换、改名、另存、删除 - 文档结构:图层、模板、目录、书签、标引
- 文档结构:目录、书签、模板、标引、扩展 - 资源管理:导入、绑定、替换、提取、清理
- 注解管理:批注、高亮、水印、印章、链接 - 注解管理:批注、高亮、水印、印章、链接
- 动作管理:跳转、链接、附件、音频、视频 - 交互动作:跳转、链接、附件、音频、视频
- 资源管理:字体、图片、色彩、媒体、附件 - 电子发票:提取、创建、编辑、校验、同步
- 文档安全:签名、签章、验签、加密、解密
- 三维处理:解析、材质、纹理、动画、渲染 - 三维处理:解析、材质、纹理、动画、渲染
- 页面渲染:矢量、位图、裁剪、蒙版、合成
- 批量转换:选页、并行、进度、取消、打包
- 打印输出:预览、双面、合印、小册、海报
- 应用交互:标签、链接、关联、拖放、复用
# 在线使用 # 在线使用
[OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务 [OFDGo WebUI](https://ofdgo.aite.me/),将OFDGo编译为WASM提供服务
@@ -50,13 +58,35 @@ docker run -d -p 80:80 ccr.ccs.tencentyun.com/xiaoqidun/ofdgo:latest
:: 1. 编译OFDGo WASM :: 1. 编译OFDGo WASM
set GOOS=js set GOOS=js
set GOARCH=wasm set GOARCH=wasm
go build -o assets/webui/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go go build -o assets/webui/wasm/ofdgo.wasm -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/wasm.go
:: 2. 编译OFDGo WebUI :: 2. 编译OFDGo WebUI
set GOOS=windows set GOOS=windows
set GOARCH=amd64 set GOARCH=amd64
go build -o ofdgo_webui.exe -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/webui.go go build -o ofdgo_webui.exe -trimpath -ldflags "-s -w -buildid=" ./cmd/webui/webui.go
``` ```
# 原生桌面
基于 [GUIGo](https://github.com/xiaoqidun/guigo) 实现,运行前需参照其说明安装系统依赖
```batch
set CGO_ENABLED=0
:: 构建Linux arm64(aarch64)
set GOOS=linux
set GOARCH=arm64
go build -o ofdgo_linux_arm64 -trimpath -ldflags "-s -w -buildid=" ./cmd/webview
:: 构建Linux amd64(x64/x86_64)
set GOOS=linux
set GOARCH=amd64
go build -o ofdgo_linux_amd64 -trimpath -ldflags "-s -w -buildid=" ./cmd/webview
:: 构建Windows amd64(x64/x86_64)
set GOOS=windows
set GOARCH=amd64
go build -o ofdgo_windows_amd64.exe -trimpath -ldflags "-H windowsgui -s -w -buildid=" ./cmd/webview
```
# 安装引擎 # 安装引擎
```shell ```shell
go get -u github.com/xiaoqidun/ofdgo go get -u github.com/xiaoqidun/ofdgo
@@ -67,6 +97,7 @@ go get -u github.com/xiaoqidun/ofdgo
package main package main
import ( import (
"context"
"log" "log"
"os" "os"
@@ -95,12 +126,11 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
// 3. 保存文档 // 3. 保存文档
ofdFile, err := os.Create("test.ofd") output, err := ofdgo.NewFileOutput("test.ofd")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer ofdFile.Close() if _, err := editor.SaveAs(context.Background(), output, ofdgo.WriteOptions{}); err != nil {
if _, err := editor.WriteTo(ofdFile); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
@@ -111,15 +141,20 @@ func main() {
package main package main
import ( import (
"context"
"log" "log"
"os"
"github.com/xiaoqidun/ofdgo" "github.com/xiaoqidun/ofdgo"
) )
func main() { func main() {
// 1. 打开文档 // 1. 打开文档
reader, err := ofdgo.Open("test.ofd") input, err := ofdgo.OpenFile(context.Background(), "test.ofd", ofdgo.InputOptions{})
if err != nil {
log.Fatal(err)
}
defer input.Close()
reader, err := ofdgo.NewReader(input)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -131,12 +166,11 @@ func main() {
} }
editor.Info.Title = "编辑后的" editor.Info.Title = "编辑后的"
// 3. 另存文档 // 3. 另存文档
ofdFile, err := os.Create("edited.ofd") output, err := ofdgo.NewFileOutput("edited.ofd")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer ofdFile.Close() if _, err := editor.SaveAs(context.Background(), output, ofdgo.WriteOptions{}); err != nil {
if _, err := editor.WriteTo(ofdFile); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
@@ -156,15 +190,11 @@ import (
func main() { func main() {
// 1. 打开文档 // 1. 打开文档
pdfFile, err := os.Open("test.pdf") input, err := ofdgo.OpenFile(context.Background(), "test.pdf", ofdgo.InputOptions{})
if err != nil {
log.Fatal(err)
}
defer pdfFile.Close()
info, err := pdfFile.Stat()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer input.Close()
// 2. 创建文件 // 2. 创建文件
ofdFile, err := os.Create("test.ofd") ofdFile, err := os.Create("test.ofd")
if err != nil { if err != nil {
@@ -172,7 +202,7 @@ func main() {
} }
defer ofdFile.Close() defer ofdFile.Close()
// 3. 转换文档 // 3. 转换文档
report, err := ofdgo.ConvertPDF(context.Background(), pdfFile, info.Size(), ofdFile, ofdgo.PDFImportOptions{}) report, err := ofdgo.ConvertPDF(context.Background(), input, ofdFile, ofdgo.PDFImportOptions{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -188,6 +218,7 @@ func main() {
package main package main
import ( import (
"context"
"log" "log"
"os" "os"
@@ -196,7 +227,12 @@ import (
func main() { func main() {
// 1. 打开OFD文件 // 1. 打开OFD文件
reader, err := ofdgo.Open("test.ofd") input, err := ofdgo.OpenFile(context.Background(), "test.ofd", ofdgo.InputOptions{})
if err != nil {
log.Fatal(err)
}
defer input.Close()
reader, err := ofdgo.NewReader(input)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -220,6 +256,7 @@ func main() {
package main package main
import ( import (
"context"
"log" "log"
"os" "os"
@@ -228,12 +265,13 @@ import (
func main() { func main() {
// 1. 打开OFD文件 // 1. 打开OFD文件
data, err := os.ReadFile("test.ofd") input, err := ofdgo.OpenFile(context.Background(), "test.ofd", ofdgo.InputOptions{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer input.Close()
// 2. 验证OFD签名 // 2. 验证OFD签名
reports, err := ofdgo.VerifySignaturesBytes(data) reports, err := ofdgo.VerifySignatures(input)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -261,5 +299,44 @@ func main() {
} }
``` ```
# 提取发票
```go
package main
import (
"context"
"encoding/json"
"log"
"os"
"github.com/xiaoqidun/ofdgo"
)
func main() {
// 1. 打开OFD文件
input, err := ofdgo.OpenFile(context.Background(), "invoice.ofd", ofdgo.InputOptions{})
if err != nil {
log.Fatal(err)
}
defer input.Close()
reader, err := ofdgo.NewReader(input)
if err != nil {
log.Fatal(err)
}
defer reader.Close()
// 2. 提取发票信息
report, err := reader.Invoices()
if err != nil {
log.Fatal(err)
}
// 3. 输出提取结果
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
log.Fatal(err)
}
}
```
# 授权协议 # 授权协议
本项目使用 [Apache License 2.0](https://github.com/xiaoqidun/ofdgo/blob/main/LICENSE) 授权协议 本项目使用 [Apache License 2.0](https://github.com/xiaoqidun/ofdgo/blob/main/LICENSE) 授权协议
+6 -17
View File
@@ -14,25 +14,14 @@
package webuiassets package webuiassets
import ( import "embed"
"crypto/sha256"
"embed"
"encoding/hex"
)
// FS WebUI嵌入静态文件 // FS WebUI嵌入静态文件
// //
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js //go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_theme.js ofdgo_util.js ofdgo_view.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
var FS embed.FS var FS embed.FS
// Checksum 计算WebUI嵌入资源校验值 // NativeFS WebView嵌入静态文件
// 返回: string SHA-256十六进制校验值 //
func Checksum() string { //go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_theme.js ofdgo_util.js ofdgo_view.js
hash := sha256.New() var NativeFS embed.FS
entries, _ := FS.ReadDir(".")
for _, entry := range entries {
data, _ := FS.ReadFile(entry.Name())
hash.Write(data)
}
return hex.EncodeToString(hash.Sum(nil))
}
+352 -247
View File
File diff suppressed because it is too large. Load diff
+1414 -403
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+4979 -2924
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -22,6 +22,12 @@
"src": "./ofdgo.svg", "src": "./ofdgo.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml" "type": "image/svg+xml"
},
{
"src": "./ofdgo.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
} }
] ]
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+690 -205
View File
File diff suppressed because it is too large. Load diff
+119 -47
View File
@@ -1,39 +1,59 @@
import { bindDropdownPointer, positionDropdown } from "./ofdgo_util.js";
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 => {
if (event.target.closest("[role=option]")) event.preventDefault();
});
list.addEventListener("click", event => {
const option = event.target.closest("[role=option]");
if (option) this.choose(Number(option.dataset.index));
}); });
bindDropdownPointer(list, index => this.choose(index));
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,36 +63,45 @@ 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(" · ") : "";
this.input.setAttribute("aria-expanded", String(this.open));
this.input.removeAttribute("aria-activedescendant");
} }
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();
for (const { font, index } of this.matches) { for (const { font, index } of this.matches) {
const option = document.createElement("div"); const option = document.createElement("div");
option.className = "font-option";
option.id = `${this.list.id}-${index}`; option.id = `${this.list.id}-${index}`;
option.dataset.index = String(index); option.dataset.index = String(index);
option.setAttribute("role", "option"); option.setAttribute("data-disabled", String(Boolean(font.disabled)));
option.setAttribute("aria-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) {
@@ -92,30 +121,24 @@ export class FontPicker {
} }
if (!this.open) this.list.showPopover(); if (!this.open) this.list.showPopover();
this.open = true; this.open = true;
this.input.setAttribute("aria-expanded", "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?.();
} }
position() { position() {
if (!this.open) return; if (!this.open) return;
const rect = this.input.parentElement.getBoundingClientRect(); if (!positionDropdown(this.list, this.input.parentElement)) this.close();
if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; }
const width = Math.min(rect.width, window.innerWidth - 16);
Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` });
} }
activate(index) { activate(index) {
this.active = index; this.active = index;
[...this.list.children].forEach((option, i) => option.setAttribute("aria-selected", String(i === index))); [...this.list.children].forEach((option, i) => option.setAttribute("data-selected", String(i === index)));
const option = this.list.children[index]; const option = this.list.children[index];
if (option) { if (option) {
this.input.setAttribute("aria-activedescendant", option.id);
option.scrollIntoView({ block: "nearest" }); option.scrollIntoView({ block: "nearest" });
} else this.input.removeAttribute("aria-activedescendant"); }
} }
choose(index) { choose(index) {
@@ -123,16 +146,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() {
@@ -141,7 +175,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") {
@@ -163,7 +197,8 @@ export class FontPicker {
} }
export class FontManager { export class FontManager {
constructor({ onChange, onPermissionChange }) { constructor({ onChange, onPermissionChange, host = null }) {
this.host = host;
this.userFonts = []; this.userFonts = [];
this.localFonts = []; this.localFonts = [];
this.catalog = []; this.catalog = [];
@@ -271,7 +306,7 @@ export class FontManager {
async queryLocal() { async queryLocal() {
try { try {
const available = await window.queryLocalFonts(); const available = this.host ? await this.host.fonts() : await window.queryLocalFonts();
this.catalog = available; this.catalog = available;
this.catalogLoaded = true; this.catalogLoaded = true;
this.permission = "granted"; this.permission = "granted";
@@ -298,6 +333,7 @@ export class FontManager {
} }
async read(font) { async read(font) {
if (font.nativeFile) return font.nativeFile;
if (font.blob) { if (font.blob) {
const blob = await font.blob(); const blob = await font.blob();
return new Uint8Array(await blob.arrayBuffer()); return new Uint8Array(await blob.arrayBuffer());
@@ -316,13 +352,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) {
@@ -402,7 +469,7 @@ export class FontManager {
for (const font of fonts) { for (const font of fonts) {
if (disabled.has(this.localName(font))) continue; if (disabled.has(this.localName(font))) continue;
const names = [this.localName(font), font.postscriptName && `${font.postscriptName}.ttf`, const names = [this.localName(font), font.postscriptName && `${font.postscriptName}.ttf`,
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`]; font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`, ...(font.names || []).map(name => `${name}.ttf`)];
for (const name of names) { for (const name of names) {
if (name && !entries.has(name)) entries.set(name, font); if (name && !entries.has(name)) entries.set(name, font);
} }
@@ -411,10 +478,15 @@ export class FontManager {
} }
canReadLocal() { canReadLocal() {
return typeof window.queryLocalFonts === "function"; return !!this.host || typeof window.queryLocalFonts === "function";
} }
async refreshPermission() { async refreshPermission() {
if (this.host) {
this.permission = "granted";
this.onPermissionChange();
return;
}
if (!this.canReadLocal() || !navigator.permissions?.query) { if (!this.canReadLocal() || !navigator.permissions?.query) {
this.onPermissionChange(); this.onPermissionChange();
return; return;
+845
View File
@@ -0,0 +1,845 @@
let engineModule = null;
let engineScheduler = null;
let engineSequence = 0;
const engineRuntimes = new Map();
function requestKey(worker, data) {
return worker.shared ? JSON.stringify([data.engineID, data.id]) : data.id;
}
export function engineLimits() {
engineScheduler ||= new BrowserScheduler();
return {parallelism: engineScheduler.capacity, memoryBytes: engineScheduler.budget};
}
class BrowserScheduler {
constructor() {
this.budget = Math.max(256, Math.min(2048, (navigator.deviceMemory || 2) * 256)) * 1024 * 1024;
this.capacity = Math.max(1, Math.min((navigator.hardwareConcurrency || 2) - 1, Math.floor(this.budget / (128 << 20))));
this.limit = Math.min(this.capacity, Math.max(1, Math.floor(this.budget / (512 << 20))));
this.workers = new Set();
this.queue = [];
this.sequence = 0;
}
get memory() {
return [...this.workers].reduce((total, worker) => total + Math.max(worker.memory, worker.reservation), 0);
}
active() {
return [...this.workers].reduce((total, worker) => total + new Set([...worker.operations.values()].map(data => data.engineID)).size, 0);
}
owner(worker, data) {
return worker.shared ? worker.sessions.get(data.engineID) : worker;
}
priority(task) {
return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload || !!task.data.background);
}
submit(worker, data, transfer) {
if (data.preload && this.owner(worker, data)?.pagesPaused) {
worker.receive({data:{engineID:data.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
return;
}
this.queue.push({worker, data, transfer, queued:performance.now(), order:++this.sequence});
this.drain();
}
send(task) {
const {worker, data, transfer} = task;
worker.requests.set(requestKey(worker, data), performance.now());
worker.operations.set(requestKey(worker, data), data);
try { worker.worker.postMessage(data, transfer); }
catch (error) {
worker.requests.delete(requestKey(worker, data));
worker.operations.delete(requestKey(worker, data));
worker.receive({data:{engineID:data.engineID, id:data.id, ok:false, error:error.message}});
}
}
complete(worker, data) {
if (data.memory) worker.memory = data.memory;
if (data.id == null || data.type) return;
const key = requestKey(worker, data);
const started = worker.requests.get(key);
const elapsed = data.elapsed ?? (started === undefined ? 0 : performance.now() - started);
const operation = worker.operations.get(key), owner = this.owner(worker, data);
if (worker.primary && started !== undefined && data.ok && owner) {
if (operation?.name === "ofdgoOpen") owner.openTime = elapsed;
if (operation?.name === "ofdgoRenderPage") owner.pageTime = owner.pageTime ? owner.pageTime * 0.75 + elapsed * 0.25 : elapsed;
}
worker.requests.delete(key);
worker.operations.delete(key);
if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1);
else if (started !== undefined && data.ok && elapsed > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, this.limit + 1);
}
drain() {
if (this.draining) return;
this.draining = true;
try {
if (this.queue.length) for (const worker of [...this.workers].sort((a,b) => b.memory-a.memory)) {
if (this.memory + (64 << 20) <= this.budget) break;
if (!worker.requests.size) worker.reclaim?.();
}
this.queue.sort((a, b) => this.priority(a) - this.priority(b) + (a.queued - b.queued) / 2000);
for (let i = 0; i < this.queue.length;) {
const task = this.queue[i];
if (task.worker.closed) { this.queue.splice(i, 1); continue; }
if (this.queue.some(previous => previous.worker === task.worker && previous.data.engineID === task.data.engineID && previous.order < task.order)) { i++; continue; }
const busy = [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name !== "ofdgoSaveSnapshot");
const snapshot = task.data.name === "ofdgoSaveSnapshot" && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name === "ofdgoSaveSnapshot");
const active = this.active();
const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload && !task.data.background;
const foregroundActive = [...this.workers].some(worker => [...worker.operations.values()].some(data => data.name !== "ofdgoSaveSnapshot" && worker.primary && this.owner(worker, data)?.foreground === true && !data.preload && !data.background));
const continuing = task.worker.primary && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID);
const available = continuing || active < this.limit || interactive && !foregroundActive && active < this.limit + 1;
if (busy || snapshot || !available || !task.worker.primary && active && this.memory + (64 << 20) > this.budget) { i++; continue; }
this.queue.splice(i, 1);
this.send(task);
}
} finally { this.draining = false; }
}
cancel(worker, data) {
const index = this.queue.findIndex(task => task.worker === worker && requestKey(worker, task.data) === requestKey(worker, data));
if (index < 0) return false;
const task = this.queue.splice(index, 1)[0];
worker.receive({data:{engineID:task.data.engineID, id:data.id, ok:false, canceled:true, error:"操作已取消"}});
this.drain();
return true;
}
pause(worker, engineID) {
const paused = this.queue.filter(task => task.worker === worker && task.data.engineID === engineID && task.data.preload);
this.queue = this.queue.filter(task => !paused.includes(task));
for (const task of paused) worker.receive({data:{engineID, id:task.data.id, ok:false, canceled:true, error:"预加载已暂停"}});
}
remove(worker) {
this.workers.delete(worker);
this.queue = this.queue.filter(task => task.worker !== worker);
worker.requests.clear();
worker.operations.clear();
this.drain();
}
}
class BrowserWorker {
constructor(options) {
engineScheduler ||= new BrowserScheduler();
this.primary = !!options.primary;
this.shared = !!options.shared;
this.engineID = this.shared ? null : `engine-${++engineSequence}`;
this.sessions = new Map();
this.closingSessions = new Set();
this.batch = !options.primary && !options.page;
this.requests = new Map();
this.operations = new Map();
this.foreground = !this.batch;
this.pagesPaused = false;
this.memory = 64 << 20;
this.reservation = 0;
this.closed = false;
this.url = String(options.url || "./ofdgo_wasm.js");
this.worker = new Worker(this.url);
engineScheduler.workers.add(this);
this.worker.onmessage = event => {
if (this.closed || event.data.type === "module") return;
engineScheduler.complete(this, event.data);
if (event.data.type === "exit" && (!this.shared || !event.data.engineID)) this.terminate();
try { this.receive(event); }
finally { engineScheduler.drain(); }
};
this.worker.onerror = event => { this.terminate(); this.fail("onerror", event); };
this.worker.onmessageerror = event => { this.terminate(); this.fail("onmessageerror", event); };
this.worker.addEventListener("message", event => {
if (event.data.type !== "module") return;
event.stopImmediatePropagation();
engineModule ||= WebAssembly.compileStreaming(fetch("./ofdgo.wasm")).catch(error => { engineModule = null; throw error; });
engineModule.then(module => { if (!this.closed) this.worker.postMessage({ type:"module", module }); }, error => { if (!this.closed) this.worker.postMessage({ type:"module", error:error.message }); });
});
if (!this.shared) this.worker.postMessage({type:"open", engineID:this.engineID});
}
receive(event) {
if (!event.data.engineID && !["progress", "exit"].includes(event.data.type)) return;
if (!this.shared) {
if (event.data.engineID && event.data.engineID !== this.engineID) return;
this.onmessage?.(event);
return;
}
if (event.data.type === "closed") {
if (!this.closingSessions.delete(event.data.engineID)) return;
for (const [key, data] of this.operations) if (data.engineID === event.data.engineID) {
this.requests.delete(key);
this.operations.delete(key);
}
if (!this.sessions.size && !this.closingSessions.size) this.terminate();
return;
}
const targets = event.data.engineID ? [this.sessions.get(event.data.engineID)] : [...this.sessions.values()];
for (const session of targets) session?.receive(event);
}
fail(name, event) {
if (!this.shared) { this[name]?.(event); return; }
for (const session of [...this.sessions.values()]) {
session.terminate();
session[name]?.(event);
}
}
postMessage(data, transfer) {
if (this.closed) throw new DOMException("引擎已关闭", "AbortError");
if (!this.shared) data = {...data, engineID:this.engineID};
if (data.type) {
if (data.type === "cancel" && engineScheduler.cancel(this, data)) return;
this.worker.postMessage(data, transfer);
} else engineScheduler.submit(this, data, transfer);
}
setForeground(foreground) {
this.foreground = !!foreground;
this.pausePages(!this.foreground);
}
pausePages(paused = true) {
this.pagesPaused = !!paused;
if (this.pagesPaused) engineScheduler.pause(this, this.engineID);
engineScheduler.drain();
}
terminate() {
if (this.closed) return;
this.closed = true;
if (engineRuntimes.get(this.url) === this) engineRuntimes.delete(this.url);
this.closingSessions.clear();
this.worker.terminate();
engineScheduler.remove(this);
}
}
class BrowserSession {
constructor(options) {
const url = String(options.url || "./ofdgo_wasm.js");
let runtime = engineRuntimes.get(url);
if (!runtime || runtime.closed) {
runtime = new BrowserWorker({primary:true, shared:true, url});
engineRuntimes.set(url, runtime);
}
this.runtime = runtime;
this.engineID = `engine-${++engineSequence}`;
this.requests = new Set();
this.foreground = true;
this.pagesPaused = false;
this.closed = false;
runtime.sessions.set(this.engineID, this);
runtime.postMessage({type:"open", engineID:this.engineID});
}
receive(event) {
if (this.closed) return;
if (event.data.id != null && !event.data.type) this.requests.delete(event.data.id);
if (event.data.type === "exit") this.terminate();
this.onmessage?.(event);
}
postMessage(data, transfer) {
if (this.closed) throw new DOMException("引擎已关闭", "AbortError");
if (!data.type) this.requests.add(data.id);
this.runtime.postMessage({...data, engineID:this.engineID}, transfer);
}
setForeground(foreground) {
this.foreground = !!foreground;
this.pausePages(!this.foreground);
}
pausePages(paused = true) {
this.pagesPaused = !!paused;
if (this.pagesPaused) engineScheduler.pause(this.runtime, this.engineID);
engineScheduler.drain();
}
terminate() {
if (this.closed) return;
this.closed = true;
this.runtime.sessions.delete(this.engineID);
engineScheduler.queue = engineScheduler.queue.filter(task => task.worker !== this.runtime || task.data.engineID !== this.engineID);
this.requests.clear();
if (!this.runtime.closed) {
this.runtime.closingSessions.add(this.engineID);
this.runtime.postMessage({type:"close", engineID:this.engineID});
}
engineScheduler.drain();
}
}
export function createEngineWorker(options = {}) {
if (nativeHost) return nativeHost.worker();
if (options.page || options.batch) return new BrowserWorker(options);
if (options.primary) return new BrowserSession(options);
return new BrowserEngine(options);
}
export async function documentBytes(file) {
if (file?.nativeFile) return file;
if (file instanceof Blob) return new Response(file.stream()).blob();
return new Uint8Array(await file.arrayBuffer());
}
class BrowserEngine {
constructor(options) {
this.primary = createEngineWorker({primary:true, url:options.url});
this.engineID = this.primary.engineID;
this.workerURL = options.url;
this.primary.onmessage = event => {
const data = event.data;
if (data.memory) this.memory = data.memory;
if (this.source) this.parallelism = engineScheduler.limit;
const rendered = data.id != null && !data.type && this.requests.delete(data.id);
this.onmessage?.(event);
if (rendered && data.ok) this.dispatchPages();
};
this.primary.onerror = event => this.onerror?.(event);
this.primary.onmessageerror = event => this.onmessageerror?.(event);
this.requests = new Map();
this.fontRequests = new Map();
this.primaryPages = new Set();
this.slots = new Set();
this.sequence = 0;
this.revision = 0;
this.memory = 0;
this.parallelism = 1;
this.budget = engineScheduler.budget;
this.closed = false;
this.source = null;
this.pageSource = null;
this.foreground = true;
this.pagesPaused = false;
}
configurePages(source) {
const current = this.pageSource;
const fonts = (source?.fonts || []).map(font => font?.data !== undefined ? {name:font.name, data:font.data} : font);
const sameNames = JSON.stringify(source?.fontNames || []) === JSON.stringify(current?.fontNames || []);
const sameFonts = current && fonts.length === current.fonts.length && fonts.every((font, index) => font === current.fonts[index] || font?.data !== undefined && font.name === current.fonts[index]?.name && font.data === current.fonts[index]?.data);
if (source && current && source.file === current.file && !!source.pdf === !!current.pdf && (source.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts && sameNames) return;
this.invalidatePages();
if (!source || this.closed) return;
this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""};
this.source = {...source, file: source.file instanceof Blob ? source.file : new Blob([source.file])};
this.parallelism = engineScheduler.limit;
}
setForeground(foreground) {
this.foreground = !!foreground;
this.primary.setForeground(this.foreground);
for (const slot of this.slots) slot.worker.setForeground(this.foreground);
this.pausePages(!this.foreground);
}
pausePages(paused = true) {
this.pagesPaused = !!paused;
this.primary.pausePages(this.pagesPaused);
for (const slot of this.slots) slot.worker.pausePages(this.pagesPaused);
}
invalidatePages() {
this.revision++;
this.primaryPages.clear();
this.source = null;
this.pageSource = null;
this.parallelism = 1;
this.startupTime = 0;
this.primary.pageTime = 0;
for (const slot of this.slots) this.closeSlot(slot);
}
closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) {
if (!this.slots.delete(slot)) return;
slot.worker.terminate();
for (const [id, request] of this.fontRequests) if (request.worker === slot.worker) this.fontRequests.delete(id);
slot.rejectReady(error);
for (const pending of slot.pending.values()) pending.reject(error);
slot.pending.clear();
}
createSlot(source) {
const started = performance.now();
const worker = createEngineWorker({page:true, url:this.workerURL});
worker.setForeground(this.foreground);
worker.pausePages(this.pagesPaused);
const slot = {worker, pending: new Map(), busy: false, memory: Math.max(128 << 20, this.memory), pages:new Set(), lastPage:0};
worker.reservation = slot.memory;
worker.reclaim = () => { if (!slot.busy) this.closeSlot(slot); };
const ready = new Promise((resolve, reject) => {
slot.rejectReady = reject;
worker.onmessage = ({data}) => {
if (data.memory) slot.memory = data.memory;
if (this.source) this.parallelism = engineScheduler.limit;
if (data.type === "ready") { resolve(); return; }
if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); return; }
if (data.type === "font") {
const request = `${slot.worker.engineID}:${data.request}`;
this.fontRequests.set(request, {worker:slot.worker, request:data.request});
this.onmessage?.({data:{...data, id:slot.requestID, request}});
return;
}
if (data.type) return;
const request = slot.pending.get(data.id);
if (!request) return;
slot.pending.delete(data.id);
if (data.ok) request.resolve(data);
else request.reject(Object.assign(new Error(data.error), {code:data.code, name:data.canceled ? "AbortError" : "Error"}));
};
worker.onerror = worker.onmessageerror = () => this.closeSlot(slot, new Error("页面加载失败"));
});
this.slots.add(slot);
slot.ready = ready.then(async () => {
let opened = source.pdf
? await this.callSlot(slot, "ofdgoOpenPDF", [source.file, source.fonts, null, source.annotations, source.fontNames])
: await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]);
if ((source.document || 0) !== (opened.data.documentIndex || 0)) opened = await this.callSlot(slot, "ofdgoSelectDocument", [source.document]);
if ((source.version || "") !== (opened.data.documentVersion || "")) await this.callSlot(slot, "ofdgoSelectVersion", [source.version || ""]);
if (this.slots.has(slot)) this.startupTime = performance.now() - started;
});
slot.ready.catch(() => {});
return slot;
}
callSlot(slot, name, args, preload = false) {
return new Promise((resolve, reject) => {
if (!this.slots.has(slot)) { reject(new DOMException("页面加载已取消", "AbortError")); return; }
const id = --this.sequence;
slot.pending.set(id, {resolve, reject});
try { slot.worker.postMessage({id, name, args, preload}); }
catch (error) { slot.pending.delete(id); reject(error); }
});
}
pageSlot(data) {
if (this.primaryPages.has(data.args[0])) return null;
const preferred = [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0]));
const pending = [...this.requests.values()].filter(slot => slot === null).length - Number(this.requests.has(data.id));
if (!preferred && pending <= 0) return null;
let slot = preferred || [...this.slots].filter(slot => !slot.busy).sort((a,b) => Math.abs(a.lastPage-data.args[0])-Math.abs(b.lastPage-data.args[0]))[0];
const worthwhile = (this.primary.pageTime || 0) * pending >= Math.max(50, this.startupTime || this.primary.openTime || 0);
if (!slot && worthwhile && this.foreground && !this.pagesPaused && this.slots.size < this.parallelism - 1
&& engineScheduler.memory + Math.max(128 << 20, this.memory) <= this.budget) slot = this.createSlot(this.source);
return slot;
}
dispatchPages() {
if (this.closed || !this.source || !this.foreground || this.pagesPaused) return;
const draining = engineScheduler.draining;
engineScheduler.draining = true;
try {
const queued = engineScheduler.queue.filter(task => task.worker === this.primary.runtime && task.data.engineID === this.engineID).sort((a,b) => a.order-b.order);
for (const task of queued) {
if (task.data.name !== "ofdgoRenderPage") break;
if (this.requests.get(task.data.id) !== null) continue;
let slot;
try { slot = this.pageSlot(task.data); } catch { break; }
if (!slot) break;
engineScheduler.queue.splice(engineScheduler.queue.indexOf(task), 1);
this.primary.requests.delete(task.data.id);
this.renderSlot(slot, task.data, task.transfer);
}
} finally { engineScheduler.draining = draining; }
}
postMessage(data, transfer) {
if (this.closed) return;
if (data.type === "font" && typeof data.request === "string") {
const request = this.fontRequests.get(data.request);
if (request) {
this.fontRequests.delete(data.request);
request.worker.postMessage({...data, request:request.request}, transfer);
}
return;
}
if (!data.type && data.preload && this.pagesPaused) {
this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
return;
}
if (data.type === "cancel") {
const slot = this.requests.get(data.id);
if (slot && slot !== this.primary) this.closeSlot(slot);
else this.primary.postMessage(data);
return;
}
if (!data.type && data.name !== "ofdgoRenderPage" && ![
"ofdgoLoadPDF", "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData",
"ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets",
"ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles",
"ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures",
"ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice",
"ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal",
"ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument",
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
].includes(data.name)) this.invalidatePages();
const slot = data.name === "ofdgoRenderPage" && this.source ? this.pageSlot(data) : null;
if (!slot) {
if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null);
this.primary.postMessage(data, transfer);
return;
}
this.renderSlot(slot, data, transfer);
}
renderSlot(slot, data, transfer) {
slot.busy = true;
slot.requestID = data.id;
this.requests.set(data.id, slot);
const revision = this.revision;
let fallback = false;
slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => {
if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError");
if (this.source.pdf && ((JSON.parse(result.data.annotations || "null") || []).length || result.data.links?.length)) {
this.primaryPages.add(data.args[0]);
this.requests.set(data.id, this.primary);
fallback = true;
this.primary.postMessage(data, transfer);
return;
}
slot.lastPage = data.args[0];
slot.pages.delete(slot.lastPage);
slot.pages.add(slot.lastPage);
if (slot.pages.size > 16) slot.pages.delete(slot.pages.values().next().value);
if (!this.closed) this.onmessage?.({data: {...result, engineID:this.engineID, id: data.id}});
}).catch(error => {
if (!this.closed && revision === this.revision && error.name !== "AbortError") {
this.closeSlot(slot, error);
this.parallelism = Math.max(1, this.slots.size + 1);
this.requests.set(data.id, this.primary);
fallback = true;
this.primary.postMessage(data, transfer);
return;
}
if (!this.closed) this.onmessage?.({data: {engineID:this.engineID, id: data.id, ok: false, error: error.message, canceled: error.name === "AbortError"}});
}).finally(() => {
if (!fallback) this.requests.delete(data.id);
slot.busy = false;
slot.worker.reservation = 0;
const memory = engineScheduler.memory;
if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); }
this.dispatchPages();
engineScheduler.drain();
});
}
terminate() {
if (this.closed) return;
this.closed = true;
this.invalidatePages();
this.primary.terminate();
this.requests.clear();
}
}
export function saveFilePicker(options) {
return nativeHost ? nativeHost.savePicker(options) : window.showSaveFilePicker ? window.showSaveFilePicker(options) : null;
}
export function directoryPicker() {
return nativeHost ? nativeHost.directoryPicker() : window.showDirectoryPicker({mode:"readwrite"});
}
class NativeFile {
constructor(value) {
Object.assign(this, value);
if (value.handle) this.handle = new NativeHandle(nativeHost, value.handle);
this.byteLength = this.size;
this.type ||= "application/octet-stream";
if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile);
}
async arrayBuffer(signal) {
const response = await fetch(this.url, {signal});
if (!response.ok) throw new Error("读取文件失败");
return response.arrayBuffer();
}
toJSON() {
return {nativeFile:this.nativeFile, size:this.size, name:this.name};
}
async text() {
return new TextDecoder().decode(await this.arrayBuffer());
}
}
class NativeHandle {
constructor(host, value) {
this.host = host;
Object.assign(this, value);
}
toJSON() {
return {nativeHandle:this.nativeHandle, name:this.name, kind:this.kind};
}
async getFile() {
return new NativeFile(await this.host.call("file", {handle:this.nativeHandle}));
}
async getFileHandle(name, options = {}) {
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:false}));
}
async getDirectoryHandle(name, options = {}) {
return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:true}));
}
removeEntry(name, options = {}) {
return this.host.call("remove", {handle:this.nativeHandle, name, recursive:!!options.recursive});
}
async createWritable() {
const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle});
return {
write: data => this.host.call("write.data", {writer, data}),
close: () => this.host.call("write.commit", {writer}),
abort: () => this.host.call("write.abort", {writer}),
};
}
}
class NativeHost {
constructor(bridge) {
this.bridge = bridge;
this.parallelism = 1;
this.cacheBytes = 32 * 1024 * 1024;
this.reading = 0;
this.readQueue = [];
this.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => {
this.call("release", {file}).catch(() => {});
}) : null;
}
call(name, value, signal) {
return this.bridge.call(`ofdgo.host.${name}`, value, {signal});
}
async readResource(file, signal) {
if (this.reading >= this.parallelism) await new Promise(resolve => this.readQueue.push(resolve));
else this.reading++;
try {
signal?.throwIfAborted();
return await file.arrayBuffer(signal);
} finally {
try { await this.call("release", {file:file.nativeFile}); }
finally {
const next = this.readQueue.shift();
if (next) next();
else this.reading--;
}
}
}
async decode(value, signal) {
const reads = [];
const visit = (item, field, replace) => {
if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return;
if (item.nativeFile) {
const file = new NativeFile(item);
if (field === "file") replace(file);
else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes))));
return;
}
for (const [child, content] of Object.entries(item)) {
if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result);
}
};
visit(value, "", result => value = result);
const results = await Promise.allSettled(reads);
const failed = results.find(result => result.status === "rejected");
if (failed) throw failed.reason;
return value;
}
async release(value) {
if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return;
if (value?.nativeFile && value.temporary) {
await this.call("release", {file:value.nativeFile}).catch(() => {});
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) await this.release(item);
}
}
worker() {
const host = this;
let closed = false;
let unsubscribe;
let pending = Promise.resolve();
let termination = null;
const readers = new Set();
const inputs = new Set();
const requests = new Map();
const completions = new Map();
const emit = data => {
if (data.type === "result") { completions.get(data.id)?.(data.result); return; }
if (!closed) worker.onmessage?.({data:{...data, engineID:worker.engineID}});
};
const ready = host.call("engine.open", {}).then(({engine, parallelism, cacheBytes}) => {
worker.engineID = engine;
if (closed) return engine;
host.parallelism = Math.max(1, parallelism || 1);
host.cacheBytes = cacheBytes || host.cacheBytes;
unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit);
emit({type:"ready"});
return engine;
}).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; });
const worker = {
engineID:null,
foreground:true,
pagesPaused:false,
onmessage:null,
onerror:null,
configurePages() {},
setForeground(foreground) {
worker.foreground = !!foreground;
worker.pausePages(!worker.foreground);
},
pausePages(paused = true) {
worker.pagesPaused = !!paused;
},
postMessage(data) {
if (closed) return;
if (data.type === "cancel") {
requests.get(data.id)?.abort();
return;
}
if (data.type === "font") return;
if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoOpenPDF"].includes(data.name)) {
const input = data.args?.[0];
if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile);
}
const controller = new AbortController();
requests.set(data.id, controller);
const parallel = data.name === "ofdgoRenderPage";
const barrier = parallel ? pending : Promise.all([pending, ...readers]);
const operation = barrier.then(async () => {
const engine = await ready;
if (closed) return;
if (data.preload && worker.pagesPaused) throw new DOMException("预加载已暂停", "AbortError");
controller.signal.throwIfAborted();
const args = {engine, id:data.id, name:data.name, args:data.args};
let result;
if (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles") {
const completed = new Promise(resolve => completions.set(data.id, resolve));
const cancel = () => host.call("engine.cancel", {engine, id:data.id}).catch(() => {});
controller.signal.addEventListener("abort", cancel, {once:true});
try {
await host.call("engine.start", args);
if (controller.signal.aborted) cancel();
result = await completed;
await host.call("engine.finish", {engine, id:data.id});
} finally {
controller.signal.removeEventListener("abort", cancel);
completions.delete(data.id);
}
} else result = await host.call("engine.call", args, controller.signal);
if (closed) { await host.release(result); return; }
let decoded;
try {
controller.signal.throwIfAborted();
decoded = await host.decode(result, controller.signal);
controller.signal.throwIfAborted();
}
catch (error) { await host.release(result); throw error; }
if (closed) { await host.release(result); return; }
emit({id:data.id, ...decoded});
}).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => requests.delete(data.id));
if (parallel) {
readers.add(operation);
operation.finally(() => readers.delete(operation));
} else if (data.name !== "ofdgoSaveSnapshot") pending = operation;
},
terminate() {
if (closed) return termination;
closed = true;
for (const controller of requests.values()) controller.abort();
termination = ready.then(engine => host.call("engine.close", {engine})).catch(() => {}).finally(async () => {
unsubscribe?.();
for (const resolve of completions.values()) resolve({ok:false, canceled:true, error:"会话已关闭"});
completions.clear();
await Promise.all([...inputs].map(file => host.call("release", {file}).catch(() => {})));
inputs.clear();
});
return termination;
},
};
ready.catch(() => {});
return worker;
}
async savePicker(options) {
return new NativeHandle(this, await this.call("save", options));
}
async directoryPicker() {
return new NativeHandle(this, await this.call("directory", {}));
}
async fonts() {
return (await this.call("fonts", {})).map(({file, ...font}) => ({...font, nativeFile:new NativeFile(file)}));
}
async start(onFiles, onError) {
let launching = Promise.resolve();
this.bridge.on("ofdgo.launch", values => {
launching = launching.then(async () => {
try { await onFiles(values.map(value => new NativeFile(value)), true); }
finally { await this.call("launch.done", {}); }
}).catch(onError);
});
this.bridge.on("ofdgo.files", async values => {
try { await onFiles(values.map(value => new NativeFile(value))); }
catch (error) { onError(error); }
});
this.bridge.on("ofdgo.error", message => onError(new Error(message)));
const title = document.querySelector("title");
if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true});
await this.call("ready", {});
}
async discardFiles(files) {
await Promise.all(files.filter(file => file.drop && file.nativeFile).map(file => this.call("drop.release", {file:file.nativeFile})));
}
installFileInputs(onError) {
document.addEventListener("click", async event => {
const input = event.target;
if (!(input instanceof HTMLInputElement) || input.type !== "file" || input.disabled) return;
event.preventDefault();
try {
const files = (await this.call("open", {multiple:input.multiple, directory:input.webkitdirectory, accept:input.accept})).map(value => new NativeFile(value));
if (!files.length) return;
const transfer = new DataTransfer();
for (const file of files) transfer.items.add(new File([], file.name, {type:file.type}));
delete input.files;
input.files = transfer.files;
Object.defineProperty(input, "files", {configurable:true, get:() => input.value ? files : []});
input.dispatchEvent(new Event("input", {bubbles:true}));
input.dispatchEvent(new Event("change", {bubbles:true}));
} catch (error) { if (error.name !== "AbortError") onError(error); }
}, true);
}
async download(data, mime, name) {
const handle = await this.savePicker({suggestedName:name});
const writer = await handle.createWritable();
try { await writer.write(data); await writer.close(); }
catch (error) { await writer.abort().catch(() => {}); throw error; }
}
}
export const nativeHost = globalThis.guigo ? new NativeHost(globalThis.guigo) : null;
+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);
}
}
+42
View File
@@ -0,0 +1,42 @@
(() => {
const root = document.documentElement;
const preference = matchMedia("(prefers-color-scheme: dark)");
const key = "ofdgo.theme";
let mode = root.dataset.themeMode || "system";
let host = null, report = null, pending = Promise.resolve();
if (!root.dataset.themeMode) {
try { mode = localStorage.getItem(key) || "system"; } catch {}
}
if (!["system", "light", "dark"].includes(mode)) mode = "system";
function apply() {
root.dataset.themeMode = mode;
root.dataset.theme = mode === "system" ? preference.matches ? "dark" : "light" : mode;
document.querySelector('meta[name="theme-color"]').content = root.dataset.theme === "dark" ? "#272e33" : "#fafbfc";
for (const button of document.querySelectorAll("[data-theme-choice]")) button.dataset.pressed = String(button.dataset.themeChoice === mode);
}
function sync() {
if (!host) return;
const value = {mode, dark: root.dataset.theme === "dark"};
pending = pending.then(() => host.call("appearance", value)).catch(error => report(error));
}
function set(value) {
mode = value;
apply();
if (!host) {
try { localStorage.setItem(key, mode); } catch {}
}
sync();
}
preference.addEventListener("change", () => {
if (mode !== "system") return;
apply();
sync();
});
window.addEventListener("storage", event => {
if (host || event.key !== key && event.key !== null) return;
mode = ["light", "dark"].includes(event.newValue) ? event.newValue : "system";
apply();
});
window.ofdgoTheme = {set, connect(value, onError) { host = value; report = onError; apply(); sync(); }};
apply();
})();
+550
View File
@@ -0,0 +1,550 @@
export class DocumentDialogs {
constructor({ document: root = document, targets = [], titlebar = null } = {}) {
Object.assign(this, { document: root, targets, titlebar, owner: null, records: new Map(), tabs: new Map(), released: new WeakSet(), inert: new Map(), styles: new Map() });
this.backdrop = root.createElement("div");
this.backdrop.className = "document-dialog-backdrop";
this.backdrop.hidden = true;
Object.assign(this.backdrop.style, { position: "fixed", inset: "0", background: "var(--backdrop)", zIndex: "1000" });
root.body.append(this.backdrop);
root.addEventListener("keydown", event => {
if (event.defaultPrevented || this.applicationOpen()) return;
const panel = this.current()?.stack.at(-1);
if (!panel) return;
if (event.key === "Tab" && !event.ctrlKey && !event.altKey && !event.metaKey) {
const selector = "button, input, select, textarea, a[href], [tabindex], [contenteditable=true]";
const elements = [this.titlebar, panel].filter(Boolean).flatMap(root => [...root.querySelectorAll(selector)])
.filter(node => node.tabIndex >= 0 && !node.disabled && !node.closest("[inert]") && node.getClientRects().length);
const index = elements.indexOf(root.activeElement), direction = event.shiftKey ? -1 : 1;
const target = index < 0 ? event.shiftKey ? elements.at(-1) : elements[0] : elements[(index + direction + elements.length) % elements.length];
event.preventDefault();
if (target) target.focus();
else panel.focus();
return;
}
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
this.cancel(panel);
}, true);
this.backdrop.addEventListener("pointerdown", event => { event.preventDefault(); this.focus(); });
root.defaultView.addEventListener("resize", () => this.paint());
}
register(panel, { shared = true, persistent = false } = {}) {
if (this.records.has(panel)) return panel;
const record = { show: panel.show.bind(panel), showModal: panel.showModal.bind(panel), close: panel.close.bind(panel), owner: null, application: false, shared, clicks: new WeakMap(), initial: shared && !persistent ? this.snapshot(panel) : null };
this.records.set(panel, record);
panel.showModal = panel.show = () => this.openFor(this.owner, panel);
panel.close = value => {
if (record.application) record.close(value);
else this.closeFor(this.owner, panel, value);
};
panel.requestClose = value => {
if (record.application) {
const event = new this.document.defaultView.Event("cancel", { cancelable: true });
if (panel.dispatchEvent(event)) record.close(value);
} else this.cancel(panel, value);
};
panel.addEventListener("close", () => {
if (record.application) { record.application = false; this.paint(); }
});
panel.addEventListener("click", event => { record.clicks.set(event, this.owner); }, true);
panel.addEventListener("click", event => {
if (!record.application && (record.clicks.get(event) !== this.owner || !this.current()?.stack.includes(panel))) event.preventDefault();
});
panel.addEventListener("submit", event => {
if (record.application || event.defaultPrevented) return;
const method = event.submitter?.getAttribute("formmethod") || event.target.getAttribute("method");
if (method?.toLowerCase() !== "dialog") return;
event.preventDefault();
this.closeFor(this.owner, panel, event.submitter?.value || "");
});
return panel;
}
current(tab = this.owner) {
return tab ? this.tabs.get(tab) : null;
}
entry(tab, panel) {
if (!tab || this.released.has(tab)) throw new Error("Document dialog owner is unavailable");
this.register(panel);
if (!this.tabs.has(tab)) this.tabs.set(tab, { entries: new Map(), stack: [] });
const entries = this.tabs.get(tab).entries;
if (!entries.has(panel)) entries.set(panel, { snapshot: this.records.get(panel).initial, setup: [], updates: [], resolve: null, promise: null });
return entries.get(panel);
}
setOwner(tab) {
if (this.owner === tab) return;
if (this.owner) this.suspend(this.owner);
if (tab) this.restore(tab);
}
openFor(tab, panel, setup) {
const entry = this.entry(tab, panel);
if (setup) entry.setup.push(setup);
const state = this.tabs.get(tab);
state.stack = state.stack.filter(value => value !== panel);
state.stack.push(panel);
if (tab !== this.owner) return panel;
this.unpaint();
this.records.get(panel).owner = tab;
this.prepare(entry, panel);
if (!this.records.get(panel).shared) this.document.body.append(panel);
if (!panel.open) this.records.get(panel).show();
this.paint();
this.focus();
return panel;
}
request(panel, tab = this.owner, setup) {
if (!tab || this.released.has(tab)) return Promise.resolve(null);
const entry = this.entry(tab, panel);
if (entry.promise) return entry.promise;
entry.promise = new Promise(resolve => { entry.resolve = resolve; });
const promise = entry.promise;
this.openFor(tab, panel, setup);
return promise;
}
updateFor(tab, panel, setup) {
if (!tab || this.released.has(tab)) return false;
const entry = this.current(tab)?.entries.get(panel);
if (!entry) return false;
if (tab === this.owner && this.records.get(panel).owner === tab) setup(panel);
else entry.updates.push(setup);
return true;
}
closeFor(tab, panel, value) {
const state = this.current(tab), entry = state?.entries.get(panel);
if (!state?.stack.includes(panel)) return;
const visible = tab === this.owner;
if (visible) {
this.unpaint();
if (value !== undefined) panel.returnValue = String(value);
panel.removeAttribute("open");
if (this.records.get(panel).initial) entry.snapshot = this.snapshot(panel);
}
const result = value === undefined ? visible ? panel.returnValue : entry.snapshot?.returnValue ?? panel.returnValue : String(value);
state.stack = state.stack.filter(value => value !== panel);
const resolve = entry.resolve;
entry.resolve = entry.promise = null;
entry.setup = [];
if (visible) {
this.paint();
panel.dispatchEvent(new this.document.defaultView.Event("close"));
this.focus();
}
resolve?.(result);
}
cancel(panel, value) {
const event = new this.document.defaultView.Event("cancel", { cancelable: true });
if (panel.dispatchEvent(event)) this.closeFor(this.owner, panel, value);
}
suspend(tab) {
if (tab !== this.owner) return;
this.unpaint();
for (const [panel, entry] of this.current(tab)?.entries || []) {
const record = this.records.get(panel);
if (record.application || record.owner !== tab) continue;
if (record.initial) entry.snapshot = this.snapshot(panel);
else if (!record.shared) {
entry.focus = panel.contains(this.document.activeElement) ? this.document.activeElement : null;
panel.remove();
}
panel.removeAttribute("open");
}
this.owner = null;
this.paint();
}
restore(tab) {
if (!tab || this.released.has(tab)) return false;
if (this.owner === tab) return true;
if (this.owner) this.suspend(this.owner);
if (!this.tabs.has(tab)) this.tabs.set(tab, { entries: new Map(), stack: [] });
this.owner = tab;
const state = this.current(tab);
for (const [panel, entry] of state.entries) {
const record = this.records.get(panel);
if (record.application) continue;
if (record.initial) this.applySnapshot(entry.snapshot, panel);
else if (!record.shared) this.document.body.append(panel);
record.owner = tab;
}
for (const [panel, entry] of state.entries) {
if (this.records.get(panel).application) continue;
for (const update of entry.updates.splice(0)) update(panel);
}
for (const panel of state.stack) {
this.prepare(state.entries.get(panel), panel);
if (!panel.open) this.records.get(panel).show();
}
this.paint();
this.focus(true);
return true;
}
release(tab) {
if (!tab || this.released.has(tab)) return;
const visible = this.owner === tab;
if (visible) this.suspend(tab);
const state = this.current(tab);
this.tabs.delete(tab);
this.released.add(tab);
for (const entry of state?.entries.values() || []) entry.resolve?.(null);
for (const [panel] of state?.entries || []) {
const record = this.records.get(panel);
if (!record.shared) {
panel.remove();
this.records.delete(panel);
continue;
}
if (record.application || record.owner !== tab) continue;
if (record.initial) this.applySnapshot(record.initial, panel);
record.owner = null;
}
state?.entries.clear();
if (state) state.stack.length = 0;
}
showApplication(panel) {
this.register(panel);
if ([...this.tabs.values()].some(state => state.stack.includes(panel))) throw new Error("Document dialog is already in use");
const record = this.records.get(panel);
record.application = true;
try { record.showModal(); }
catch (error) { record.application = false; throw error; }
}
applicationOpen() {
return [...this.records.values()].some(record => record.application);
}
prepare(entry, panel) {
for (const setup of entry.setup.splice(0)) setup(panel);
}
snapshot(panel) {
const nodes = [panel, ...panel.querySelectorAll("*")].map(node => ({ node,
children: [...node.childNodes], attributes: [...node.attributes].filter(attribute => node !== panel || attribute.name !== "open").map(attribute => [attribute.name, attribute.value]),
value: "value" in node && node.type !== "file" ? node.value : undefined,
checked: node.checked, indeterminate: node.indeterminate, selected: node.selected,
files: node.type === "file" ? [...node.files] : undefined, scrollTop: node.scrollTop, scrollLeft: node.scrollLeft,
selectionStart: node.selectionStart, selectionEnd: node.selectionEnd, selectionDirection: node.selectionDirection,
text: [...node.childNodes].filter(child => child.nodeType === 3).map(child => [child, child.data]) }));
const focus = panel.contains(this.document.activeElement) ? this.document.activeElement : null;
const range = this.document.getSelection();
const selection = !focus?.matches("input, textarea") && range?.anchorNode && panel.contains(range.anchorNode) && panel.contains(range.focusNode)
? { anchor: range.anchorNode, start: range.anchorOffset, focus: range.focusNode, end: range.focusOffset } : null;
return { nodes, focus, selection, returnValue: panel.returnValue };
}
applySnapshot(snapshot, panel) {
panel.removeAttribute("open");
for (const item of snapshot.nodes) {
const { node, children, attributes } = item;
if (node.childNodes.length !== children.length || children.some((child, index) => node.childNodes[index] !== child)) node.replaceChildren(...children);
for (const attribute of [...node.attributes]) if (!attributes.some(([name]) => name === attribute.name)) node.removeAttribute(attribute.name);
for (const [name, value] of attributes) if (node.getAttribute(name) !== value) node.setAttribute(name, value);
for (const [text, value] of item.text) text.data = value;
}
for (const item of snapshot.nodes) {
const { node } = item;
if (item.value !== undefined && node.tagName !== "SELECT") node.value = item.value;
for (const key of ["checked", "indeterminate", "selected"]) if (item[key] !== undefined) node[key] = item[key];
if (item.files !== undefined) {
const transfer = new this.document.defaultView.DataTransfer();
for (const file of item.files) transfer.items.add(file);
node.files = transfer.files;
}
if (item.selectionStart != null && typeof node.setSelectionRange === "function") node.setSelectionRange(item.selectionStart, item.selectionEnd, item.selectionDirection);
node.scrollTop = item.scrollTop;
node.scrollLeft = item.scrollLeft;
}
panel.returnValue = snapshot.returnValue;
}
unpaint() {
for (const [node, value] of this.inert) node.inert = value;
this.inert.clear();
for (const [panel, styles] of this.styles) for (const [name, [value, priority]] of styles) {
if (value) panel.style.setProperty(name, value, priority);
else panel.style.removeProperty(name);
}
this.styles.clear();
}
paint() {
this.unpaint();
const stack = this.current()?.stack || [];
const top = stack.at(-1);
this.backdrop.hidden = !top;
if (!top) return;
const inset = Math.max(0, this.titlebar?.getBoundingClientRect().bottom || 0);
this.backdrop.style.top = `${inset}px`;
this.backdrop.style.zIndex = String(1000 + (stack.length - 1) * 2);
const disable = node => {
if (!node || node === this.titlebar || node === this.backdrop || this.records.has(node)) return;
if (node.contains(this.titlebar) || [...this.records.keys()].some(panel => node.contains(panel))) {
for (const child of node.children) disable(child);
return;
}
if (!this.inert.has(node)) this.inert.set(node, node.inert);
node.inert = true;
};
for (const target of typeof this.targets === "function" ? this.targets() : this.targets) disable(target);
for (const [index, panel] of stack.entries()) {
if (panel !== top) { this.inert.set(panel, panel.inert); panel.inert = true; }
const values = { position: "fixed", inset: `${inset}px 0 0`, zIndex: String(1001 + index * 2), maxHeight: `calc(100dvh - ${inset + 24}px)`, "--dialog-inset": `${inset}px` };
const styles = new Map();
for (const [key, value] of Object.entries(values)) {
const name = key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
styles.set(name, [panel.style.getPropertyValue(name), panel.style.getPropertyPriority(name)]);
panel.style.setProperty(name, value);
}
this.styles.set(panel, styles);
}
}
focus(restore = false) {
const panel = this.current()?.stack.at(-1);
if (!panel || this.applicationOpen()) return;
const saved = restore ? this.current().entries.get(panel)?.snapshot?.focus || this.current().entries.get(panel)?.focus : null;
const target = saved?.isConnected && panel.contains(saved) ? saved : panel.querySelector("[autofocus], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]") || panel;
if (restore || !panel.contains(this.document.activeElement)) target.focus({ preventScroll: true });
const selection = restore && this.current().entries.get(panel)?.snapshot?.selection;
if (selection?.anchor?.isConnected && selection.focus?.isConnected) this.document.getSelection().setBaseAndExtent(selection.anchor, selection.start, selection.focus, selection.end);
if (restore) for (const item of this.current().entries.get(panel)?.snapshot?.nodes || []) { item.node.scrollTop = item.scrollTop; item.node.scrollLeft = item.scrollLeft; }
}
}
export function positionDropdown(list, anchor, fit = false) {
const rect = anchor.getBoundingClientRect(), viewport = window.visualViewport;
const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
const width = viewport?.width || window.innerWidth, height = viewport?.height || window.innerHeight;
if (rect.right <= left || rect.left >= left + width || rect.bottom <= top || rect.top >= top + height) return false;
const below = top + height - rect.bottom - 12, above = rect.top - top - 12;
const limit = Math.min(280, Math.max(below, above));
if (limit < 20) return false;
const minimum = Math.min(rect.width, width - 16);
Object.assign(list.style, { width: fit ? "max-content" : `${minimum}px`, minWidth: `${minimum}px`, maxWidth: `${width - 16}px`, maxHeight: `${limit}px` });
const size = list.getBoundingClientRect();
Object.assign(list.style, { left: `${Math.max(left + 8, Math.min(rect.left, left + width - size.width - 8))}px`,
top: `${below >= size.height || below >= above ? rect.bottom + 4 : rect.top - size.height - 4}px` });
return true;
}
export function bindDropdownPointer(list, choose) {
list.addEventListener("pointerdown", event => {
if (event.pointerType !== "touch" && event.target.closest("[data-index]")) event.preventDefault();
});
list.addEventListener("mousedown", event => { if (event.target.closest("[data-index]")) event.preventDefault(); });
list.addEventListener("click", event => {
const option = event.target.closest("[data-index]");
if (option) choose(Number(option.dataset.index));
});
}
export class SelectPicker {
constructor(onOpen) {
Object.assign(this, { onOpen, select: null, options: [], active: -1, selected: -1, query: "", queryTime: 0, frame: 0, touchClick: false });
this.list = document.createElement("div");
this.list.className = "dropdown-list";
this.list.setAttribute("popover", "manual");
bindDropdownPointer(this.list, index => this.choose(index));
window.addEventListener("pointerdown", event => {
if (event.button !== 0) return;
this.touchClick = false;
const select = event.target.closest("select");
if (select && !select.matches(":disabled")) event.preventDefault();
if (this.select && event.target !== this.select && !this.list.contains(event.target)) this.close();
}, true);
window.addEventListener("pointerup", event => {
const select = event.target.closest("select");
if (event.pointerType !== "touch" || !select || select.matches(":disabled")) return;
event.preventDefault();
if (this.select === select) this.close();
else this.show(select);
this.touchClick = true;
}, true);
window.addEventListener("mousedown", event => {
if (event.button === 0 && event.target.closest("select")) event.preventDefault();
}, true);
window.addEventListener("click", event => {
const select = event.target.closest("select");
if (!select || select.matches(":disabled")) return;
event.preventDefault();
if (this.touchClick) { this.touchClick = false; return; }
if (this.select === select) this.close();
else this.show(select);
}, true);
window.addEventListener("keydown", event => this.keyDown(event), true);
window.addEventListener("focusin", event => {
if (this.select && event.target !== this.select && !this.list.contains(event.target)) this.close();
});
window.addEventListener("scroll", event => {
if (this.select && !this.list.contains(event.target)) this.position();
}, true);
window.addEventListener("resize", () => this.close());
window.addEventListener("blur", () => this.close());
window.addEventListener("pagehide", () => this.close());
document.addEventListener("close", event => {
if (this.select && event.target.contains(this.select)) this.close();
}, true);
document.addEventListener("reset", event => {
if (this.select?.form === event.target) this.close();
}, true);
document.addEventListener("change", event => {
if (event.target === this.select) this.render();
}, true);
}
show(select) {
this.close();
this.onOpen?.();
select.focus({ preventScroll: true });
if (!select.isConnected || select.matches(":disabled")) return;
this.select = select;
(select.closest("dialog") || document.body).append(this.list);
this.render();
if (!this.select) return;
this.list.showPopover();
this.position();
if (!this.select) return;
this.activate(this.active);
this.observer = new MutationObserver(() => this.render());
this.observer.observe(select, { subtree: true, childList: true, characterData: true, attributes: true });
this.watch();
}
render() {
const select = this.select;
if (!select) return;
const previous = this.options[this.active]?.option, changed = this.selected !== select.selectedIndex;
this.selected = select.selectedIndex;
this.options = [...select.options].filter(option => !option.hidden && !option.parentElement.hidden).map(option => ({
option, disabled: option.disabled || option.parentElement.tagName === "OPTGROUP" && option.parentElement.disabled,
}));
if (!this.options.length) { this.close(); return; }
this.list.replaceChildren();
let group = null;
for (const [index, { option, disabled }] of this.options.entries()) {
if (option.parentElement.tagName === "OPTGROUP" && group !== option.parentElement) {
group = option.parentElement;
const heading = document.createElement("div");
heading.className = "dropdown-group";
heading.textContent = group.label;
this.list.append(heading);
}
const row = document.createElement("div");
row.className = "dropdown-option";
row.dataset.index = String(index);
row.dataset.disabled = String(disabled);
const label = document.createElement("span");
label.textContent = option.label;
row.append(label);
row.title = option.title;
this.list.append(row);
}
const selected = this.options.findIndex(({ option, disabled }) => !disabled && option === (changed ? select.selectedOptions[0] : previous));
this.activate(selected < 0 ? this.options.findIndex(({ disabled }) => !disabled) : selected);
if (this.list.matches(":popover-open")) this.position();
}
watch() {
const select = this.select;
if (!select) return;
if (!select.isConnected || select.matches(":disabled") || !select.getClientRects().length || select.closest("[hidden], [inert], dialog:not([open])")) {
this.close();
return;
}
if (this.selected !== select.selectedIndex) this.render();
if (this.select) this.frame = requestAnimationFrame(() => this.watch());
}
position() {
if (this.select && !positionDropdown(this.list, this.select, true)) this.close();
}
activate(index) {
this.active = index;
for (const row of this.list.querySelectorAll(".dropdown-option")) {
row.dataset.selected = String(Number(row.dataset.index) === index);
if (row.dataset.selected === "true" && this.list.matches(":popover-open")) row.scrollIntoView({ block: "nearest" });
}
}
choose(index) {
const select = this.select, item = this.options[index];
if (!select || select.matches(":disabled") || !item || item.disabled || !select.contains(item.option)) return;
const changed = select.selectedIndex !== item.option.index;
select.selectedIndex = item.option.index;
this.close();
if (changed) {
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
}
}
close() {
if (!this.select) return;
this.observer?.disconnect();
this.observer = null;
cancelAnimationFrame(this.frame);
this.frame = 0;
this.list.hidePopover();
this.list.remove();
this.list.replaceChildren();
this.select = null;
this.options = [];
this.active = this.selected = -1;
this.query = "";
this.queryTime = 0;
}
keyDown(event) {
if (event.defaultPrevented || event.isComposing) return;
this.touchClick = false;
if (event.ctrlKey || event.metaKey || event.key === "Tab") { this.close(); return; }
const select = event.target.closest("select");
if (!select || select.matches(":disabled") || event.altKey && !["ArrowDown", "ArrowUp"].includes(event.key)) return;
const key = event.key, open = this.select === select;
if (key === "Escape") {
if (!open) return;
this.close();
} else if (key === "F4" || event.altKey) {
if (open) this.close();
else this.show(select);
} else if (["Enter", " "].includes(key)) {
if (open) this.choose(this.active);
else this.show(select);
} else if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(key)) {
if (!open) this.show(select);
if (this.select && (open || !["ArrowDown", "ArrowUp"].includes(key))) {
const available = this.options.flatMap(({ disabled }, index) => disabled ? [] : [index]);
const at = available.indexOf(this.active), direction = ["ArrowUp", "PageUp"].includes(key) ? -1 : 1;
const step = key.startsWith("Page") ? Math.max(1, Math.floor(this.list.clientHeight / (this.list.querySelector(".dropdown-option")?.offsetHeight || 32)) - 1) : 1;
const next = key === "Home" ? 0 : key === "End" ? available.length - 1 : Math.max(0, Math.min(available.length - 1, at + direction * step));
this.activate(available[next] ?? -1);
}
} else if (key.length === 1) {
if (!open) this.show(select);
const now = performance.now();
this.query = (now - this.queryTime < 700 ? this.query : "") + key.toLocaleLowerCase();
this.queryTime = now;
const repeated = [...this.query].every(value => value === this.query[0]), query = repeated ? this.query[0] : this.query;
const start = repeated ? this.active + 1 : Math.max(0, this.active);
for (let offset = 0; offset < this.options.length; offset++) {
const index = (start + offset) % this.options.length, item = this.options[index];
if (!item.disabled && item.option.label.trim().toLocaleLowerCase().startsWith(query)) { this.activate(index); break; }
}
} else return;
event.preventDefault();
event.stopImmediatePropagation();
}
}
File diff suppressed because it is too large. Load diff
+159 -59
View File
@@ -1,11 +1,15 @@
importScripts("./wasm_exec.js"); importScripts("./wasm_exec.js");
let pending = Promise.resolve(); const engines = new Map();
const operations = new Map(); let resolveRuntime;
const fontRequests = new Map(); let rejectRuntime;
const runtimeReady = new Promise((resolve, reject) => { resolveRuntime = resolve; rejectRuntime = reject; });
runtimeReady.catch(() => {});
let fontSequence = 0; let fontSequence = 0;
let engineMemory = null;
function requestFont(id, signal, name, done) { function requestFont(engine, id, signal, name, done) {
const fontRequests = engine.fontRequests;
if (signal.aborted) { done(null, "", true); return; } if (signal.aborted) { done(null, "", true); return; }
const request = ++fontSequence; const request = ++fontSequence;
const cancel = () => { const cancel = () => {
@@ -17,23 +21,79 @@ function requestFont(id, signal, name, done) {
signal.removeEventListener("abort", cancel); signal.removeEventListener("abort", cancel);
done(result.bytes, result.error || "", signal.aborted); done(result.bytes, result.error || "", signal.aborted);
}); });
self.postMessage({ id, type: "font", request, name }); self.postMessage({ engineID: engine.id, id, type: "font", request, name });
} }
self.onmessage = ({ data }) => { self.onmessage = ({ data }) => {
if (data.type === "module") {
if (data.error) exitWASM(new Error(data.error));
else loadWASM(data.module).catch(exitWASM);
return;
}
const engineID = data.engineID;
if (typeof engineID !== "string" || !engineID) {
self.postMessage({ engineID, id: data.id, ok: false, error: "missing engine ID" });
return;
}
if (data.type === "open") {
if (engines.has(engineID)) {
self.postMessage({ engineID, type: "exit", error: "engine already exists" });
return;
}
const engine = { id: engineID, operations: new Map(), fontRequests: new Map(), active: new Set(), closed: false };
engines.set(engineID, engine);
engine.pending = runtimeReady.then(async () => {
if (engine.closed) return;
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
if (!opened.ok) throw new Error(opened.error);
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends", null));
if (!backends.ok) throw new Error(backends.error);
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
}).catch(error => {
engine.closed = true;
self.postMessage({ engineID, type: "exit", error: error.message });
});
return;
}
const engine = engines.get(engineID);
if (!engine && data.type === "close") {
self.postMessage({ engineID, type: "closed" });
return;
}
if (!engine || engine.closed && data.type !== "close") {
self.postMessage({ engineID, id: data.id, ok: false, error: "unknown or closed engine" });
return;
}
if (data.type === "close") {
engine.closed = true;
for (const operation of engine.operations.values()) operation.abort();
engine.pending.then(async () => {
await Promise.all([...engine.active]);
await runtimeReady;
globalThis.ofdgoEngineClose(engineID);
engines.delete(engineID);
self.postMessage({ engineID, type: "closed" });
}).catch(() => engines.delete(engineID));
return;
}
if (data.type === "font") { if (data.type === "font") {
fontRequests.get(data.request)?.(data); engine.fontRequests.get(data.request)?.(data);
fontRequests.delete(data.request); engine.fontRequests.delete(data.request);
return; return;
} }
if (data.type === "cancel") { if (data.type === "cancel") {
operations.get(data.id)?.abort(); engine.operations.get(data.id)?.abort();
return; return;
} }
if (data.name === "ofdgoResources") operations.set(data.id, new AbortController()); engine.operations.set(data.id, new AbortController());
if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportResource" || data.name === "ofdgoExportMedia" || data.name === "ofdgoExportVersion" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { engine.pending = engine.pending.then(() => {
operations.set(data.id, new AbortController()); const operation = (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(engine, data) : handleMessage(engine, data)).catch(error => {
} engine.operations.delete(data.id);
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); self.postMessage({ engineID, id: data.id, ok: false, error: error.message });
});
engine.active.add(operation);
operation.finally(() => engine.active.delete(operation));
if (data.name !== "ofdgoSaveSnapshot") return operation;
});
}; };
async function availableOutput(directory, base, format) { async function availableOutput(directory, base, format) {
@@ -49,7 +109,9 @@ async function availableOutput(directory, base, format) {
} }
} }
async function handleBatchMessage({ id, name, args }) { async function handleBatchMessage(engine, { id, name, args }) {
const operations = engine.operations;
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
const signal = operations.get(id).signal; const signal = operations.get(id).signal;
const channel = new MessageChannel(); const channel = new MessageChannel();
const finish = (done, err) => { const finish = (done, err) => {
@@ -79,17 +141,20 @@ async function handleBatchMessage({ id, name, args }) {
if (name === "ofdgoConvertFile") { if (name === "ofdgoConvertFile") {
const [file, options, fonts, destination] = args; const [file, options, fonts, destination] = args;
credentials = options.credentials; credentials = options.credentials;
const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data; const formats = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoOutputFormats", null)).data;
const format = formats.find(item => item.value === options.format); const format = formats.find(item => item.value === options.format);
if (!format) throw new Error("不支持此格式"); if (!format) throw new Error("不支持此格式");
root = destination; root = destination;
const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document"; const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
const target = root ? await availableOutput(root, base, format) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` }; const target = root ? await navigator.locks.request("ofdgo-convert-output", { signal }, async () => {
if (root && format.paged) { signal.throwIfAborted();
directory = await root.getDirectoryHandle(target.name, { create: true }); const target = await availableOutput(root, base, format);
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
else { await root.getFileHandle(target.name, { create: true }); directory = root; }
created = target.name; created = target.name;
self.postMessage({ id, type: "output", name: created }); postMessage({ id, type: "output", name: created });
} else directory = root; return target;
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
const boundary = async (action, pageName) => { const boundary = async (action, pageName) => {
signal.throwIfAborted(); signal.throwIfAborted();
if (action === "open") { if (action === "open") {
@@ -100,7 +165,7 @@ async function handleBatchMessage({ id, name, args }) {
const handle = await directory.getFileHandle(fileName, { create: true }); const handle = await directory.getFileHandle(fileName, { create: true });
if (!format.paged) { if (!format.paged) {
created = fileName; created = fileName;
self.postMessage({ id, type: "output", name: created }); postMessage({ id, type: "output", name: created });
} }
current.handle = handle; current.handle = handle;
output = await handle.createWritable(); output = await handle.createWritable();
@@ -113,19 +178,19 @@ async function handleBatchMessage({ id, name, args }) {
chunks = []; chunks = [];
} }
}; };
payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write, payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoConvertFile", done => finish(done), read(file), file.size, options, write,
(phase, completed, total, done) => { (phase, completed, total, done) => {
self.postMessage({ id, type: "conversion", phase, completed, total }); postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
finish(done); finish(done);
}, },
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)), (action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done)); done => finish(done), fonts, (name, done) => requestFont(engine, id, signal, name, done));
} else { } else {
const [entries, handle, compression] = args; const [entries, handle, compression] = args;
if (handle) output = await handle.createWritable(); if (handle) output = await handle.createWritable();
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })), payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPackFiles", done => finish(done), entries.map(entry => ({ name: entry.name, size: entry.file.size })),
index => read(entries[index].file), write, index => read(entries[index].file), write,
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); }, (completed, total, done) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); },
done => finish(done), compression); done => finish(done), compression);
} }
signal.throwIfAborted(); signal.throwIfAborted();
@@ -144,7 +209,7 @@ async function handleBatchMessage({ id, name, args }) {
} }
result.data.size = size; result.data.size = size;
operations.delete(id); operations.delete(id);
self.postMessage({ id, ...result }); postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
} catch (err) { } catch (err) {
await output?.abort().catch(() => {}); await output?.abort().catch(() => {});
let message = signal.aborted ? "转换已取消" : err.message; let message = signal.aborted ? "转换已取消" : err.message;
@@ -152,7 +217,7 @@ async function handleBatchMessage({ id, name, args }) {
try { await root.removeEntry(created, { recursive: true }); } try { await root.removeEntry(created, { recursive: true }); }
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; } catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
} }
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted }); postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
} finally { } finally {
credentials?.password?.fill(0); credentials?.password?.fill(0);
credentials?.key?.fill(0); credentials?.key?.fill(0);
@@ -162,19 +227,32 @@ async function handleBatchMessage({ id, name, args }) {
operations.delete(id); operations.delete(id);
} }
} }
async function handleMessage({ id, name, args }) { async function handleMessage(engine, { id, name, args }) {
const operations = engine.operations;
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
let output; let output;
let saveFile;
let saveStamp;
let channel; let channel;
const signal = operations.get(id)?.signal; const signal = operations.get(id)?.signal;
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; 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 converting = name === "ofdgoConvertPDF"; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned"; const converting = name === "ofdgoOpenPDF";
const resources = name === "ofdgoResources"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name);
if (engine.pdf) {
const checked = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoCheckDocument", null, name));
if (!checked.ok) { postMessage({id, ...checked}); operations.delete(id); return; }
}
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null; const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null; const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
try { try {
signal?.throwIfAborted(); signal?.throwIfAborted();
if (["ofdgoOpen", "ofdgoOpenPDF", "ofdgoLoadImport"].includes(name) && args[0] instanceof Blob) {
const file = args[0], reader = new FileReaderSync();
args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))};
}
if (name === "ofdgoDetectFormat") { if (name === "ofdgoDetectFormat") {
const file = args[0], reader = new FileReaderSync(); const file = args[0], reader = new FileReaderSync();
channel = new MessageChannel(); channel = new MessageChannel();
@@ -203,7 +281,7 @@ async function handleMessage({ id, name, args }) {
} }
const chunks = []; const chunks = [];
let size = 0; let size = 0;
if (signal) { if (cancellable) {
channel = new MessageChannel(); channel = new MessageChannel();
const finish = (done, err, commit = false) => { const finish = (done, err, commit = false) => {
channel.port1.onmessage = () => { channel.port1.onmessage = () => {
@@ -216,12 +294,15 @@ async function handleMessage({ id, name, args }) {
args.push(done => finish(done)); args.push(done => finish(done));
} else if (importing || converting) { } else if (importing || converting) {
args.push((phase, completed, total, done) => { args.push((phase, completed, total, done) => {
self.postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total }); postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total });
finish(done, null, importing && phase === "commit"); finish(done, null, importing && phase === "commit");
}); });
if (converting) args.push((name, done) => requestFont(id, signal, name, done)); if (converting) args.push((name, done) => requestFont(engine, id, signal, name, done));
} else { } else {
const file = args.pop(); const destination = args.pop();
const file = destination?.fileHandle || destination;
saveFile = file;
saveStamp = destination?.saveStamp;
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null; const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
const indices = saving ? args.shift() : null; const indices = saving ? args.shift() : null;
if (file) output = await file.createWritable(); if (file) output = await file.createWritable();
@@ -241,33 +322,55 @@ async function handleMessage({ id, name, args }) {
}); });
if (name === "ofdgoExportDocument") { if (name === "ofdgoExportDocument") {
args.push((completed, total, done) => { args.push((completed, total, done) => {
self.postMessage({ id, type: "export", stage: "pages", completed, total }); postMessage({ id, type: "export", stage: "pages", completed, total });
finish(done); finish(done);
}); });
} else if (name === "ofdgoExportVersion") { } else if (name === "ofdgoExportVersion") {
args.push((phase, completed, total, done) => { args.push((phase, completed, total, done) => {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done); finish(done);
}, done => finish(done)); }, done => finish(done));
} else if (saving) { } else if (saving) {
args.push((phase, completed, total, done) => { args.push((phase, completed, total, done) => {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done); finish(done);
}); });
if (compression) args.push(indices, compression, done => finish(done)); if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done));
else if (indices != null) args.push(indices); else if (indices != null) args.push(indices);
} }
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done)); if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
} }
} }
const payload = await globalThis[name](...args); if (engine.pdf && name !== "ofdgoOpenPDF") {
channel ||= new MessageChannel();
const context = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPDFContext", null, (phase, completed, total, done) => {
postMessage({id, type: "conversion", phase, completed, total});
channel.port1.onmessage = () => done("", signal.aborted);
channel.port2.postMessage(null);
}, (name, done) => requestFont(engine, id, signal, name, done));
engine.pdf = JSON.parse(context).data === true;
}
const started = name === "ofdgoOpen" || name === "ofdgoRenderPage" ? performance.now() : null;
const payload = await globalThis.ofdgoEngineCall(engine.id, name, done => {
channel ||= new MessageChannel();
channel.port1.onmessage = () => done("", signal.aborted);
channel.port2.postMessage(null);
}, ...args);
signal?.throwIfAborted(); signal?.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload; const result = typeof payload === "string" ? JSON.parse(payload) : payload;
if (signal && !importing && !converting && !resources && result.ok) { if (result.ok && name === "ofdgoOpenPDF") engine.pdf = true;
operations.delete(id); if (result.ok && (name === "ofdgoOpen" || name === "ofdgoLoadPDF" && result.data.done)) engine.pdf = false;
if (started !== null && result.ok) result.elapsed = performance.now() - started;
if (cancellable && !importing && !converting && !resources && result.ok) {
result.data.size = size; result.data.size = size;
if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" }); if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
if (output) { if (output) {
if (saveStamp) {
const current = await saveFile.getFile();
if (current.size !== saveStamp.size || current.lastModified !== saveStamp.modified) throw new Error("文件已被修改,请另存");
}
signal.throwIfAborted();
operations.delete(id);
await output.close(); await output.close();
} else { } else {
result.data.blob = new Blob(chunks, { type: result.data.mime }); result.data.blob = new Blob(chunks, { type: result.data.mime });
@@ -276,16 +379,16 @@ async function handleMessage({ id, name, args }) {
await output.abort(); await output.abort();
} }
output = null; output = null;
const transfer = result.data?.bytes ? [result.data.bytes.buffer] : (result.data?.images || []).map((image) => image.bytes.buffer); const transfer = [...new Set([result.data?.bytes, ...(result.data?.images || []).map(image => image.bytes), ...(result.data?.fonts || []).map(font => font.data)].filter(bytes => bytes?.byteLength).map(bytes => bytes.buffer))];
self.postMessage({ id, ...result }, transfer); postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer);
} catch (err) { } catch (err) {
await output?.abort().catch(() => {}); await output?.abort().catch(() => {});
const result = { id, ok: false, error: err.message }; const result = { id, ok: false, error: err.message, errorName: err.name };
if (signal?.aborted) { if (signal?.aborted) {
result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消"; result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : cancellable ? "导出已取消" : "操作已取消";
result.canceled = true; result.canceled = true;
} }
self.postMessage(result); postMessage(result);
} finally { } finally {
password?.fill(0); password?.fill(0);
key?.fill(0); key?.fill(0);
@@ -297,23 +400,20 @@ async function handleMessage({ id, name, args }) {
} }
function exitWASM(err) { function exitWASM(err) {
rejectRuntime(err || new Error("引擎运行中断"));
self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" }); self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" });
self.close(); self.close();
} }
async function loadWASM() { async function loadWASM(module) {
const go = new Go(); const go = new Go();
self.postMessage({ type: "progress", phase: "download", text: "正在下载引擎", percent: 16 }); const instance = await WebAssembly.instantiate(module, go.importObject);
const response = await fetch("./ofdgo.wasm"); engineMemory = instance.exports.mem;
self.postMessage({ type: "progress", phase: "compile", text: "正在编译引擎", percent: 35 });
const { instance } = await WebAssembly.instantiateStreaming(response, go.importObject);
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 }); self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
go.run(instance).then(exitWASM, exitWASM); go.run(instance).then(exitWASM, exitWASM);
if (!go.exited) { if (!go.exited) {
const backends = JSON.parse(globalThis.ofdgoRenderBackends()); resolveRuntime();
if (!backends.ok) throw new Error(backends.error);
self.postMessage({ type: "ready", backends: backends.data });
} }
} }
loadWASM().catch(exitWASM); self.postMessage({ type: "module" });
+5 -2
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.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"] const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_print.js", "ofdgo_theme.js", "ofdgo_util.js", "ofdgo_view.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
.map((path) => new URL(path, self.registration.scope).href); .map((path) => new URL(path, self.registration.scope).href);
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href; const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
@@ -92,6 +92,8 @@ async function readResource(event, navigation) {
bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId); bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId);
} }
if (!bundle || !await caches.has(bundle.name)) { if (!bundle || !await caches.has(bundle.name)) {
const client = await self.clients.get(event.clientId);
if (client && resourceURL(client.url) !== self.registration.scope) return fetch(event.request);
return Response.error(); return Response.error();
} }
if (event.resultingClientId) { if (event.resultingClientId) {
@@ -106,8 +108,9 @@ async function readResource(event, navigation) {
} }
async function updateBundle(meta) { async function updateBundle(meta) {
const signal = AbortSignal.timeout(120000);
const resources = await Promise.all(ASSETS.map(async (url) => { const resources = await Promise.all(ASSETS.map(async (url) => {
const response = await fetch(new Request(url, { cache: "no-store" })); const response = await fetch(new Request(url, { cache: "no-store", signal }));
if (!response.ok || response.redirected) { if (!response.ok || response.redirected) {
throw new Error("资源下载失败"); throw new Error("资源下载失败");
} }
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package wasmassets
import "embed"
// FS WebUI嵌入WASM引擎
//
//go:embed ofdgo.wasm
var FS embed.FS
+18
View File
@@ -0,0 +1,18 @@
setlocal
set Version=
for /f %%V in ('git show -s --format^=%%ct-%%H HEAD') do set Version=%%V
set GOOS=linux
set GOARCH=arm64
set CGO_ENABLED=0
go build -o releases/ofdgo_linux_arm64 -trimpath -ldflags "-s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=%Version%" ./cmd/webview
set GOOS=linux
set GOARCH=amd64
set CGO_ENABLED=0
go build -o releases/ofdgo_linux_amd64 -trimpath -ldflags "-s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=%Version%" ./cmd/webview
set GOOS=windows
set GOARCH=amd64
set CGO_ENABLED=0
go build -o releases/ofdgo_windows_amd64.exe -trimpath -ldflags "-H windowsgui -s -w -buildid= -X github.com/xiaoqidun/ofdgo/internal/build.Version=%Version%" ./cmd/webview
+26 -1
View File
@@ -18,6 +18,9 @@ package main
import ( import (
"bytes" "bytes"
"crypto/sha256"
"embed"
"encoding/hex"
"flag" "flag"
"fmt" "fmt"
"net/http" "net/http"
@@ -25,6 +28,8 @@ import (
"time" "time"
"github.com/xiaoqidun/ofdgo/assets/webui" "github.com/xiaoqidun/ofdgo/assets/webui"
"github.com/xiaoqidun/ofdgo/assets/webui/wasm"
"github.com/xiaoqidun/ofdgo/internal/build"
) )
func main() { func main() {
@@ -39,12 +44,18 @@ func main() {
// 返回: http.Handler HTTP处理器 // 返回: http.Handler HTTP处理器
func serveWebUI() http.Handler { func serveWebUI() http.Handler {
files := http.FileServerFS(webuiassets.FS) files := http.FileServerFS(webuiassets.FS)
checksum := webuiassets.Checksum() wasm := http.FileServerFS(wasmassets.FS)
checksum := resourceChecksum()
serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo_work.js") serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo_work.js")
serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...) serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-OFDGo-Checksum", checksum) w.Header().Set("X-OFDGo-Checksum", checksum)
w.Header().Set("X-OFDGo-Build-Version", build.Version)
if r.URL.Path == "/ofdgo.wasm" {
wasm.ServeHTTP(w, r)
return
}
if r.URL.Path == "/ofdgo_work.js" { if r.URL.Path == "/ofdgo_work.js" {
http.ServeContent(w, r, "ofdgo_work.js", time.Time{}, bytes.NewReader(serviceWorker)) http.ServeContent(w, r, "ofdgo_work.js", time.Time{}, bytes.NewReader(serviceWorker))
return return
@@ -52,3 +63,17 @@ func serveWebUI() http.Handler {
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
}) })
} }
// resourceChecksum 计算WebUI界面与WASM引擎的资源校验值
// 返回: string SHA-256十六进制校验值
func resourceChecksum() string {
hash := sha256.New()
for _, resources := range []embed.FS{webuiassets.FS, wasmassets.FS} {
entries, _ := resources.ReadDir(".")
for _, entry := range entries {
data, _ := resources.ReadFile(entry.Name())
hash.Write(data)
}
}
return hex.EncodeToString(hash.Sum(nil))
}
+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
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build linux && (amd64 || arm64)
package main
import (
"context"
"fmt"
"os"
"github.com/xiaoqidun/ofdgo/internal/webview"
)
func main() {
paths, dataDir := commandLine()
if err := webview.Run(context.Background(), paths, dataDir); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !(windows && amd64) && !(linux && (amd64 || arm64))
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "OFDGo WebView Unsupported")
os.Exit(1)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows && amd64
package main
import (
"context"
"fmt"
"os"
"syscall"
"unsafe"
"github.com/xiaoqidun/ofdgo/internal/webview"
)
func main() {
paths, dataDir := commandLine()
if err := webview.Run(context.Background(), paths, dataDir); err != nil {
fmt.Fprintln(os.Stderr, err)
message, _ := syscall.UTF16PtrFromString(err.Error())
title, _ := syscall.UTF16PtrFromString("OFDGo")
syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10)
os.Exit(1)
}
}
Binary file not shown.
+8 -6
View File
@@ -3,16 +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.2
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/j2kgo v1.0.1 github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f
github.com/xiaoqidun/jbig2 v1.1.7 github.com/xiaoqidun/j2kgo v1.0.2
github.com/xiaoqidun/pdfgo v0.0.0-20261015160218-fa5114475eb5 github.com/xiaoqidun/jbig2 v1.1.8
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0
golang.org/x/crypto v0.58.0 golang.org/x/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
) )
@@ -25,6 +27,7 @@ require (
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
github.com/benoitkugler/textlayout v0.3.2 // indirect github.com/benoitkugler/textlayout v0.3.2 // indirect
github.com/benoitkugler/textprocessing v0.0.6 // indirect github.com/benoitkugler/textprocessing v0.0.6 // indirect
github.com/ebitengine/purego v0.11.2 // indirect
github.com/go-fonts/latin-modern v0.3.3 // indirect github.com/go-fonts/latin-modern v0.3.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
@@ -33,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
+14 -10
View File
@@ -20,16 +20,18 @@ github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic= github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE= github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE= github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w= github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo= github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g= github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A= github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
github.com/emmansun/gmsm v0.45.0 h1:PU/6ObyR3g05YN3jn1L5GZDowzyAi8Xhkq4svzYNyiA= github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/emmansun/gmsm v0.45.0/go.mod h1:HgLSvmdePGLo+Fo2PNgdTQU6lqAepSyriSQJRpECY6w= github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/emmansun/gmsm v0.45.2 h1:GUqMkdHDrEvxqcRtIfhkyqMzgwtiQsnCVYtioOdYlQc=
github.com/emmansun/gmsm v0.45.2/go.mod h1:RfnfrzyB9XbDi73nF1NHbJeqJj+a6pg/pXr5lrZKMok=
github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0= github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY= github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
@@ -58,12 +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/j2kgo v1.0.1 h1:W0SytzjvgiOhXwq+JNRSVKLB847fjGOvGWTNKqmwvo0= github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f h1:wek1tEiTjf0i5eT6w6ILJLEPwdXP+TaH3qtRBOO1xeI=
github.com/xiaoqidun/j2kgo v1.0.1/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f/go.mod h1:bXz+dPMVntPIVwePTTuMXt8Q0z1YoUGuROQSsXyUEfY=
github.com/xiaoqidun/jbig2 v1.1.7 h1:118d2lQn2ZsupmXazK+2p23Jp5xQ09btBmPUoYoa/+Y= github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
github.com/xiaoqidun/jbig2 v1.1.7/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA= github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/pdfgo v0.0.0-20261015160218-fa5114475eb5 h1:eJuai2A2q9R8Mo/oKXiIpNc41uzolkKplPWXUmYiHcU= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
github.com/xiaoqidun/pdfgo v0.0.0-20261015160218-fa5114475eb5/go.mod h1:FUQ8+TfNoec6tZCdsckx+rrD25JACm7EzuZyFH37QHg= github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU=
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 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=
+232
View File
@@ -0,0 +1,232 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"os"
"path/filepath"
"sync"
)
// ErrChanged 表示目标文件已被其他操作修改
var ErrChanged = errors.New("file changed since opening")
// ErrBusy 表示目标文件正在写入
var ErrBusy = errors.New("file save is in progress")
// activeFiles 协调当前进程内的目标写入
var activeFiles sync.Map
// Target 保存文件路径及最近确认的文件状态,可跨次写入复用
type Target struct {
mu sync.Mutex
name string
info os.FileInfo
}
// Writer 写入同目录临时文件,提交前检查取消及目标冲突
type Writer struct {
mu sync.Mutex
target *Target
file *os.File
ctx context.Context
err error
closed bool
}
// NewTemp 创建私有输入缓存文件,调用方负责关闭和删除
// 入参: directory 缓存目录,空值使用系统临时目录
// 返回: *os.File 缓存文件, error 创建错误
func NewTemp(directory string) (*os.File, error) {
if directory == "" {
directory = os.TempDir()
}
for range 100 {
n, err := rand.Int(rand.Reader, big.NewInt(10000000))
if err != nil {
return nil, err
}
name := filepath.Join(directory, fmt.Sprintf("ofdgo-%07d.tmp", n.Int64()))
file, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if errors.Is(err, os.ErrExist) {
continue
}
return file, err
}
return nil, os.ErrExist
}
// NewTarget 记录目标当前状态,允许目标尚不存在
// 入参: name 目标路径
// 返回: *Target 保存目标, error 路径或文件错误
func NewTarget(name string) (*Target, error) {
if name == "" {
return nil, os.ErrInvalid
}
name, err := filepath.Abs(name)
if err != nil {
return nil, err
}
info, err := os.Lstat(name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if info != nil && !info.Mode().IsRegular() {
return nil, fmt.Errorf("save target is not a regular file")
}
return &Target{name: name, info: info}, nil
}
// check 检查目标是否保持最近确认的状态
// 返回: error 文件状态错误
func (t *Target) check() error {
info, err := os.Lstat(t.name)
if errors.Is(err, os.ErrNotExist) {
if t.info == nil {
return nil
}
return ErrChanged
}
if err != nil {
return err
}
if t.info == nil || !os.SameFile(t.info, info) || t.info.Size() != info.Size() || !t.info.ModTime().Equal(info.ModTime()) {
return ErrChanged
}
if info.Mode().Perm()&0222 == 0 {
return os.ErrPermission
}
return nil
}
// Begin 创建独占写入事务,调用方必须提交或终止
// 入参: ctx 取消上下文
// 返回: *Writer 事务, error 创建错误
func (t *Target) Begin(ctx context.Context) (*Writer, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
t.mu.Lock()
defer t.mu.Unlock()
if _, loaded := activeFiles.LoadOrStore(t.name, true); loaded {
return nil, ErrBusy
}
if err := t.check(); err != nil {
activeFiles.Delete(t.name)
return nil, err
}
for range 100 {
n, err := rand.Int(rand.Reader, big.NewInt(10000000))
if err != nil {
activeFiles.Delete(t.name)
return nil, err
}
name := fmt.Sprintf("%s.%07d.tmp", t.name, n.Int64())
f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
activeFiles.Delete(t.name)
return nil, err
}
return &Writer{target: t, file: f, ctx: ctx}, nil
}
activeFiles.Delete(t.name)
return nil, os.ErrExist
}
// Write 追加数据并记录写入错误
// 入参: data 数据
// 返回: int 写入长度, error 写入错误
func (w *Writer) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0, os.ErrClosed
}
if w.err == nil {
w.err = w.ctx.Err()
}
if w.err != nil {
return 0, w.err
}
n, err := w.file.Write(data)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
w.err = err
return n, err
}
// Commit 完成写入并替换目标,成功后刷新目标状态
// 返回: error 提交错误
func (w *Writer) Commit() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return os.ErrClosed
}
w.closed = true
defer activeFiles.Delete(w.target.name)
defer os.Remove(w.file.Name())
w.target.mu.Lock()
defer w.target.mu.Unlock()
if err := errors.Join(w.err, w.ctx.Err(), w.target.check()); err != nil {
return errors.Join(err, w.file.Close())
}
if w.target.info != nil {
if err := w.file.Chmod(w.target.info.Mode().Perm()); err != nil {
return errors.Join(err, w.file.Close())
}
}
if err := w.file.Sync(); err != nil {
return errors.Join(err, w.file.Close())
}
info, err := w.file.Stat()
if err = errors.Join(err, w.file.Close(), w.ctx.Err()); err != nil {
return err
}
if err := w.target.check(); err != nil {
return err
}
if err := Replace(w.file.Name(), w.target.name); err != nil {
return err
}
w.target.info = info
if current, err := os.Stat(w.target.name); err == nil {
w.target.info = current
}
return nil
}
// Abort 丢弃临时输出,不改变目标
// 返回: error 清理错误
func (w *Writer) Abort() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
w.closed = true
defer activeFiles.Delete(w.target.name)
return errors.Join(w.file.Close(), os.Remove(w.file.Name()))
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !windows
package file
import "os"
// Open 打开文件,路径替换后已有读取句柄仍有效
// 入参: name 文件路径
// 返回: *os.File 文件, error 打开错误
func Open(name string) (*os.File, error) {
return os.Open(name)
}
// Replace 替换目标路径
// 入参: source 临时路径, target 目标路径
// 返回: error 替换错误
func Replace(source, target string) error {
return os.Rename(source, target)
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package file
import (
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
)
// replaceFileProc 保留目标文件属性并替换文件内容
var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW")
// Open 保留替换共享权限,路径替换后已有读取句柄仍有效
// 入参: name 文件路径
// 返回: *os.File 文件, error 打开错误
func Open(name string) (*os.File, error) {
path, err := nativePath(name)
if err != nil {
return nil, err
}
handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: name, Err: err}
}
return os.NewFile(uintptr(handle), name), nil
}
// Replace 替换目标,目标不存在时重命名
// 入参: source 临时路径, target 目标路径
// 返回: error 替换错误
func Replace(source, target string) error {
from, err := nativePath(source)
if err != nil {
return err
}
to, err := nativePath(target)
if err != nil {
return err
}
result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0)
if result != 0 {
return nil
}
if err == syscall.ERROR_FILE_NOT_FOUND {
return os.Rename(source, target)
}
return &os.LinkError{Op: "replace", Old: source, New: target, Err: err}
}
// nativePath 转换绝对长路径
// 入参: name 路径
// 返回: *uint16 原生路径, error 转换错误
func nativePath(name string) (*uint16, error) {
path, err := filepath.Abs(name)
if err != nil {
return nil, err
}
if !strings.HasPrefix(path, `\\?\`) {
if strings.HasPrefix(path, `\\`) {
path = `\\?\UNC\` + path[2:]
} else {
path = `\\?\` + path
}
}
return syscall.UTF16PtrFromString(path)
}
+83 -52
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}
@@ -260,6 +268,21 @@ func SanitizeCFF(data []byte) ([]byte, error) {
if kind == 1 { if kind == 1 {
return normalizeCFFType1(data, topDict) return normalizeCFFType1(data, topDict)
} }
private, err := readType2Private(data, topDict[18], &type2Font{})
if err != nil {
return nil, err
}
count := len(private)
if err := normalizeCFFPrivate(private); err != nil {
return nil, err
}
if len(private) != count {
font, err := readType2Font(data)
if err != nil {
return nil, err
}
return rebuildNamedCFF(data, font, font.chars, nil)
}
return data, nil return data, nil
} }
fdArrOff, err := cffDictOffset(data, topDict, 1236) fdArrOff, err := cffDictOffset(data, topDict, 1236)
@@ -316,10 +339,9 @@ func SanitizeCFF(data []byte) ([]byte, error) {
localSubrData = data[start:end] localSubrData = data[start:end]
} }
charStringsData := data[charStringsOff:charEnd] charStringsData := data[charStringsOff:charEnd]
delete(topDict, 1230) for op := 1230; op <= 1238; op++ {
delete(topDict, 1236) delete(topDict, op)
delete(topDict, 1237) }
delete(topDict, 1234)
delete(topDict, 15) delete(topDict, 15)
delete(topDict, 16) delete(topDict, 16)
var finalPrivData []byte var finalPrivData []byte
@@ -342,17 +364,21 @@ func SanitizeCFF(data []byte) ([]byte, error) {
return encodeSanitizedCFF(data[:hdrSize], nameIndexData, topDict, stringIndexData, globalSubrIndexData, charStringsData, finalPrivData, localSubrData), nil return encodeSanitizedCFF(data[:hdrSize], nameIndexData, topDict, stringIndexData, globalSubrIndexData, charStringsData, finalPrivData, localSubrData), nil
} }
// normalizeCFFPrivate 移除旧版CFF中不生效的缺省字段,拒绝需要额外解释的取值 // normalizeCFFPrivate 移除废弃的加粗阈值及未加密标记,拒绝加密字形程序
// 入参: dict 私有字典 // 入参: dict 私有字典
// 返回: error 不支持的旧版行为 // 返回: error 不支持的旧版行为
func normalizeCFFPrivate(dict cffDict) error { func normalizeCFFPrivate(dict cffDict) error {
for op, value := range map[int]float64{1215: 0, 1216: -1} { if values, ok := dict[1215]; ok {
if values, ok := dict[op]; ok { if len(values) != 1 || !finite(values[0]) {
if len(values) != 1 || values[0] != value { return fmt.Errorf("invalid legacy CFF bold threshold")
return fmt.Errorf("unsupported legacy CFF private operator %d", op)
} }
delete(dict, op) delete(dict, 1215)
} }
if values, ok := dict[1216]; ok {
if len(values) != 1 || values[0] != -1 {
return fmt.Errorf("unsupported legacy CFF private operator 1216")
}
delete(dict, 1216)
} }
return nil return nil
} }
@@ -475,10 +501,9 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict
} }
inlined[gid] = output.Bytes() inlined[gid] = output.Bytes()
} }
delete(topDict, 1230) for op := 1230; op <= 1238; op++ {
delete(topDict, 1236) delete(topDict, op)
delete(topDict, 1237) }
delete(topDict, 1234)
delete(topDict, 15) delete(topDict, 15)
delete(topDict, 16) delete(topDict, 16)
delete(topDict, 1206) delete(topDict, 1206)
@@ -826,10 +851,8 @@ func encodeNumberCFF(buf *bytes.Buffer, val float64) {
// 返回: []byte 编码后的索引数据 // 返回: []byte 编码后的索引数据
func encodeCFFIndex(items []([]byte)) []byte { func encodeCFFIndex(items []([]byte)) []byte {
count := len(items) count := len(items)
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint16(count))
if count == 0 { if count == 0 {
return buf.Bytes() return []byte{0, 0}
} }
totalSize := 0 totalSize := 0
for _, item := range items { for _, item := range items {
@@ -845,25 +868,21 @@ func encodeCFFIndex(items []([]byte)) []byte {
if totalSize+1 > 16777215 { if totalSize+1 > 16777215 {
offSize = 4 offSize = 4
} }
buf.WriteByte(byte(offSize)) base := 3 + (count+1)*offSize
data := make([]byte, base+totalSize)
binary.BigEndian.PutUint16(data, uint16(count))
data[2] = byte(offSize)
offset := 1 offset := 1
putOffset(buf, offset, offSize) for i := 0; i <= count; i++ {
for _, item := range items { for j := 0; j < offSize; j++ {
offset += len(item) data[3+i*offSize+offSize-1-j] = byte(offset >> (8 * j))
putOffset(buf, offset, offSize)
} }
for _, item := range items { if i < count {
buf.Write(item) copy(data[base+offset-1:], items[i])
offset += len(items[i])
} }
return buf.Bytes()
} }
return data
// putOffset 写入指定大小的偏移量
// 入参: buf 缓冲区, val 偏移值, size 字节大小
func putOffset(buf *bytes.Buffer, val int, size int) {
tmp := make([]byte, 4)
binary.BigEndian.PutUint32(tmp, uint32(val))
buf.Write(tmp[4-size:])
} }
// getCFFIndexCount 读取CFF索引的计数和大小 // getCFFIndexCount 读取CFF索引的计数和大小
@@ -1042,6 +1061,9 @@ func getCFFCharsetInfo(data []byte, numGlyphs int) ([]int, string, string, int,
if topDictData == nil { if topDictData == nil {
return nil, "", "", 0, false return nil, "", "", 0, false
} }
if _, _, _, err := scanCFFIndex(data, stringIndexOff); err != nil {
return nil, "", "", 0, false
}
td := parseCFFDict(topDictData) td := parseCFFDict(topDictData)
registry, ordering := getCFFROS(data, stringIndexOff, td) registry, ordering := getCFFROS(data, stringIndexOff, td)
actual, err := parseCFFAndCountGlyphs(data) actual, err := parseCFFAndCountGlyphs(data)
@@ -1073,7 +1095,18 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
if !ok { if !ok {
return nil return nil
} }
mapping := make(map[rune]uint16) return cffCharsetMapping(data, sids, registry, ordering, stringIndexOff)
}
// cffCharsetMapping 根据已解析的CFF字符集恢复Unicode映射
// 入参: data CFF数据, sids 字符标识, registry 注册机构, ordering 字符集, stringIndexOff 字符串索引偏移
// 返回: map[rune]uint16 恢复的映射表
func cffCharsetMapping(data []byte, sids []int, registry, ordering string, stringIndexOff int) map[rune]uint16 {
capacity := max(0, len(sids)-1)
if registry == "" || registry == "Adobe" && ordering == "GB1" {
capacity *= 2
}
mapping := make(map[rune]uint16, capacity)
if registry == "Adobe" && ordering == "GB1" { if registry == "Adobe" && ordering == "GB1" {
for gid, cid := range sids { for gid, cid := range sids {
if gid == 0 { if gid == 0 {
@@ -1106,7 +1139,7 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
} }
} else { } else {
idx := sid - 391 idx := sid - 391
name = readStringIndexItem(data, stringIndexOff, idx) name = string(readStringIndexItem(data, stringIndexOff, idx))
} }
r := rune(0) r := rune(0)
if name != "" { if name != "" {
@@ -1133,11 +1166,11 @@ func GetCFFCIDRuneMap(data []byte) map[uint16]rune {
if err != nil { if err != nil {
return nil return nil
} }
sids, registry, _, _, ok := getCFFCharsetInfo(cffData, numGlyphs) sids, registry, ordering, stringIndexOff, ok := getCFFCharsetInfo(cffData, numGlyphs)
if !ok || registry == "" { if !ok || registry == "" {
return nil return nil
} }
mapping := getCmapFromCFF(cffData, numGlyphs) mapping := cffCharsetMapping(cffData, sids, registry, ordering, stringIndexOff)
if len(mapping) == 0 { if len(mapping) == 0 {
return nil return nil
} }
@@ -1193,7 +1226,7 @@ func getCFFData(data []byte) []byte {
} }
offset := int(binary.BigEndian.Uint32(data[pos+8 : pos+12])) offset := int(binary.BigEndian.Uint32(data[pos+8 : pos+12]))
length := int(binary.BigEndian.Uint32(data[pos+12 : pos+16])) length := int(binary.BigEndian.Uint32(data[pos+12 : pos+16]))
if offset < 0 || length < 0 || offset+length > len(data) { if offset < 0 || length < 0 || offset > len(data) || length > len(data)-offset {
return nil return nil
} }
return data[offset : offset+length] return data[offset : offset+length]
@@ -1275,31 +1308,29 @@ func parseCFFCharset(data []byte, offset, count int) ([]int, error) {
// readStringIndexItem 读取CFF字符串索引项 // readStringIndexItem 读取CFF字符串索引项
// 入参: data CFF数据, offset 索引偏移, idx 索引号 // 入参: data CFF数据, offset 索引偏移, idx 索引号
// 返回: string 读取的字符串 // 返回: []byte 共享原始数据的只读字符串,索引无效时为nil
func readStringIndexItem(data []byte, offset int, idx int) string { func readStringIndexItem(data []byte, offset int, idx int) []byte {
if offset >= len(data) { if offset < 0 || offset > len(data)-2 || idx < 0 {
return "" return nil
} }
count := int(binary.BigEndian.Uint16(data[offset:])) count := int(binary.BigEndian.Uint16(data[offset:]))
if idx >= count || offset > len(data)-3 {
return nil
}
offSize := int(data[offset+2]) offSize := int(data[offset+2])
if idx >= count { if offSize < 1 || offSize > 4 || count+1 > (len(data)-offset-3)/offSize {
return "" return nil
} }
offArrayStart := offset + 3 offArrayStart := offset + 3
p1 := offArrayStart + idx*offSize p1 := offArrayStart + idx*offSize
p2 := p1 + offSize p2 := p1 + offSize
if p2+offSize > len(data) {
return ""
}
loc1 := readCFFOffset(data, p1, offSize) loc1 := readCFFOffset(data, p1, offSize)
loc2 := readCFFOffset(data, p2, offSize) loc2 := readCFFOffset(data, p2, offSize)
dataStart := offArrayStart + (count+1)*offSize dataStart := offArrayStart + (count+1)*offSize
start := dataStart + loc1 - 1 if loc1 < 1 || loc2 < loc1 || loc2-1 > len(data)-dataStart {
length := loc2 - loc1 return nil
if start < 0 || start+length > len(data) {
return ""
} }
return string(data[start : start+length]) return data[dataStart+loc1-1 : dataStart+loc2-1]
} }
// getCFFROS 读取CID字体ROS信息 // getCFFROS 读取CID字体ROS信息
@@ -1323,7 +1354,7 @@ func getCFFSIDString(data []byte, stringIndexOff int, sid int) string {
return cffStandardStrings[sid] return cffStandardStrings[sid]
} }
if sid > 390 { if sid > 390 {
return readStringIndexItem(data, stringIndexOff, sid-391) return string(readStringIndexItem(data, stringIndexOff, sid-391))
} }
return "" return ""
} }
+3
View File
@@ -231,6 +231,9 @@ func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte, ids []uint16)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := normalizeCFFPrivate(private); err != nil {
return nil, err
}
privateData, err := encodeCFFSubsetPrivate(private) privateData, err := encodeCFFSubsetPrivate(private)
if err != nil { if err != nil {
return nil, err return nil, err
+69 -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,12 +33,24 @@ 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 从名称表读取字体信息
// 入参: data 名称表数据, index 零起始字体索引 // 入参: data 名称表数据, index 零起始字体索引
// 返回: Face 字体信息 // 返回: Face 字体信息
func FaceInfo(data []byte, index int) Face { func FaceInfo(data []byte, index int) Face {
return faceInfoLanguage(data, index, 0xffff, 0xffff)
}
// faceInfoLanguage 优先读取指定平台和语言的显示名称,保留标识及全部别名
// 入参: data 名称表数据, index 字体索引, platform 平台标识, language 语言标识
// 返回: Face 字体信息
func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
records, values := fontNameValues(data) records, values := fontNameValues(data)
name := func(ids ...uint16) string { name := func(ids ...uint16) string {
for _, id := range ids { for _, id := range ids {
@@ -49,7 +64,52 @@ func FaceInfo(data []byte, index int) Face {
} }
return "" return ""
} }
return Face{Index: index, FullName: name(4, 6, 16, 1), Family: name(16, 1), Style: name(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values)} localized := func(ids ...uint16) string {
for _, id := range ids {
for _, record := range records {
if record.Platform == platform && record.Language == language && record.Name == id {
if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" {
return value
}
}
}
}
return name(ids...)
}
key := ""
if family, version := name(1), name(5); family != "" && version != "" {
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
}
family, style := localized(1), localized(2)
if family == "" {
family = localized(16, 4, 6)
}
if style == "" {
style = localized(17)
}
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
}
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
// 返回: Face 字体信息
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
face := faceInfoLanguage(tables["name"], index, platform, language)
key := face.FamilyKey
if head := tables["head"]; len(head) >= 46 {
key += string(head[4:8])
style := binary.BigEndian.Uint16(head[44:])
face.Bold, face.Italic = style&1 != 0, style&2 != 0
}
if os2 := tables["OS/2"]; len(os2) >= 64 {
key += string(os2[58:62])
style := binary.BigEndian.Uint16(os2[62:])
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
}
if face.FamilyKey != "" {
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
}
return face
} }
// FileCount 校验字体或集合头并读取字体数量 // FileCount 校验字体或集合头并读取字体数量
@@ -78,6 +138,13 @@ func FileCount(data []byte) (int, error) {
// 入参: data OpenType数据 // 入参: data OpenType数据
// 返回: []Face 字体列表, error 格式或名称表错误 // 返回: []Face 字体列表, error 格式或名称表错误
func ReadFaces(data []byte) ([]Face, error) { func ReadFaces(data []byte) ([]Face, error) {
return ReadLocalizedFaces(data, 0xffff, 0xffff)
}
// ReadLocalizedFaces 读取指定平台和语言的字体名称,缺失时沿用名称表顺序
// 入参: data OpenType数据, platform 名称表平台标识, language 名称表语言标识
// 返回: []Face 字体列表, error 格式或名称表错误
func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error) {
count, err := FileCount(data) count, err := FileCount(data)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -88,7 +155,7 @@ func ReadFaces(data []byte) ([]Face, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
face := FaceInfo(tables["name"], index) 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
@@ -235,7 +235,7 @@ func (f *Outliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.Font.GlyphAdvance(id))) * 64, ox: (left + int32(f.Font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64}} return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.Font.GlyphAdvance(id))) * 64, ox: (left + int32(f.Font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64}}
} }
// ttRecoverableHint 判断栈参数、指令引用或控制流错误是否允许保留执行前轮廓 // ttRecoverableHint 判断栈参数、区点轮廓及函数引用或控制流错误是否允许保留执行前轮廓
// 入参: err 指令错误 // 入参: err 指令错误
// 返回: bool 是否可恢复 // 返回: bool 是否可恢复
func ttRecoverableHint(err error) bool { func ttRecoverableHint(err error) bool {
+5 -4
View File
@@ -25,7 +25,7 @@ import (
// errTTStackUnderflow 表示字形程序缺少必需的栈参数 // errTTStackUnderflow 表示字形程序缺少必需的栈参数
var errTTStackUnderflow = errors.New("TrueType instruction stack underflow") var errTTStackUnderflow = errors.New("TrueType instruction stack underflow")
// errTTInvalidReference 表示字形指令引用不存在的点或函数 // errTTInvalidReference 表示字形指令引用不存在的区、点、轮廓或函数
var errTTInvalidReference = errors.New("invalid TrueType instruction reference") var errTTInvalidReference = errors.New("invalid TrueType instruction reference")
// errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围 // errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围
@@ -380,7 +380,8 @@ func (v *ttInterpreter) execute(code []byte, depth int) error {
case op >= 0x13 && op <= 0x16: case op >= 0x13 && op <= 0x16:
z := v.pop() z := v.pop()
if z < 0 || z > 1 { if z < 0 || z > 1 {
return fmt.Errorf("invalid TrueType zone") v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, z)
break
} }
if op == 0x16 { if op == 0x16 {
g.zone = [3]int32{z, z, z} g.zone = [3]int32{z, z, z}
@@ -854,7 +855,7 @@ func (v *ttInterpreter) shift(op byte) {
if op <= 0x35 { if op <= 0x35 {
contour := v.pop() contour := v.pop()
if contour < 0 || int64(contour) >= int64(len(v.ends)) { if contour < 0 || int64(contour) >= int64(len(v.ends)) {
v.err = fmt.Errorf("invalid TrueType contour reference") v.err = fmt.Errorf("%w: contour %d", errTTInvalidReference, contour)
return return
} }
if contour > 0 { if contour > 0 {
@@ -864,7 +865,7 @@ func (v *ttInterpreter) shift(op byte) {
} else { } else {
target = v.pop() target = v.pop()
if target < 0 || target > 1 { if target < 0 || target > 1 {
v.err = fmt.Errorf("invalid TrueType zone") v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, target)
return return
} }
to = len(v.zones[target]) to = len(v.zones[target])
+1 -1
View File
@@ -174,7 +174,7 @@ func NormalizeName(name string) string {
// 返回: bool 是否为字体文件 // 返回: bool 是否为字体文件
func IsFontFileName(name string) bool { func IsFontFileName(name string) bool {
switch strings.ToLower(path.Ext(name)) { switch strings.ToLower(path.Ext(name)) {
case ".ttf", ".otf", ".ttc": case ".ttf", ".otf", ".ttc", ".otc":
return true return true
default: default:
return false return false
+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
} }
+118 -18
View File
@@ -17,9 +17,11 @@ package font
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"iter"
"maps" "maps"
"slices" "slices"
"sync" "sync"
"unicode/utf8"
"github.com/go-text/typesetting/font/opentype/tables" "github.com/go-text/typesetting/font/opentype/tables"
) )
@@ -34,10 +36,12 @@ type SFNT struct {
reverse map[uint16][]rune reverse map[uint16][]rune
} }
// sfntCmap 保存编码记录及对应字符映射,标记已解析的共享子表 // sfntCmap 保存编码记录及字符映射,连续区间与共享子表不重复展开
type sfntCmap struct { type sfntCmap struct {
platform, encoding, format uint16 platform, encoding, format uint16
mapping map[rune]uint16 mapping map[rune]uint16
groups []byte
entries uint64
duplicate bool duplicate bool
} }
@@ -90,7 +94,7 @@ func ParseSFNTTables(table map[string][]byte) (*SFNT, error) {
if cmap.duplicate { if cmap.duplicate {
continue continue
} }
for _, glyph := range cmap.mapping { for _, glyph := range cmap.pairs() {
if glyph >= f.count { if glyph >= f.count {
return nil, fmt.Errorf("cmap glyph index out of range") return nil, fmt.Errorf("cmap glyph index out of range")
} }
@@ -119,7 +123,7 @@ func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
} }
count := int(binary.BigEndian.Uint16(data[2:])) count := int(binary.BigEndian.Uint16(data[2:]))
result := make([]sfntCmap, 0, count) result := make([]sfntCmap, 0, count)
parsed := make(map[uint32]map[rune]uint16) parsed := make(map[uint32]sfntCmap)
for i := range count { for i := range count {
record := data[4+8*i:] record := data[4+8*i:]
offset := binary.BigEndian.Uint32(record[4:]) offset := binary.BigEndian.Uint32(record[4:])
@@ -128,23 +132,44 @@ func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
} }
sub := data[offset:] sub := data[offset:]
format := binary.BigEndian.Uint16(sub) format := binary.BigEndian.Uint16(sub)
mapping, found := parsed[offset] cmap, found := parsed[offset]
if !found { if !found {
mapping = make(map[rune]uint16) cmap.mapping = make(map[rune]uint16)
var err error var err error
switch format { switch format {
case 0: case 0:
parseCmapFormat0(sub, mapping) parseCmapFormat0(sub, cmap.mapping)
case 4: case 4:
parseCmapFormat4(sub, mapping) parseCmapFormat4(sub, cmap.mapping)
case 6: case 6:
parseCmapFormat6(sub, mapping) parseCmapFormat6(sub, cmap.mapping)
case 12: case 12:
parseCmapFormat12(sub, mapping) if len(sub) >= 16 {
length := uint64(binary.BigEndian.Uint32(sub[4:]))
count := uint64(binary.BigEndian.Uint32(sub[12:]))
if length >= 16 && length <= uint64(len(sub)) && count <= (length-16)/12 {
cmap.groups = sub[16 : 16+12*count]
for offset := 0; offset < len(cmap.groups); offset += 12 {
first := binary.BigEndian.Uint32(cmap.groups[offset:])
last := binary.BigEndian.Uint32(cmap.groups[offset+4:])
if first > last || offset != 0 && first <= binary.BigEndian.Uint32(cmap.groups[offset-8:]) {
cmap.groups = nil
parseCmapFormat12(sub, cmap.mapping)
break
}
}
if cmap.groups != nil {
cmap.mapping = nil
for range cmap.pairs() {
cmap.entries++
}
}
}
}
case 2: case 2:
mapping, err = parseCmapFormat2(sub) cmap.mapping, err = parseCmapFormat2(sub)
case 8, 10, 13: case 8, 10, 13:
mapping, err = parseCmapExtended(sub) cmap.mapping, err = parseCmapExtended(sub)
case 14: case 14:
default: default:
return nil, fmt.Errorf("unsupported cmap format %d", format) return nil, fmt.Errorf("unsupported cmap format %d", format)
@@ -152,13 +177,67 @@ func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
parsed[offset] = mapping parsed[offset] = cmap
} }
result = append(result, sfntCmap{platform: binary.BigEndian.Uint16(record), encoding: binary.BigEndian.Uint16(record[2:]), format: format, mapping: mapping, duplicate: found}) cmap.platform, cmap.encoding, cmap.format, cmap.duplicate = binary.BigEndian.Uint16(record), binary.BigEndian.Uint16(record[2:]), format, found
result = append(result, cmap)
} }
return result, nil return result, nil
} }
// pairs 按原映射返回字符与字形,连续编码段不展开存储
// 返回: iter.Seq2[rune, uint16] 非零有效字符映射
func (c sfntCmap) pairs() iter.Seq2[rune, uint16] {
return func(yield func(rune, uint16) bool) {
for char, glyph := range c.mapping {
if !yield(char, glyph) {
return
}
}
for offset := 0; offset+12 <= len(c.groups); offset += 12 {
group := c.groups[offset:]
first, last, glyph := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:]), binary.BigEndian.Uint32(group[8:])
if first > last || first > utf8.MaxRune || glyph > 65535 {
continue
}
end := min(uint64(last), uint64(first)+65535-uint64(glyph), uint64(utf8.MaxRune))
for char := uint64(first); char <= end; char++ {
id := uint16(uint64(glyph) + char - uint64(first))
if id != 0 && utf8.ValidRune(rune(char)) && !yield(rune(char), id) {
return
}
}
}
}
}
// lookup 查找原字符映射,连续编码段使用有序区间查询
// 入参: char 字符
// 返回: uint16 字形编号, bool 是否存在映射
func (c sfntCmap) lookup(char rune) (uint16, bool) {
if glyph, ok := c.mapping[char]; ok {
return glyph, true
}
if !utf8.ValidRune(char) {
return 0, false
}
low, high := 0, len(c.groups)/12
for low < high {
middle := low + (high-low)/2
group := c.groups[middle*12:]
first, last := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:])
if uint32(char) < first {
high = middle
} else if uint32(char) > last {
low = middle + 1
} else {
glyph := uint64(binary.BigEndian.Uint32(group[8:])) + uint64(uint32(char)-first)
return uint16(glyph), glyph > 0 && glyph <= 65535
}
}
return 0, false
}
// UnitsPerEm 返回字体设计单位 // UnitsPerEm 返回字体设计单位
// 返回: uint16 每em单位数 // 返回: uint16 每em单位数
func (f *SFNT) UnitsPerEm() uint16 { return f.units } func (f *SFNT) UnitsPerEm() uint16 { return f.units }
@@ -172,7 +251,7 @@ func (f *SFNT) NumGlyphs() uint16 { return f.count }
func (f *SFNT) CmapEntries() uint64 { func (f *SFNT) CmapEntries() uint64 {
var count uint64 var count uint64
for _, cmap := range f.cmaps { for _, cmap := range f.cmaps {
count += uint64(len(cmap.mapping)) count += uint64(len(cmap.mapping)) + cmap.entries
} }
return count return count
} }
@@ -185,14 +264,14 @@ func (f *SFNT) GlyphIndex(char rune) uint16 {
if cmap.duplicate { if cmap.duplicate {
continue continue
} }
if gid := cmap.mapping[char]; gid != 0 { if gid, ok := cmap.lookup(char); ok && gid != 0 {
return gid return gid
} }
} }
return 0 return 0
} }
// glyphCharacters 返回指向指定字形的全部字符 // glyphCharacters 返回指向指定字形的全部字符,连续区间按需反查
// 入参: gid 字形编号 // 入参: gid 字形编号
// 返回: []rune 排序后的字符 // 返回: []rune 排序后的字符
func (f *SFNT) glyphCharacters(gid uint16) []rune { func (f *SFNT) glyphCharacters(gid uint16) []rune {
@@ -204,7 +283,7 @@ func (f *SFNT) glyphCharacters(gid uint16) []rune {
continue continue
} }
for char, glyph := range cmap.mapping { for char, glyph := range cmap.mapping {
if glyph != 0 && !seen[char] { if glyph != 0 && !seen[char] && f.GlyphIndex(char) == glyph {
f.reverse[glyph] = append(f.reverse[glyph], char) f.reverse[glyph] = append(f.reverse[glyph], char)
seen[char] = true seen[char] = true
} }
@@ -214,7 +293,28 @@ func (f *SFNT) glyphCharacters(gid uint16) []rune {
slices.Sort(chars) slices.Sort(chars)
} }
}) })
return f.reverse[gid] if gid == 0 {
return nil
}
chars := slices.Clone(f.reverse[gid])
for _, cmap := range f.cmaps {
if cmap.duplicate {
continue
}
for offset := 0; offset+12 <= len(cmap.groups); offset += 12 {
group := cmap.groups[offset:]
first, last, glyph := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:]), binary.BigEndian.Uint32(group[8:])
if first > last || uint32(gid) < glyph {
continue
}
char := uint64(first) + uint64(uint32(gid)-glyph)
if char <= uint64(last) && char <= utf8.MaxRune && utf8.ValidRune(rune(char)) && f.GlyphIndex(rune(char)) == gid {
chars = append(chars, rune(char))
}
}
}
slices.Sort(chars)
return slices.Compact(chars)
} }
// GlyphAdvance 返回水平字宽 // GlyphAdvance 返回水平字宽
+1 -1
View File
@@ -197,7 +197,7 @@ func subsetSourceSFNT(sfnt *SFNT, usage *Usage, size int) []byte {
for char := range usage.Characters { for char := range usage.Characters {
id := sfnt.GlyphIndex(char) id := sfnt.GlyphIndex(char)
for _, cmap := range unicodeTables { for _, cmap := range unicodeTables {
if alternate, ok := cmap.mapping[char]; ok && alternate != id { if alternate, ok := cmap.lookup(char); ok && alternate != id {
return nil return nil
} }
} }
-1
View File
@@ -29,7 +29,6 @@ func NormalizeOS2(data []byte) []byte {
var size int var size int
switch binary.BigEndian.Uint16(data) { switch binary.BigEndian.Uint16(data) {
case 0: case 0:
// 旧式68字节表保持原样,不将截断的78字节表推断为旧式表。
size = 78 size = 78
case 1: case 1:
size = 86 size = 86
+58
View File
@@ -198,6 +198,9 @@ func ParseType1Program(data []byte) (Type1Program, error) {
return Type1Program{}, fmt.Errorf("Type1 font has no glyphs") return Type1Program{}, fmt.Errorf("Type1 font has no glyphs")
} }
for index := range program.subrs { for index := range program.subrs {
if program.subrs[index] == nil {
continue
}
program.subrs[index], err = type1CharString(program.subrs[index], program.lenIV) program.subrs[index], err = type1CharString(program.subrs[index], program.lenIV)
if err != nil { if err != nil {
return Type1Program{}, err return Type1Program{}, err
@@ -212,6 +215,61 @@ func ParseType1Program(data []byte) (Type1Program, error) {
return program, nil return program, nil
} }
// NormalizePDFType1 修复完整加密分段之后的PFB明文尾部,不补齐加密或字形数据
// 入参: data 原始PFB程序
// 返回: []byte 原程序或独立修复容器, bool 是否修复, error 容器或程序错误
func NormalizePDFType1(data []byte) ([]byte, bool, error) {
if len(data) < 6 || data[0] != 0x80 {
return data, false, nil
}
binarySeen := false
for offset := 0; offset+2 <= len(data) && data[offset] == 0x80; {
kind := data[offset+1]
if kind == 3 {
return data, false, nil
}
if offset+6 > len(data) || kind != 1 && kind != 2 {
return data, false, fmt.Errorf("incomplete PFB segment header")
}
length := uint64(binary.LittleEndian.Uint32(data[offset+2:]))
remaining := len(data) - offset - 6
if binarySeen && kind == 1 && remaining == 0 {
result := append(bytes.Clone(data[:offset]), 0x80, 3)
if _, err := ParseType1Program(result); err != nil {
return nil, false, err
}
return result, true, nil
}
if length > uint64(remaining) {
return nil, false, fmt.Errorf("incomplete PFB encrypted or text segment")
}
if binarySeen && kind == 1 && length == 0 {
tail := data[offset+6:]
if len(tail) < 2 || !bytes.Equal(tail[len(tail)-2:], []byte{0x80, 3}) {
return data, false, fmt.Errorf("unbounded PFB text trailer")
}
text := bytes.TrimSpace(tail[:len(tail)-2])
if !bytes.HasSuffix(text, []byte("cleartomark")) {
return data, false, fmt.Errorf("PFB trailer lacks cleartomark")
}
for _, b := range bytes.TrimSuffix(text, []byte("cleartomark")) {
if b != '0' && !type1Space(b) {
return data, false, fmt.Errorf("unexpected PFB text trailer")
}
}
result := bytes.Clone(data)
binary.LittleEndian.PutUint32(result[offset+2:], uint32(len(tail)-2))
if _, err := ParseType1Program(result); err != nil {
return nil, false, err
}
return result, true, nil
}
binarySeen = binarySeen || kind == 2
offset += 6 + int(length)
}
return data, false, nil
}
// readType1Header 读取明文字体矩阵及加密数据起点 // readType1Header 读取明文字体矩阵及加密数据起点
// 入参: data Type1程序 // 入参: data Type1程序
// 返回: []float64 字体矩阵, int 加密起点, error 头部错误 // 返回: []float64 字体矩阵, int 加密起点, error 头部错误
+115 -11
View File
@@ -87,23 +87,127 @@ func FileTables(data []byte, index int) (map[string][]byte, error) {
return tables, nil return tables, nil
} }
// NormalizePDFProgram 修复可确定的PDF内嵌SFNT封装,不补造TrueType轮廓或字形编号
// 入参: data 原始字体程序
// 返回: []byte 原程序或独立修复程序, bool 是否修复, error 无法确认的目录或度量错误
func NormalizePDFProgram(data []byte) ([]byte, bool, error) {
if len(data) < 12 || string(data[:4]) != "OTTO" && string(data[:4]) != "\x00\x01\x00\x00" && string(data[:4]) != "true" {
return data, false, nil
}
count := int(binary.BigEndian.Uint16(data[4:6]))
if count == 0 || count > (len(data)-12)/16 {
return nil, false, fmt.Errorf("unrecoverable PDF font directory size")
}
first := len(data)
for n := 0; n < count; n++ {
record := data[12+n*16:]
start, size := uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
if size != 0 && start >= 12 && start+size <= uint64(len(data)) {
first = min(first, int(start))
}
}
changed := false
if first < 12+count*16 {
if (first-12)%16 != 0 {
return nil, false, fmt.Errorf("ambiguous PDF font directory overlap")
}
actual := (first - 12) / 16
for n := 0; n < count; n++ {
record := data[12+n*16:]
printable := true
for _, b := range record[:4] {
printable = printable && b >= 32 && b <= 126
}
if printable != (n < actual) {
return nil, false, fmt.Errorf("ambiguous PDF font table count")
}
if n < actual {
start, size := uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
if size != 0 && (start < uint64(first) || start+size > uint64(len(data))) {
return nil, false, fmt.Errorf("incomplete PDF font table")
}
}
}
data = bytes.Clone(data)
binary.BigEndian.PutUint16(data[4:6], uint16(actual))
changed = true
}
tables, err := FileTables(data, 0)
if err != nil {
return nil, false, err
}
if head := tables["head"]; len(head) > 54 && len(head) <= 57 {
tables["head"] = head[:54]
changed = true
}
if hhea := tables["hhea"]; len(hhea) > 36 && len(hhea) <= 39 && binary.BigEndian.Uint32(hhea) == 0x00010000 {
tables["hhea"] = hhea[:36]
changed = true
}
if maxp := tables["maxp"]; tables["glyf"] != nil && len(maxp) > 32 && len(maxp) <= 35 && binary.BigEndian.Uint32(maxp) == 0x00010000 {
tables["maxp"] = maxp[:32]
changed = true
}
if post := tables["post"]; len(post) > 32 && binary.BigEndian.Uint32(post) == 0x00030000 {
tables["post"] = post[:32]
changed = true
}
if cvt := tables["cvt "]; len(cvt)%2 != 0 && cvt[len(cvt)-1] == 0 {
tables["cvt "] = cvt[:len(cvt)-1]
changed = true
}
if len(tables["CFF "]) != 0 && len(tables["head"]) == 0 && len(tables["maxp"]) == 0 && len(tables["hhea"]) == 0 && len(tables["hmtx"]) == 0 {
program, _, err := WrapCFFToOTF(tables["CFF "])
return program, true, err
}
if maxp := tables["maxp"]; len(tables["CFF "]) != 0 && tables["glyf"] == nil && len(maxp) >= 6 && (binary.BigEndian.Uint32(maxp) == 0x00010000 || binary.BigEndian.Uint32(maxp) == 0x00005000 && len(maxp) != 6) {
count, err := parseCFFAndCountGlyphs(tables["CFF "])
if err != nil {
return nil, false, err
}
if count != int(binary.BigEndian.Uint16(maxp[4:6])) {
program, _, err := WrapCFFToOTF(tables["CFF "])
if err != nil {
return nil, false, err
}
rebuilt, err := FileTables(program, 0)
if err != nil {
return nil, false, err
}
if len(tables["head"]) < 20 || !bytes.Equal(tables["head"][18:20], rebuilt["head"][18:20]) {
return nil, false, fmt.Errorf("CFF and SFNT design units differ")
}
return program, true, nil
}
tables["maxp"] = buildCFFMaxpTable(uint16(count))
changed = true
}
if !changed {
return data, false, nil
}
program, err := SerializeOTF(tables)
return program, true, err
}
// calcTableChecksum 计算字体表校验和 // calcTableChecksum 计算字体表校验和
// 入参: data 表数据 // 入参: data 表数据
// 返回: uint32 校验和 // 返回: uint32 校验和
func calcTableChecksum(data []byte) uint32 { func calcTableChecksum(data []byte) uint32 {
var sum uint32 var a, b, c, d uint32
length := len(data) for len(data) >= 16 {
for i := 0; i < length; i += 4 { a += binary.BigEndian.Uint32(data[:4])
if i+4 <= length { b += binary.BigEndian.Uint32(data[4:8])
sum += binary.BigEndian.Uint32(data[i : i+4]) c += binary.BigEndian.Uint32(data[8:12])
} else { d += binary.BigEndian.Uint32(data[12:16])
var val uint32 data = data[16:]
rem := data[i:]
for j, b := range rem {
val |= uint32(b) << (24 - 8*j)
} }
sum += val sum := a + b + c + d
for len(data) >= 4 {
sum += binary.BigEndian.Uint32(data[:4])
data = data[4:]
} }
for i, value := range data {
sum += uint32(value) << (24 - 8*i)
} }
return sum return sum
} }
+13 -2
View File
@@ -116,7 +116,7 @@ func centerArc(start Point, arc Segment) (geometryArc, error) {
} }
angle := math.Mod(arc.Rotation, 360) * math.Pi / 180 angle := math.Mod(arc.Rotation, 360) * math.Pi / 180
sine, cosine := math.Sincos(angle) sine, cosine := math.Sincos(angle)
dx, dy := (start.X-arc.End.X)/2, (start.Y-arc.End.Y)/2 dx, dy := arcCoordinateMean(start.X, -arc.End.X), arcCoordinateMean(start.Y, -arc.End.Y)
x, y := cosine*dx+sine*dy, -sine*dx+cosine*dy x, y := cosine*dx+sine*dy, -sine*dx+cosine*dy
scale := math.Hypot(x/rx, y/ry) scale := math.Hypot(x/rx, y/ry)
if scale > 1 { if scale > 1 {
@@ -139,13 +139,24 @@ func centerArc(start Point, arc Segment) (geometryArc, error) {
if !arc.Sweep && delta > 0 { if !arc.Sweep && delta > 0 {
delta -= 2 * math.Pi delta -= 2 * math.Pi
} }
center := Point{cosine*cx - sine*cy + (start.X+arc.End.X)/2, sine*cx + cosine*cy + (start.Y+arc.End.Y)/2} center := Point{cosine*cx - sine*cy + arcCoordinateMean(start.X, arc.End.X), sine*cx + cosine*cy + arcCoordinateMean(start.Y, arc.End.Y)}
if !finite(center.X) || !finite(center.Y) || !finite(rx) || !finite(ry) || !finite(delta) || !finite(theta) || delta == 0 { if !finite(center.X) || !finite(center.Y) || !finite(rx) || !finite(ry) || !finite(delta) || !finite(theta) || delta == 0 {
return geometryArc{}, fmt.Errorf("invalid arc extent") return geometryArc{}, fmt.Errorf("invalid arc extent")
} }
return geometryArc{center, Point{rx * cosine, rx * sine}, Point{-ry * sine, ry * cosine}, theta, delta}, nil return geometryArc{center, Point{rx * cosine, rx * sine}, Point{-ry * sine, ry * cosine}, theta, delta}, nil
} }
// arcCoordinateMean 计算有限坐标均值,仅在求和溢出时先减半,避免微小坐标提前下溢
// 入参: a 第一个坐标, b 第二个坐标
// 返回: float64 坐标均值
func arcCoordinateMean(a, b float64) float64 {
value := (a + b) / 2
if math.IsInf(value, 0) {
return a/2 + b/2
}
return value
}
// point 计算椭圆参数对应的坐标 // point 计算椭圆参数对应的坐标
// 入参: angle 参数角 // 入参: angle 参数角
// 返回: Point 椭圆上的点 // 返回: Point 椭圆上的点
+5 -2
View File
@@ -125,7 +125,7 @@ func (m Matrix) Invert() (Matrix, bool) {
return n, true return n, true
} }
// matrixProductDifference 计算两个乘积之差,检查溢出、下溢及相消造成的精度损失 // matrixProductDifference 补偿乘积相减的舍入残差,检查溢出、下溢及严重相消
// 入参: a 第一个乘数, b 第二个乘数, c 第三个乘数, d 第四个乘数 // 入参: a 第一个乘数, b 第二个乘数, c 第三个乘数, d 第四个乘数
// 返回: float64 乘积之差, bool 是否可直接用于求逆 // 返回: float64 乘积之差, bool 是否可直接用于求逆
func matrixProductDifference(a, b, c, d float64) (float64, bool) { func matrixProductDifference(a, b, c, d float64) (float64, bool) {
@@ -134,6 +134,9 @@ func matrixProductDifference(a, b, c, d float64) (float64, bool) {
if !finite(value) || x == 0 && a != 0 && b != 0 || y == 0 && c != 0 && d != 0 { if !finite(value) || x == 0 && a != 0 && b != 0 || y == 0 && c != 0 && d != 0 {
return value, false return value, false
} }
if x != 0 && y != 0 {
value = math.FMA(a, b, -y) + math.FMA(-c, d, y)
}
return value, x == 0 && y == 0 || math.Abs(value) >= 0x1p-1022 && math.Abs(value) >= 0x1p-48*math.Max(math.Abs(x), math.Abs(y)) return value, x == 0 && y == 0 || math.Abs(value) >= 0x1p-1022 && math.Abs(value) >= 0x1p-48*math.Max(math.Abs(x), math.Abs(y))
} }
@@ -169,7 +172,7 @@ func TranslationMatrix(x, y float64) Matrix {
// YScale 获取Y轴缩放比例 // YScale 获取Y轴缩放比例
// 返回: float64 缩放比例 // 返回: float64 缩放比例
func (m Matrix) YScale() float64 { func (m Matrix) YScale() float64 {
return math.Sqrt(m.c*m.c + m.d*m.d) return math.Hypot(m.c, m.d)
} }
// Equal 比较毫米坐标,使用固定绝对误差,不受绘图库全局精度影响 // Equal 比较毫米坐标,使用固定绝对误差,不受绘图库全局精度影响
+17 -1
View File
@@ -17,6 +17,7 @@ package geom
import ( import (
"fmt" "fmt"
"math" "math"
"slices"
) )
// UnsupportedError 表示当前几何实现无法展开的描边形态 // UnsupportedError 表示当前几何实现无法展开的描边形态
@@ -347,7 +348,22 @@ func (line geometryPolyline) stroke(result Path, options StrokeOptions) (Path, e
} }
return result, nil return result, nil
} }
directions := make([]Point, len(points)-1) segments := len(points) - 1
joins := segments - 1
caps := 0
if line.closed {
joins++
} else if options.Cap == "Round" {
caps = 8
} else if options.Cap == "Square" {
caps = 10
}
joinSize := 4
if options.Join == "" || options.Join == "Miter" {
joinSize = 5
}
result = slices.Grow(result, min(segments*5+joins*joinSize+caps, 1<<20-len(result)))
directions := make([]Point, segments)
for i := range directions { for i := range directions {
a, b := points[i], points[i+1] a, b := points[i], points[i+1]
length := math.Hypot(b.X-a.X, b.Y-a.Y) length := math.Hypot(b.X-a.X, b.Y-a.Y)
+1 -4
View File
@@ -33,10 +33,7 @@ func (p Path) Transform(matrix Matrix) (Path, error) {
} }
} }
matrixScale := math.Max(math.Max(math.Abs(matrix.a), math.Abs(matrix.b)), math.Max(math.Abs(matrix.c), math.Abs(matrix.d))) matrixScale := math.Max(math.Max(math.Abs(matrix.a), math.Abs(matrix.b)), math.Max(math.Abs(matrix.c), math.Abs(matrix.d)))
determinant := 0.0 determinant, _ := matrixProductSum(matrix.a, matrix.d, -matrix.b, matrix.c)
if matrixScale != 0 {
determinant = (matrix.a/matrixScale)*(matrix.d/matrixScale) - (matrix.b/matrixScale)*(matrix.c/matrixScale)
}
transform := func(p Point) Point { transform := func(p Point) Point {
x, y := matrix.Transform(p.X, p.Y) x, y := matrix.Transform(p.X, p.Y)
return Point{x, y} return Point{x, y}
+16 -4
View File
@@ -96,7 +96,7 @@ func pngColorProfile(ctx context.Context, data []byte) ([]byte, *pngColor, error
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
profile, err := io.ReadAll(io.LimitReader(contextInput{ReadCloser: reader, context: ctx}, profileLimit+1)) profile, err := io.ReadAll(io.LimitReader(contextInput{ReadCloser: reader, ctx: ctx}, profileLimit+1))
closeErr := reader.Close() closeErr := reader.Close()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -249,7 +249,7 @@ func jpegColorProfile(ctx context.Context, data []byte, model color.Model) ([]by
return profile, nil return profile, nil
} }
// jpegDisplayImage 统一JPEG显示变换,保留源分量并使用文档内嵌ICC // jpegDisplayImage 统一JPEG显示变换,保留源分量并精确复用相邻同色的ICC结果
// 入参: ctx 取消上下文, source 源像素, profile 颜色配置,可为nil // 入参: ctx 取消上下文, source 源像素, profile 颜色配置,可为nil
// 返回: image.Image 显示像素, error 变换或取消错误 // 返回: image.Image 显示像素, error 变换或取消错误
func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) { func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
@@ -265,6 +265,12 @@ func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.Co
return nil, fmt.Errorf("JPEG color image dimensions exceed capacity") return nil, fmt.Errorf("JPEG color image dimensions exceed capacity")
} }
pixels := image.NewNRGBA64(bounds) pixels := image.NewNRGBA64(bounds)
var intent pdfgo.Name
if profile != nil {
intent = profile.DefaultIntent()
}
var previous color.CMYK
var previousPixel color.NRGBA64
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ { for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)&255 == 0 { if (x-bounds.Min.X)&255 == 0 {
@@ -283,12 +289,18 @@ func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.Co
}) })
continue continue
} }
if previousPixel.A != 0 && ink == previous {
pixels.SetNRGBA64(x, y, previousPixel)
continue
}
values := [4]float64{float64(ink.C) / 255, float64(ink.M) / 255, float64(ink.Y) / 255, float64(ink.K) / 255} values := [4]float64{float64(ink.C) / 255, float64(ink.M) / 255, float64(ink.Y) / 255, float64(ink.K) / 255}
rgb, err := profile.SRGB(values[:], profile.DefaultIntent()) rgb, err := profile.SRGB(values[:], intent)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pixels.SetNRGBA64(x, y, color.NRGBA64{R: colorWord(rgb[0]), G: colorWord(rgb[1]), B: colorWord(rgb[2]), A: 65535}) previous = ink
previousPixel = color.NRGBA64{R: colorWord(rgb[0]), G: colorWord(rgb[1]), B: colorWord(rgb[2]), A: 65535}
pixels.SetNRGBA64(x, y, previousPixel)
} }
} }
return &colorImage{NRGBA64: pixels, source: source}, nil return &colorImage{NRGBA64: pixels, source: source}, nil
+2 -2
View File
@@ -139,7 +139,7 @@ func (img *EncodedImage) decodeContext(ctx context.Context, pending chan struct{
close(pending) close(pending)
img.mu.Unlock() img.mu.Unlock()
}() }()
pixels, _, err := image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(img.data)), context: ctx}) pixels, _, err := image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(img.data)), ctx: ctx})
if err == nil && (img.format == "jpeg" || img.profile != nil) { if err == nil && (img.format == "jpeg" || img.profile != nil) {
var profile *pdfgo.ColorSpace var profile *pdfgo.ColorSpace
if img.profile != nil { if img.profile != nil {
@@ -184,7 +184,7 @@ func newEncodedImage(data []byte) (*EncodedImage, error) {
// 入参: ctx 取消上下文, data 只读图片编码 // 入参: ctx 取消上下文, data 只读图片编码
// 返回: *EncodedImage 惰性图片, error 头信息、格式或取消错误 // 返回: *EncodedImage 惰性图片, error 头信息、格式或取消错误
func newEncodedImageContext(ctx context.Context, data []byte) (*EncodedImage, error) { func newEncodedImageContext(ctx context.Context, data []byte) (*EncodedImage, error) {
config, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) config, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if cancelErr := ctx.Err(); cancelErr != nil { if cancelErr := ctx.Err(); cancelErr != nil {
return nil, cancelErr return nil, cancelErr
} }
+49 -9
View File
@@ -170,7 +170,7 @@ func DecodeContext(ctx context.Context, data []byte) (img image.Image, format st
img, err := DecodeTIFF(ctx, data, layout) img, err := DecodeTIFF(ctx, data, layout)
return img, "tiff", err return img, "tiff", err
} }
img, format, err = image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) img, format, err = image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if err == nil { if err == nil {
return img, NormalizeFormat(format), nil return img, NormalizeFormat(format), nil
} }
@@ -267,7 +267,7 @@ func DecodeConfigDataContext(ctx context.Context, data []byte) (config image.Con
cfg.Width, cfg.Height = size.X, size.Y cfg.Width, cfg.Height = size.X, size.Y
return cfg, "tiff", nil return cfg, "tiff", nil
} }
cfg, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) cfg, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if err == nil { if err == nil {
return cfg, NormalizeFormat(format), nil return cfg, NormalizeFormat(format), nil
} }
@@ -451,13 +451,11 @@ func GrayAt(img image.Image, x, y int) color.Gray {
case *colorImage: case *colorImage:
return GrayAt(src.source, x, y) return GrayAt(src.source, x, y)
case *maskedTIFFImage: case *maskedTIFFImage:
if src.NRGBA64At(x, y).A == 0 { if src.alphaAt(x, y) == 0 {
return color.Gray{} return color.Gray{}
} }
bounds, size := src.source.Bounds(), src.bounds.Size() point := src.samplePoint(src.source.Bounds(), x, y)
sx := bounds.Min.X + int((uint64(x-src.bounds.Min.X)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X)) return GrayAt(src.source, point.X, point.Y)
sy := bounds.Min.Y + int((uint64(y-src.bounds.Min.Y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y))
return GrayAt(src.source, sx, sy)
case *transparentEdgeImage: case *transparentEdgeImage:
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 { if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
return color.Gray{} return color.Gray{}
@@ -534,6 +532,39 @@ func NRGBAAt(img image.Image, x, y int) color.NRGBA {
// 返回: uint32 十六位透明度 // 返回: uint32 十六位透明度
func AlphaAt(img image.Image, x, y int) uint32 { func AlphaAt(img image.Image, x, y int) uint32 {
switch source := img.(type) { switch source := img.(type) {
case *maskedTIFFImage:
return source.alphaAt(x, y)
case *image.YCbCr, *image.CMYK, *image.Gray, *image.Gray16:
return 65535
case *image.NYCbCrA:
if !image.Pt(x, y).In(source.Rect) {
return 0
}
return uint32(source.A[source.AOffset(x, y)]) * 257
case *orientedTIFFImage:
point, ok := source.sourcePoint(x, y)
if !ok {
return 0
}
return AlphaAt(source.source, point.X, point.Y)
case *planarTIFFImage:
if !image.Pt(x, y).In(source.bounds) {
return 0
}
if source.alpha != 0 {
index := 3
if source.cmyk {
index = 4
} else if source.gray || len(source.palette) != 0 {
index = 1
}
return uint32(source.sampleAt(index, x, y))
}
if len(source.palette) != 0 {
index := uint32(source.sampleAt(0, x, y)) * uint32(len(source.palette)-1) / 65535
return uint32(source.palette[index].A)
}
return 65535
case *image.RGBA: case *image.RGBA:
if !(image.Point{X: x, Y: y}).In(source.Rect) { if !(image.Point{X: x, Y: y}).In(source.Rect) {
return 0 return 0
@@ -722,6 +753,7 @@ func WithTransparentEdge(ctx context.Context, img image.Image) (image.Image, int
hasZero, hasVisible := false, false hasZero, hasVisible := false, false
var pixels []byte var pixels []byte
var stride, step int var stride, step int
wideAlpha := false
switch src := source.(type) { switch src := source.(type) {
case *image.NRGBA: case *image.NRGBA:
pixels, stride, step = src.Pix, src.Stride, 4 pixels, stride, step = src.Pix, src.Stride, 4
@@ -729,9 +761,17 @@ func WithTransparentEdge(ctx context.Context, img image.Image) (image.Image, int
pixels, stride, step = src.Pix, src.Stride, 4 pixels, stride, step = src.Pix, src.Stride, 4
case *image.Alpha: case *image.Alpha:
pixels, stride, step = src.Pix, src.Stride, 1 pixels, stride, step = src.Pix, src.Stride, 1
case *image.NRGBA64:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
case *image.RGBA64:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
case *image.Alpha16:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 2, true
case *colorImage:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
} }
if pixels != nil { if pixels != nil {
hasZero = pixels[step-1] == 0 hasZero = pixels[step-1] == 0 && (!wideAlpha || pixels[step-2] == 0)
hasVisible = !hasZero hasVisible = !hasZero
scan: scan:
for y := 0; y < h; y++ { for y := 0; y < h; y++ {
@@ -745,7 +785,7 @@ func WithTransparentEdge(ctx context.Context, img image.Image) (image.Image, int
return nil, 0, err return nil, 0, err
} }
} }
if (pixels[offset] == 0) != hasZero { if (pixels[offset] == 0 && (!wideAlpha || pixels[offset-1] == 0)) != hasZero {
hasZero, hasVisible = true, true hasZero, hasVisible = true, true
break scan break scan
} }
+2 -2
View File
@@ -22,14 +22,14 @@ import (
// contextInput 在读取图像编码时检查调用方取消状态 // contextInput 在读取图像编码时检查调用方取消状态
type contextInput struct { type contextInput struct {
io.ReadCloser io.ReadCloser
context context.Context ctx context.Context
} }
// Read 检查取消状态后读取原始数据 // Read 检查取消状态后读取原始数据
// 入参: data 数据缓冲区 // 入参: data 数据缓冲区
// 返回: int 读取字节数, error 读取或取消错误 // 返回: int 读取字节数, error 读取或取消错误
func (r contextInput) Read(data []byte) (int, error) { func (r contextInput) Read(data []byte) (int, error) {
if err := r.context.Err(); err != nil { if err := r.ctx.Err(); err != nil {
return 0, err return 0, err
} }
return r.ReadCloser.Read(data) return r.ReadCloser.Read(data)
+154 -11
View File
@@ -56,7 +56,7 @@ func (s rasterImageSource) RGBA64At(x, y int) color.RGBA64 {
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)} return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
} }
// drawRasterAxisImage 按像素中心进行轴向缩放,逐行复用横向采样并保留镜像与小数坐标 // drawRasterAxisImage 轴向缩放复用逐行采样,细轴按面积覆盖并保留镜像与小数坐标
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作 // 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作
// 返回: bool 是否使用轴向采样 // 返回: bool 是否使用轴向采样
func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool { func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool {
@@ -78,8 +78,14 @@ func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.
return true return true
} }
if float64(r.Dx())*math.Min(float64(r.Dy()), math.Ceil(4*math.Max(1, sy))+2) > rasterScaleBufferLimit/32 { if float64(r.Dx())*math.Min(float64(r.Dy()), math.Ceil(4*math.Max(1, sy))+2) > rasterScaleBufferLimit/32 {
if sx*float64(b.Dx()) < 1 || sy*float64(b.Dy()) < 1 {
return drawRasterFootprintImage(dst, src, m, op, r)
}
return false return false
} }
if !rasterFootprintWeightsFit(b, r, m) {
return drawRasterFootprintImage(dst, src, m, op, r)
}
xs := rasterImageAxis(r.Min.X, r.Max.X, b.Min.X, b.Max.X, m[0], m[4]) xs := rasterImageAxis(r.Min.X, r.Max.X, b.Min.X, b.Max.X, m[0], m[4])
ys := rasterImageAxis(r.Min.Y, r.Max.Y, b.Min.Y, b.Max.Y, m[3], m[5]) ys := rasterImageAxis(r.Min.Y, r.Max.Y, b.Min.Y, b.Max.Y, m[3], m[5])
if len(xs) == 0 || len(ys) == 0 { if len(xs) == 0 || len(ys) == 0 {
@@ -139,10 +145,13 @@ func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.
return true return true
} }
// rasterImageAxis 按连续源坐标生成CatmullRom权重,不对图像几何边界取整 // rasterImageAxis 按连续源坐标生成CatmullRom权重,亚像素细轴使用面积覆盖权重
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离 // 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
// 返回: []rasterAxisSample 轴向采样表 // 返回: []rasterAxisSample 轴向采样表
func rasterImageAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample { func rasterImageAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample {
if math.Abs(scale)*float64(limit-source) < 1 {
return rasterImageFootprintAxis(first, end, source, limit, scale, offset)
}
result := make([]rasterAxisSample, 0, end-first) result := make([]rasterAxisSample, 0, end-first)
argument := math.Min(1, math.Abs(scale)) argument := math.Min(1, math.Abs(scale))
support := draw.CatmullRom.Support / argument support := draw.CatmullRom.Support / argument
@@ -183,17 +192,150 @@ func rasterImageAxis(first, end, source, limit int, scale, offset float64) []ras
return result return result
} }
// rasterFootprintWeightsFit 检查细轴权重表的合计预算,乘法前排除溢出
// 入参: source 源边界, target 目标范围, m 像素变换
// 返回: bool 权重表是否符合预算
func rasterFootprintWeightsFit(source, target image.Rectangle, m [6]float64) bool {
budget := rasterScaleBufferLimit / 8
for _, axis := range [...]struct {
source, target int
scale float64
}{{source.Dx(), target.Dx(), m[0]}, {source.Dy(), target.Dy(), m[3]}} {
if math.Abs(axis.scale)*float64(axis.source) < 1 && axis.target > 0 {
n, samples := axis.source, min(2, axis.target)
if n > budget/samples {
return false
}
budget -= n * samples
}
}
return true
}
// rasterImageWeightSpan 提供单个目标像素的源范围和权重,不分配整轴权重表
// 入参: pixel 目标像素, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
// 返回: int 源起点, int 源终点, func(int)float64 源权重
func rasterImageWeightSpan(pixel, source, limit int, scale, offset float64) (int, int, func(int) float64) {
if math.Abs(scale)*float64(limit-source) < 1 {
left, right := (float64(pixel)-offset)/scale, (float64(pixel+1)-offset)/scale
if right < left {
left, right = right, left
}
if right <= float64(source) || left >= float64(limit) {
return 0, 0, nil
}
return source, limit, func(x int) float64 {
return math.Max(0, math.Min(float64(x)+1, right)-math.Max(float64(x), left)) * math.Abs(scale)
}
}
center := (float64(pixel) + .5 - offset) / scale
if center < float64(source) || center >= float64(limit) {
return 0, 0, nil
}
center -= .5
argument := math.Min(1, math.Abs(scale))
support := draw.CatmullRom.Support / argument
start := int(max(float64(source), math.Floor(center-support)))
stop := int(min(float64(limit), math.Ceil(center+support)))
weight := func(x int) float64 {
distance := math.Abs((float64(x) - center) * argument)
if distance < draw.CatmullRom.Support {
return draw.CatmullRom.At(distance)
}
return 0
}
var total float64
for x := start; x < stop; x++ {
total += weight(x)
}
return start, stop, func(x int) float64 { return weight(x) / total }
}
// drawRasterFootprintImage 在细轴权重超预算时按原累加顺序逐点绘制
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作, target 目标范围
// 返回: bool 是否完成绘制
func drawRasterFootprintImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op, target image.Rectangle) bool {
pixels, ok := src.(image.RGBA64Image)
if !ok {
pixels = rasterImageSource{src}
}
b := src.Bounds()
output := image.NewRGBA64(image.Rect(0, 0, 1, 1))
var value [1]rasterImagePixel
for y := target.Min.Y; y < target.Max.Y; y++ {
firstY, endY, wy := rasterImageWeightSpan(y, b.Min.Y, b.Max.Y, m[3], m[5])
if firstY == endY {
continue
}
for x := target.Min.X; x < target.Max.X; x++ {
firstX, endX, wx := rasterImageWeightSpan(x, b.Min.X, b.Max.X, m[0], m[4])
if firstX == endX {
continue
}
value[0] = rasterImagePixel{}
for sy := firstY; sy < endY; sy++ {
var row rasterImagePixel
for sx := firstX; sx < endX; sx++ {
p, weight := pixels.RGBA64At(sx, sy), wx(sx)
if weight != 0 {
row.r += float64(p.R) * weight
row.g += float64(p.G) * weight
row.b += float64(p.B) * weight
row.a += float64(p.A) * weight
}
}
if weight := wy(sy); weight != 0 {
value[0].r += row.r * weight
value[0].g += row.g * weight
value[0].b += row.b * weight
value[0].a += row.a * weight
}
}
rasterImageWriteRow(dst, output, value[:], x, y, op)
}
}
return true
}
// rasterImageFootprintAxis 对不足一个目标像素的整轴保留真实交叠面积及预乘透明度
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
// 返回: []rasterAxisSample 轴向覆盖权重
func rasterImageFootprintAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample {
lo, hi := float64(source)*scale+offset, float64(limit)*scale+offset
if hi < lo {
lo, hi = hi, lo
}
result := make([]rasterAxisSample, 0, min(2, end-first))
for pixel := first; pixel < end; pixel++ {
if math.Min(float64(pixel+1), hi) > math.Max(float64(pixel), lo) {
result = append(result, rasterAxisSample{pixel: pixel, first: source})
}
}
buffer := make([]float64, len(result)*(limit-source))
for index := range result {
sample := &result[index]
left, right := (float64(sample.pixel)-offset)/scale, (float64(sample.pixel+1)-offset)/scale
if right < left {
left, right = right, left
}
n := limit - source
sample.weights, buffer = buffer[:n:n], buffer[n:]
for i := range sample.weights {
x := float64(source + i)
sample.weights[i] = math.Max(0, math.Min(x+1, right)-math.Max(x, left)) * math.Abs(scale)
}
}
return result
}
// rasterImageHorizontal 按源列复用像素,保留各目标像素的权重累加顺序与中间精度 // rasterImageHorizontal 按源列复用像素,保留各目标像素的权重累加顺序与中间精度
// 入参: row 横向缓冲, src 源像素, samples 采样表, y 源行坐标 // 入参: row 横向缓冲, src 源像素, samples 采样表, y 源行坐标
func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, samples []rasterAxisSample, y int) { func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, samples []rasterAxisSample, y int) {
clear(row) clear(row)
first, last := samples[0], samples[len(samples)-1] first, last := samples[0], samples[len(samples)-1]
reversed := first.first > last.first || first.first == last.first && len(first.weights) > len(last.weights) reversed := first.first > last.first || first.first == last.first && len(first.weights) > len(last.weights)
start, end := samples[0].first, samples[0].first+len(samples[0].weights) start := min(first.first, last.first)
for _, sample := range samples[1:] { end := max(first.first+len(first.weights), last.first+len(last.weights))
start = min(start, sample.first)
end = max(end, sample.first+len(sample.weights))
}
active, next := 0, 0 active, next := 0, 0
for x := start; x < end; x++ { for x := start; x < end; x++ {
for next < len(samples) { for next < len(samples) {
@@ -217,6 +359,7 @@ func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, sample
active++ active++
} }
p := src.RGBA64At(x, y) p := src.RGBA64At(x, y)
r, g, b, a := float64(p.R), float64(p.G), float64(p.B), float64(p.A)
for column := active; column < next; column++ { for column := active; column < next; column++ {
index := column index := column
if reversed { if reversed {
@@ -226,10 +369,10 @@ func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, sample
weight := sample.weights[x-sample.first] weight := sample.weights[x-sample.first]
if weight != 0 { if weight != 0 {
value := &row[index] value := &row[index]
value.r += float64(p.R) * weight value.r += r * weight
value.g += float64(p.G) * weight value.g += g * weight
value.b += float64(p.B) * weight value.b += b * weight
value.a += float64(p.A) * weight value.a += a * weight
} }
} }
} }
+6 -2
View File
@@ -42,10 +42,14 @@ func StorageCost(img image.Image, limit int) int {
} }
cost += cap(encoded.profile) cost += cap(encoded.profile)
if encoded.profile != nil || encoded.pngColor != nil || encoded.config.ColorModel == color.CMYKModel { if encoded.profile != nil || encoded.pngColor != nil || encoded.config.ColorModel == color.CMYKModel {
if w > (limit-cost)/12/h { pixelSize := 12
if encoded.config.ColorModel == color.RGBA64Model || encoded.config.ColorModel == color.NRGBA64Model {
pixelSize = 16
}
if w > (limit-cost)/pixelSize/h {
return limit + 1 return limit + 1
} }
pixels = w * h * 12 pixels = w * h * pixelSize
} }
if result := encoded.result.Load(); result != nil && result.image != nil { if result := encoded.result.Load(); result != nil && result.image != nil {
pixels = max(pixels, StorageCost(result.image, limit-cost)) pixels = max(pixels, StorageCost(result.image, limit-cost))
+1 -1
View File
@@ -165,7 +165,7 @@ func readTIFFDirectory(ctx context.Context, input io.Reader, order binary.ByteOr
if offset < position { if offset < position {
return result, position, ErrTIFFRandomAccess return result, position, ErrTIFFRandomAccess
} }
reader := contextInput{ReadCloser: io.NopCloser(input), context: ctx} reader := contextInput{ReadCloser: io.NopCloser(input), ctx: ctx}
if _, err := io.CopyN(io.Discard, reader, offset-position); err != nil { if _, err := io.CopyN(io.Discard, reader, offset-position); err != nil {
return result, position, err return result, position, err
} }
+34 -10
View File
@@ -49,6 +49,36 @@ func (s *maskedTIFFImage) Bounds() image.Rectangle { return s.bounds }
// 返回: color.Model 颜色模型 // 返回: color.Model 颜色模型
func (s *maskedTIFFImage) ColorModel() color.Model { return color.NRGBA64Model } func (s *maskedTIFFImage) ColorModel() color.Model { return color.NRGBA64Model }
// samplePoint 将显示坐标映射到主图或蒙版的像素中心
// 入参: bounds 采样图像范围, x 横坐标, y 纵坐标
// 返回: image.Point 采样坐标
func (s *maskedTIFFImage) samplePoint(bounds image.Rectangle, x, y int) image.Point {
size := s.bounds.Size()
x, y = x-s.bounds.Min.X, y-s.bounds.Min.Y
if bounds.Size() == size {
return bounds.Min.Add(image.Pt(x, y))
}
return image.Pt(
bounds.Min.X+int((uint64(x)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X)),
bounds.Min.Y+int((uint64(y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y)),
)
}
// alphaAt 读取蒙版与主图透明度,不解算主图颜色
// 入参: x 横坐标, y 纵坐标
// 返回: uint32 十六位透明度
func (s *maskedTIFFImage) alphaAt(x, y int) uint32 {
if !image.Pt(x, y).In(s.bounds) {
return 0
}
mask := s.samplePoint(s.mask.Bounds(), x, y)
if NRGBA64At(s.mask, mask.X, mask.Y).R == 0 {
return 0
}
source := s.samplePoint(s.source.Bounds(), x, y)
return AlphaAt(s.source, source.X, source.Y)
}
// NRGBA64At 按像素中心读取主图及蒙版,零位隐藏原图并保留隐藏颜色 // NRGBA64At 按像素中心读取主图及蒙版,零位隐藏原图并保留隐藏颜色
// 入参: x 横坐标, y 纵坐标 // 入参: x 横坐标, y 纵坐标
// 返回: color.NRGBA64 非预乘十六位颜色 // 返回: color.NRGBA64 非预乘十六位颜色
@@ -56,16 +86,10 @@ func (s *maskedTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
if !image.Pt(x, y).In(s.bounds) { if !image.Pt(x, y).In(s.bounds) {
return color.NRGBA64{} return color.NRGBA64{}
} }
size := s.bounds.Size() source := s.samplePoint(s.source.Bounds(), x, y)
x, y = x-s.bounds.Min.X, y-s.bounds.Min.Y pixel := NRGBA64At(s.source, source.X, source.Y)
b := s.source.Bounds() mask := s.samplePoint(s.mask.Bounds(), x, y)
sx := b.Min.X + int((uint64(x)*uint64(b.Dx())+uint64(b.Dx()/2))/uint64(size.X)) if NRGBA64At(s.mask, mask.X, mask.Y).R == 0 {
sy := b.Min.Y + int((uint64(y)*uint64(b.Dy())+uint64(b.Dy()/2))/uint64(size.Y))
pixel := NRGBA64At(s.source, sx, sy)
b = s.mask.Bounds()
mx := b.Min.X + int((uint64(x)*uint64(b.Dx())+uint64(b.Dx()/2))/uint64(size.X))
my := b.Min.Y + int((uint64(y)*uint64(b.Dy())+uint64(b.Dy()/2))/uint64(size.Y))
if NRGBA64At(s.mask, mx, my).R == 0 {
pixel.A = 0 pixel.A = 0
} }
return pixel return pixel
+2 -10
View File
@@ -99,11 +99,11 @@ func applyImageColorProfile(ctx context.Context, source image.Image, profile *pd
model := source.ColorModel() model := source.ColorModel()
premultiplied := model == color.RGBAModel || model == color.RGBA64Model premultiplied := model == color.RGBAModel || model == color.RGBA64Model
table := newTIFFProfileTable(source) table := newTIFFProfileTable(source)
components, intent := profile.Components(), profile.DefaultIntent()
result := image.NewNRGBA64(bounds) result := image.NewNRGBA64(bounds)
var previous [4]float64 var previous [4]float64
var previousPixel color.NRGBA64 var previousPixel color.NRGBA64
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
misses := 0
for x := bounds.Min.X; x < bounds.Max.X; x++ { for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)&255 == 0 { if (x-bounds.Min.X)&255 == 0 {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -154,17 +154,13 @@ func applyImageColorProfile(ctx context.Context, source image.Image, profile *pd
alpha = pixel.A alpha = pixel.A
values = [4]float64{float64(pixel.R) / 65535, float64(pixel.G) / 65535, float64(pixel.B) / 65535} values = [4]float64{float64(pixel.R) / 65535, float64(pixel.G) / 65535, float64(pixel.B) / 65535}
} }
if misses < 32 {
if previousPixel.A != 0 && values == previous { if previousPixel.A != 0 && values == previous {
pixel := previousPixel pixel := previousPixel
pixel.A = alpha pixel.A = alpha
result.SetNRGBA64(x, y, pixel) result.SetNRGBA64(x, y, pixel)
misses = 0
continue continue
} }
previous = values previous = values
misses++
}
if linearSRGB { if linearSRGB {
for i := range 3 { for i := range 3 {
if values[i] <= 0.04045 { if values[i] <= 0.04045 {
@@ -174,7 +170,7 @@ func applyImageColorProfile(ctx context.Context, source image.Image, profile *pd
} }
} }
} }
rgb, err := profile.SRGB(values[:profile.Components()], profile.DefaultIntent()) rgb, err := profile.SRGB(values[:components], intent)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -182,11 +178,7 @@ func applyImageColorProfile(ctx context.Context, source image.Image, profile *pd
for i, value := range rgb { for i, value := range rgb {
channels[i] = uint16(math.Round(max(0, min(1, value)) * 65535)) channels[i] = uint16(math.Round(max(0, min(1, value)) * 65535))
} }
if misses < 32 {
previousPixel = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535} previousPixel = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535}
} else {
previousPixel.A = 0
}
if slot >= 0 { if slot >= 0 {
table[slot] = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535} table[slot] = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535}
} }
+3 -3
View File
@@ -211,11 +211,11 @@ func (s *u3dLineState) accumulateCorners(mesh *U3DMesh, shadingIndex uint32, cor
} }
// value 按指定上下文和位宽读取点集或线集字段 // value 按指定上下文和位宽读取点集或线集字段
// 入参: context 压缩上下文, small 是否为8位字段 // 入参: ctx 压缩上下文, small 是否为8位字段
// 返回: uint32 字段值 // 返回: uint32 字段值
func (r *u3dLineReader) value(context int, small bool) uint32 { func (r *u3dLineReader) value(ctx int, small bool) uint32 {
if r.bits != nil { if r.bits != nil {
return r.bits.dynamicValue(&r.hist[context], small) return r.bits.dynamicValue(&r.hist[ctx], small)
} }
if small { if small {
return uint32(r.values.u8()) return uint32(r.values.u8())
+4 -4
View File
@@ -167,15 +167,15 @@ func (r *u3dResourceReader) motionFrame(frame *U3DKeyFrame, previous U3DKeyFrame
} }
// motionDifference 用共享幅值上下文恢复一至三个差分分量 // motionDifference 用共享幅值上下文恢复一至三个差分分量
// 入参: previous 前一帧分量, scale 逆量化系数, context 符号上下文,下一项为幅值上下文 // 入参: previous 前一帧分量, scale 逆量化系数, ctx 符号上下文,下一项为幅值上下文
// 返回: [3]float32 重建分量, error 符号、数值或读取错误 // 返回: [3]float32 重建分量, error 符号、数值或读取错误
func (r *u3dResourceReader) motionDifference(previous []float32, scale float32, context int) (values [3]float32, err error) { func (r *u3dResourceReader) motionDifference(previous []float32, scale float32, ctx int) (values [3]float32, err error) {
signs := r.value(context, true) signs := r.value(ctx, true)
if signs >= 1<<len(previous) { if signs >= 1<<len(previous) {
return values, fmt.Errorf("invalid U3D motion difference signs") return values, fmt.Errorf("invalid U3D motion difference signs")
} }
for index, prior := range previous { for index, prior := range previous {
difference := float64(r.value(context+1, false)) * float64(scale) difference := float64(r.value(ctx+1, false)) * float64(scale)
if signs&(1<<index) != 0 { if signs&(1<<index) != 0 {
difference = -difference difference = -difference
} }
+3 -3
View File
@@ -30,9 +30,9 @@ type u3dResourceReader struct {
} }
// value 读取指定上下文的压缩字段,首次使用时初始化算术区间 // value 读取指定上下文的压缩字段,首次使用时初始化算术区间
// 入参: context 压缩上下文, small 是否为8位字段 // 入参: ctx 压缩上下文, small 是否为8位字段
// 返回: uint32 字段值 // 返回: uint32 字段值
func (r *u3dResourceReader) value(context int, small bool) uint32 { func (r *u3dResourceReader) value(ctx int, small bool) uint32 {
if r.err() != nil { if r.err() != nil {
return 0 return 0
} }
@@ -52,7 +52,7 @@ func (r *u3dResourceReader) value(context int, small bool) uint32 {
return nil return nil
} }
} }
return r.bits.dynamicValue(&r.hist[context], small) return r.bits.dynamicValue(&r.hist[ctx], small)
} }
// u32 读取普通32位字段,压缩开始后沿用当前算术区间 // u32 读取普通32位字段,压缩开始后沿用当前算术区间
+17
View File
@@ -0,0 +1,17 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package build
var Version string
+15
View File
@@ -340,3 +340,18 @@ func PaintChannels(values [4]float64, compatible bool, marked *[4]bool) [4]bool
} }
return channels return channels
} }
// OpaqueBlack 判断不受底层分色影响的全黑设备色
// 入参: paint PDF颜色与不透明度
// 返回: bool 是否为不透明全黑CMYK颜色
func OpaqueBlack(paint pdfgo.Paint) bool {
return paint.Alpha == 1 && paint.CMYK != nil && paint.CMYK[3] == 1
}
// OverprintNeedsSeparation 判断彩色CMYK套印是否依赖分色输出
// 入参: paint PDF颜色与不透明度
// 返回: bool 是否需要保留分色语义
func OverprintNeedsSeparation(paint pdfgo.Paint) bool {
_, process := paint.Process.CMYKMask()
return (paint.CMYK != nil || process) && !OpaqueBlack(paint)
}
+8 -3
View File
@@ -31,10 +31,11 @@ type TextPage struct {
Replacements []pdfgo.TextReplacement Replacements []pdfgo.TextReplacement
} }
// WriteOptions 保存PDF最终写出所需的像素、文字和导航修正 // WriteOptions 保存PDF最终写出所需的元数据、像素、文字和导航修正
// Images逐页记录绘制图像,nil图像表示不替换;Links保留全部链接位置,包括矩形区域的nil项 // Images逐页记录绘制图像,nil图像表示不替换;Links保留全部链接位置,包括矩形区域的nil项
// PrepareRewrite在页面引用确定后填充导航动作,不得提前写出PDF // PrepareRewrite在页面引用确定后填充导航动作,不得提前写出PDF
type WriteOptions struct { type WriteOptions struct {
Information pdfgo.Dictionary
Images [][]image.Image Images [][]image.Image
Text []TextPage Text []TextPage
Links map[int][]*pdfgo.LinkRegion Links map[int][]*pdfgo.LinkRegion
@@ -49,7 +50,7 @@ type WriteOptions struct {
// 返回: error 资源、压缩或写入错误 // 返回: error 资源、压缩或写入错误
func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOptions) error { func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOptions) error {
nativeWrite := settings.PrepareRewrite != nil nativeWrite := settings.PrepareRewrite != nil
if !settings.ExactImages && !settings.ExactText && !nativeWrite && settings.Optimization.Compression.Mode == pdfgo.CompressionUnchanged { if !settings.ExactImages && !settings.ExactText && !nativeWrite && len(settings.Information) == 0 && settings.Optimization.Compression.Mode == pdfgo.CompressionUnchanged {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
@@ -65,6 +66,10 @@ func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOpt
} }
defer reader.Close() defer reader.Close()
if !settings.ExactImages && !settings.ExactText && !nativeWrite { if !settings.ExactImages && !settings.ExactText && !nativeWrite {
if len(settings.Information) != 0 {
_, err := reader.RewriteTo(ctx, writer, pdfgo.RewriteOptions{Information: settings.Information, Optimization: settings.Optimization})
return err
}
_, err := reader.OptimizeTo(ctx, writer, settings.Optimization) _, err := reader.OptimizeTo(ctx, writer, settings.Optimization)
return err return err
} }
@@ -171,7 +176,7 @@ func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOpt
if pages != len(settings.Images) { if pages != len(settings.Images) {
return fmt.Errorf("PDF output page missing") return fmt.Errorf("PDF output page missing")
} }
options := pdfgo.RewriteOptions{Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, TextReplacements: texts, Optimization: settings.Optimization} options := pdfgo.RewriteOptions{Information: settings.Information, Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, TextReplacements: texts, Optimization: settings.Optimization}
if nativeWrite { if nativeWrite {
if err := settings.PrepareRewrite(ctx, reader, references, &options); err != nil { if err := settings.PrepareRewrite(ctx, reader, references, &options); err != nil {
return err return err
+66
View File
@@ -17,6 +17,8 @@ package pdf
import ( import (
"context" "context"
"fmt" "fmt"
"math"
"strconv"
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
@@ -99,3 +101,67 @@ func GlyphPath(outline basegeom.Path, matrix pdfgo.Matrix) (pdfgo.Path, error) {
} }
return path, nil return path, nil
} }
// PathBounds 逐段计算变换后的控制点边界,不创建临时点集合
// 入参: ctx 取消上下文, path PDF路径, matrix 坐标变换
// 返回: basegeom.Box 控制点边界,空路径返回零值, error 取消错误
func PathBounds(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix) (basegeom.Box, error) {
if err := ctx.Err(); err != nil {
return basegeom.Box{}, err
}
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
found := false
for _, segment := range path.Segments {
if err := ctx.Err(); err != nil {
return basegeom.Box{}, err
}
for index, point := range segment.Points {
if index&255 == 0 {
if err := ctx.Err(); err != nil {
return basegeom.Box{}, err
}
}
point = matrix.Apply(point)
minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y)
maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y)
found = true
}
}
if err := ctx.Err(); err != nil {
return basegeom.Box{}, err
}
if !found {
return basegeom.Box{}, nil
}
return basegeom.Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil
}
// PathData 转换路径并在分段处理时检查取消
// 入参: ctx 取消上下文, path PDF路径, matrix 坐标变换, origin 对象边界
// 返回: string OFD路径数据, error 取消错误
func PathData(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix, origin basegeom.Box) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
data := make([]byte, 0, len(path.Segments)*48)
for index, segment := range path.Segments {
if err := ctx.Err(); err != nil {
return "", err
}
if index != 0 {
data = append(data, ' ')
}
data = append(data, segment.Operator...)
for _, point := range segment.Points {
point = matrix.Apply(point)
data = append(data, ' ')
data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64)
data = append(data, ' ')
data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64)
}
}
if err := ctx.Err(); err != nil {
return "", err
}
return string(data), nil
}
+12 -12
View File
@@ -28,6 +28,18 @@ import (
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// StencilImage 按当前填充色读取模板图像,不分配整幅彩色样本
type StencilImage struct {
Source image.Image
Fill color.NRGBA64
}
// NativeImage 按源像素输出原生过程色,不分配完整显示颜色缓冲
type NativeImage struct {
Source *pdfgo.ImageProcess
Space *pdfgo.ColorSpace
}
// EncodeCMYKTIFF 将直接四色图像保存为十六位无损TIFF,不经过显示颜色转换 // EncodeCMYKTIFF 将直接四色图像保存为十六位无损TIFF,不经过显示颜色转换
// 入参: ctx 取消上下文, source PDF源图像 // 入参: ctx 取消上下文, source PDF源图像
// 返回: []byte 含ICC和非预乘透明度的TIFF,非四色图像为nil, error 解码或编码错误 // 返回: []byte 含ICC和非预乘透明度的TIFF,非四色图像为nil, error 解码或编码错误
@@ -143,18 +155,6 @@ func EncodeCMYKTIFF(ctx context.Context, source *pdfgo.Image) ([]byte, error) {
return data, nil return data, nil
} }
// StencilImage 按当前填充色读取模板图像,不分配整幅彩色样本
type StencilImage struct {
Source image.Image
Fill color.NRGBA64
}
// NativeImage 按源像素输出原生过程色,不分配完整显示颜色缓冲
type NativeImage struct {
Source *pdfgo.ImageProcess
Space *pdfgo.ColorSpace
}
// ColorModel 保留填充色和模板的有效采样精度 // ColorModel 保留填充色和模板的有效采样精度
// 返回: color.Model 非预乘颜色模型 // 返回: color.Model 非预乘颜色模型
func (s *StencilImage) ColorModel() color.Model { func (s *StencilImage) ColorModel() color.Model {
+306
View File
@@ -0,0 +1,306 @@
// 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 (
"context"
"fmt"
"math"
"slices"
"sync"
"github.com/xiaoqidun/pdfgo"
)
// meshVertex 保存局部毫米坐标及原曲面参数
type meshVertex struct {
point pdfgo.Point
u, v float64
}
// MeshTriangle 保存按输出精度细分的曲面网格
type MeshTriangle struct {
vertices [3]meshVertex
patch int
}
// meshSample 保存采样点参数,patch为一基编号,零表示未覆盖
type meshSample struct {
u, v float64
patch int
}
// MeshRasterOptions 保存网格采样的坐标、精度及并行配置
// Matrix将PDF页面坐标转换为毫米,Origin为输出块的毫米原点
type MeshRasterOptions struct {
Matrix pdfgo.Matrix
Origin pdfgo.Point
DPI float64
Width, Height int
Workers int
SoftMask bool
}
// MeshEdge 按坐标及颜色分量匹配相邻三角形的共享边,不改变顶点顺序
// 入参: previous 前一三角形, current 当前三角形
// 返回: int OFD共享边标志,零表示独立三角形
func MeshEdge(previous, current pdfgo.MeshTriangle) int {
if previous.Points[2] != current.Points[1] || !slices.Equal(previous.Colors[2], current.Colors[1]) {
return 0
}
for _, index := range [2]int{1, 0} {
if previous.Points[index] == current.Points[0] && slices.Equal(previous.Colors[index], current.Colors[0]) {
return 2 - index
}
}
return 0
}
// MeshTriangles 按曲面二阶导数界细分,将几何误差控制在输出像素的十六分之一内
// 入参: ctx 取消上下文, mesh 原始曲面, matrix 页面变换, dpi 输出精度
// 返回: []MeshTriangle 细分结果, error 取消或坐标错误
func MeshTriangles(ctx context.Context, mesh *pdfgo.MeshGradient, matrix pdfgo.Matrix, dpi float64) ([]MeshTriangle, error) {
matrix = matrix.Mul(mesh.Matrix)
var triangles []MeshTriangle
for index, patch := range mesh.Patches {
if err := ctx.Err(); err != nil {
return nil, err
}
for i := range patch.Points {
for j := range patch.Points[i] {
patch.Points[i][j] = matrix.Apply(patch.Points[i][j])
}
}
var duu, dvv, duv float64
for i := 0; i < 4; i++ {
for j := 0; j < 4; j++ {
a := patch.Points[i][j]
if i < 2 {
b, c := patch.Points[i+1][j], patch.Points[i+2][j]
duu = math.Max(duu, 6*math.Hypot(c.X-2*b.X+a.X, c.Y-2*b.Y+a.Y))
}
if j < 2 {
b, c := patch.Points[i][j+1], patch.Points[i][j+2]
dvv = math.Max(dvv, 6*math.Hypot(c.X-2*b.X+a.X, c.Y-2*b.Y+a.Y))
}
if i < 3 && j < 3 {
b, c, d := patch.Points[i+1][j], patch.Points[i][j+1], patch.Points[i+1][j+1]
duv = math.Max(duv, 9*math.Hypot(d.X-b.X-c.X+a.X, d.Y-b.Y-c.Y+a.Y))
}
}
}
steps := math.Max(1, math.Ceil(math.Sqrt(8*(duu+dvv+2*duv)*dpi/25.4)))
if !finite(steps) || steps >= math.Sqrt(float64(int(^uint(0)>>1)/2)) {
return nil, fmt.Errorf("mesh subdivision exceeds platform integer range")
}
n := int(steps)
row := make([]meshVertex, n+1)
for i := range row {
if i%CompositeTileSize == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
u := float64(i) / steps
row[i] = meshVertex{patch.PointAt(u, 0), u, 0}
}
for j := 1; j <= n; j++ {
if err := ctx.Err(); err != nil {
return nil, err
}
v := float64(j) / steps
left := meshVertex{patch.PointAt(0, v), 0, v}
for i := 1; i <= n; i++ {
u := float64(i) / steps
right := meshVertex{patch.PointAt(u, v), u, v}
a, b := row[i-1], row[i]
triangles = append(triangles, MeshTriangle{[3]meshVertex{a, b, left}, index}, MeshTriangle{[3]meshVertex{b, right, left}, index})
row[i-1], left = left, right
}
row[n] = left
}
}
for index, triangle := range mesh.Triangles {
if err := ctx.Err(); err != nil {
return nil, err
}
vertices := [3]meshVertex{
{matrix.Apply(triangle.Points[0]), 0, 0},
{matrix.Apply(triangle.Points[1]), 1, 0},
{matrix.Apply(triangle.Points[2]), 0, 1},
}
triangles = append(triangles, MeshTriangle{vertices, len(mesh.Patches) + index})
}
return triangles, nil
}
// MeshPixels 对输出块执行四倍超采样,曲面重叠按后补片及较大v、u取值
// 入参: ctx 取消上下文, mesh 原曲面, triangles 已细分网格, space 混合空间, converter 已准备的颜色转换, options 采样配置
// 返回: []ShadingPixel 颜色及覆盖, error 几何、颜色或取消错误
func MeshPixels(ctx context.Context, mesh *pdfgo.MeshGradient, triangles []MeshTriangle, space *pdfgo.ColorSpace, converter pdfgo.ColorConverter, options MeshRasterOptions) ([]ShadingPixel, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
const samples = 4
w, h := options.Width*samples, options.Height*samples
grid := make([]meshSample, w*h)
scale := options.DPI / 25.4 * samples
for _, triangle := range triangles {
if err := ctx.Err(); err != nil {
return nil, err
}
vertices := triangle.vertices
for i := range vertices {
vertices[i].point.X = (vertices[i].point.X - options.Origin.X) * scale
vertices[i].point.Y = (vertices[i].point.Y - options.Origin.Y) * scale
}
a, b, d := vertices[0].point, vertices[1].point, vertices[2].point
denominator := (b.Y-d.Y)*(a.X-d.X) + (d.X-b.X)*(a.Y-d.Y)
if denominator == 0 {
continue
}
minX, maxX := math.Min(a.X, math.Min(b.X, d.X)), math.Max(a.X, math.Max(b.X, d.X))
minY, maxY := math.Min(a.Y, math.Min(b.Y, d.Y)), math.Max(a.Y, math.Max(b.Y, d.Y))
if maxX < 0 || maxY < 0 || minX >= float64(w) || minY >= float64(h) {
continue
}
endX := int(math.Min(float64(w), math.Ceil(maxX)))
endY := int(math.Min(float64(h), math.Ceil(maxY)))
for y := int(math.Max(0, math.Floor(minY))); y < endY; y++ {
if err := ctx.Err(); err != nil {
return nil, err
}
for x := int(math.Max(0, math.Floor(minX))); x < endX; x++ {
px, py := float64(x)+.5, float64(y)+.5
l0 := ((b.Y-d.Y)*(px-d.X) + (d.X-b.X)*(py-d.Y)) / denominator
l1 := ((d.Y-a.Y)*(px-d.X) + (a.X-d.X)*(py-d.Y)) / denominator
l2 := 1 - l0 - l1
if l0 < -1e-10 || l1 < -1e-10 || l2 < -1e-10 {
continue
}
u := math.Max(0, math.Min(1, l0*vertices[0].u+l1*vertices[1].u+l2*vertices[2].u))
v := math.Max(0, math.Min(1, l0*vertices[0].v+l1*vertices[1].v+l2*vertices[2].v))
current := &grid[y*w+x]
if triangle.patch+1 > current.patch || triangle.patch+1 == current.patch && (v > current.v || v == current.v && u > current.u) {
*current = meshSample{u, v, triangle.patch + 1}
}
}
}
}
pixels := make([]ShadingPixel, options.Width*options.Height)
var background [4]float64
if mesh.Background != nil {
var err error
background, err = converter.Convert(mesh.Background[:mesh.Space.Components()])
if err != nil {
return nil, err
}
}
var inverse pdfgo.Matrix
if mesh.Bounds != nil {
var ok bool
inverse, ok = options.Matrix.Mul(mesh.Matrix).Inverse()
if !ok {
return nil, fmt.Errorf("singular mesh bounds transform")
}
}
sampleRows := func(start, end int) error {
colors := make(map[[4]float64][4]float64)
colorants := PrepareShadingColorants(pdfgo.Paint{Mesh: mesh}, space, options.SoftMask)
var sampler *pdfgo.MeshSampler
if colorants == nil {
sampler = mesh.NewSampler()
}
for y := start * samples; y < end*samples; y++ {
if err := ctx.Err(); err != nil {
return err
}
for x := 0; x < w; x++ {
if box := mesh.Bounds; box != nil {
point := inverse.Apply(pdfgo.Point{X: options.Origin.X + (float64(x)+.5)/scale, Y: options.Origin.Y + (float64(y)+.5)/scale})
if point.X < box.XMin || point.X > box.XMax || point.Y < box.YMin || point.Y > box.YMax {
continue
}
}
sample := grid[y*w+x]
if sample.patch == 0 && mesh.Background == nil {
continue
}
values := background
if sample.patch != 0 {
var err error
if colorants != nil {
values, err = colorants.MeshAt(sample.patch-1, sample.u, sample.v)
} else {
values, err = sampler.ValuesAt(sample.patch-1, sample.u, sample.v)
}
if err != nil {
return err
}
if cached, ok := colors[values]; ok {
values = cached
} else if colorants == nil {
source := values
values, err = converter.Convert(values[:mesh.Space.Components()])
if err != nil {
return err
}
if len(colors) == 2*CompositeTileSize {
clear(colors)
}
colors[source] = values
}
}
pixel := &pixels[(y/samples)*options.Width+x/samples]
for i, value := range values {
pixel.Values[i] += value
}
pixel.Shape++
}
}
return ctx.Err()
}
workers := min(max(1, options.Workers), max(1, options.Height/32))
if workers == 1 {
if err := sampleRows(0, options.Height); err != nil {
return nil, err
}
} else {
var group sync.WaitGroup
errors := make([]error, workers)
for worker := range workers {
group.Go(func() {
errors[worker] = sampleRows(worker*options.Height/workers, (worker+1)*options.Height/workers)
})
}
group.Wait()
for _, err := range errors {
if err != nil {
return nil, err
}
}
}
for i := range pixels {
pixel := &pixels[i]
if pixel.Shape == 0 {
continue
}
for j := range pixel.Values {
pixel.Values[j] /= pixel.Shape
}
pixel.Shape /= samples * samples
}
return pixels, nil
}
+85
View File
@@ -0,0 +1,85 @@
// 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 (
"fmt"
"strings"
"time"
"github.com/xiaoqidun/pdfgo"
)
// NormalizeDate 校验PDF日期,非严格模式修正单层外括号及完整时区偏移的分隔符
// 入参: text PDF日期字符串, strict 是否严格校验
// 返回: string 标准日期字符串, error 日期错误
func NormalizeDate(text string, strict bool) (string, error) {
if text == "" {
return text, nil
}
_, err := pdfgo.ParseDate(text)
if err == nil || strict {
return text, err
}
normalized := text
if strings.HasPrefix(normalized, "(") && strings.HasSuffix(normalized, ")") {
normalized = normalized[1 : len(normalized)-1]
if _, parseErr := pdfgo.ParseDate(normalized); parseErr == nil {
return normalized, nil
}
}
offset := len(normalized) - 5
if offset < 6 || normalized[offset] != '+' && normalized[offset] != '-' {
return text, err
}
for _, digit := range normalized[offset+1:] {
if digit < '0' || digit > '9' {
return text, err
}
}
normalized = normalized[:offset+3] + "'" + normalized[offset+3:] + "'"
if _, parseErr := pdfgo.ParseDate(normalized); parseErr != nil {
return text, err
}
return normalized, nil
}
// MetadataDate 保留当地日历日期,PDF省略时区按GMT,XMP省略日或时区时不推算
// 入参: text 来源日期, xmp 是否为XMP日期
// 返回: time.Time 解析值, bool 是否精确到日, bool 是否可比较绝对时间, error 日期错误
func MetadataDate(text string, xmp bool) (time.Time, bool, bool, error) {
if text == "" {
return time.Time{}, false, false, nil
}
if !xmp {
date, err := pdfgo.ParseDate(text)
return date, err == nil && date.Year() > 0, err == nil, err
}
if !strings.Contains(text, ",") {
for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04Z07:00", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02", "2006-01", "2006"} {
if date, err := time.Parse(layout, text); err == nil {
zoned := strings.Contains(layout, "Z07:00")
if zoned && !strings.HasSuffix(text, "Z") {
zone := text[len(text)-6:]
if zone[1:3] > "23" || zone[4:] > "59" {
return time.Time{}, false, false, fmt.Errorf("invalid XMP time zone")
}
}
return date, len(text) >= 10 && date.Year() > 0, zoned, nil
}
}
}
return time.Time{}, false, false, fmt.Errorf("invalid XMP date")
}
+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
}
+97
View File
@@ -0,0 +1,97 @@
// 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 "image"
// CompositeTileSize 限定单次合成块的像素边长
const CompositeTileSize = 256
// CompositeBuffers 在一次局部合成中保留至多八个块容量内的空闲缓冲
type CompositeBuffers[T any] [8][]T
// RasterBuffers 保存按像素尺寸复用的独占覆盖画布
type RasterBuffers [8]*image.RGBA
// CoverageRow 读取完整范围内的原生覆盖行,其他类型沿用逐点采样
// 入参: coverage 覆盖图像, y 行坐标, left 起始列, right 结束列
// 返回: []byte RGBA像素或空值
func CoverageRow(coverage image.Image, y, left, right int) []byte {
buffer, ok := coverage.(*image.RGBA)
if !ok || y < buffer.Rect.Min.Y || y >= buffer.Rect.Max.Y || left < buffer.Rect.Min.X || right > buffer.Rect.Max.X || left >= right {
return nil
}
start := buffer.PixOffset(left, y)
return buffer.Pix[start : start+4*(right-left)]
}
// Acquire 借用尚未初始化的独立缓冲,嵌套调用不复用活动缓冲
// 入参: count 所需元素数量
// 返回: []T 可由当前调用独占的缓冲
func (s *CompositeBuffers[T]) Acquire(count int) []T {
selected := -1
for i, buffer := range s {
if cap(buffer) >= count && (selected < 0 || cap(buffer) < cap(s[selected])) {
selected = i
}
}
if selected < 0 {
return make([]T, count)
}
buffer := s[selected]
s[selected] = nil
return buffer[:count]
}
// Release 归还已不再访问的缓冲,超出块容量或空闲槽数量时直接释放
// 入参: buffer 当前调用借用的缓冲
func (s *CompositeBuffers[T]) Release(buffer []T) {
if len(buffer) == 0 || cap(buffer) > CompositeTileSize*CompositeTileSize {
return
}
for i, stored := range s {
if stored == nil {
s[i] = buffer
return
}
}
}
// Acquire 借用尺寸相同的独占画布,不清空已有像素
// 入参: rect 输出像素范围
// 返回: *image.RGBA 当前调用独占的画布
func (s *RasterBuffers) Acquire(rect image.Rectangle) *image.RGBA {
for i, stored := range s {
if stored != nil && stored.Rect == rect {
s[i] = nil
return stored
}
}
return image.NewRGBA(rect)
}
// Release 归还块容量内的画布,超出容量或空闲槽数量时直接释放
// 入参: buffer 当前调用借用的画布
func (s *RasterBuffers) Release(buffer *image.RGBA) {
if buffer == nil || cap(buffer.Pix) > 4*CompositeTileSize*CompositeTileSize {
return
}
for i, stored := range s {
if stored == nil {
s[i] = buffer
return
}
}
}
+6
View File
@@ -18,6 +18,12 @@ import (
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// ShadingPixel 保存抗锯齿覆盖率与混合空间分量
type ShadingPixel struct {
Values [4]float64
Shape float64
}
// ShadingColorants 复用原始浓度缓冲及原生通道映射,不跨并发采样共享 // ShadingColorants 复用原始浓度缓冲及原生通道映射,不跨并发采样共享
type ShadingColorants struct { type ShadingColorants struct {
paint pdfgo.Paint paint pdfgo.Paint
+102
View File
@@ -0,0 +1,102 @@
// 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 (
"math"
"reflect"
"github.com/xiaoqidun/pdfgo"
)
// MergeReplacement 统一同一替换区段内的坐标原点,不重新塑形或调整字距
// 入参: marks 连续文字图元, replacement 整段原文
// 返回: *pdfgo.TextMark 合并文字, bool 是否可等价合并
func MergeReplacement(marks []*pdfgo.TextMark, replacement *pdfgo.ReplacementText) (*pdfgo.TextMark, bool) {
first := marks[0]
style := first.Style
paint := style.Fill
if first.Font == nil || len(first.Font.Program) == 0 || first.Mode != 0 || first.Size <= 0 || first.HorizontalScale <= 0 || first.Clip != nil || style.SoftMask != nil || style.Transfer != nil || style.AlphaIsShape || style.FillOverprint || paint.Alpha != 1 || !NormalBlend(style.BlendMode) || paint.Tiling != nil || paint.Axial != nil || paint.Radial != nil || paint.Mesh != nil || paint.Function != nil {
return nil, false
}
inverse, ok := first.Matrix.Inverse()
if !ok {
return nil, false
}
count := 0
sequence := first.Sequence
for _, mark := range marks {
if mark.Font != first.Font || mark.Size != first.Size || mark.HorizontalScale != first.HorizontalScale || mark.Mode != first.Mode || mark.Clip != nil || len(mark.Glyphs) != len(mark.Positions) || !reflect.DeepEqual(mark.Style, style) {
return nil, false
}
if mark.Matrix[0] != first.Matrix[0] || mark.Matrix[1] != first.Matrix[1] || mark.Matrix[2] != first.Matrix[2] || mark.Matrix[3] != first.Matrix[3] {
return nil, false
}
count += len(mark.Glyphs)
sequence = CommonSequence(sequence, mark.Sequence)
}
if count == 0 {
return nil, false
}
merged := *first
merged.Replacement = replacement
merged.Sequence = sequence
merged.Glyphs = make([]pdfgo.Glyph, 0, count)
merged.Positions = make([]pdfgo.Point, 0, count)
for _, mark := range marks {
dx, dy := mark.Matrix[4]-first.Matrix[4], mark.Matrix[5]-first.Matrix[5]
offset := pdfgo.Point{X: inverse[0]*dx + inverse[2]*dy, Y: inverse[1]*dx + inverse[3]*dy}
for _, point := range mark.Positions {
point.X += offset.X
point.Y += offset.Y
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) {
return nil, false
}
merged.Positions = append(merged.Positions, point)
}
merged.Glyphs = append(merged.Glyphs, mark.Glyphs...)
}
return &merged, true
}
// CommonSequence 查找两个内容区段的最近共同外层,不将合并内容归入单个子区段
// 入参: first 首个区段, second 后续区段
// 返回: *pdfgo.ContentSequence 共同区段,没有共同区段时为空
func CommonSequence(first, second *pdfgo.ContentSequence) *pdfgo.ContentSequence {
if first == second {
return first
}
if first == nil || second == nil {
return nil
}
depth := func(sequence *pdfgo.ContentSequence) int {
count := 0
for ; sequence != nil; sequence = sequence.Parent {
count++
}
return count
}
a, b := depth(first), depth(second)
for ; a > b; a-- {
first = first.Parent
}
for ; b > a; b-- {
second = second.Parent
}
for first != second {
first, second = first.Parent, second.Parent
}
return first
}
File diff suppressed because it is too large. Load diff
+7200 -68
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+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
}
+295
View File
@@ -0,0 +1,295 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build js && wasm
package webui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"syscall/js"
"github.com/xiaoqidun/ofdgo"
)
const (
platformTypeNumber = js.TypeNumber
platformTypeString = js.TypeString
platformTypeBoolean = js.TypeBoolean
platformTypeObject = js.TypeObject
platformTypeFunction = js.TypeFunction
)
// platformValue 浏览器参数值
type platformValue = js.Value
// wasmEngines 管理共享资源的独立浏览器业务会话
type wasmEngines struct {
resources *ofdgo.Resources
engines map[string]*businessEngine
}
// RunWASM 注册浏览器WASM接口并阻塞运行
func RunWASM() {
resources, _ := ofdgo.NewResources(ofdgo.ResourceOptions{Parallelism: 1, MemoryLimit: 64 << 20, CacheBytes: 48 << 20, SnapshotBytes: 32 << 20})
engines := &wasmEngines{resources: resources, engines: make(map[string]*businessEngine)}
registerCallback("ofdgoEngineOpen", engines.open)
registerAsyncCallback("ofdgoEngineCall", engines.call)
registerCallback("ofdgoEngineClose", engines.close)
select {}
}
// open 创建具有稳定标识的独立业务会话
// 入参: args 会话标识
// 返回: any 空结果, error 参数或会话错误
func (w *wasmEngines) open(args []js.Value) (any, error) {
if len(args) != 1 || args[0].Type() != js.TypeString || args[0].String() == "" {
return nil, fmt.Errorf("missing engine ID")
}
id := args[0].String()
if w.engines[id] != nil {
return nil, fmt.Errorf("engine already exists: %s", id)
}
w.engines[id] = &businessEngine{resources: w.resources}
return nil, nil
}
// call 按会话标识分派业务命令,统一绑定本次调用的取消上下文
// 入参: args 会话标识、命令、取消检查点及业务参数
// 返回: any 业务结果, error 调用错误
func (w *wasmEngines) call(args []js.Value) (any, error) {
if len(args) < 3 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString {
return nil, fmt.Errorf("missing engine ID or operation")
}
e := w.engines[args[0].String()]
if e == nil {
return nil, fmt.Errorf("unknown engine: %s", args[0].String())
}
name := args[1].String()
fn := e.businessCommands()[name]
if fn == nil {
return nil, fmt.Errorf("unknown operation: %s", name)
}
if err := e.checkDocument(name); err != nil {
return nil, err
}
e.ctx = context.Background()
if args[2].Type() == js.TypeFunction {
e.ctx = e.newOutputContext(args[2])
}
ctx := e.ctx
if name != "ofdgoSaveSnapshot" && e.currentSession != nil {
e.currentSession.Renderer.OutputContext = ctx
}
defer func() {
if e.ctx == ctx {
e.ctx = nil
}
if name != "ofdgoSaveSnapshot" && e.currentSession != nil {
e.currentSession.Renderer.OutputContext = context.Background()
}
}()
if err := ctx.Err(); err != nil {
return nil, err
}
if err := e.preparePDF(name, args[3:]); err != nil {
return nil, err
}
return fn(args[3:])
}
// close 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源
// 入参: args 会话标识
// 返回: any 空结果, error 参数或关闭错误
func (w *wasmEngines) close(args []js.Value) (any, error) {
if len(args) != 1 || args[0].Type() != js.TypeString || args[0].String() == "" {
return nil, fmt.Errorf("missing engine ID")
}
id := args[0].String()
e := w.engines[id]
if e == nil {
return nil, nil
}
delete(w.engines, id)
_, _ = e.discardPrint(nil)
e.clearPDF()
e.clearImport()
err := e.currentSession.Close()
*e = businessEngine{}
return nil, err
}
// awaitExport 等待浏览器处理导出检查点
// 入参: fn 浏览器回调, args 回调参数
// 返回: error 写入错误或取消原因
func awaitExport(fn js.Value, args ...any) error {
done := make(chan error)
callback := js.FuncOf(func(this js.Value, args []js.Value) any {
var err error
if args[1].Bool() {
err = context.Canceled
} else if message := args[0].String(); message != "" {
err = fmt.Errorf("%s", message)
}
done <- err
return nil
})
defer callback.Release()
fn.Invoke(append(args, callback)...)
return <-done
}
// registerCallback 注册浏览器回调函数
// 入参: name 回调名称, fn 回调函数
func registerCallback(name string, fn func([]js.Value) (any, error)) {
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
return callbackResult(fn, args)
})
js.Global().Set(name, cb)
}
// registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环
// 入参: name 回调名称, fn 回调函数
func registerAsyncCallback(name string, fn func([]js.Value) (any, error)) {
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
executor := js.FuncOf(func(this js.Value, callbacks []js.Value) any {
resolve := callbacks[0]
go func() {
resolve.Invoke(callbackResult(fn, args))
}()
return nil
})
defer executor.Release()
return js.Global().Get("Promise").New(executor)
})
js.Global().Set(name, cb)
}
// callbackResult 转换浏览器接口结果
// 入参: fn 回调函数, args 回调参数
// 返回: any 浏览器结果
func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
data, err := (&businessEngine{}).safeCall(fn, args)
if err != nil {
result := failureResult(err)
return (&businessEngine{}).encodeResult(result)
}
if result, ok := data.(js.Value); ok {
return result
}
return (&businessEngine{}).encodeResult(apiResult{OK: true, Data: data})
}
// loadConversionFont 按需读取浏览器已授权字体,不预读系统字体目录
// 入参: load 浏览器读取函数, name 字体文件名
// 返回: []byte 字体内容, error 读取错误或取消原因
func (e *businessEngine) loadConversionFont(load js.Value, name string) ([]byte, error) {
done := make(chan error, 1)
var data []byte
callback := js.FuncOf(func(this js.Value, args []js.Value) any {
var err error
if args[2].Bool() {
err = context.Canceled
} else if message := args[1].String(); message != "" {
err = fmt.Errorf("%s", message)
} else {
data, err = e.bytesFromJS(args[0])
}
done <- err
return nil
})
defer callback.Release()
load.Invoke(name, callback)
err := <-done
return data, err
}
// successResult 创建成功接口结果
// 入参: data 返回数据
// 返回: js.Value 接口结果
func successResult(data any) js.Value {
result := js.Global().Get("Object").New()
result.Set("ok", true)
result.Set("data", data)
return result
}
// bytesToJS 将字节数据转换为Uint8Array
// 入参: data 字节数据
// 返回: js.Value Uint8Array对象
func bytesToJS(data []byte) js.Value {
value := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(value, data)
return value
}
// bytesFromJS 从浏览器值读取二进制数据
// 入参: value 浏览器值
// 返回: []byte 二进制数据, error 错误信息
func (e *businessEngine) bytesFromJS(value js.Value) ([]byte, error) {
if value.IsUndefined() || value.IsNull() {
return nil, fmt.Errorf("missing binary data")
}
uint8Array := js.Global().Get("Uint8Array")
arrayBuffer := js.Global().Get("ArrayBuffer")
if value.InstanceOf(arrayBuffer) {
value = uint8Array.New(value)
}
if !value.InstanceOf(uint8Array) {
return nil, fmt.Errorf("binary data must be Uint8Array or ArrayBuffer")
}
data := make([]byte, value.Get("byteLength").Int())
if n := js.CopyBytesToGo(data, value); n != len(data) {
return nil, fmt.Errorf("copied %d of %d bytes", n, len(data))
}
return data, nil
}
// platformValueOf 转换浏览器参数
func platformValueOf(v any) platformValue { return js.ValueOf(v) }
// platformNull 返回空参数
func platformNull() platformValue { return js.Null() }
// platformCopyBytes 复制浏览器字节
func platformCopyBytes(dst []byte, src platformValue) int { return js.CopyBytesToGo(dst, src) }
// platformJSON 编码小参数
func platformJSON(v platformValue) string {
return js.Global().Get("JSON").Call("stringify", v).String()
}
// platformParseJSON 解析结构参数
func platformParseJSON(data []byte) map[string]any {
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
panic(err)
}
return result
}
// inputFromValue 读取浏览器随机输入或二进制数据
func (e *businessEngine) inputFromValue(value platformValue) (*ofdgo.Input, error) {
if value.Type() == platformTypeObject && !value.IsNull() && value.Get("read").Type() == platformTypeFunction {
return ofdgo.NewInput(&browserReaderAt{read: value.Get("read"), size: int64(value.Get("size").Float())}, int64(value.Get("size").Float()))
}
data, err := e.bytesFromJS(value)
if err != nil {
return nil, err
}
return ofdgo.NewInput(bytes.NewReader(data), int64(len(data)))
}
File diff suppressed because it is too large. Load diff
+622
View File
@@ -0,0 +1,622 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package webview
import (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"io/fs"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/xiaoqidun/ofdgo"
basefile "github.com/xiaoqidun/ofdgo/internal/base/file"
)
// fileEntry 保存窗口授权的文件或目录
type fileEntry struct {
path string
name string
data []byte
file *os.File
stop func() bool
directory bool
owned bool
temporary bool
saveTarget ofdgo.Output
readers int
retired bool
dropHandle string
}
// fileStore 管理窗口范围内的文件授权和临时数据
type fileStore struct {
mu sync.Mutex
entries map[string]*fileEntry
writers map[string]*fileWriter
temporary string
closed bool
}
// fileWriter 在同目录临时文件中保存输出
type fileWriter struct {
id string
store *fileStore
output ofdgo.WriteTransaction
}
// newFileStore 创建独立文件授权表
// 返回: *fileStore 文件授权表, error 创建错误
func newFileStore() (*fileStore, error) {
dir, err := temporaryDirectory()
if err != nil {
return nil, err
}
return &fileStore{entries: make(map[string]*fileEntry), writers: make(map[string]*fileWriter), temporary: dir}, nil
}
// temporaryDirectory 创建独立临时目录
// 返回: string 目录路径, error 创建错误
func temporaryDirectory() (string, error) {
for i := 0; i < 100; i++ {
n, err := rand.Int(rand.Reader, big.NewInt(10000000))
if err != nil {
return "", err
}
path := filepath.Join(os.TempDir(), fmt.Sprintf("ofdgo-%07d", n.Int64()))
err = os.Mkdir(path, 0700)
if os.IsExist(err) {
continue
}
return path, err
}
return "", os.ErrExist
}
// temporaryFile 创建临时文件
// 入参: dir 所在目录, prefix 文件名前缀
// 返回: *os.File 临时文件, error 创建错误
func temporaryFile(dir, prefix string) (*os.File, error) {
for i := 0; i < 100; i++ {
n, err := rand.Int(rand.Reader, big.NewInt(10000000))
if err != nil {
return nil, err
}
path := filepath.Join(dir, fmt.Sprintf("%s%07d.tmp", prefix, n.Int64()))
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if os.IsExist(err) {
continue
}
return file, err
}
return nil, os.ErrExist
}
// add 登记已有授权文件
// 入参: name 文件路径, directory 是否为目录
// 返回: map[string]any 句柄描述, error 登记错误
func (s *fileStore) add(name string, directory bool) (map[string]any, error) {
info, err := os.Stat(name)
if err != nil {
return nil, err
}
if info.IsDir() != directory {
return nil, fmt.Errorf("文件类型不匹配")
}
s.mu.Lock()
defer s.mu.Unlock()
id := rand.Text()
entry := &fileEntry{path: name, name: filepath.Base(name), directory: directory}
if !directory {
entry.saveTarget, err = ofdgo.NewFileOutput(name)
if err != nil {
return nil, err
}
}
s.entries[id] = entry
return s.descriptor(id), nil
}
// addDrop 登记拖放文件及读取快照,失败时撤销本次授权
// 入参: name 拖放文件路径
// 返回: map[string]any 文件描述, error 登记错误
func (s *fileStore) addDrop(name string) (map[string]any, error) {
handle, err := s.add(name, false)
if err != nil {
return nil, err
}
id := handle["nativeHandle"].(string)
file, err := s.file(id)
s.mu.Lock()
defer s.mu.Unlock()
if err == nil && !s.closed {
if entry := s.entries[file["nativeFile"].(string)]; entry != nil && s.entries[id] != nil {
entry.dropHandle = id
file["drop"] = true
return file, nil
}
err = os.ErrNotExist
}
delete(s.entries, id)
if file != nil {
key := file["nativeFile"].(string)
if entry := s.entries[key]; entry != nil {
delete(s.entries, key)
if entry.file != nil {
entry.file.Close()
}
}
}
if err == nil {
err = os.ErrClosed
}
return nil, err
}
// discardDrop 撤销未接收的拖放文件及其授权,不删除源文件
// 入参: id 拖放读取快照标识
func (s *fileStore) discardDrop(id string) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.dropHandle == "" {
return
}
delete(s.entries, entry.dropHandle)
delete(s.entries, id)
entry.retired = true
if entry.file != nil && entry.readers == 0 {
entry.file.Close()
}
}
// save 登记用户选择的输出路径
// 入参: name 输出文件路径
// 返回: map[string]any 句柄描述
func (s *fileStore) save(name string) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
id := rand.Text()
s.entries[id] = &fileEntry{path: name, name: filepath.Base(name)}
return s.descriptor(id)
}
// descriptor 返回文件句柄,不暴露本机路径
// 入参: id 授权标识
// 返回: map[string]any 句柄描述
func (s *fileStore) descriptor(id string) map[string]any {
entry := s.entries[id]
kind := "file"
if entry.directory {
kind = "directory"
}
return map[string]any{"nativeHandle": id, "name": entry.name, "kind": kind}
}
// file 创建独立读取快照并返回资源描述
// 入参: id 授权标识
// 返回: map[string]any 资源描述, error 读取错误
func (s *fileStore) file(id string) (map[string]any, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.directory {
return nil, fmt.Errorf("文件授权无效")
}
handle := s.descriptor(id)
size := int64(len(entry.data))
if entry.data == nil {
file, err := basefile.Open(entry.path)
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
size = info.Size()
id = rand.Text()
entry = &fileEntry{path: entry.path, name: entry.name, file: file}
s.entries[id] = entry
}
return map[string]any{"nativeFile": id, "name": entry.name, "size": size, "url": "/__native/data/" + id, "handle": handle}, nil
}
// resolve 按授权标识读取文件并保持随机读取句柄
// 入参: id 授权标识
// 返回: io.ReaderAt 随机读取器, int64 文件大小, error 读取错误
func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.directory {
return nil, 0, fmt.Errorf("文件授权无效")
}
if entry.data != nil {
return bytes.NewReader(entry.data), int64(len(entry.data)), nil
}
if entry.file == nil {
file, err := basefile.Open(entry.path)
if err != nil {
return nil, 0, err
}
entry.file = file
}
info, err := entry.file.Stat()
if err != nil {
return nil, 0, err
}
return entry.file, info.Size(), nil
}
// bytes 登记原生输出,页面退出时释放未读取数据
// 入参: ctx 页面上下文, data 输出数据
// 返回: map[string]any 资源描述,页面取消或授权表关闭时返回nil
func (s *fileStore) bytes(ctx context.Context, data []byte) map[string]any {
if data == nil {
data = []byte{}
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || ctx.Err() != nil {
return nil
}
id := rand.Text()
s.entries[id] = &fileEntry{data: data, name: "data", stop: context.AfterFunc(ctx, func() { s.release(id) })}
return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true}
}
// resource 创建独立资源读取句柄
// 入参: id 文件标识
// 返回: io.ReaderAt 随机读取器, int64 文件大小, func() error 关闭函数, error 读取错误
func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.directory {
return nil, 0, nil, os.ErrNotExist
}
if entry.data != nil {
return bytes.NewReader(entry.data), int64(len(entry.data)), nil, nil
}
if entry.file != nil && !entry.temporary {
info, err := entry.file.Stat()
if err != nil {
return nil, 0, nil, err
}
entry.readers++
var once sync.Once
var closeErr error
return entry.file, info.Size(), func() error {
once.Do(func() {
s.mu.Lock()
defer s.mu.Unlock()
entry.readers--
if entry.retired && entry.readers == 0 {
closeErr = entry.file.Close()
}
})
return closeErr
}, nil
}
file, err := basefile.Open(entry.path)
if err != nil {
return nil, 0, nil, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, nil, err
}
return file, info.Size(), file.Close, nil
}
// release 释放读取快照、内存结果或临时文件,保留在途读取及源文件
// 入参: id 资源标识
func (s *fileStore) release(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if entry := s.entries[id]; entry != nil && (entry.data != nil || entry.temporary || entry.file != nil) {
delete(s.entries, id)
entry.retired = true
if entry.stop != nil {
entry.stop()
}
if entry.file != nil && entry.readers == 0 {
entry.file.Close()
}
if entry.temporary {
os.Remove(entry.path)
}
}
}
// child 在已授权目录中查找或创建直接子项
// 入参: id 目录授权标识, name 子项名称, directory 是否为目录, create 是否允许创建
// 返回: map[string]any 句柄描述, error 访问错误
func (s *fileStore) child(id, name string, directory, create bool) (map[string]any, error) {
if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") {
return nil, fmt.Errorf("文件名无效")
}
s.mu.Lock()
defer s.mu.Unlock()
parent := s.entries[id]
if parent == nil || !parent.directory {
return nil, fmt.Errorf("目录授权无效")
}
path := filepath.Join(parent.path, name)
for key, entry := range s.entries {
if entry.path == path && entry.directory == directory {
if _, err := os.Stat(path); err == nil {
return s.descriptor(key), nil
}
}
}
root, err := os.OpenRoot(parent.path)
if err != nil {
return nil, err
}
defer root.Close()
info, err := root.Stat(name)
created := false
if errors.Is(err, os.ErrNotExist) && create {
if directory {
err = root.Mkdir(name, 0755)
} else {
var file *os.File
file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err == nil {
err = file.Close()
}
}
if err != nil {
return nil, err
}
created = true
info, err = root.Stat(name)
}
if err != nil {
return nil, err
}
if info.IsDir() != directory {
return nil, fmt.Errorf("文件类型不匹配")
}
key := rand.Text()
s.entries[key] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: created}
return s.descriptor(key), nil
}
// reserve 为批量输出独占创建入口并避开已有文件
// 入参: id 目录授权标识, base 基础名称, extension 扩展名, directory 是否创建目录
// 返回: string 输出基础名称, string 输出入口名称, error 创建错误
func (s *fileStore) reserve(id, base, extension string, directory bool) (string, string, error) {
if !fs.ValidPath(base) || base == "." || strings.ContainsAny(base+extension, "/\\:") {
return "", "", fmt.Errorf("文件名无效")
}
s.mu.Lock()
defer s.mu.Unlock()
parent := s.entries[id]
if parent == nil || !parent.directory {
return "", "", fmt.Errorf("目录授权无效")
}
root, err := os.OpenRoot(parent.path)
if err != nil {
return "", "", err
}
defer root.Close()
for suffix := 0; ; suffix++ {
stem := base
if suffix > 0 {
stem = fmt.Sprintf("%s (%d)", base, suffix+1)
}
name := stem
if !directory {
name += "." + extension
}
if directory {
err = root.Mkdir(name, 0755)
} else {
var file *os.File
file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err == nil {
err = file.Close()
if err != nil {
root.Remove(name)
}
}
}
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", "", err
}
s.entries[rand.Text()] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: true}
return stem, name, nil
}
}
// begin 创建文件写入事务
// 入参: ctx 取消上下文, id 输出授权标识
// 返回: *fileWriter 事务写入器, error 创建错误
func (s *fileStore) begin(ctx context.Context, id string) (*fileWriter, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, os.ErrClosed
}
entry := s.entries[id]
if entry == nil || entry.directory || entry.path == "" {
return nil, fmt.Errorf("输出授权无效")
}
if entry.saveTarget == nil {
var err error
entry.saveTarget, err = ofdgo.NewFileOutput(entry.path)
if err != nil {
return nil, err
}
}
output, err := entry.saveTarget.Begin(ctx)
if err != nil {
return nil, err
}
writer := &fileWriter{id: rand.Text(), store: s, output: output}
s.writers[writer.id] = writer
return writer, nil
}
// remove 清理由当前窗口创建的目录子项
// 入参: id 目录授权标识, name 子项名称, recursive 是否递归删除
// 返回: error 删除错误
func (s *fileStore) remove(id, name string, recursive bool) error {
if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") {
return fmt.Errorf("文件名无效")
}
s.mu.Lock()
defer s.mu.Unlock()
parent := s.entries[id]
if parent == nil || !parent.directory {
return fmt.Errorf("目录授权无效")
}
target := filepath.Join(parent.path, name)
owned := false
for _, entry := range s.entries {
if entry.path == target && entry.owned {
owned = true
break
}
}
if !owned {
if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("只能清理本次创建的输出")
}
root, err := os.OpenRoot(parent.path)
if err != nil {
return err
}
defer root.Close()
for key, entry := range s.entries {
if entry.path == target || recursive && strings.HasPrefix(entry.path, target+string(filepath.Separator)) {
entry.retired = true
if entry.file != nil && entry.readers == 0 {
entry.file.Close()
}
delete(s.entries, key)
}
}
if recursive {
return root.RemoveAll(name)
}
return root.Remove(name)
}
// Write 追加输出数据
// 入参: data 输出数据
// 返回: int 写入字节数, error 写入错误
func (w *fileWriter) Write(data []byte) (int, error) {
return w.output.Write(data)
}
// Close 同步并替换目标文件
// 返回: error 提交错误
func (w *fileWriter) Close() error {
defer w.release()
if err := w.output.Commit(); err != nil {
return errors.Join(err, w.output.Abort())
}
return nil
}
// Abort 删除未提交的临时输出
// 返回: error 清理错误
func (w *fileWriter) Abort() error {
defer w.release()
return w.output.Abort()
}
// release 移除已结束的写入事务
func (w *fileWriter) release() {
w.store.mu.Lock()
defer w.store.mu.Unlock()
delete(w.store.writers, w.id)
}
// serve 提供窗口内资源,不建立网络监听器
// 入参: w 响应写入器, r 资源请求
func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
id := strings.TrimPrefix(r.URL.Path, "/__native/data/")
reader, size, err := s.resolve(id)
if err != nil {
http.NotFound(w, r)
return
}
s.mu.Lock()
entry := s.entries[id]
s.mu.Unlock()
if entry == nil {
http.NotFound(w, r)
return
}
http.ServeContent(w, r, entry.name, time.Time{}, io.NewSectionReader(reader, 0, size))
}
// close 释放授权文件与未提交输出
// 返回: error 清理错误
func (s *fileStore) close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
writers := s.writers
entries := s.entries
s.writers = make(map[string]*fileWriter)
s.entries = make(map[string]*fileEntry)
s.mu.Unlock()
var err error
for _, writer := range writers {
err = errors.Join(err, writer.Abort())
}
for _, entry := range entries {
if entry.stop != nil {
entry.stop()
}
s.mu.Lock()
entry.retired = true
if entry.file != nil && entry.readers == 0 {
err = errors.Join(err, entry.file.Close())
}
s.mu.Unlock()
}
return errors.Join(err, os.RemoveAll(s.temporary))
}
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package webview
import (
"os"
"path/filepath"
"strings"
)
// conversionMemory 未提供系统内存探测时仅按并行数调度
var conversionMemory func() int64
// instanceDirectory 解析数据目录的实际路径,使符号链接复用同一实例
// 入参: path 数据目录路径
// 返回: string 规范路径, error 路径解析错误
func instanceDirectory(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
// fontNameLanguage 获取Linux区域设置对应的OpenType名称表标识
// 返回: uint16 名称表平台标识, uint16 显示语言标识
func fontNameLanguage() (uint16, uint16) {
locale := ""
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
if locale = os.Getenv(name); locale != "" {
break
}
}
locale = strings.ToLower(strings.ReplaceAll(locale, "-", "_"))
locale, _, _ = strings.Cut(locale, ".")
locale, _, _ = strings.Cut(locale, "@")
switch locale {
case "zh", "zh_cn", "zh_hans":
return 3, 0x0804
case "zh_hk":
return 3, 0x0c04
case "zh_mo":
return 3, 0x1404
case "zh_tw", "zh_hant":
return 3, 0x0404
case "zh_sg":
return 3, 0x1004
case "ja", "ja_jp":
return 3, 0x0411
case "ko", "ko_kr":
return 3, 0x0412
case "c", "posix", "en", "en_us":
return 3, 0x0409
}
return 0xffff, 0xffff
}
+35
View File
@@ -0,0 +1,35 @@
// 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 && !linux
package webview
import "path/filepath"
// conversionMemory 未提供系统内存探测时仅按并行数调度
var conversionMemory func() int64
// instanceDirectory 解析数据目录的实际路径
// 入参: path 数据目录路径
// 返回: string 规范路径, error 路径解析错误
func instanceDirectory(path string) (string, error) {
return filepath.EvalSymlinks(path)
}
// fontNameLanguage 未指定系统显示语言时沿用字体名称表顺序
// 返回: uint16 未指定平台标识, uint16 未指定语言标识
func fontNameLanguage() (uint16, uint16) {
return 0xffff, 0xffff
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !js || !wasm
package webview
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/xiaoqidun/guigo"
webuiassets "github.com/xiaoqidun/ofdgo/assets/webui"
)
// applicationTheme 保存应用主题偏好及最后一次解析的系统外观
type applicationTheme struct {
Mode string `json:"mode"`
Dark bool `json:"dark"`
}
// themeColors 返回应用对应外观的原生标题栏配色
// 入参: dark 是否使用深色
// 返回: guigo.TitleBarColors 标题栏配色
func themeColors(dark bool) guigo.TitleBarColors {
if dark {
return guigo.TitleBarColors{Background: "#20272b", Foreground: "#e0e7eb", InactiveBackground: "#242b30", InactiveForeground: "#9aa8b1"}
}
return guigo.TitleBarColors{Background: "#e8edee", Foreground: "#263238", InactiveBackground: "#eef1f3", InactiveForeground: "#728087"}
}
// loadTheme 在创建窗口前读取用户目录中的主题偏好
// 返回: error 读取或解析错误
func (h *Host) loadTheme() error {
h.theme = applicationTheme{Mode: "system"}
data, err := os.ReadFile(h.themePath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if err := json.Unmarshal(data, &h.theme); err != nil {
return err
}
return h.theme.validate()
}
// validate 检查应用主题及解析后的配色是否一致
// 返回: error 偏好错误
func (a applicationTheme) validate() error {
if a.Mode != "system" && a.Mode != "light" && a.Mode != "dark" {
return fmt.Errorf("主题无效")
}
if a.Mode == "light" && a.Dark || a.Mode == "dark" && !a.Dark {
return fmt.Errorf("主题配色无效")
}
return nil
}
// themeIndex 在页面首次绘制前提供当前主题偏好
// 返回: []byte 页面内容, error 资源读取错误
func (h *Host) themeIndex() ([]byte, error) {
data, err := webuiassets.NativeFS.ReadFile("index.html")
if err != nil {
return nil, err
}
h.themeMu.Lock()
mode := h.theme.Mode
h.themeMu.Unlock()
if mode == "" {
mode = "system"
}
return bytes.Replace(data, []byte(`<html lang="zh-CN">`), []byte(`<html lang="zh-CN" data-theme-mode="`+mode+`">`), 1), nil
}
// setTheme 串行保存应用偏好并同步当前窗口配色
// 入参: value 已解析的主题偏好
// 返回: error 校验、持久化或窗口更新错误
func (h *Host) setTheme(value applicationTheme) error {
if err := value.validate(); err != nil {
return err
}
h.themeMu.Lock()
defer h.themeMu.Unlock()
h.mu.Lock()
window := h.window
h.mu.Unlock()
if window == nil {
return fmt.Errorf("窗口尚未就绪")
}
if h.theme != value {
data, err := json.Marshal(value)
if err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(h.themePath), "appearance-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
_, writeErr := file.Write(data)
closeErr := file.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
if err := os.Rename(file.Name(), h.themePath); err != nil {
return err
}
h.theme = value
}
return window.SetTitleBarColors(themeColors(value.Dark))
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package webview
import (
"os"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// memoryStatusProc 查询系统物理内存和提交额度
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名称表标识
// 返回: uint16 名称表平台标识, uint16 系统显示语言标识
func fontNameLanguage() (uint16, uint16) {
language, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetUserDefaultUILanguage").Call()
return 3, uint16(language)
}
// conversionMemory 预留系统内存后返回可用于新增转换的额度
// 返回: int64 可用字节数,查询失败时暂停扩容
func conversionMemory() int64 {
status := struct {
length uint32
load uint32
total uint64
available uint64
totalPage uint64
availablePage uint64
totalVirtual uint64
availableVirtual uint64
extended uint64
}{}
status.length = uint32(unsafe.Sizeof(status))
ok, _, _ := memoryStatusProc.Call(uintptr(unsafe.Pointer(&status)))
if ok == 0 {
return 0
}
return max(0, int64(min(status.available, status.availablePage))-int64(status.total/8))
}
+253 -25
View File
@@ -17,42 +17,267 @@ package ofdgo
import ( import (
"archive/zip" "archive/zip"
"bytes"
"context"
"errors"
"fmt"
"image" "image"
"io" "io"
"io/fs" "io/fs"
"os"
"runtime"
"sync"
basefile "github.com/xiaoqidun/ofdgo/internal/base/file"
) )
// Open 打开OFD文件 // Input 统一文件、内存及顺序流的随机读取,可并发读取和关闭,不可复制
// 返回的阅读器持有文件,使用完毕后需调用Close // 调用方应在阅读、编辑及转换结束后调用Close,借入的数据需在此期间保持不变
// 入参: path 文件路径, options 阅读选项 // 借入的随机读取器应支持并发ReadAt,Close不会关闭调用方传入的读取器
// 返回: *Reader 阅读器实例, error 错误信息 type Input struct {
func Open(path string, options ...ReaderOption) (*Reader, error) { mu sync.RWMutex
r, err := zip.OpenReader(path) reader io.ReaderAt
if err != nil { size int64
return nil, err path string
} file *os.File
reader := &Reader{ temp bool
Path: path,
Zip: &r.Reader,
Closer: r,
}
if err := reader.open(options); err != nil {
reader.Close()
return nil, err
}
return reader, nil
} }
// NewReader 从IO读取器创建OFD阅读器 // InputOptions 设置顺序输入的缓存及大小限制,单位为字节
// 读取器的生命周期由调用方管理,Reader.Close不会关闭它 // MemoryLimit为0时使用8MiB,浏览器WASM仅用内存;负值表示仅用内存
// 入参: r IO读取器, size 数据大小, options 阅读选项 // TempDir为空时使用系统临时目录,不自动创建调用方指定的目录
// MaxSize为0时不限制输入大小,OpenFile和ReadInput均检查此限制
// 无临时文件系统的平台可显式使用仅内存模式,并通过MaxSize限制输入
type InputOptions struct {
MemoryLimit int64
MaxSize int64
TempDir string
}
// OpenFile 打开文件输入,不复制文件内容,Close释放文件句柄
// 入参: ctx 取消上下文, path 文件路径, options 输入选项
// 返回: *Input 输入源, error 打开或大小错误
func OpenFile(ctx context.Context, path string, options InputOptions) (*Input, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
f, err := basefile.Open(path)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
if !info.Mode().IsRegular() {
f.Close()
return nil, fmt.Errorf("input is not a regular file")
}
input, err := NewInput(f, info.Size())
if err != nil {
f.Close()
return nil, err
}
input.file, input.path = f, path
if err := input.checkOptions(ctx, options); err != nil {
input.Close()
return nil, err
}
return input, nil
}
// NewInput 借用指定长度的随机读取源,不复制数据或接管其生命周期
// 入参: reader 随机读取器, size 数据大小
// 返回: *Input 输入源, error 参数错误
func NewInput(reader io.ReaderAt, size int64) (*Input, error) {
if reader == nil || size < 0 {
return nil, fmt.Errorf("invalid input")
}
return &Input{reader: reader, size: size}, nil
}
// ReadInput 从当前位置接收顺序输入,超过内存阈值后转入临时文件
// 已知大小且支持随机访问的可定位读取器直接复用,不移动其当前位置
// 不关闭传入流;取消无法中断阻塞的Read,调用方应自行设置超时或关闭流
// 入参: ctx 取消上下文, reader 输入流, options 输入选项
// 返回: *Input 输入源, error 读取、缓存或取消错误
func ReadInput(ctx context.Context, reader io.Reader, options InputOptions) (*Input, error) {
if reader == nil || options.MaxSize < 0 {
return nil, fmt.Errorf("invalid input options")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if random, ok := reader.(io.ReaderAt); ok {
if seeker, ok := reader.(io.Seeker); ok {
var size int64 = -1
if sized, ok := reader.(interface{ Size() int64 }); ok {
size = sized.Size()
} else if stated, ok := reader.(interface{ Stat() (os.FileInfo, error) }); ok {
info, err := stated.Stat()
if err != nil {
return nil, err
}
if info.Mode().IsRegular() {
size = info.Size()
}
}
if size >= 0 {
offset, err := seeker.Seek(0, io.SeekCurrent)
if err != nil {
return nil, err
}
if offset < 0 || offset > size {
return nil, fmt.Errorf("invalid input offset")
}
input, err := NewInput(io.NewSectionReader(random, offset, size-offset), size-offset)
if err != nil {
return nil, err
}
if err := input.checkOptions(ctx, options); err != nil {
return nil, err
}
return input, nil
}
}
}
limit := options.MemoryLimit
if limit == 0 {
limit = 8 << 20
if runtime.GOOS == "js" {
limit = -1
}
}
input := &Input{}
success := false
defer func() {
if !success {
input.Close()
}
}()
var memory bytes.Buffer
buffer := make([]byte, 64<<10)
empty := 0
for {
if err := ctx.Err(); err != nil {
return nil, err
}
n, err := reader.Read(buffer)
if n < 0 || n > len(buffer) {
return nil, fmt.Errorf("invalid input read count")
}
if err != nil && err != io.EOF {
return nil, err
}
if failure := ctx.Err(); failure != nil {
return nil, failure
}
if n > 0 {
empty = 0
if input.size > int64(^uint64(0)>>1)-int64(n) || options.MaxSize > 0 && int64(n) > options.MaxSize-input.size {
return nil, fmt.Errorf("input exceeds size limit")
}
if input.file == nil && limit >= 0 && input.size+int64(n) > limit {
file, failure := basefile.NewTemp(options.TempDir)
if failure != nil {
return nil, failure
}
input.file, input.temp = file, true
if _, failure = file.Write(memory.Bytes()); failure != nil {
return nil, failure
}
memory = bytes.Buffer{}
}
var output io.Writer = &memory
if input.file != nil {
output = input.file
}
if _, failure := output.Write(buffer[:n]); failure != nil {
return nil, failure
}
input.size += int64(n)
} else if err == nil {
empty++
if empty >= 100 {
return nil, io.ErrNoProgress
}
}
if err != nil {
break
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
input.reader = bytes.NewReader(memory.Bytes())
if input.file != nil {
input.reader = input.file
}
success = true
return input, nil
}
// checkOptions 检查取消状态及输入大小
// 入参: ctx 取消上下文, options 输入选项
// 返回: error 取消或大小错误
func (s *Input) checkOptions(ctx context.Context, options InputOptions) error {
if options.MaxSize < 0 || options.MaxSize > 0 && s.size > options.MaxSize {
return fmt.Errorf("input exceeds size limit")
}
return ctx.Err()
}
// Size 返回输入字节数,关闭后仍可查询
// 返回: int64 数据大小
func (s *Input) Size() int64 {
return s.size
}
// ReadAt 读取指定位置的数据,关闭后返回os.ErrClosed
// 入参: data 目标缓冲区, offset 字节偏移
// 返回: int 读取字节数, error 读取错误
func (s *Input) ReadAt(data []byte, offset int64) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.reader == nil {
return 0, os.ErrClosed
}
return io.NewSectionReader(s.reader, 0, s.size).ReadAt(data, offset)
}
// Close 释放自有文件和缓存,等待正在进行的读取结束,可重复调用
// 返回: error 文件关闭或临时文件删除错误
func (s *Input) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.reader = nil
if s.file == nil {
return nil
}
file := s.file
s.file = nil
err := file.Close()
if s.temp {
err = errors.Join(err, os.Remove(file.Name()))
}
return err
}
// NewReader 从统一输入源创建OFD阅读器,不接管输入源生命周期
// 阅读器及其派生编辑器使用结束后,调用方再关闭输入源
// 入参: input 输入源, options 阅读选项
// 返回: *Reader 阅读器实例, error 错误信息 // 返回: *Reader 阅读器实例, error 错误信息
func NewReader(r io.ReaderAt, size int64, options ...ReaderOption) (*Reader, error) { func NewReader(input *Input, options ...ReaderOption) (*Reader, error) {
zr, err := zip.NewReader(r, size) if input == nil {
return nil, fmt.Errorf("missing input")
}
zr, err := zip.NewReader(input, input.Size())
if err != nil { if err != nil {
return nil, err return nil, err
} }
reader := &Reader{ reader := &Reader{
Path: input.path,
Zip: zr, Zip: zr,
} }
if err := reader.open(options); err != nil { if err := reader.open(options); err != nil {
@@ -65,6 +290,9 @@ func NewReader(r io.ReaderAt, size int64, options ...ReaderOption) (*Reader, err
// 入参: reader 阅读器, opts 渲染选项 // 入参: reader 阅读器, opts 渲染选项
// 返回: *Renderer 渲染器实例 // 返回: *Renderer 渲染器实例
func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer {
if reader.resources == nil {
reader.resources, _ = NewResources(ResourceOptions{})
}
r := &Renderer{ r := &Renderer{
Reader: reader, Reader: reader,
DPI: 300.0, DPI: 300.0,
@@ -72,7 +300,7 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer {
DrawParams: reader.drawParamCache, DrawParams: reader.drawParamCache,
CompositeGraphicUnits: reader.compositeGraphicUnitCache, CompositeGraphicUnits: reader.compositeGraphicUnitCache,
templatePageCache: make(map[string]*PageContent), templatePageCache: make(map[string]*PageContent),
imageCache: &renderCache[string, image.Image]{limit: imageCacheLimit}, imageCache: resourceCache[string, image.Image](reader.resources, imageCacheLimit),
backends: defaultRenderBackends(), backends: defaultRenderBackends(),
backendStates: make(map[any]any), backendStates: make(map[any]any),
} }
+2 -2
View File
@@ -276,7 +276,7 @@ func (r *Reader) parseAnnotations(ctx context.Context, doc *Document) error {
continue continue
} }
var pageAnnot PageAnnot var pageAnnot PageAnnot
err = xml.NewDecoder(imageInput{ReadCloser: af, context: ctx}).Decode(&pageAnnot) err = xml.NewDecoder(imageInput{ReadCloser: af, ctx: ctx}).Decode(&pageAnnot)
_ = af.Close() _ = af.Close()
if err != nil { if err != nil {
continue continue
@@ -310,7 +310,7 @@ func (r *Reader) annotationIndexContext(ctx context.Context, doc *Document) (Ann
} }
defer f.Close() defer f.Close()
var annotations Annotations var annotations Annotations
if err := xml.NewDecoder(imageInput{ReadCloser: f, context: ctx}).Decode(&annotations); err != nil { if err := xml.NewDecoder(imageInput{ReadCloser: f, ctx: ctx}).Decode(&annotations); err != nil {
return Annotations{}, err return Annotations{}, err
} }
pageIDs := make(map[string]string, len(doc.Pages.Page)) pageIDs := make(map[string]string, len(doc.Pages.Page))
+6 -7
View File
@@ -28,8 +28,7 @@ import (
// ArchiveFile 描述已生成的归档文件,不关闭调用方提供的源读取器 // ArchiveFile 描述已生成的归档文件,不关闭调用方提供的源读取器
type ArchiveFile struct { type ArchiveFile struct {
Name string Name string
Source io.ReaderAt Input *Input
Size int64
} }
// ArchiveOptions 设置ZIP封装,压缩不重新编码归档内已生成的文档 // ArchiveOptions 设置ZIP封装,压缩不重新编码归档内已生成的文档
@@ -47,13 +46,13 @@ func WriteArchive(ctx context.Context, writer io.Writer, files []ArchiveFile, op
} }
names := map[string]bool{} names := map[string]bool{}
for _, file := range files { for _, file := range files {
if !fs.ValidPath(file.Name) || file.Name == "." || strings.Contains(file.Name, "\\") || names[file.Name] || file.Source == nil || file.Size < 0 { if !fs.ValidPath(file.Name) || file.Name == "." || strings.Contains(file.Name, "\\") || names[file.Name] || file.Input == nil {
return 0, fmt.Errorf("invalid archive file %q", file.Name) return 0, fmt.Errorf("invalid archive file %q", file.Name)
} }
names[file.Name] = true names[file.Name] = true
} }
var count int64 var count int64
archive := zip.NewWriter(convertWriter{context: ctx, writer: writer, count: &count}) archive := zip.NewWriter(convertWriter{ctx: ctx, writer: writer, count: &count})
configureArchive(archive, options.Compression) configureArchive(archive, options.Compression)
var buffer []byte var buffer []byte
for i, file := range files { for i, file := range files {
@@ -77,15 +76,15 @@ func WriteArchive(ctx context.Context, writer io.Writer, files []ArchiveFile, op
if err != nil { if err != nil {
return count, err return count, err
} }
input := convertReader{context: ctx, source: file.Source} input := convertReader{ctx: ctx, input: file.Input}
if buffer == nil { if buffer == nil {
buffer = make([]byte, 32<<10) buffer = make([]byte, 32<<10)
} }
written, err := io.CopyBuffer(entry, io.NewSectionReader(input, 0, file.Size), buffer) written, err := io.CopyBuffer(entry, io.NewSectionReader(input, 0, file.Input.Size()), buffer)
if err != nil { if err != nil {
return count, err return count, err
} }
if written != file.Size { if written != file.Input.Size() {
return count, io.ErrUnexpectedEOF return count, io.ErrUnexpectedEOF
} }
} }
+13 -13
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编码,不能缩小时终止本次压缩
@@ -99,13 +100,16 @@ func (e *Editor) WriteToWithOptions(ctx context.Context, writer io.Writer, optio
return 0, err return 0, err
} }
var count int64 var count int64
return snapshot.WriteTo(convertWriter{context: ctx, writer: writer, count: &count}) return snapshot.WriteTo(convertWriter{ctx: ctx, writer: writer, count: &count})
} }
// WritePagesToWithOptions 按指定顺序另存页面,仅为所选页面准备资源和压缩计划 // WritePagesTo 按指定顺序另存页面,仅为所选页面准备资源和压缩计划
// 保留关联资源、目录、注释和签章外观,生成新文档标识,不改变原文档和撤销记录
// 签章外观不代表原签名在导出文档中仍有效,页码不可重复或为空
// 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置 // 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置
// 返回: int64 已写入字节数, error 错误信息 // 返回: int64 已写入字节数, error 错误信息
func (e *Editor) WritePagesToWithOptions(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) { func (e *Editor) WritePagesTo(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) {
ctx = e.runtimeResources.context(ctx)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return 0, err return 0, err
} }
@@ -116,13 +120,14 @@ func (e *Editor) WritePagesToWithOptions(ctx context.Context, writer io.Writer,
snapshot.OnWriteProgress = editorProgress(e.OnWriteProgress).withContext(ctx) snapshot.OnWriteProgress = editorProgress(e.OnWriteProgress).withContext(ctx)
snapshot.output = &outputOptimization{ctx: ctx, options: options.Compression} snapshot.output = &outputOptimization{ctx: ctx, options: options.Compression}
var count int64 var count int64
return snapshot.WritePagesTo(convertWriter{context: ctx, writer: writer, count: &count}, indexes) return snapshot.writePagesTo(convertWriter{ctx: ctx, writer: writer, count: &count}, indexes)
} }
// outputSnapshot 创建只读输出配置,原签名存在时仅调整ZIP封装 // outputSnapshot 创建只读输出配置,原签名存在时仅调整ZIP封装
// 入参: ctx 取消上下文, options 输出配置 // 入参: ctx 取消上下文, options 输出配置
// 返回: *Editor 输出快照, error 配置或资源错误 // 返回: *Editor 输出快照, error 配置或资源错误
func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) { func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) {
ctx = e.runtimeResources.context(ctx)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return nil, err return nil, err
} }
@@ -130,12 +135,7 @@ func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Edi
return nil, err return nil, err
} }
snapshot := *e snapshot := *e
snapshot.OnWriteProgress = func(stage string, completed, total int) error { snapshot.OnWriteProgress = editorProgress(e.OnWriteProgress).withContext(ctx)
if err := ctx.Err(); err != nil {
return err
}
return editorProgress(e.OnWriteProgress).report(stage, completed, total)
}
snapshot.output = &outputOptimization{ctx: ctx, options: options.Compression} snapshot.output = &outputOptimization{ctx: ctx, options: options.Compression}
if options.Compression.Mode == CompressionUnchanged { if options.Compression.Mode == CompressionUnchanged {
return &snapshot, nil return &snapshot, nil
@@ -216,7 +216,7 @@ func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
buffer := bufio.NewReader(imageInput{ReadCloser: input, context: ctx}) buffer := bufio.NewReader(imageInput{ReadCloser: input, ctx: ctx})
found, err := editorXMLPrefix(buffer) found, err := editorXMLPrefix(buffer)
if err != nil || !found && (structured || strings.EqualFold(path.Ext(name), ".xml")) { if err != nil || !found && (structured || strings.EqualFold(path.Ext(name), ".xml")) {
safe = false safe = false
@@ -437,7 +437,7 @@ func (e *Editor) compressOutputFile(file *zip.File, buffer []byte, limit int) ([
deflater.Reset(output) deflater.Reset(output)
} }
defer deflater.Close() defer deflater.Close()
source := imageInput{ReadCloser: input, context: e.output.ctx} source := imageInput{ReadCloser: input, ctx: e.output.ctx}
_, err = io.CopyBuffer(deflater, source, buffer) _, err = io.CopyBuffer(deflater, source, buffer)
if output.exceeded { if output.exceeded {
_, err = io.CopyBuffer(io.Discard, source, buffer) _, err = io.CopyBuffer(io.Discard, source, buffer)
@@ -547,7 +547,7 @@ func (e *Editor) writeOutputFile(archive *zip.Writer, file *zip.File, buffer []b
if err != nil { if err != nil {
return err return err
} }
if _, err := io.CopyBuffer(entry, imageInput{ReadCloser: io.NopCloser(payload), context: e.output.ctx}, buffer); err != nil { if _, err := io.CopyBuffer(entry, imageInput{ReadCloser: io.NopCloser(payload), ctx: e.output.ctx}, buffer); err != nil {
return err return err
} }
e.output.completed++ e.output.completed++
-110
View File
@@ -1,110 +0,0 @@
// 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"
"encoding/xml"
"strings"
)
// compressionFontUsage 按文档合并共享字体用字,版本编辑仅统计选中入口
// 入参: ctx 取消上下文, documents 文档入口, resources 资源索引, references 各文件引用, read 输出文件读取方法
// 返回: map[string]*editorFontUsage 各字体文件的用字,无法确定作用域时为空, error 读取或取消错误
func (r *Reader) compressionFontUsage(ctx context.Context, documents map[string]bool, resources []string, references map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) {
selected := ""
if r.OFD.DocBody[r.documentIndex].versioned {
var err error
selected, err = r.DocumentRootContext(ctx)
if err != nil {
return nil, err
}
}
scoped, err := resourceReferenceScopes(ctx, documents, resources, references, selected)
if err != nil || scoped == nil {
return nil, err
}
return resourceFontUsage(ctx, resources, scoped, read)
}
// resourceFontUsage 按已确认的文档作用域合并各字体文件的字符和字形引用
// 入参: ctx 取消上下文, resources 资源索引, scoped 各索引的用字范围, read 输出文件读取方法
// 返回: map[string]*editorFontUsage 各字体文件的用字, error 读取或取消错误
func resourceFontUsage(ctx context.Context, resources []string, scoped map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) {
result := make(map[string]*editorFontUsage)
protected := make(map[string]bool)
for _, name := range resources {
if err := ctx.Err(); err != nil {
return nil, err
}
refs := scoped[name]
if refs == nil {
continue
}
data, err := read(name)
if err != nil {
return nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, err
}
for font := range editorResourceNodes(root, "Fonts", "Font") {
if err := ctx.Err(); err != nil {
return nil, err
}
file := font.child("FontFile")
if file == nil {
continue
}
var value string
if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil {
return nil, err
}
location := editorResourceLocation(name, root.attr("BaseLoc"), value)
alternate := cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))
id, charset := editorResourceID(font.attr("ID")), font.attr("Charset")
unsafe := id == "" || charset != "" && !strings.EqualFold(charset, "unicode") || location != alternate
for i, path := range [2]string{location, alternate} {
if i == 1 && path == location {
continue
}
if result[path] == nil {
result[path] = newEditorFontUsage()
}
result[path].merge(refs.fonts[id], unsafe)
}
}
for media := range editorResourceNodes(root, "MultiMedias", "MultiMedia") {
file := media.child("MediaFile")
if file == nil {
continue
}
var value string
if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil {
return nil, err
}
for _, location := range []string{editorResourceLocation(name, root.attr("BaseLoc"), value), cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))} {
protected[location] = true
}
}
}
for location := range protected {
if usage := result[location]; usage != nil {
usage.unsafe = true
}
}
return result, ctx.Err()
}
+177
View File
@@ -15,10 +15,17 @@
package ofdgo package ofdgo
import ( import (
"bytes"
"context" "context"
"encoding/binary"
"errors"
"fmt" "fmt"
"image" "image"
"io"
"math" "math"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
"github.com/xiaoqidun/pdfgo"
) )
// compressionPatternImageLimit 限制底纹尺寸分析的变换组合数,超限时保留原图尺寸 // compressionPatternImageLimit 限制底纹尺寸分析的变换组合数,超限时保留原图尺寸
@@ -397,3 +404,173 @@ func (v *compressionImages) patternImages(color *FillColor, object Matrix) error
} }
return nil return nil
} }
// outputImageFormat 识别可由资源压缩生成的图片格式
// 入参: data 图片编码
// 返回: string 标准资源格式,未识别时为空
func outputImageFormat(data []byte) string {
if baseimage.IsJPEG(data) {
return "JPEG"
}
if baseimage.IsPNG(data) {
return "PNG"
}
return ""
}
// optimizeImageResource 优化可完整保留的BMP及TIFF,复用图像压缩和降采样策略
// 原始模式及无法保留的色彩配置不转换,仅采用小于原始编码的结果
// 入参: ctx 取消上下文, data 图片编码, options 压缩选项, size 像素需求
// 返回: []byte 图片编码,可能引用输入, error 解码、编码或取消错误
func optimizeImageResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) {
if err := options.Validate(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if options.Mode != CompressionUnchanged && baseimage.IsTIFF(data) {
return optimizeTIFFResource(ctx, data, options, size)
}
if options.Mode == CompressionUnchanged || !bytes.HasPrefix(data, []byte("BM")) {
return pdfgo.OptimizeImageResource(ctx, data, options, size)
}
return optimizeBMPResource(ctx, data, options, size)
}
// optimizeBMPResource 压缩可保留色彩与密度的位图,未知元数据及高精度位域保持原样
// 入参: ctx 取消上下文, data BMP编码, options 压缩选项, size 像素需求
// 返回: []byte 图片编码, error 解码、编码或取消错误
func optimizeBMPResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) {
offset, width, height, depth, compression, err := baseimage.ParseBMPHeader(data)
if err != nil {
return data, nil
}
if height < 0 {
height = -height
}
if int64(width)*int64(height) > outputOptimizationBufferLimit/4 || compression != 0 && compression != 3 {
return data, nil
}
header := binary.LittleEndian.Uint32(data[14:18])
if header != 40 && header != 108 && header != 124 {
return data, nil
}
if header >= 108 && binary.LittleEndian.Uint32(data[70:74]) != 0x73524742 {
return data, nil
}
if header == 124 && (binary.LittleEndian.Uint32(data[122:126]) != 0 || binary.LittleEndian.Uint32(data[126:130]) != 0 || binary.LittleEndian.Uint32(data[130:134]) != 0) {
return data, nil
}
stride := (int64(width)*int64(depth) + 31) / 32 * 4
if int64(offset)+stride*int64(height) != int64(len(data)) {
return data, nil
}
density, ok := baseimage.BMPDensity(data)
if !ok {
return data, nil
}
if depth == 16 || depth == 32 {
masks, err := baseimage.BMPColorMasks(data, offset, depth, compression)
if err != nil {
return data, nil
}
if options.Mode == CompressionLossless {
for _, mask := range masks {
if mask.Max > 255 {
return data, nil
}
}
}
}
var pixels image.Image
if depth == 16 || compression == 3 {
pixels, err = baseimage.DecodeBMPContext(ctx, data)
} else {
pixels, _, err = image.Decode(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
}
if err != nil {
return nil, err
}
return optimizeRasterResource(ctx, data, pixels, options, size, density)
}
// optimizeRasterResource 编码已解码的资源图片,同步降采样后的像素密度,仅采用更小结果
// 先降采样再编码,绝对密度按最终尺寸量化,相对密度保留精确比例
// 有密度时保留PNG,未知密度按最终像素选择编码格式
// 入参: ctx 取消上下文, data 原编码, pixels 像素, options 压缩选项, size 像素需求, density 像素密度
// 返回: []byte 图片编码, error 编码或取消错误
func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image, options CompressionOptions, size image.Point, density baseimage.Density) ([]byte, error) {
hasDensity := density.X != 0 || density.Y != 0
before := pixels.Bounds().Size()
if options.Mode == CompressionLossy {
var err error
pixels, err = pdfgo.ResizeImage(ctx, pixels, size)
if err != nil {
return nil, err
}
}
var ok bool
density, ok = density.Scale(before, pixels.Bounds().Size())
if !ok {
return data, ctx.Err()
}
var candidate []byte
var err error
if hasDensity {
var encoded bytes.Buffer
err = pdfgo.EncodePNGWithOptions(ctx, &encoded, pixels, options)
candidate = encoded.Bytes()
} else {
candidate, err = pdfgo.EncodeImageResource(ctx, pixels, options)
}
if err != nil {
if errors.Is(err, io.ErrShortBuffer) {
return data, ctx.Err()
}
return nil, err
}
if hasDensity {
if len(candidate) > outputOptimizationBufferLimit-baseimage.PNGDensityChunkSize {
return data, ctx.Err()
}
candidate, ok = baseimage.WithPNGDensity(candidate, density)
if !ok {
return data, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
if len(candidate) < len(data) {
return candidate, nil
}
return data, nil
}
// optimizeTIFFResource 压缩单目录TIFF,保留方向、透明度、采样精度及像素密度
// 主图或单个瓦片的预计缓冲超限时保留原编码
// 入参: ctx 取消上下文, data TIFF编码, options 压缩选项, size 显示像素需求
// 返回: []byte 图片编码,不可完整转换时保留原文, error 编码或取消错误
func optimizeTIFFResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) {
layout, err := baseimage.ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
if err != nil {
return data, ctx.Err()
}
pixelSize := int64(layout.DecodedPixelBytes())
if int64(layout.Width) > outputOptimizationBufferLimit/pixelSize/int64(layout.Height) {
return data, ctx.Err()
}
if layout.TileWidth != 0 && int64(layout.TileWidth) > outputOptimizationBufferLimit/pixelSize/int64(layout.TileHeight) {
return data, ctx.Err()
}
density, ok := baseimage.TIFFConversionDensity(data, layout)
if !ok {
return data, ctx.Err()
}
pixels, err := baseimage.DecodeTIFF(ctx, data, layout)
if err != nil {
return data, ctx.Err()
}
return optimizeRasterResource(ctx, data, pixels, options, size, density)
}
+119 -37
View File
@@ -20,22 +20,19 @@ import (
"crypto/sha256" "crypto/sha256"
"image" "image"
"io" "io"
"runtime"
"sync" "sync"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
) )
// outputImageBatchLimit 限制并行批次持有的原始编码,超限图片单独处理 // outputImageWorkingSetLimit 指定未提供任务预算时的图像成本估算上限
const outputImageBatchLimit = 16 << 20 const outputImageWorkingSetLimit = 256 << 20
// outputImageWorkingSetLimit 限制并行批次的像素工作量估算,超限图片单独处理
const outputImageWorkingSetLimit = 64 << 20
// outputImageJob 保存独立图片任务,不访问编辑文档或调用进度回调 // outputImageJob 保存独立图片任务,不访问编辑文档或调用进度回调
type outputImageJob struct { type outputImageJob struct {
key outputImageKey key outputImageKey
data []byte data []byte
workingSet int
candidate []byte candidate []byte
err error err error
} }
@@ -46,6 +43,21 @@ type outputImageSource struct {
format string format string
} }
// outputImageContext 保留调用方取消检查并支持提前结束并行任务
type outputImageContext struct {
context.Context
parent context.Context
}
// Err 检查调用方及本次任务的取消状态
// 返回: error 取消错误
func (c outputImageContext) Err() error {
if err := c.Context.Err(); err != nil {
return err
}
return c.parent.Err()
}
// outputImageOptions 统一资源压缩及像素需求,受保护图片只进行无损编码 // outputImageOptions 统一资源压缩及像素需求,受保护图片只进行无损编码
// 入参: key 资源路径 // 入参: key 资源路径
// 返回: CompressionOptions 实际策略, image.Point 像素需求 // 返回: CompressionOptions 实际策略, image.Point 像素需求
@@ -62,35 +74,70 @@ func (e *Editor) outputImageOptions(key string) (CompressionOptions, image.Point
return options, demand return options, demand
} }
// prepareOutputImages 有界并行优化独立图片,读取、缓存及进度仍在调用线程处理 // prepareOutputImages 有界并行优化独立图片,完成后补入任务,读取、缓存及进度保持串行
// 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空 // 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空
// 返回: error 读取、进度或取消错误 // 返回: error 读取、进度或取消错误
func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) error { func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) error {
workers := min(2, runtime.GOMAXPROCS(0)) workers := conversionWorkers(e.output.ctx)
if workers < 2 || len(keys) < 2 { if workers < 2 || len(keys) < 2 {
return nil return nil
} }
batch := make([]outputImageJob, 0, workers) limit := int(min(int64(workers)*(64<<20), int64(int(^uint(0)>>1)-1)))
size, workingSet := 0, 0 encodedLimit := limit
run := func() error { if pool, _ := e.output.ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil {
if err := e.runOutputImages(batch, results); err != nil { available := pool.memoryLimit()
if available == 0 {
return nil
}
if available > 0 {
limit = int(min(available, int64(int(^uint(0)>>1))))
encodedLimit = limit
}
}
base, cancel := context.WithCancel(e.output.ctx)
ctx := outputImageContext{Context: base, parent: e.output.ctx}
var group sync.WaitGroup
defer func() {
cancel()
group.Wait()
}()
completed := make(chan outputImageJob, workers)
pending := make(map[outputImageKey]bool)
running, size, workingSet := 0, 0, 0
finish := func() error {
job := <-completed
running--
size -= len(job.data)
workingSet -= job.workingSet
delete(pending, job.key)
if err := e.output.ctx.Err(); err != nil {
return err return err
} }
clear(batch) results[job.key] = nil
batch, size, workingSet = batch[:0], 0, 0 if job.err == nil && len(job.candidate) < len(job.data) {
results[job.key] = job.candidate
}
return nil return nil
} }
for _, key := range keys { for _, key := range keys {
if err := e.output.ctx.Err(); err != nil { if err := e.output.ctx.Err(); err != nil {
return err return err
} }
if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil { for len(completed) != 0 || running == workers {
if err := finish(); err != nil {
return err
}
}
if err := editorProgress(e.OnWriteProgress).withContext(e.output.ctx).report("compress", e.output.completed, 0); err != nil {
return err return err
} }
data, err := load(key) data, err := load(key)
if err != nil { if err != nil {
return err return err
} }
if err := e.output.ctx.Err(); err != nil {
return err
}
if len(data) == 0 { if len(data) == 0 {
continue continue
} }
@@ -102,54 +149,69 @@ func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, e
if _, cached := results[cacheKey]; cached { if _, cached := results[cacheKey]; cached {
continue continue
} }
duplicate := false if pending[cacheKey] {
for _, job := range batch {
duplicate = duplicate || job.key == cacheKey
}
if duplicate {
continue continue
} }
cost := outputImageWorkingSet(e.output.ctx, data) cost := imageWorkingSet(e.output.ctx, data, limit)
if len(batch) != 0 && (len(data) > outputImageBatchLimit-size || cost > outputImageWorkingSetLimit-workingSet) { for running != 0 && (len(data) > encodedLimit-size || cost > limit-workingSet) {
if err := run(); err != nil { if err := finish(); err != nil {
return err return err
} }
} }
batch = append(batch, outputImageJob{key: cacheKey, data: data}) job := outputImageJob{key: cacheKey, data: data, workingSet: cost}
if len(data) >= encodedLimit || cost >= limit {
if err := e.runOutputImages([]outputImageJob{job}, results); err != nil {
return err
}
continue
}
pending[cacheKey] = true
running++
size += len(data) size += len(data)
workingSet += cost workingSet += cost
if len(batch) == workers || size >= outputImageBatchLimit || workingSet >= outputImageWorkingSetLimit { group.Go(func() {
if err := run(); err != nil { job.candidate, job.err = optimizeOutputImage(ctx, job)
completed <- job
})
}
for running != 0 {
if err := finish(); err != nil {
return err return err
} }
} }
} return e.output.ctx.Err()
return run()
} }
// outputImageWorkingSet 按原图尺寸估算解码和重采样缓冲,不以降采样目标代替原图 // outputImageWorkingSet 按原图尺寸估算解码和重采样缓冲,不以降采样目标代替原图
// 通常预留两份16位RGBA缓冲,TIFF另计分量、瓦片及蒙版存储;格式或尺寸不明时单独处理 // 通常预留两份16位RGBA缓冲,TIFF另计分量、瓦片及蒙版存储;格式或尺寸不明时单独处理
// 入参: ctx 取消上下文, data 原始图像编码 // 入参: ctx 取消上下文, data 原始图像编码
// 返回: int 编码及像素缓冲的估算字节数,上限为单批预算 // 返回: int 编码及像素缓冲的估算字节数,上限为outputImageWorkingSetLimit
func outputImageWorkingSet(ctx context.Context, data []byte) int { func outputImageWorkingSet(ctx context.Context, data []byte) int {
if len(data) >= outputImageWorkingSetLimit { return imageWorkingSet(ctx, data, outputImageWorkingSetLimit)
return outputImageWorkingSetLimit }
// imageWorkingSet 按当前预算估算图像解码工作量
// 入参: ctx 取消上下文, data 图像编码, limit 估算上限
// 返回: int 工作量字节数
func imageWorkingSet(ctx context.Context, data []byte, limit int) int {
if len(data) >= limit {
return limit
} }
input := bytes.NewReader(data) input := bytes.NewReader(data)
available := outputImageWorkingSetLimit - len(data) available := limit - len(data)
if baseimage.IsTIFF(data) { if baseimage.IsTIFF(data) {
layout, err := baseimage.ReadTIFFLayoutContext(ctx, input) layout, err := baseimage.ReadTIFFLayoutContext(ctx, input)
if err != nil { if err != nil {
return outputImageWorkingSetLimit return limit
} }
return len(data) + tiffImageWorkingSet(layout, available) return len(data) + tiffImageWorkingSet(layout, available)
} }
config, _, err := baseimage.DecodeConfig(imageInput{ReadCloser: io.NopCloser(input), context: ctx}) config, _, err := baseimage.DecodeConfig(imageInput{ReadCloser: io.NopCloser(input), ctx: ctx})
if err != nil || config.Width <= 0 || config.Height <= 0 { if err != nil || config.Width <= 0 || config.Height <= 0 {
return outputImageWorkingSetLimit return limit
} }
if config.Width > available/16/config.Height { if config.Width > available/16/config.Height {
return outputImageWorkingSetLimit return limit
} }
return len(data) + config.Width*config.Height*16 return len(data) + config.Width*config.Height*16
} }
@@ -187,7 +249,7 @@ func tiffImageWorkingSet(layout baseimage.TIFFLayout, limit int) int {
func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageKey][]byte) error { func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageKey][]byte) error {
ctx := e.output.ctx ctx := e.output.ctx
optimize := func(job *outputImageJob) { optimize := func(job *outputImageJob) {
job.candidate, job.err = optimizeImageResource(ctx, job.data, job.key.options, job.key.size) job.candidate, job.err = optimizeOutputImage(ctx, *job)
} }
if len(jobs) == 1 { if len(jobs) == 1 {
optimize(&jobs[0]) optimize(&jobs[0])
@@ -209,3 +271,23 @@ func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageK
} }
return nil return nil
} }
// optimizeOutputImage 在共享预算内优化独立图像
// 入参: ctx 转换上下文, job 图像任务
// 返回: []byte 候选编码, error 转换错误
func optimizeOutputImage(ctx context.Context, job outputImageJob) ([]byte, error) {
if pool, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil {
if job.workingSet == 0 {
limit := outputImageWorkingSetLimit
if available := pool.memoryLimit(); available > 0 {
limit = int(min(available, int64(int(^uint(0)>>1))))
}
job.workingSet = imageWorkingSet(ctx, job.data, limit)
}
if err := pool.acquire(ctx, true, int64(job.workingSet)); err != nil {
return nil, err
}
defer pool.release(true, int64(job.workingSet))
}
return optimizeImageResource(ctx, job.data, job.key.options, job.key.size)
}
+1 -1
View File
@@ -67,7 +67,7 @@ func (e *Editor) compressPatternPaints() error {
} }
hash := sha256.New() hash := sha256.New()
var size int64 var size int64
x := newOFDXML(convertWriter{writer: hash, count: &size, context: e.output.ctx}) x := newOFDXML(convertWriter{writer: hash, count: &size, ctx: e.output.ctx})
x.ctx = e.output.ctx x.ctx = e.output.ctx
x.color("FillColor", fill) x.color("FillColor", fill)
x.color("StrokeColor", (*FillColor)(stroke)) x.color("StrokeColor", (*FillColor)(stroke))
-136
View File
@@ -1,136 +0,0 @@
// 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"
"fmt"
"maps"
"slices"
)
// editorProfileReference 记录颜色配置文件所在的资源索引及颜色空间标识
type editorProfileReference struct {
index string
id string
}
// compactResourceProfiles 合并相同颜色配置文件,保留颜色空间声明和独立文件引用
// 入参: ctx 取消上下文, reader 原包,可为空, parts 输出改动, profiles 配置文件引用, protected 不可替换的文件, removed 待移除文件
// 返回: error 读取、改写或取消错误
func compactResourceProfiles(ctx context.Context, reader *Reader, parts map[string][]byte, profiles map[string][]editorProfileReference, protected, removed map[string]bool) error {
if len(profiles) < 2 {
return ctx.Err()
}
seen := make(map[[32]byte]string)
aliases := make(map[string]string)
indexes := make(map[string]map[string]string)
ordered := slices.Sorted(maps.Keys(profiles))
sizes := make(map[string]uint64, len(profiles))
counts := make(map[uint64]int)
for _, name := range ordered {
if err := ctx.Err(); err != nil {
return err
}
if protected[name] {
continue
}
size, err := outputResourceSize(reader, parts, name)
if err != nil {
return err
}
sizes[name] = size
counts[size]++
}
var buffer []byte
for _, name := range ordered {
if err := ctx.Err(); err != nil {
return err
}
if protected[name] || counts[sizes[name]] < 2 {
continue
}
if buffer == nil {
buffer = make([]byte, 32*1024)
}
key, err := outputResourceDigest(ctx, reader, parts, name, buffer)
if err != nil {
return err
}
first, found := seen[key]
if !found {
seen[key] = name
continue
}
aliases[name] = first
for _, reference := range profiles[name] {
if indexes[reference.index] == nil {
indexes[reference.index] = make(map[string]string)
}
indexes[reference.index][reference.id] = first
}
}
updates := make(map[string][]byte, len(indexes))
for _, name := range slices.Sorted(maps.Keys(indexes)) {
if err := ctx.Err(); err != nil {
return err
}
data, ok := parts[name]
if !ok {
if reader == nil {
return fmt.Errorf("file not found: %s", name)
}
var err error
data, err = reader.readFileViewContext(ctx, name)
if err != nil {
return err
}
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return err
}
var patches []editorXMLPatch
for _, group := range root.children {
if !group.matchesOFD("ColorSpaces") {
continue
}
for _, node := range group.children {
if err := ctx.Err(); err != nil {
return err
}
location := indexes[name][editorResourceID(node.attr("ID"))]
if !node.matchesOFD("ColorSpace") || location == "" {
continue
}
patch, err := editorXMLAttributePatch(data, node, "Profile", "/"+location)
if err != nil {
return err
}
patches = append(patches, patch)
}
}
updates[name] = editorPatchXML(data, patches)
}
if err := ctx.Err(); err != nil {
return err
}
maps.Copy(parts, updates)
for name := range aliases {
removed[name] = true
delete(parts, name)
}
return nil
}
-197
View File
@@ -1,197 +0,0 @@
// 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 (
"bytes"
"context"
"encoding/binary"
"errors"
"image"
"io"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
"github.com/xiaoqidun/pdfgo"
)
// outputImageFormat 识别可由资源压缩生成的图片格式
// 入参: data 图片编码
// 返回: string 标准资源格式,未识别时为空
func outputImageFormat(data []byte) string {
if baseimage.IsJPEG(data) {
return "JPEG"
}
if baseimage.IsPNG(data) {
return "PNG"
}
return ""
}
// optimizeImageResource 优化可完整保留的BMP及TIFF,复用图像压缩和降采样策略
// 原始模式及无法保留的色彩配置不转换,仅采用小于原始编码的结果
// 入参: ctx 取消上下文, data 图片编码, options 压缩选项, size 像素需求
// 返回: []byte 图片编码,可能引用输入, error 解码、编码或取消错误
func optimizeImageResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) {
if err := options.Validate(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if options.Mode != CompressionUnchanged && baseimage.IsTIFF(data) {
return optimizeTIFFResource(ctx, data, options, size)
}
if options.Mode == CompressionUnchanged || !bytes.HasPrefix(data, []byte("BM")) {
return pdfgo.OptimizeImageResource(ctx, data, options, size)
}
return optimizeBMPResource(ctx, data, options, size)
}
// optimizeBMPResource 压缩可保留色彩与密度的位图,未知元数据及高精度位域保持原样
// 入参: ctx 取消上下文, data BMP编码, options 压缩选项, size 像素需求
// 返回: []byte 图片编码, error 解码、编码或取消错误
func optimizeBMPResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) {
offset, width, height, depth, compression, err := baseimage.ParseBMPHeader(data)
if err != nil {
return data, nil
}
if height < 0 {
height = -height
}
if int64(width)*int64(height) > outputOptimizationBufferLimit/4 || compression != 0 && compression != 3 {
return data, nil
}
header := binary.LittleEndian.Uint32(data[14:18])
if header != 40 && header != 108 && header != 124 {
return data, nil
}
if header >= 108 && binary.LittleEndian.Uint32(data[70:74]) != 0x73524742 {
return data, nil
}
if header == 124 && (binary.LittleEndian.Uint32(data[122:126]) != 0 || binary.LittleEndian.Uint32(data[126:130]) != 0 || binary.LittleEndian.Uint32(data[130:134]) != 0) {
return data, nil
}
stride := (int64(width)*int64(depth) + 31) / 32 * 4
if int64(offset)+stride*int64(height) != int64(len(data)) {
return data, nil
}
density, ok := baseimage.BMPDensity(data)
if !ok {
return data, nil
}
if depth == 16 || depth == 32 {
masks, err := baseimage.BMPColorMasks(data, offset, depth, compression)
if err != nil {
return data, nil
}
if options.Mode == CompressionLossless {
for _, mask := range masks {
if mask.Max > 255 {
return data, nil
}
}
}
}
var pixels image.Image
if depth == 16 || compression == 3 {
pixels, err = baseimage.DecodeBMPContext(ctx, data)
} else {
pixels, _, err = image.Decode(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx})
}
if err != nil {
return nil, err
}
return optimizeRasterResource(ctx, data, pixels, options, size, density)
}
// optimizeRasterResource 编码已解码的资源图片,同步降采样后的像素密度,仅采用更小结果
// 先降采样再编码,绝对密度按最终尺寸量化,相对密度保留精确比例
// 有密度时保留PNG,未知密度按最终像素选择编码格式
// 入参: ctx 取消上下文, data 原编码, pixels 像素, options 压缩选项, size 像素需求, density 像素密度
// 返回: []byte 图片编码, error 编码或取消错误
func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image, options CompressionOptions, size image.Point, density baseimage.Density) ([]byte, error) {
hasDensity := density.X != 0 || density.Y != 0
before := pixels.Bounds().Size()
if options.Mode == CompressionLossy {
var err error
pixels, err = pdfgo.ResizeImage(ctx, pixels, size)
if err != nil {
return nil, err
}
}
var ok bool
density, ok = density.Scale(before, pixels.Bounds().Size())
if !ok {
return data, ctx.Err()
}
var candidate []byte
var err error
if hasDensity {
var encoded bytes.Buffer
err = pdfgo.EncodePNGWithOptions(ctx, &encoded, pixels, options)
candidate = encoded.Bytes()
} else {
candidate, err = pdfgo.EncodeImageResource(ctx, pixels, options)
}
if err != nil {
if errors.Is(err, io.ErrShortBuffer) {
return data, ctx.Err()
}
return nil, err
}
if hasDensity {
if len(candidate) > outputOptimizationBufferLimit-baseimage.PNGDensityChunkSize {
return data, ctx.Err()
}
candidate, ok = baseimage.WithPNGDensity(candidate, density)
if !ok {
return data, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
if len(candidate) < len(data) {
return candidate, nil
}
return data, nil
}
// optimizeTIFFResource 压缩单目录TIFF,保留方向、透明度、采样精度及像素密度
// 主图或单个瓦片的预计缓冲超限时保留原编码
// 入参: ctx 取消上下文, data TIFF编码, options 压缩选项, size 显示像素需求
// 返回: []byte 图片编码,不可完整转换时保留原文, error 编码或取消错误
func optimizeTIFFResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) {
layout, err := baseimage.ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
if err != nil {
return data, ctx.Err()
}
pixelSize := int64(layout.DecodedPixelBytes())
if int64(layout.Width) > outputOptimizationBufferLimit/pixelSize/int64(layout.Height) {
return data, ctx.Err()
}
if layout.TileWidth != 0 && int64(layout.TileWidth) > outputOptimizationBufferLimit/pixelSize/int64(layout.TileHeight) {
return data, ctx.Err()
}
density, ok := baseimage.TIFFConversionDensity(data, layout)
if !ok {
return data, ctx.Err()
}
pixels, err := baseimage.DecodeTIFF(ctx, data, layout)
if err != nil {
return data, ctx.Err()
}
return optimizeRasterResource(ctx, data, pixels, options, size, density)
}
+205 -2
View File
@@ -35,6 +35,12 @@ type outputImageKey struct {
size image.Point size image.Point
} }
// editorProfileReference 记录颜色配置文件所在的资源索引及颜色空间标识
type editorProfileReference struct {
index string
id string
}
// compressResourceParts 优化输出图片并合并相同资源文件,保留资源标识及各自属性 // compressResourceParts 优化输出图片并合并相同资源文件,保留资源标识及各自属性
// 入参: parts 输出改动, reader 原包,可为空, removed 已移除条目 // 入参: parts 输出改动, reader 原包,可为空, removed 已移除条目
// 返回: error 读取或取消错误 // 返回: error 读取或取消错误
@@ -294,7 +300,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
candidate, cached := imageResults[cacheKey] candidate, cached := imageResults[cacheKey]
var err error var err error
if !cached { if !cached {
candidate, err = optimizeImageResource(e.output.ctx, data, options, demand) candidate, err = optimizeOutputImage(e.output.ctx, outputImageJob{data: data, key: outputImageKey{options: options, size: demand}})
if err := e.output.ctx.Err(); err != nil { if err := e.output.ctx.Err(); err != nil {
return err return err
} }
@@ -460,7 +466,7 @@ func outputResourceDigest(ctx context.Context, reader *Reader, parts map[string]
} }
} }
hash := sha256.New() hash := sha256.New()
_, err := io.CopyBuffer(hash, imageInput{ReadCloser: input, context: ctx}, buffer) _, err := io.CopyBuffer(hash, imageInput{ReadCloser: input, ctx: ctx}, buffer)
closeErr := input.Close() closeErr := input.Close()
if err != nil { if err != nil {
return [32]byte{}, err return [32]byte{}, err
@@ -470,3 +476,200 @@ func outputResourceDigest(ctx context.Context, reader *Reader, parts map[string]
} }
return [32]byte(hash.Sum(nil)), ctx.Err() return [32]byte(hash.Sum(nil)), ctx.Err()
} }
// compressionFontUsage 按文档合并共享字体用字,版本编辑仅统计选中入口
// 入参: ctx 取消上下文, documents 文档入口, resources 资源索引, references 各文件引用, read 输出文件读取方法
// 返回: map[string]*editorFontUsage 各字体文件的用字,无法确定作用域时为空, error 读取或取消错误
func (r *Reader) compressionFontUsage(ctx context.Context, documents map[string]bool, resources []string, references map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) {
selected := ""
if r.OFD.DocBody[r.documentIndex].versioned {
var err error
selected, err = r.DocumentRootContext(ctx)
if err != nil {
return nil, err
}
}
scoped, err := resourceReferenceScopes(ctx, documents, resources, references, selected)
if err != nil || scoped == nil {
return nil, err
}
return resourceFontUsage(ctx, resources, scoped, read)
}
// resourceFontUsage 按已确认的文档作用域合并各字体文件的字符和字形引用
// 入参: ctx 取消上下文, resources 资源索引, scoped 各索引的用字范围, read 输出文件读取方法
// 返回: map[string]*editorFontUsage 各字体文件的用字, error 读取或取消错误
func resourceFontUsage(ctx context.Context, resources []string, scoped map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) {
result := make(map[string]*editorFontUsage)
protected := make(map[string]bool)
for _, name := range resources {
if err := ctx.Err(); err != nil {
return nil, err
}
refs := scoped[name]
if refs == nil {
continue
}
data, err := read(name)
if err != nil {
return nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, err
}
for font := range editorResourceNodes(root, "Fonts", "Font") {
if err := ctx.Err(); err != nil {
return nil, err
}
file := font.child("FontFile")
if file == nil {
continue
}
var value string
if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil {
return nil, err
}
location := editorResourceLocation(name, root.attr("BaseLoc"), value)
alternate := cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))
id, charset := editorResourceID(font.attr("ID")), font.attr("Charset")
unsafe := id == "" || charset != "" && !strings.EqualFold(charset, "unicode") || location != alternate
for i, path := range [2]string{location, alternate} {
if i == 1 && path == location {
continue
}
if result[path] == nil {
result[path] = newEditorFontUsage()
}
result[path].merge(refs.fonts[id], unsafe)
}
}
for media := range editorResourceNodes(root, "MultiMedias", "MultiMedia") {
file := media.child("MediaFile")
if file == nil {
continue
}
var value string
if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil {
return nil, err
}
for _, location := range []string{editorResourceLocation(name, root.attr("BaseLoc"), value), cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))} {
protected[location] = true
}
}
}
for location := range protected {
if usage := result[location]; usage != nil {
usage.unsafe = true
}
}
return result, ctx.Err()
}
// compactResourceProfiles 合并相同颜色配置文件,保留颜色空间声明和独立文件引用
// 入参: ctx 取消上下文, reader 原包,可为空, parts 输出改动, profiles 配置文件引用, protected 不可替换的文件, removed 待移除文件
// 返回: error 读取、改写或取消错误
func compactResourceProfiles(ctx context.Context, reader *Reader, parts map[string][]byte, profiles map[string][]editorProfileReference, protected, removed map[string]bool) error {
if len(profiles) < 2 {
return ctx.Err()
}
seen := make(map[[32]byte]string)
aliases := make(map[string]string)
indexes := make(map[string]map[string]string)
ordered := slices.Sorted(maps.Keys(profiles))
sizes := make(map[string]uint64, len(profiles))
counts := make(map[uint64]int)
for _, name := range ordered {
if err := ctx.Err(); err != nil {
return err
}
if protected[name] {
continue
}
size, err := outputResourceSize(reader, parts, name)
if err != nil {
return err
}
sizes[name] = size
counts[size]++
}
var buffer []byte
for _, name := range ordered {
if err := ctx.Err(); err != nil {
return err
}
if protected[name] || counts[sizes[name]] < 2 {
continue
}
if buffer == nil {
buffer = make([]byte, 32*1024)
}
key, err := outputResourceDigest(ctx, reader, parts, name, buffer)
if err != nil {
return err
}
first, found := seen[key]
if !found {
seen[key] = name
continue
}
aliases[name] = first
for _, reference := range profiles[name] {
if indexes[reference.index] == nil {
indexes[reference.index] = make(map[string]string)
}
indexes[reference.index][reference.id] = first
}
}
updates := make(map[string][]byte, len(indexes))
for _, name := range slices.Sorted(maps.Keys(indexes)) {
if err := ctx.Err(); err != nil {
return err
}
data, ok := parts[name]
if !ok {
if reader == nil {
return fmt.Errorf("file not found: %s", name)
}
var err error
data, err = reader.readFileViewContext(ctx, name)
if err != nil {
return err
}
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return err
}
var patches []editorXMLPatch
for _, group := range root.children {
if !group.matchesOFD("ColorSpaces") {
continue
}
for _, node := range group.children {
if err := ctx.Err(); err != nil {
return err
}
location := indexes[name][editorResourceID(node.attr("ID"))]
if !node.matchesOFD("ColorSpace") || location == "" {
continue
}
patch, err := editorXMLAttributePatch(data, node, "Profile", "/"+location)
if err != nil {
return err
}
patches = append(patches, patch)
}
}
updates[name] = editorPatchXML(data, patches)
}
if err := ctx.Err(); err != nil {
return err
}
maps.Copy(parts, updates)
for name := range aliases {
removed[name] = true
delete(parts, name)
}
return nil
}
+250 -22
View File
@@ -15,11 +15,17 @@
package ofdgo package ofdgo
import ( import (
"archive/zip"
"bytes"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"runtime"
"slices" "slices"
"strings" "strings"
"sync"
"time"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
@@ -45,6 +51,8 @@ type ConvertProgress struct {
// Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面 // Input为空时识别源格式,Format为输出格式,Pages使用零基索引,nil表示全部页面
// PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 // PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面
// SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据 // SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据
// Pool可由多个独立转换共享,nil时不限制文件间并发
// Resources提供共享计算与缓存策略,显式Pool优先控制计算调度
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出 // 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
@@ -53,11 +61,13 @@ type ConvertProgress struct {
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil // PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换 // 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换
type ConvertOptions struct { type ConvertOptions struct {
Resources *Resources
Input string Input string
Format string Format string
Pages []int Pages []int
PageRange string PageRange string
SkipUnchanged bool SkipUnchanged bool
Pool *ConversionPool
Compression CompressionOptions Compression CompressionOptions
Fonts FontMode Fonts FontMode
Backends *RenderBackends Backends *RenderBackends
@@ -80,19 +90,42 @@ type ConvertReport struct {
Fonts []FontChange `json:"fonts,omitempty"` Fonts []FontChange `json:"fonts,omitempty"`
} }
// ConversionPoolOptions 设置共享转换并行度及图像工作集预算
// Parallelism为0时使用GOMAXPROCS,MemoryLimit为0时不指定固定预算
// AvailableMemory返回可用于新增任务的字节数,nil表示不探测,回调须支持并发调用
// 内存预算用于调度,不限制解析器及运行时的实际分配
type ConversionPoolOptions struct {
Parallelism int
MemoryLimit int64
AvailableMemory func() int64
}
// ConversionPool 协调独立转换和图像任务,配置在创建后不变,可跨会话复用
type ConversionPool struct {
options ConversionPoolOptions
mu sync.Mutex
changed chan struct{}
active int
images int
memory int64
}
// convertReader 将取消上下文传递到随机读取 // convertReader 将取消上下文传递到随机读取
type convertReader struct { type convertReader struct {
context context.Context ctx context.Context
source io.ReaderAt input io.ReaderAt
} }
// convertWriter 将取消上下文传递到写入并统计实际字节数 // convertWriter 将取消上下文传递到写入并统计实际字节数
type convertWriter struct { type convertWriter struct {
context context.Context ctx context.Context
writer io.Writer writer io.Writer
count *int64 count *int64
} }
// conversionPoolKey 标识单次转换使用的共享预算
type conversionPoolKey struct{}
// OutputFormats 返回独立的输出格式列表,不依赖WebUI或具体渲染后端 // OutputFormats 返回独立的输出格式列表,不依赖WebUI或具体渲染后端
// 返回: []OutputFormat 输出格式列表 // 返回: []OutputFormat 输出格式列表
func OutputFormats() []OutputFormat { func OutputFormats() []OutputFormat {
@@ -111,9 +144,23 @@ func OutputFormats() []OutputFormat {
// 原始模式完整OFD另存保持源字节;选页和跨格式输出重新生成文档,压缩仅作用于输出副本 // 原始模式完整OFD另存保持源字节;选页和跨格式输出重新生成文档,压缩仅作用于输出副本
// 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存 // 输入按需随机读取,输出分块写出;解析对象及后端可能缓存资源,不保证恒定内存
// 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功 // 出错时报告保留已有诊断,调用方应丢弃未完成输出;只有返回nil才表示转换成功
// 入参: ctx 取消上下文, source 源数据, size 源字节数, output 输出流, options 转换选项 // 入参: ctx 取消上下文, input 输入源, output 输出流, options 转换选项
// 返回: ConvertReport 转换报告, error 错误信息 // 返回: ConvertReport 转换报告, error 错误信息
func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writer, options ConvertOptions) (report ConvertReport, err error) { func Convert(ctx context.Context, input *Input, output io.Writer, options ConvertOptions) (report ConvertReport, err error) {
if options.Resources != nil {
if options.Pool == nil {
options.Pool = options.Resources.pool
}
options.ReaderOptions = append(slices.Clone(options.ReaderOptions), WithResources(options.Resources))
options.PDF.Resources = options.Resources
}
if options.Pool != nil {
if err = options.Pool.acquire(ctx, false, 0); err != nil {
return report, err
}
defer options.Pool.release(false, 0)
ctx = context.WithValue(ctx, conversionPoolKey{}, options.Pool)
}
if err = options.Compression.Validate(); err != nil { if err = options.Compression.Validate(); err != nil {
return report, err return report, err
} }
@@ -165,13 +212,14 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
if err = progress("open", 0, 0); err != nil { if err = progress("open", 0, 0); err != nil {
return report, err return report, err
} }
if source == nil || size <= 0 { if input == nil || input.Size() <= 0 {
return report, fmt.Errorf("empty document source") return report, fmt.Errorf("empty document input")
} }
source = convertReader{context: ctx, source: source} size := input.Size()
operationInput := &Input{reader: convertReader{ctx: ctx, input: input}, size: size}
report.Input = strings.ToLower(strings.TrimSpace(options.Input)) report.Input = strings.ToLower(strings.TrimSpace(options.Input))
if report.Input == "" { if report.Input == "" {
report.Input, err = DetectFormat(source, size) report.Input, err = DetectFormat(operationInput)
if err != nil { if err != nil {
return report, err return report, err
} }
@@ -190,7 +238,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
return report, fmt.Errorf("missing output writer") return report, fmt.Errorf("missing output writer")
} }
if report.Input == "pdf" && format == "pdf" && options.Compression.Mode != CompressionUnchanged && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { if report.Input == "pdf" && format == "pdf" && options.Compression.Mode != CompressionUnchanged && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" {
pdfReader, openErr := pdfgo.NewReaderWithOptions(source, size, pdfgo.ReaderOptions{Password: options.PDF.Password, PasswordUTF8: options.PDF.PasswordUTF8}) pdfReader, openErr := pdfgo.NewReaderWithOptions(operationInput, size, pdfgo.ReaderOptions{Password: options.PDF.Password, PasswordUTF8: options.PDF.PasswordUTF8})
if openErr != nil { if openErr != nil {
return report, openErr return report, openErr
} }
@@ -224,13 +272,13 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
} }
return nil return nil
} }
editor, report.PDF, err = ImportPDF(ctx, source, size, pdfOptions) editor, report.PDF, err = ImportPDF(ctx, operationInput, pdfOptions)
if err != nil { if err != nil {
return report, err return report, err
} }
count = editor.PageCount() count = editor.PageCount()
case "ofd": case "ofd":
reader, err = NewReader(source, size, options.ReaderOptions...) reader, err = NewReader(operationInput, options.ReaderOptions...)
if err != nil { if err != nil {
return report, err return report, err
} }
@@ -251,13 +299,13 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
return report, err return report, err
} }
report.Pages = len(indices) report.Pages = len(indices)
writer := convertWriter{context: ctx, writer: output, count: &report.Bytes} writer := convertWriter{ctx: ctx, writer: output, count: &report.Bytes}
if format == "ofd" { if format == "ofd" {
if options.Pages == nil && editor == nil && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged { if options.Pages == nil && editor == nil && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged {
if err = progress("write", 0, 0); err != nil { if err = progress("write", 0, 0); err != nil {
return report, err return report, err
} }
_, err = io.Copy(writer, io.NewSectionReader(source, 0, size)) _, err = io.Copy(writer, io.NewSectionReader(operationInput, 0, size))
report.Unchanged = err == nil report.Unchanged = err == nil
return report, err return report, err
} }
@@ -292,7 +340,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
if err = progress("write", 0, 0); err != nil { if err = progress("write", 0, 0); err != nil {
return report, err return report, err
} }
_, err = io.Copy(writer, io.NewSectionReader(source, 0, size)) _, err = io.Copy(writer, io.NewSectionReader(operationInput, 0, size))
report.Unchanged = err == nil report.Unchanged = err == nil
return report, err return report, err
} }
@@ -300,7 +348,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
if options.Pages == nil { if options.Pages == nil {
_, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression}) _, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression})
} else { } else {
_, err = editor.WritePagesToWithOptions(ctx, writer, indices, WriteOptions{Compression: options.Compression}) _, err = editor.WritePagesTo(ctx, writer, indices, WriteOptions{Compression: options.Compression})
} }
return report, err return report, err
} }
@@ -343,7 +391,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
return report, readErr return report, readErr
} }
if err = options.PageOutput(index, func(output io.Writer) error { if err = options.PageOutput(index, func(output io.Writer) error {
return renderer.RenderTo(page, convertWriter{context: ctx, writer: output, count: &report.Bytes}, format) return renderer.RenderTo(page, convertWriter{ctx: ctx, writer: output, count: &report.Bytes}, format)
}); err != nil { }); err != nil {
return report, fmt.Errorf("export page %d: %w", index+1, err) return report, fmt.Errorf("export page %d: %w", index+1, err)
} }
@@ -353,21 +401,25 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
return report, err return report, err
} }
// ReadAt 读取指定位置的数据并响应取消 // ReadAt 读取前后响应取消,保留读取字节数和源错误
// 入参: data 目标缓冲, offset 源偏移 // 入参: data 目标缓冲, offset 源偏移
// 返回: int 读取字节数, error 读取错误 // 返回: int 读取字节数, error 读取错误
func (r convertReader) ReadAt(data []byte, offset int64) (int, error) { func (r convertReader) ReadAt(data []byte, offset int64) (int, error) {
if err := r.context.Err(); err != nil { if err := r.ctx.Err(); err != nil {
return 0, err return 0, err
} }
return r.source.ReadAt(data, offset) n, err := r.input.ReadAt(data, offset)
if err == nil {
err = r.ctx.Err()
}
return n, err
} }
// Write 写出数据并响应取消及短写 // Write 写入前后响应取消,保留写入及短写错误
// 入参: data 待写数据 // 入参: data 待写数据
// 返回: int 写入字节数, error 写入错误 // 返回: int 写入字节数, error 写入错误
func (w convertWriter) Write(data []byte) (int, error) { func (w convertWriter) Write(data []byte) (int, error) {
if err := w.context.Err(); err != nil { if err := w.ctx.Err(); err != nil {
return 0, err return 0, err
} }
n, err := w.writer.Write(data) n, err := w.writer.Write(data)
@@ -375,5 +427,181 @@ func (w convertWriter) Write(data []byte) (int, error) {
if err == nil && n != len(data) { if err == nil && n != len(data) {
err = io.ErrShortWrite err = io.ErrShortWrite
} }
if err == nil {
err = w.ctx.Err()
}
return n, err return n, err
} }
// WriteString 写入字符串并保留底层原生写入能力,前后响应取消
// 入参: data 待写文本
// 返回: int 写入字节数, error 写入、短写或取消错误
func (w convertWriter) WriteString(data string) (int, error) {
if err := w.ctx.Err(); err != nil {
return 0, err
}
n, err := io.WriteString(w.writer, data)
*w.count += int64(n)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
if err == nil {
err = w.ctx.Err()
}
return n, err
}
// DetectFormat 按文件内容识别PDF或OFD,不读取压缩内容或关闭输入
// 识别结果不代表文档完整有效,仍需通过对应阅读器解析
// 入参: input 输入源
// 返回: string 格式标识pdf或ofd, error 读取错误或不支持的格式
func DetectFormat(input *Input) (string, error) {
if input == nil || input.Size() <= 0 {
return "", fmt.Errorf("empty document input")
}
size := input.Size()
var header [1024]byte
data := header[:min(size, int64(len(header)))]
n, err := input.ReadAt(data, 0)
if err != nil && err != io.EOF {
return "", err
}
if n != len(data) {
return "", io.ErrUnexpectedEOF
}
if bytes.HasPrefix(data, []byte("%PDF-")) {
return "pdf", nil
}
archive, err := zip.NewReader(input, size)
if err == nil {
reader := &Reader{Zip: archive}
if err := reader.indexPackage(); err != nil {
return "", err
}
if reader.fileNames["OFD.xml"] || reader.fileNames["Encryptions.xml"] {
return "ofd", nil
}
} else {
if !errors.Is(err, zip.ErrFormat) {
return "", err
}
if bytes.Contains(data, []byte("%PDF-")) {
return "pdf", nil
}
}
return "", fmt.Errorf("unsupported input format")
}
// NewConversionPool 创建共享转换调度器,不启动后台任务
// 入参: options 并行及内存选项
// 返回: *ConversionPool 调度器, error 配置错误
func NewConversionPool(options ConversionPoolOptions) (*ConversionPool, error) {
if options.Parallelism < 0 || options.MemoryLimit < 0 {
return nil, fmt.Errorf("invalid conversion pool options")
}
if options.Parallelism == 0 {
options.Parallelism = runtime.GOMAXPROCS(0)
}
return &ConversionPool{options: options, changed: make(chan struct{})}, nil
}
// Parallelism 返回同时运行的转换数量上限
// 返回: int 并行上限
func (p *ConversionPool) Parallelism() int { return p.options.Parallelism }
// memoryLimit 读取当前可分配的图像工作量预算
// 返回: int64 可用字节数,负值表示未设置预算
func (p *ConversionPool) memoryLimit() int64 {
limit := int64(-1)
if p.options.MemoryLimit > 0 {
limit = p.options.MemoryLimit
}
if p.options.AvailableMemory != nil {
available := max(0, p.options.AvailableMemory())
if limit < 0 || available < limit {
limit = available
}
}
return limit
}
// acquire 等待转换或图像预算,超预算图像可独占运行
// 入参: ctx 取消上下文, image 是否为图像任务, size 图像工作量
// 返回: error 取消错误
func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) error {
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
}()
for {
if err := ctx.Err(); err != nil {
return err
}
limit := p.memoryLimit()
p.mu.Lock()
allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0)
if image {
allowed = p.images < p.options.Parallelism && (p.images == 0 || limit < 0 || size <= limit-p.memory)
}
if allowed {
if image {
p.images++
p.memory += size
} else {
p.active++
}
p.mu.Unlock()
return nil
}
changed := p.changed
p.mu.Unlock()
var tick <-chan time.Time
if p.options.AvailableMemory != nil {
if timer == nil {
timer = time.NewTimer(250 * time.Millisecond)
} else {
timer.Reset(250 * time.Millisecond)
}
tick = timer.C
}
select {
case <-ctx.Done():
case <-changed:
case <-tick:
}
if timer != nil {
timer.Stop()
}
}
}
// release 归还任务预算并唤醒等待者
// 入参: image 是否为图像任务, size 图像工作量
func (p *ConversionPool) release(image bool, size int64) {
p.mu.Lock()
defer p.mu.Unlock()
if image {
p.images--
p.memory -= size
} else {
p.active--
}
close(p.changed)
p.changed = make(chan struct{})
}
// conversionWorkers 按当前文件数量分配内部计算任务
// 入参: ctx 转换上下文
// 返回: int 内部并行数
func conversionWorkers(ctx context.Context) int {
p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool)
if p == nil {
return runtime.GOMAXPROCS(0)
}
p.mu.Lock()
active := p.active
p.mu.Unlock()
return max(1, p.options.Parallelism/max(1, active))
}
+176 -7
View File
@@ -17,7 +17,18 @@ package ofdgo
import ( import (
"bytes" "bytes"
"crypto" "crypto"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"errors" "errors"
"fmt"
"io"
"github.com/emmansun/gmsm/padding"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
"github.com/emmansun/gmsm/sm4"
"github.com/emmansun/gmsm/smx509"
) )
// 加密错误可通过errors.Is识别,不包含口令或私钥 // 加密错误可通过errors.Is识别,不包含口令或私钥
@@ -48,15 +59,9 @@ type CryptoProvider interface {
EncryptKey(certificate, key []byte) ([]byte, error) EncryptKey(certificate, key []byte) ([]byte, error)
} }
// ReaderOption 阅读器的凭据与密码提供者选项 // ReaderOption 阅读器的凭据、密码提供者与资源选项
type ReaderOption func(*readerOptions) type ReaderOption func(*readerOptions)
// readerOptions 保存打开文档时的会话参数
type readerOptions struct {
credentials []Credentials
provider CryptoProvider
}
// EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码 // EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码
type EncryptionRecipient struct { type EncryptionRecipient struct {
UserName string UserName string
@@ -85,12 +90,22 @@ type EncryptionInfo struct {
PolicyRequired bool PolicyRequired bool
} }
// readerOptions 保存打开文档时的会话参数
type readerOptions struct {
resources *Resources
credentials []Credentials
provider CryptoProvider
}
// encryptionState 保存解密来源与可继承的输出策略 // encryptionState 保存解密来源与可继承的输出策略
type encryptionState struct { type encryptionState struct {
info EncryptionInfo info EncryptionInfo
options *EncryptionOptions options *EncryptionOptions
} }
// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用
type GMCryptoProvider struct{}
// WithCredentials 添加解密凭据,多次调用可解开多层加密 // WithCredentials 添加解密凭据,多次调用可解开多层加密
// 入参: credentials 解密凭据 // 入参: credentials 解密凭据
// 返回: ReaderOption 阅读选项 // 返回: ReaderOption 阅读选项
@@ -145,3 +160,157 @@ func cloneEncryptionOptions(options *EncryptionOptions) *EncryptionOptions {
} }
return &copy return &copy
} }
// Encryption 获取当前保存策略,不暴露口令或私钥
// 返回: EncryptionInfo 加密状态
func (e *Editor) Encryption() EncryptionInfo {
return e.encryption.summary()
}
// SetEncryption 设置后续保存的加密策略,nil显式选择明文保存
// 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略
// 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史
// 入参: options 加密策略
// 返回: error 错误信息
func (e *Editor) SetEncryption(options *EncryptionOptions) error {
if options == nil {
e.encryption = nil
return nil
}
if err := validateEncryptionOptions(*options); err != nil {
return err
}
info := EncryptionInfo{Encrypted: true, Method: "1.1.1", Layers: 1}
if len(options.Password) != 0 {
info.Users = []string{options.UserName}
} else {
info.Method = "1.1.2"
for _, recipient := range options.Recipients {
info.Users = append(info.Users, recipient.UserName)
}
}
e.encryption = &encryptionState{info: info, options: cloneEncryptionOptions(options)}
return nil
}
// writeEncrypted 在全部准备及加密成功后写出结果,取消不产生输出
// 输出流自身写入失败时调用方仍需丢弃本次输出
// 入参: writer 输出流
// 返回: int64 写入字节数, error 错误信息
func (e *Editor) writeEncrypted(writer io.Writer) (int64, error) {
if e.encryption.options == nil {
return 0, ErrEncryptionPolicyRequired
}
var plaintext bytes.Buffer
if _, err := e.writePlaintext(&plaintext); err != nil {
return 0, err
}
defer clear(plaintext.Bytes())
r, err := NewReader(&Input{reader: bytes.NewReader(plaintext.Bytes()), size: int64(plaintext.Len())})
if err != nil {
return 0, err
}
defer r.Close()
parts, err := r.packageData()
if err != nil {
return 0, err
}
defer func() {
for _, data := range parts {
clear(data)
}
}()
data, err := encryptPackageParts(parts, *e.encryption.options, editorProgress(e.OnWriteProgress))
if err != nil {
return 0, err
}
return io.Copy(writer, bytes.NewReader(data))
}
// EncryptSM4 使用SM4-CBC及PKCS#7填充加密
// 入参: key 密钥, iv 初始向量, plaintext 明文
// 返回: []byte 密文, error 错误信息
func (GMCryptoProvider) EncryptSM4(key, iv, plaintext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() {
return nil, fmt.Errorf("invalid SM4 IV length")
}
out := padding.NewPKCS7Padding(sm4.BlockSize).Pad(bytes.Clone(plaintext))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, out)
return out, nil
}
// DecryptSM4 解密SM4-CBC并校验PKCS#7填充
// 入参: key 密钥, iv 初始向量, ciphertext 密文
// 返回: []byte 明文, error 错误信息
func (GMCryptoProvider) DecryptSM4(key, iv, ciphertext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() || len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return nil, ErrInvalidCredentials
}
out := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ciphertext)
plain, err := padding.NewPKCS7Padding(sm4.BlockSize).ConstantTimeUnpad(out)
if err != nil {
clear(out)
return nil, ErrInvalidCredentials
}
return plain, nil
}
// PasswordKey 按GB/T32918派生16字节口令密钥
// 入参: password UTF-8口令
// 返回: []byte 密钥, error 错误信息
func (GMCryptoProvider) PasswordKey(password []byte) ([]byte, error) {
if len(password) == 0 {
return nil, ErrCredentialsRequired
}
return sm3.Kdf(password, sm4.BlockSize), nil
}
// EncryptKey 使用SM2公钥证书封装文件密钥
// 入参: certificate DER证书, key 文件密钥
// 返回: []byte ASN.1密文, error 错误信息
func (GMCryptoProvider) EncryptKey(certificate, key []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return sm2.EncryptASN1(rand.Reader, cert.PublicKey.(*ecdsa.PublicKey), key)
}
// ParseEncryptionCertificate 解析单张SM2加密证书并返回DER副本
// 仅检查格式、算法和密钥用途,接收者身份及信任应由调用方确认
// 入参: certificate PEM或DER证书
// 返回: []byte DER证书, error 错误信息
func ParseEncryptionCertificate(certificate []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return bytes.Clone(cert.Raw), nil
}
// parseEncryptionCertificate 解析并检查加密证书
// 入参: certificate PEM或DER证书
// 返回: *smx509.Certificate 证书, error 错误信息
func parseEncryptionCertificate(certificate []byte) (*smx509.Certificate, error) {
cert, err := parseSignatureCertificate(certificate)
if err != nil {
return nil, err
}
public, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || !sm2.IsSM2PublicKey(public) {
return nil, fmt.Errorf("SM2 encryption certificate required")
}
if cert.KeyUsage != 0 && cert.KeyUsage&(smx509.KeyUsageKeyEncipherment|smx509.KeyUsageDataEncipherment|smx509.KeyUsageKeyAgreement) == 0 {
return nil, fmt.Errorf("certificate does not permit encryption")
}
return cert, nil
}
-86
View File
@@ -1,86 +0,0 @@
// 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 (
"bytes"
"io"
)
// Encryption 获取当前保存策略,不暴露口令或私钥
// 返回: EncryptionInfo 加密状态
func (e *Editor) Encryption() EncryptionInfo {
return e.encryption.summary()
}
// SetEncryption 设置后续保存的加密策略,nil显式选择明文保存
// 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略
// 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史
// 入参: options 加密策略
// 返回: error 错误信息
func (e *Editor) SetEncryption(options *EncryptionOptions) error {
if options == nil {
e.encryption = nil
return nil
}
if err := validateEncryptionOptions(*options); err != nil {
return err
}
info := EncryptionInfo{Encrypted: true, Method: "1.1.1", Layers: 1}
if len(options.Password) != 0 {
info.Users = []string{options.UserName}
} else {
info.Method = "1.1.2"
for _, recipient := range options.Recipients {
info.Users = append(info.Users, recipient.UserName)
}
}
e.encryption = &encryptionState{info: info, options: cloneEncryptionOptions(options)}
return nil
}
// writeEncrypted 在全部准备及加密成功后写出结果,取消不产生输出
// 输出流自身写入失败时调用方仍需丢弃本次输出
// 入参: writer 输出流
// 返回: int64 写入字节数, error 错误信息
func (e *Editor) writeEncrypted(writer io.Writer) (int64, error) {
if e.encryption.options == nil {
return 0, ErrEncryptionPolicyRequired
}
var plaintext bytes.Buffer
if _, err := e.writePlaintext(&plaintext); err != nil {
return 0, err
}
defer clear(plaintext.Bytes())
r, err := NewReader(bytes.NewReader(plaintext.Bytes()), int64(plaintext.Len()))
if err != nil {
return 0, err
}
defer r.Close()
parts, err := r.packageData()
if err != nil {
return 0, err
}
defer func() {
for _, data := range parts {
clear(data)
}
}()
data, err := encryptPackageParts(parts, *e.encryption.options, editorProgress(e.OnWriteProgress))
if err != nil {
return 0, err
}
return io.Copy(writer, bytes.NewReader(data))
}
-120
View File
@@ -1,120 +0,0 @@
// 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 (
"bytes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"fmt"
"github.com/emmansun/gmsm/padding"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
"github.com/emmansun/gmsm/sm4"
"github.com/emmansun/gmsm/smx509"
)
// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用
type GMCryptoProvider struct{}
// EncryptSM4 使用SM4-CBC及PKCS#7填充加密
// 入参: key 密钥, iv 初始向量, plaintext 明文
// 返回: []byte 密文, error 错误信息
func (GMCryptoProvider) EncryptSM4(key, iv, plaintext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() {
return nil, fmt.Errorf("invalid SM4 IV length")
}
out := padding.NewPKCS7Padding(sm4.BlockSize).Pad(bytes.Clone(plaintext))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, out)
return out, nil
}
// DecryptSM4 解密SM4-CBC并校验PKCS#7填充
// 入参: key 密钥, iv 初始向量, ciphertext 密文
// 返回: []byte 明文, error 错误信息
func (GMCryptoProvider) DecryptSM4(key, iv, ciphertext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() || len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return nil, ErrInvalidCredentials
}
out := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ciphertext)
plain, err := padding.NewPKCS7Padding(sm4.BlockSize).ConstantTimeUnpad(out)
if err != nil {
clear(out)
return nil, ErrInvalidCredentials
}
return plain, nil
}
// PasswordKey 按GB/T32918派生16字节口令密钥
// 入参: password UTF-8口令
// 返回: []byte 密钥, error 错误信息
func (GMCryptoProvider) PasswordKey(password []byte) ([]byte, error) {
if len(password) == 0 {
return nil, ErrCredentialsRequired
}
return sm3.Kdf(password, sm4.BlockSize), nil
}
// EncryptKey 使用SM2公钥证书封装文件密钥
// 入参: certificate DER证书, key 文件密钥
// 返回: []byte ASN.1密文, error 错误信息
func (GMCryptoProvider) EncryptKey(certificate, key []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return sm2.EncryptASN1(rand.Reader, cert.PublicKey.(*ecdsa.PublicKey), key)
}
// ParseEncryptionCertificate 解析单张SM2加密证书并返回DER副本
// 仅检查格式、算法和密钥用途,接收者身份及信任应由调用方确认
// 入参: certificate PEM或DER证书
// 返回: []byte DER证书, error 错误信息
func ParseEncryptionCertificate(certificate []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return bytes.Clone(cert.Raw), nil
}
// parseEncryptionCertificate 解析并检查加密证书
// 入参: certificate PEM或DER证书
// 返回: *smx509.Certificate 证书, error 错误信息
func parseEncryptionCertificate(certificate []byte) (*smx509.Certificate, error) {
cert, err := parseSignatureCertificate(certificate)
if err != nil {
return nil, err
}
public, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || !sm2.IsSM2PublicKey(public) {
return nil, fmt.Errorf("SM2 encryption certificate required")
}
if cert.KeyUsage != 0 && cert.KeyUsage&(smx509.KeyUsageKeyEncipherment|smx509.KeyUsageDataEncipherment|smx509.KeyUsageKeyAgreement) == 0 {
return nil, fmt.Errorf("certificate does not permit encryption")
}
return cert, nil
}
+2 -1
View File
@@ -93,7 +93,7 @@ type encryptionEntry struct {
// 入参: data 明文OFD包, options 加密策略 // 入参: data 明文OFD包, options 加密策略
// 返回: []byte 加密OFD包, error 错误信息 // 返回: []byte 加密OFD包, error 错误信息
func EncryptPackage(data []byte, options EncryptionOptions) ([]byte, error) { func EncryptPackage(data []byte, options EncryptionOptions) ([]byte, error) {
r, err := NewReader(bytes.NewReader(data), int64(len(data))) r, err := NewReader(&Input{reader: bytes.NewReader(data), size: int64(len(data))})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -113,6 +113,7 @@ func (r *Reader) open(options []ReaderOption) error {
for _, option := range options { for _, option := range options {
option(&settings) option(&settings)
} }
r.resources = settings.resources
if settings.provider == nil { if settings.provider == nil {
settings.provider = GMCryptoProvider{} settings.provider = GMCryptoProvider{}
} }
+63 -21
View File
@@ -40,7 +40,7 @@ import (
const editorPathValidationLimit = 1024 const editorPathValidationLimit = 1024
// Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用 // Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
// Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo另存结果,不覆盖输入 // Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo输出副本,Save和SaveAs通过输出事务保存
// OnWriteProgress可选,按fonts、pages、references、resources、compress、write阶段同步回报进度,返回错误则停止保存 // OnWriteProgress可选,按fonts、pages、references、resources、compress、write阶段同步回报进度,返回错误则停止保存
// completed和total为当前阶段已处理及总工作项,compress累计完成条目数且total为0,write阶段不计数 // completed和total为当前阶段已处理及总工作项,compress累计完成条目数且total为0,write阶段不计数
// 阶段可重复,准备完成不代表保存成功 // 阶段可重复,准备完成不代表保存成功
@@ -50,6 +50,7 @@ type Editor struct {
Info DocInfo Info DocInfo
pages []PageContent pages []PageContent
resources []editorResource resources []editorResource
runtimeResources *Resources
fonts map[string]*FontResource fonts map[string]*FontResource
fontRegistration editorFontRegistration fontRegistration editorFontRegistration
fontDirs []string fontDirs []string
@@ -73,7 +74,12 @@ type Editor struct {
outlines []byte outlines []byte
encryption *encryptionState encryption *encryptionState
output *outputOptimization output *outputOptimization
saveTarget Output
snapshotArchive *editorSnapshotArchive
validatedPaths *renderCache[string, bool] validatedPaths *renderCache[string, bool]
changes EditorChanges
transactionChanges *EditorChanges
objectIndex *editorObjectIndex
richIndex *richTextIndex richIndex *richTextIndex
} }
@@ -152,11 +158,13 @@ func (e *Editor) Backends() RenderBackends {
// NewEditor 创建空白文档,保存前至少添加一页 // NewEditor 创建空白文档,保存前至少添加一页
// 返回: *Editor 文档编辑器 // 返回: *Editor 文档编辑器
func NewEditor() *Editor { func NewEditor() *Editor {
resources, _ := NewResources(ResourceOptions{})
var id [16]byte var id [16]byte
rand.Read(id[:]) rand.Read(id[:])
id[6] = id[6]&0x0f | 0x40 id[6] = id[6]&0x0f | 0x40
id[8] = id[8]&0x3f | 0x80 id[8] = id[8]&0x3f | 0x80
return &Editor{ return &Editor{
runtimeResources: resources,
Info: DocInfo{ Info: DocInfo{
DocID: hex.EncodeToString(id[:]), DocID: hex.EncodeToString(id[:]),
CreationDate: time.Now().Format("2006-01-02"), CreationDate: time.Now().Format("2006-01-02"),
@@ -170,6 +178,30 @@ func NewEditor() *Editor {
} }
} }
// SetResources 替换后续操作的资源策略,需与编辑操作串行调用
// 已创建的阅读器和快照继续使用原策略,不修改文档或撤销记录
// 入参: resources 共享策略,nil使用默认策略
func (e *Editor) SetResources(resources *Resources) {
if resources == nil {
resources, _ = NewResources(ResourceOptions{})
}
if resources == e.runtimeResources {
return
}
e.runtimeResources = resources
e.snapshotArchive = nil
e.fontRenderer = nil
e.fontSourcesCache = newFontSourceCache(resources)
e.validatedPaths = nil
if e.source != nil {
source := *e.source
e.source = &source
e.source.reader = cloneEditorReader(e.source.reader)
e.source.reader.resources = resources
e.source.reader.pageContents = resourceCache[pageContentKey, PageContent](resources, 16<<20)
}
}
// PageCount 获取当前页数 // PageCount 获取当前页数
// 返回: int 页数 // 返回: int 页数
func (e *Editor) PageCount() int { func (e *Editor) PageCount() int {
@@ -194,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
} }
@@ -204,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)
@@ -589,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
} }
@@ -757,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 使用编辑器字体来源和完整后端配置创建资源度量器
@@ -771,7 +808,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
// 返回: *Renderer 度量器 // 返回: *Renderer 度量器
func (e *Editor) newRenderer(reader *Reader) *Renderer { func (e *Editor) newRenderer(reader *Reader) *Renderer {
if e.fontSourcesCache == nil { if e.fontSourcesCache == nil {
e.fontSourcesCache = newFontSourceCache() e.fontSourcesCache = newFontSourceCache(e.runtimeResources)
} }
renderer := NewRenderer(reader, WithFontDirs(e.fontDirs...), WithFontFS(e.fontFS...), WithRenderBackends(e.backends)) renderer := NewRenderer(reader, WithFontDirs(e.fontDirs...), WithFontFS(e.fontFS...), WithRenderBackends(e.backends))
renderer.fontSourcesCache = e.fontSourcesCache.child() renderer.fontSourcesCache = e.fontSourcesCache.child()
@@ -791,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
} }
} }
@@ -843,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)
} }
@@ -1323,7 +1365,7 @@ func (v *editorValidation) creationPath(value string) error {
// 返回: *renderCache[string, bool] 校验缓存 // 返回: *renderCache[string, bool] 校验缓存
func (e *Editor) pathValidationCache() *renderCache[string, bool] { func (e *Editor) pathValidationCache() *renderCache[string, bool] {
if e.validatedPaths == nil { if e.validatedPaths == nil {
e.validatedPaths = &renderCache[string, bool]{limit: 8 << 20} e.validatedPaths = resourceCache[string, bool](e.runtimeResources, 8<<20)
} }
return e.validatedPaths return e.validatedPaths
} }
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more