Compare commits

..
458 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
xiaoqidun 8dfd610cd5 perf(字体映射): 提升中文字体映射性能
build.yaml / build (push) Successful in 54s
2026-10-16 00:28:03 +08:00
xiaoqidun 078b67ccf0 perf(压缩性能): 提升归档压缩性能
build.yaml / build (push) Successful in 52s
2026-10-16 00:23:49 +08:00
xiaoqidun e64575ec16 refactor(色彩管理): 优化图像色彩转换性能
build.yaml / build (push) Successful in 53s
2026-10-16 00:18:59 +08:00
xiaoqidun e1bf215e6a perf(色彩管理): 优化四色图像显示性能 2026-10-16 00:12:48 +08:00
xiaoqidun b5a8c9af9c refactor(色彩管理): 统一色彩与四色保真
build.yaml / build (push) Successful in 10m5s
2026-10-16 00:05:46 +08:00
xiaoqidun 5e0bca66e3 feat(图像处理): 优化图像重采样性能
build.yaml / build (push) Successful in 52s
2026-10-15 22:07:31 +08:00
xiaoqidun 0b1dc7e6c8 feat(字体图像): 优化字体图像性能
build.yaml / build (push) Successful in 54s
2026-10-15 21:58:12 +08:00
xiaoqidun 6926ceafc9 feat(资源管理): 优化资源首次扫描
build.yaml / build (push) Successful in 50s
2026-10-15 17:47:03 +08:00
xiaoqidun 59e3a7d36e feat(资源管理): 加速资源首次加载
build.yaml / build (push) Successful in 51s
2026-10-15 17:26:57 +08:00
xiaoqidun 5dea48d54b perf(资源管理): 优化海量资源管理性能
build.yaml / build (push) Successful in 57s
2026-10-15 17:11:01 +08:00
xiaoqidun 6e8eb7cfcc feat(转换能力): 加速图像重采样
build.yaml / build (push) Successful in 3m1s
2026-10-15 16:47:42 +08:00
xiaoqidun 307e3a718a feat(图像合成): 加速图像采样合成
build.yaml / build (push) Successful in 5m15s
2026-10-15 16:12:06 +08:00
xiaoqidun 43d0713488 feat(转换能力): 无损提速离线转换
build.yaml / build (push) Successful in 50s
2026-10-15 15:48:36 +08:00
xiaoqidun 7f6282fd94 feat(合成处理): 加速渐变与透明合成
build.yaml / build (push) Successful in 54s
2026-10-15 15:22:33 +08:00
xiaoqidun 3d54b15b11 feat(合成处理): 加速蒙版与图像合成
build.yaml / build (push) Successful in 50s
2026-10-15 14:51:28 +08:00
xiaoqidun 75b6943e10 feat(字体处理): 降低字体裁剪内存开销
build.yaml / build (push) Successful in 51s
2026-10-15 14:25:05 +08:00
xiaoqidun 7008f36d2b feat(图像处理): 减少无效图像内存分配
build.yaml / build (push) Successful in 51s
2026-10-15 14:18:08 +08:00
xiaoqidun 3164548eb4 feat(测试依赖): 更新测试依赖
build.yaml / build (push) Successful in 53s
2026-10-15 11:28:14 +08:00
xiaoqidun 96a8997482 feat(前端界面): 修复连续适宽横向滚动条
build.yaml / build (push) Successful in 48s
2026-10-15 10:13:56 +08:00
xiaoqidun fa476f18a3 feat(转换能力): 已修复PDF附件名称兼容问题
build.yaml / build (push) Successful in 49s
2026-10-15 09:55:16 +08:00
xiaoqidun 97a00cf1a8 feat(前端界面): 修复适宽连续阅读横向溢出
build.yaml / build (push) Successful in 51s
2026-10-15 09:39:13 +08:00
xiaoqidun aacf789615 feat(转换能力): 修复PDF链接及压缩流兼容问题
build.yaml / build (push) Successful in 4m27s
2026-10-14 23:15:36 +08:00
xiaoqidun 89252a7454 feat(前端界面): 统一工具栏单排滚动布局
build.yaml / build (push) Successful in 52s
2026-10-14 22:01:25 +08:00
xiaoqidun 4be11e7372 feat(前端界面): 修复编辑工具栏缩放换行异常
build.yaml / build (push) Successful in 50s
2026-10-14 20:41:47 +08:00
xiaoqidun 82d78feb6b feat(注解渲染): 修复注解命中、点击与定位异常
build.yaml / build (push) Successful in 48s
2026-10-14 19:47:20 +08:00
xiaoqidun 07e1ec18e1 feat(前端界面): 优化注解交互,减少阅读遮挡
build.yaml / build (push) Successful in 51s
2026-10-14 19:27:35 +08:00
xiaoqidun ef901bea0f feat(转换能力): 修复PDF字体转换
build.yaml / build (push) Successful in 52s
2026-10-14 18:46:22 +08:00
xiaoqidun 9791571d75 feat(编辑能力): 修复富文本颜色设置
build.yaml / build (push) Successful in 59s
2026-10-14 16:27:38 +08:00
xiaoqidun a182f025e1 feat(编辑能力): 优化富文本选择与编辑
build.yaml / build (push) Successful in 50s
2026-10-14 16:11:42 +08:00
xiaoqidun de229c163b feat(编辑能力): 修正文本选框与工具栏布局
build.yaml / build (push) Successful in 54s
2026-10-14 15:12:01 +08:00
xiaoqidun 6299c0f8f9 feat(编辑能力): 优化富文本编辑体验
build.yaml / build (push) Successful in 53s
2026-10-14 14:45:23 +08:00
xiaoqidun 61cfb79f25 feat(编辑能力): 支持富文本选区与六种格式
build.yaml / build (push) Successful in 54s
2026-10-14 14:27:17 +08:00
xiaoqidun 71a3a4ee79 docs(更新文档): 更新主要能力
build.yaml / build (push) Successful in 54s
2026-10-14 12:20:44 +08:00
xiaoqidun 9228c0acbb feat(升级依赖): j2kgo、jbig2、pdfgo
build.yaml / build (push) Successful in 3m50s
2026-10-14 11:46:01 +08:00
xiaoqidun 70003486b8 feat(归并文件): 归并文件下沉通用能力
build.yaml / build (push) Successful in 53s
2026-10-14 10:40:08 +08:00
xiaoqidun d07972346b feat(文件归并): 归并同类文件清理辅助代码
build.yaml / build (push) Successful in 51s
2026-10-14 09:38:41 +08:00
xiaoqidun 961bc52038 feat(文件归并): 归并PDF适配与解析逻辑
build.yaml / build (push) Successful in 48s
2026-10-14 09:22:47 +08:00
xiaoqidun b3c4bbc844 feat(文件拆分): 拆分PDF格式适配模块
build.yaml / build (push) Successful in 51s
2026-10-14 09:02:24 +08:00
xiaoqidun bec13d523f perf(性能优化): 优化资源读写与字体图像处理
build.yaml / build (push) Successful in 9m22s
2026-10-14 08:22:31 +08:00
xiaoqidun 21bd592358 feat(字体能力): 优化集合字体读取复用
build.yaml / build (push) Successful in 52s
2026-10-14 01:05:19 +08:00
xiaoqidun 20e1bf95cd feat(字体能力): 优化字体信息读取性能
build.yaml / build (push) Successful in 50s
2026-10-14 00:57:03 +08:00
xiaoqidun 5cc811c2a1 feat(文件合并): 合并字体适配文件
build.yaml / build (push) Successful in 48s
2026-10-14 00:51:39 +08:00
xiaoqidun 0be872691d feat(文件拆分): 拆分几何三维基础能力
build.yaml / build (push) Successful in 48s
2026-10-14 00:44:44 +08:00
xiaoqidun 64f9ec4241 feat(文件拆分): 拆分字体图像基础能力
build.yaml / build (push) Successful in 49s
2026-10-14 00:12:00 +08:00
xiaoqidun ee3fd02a0a feat(图像处理): 降低图像处理内存开销
build.yaml / build (push) Successful in 1m0s
2026-10-13 23:10:31 +08:00
xiaoqidun 9ade2466ba feat(性能优化): 降低曲线描边内存开销
build.yaml / build (push) Successful in 50s
2026-10-13 22:53:35 +08:00
xiaoqidun 67aabbda84 feat(前端界面): 统一配置空态布局
build.yaml / build (push) Successful in 50s
2026-10-13 22:13:13 +08:00
xiaoqidun de414de754 feat(前端界面): 优化配置空态与焦点样式
build.yaml / build (push) Successful in 55s
2026-10-13 21:58:37 +08:00
xiaoqidun 1edcc53ea3 feat(更新依赖): 更新PDFGo
build.yaml / build (push) Successful in 2m32s
2026-10-13 21:44:59 +08:00
xiaoqidun a812d7ed8e feat(字体处理): 修复字体裁剪纵向度量丢失
build.yaml / build (push) Successful in 50s
2026-10-13 20:49:13 +08:00
xiaoqidun b03cdf3ad0 feat(资源定位): 修复隐藏内容干扰资源定位
build.yaml / build (push) Successful in 52s
2026-10-13 20:29:17 +08:00
xiaoqidun e4d38eb289 feat(资源定位): 优化资源定位与原文汇总 2026-10-13 20:14:15 +08:00
xiaoqidun 2db0cbcb95 feat(编辑资源): 优化资源依赖清理性能
build.yaml / build (push) Successful in 51s
2026-10-13 20:00:30 +08:00
xiaoqidun aedc1493fb feat(编辑能力): 修复字体切换预览残留
build.yaml / build (push) Successful in 53s
2026-10-13 19:47:53 +08:00
xiaoqidun ddf0dda283 feat(资源编辑): 修复嵌套图片预览清理
build.yaml / build (push) Successful in 51s
2026-10-13 19:35:01 +08:00
xiaoqidun f024eff35d feat(资源管理): 修复共享页面资源定位
build.yaml / build (push) Successful in 50s
2026-10-13 19:25:12 +08:00
xiaoqidun 6f9bebae26 feat(资源管理): 修复字体预览与资源定位
build.yaml / build (push) Successful in 54s
2026-10-13 19:17:53 +08:00
xiaoqidun f5463b9479 feat(术语文案): 统一前后端术语与界面文案
build.yaml / build (push) Successful in 51s
2026-10-13 18:55:26 +08:00
xiaoqidun 6d782de471 feat(编辑交互): 优化文档配置与编辑交互
build.yaml / build (push) Successful in 50s
2026-10-13 18:29:21 +08:00
xiaoqidun 0afb368829 feat(前端界面): 统一配置表单与边框布局
build.yaml / build (push) Successful in 49s
2026-10-13 17:12:16 +08:00
xiaoqidun 8a88a86264 feat(前端界面): 修正文档配置提示与对齐
build.yaml / build (push) Successful in 52s
2026-10-13 16:56:21 +08:00
xiaoqidun fc01fbdc17 feat(前端界面): 统一侧栏文案与注解布局
build.yaml / build (push) Successful in 1m1s
2026-10-13 16:50:25 +08:00
xiaoqidun cd5ea45a89 feat(前端界面): 统一注解搜索文案
build.yaml / build (push) Successful in 51s
2026-10-13 16:41:57 +08:00
xiaoqidun c74dff1e41 feat(阅读编辑): 完善阅读编辑与资源管理
build.yaml / build (push) Successful in 58s
2026-10-13 16:34:47 +08:00
xiaoqidun 0139433e25 feat(资源管理): 精简资源管理按钮布局
build.yaml / build (push) Successful in 54s
2026-10-13 15:23:17 +08:00
xiaoqidun 523f66983e feat(资源管理): 优化资源管理与图元定位选取
build.yaml / build (push) Successful in 52s
2026-10-13 15:16:57 +08:00
xiaoqidun 7bdb03f7bd feat(资源管理): 完善资源管理与校验,优化性能
build.yaml / build (push) Successful in 50s
2026-10-13 14:24:37 +08:00
xiaoqidun 0e423c9713 feat(前端界面): 优化资源列表滚动与对齐
build.yaml / build (push) Successful in 50s
2026-10-13 13:09:08 +08:00
xiaoqidun 656a0d51b0 feat(资源管理): 统一附件管理并清理重复入口
build.yaml / build (push) Successful in 50s
2026-10-13 13:04:37 +08:00
xiaoqidun ab0c6dfed0 feat(前端界面): 修复资源列表列对齐
build.yaml / build (push) Successful in 48s
2026-10-13 12:58:11 +08:00
xiaoqidun 7841eaa997 feat(资源管理): 修复缺失资源阻断附件管理
build.yaml / build (push) Successful in 49s
2026-10-13 12:51:48 +08:00
xiaoqidun b64668514f feat(资源管理): 修复资源预览与媒体绑定
build.yaml / build (push) Successful in 48s
2026-10-13 12:44:48 +08:00
xiaoqidun 979b660703 feat(资源管理): 新增资源管理及媒体编辑
build.yaml / build (push) Successful in 50s
2026-10-13 12:36:42 +08:00
xiaoqidun 5a88a9f31f feat(编辑能力): 优化字体资源及图像处理链路
build.yaml / build (push) Successful in 49s
2026-10-13 12:01:49 +08:00
xiaoqidun 7e774f99df feat(导出能力): 限制导出字形缓存占用
build.yaml / build (push) Successful in 50s
2026-10-13 11:02:36 +08:00
xiaoqidun 09bb08987e feat(编辑能力): 编辑图元支持页外显示
build.yaml / build (push) Successful in 51s
2026-10-13 10:51:29 +08:00
xiaoqidun 489db301c5 feat(图文处理): 优化图文缓存与导出处理
build.yaml / build (push) Successful in 52s
2026-10-13 10:38:28 +08:00
xiaoqidun 7df35ac122 feat(压缩能力): 完善图像压缩与蒙版保护
build.yaml / build (push) Successful in 53s
2026-10-13 09:30:34 +08:00
xiaoqidun a3594d1280 feat(编辑能力): 完善资源扫描与裁剪保护
build.yaml / build (push) Successful in 50s
2026-10-13 09:04:16 +08:00
xiaoqidun 078c4e0736 feat(前端界面): 优化电子公文注引编辑
build.yaml / build (push) Successful in 48s
2026-10-13 08:49:13 +08:00
xiaoqidun e91c30ca3c feat(电子公文): 修复标引布局与资源扫描
build.yaml / build (push) Successful in 50s
2026-10-13 08:40:56 +08:00
xiaoqidun 91e5b57e24 feat(电子公文): 完善标引注释,优化资源处理
build.yaml / build (push) Successful in 48s
2026-10-13 08:30:03 +08:00
xiaoqidun 3f797e656b feat(编辑能力): 修复旧命名空间资源压缩
build.yaml / build (push) Successful in 54s
2026-10-13 01:10:59 +08:00
xiaoqidun 261971cc41 feat(电子公文): 支持标引编辑与图元绑定
build.yaml / build (push) Successful in 50s
2026-10-13 01:04:40 +08:00
xiaoqidun e5685e5c7b feat(电子公文): 规范标引命名空间管理
build.yaml / build (push) Successful in 50s
2026-10-13 00:33:19 +08:00
xiaoqidun e86fc6a4e0 feat(电子公文): 支持标引解析与原文定位
build.yaml / build (push) Successful in 49s
2026-10-13 00:30:27 +08:00
xiaoqidun 3780a25140 feat(签名时间): 修复签名时间解析
build.yaml / build (push) Successful in 48s
2026-10-12 23:46:52 +08:00
xiaoqidun 85fbb77d63 feat(空格处理): 规范空格转义,修复文字错位
build.yaml / build (push) Successful in 48s
2026-10-12 23:39:27 +08:00
xiaoqidun f8263f798f feat(字体图像): 优化字体图像并修正签章裁剪
build.yaml / build (push) Successful in 3m41s
2026-10-12 23:04:11 +08:00
xiaoqidun 5d1fd593cd feat(图像签章): 完善图像取消与签章缓存
build.yaml / build (push) Successful in 48s
2026-10-12 19:18:25 +08:00
xiaoqidun 3d37b0d510 feat(图像处理): 优化图像读取与取消响应
build.yaml / build (push) Successful in 49s
2026-10-12 19:07:59 +08:00
xiaoqidun 4caf49d3d0 feat(字体处理): 修复字体取消后的缓存污染
build.yaml / build (push) Successful in 50s
2026-10-12 18:56:35 +08:00
xiaoqidun 8d3e6944aa feat(附件扩展): 规范附件标引与扩展解析
build.yaml / build (push) Successful in 49s
2026-10-12 18:45:32 +08:00
xiaoqidun 4b9dbd6447 feat(压缩编辑): 优化字体与压缩,规范文档解析
build.yaml / build (push) Successful in 52s
2026-10-12 18:35:50 +08:00
xiaoqidun 778d36d373 feat(压缩能力): 优化异常图像压缩缓存
build.yaml / build (push) Successful in 53s
2026-10-12 17:20:14 +08:00
xiaoqidun d8dc25293e feat(压缩能力): 优化位图降采样与压缩性能
build.yaml / build (push) Successful in 49s
2026-10-12 17:09:57 +08:00
xiaoqidun 92149b220e feat(字体处理): 优化字体裁剪与依赖校验
build.yaml / build (push) Successful in 1m17s
2026-10-12 16:55:10 +08:00
xiaoqidun 7bce701907 feat(压缩能力): 修复版本压缩并优化资源扫描
build.yaml / build (push) Successful in 50s
2026-10-12 16:39:05 +08:00
xiaoqidun c51ae86876 feat(字体处理): 修复多文档字体裁剪
build.yaml / build (push) Successful in 53s
2026-10-12 16:28:01 +08:00
xiaoqidun 9a32c510c4 feat(字体处理): 修复多版本字体裁剪 2026-10-12 16:16:52 +08:00
xiaoqidun ff166b1ee6 feat(参照标准): 修复绘制参数与渐变解析
build.yaml / build (push) Successful in 49s
2026-10-12 15:30:10 +08:00
xiaoqidun 2370fb07d8 style(代码风格): 统一参数注释与代码格式
build.yaml / build (push) Successful in 51s
2026-10-12 15:19:06 +08:00
xiaoqidun 9c84264ac7 fix(修复问题): 修复图像裁剪渲染回归
build.yaml / build (push) Successful in 50s
2026-10-12 15:09:45 +08:00
xiaoqidun 05579265b8 fix(问题修复): 修复资源解析与字体压缩
build.yaml / build (push) Successful in 50s
2026-10-12 14:56:49 +08:00
xiaoqidun 8979550127 feat(更新依赖): 更新github.com/xiaoqidun/j2kgo到v1.0.0
build.yaml / build (push) Successful in 2m50s
2026-10-12 14:13:48 +08:00
xiaoqidun 47b1957e72 feat(参照标准): 规范元数据与导航解析
build.yaml / build (push) Successful in 52s
2026-10-12 12:18:30 +08:00
xiaoqidun 2a7fbd7f61 feat(更新依赖): 更新PDFGo、JBIG2、J2KGo
build.yaml / build (push) Successful in 3m17s
2026-10-12 11:37:36 +08:00
xiaoqidun c4b81155a9 feat(命名空间): 规范声明顺序,修复命名空间
build.yaml / build (push) Successful in 49s
2026-10-12 11:20:30 +08:00
xiaoqidun 17a9a03361 feat(版本编辑): 优化版本读取与多文档排序
build.yaml / build (push) Successful in 51s
2026-10-12 10:57:18 +08:00
xiaoqidun a710bc5f41 feat(渲染编辑): 完善渲染编辑,优化版本压缩
build.yaml / build (push) Successful in 4m25s
2026-10-12 10:47:20 +08:00
xiaoqidun 19dc79e8e3 feat(编辑压缩): 规范导入编辑,优化资源压缩
build.yaml / build (push) Successful in 7m31s
2026-10-12 00:43:15 +08:00
xiaoqidun 225f60919b feat(参照标准): 规范包内路径与资源引用
build.yaml / build (push) Successful in 44s
2026-10-11 21:12:09 +08:00
xiaoqidun d7e9604282 feat(版本读取): 完善版本提取与文档读取取消
build.yaml / build (push) Successful in 50s
2026-10-11 20:52:18 +08:00
xiaoqidun 2e3809179b feat(编辑字体): 优化多文档及版本字体清理
build.yaml / build (push) Successful in 47s
2026-10-11 20:38:07 +08:00
xiaoqidun 8864f6c7ba feat(前端界面): 提示信息添加PDF
build.yaml / build (push) Successful in 44s
2026-10-11 20:28:00 +08:00
xiaoqidun 10e724d57f feat(编辑版本): 完善版本与字体操作取消
build.yaml / build (push) Successful in 48s
2026-10-11 20:17:36 +08:00
xiaoqidun 52d7b6aa64 feat(版本编辑): 优化版本依赖扫描与编辑内存
build.yaml / build (push) Successful in 49s
2026-10-11 19:56:09 +08:00
xiaoqidun 34e17383f1 feat(图像处理): 修复黑白压缩丢失附加信息
build.yaml / build (push) Successful in 3m40s
2026-10-11 19:34:17 +08:00
xiaoqidun e5f34c6ea6 feat(图像处理): 修正图像压缩密度与比例
build.yaml / build (push) Successful in 3m7s
2026-10-11 19:21:57 +08:00
xiaoqidun 478c18cb1f feat(字体渲染): 优化字体解析与渲染缓存
build.yaml / build (push) Successful in 49s
2026-10-11 18:57:10 +08:00
xiaoqidun 2c06e5ff69 feat(渲染编辑): 完善渲染压缩及文档版本编辑
build.yaml / build (push) Successful in 1m51s
2026-10-11 18:45:54 +08:00
xiaoqidun cfbaa8a177 feat(压缩编辑): 复用资源引用,优化压缩性能
build.yaml / build (push) Successful in 49s
2026-10-11 11:55:31 +08:00
xiaoqidun 6c3068b813 feat(图像压缩): 修复替换图像压缩与蒙版保护
build.yaml / build (push) Successful in 45s
2026-10-11 11:48:34 +08:00
xiaoqidun 36c0dc3ca0 feat(压缩性能): 优化多文档压缩内存与性能
build.yaml / build (push) Successful in 46s
2026-10-11 11:42:46 +08:00
xiaoqidun c3f6b61dfd feat(编辑能力): 完善多文档资源清理与压缩
build.yaml / build (push) Successful in 45s
2026-10-11 11:37:07 +08:00
xiaoqidun a3098212eb feat(编辑能力): 修复附件误改,优化压缩扫描
build.yaml / build (push) Successful in 46s
2026-10-11 11:24:44 +08:00
xiaoqidun 6a9d2a12eb feat(编辑能力): 修复字体外置残留与版本清理
build.yaml / build (push) Successful in 49s
2026-10-11 11:07:33 +08:00
xiaoqidun f71000befd feat(渲染能力): 完善三维渲染、字体及版本保存
build.yaml / build (push) Successful in 2m39s
2026-10-11 10:20:14 +08:00
xiaoqidun 13dbacad15 feat(字体裁剪): 优化版本字体裁剪范围
build.yaml / build (push) Successful in 45s
2026-10-11 01:57:05 +08:00
xiaoqidun d5944b30ff feat(编辑压缩): 完善多文档字体压缩
build.yaml / build (push) Successful in 43s
2026-10-11 01:48:51 +08:00
xiaoqidun 226688bbdc feat(字体处理): 优化字体压缩,降低裁剪开销
build.yaml / build (push) Successful in 43s
2026-10-11 01:36:50 +08:00
xiaoqidun 62c81e7061 feat(字体处理): 修复字体编辑,优化字体裁剪
build.yaml / build (push) Successful in 46s
2026-10-11 01:22:46 +08:00
xiaoqidun 78379bfa6e feat(清理依赖): 清理go.mod文件
build.yaml / build (push) Successful in 45s
2026-10-11 00:56:30 +08:00
xiaoqidun cf8dfd046b feat(字体解耦): 解耦字体依赖,完善字体处理
build.yaml / build (push) Successful in 47s
2026-10-11 00:52:01 +08:00
389 changed files with 86357 additions and 26093 deletions

No files matched your search

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

After

Width:  |  Height:  |  Size: 361 KiB

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

After

Width:  |  Height:  |  Size: 7.0 KiB

+1042 -138
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";
export class FontPicker {
constructor(input, toggle, list, onChange, onOpen) {
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false });
input.addEventListener("input", () => this.show(input.value));
input.addEventListener("focus", () => input.select());
input.addEventListener("click", () => { if (!this.open) this.show(); });
input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); this.show(); } });
input.addEventListener("keydown", event => this.keyDown(event));
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
toggle.addEventListener("pointerdown", event => event.preventDefault());
toggle.addEventListener("click", () => {
if (this.open) this.close();
else { input.focus(); this.show(); }
});
list.addEventListener("pointerdown", event => {
if (event.target.closest("[role=option]")) event.preventDefault();
});
list.addEventListener("click", event => {
const option = event.target.closest("[role=option]");
if (option) this.choose(Number(option.dataset.index));
else { input.focus(); input.select(); this.show(); }
});
bindDropdownPointer(list, index => this.choose(index));
window.addEventListener("resize", () => this.close());
}
setFonts(fonts, selected) {
this.close();
setFonts(fonts, selected, preserve = false) {
const open = preserve && this.open;
if (!open) this.close();
this.fonts = fonts;
this.groups = fonts.map((_, index) => [index]);
this.names = new Map();
for (const font of fonts) {
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
this.names.set(name, (this.names.get(name) || 0) + 1);
}
const key = font => font?.id || font?.postscriptName;
const key = font => font?.richKey || font?.id || font?.postscriptName;
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
this.restore();
if (open) {
const active = this.matches[this.active]?.font;
this.show(this.query, false);
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
if (index >= 0) this.activate(index);
} else this.restore();
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
if (this.fonts !== fonts) return;
this.groups = groups;
this.names.clear();
for (const group of groups) {
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
this.names.set(name, (this.names.get(name) || 0) + 1);
}
if (this.open) {
const active = this.matches[this.active]?.index;
this.show(this.query, false);
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
if (index >= 0) this.activate(index);
}
else this.restore();
}).catch(() => {});
}
setDisabled(disabled) {
@@ -43,36 +63,45 @@ export class FontPicker {
restore() {
const font = this.fonts[Number(this.value)];
this.input.value = font?.fullName || font?.name || "";
const value = font?.menuFamily || font?.fullName || font?.name || "";
if (this.input.value !== value) {
this.input.value = value;
if (document.activeElement === this.input) this.input.select();
}
this.input.scrollLeft = 0;
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
this.input.setAttribute("aria-expanded", String(this.open));
this.input.removeAttribute("aria-activedescendant");
}
sourceLabel(font) {
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
if (this.names.get(name) < 2) return "";
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
}
show(query = "", load = true) {
if (this.input.disabled) return;
this.query = query;
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
const terms = normalize(query).split(" ").filter(Boolean);
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
this.matches = this.groups.map(group => {
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
return { font: this.fonts[index], index, group };
}).filter(({ group }) => {
const name = normalize(group.flatMap(index => {
const font = this.fonts[index];
return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])];
}).filter(Boolean).join(" "));
return terms.every(term => name.includes(term));
});
this.list.replaceChildren();
for (const { font, index } of this.matches) {
const option = document.createElement("div");
option.className = "font-option";
option.id = `${this.list.id}-${index}`;
option.dataset.index = String(index);
option.setAttribute("role", "option");
option.setAttribute("aria-disabled", String(Boolean(font.disabled)));
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
const name = document.createElement("span");
name.textContent = font.fullName || font.name;
name.textContent = font.menuFamily || font.fullName || font.name;
option.append(name);
const source = this.sourceLabel(font);
if (source) {
@@ -92,30 +121,24 @@ export class FontPicker {
}
if (!this.open) this.list.showPopover();
this.open = true;
this.input.setAttribute("aria-expanded", "true");
this.position();
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
if (load) this.onOpen?.();
}
position() {
if (!this.open) return;
const rect = this.input.parentElement.getBoundingClientRect();
if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; }
const width = Math.min(rect.width, window.innerWidth - 16);
Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` });
if (!positionDropdown(this.list, this.input.parentElement)) this.close();
}
activate(index) {
this.active = index;
[...this.list.children].forEach((option, i) => option.setAttribute("aria-selected", String(i === index)));
[...this.list.children].forEach((option, i) => option.setAttribute("data-selected", String(i === index)));
const option = this.list.children[index];
if (option) {
this.input.setAttribute("aria-activedescendant", option.id);
option.scrollIntoView({ block: "nearest" });
} else this.input.removeAttribute("aria-activedescendant");
}
}
choose(index) {
@@ -123,16 +146,27 @@ export class FontPicker {
const changed = this.value !== String(index);
this.value = String(index);
this.close();
if (changed) return this.onChange();
if (changed) {
const task = this.changing = Promise.resolve(this.onChange());
return task.finally(() => { if (this.changing === task) this.changing = null; });
}
}
commitExact() {
const value = this.input.value.trim().toLocaleLowerCase();
const matches = font => font && !font.disabled
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
&& [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
const selected = Number(this.value);
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
if (index >= 0) this.choose(index);
if (index >= 0) return this.choose(index);
}
prepareCommit(event) {
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
this.commitExact();
this.close();
}
return this.changing;
}
close() {
@@ -141,7 +175,7 @@ export class FontPicker {
}
keyDown(event) {
if (event.isComposing) return;
if (event.isComposing || event.key === "Tab" && (event.ctrlKey || event.metaKey || event.altKey)) return;
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
event.stopPropagation();
if (event.key === "Escape") {
@@ -163,7 +197,8 @@ export class FontPicker {
}
export class FontManager {
constructor({ onChange, onPermissionChange }) {
constructor({ onChange, onPermissionChange, host = null }) {
this.host = host;
this.userFonts = [];
this.localFonts = [];
this.catalog = [];
@@ -271,7 +306,7 @@ export class FontManager {
async queryLocal() {
try {
const available = await window.queryLocalFonts();
const available = this.host ? await this.host.fonts() : await window.queryLocalFonts();
this.catalog = available;
this.catalogLoaded = true;
this.permission = "granted";
@@ -298,6 +333,7 @@ export class FontManager {
}
async read(font) {
if (font.nativeFile) return font.nativeFile;
if (font.blob) {
const blob = await font.blob();
return new Uint8Array(await blob.arrayBuffer());
@@ -316,13 +352,44 @@ export class FontManager {
}), ...this.catalog];
}
async loadFaces(inspect) {
while (true) {
async loadFaces(inspect, current = () => true) {
while (current()) {
const file = this.userFonts.find(font => font.enabled && !font.faces);
if (!file) return;
if (!file) break;
try { file.faces = await inspect(await this.read(file)); }
catch { file.faces = []; }
catch { if (!current()) return; file.faces = []; }
}
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
while (current() && pending.length) {
const font = pending.shift();
try {
const faces = await inspect(await this.read(font));
const face = faces.find(face => face.postscriptName === font.postscriptName);
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
else font.metadataFailed = true;
} catch { if (current()) font.metadataFailed = true; }
}
}));
}
async defaultFont(order, inspect, current = () => true) {
const fonts = this.editorFonts().filter(font => !font.disabled);
if (!fonts.length) return;
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
const indices = await order(fonts, families);
let fallback = fonts.length;
for (const index of indices) {
if (!current()) return;
try {
await inspect(fonts[index], "中文汉字编辑水印,。!?");
if (current()) return fonts[index];
} catch (error) {
if (error.missingGlyphs) fallback = Math.min(fallback, index);
}
}
if (current()) return fonts[fallback];
}
async add(fonts) {
@@ -402,7 +469,7 @@ export class FontManager {
for (const font of fonts) {
if (disabled.has(this.localName(font))) continue;
const names = [this.localName(font), font.postscriptName && `${font.postscriptName}.ttf`,
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`];
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`, ...(font.names || []).map(name => `${name}.ttf`)];
for (const name of names) {
if (name && !entries.has(name)) entries.set(name, font);
}
@@ -411,10 +478,15 @@ export class FontManager {
}
canReadLocal() {
return typeof window.queryLocalFonts === "function";
return !!this.host || typeof window.queryLocalFonts === "function";
}
async refreshPermission() {
if (this.host) {
this.permission = "granted";
this.onPermissionChange();
return;
}
if (!this.canReadLocal() || !navigator.permissions?.query) {
this.onPermissionChange();
return;
+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
+169 -57
View File
@@ -1,11 +1,15 @@
importScripts("./wasm_exec.js");
let pending = Promise.resolve();
const operations = new Map();
const fontRequests = new Map();
const engines = new Map();
let resolveRuntime;
let rejectRuntime;
const runtimeReady = new Promise((resolve, reject) => { resolveRuntime = resolve; rejectRuntime = reject; });
runtimeReady.catch(() => {});
let fontSequence = 0;
let engineMemory = null;
function requestFont(id, signal, name, done) {
function requestFont(engine, id, signal, name, done) {
const fontRequests = engine.fontRequests;
if (signal.aborted) { done(null, "", true); return; }
const request = ++fontSequence;
const cancel = () => {
@@ -17,22 +21,79 @@ function requestFont(id, signal, name, done) {
signal.removeEventListener("abort", cancel);
done(result.bytes, result.error || "", signal.aborted);
});
self.postMessage({ id, type: "font", request, name });
self.postMessage({ engineID: engine.id, id, type: "font", request, name });
}
self.onmessage = ({ data }) => {
if (data.type === "module") {
if (data.error) exitWASM(new Error(data.error));
else loadWASM(data.module).catch(exitWASM);
return;
}
const engineID = data.engineID;
if (typeof engineID !== "string" || !engineID) {
self.postMessage({ engineID, id: data.id, ok: false, error: "missing engine ID" });
return;
}
if (data.type === "open") {
if (engines.has(engineID)) {
self.postMessage({ engineID, type: "exit", error: "engine already exists" });
return;
}
const engine = { id: engineID, operations: new Map(), fontRequests: new Map(), active: new Set(), closed: false };
engines.set(engineID, engine);
engine.pending = runtimeReady.then(async () => {
if (engine.closed) return;
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
if (!opened.ok) throw new Error(opened.error);
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends", null));
if (!backends.ok) throw new Error(backends.error);
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
}).catch(error => {
engine.closed = true;
self.postMessage({ engineID, type: "exit", error: error.message });
});
return;
}
const engine = engines.get(engineID);
if (!engine && data.type === "close") {
self.postMessage({ engineID, type: "closed" });
return;
}
if (!engine || engine.closed && data.type !== "close") {
self.postMessage({ engineID, id: data.id, ok: false, error: "unknown or closed engine" });
return;
}
if (data.type === "close") {
engine.closed = true;
for (const operation of engine.operations.values()) operation.abort();
engine.pending.then(async () => {
await Promise.all([...engine.active]);
await runtimeReady;
globalThis.ofdgoEngineClose(engineID);
engines.delete(engineID);
self.postMessage({ engineID, type: "closed" });
}).catch(() => engines.delete(engineID));
return;
}
if (data.type === "font") {
fontRequests.get(data.request)?.(data);
fontRequests.delete(data.request);
engine.fontRequests.get(data.request)?.(data);
engine.fontRequests.delete(data.request);
return;
}
if (data.type === "cancel") {
operations.get(data.id)?.abort();
engine.operations.get(data.id)?.abort();
return;
}
if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportMedia" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") {
operations.set(data.id, new AbortController());
}
pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data));
engine.operations.set(data.id, new AbortController());
engine.pending = engine.pending.then(() => {
const operation = (data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(engine, data) : handleMessage(engine, data)).catch(error => {
engine.operations.delete(data.id);
self.postMessage({ engineID, id: data.id, ok: false, error: error.message });
});
engine.active.add(operation);
operation.finally(() => engine.active.delete(operation));
if (data.name !== "ofdgoSaveSnapshot") return operation;
});
};
async function availableOutput(directory, base, format) {
@@ -48,7 +109,9 @@ async function availableOutput(directory, base, format) {
}
}
async function handleBatchMessage({ id, name, args }) {
async function handleBatchMessage(engine, { id, name, args }) {
const operations = engine.operations;
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
const signal = operations.get(id).signal;
const channel = new MessageChannel();
const finish = (done, err) => {
@@ -78,17 +141,20 @@ async function handleBatchMessage({ id, name, args }) {
if (name === "ofdgoConvertFile") {
const [file, options, fonts, destination] = args;
credentials = options.credentials;
const formats = JSON.parse(globalThis.ofdgoOutputFormats()).data;
const formats = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoOutputFormats", null)).data;
const format = formats.find(item => item.value === options.format);
if (!format) throw new Error("不支持此格式");
root = destination;
const base = options.base.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/[. ]+$/g, "") || "document";
const target = root ? await availableOutput(root, base, format) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
if (root && format.paged) {
directory = await root.getDirectoryHandle(target.name, { create: true });
const target = root ? await navigator.locks.request("ofdgo-convert-output", { signal }, async () => {
signal.throwIfAborted();
const target = await availableOutput(root, base, format);
if (format.paged) directory = await root.getDirectoryHandle(target.name, { create: true });
else { await root.getFileHandle(target.name, { create: true }); directory = root; }
created = target.name;
self.postMessage({ id, type: "output", name: created });
} else directory = root;
postMessage({ id, type: "output", name: created });
return target;
}) : { stem: base, name: format.paged ? base : `${base}.${format.extension}` };
const boundary = async (action, pageName) => {
signal.throwIfAborted();
if (action === "open") {
@@ -99,7 +165,7 @@ async function handleBatchMessage({ id, name, args }) {
const handle = await directory.getFileHandle(fileName, { create: true });
if (!format.paged) {
created = fileName;
self.postMessage({ id, type: "output", name: created });
postMessage({ id, type: "output", name: created });
}
current.handle = handle;
output = await handle.createWritable();
@@ -112,19 +178,19 @@ async function handleBatchMessage({ id, name, args }) {
chunks = [];
}
};
payload = await globalThis.ofdgoConvertFile(read(file), file.size, options, write,
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoConvertFile", done => finish(done), read(file), file.size, options, write,
(phase, completed, total, done) => {
self.postMessage({ id, type: "conversion", phase, completed, total });
postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
finish(done);
},
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts, (name, done) => requestFont(id, signal, name, done));
done => finish(done), fonts, (name, done) => requestFont(engine, id, signal, name, done));
} else {
const [entries, handle, compression] = args;
if (handle) output = await handle.createWritable();
payload = await globalThis.ofdgoPackFiles(entries.map(entry => ({ name: entry.name, size: entry.file.size })),
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPackFiles", done => finish(done), entries.map(entry => ({ name: entry.name, size: entry.file.size })),
index => read(entries[index].file), write,
(completed, total, done) => { self.postMessage({ id, type: "conversion", phase: "pack", completed, total }); finish(done); },
(completed, total, done) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); },
done => finish(done), compression);
}
signal.throwIfAborted();
@@ -143,7 +209,7 @@ async function handleBatchMessage({ id, name, args }) {
}
result.data.size = size;
operations.delete(id);
self.postMessage({ id, ...result });
postMessage({ id, ...result, memory: engineMemory.buffer.byteLength });
} catch (err) {
await output?.abort().catch(() => {});
let message = signal.aborted ? "转换已取消" : err.message;
@@ -151,7 +217,7 @@ async function handleBatchMessage({ id, name, args }) {
try { await root.removeEntry(created, { recursive: true }); }
catch (cleanupError) { message += `;清理失败:${cleanupError.message}`; }
}
self.postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted });
postMessage({ id, ok: false, error: message, code: err.code, canceled: signal.aborted, memory: engineMemory.buffer.byteLength });
} finally {
credentials?.password?.fill(0);
credentials?.key?.fill(0);
@@ -161,18 +227,32 @@ async function handleBatchMessage({ id, name, args }) {
operations.delete(id);
}
}
async function handleMessage({ id, name, args }) {
async function handleMessage(engine, { id, name, args }) {
const operations = engine.operations;
const postMessage = (data, transfer) => self.postMessage({ ...data, engineID: engine.id }, transfer);
let output;
let saveFile;
let saveStamp;
let channel;
const signal = operations.get(id)?.signal;
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages";
const converting = name === "ofdgoConvertPDF";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned";
const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoOpenPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
const converting = name === "ofdgoOpenPDF";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name);
if (engine.pdf) {
const checked = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoCheckDocument", null, name));
if (!checked.ok) { postMessage({id, ...checked}); operations.delete(id); return; }
}
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
try {
signal?.throwIfAborted();
if (["ofdgoOpen", "ofdgoOpenPDF", "ofdgoLoadImport"].includes(name) && args[0] instanceof Blob) {
const file = args[0], reader = new FileReaderSync();
args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))};
}
if (name === "ofdgoDetectFormat") {
const file = args[0], reader = new FileReaderSync();
channel = new MessageChannel();
@@ -188,6 +268,9 @@ async function handleMessage({ id, name, args }) {
catch (err) { return { error: err.message }; }
};
}
if (name === "ofdgoChangeResource" && (args[0] === "add" || args[0] === "replace")) {
args[4] = new Uint8Array(new FileReaderSync().readAsArrayBuffer(args[4]));
}
if (name === "ofdgoImportImages") {
const files = args[0], reader = new FileReaderSync();
args[0] = files.map(file => file.name);
@@ -198,7 +281,7 @@ async function handleMessage({ id, name, args }) {
}
const chunks = [];
let size = 0;
if (signal) {
if (cancellable) {
channel = new MessageChannel();
const finish = (done, err, commit = false) => {
channel.port1.onmessage = () => {
@@ -207,14 +290,19 @@ async function handleMessage({ id, name, args }) {
};
channel.port2.postMessage(null);
};
if (importing || converting) {
if (resources) {
args.push(done => finish(done));
} else if (importing || converting) {
args.push((phase, completed, total, done) => {
self.postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total });
postMessage({ id, type: importing ? "import" : "conversion", phase, completed, total });
finish(done, null, importing && phase === "commit");
});
if (converting) args.push((name, done) => requestFont(id, signal, name, done));
if (converting) args.push((name, done) => requestFont(engine, id, signal, name, done));
} else {
const file = args.pop();
const destination = args.pop();
const file = destination?.fileHandle || destination;
saveFile = file;
saveStamp = destination?.saveStamp;
const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null;
const indices = saving ? args.shift() : null;
if (file) output = await file.createWritable();
@@ -234,28 +322,55 @@ async function handleMessage({ id, name, args }) {
});
if (name === "ofdgoExportDocument") {
args.push((completed, total, done) => {
self.postMessage({ id, type: "export", stage: "pages", completed, total });
postMessage({ id, type: "export", stage: "pages", completed, total });
finish(done);
});
} else if (name === "ofdgoExportVersion") {
args.push((phase, completed, total, done) => {
postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done);
}, done => finish(done));
} else if (saving) {
args.push((phase, completed, total, done) => {
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
finish(done);
});
if (compression) args.push(indices, compression, done => finish(done));
if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done));
else if (indices != null) args.push(indices);
}
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
}
}
const payload = await globalThis[name](...args);
if (engine.pdf && name !== "ofdgoOpenPDF") {
channel ||= new MessageChannel();
const context = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPDFContext", null, (phase, completed, total, done) => {
postMessage({id, type: "conversion", phase, completed, total});
channel.port1.onmessage = () => done("", signal.aborted);
channel.port2.postMessage(null);
}, (name, done) => requestFont(engine, id, signal, name, done));
engine.pdf = JSON.parse(context).data === true;
}
const started = name === "ofdgoOpen" || name === "ofdgoRenderPage" ? performance.now() : null;
const payload = await globalThis.ofdgoEngineCall(engine.id, name, done => {
channel ||= new MessageChannel();
channel.port1.onmessage = () => done("", signal.aborted);
channel.port2.postMessage(null);
}, ...args);
signal?.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
if (signal && !importing && !converting && result.ok) {
operations.delete(id);
if (result.ok && name === "ofdgoOpenPDF") engine.pdf = true;
if (result.ok && (name === "ofdgoOpen" || name === "ofdgoLoadPDF" && result.data.done)) engine.pdf = false;
if (started !== null && result.ok) result.elapsed = performance.now() - started;
if (cancellable && !importing && !converting && !resources && result.ok) {
result.data.size = size;
if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" });
if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
if (output) {
if (saveStamp) {
const current = await saveFile.getFile();
if (current.size !== saveStamp.size || current.lastModified !== saveStamp.modified) throw new Error("文件已被修改,请另存");
}
signal.throwIfAborted();
operations.delete(id);
await output.close();
} else {
result.data.blob = new Blob(chunks, { type: result.data.mime });
@@ -264,16 +379,16 @@ async function handleMessage({ id, name, args }) {
await output.abort();
}
output = null;
const transfer = result.data?.bytes ? [result.data.bytes.buffer] : (result.data?.images || []).map((image) => image.bytes.buffer);
self.postMessage({ id, ...result }, transfer);
const transfer = [...new Set([result.data?.bytes, ...(result.data?.images || []).map(image => image.bytes), ...(result.data?.fonts || []).map(font => font.data)].filter(bytes => bytes?.byteLength).map(bytes => bytes.buffer))];
postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer);
} catch (err) {
await output?.abort().catch(() => {});
const result = { id, ok: false, error: err.message };
const result = { id, ok: false, error: err.message, errorName: err.name };
if (signal?.aborted) {
result.error = importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消";
result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : cancellable ? "导出已取消" : "操作已取消";
result.canceled = true;
}
self.postMessage(result);
postMessage(result);
} finally {
password?.fill(0);
key?.fill(0);
@@ -285,23 +400,20 @@ async function handleMessage({ id, name, args }) {
}
function exitWASM(err) {
rejectRuntime(err || new Error("引擎运行中断"));
self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" });
self.close();
}
async function loadWASM() {
async function loadWASM(module) {
const go = new Go();
self.postMessage({ type: "progress", phase: "download", text: "正在下载引擎", percent: 16 });
const response = await fetch("./ofdgo.wasm");
self.postMessage({ type: "progress", phase: "compile", text: "正在编译引擎", percent: 35 });
const { instance } = await WebAssembly.instantiateStreaming(response, go.importObject);
const instance = await WebAssembly.instantiate(module, go.importObject);
engineMemory = instance.exports.mem;
self.postMessage({ type: "progress", phase: "start", text: "正在启动引擎", percent: 58 });
go.run(instance).then(exitWASM, exitWASM);
if (!go.exited) {
const backends = JSON.parse(globalThis.ofdgoRenderBackends());
if (!backends.ok) throw new Error(backends.error);
self.postMessage({ type: "ready", backends: backends.data });
resolveRuntime();
}
}
loadWASM().catch(exitWASM);
self.postMessage({ type: "module" });
+5 -2
View File
@@ -1,6 +1,6 @@
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
const META_CACHE = `${CACHE_PREFIX}meta`;
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_print.js", "ofdgo_theme.js", "ofdgo_util.js", "ofdgo_view.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
.map((path) => new URL(path, self.registration.scope).href);
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
@@ -92,6 +92,8 @@ async function readResource(event, navigation) {
bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId);
}
if (!bundle || !await caches.has(bundle.name)) {
const client = await self.clients.get(event.clientId);
if (client && resourceURL(client.url) !== self.registration.scope) return fetch(event.request);
return Response.error();
}
if (event.resultingClientId) {
@@ -106,8 +108,9 @@ async function readResource(event, navigation) {
}
async function updateBundle(meta) {
const signal = AbortSignal.timeout(120000);
const resources = await Promise.all(ASSETS.map(async (url) => {
const response = await fetch(new Request(url, { cache: "no-store" }));
const response = await fetch(new Request(url, { cache: "no-store", signal }));
if (!response.ok || response.redirected) {
throw new Error("资源下载失败");
}
+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 (
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
"flag"
"fmt"
"net/http"
@@ -25,6 +28,8 @@ import (
"time"
"github.com/xiaoqidun/ofdgo/assets/webui"
"github.com/xiaoqidun/ofdgo/assets/webui/wasm"
"github.com/xiaoqidun/ofdgo/internal/build"
)
func main() {
@@ -39,12 +44,18 @@ func main() {
// 返回: http.Handler HTTP处理器
func serveWebUI() http.Handler {
files := http.FileServerFS(webuiassets.FS)
checksum := webuiassets.Checksum()
wasm := http.FileServerFS(wasmassets.FS)
checksum := resourceChecksum()
serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo_work.js")
serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-OFDGo-Checksum", checksum)
w.Header().Set("X-OFDGo-Build-Version", build.Version)
if r.URL.Path == "/ofdgo.wasm" {
wasm.ServeHTTP(w, r)
return
}
if r.URL.Path == "/ofdgo_work.js" {
http.ServeContent(w, r, "ofdgo_work.js", time.Time{}, bytes.NewReader(serviceWorker))
return
@@ -52,3 +63,17 @@ func serveWebUI() http.Handler {
files.ServeHTTP(w, r)
})
}
// resourceChecksum 计算WebUI界面与WASM引擎的资源校验值
// 返回: string SHA-256十六进制校验值
func resourceChecksum() string {
hash := sha256.New()
for _, resources := range []embed.FS{webuiassets.FS, wasmassets.FS} {
entries, _ := resources.ReadDir(".")
for _, entry := range entries {
data, _ := resources.ReadFile(entry.Name())
hash.Write(data)
}
}
return hex.EncodeToString(hash.Sum(nil))
}
+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.
+10 -8
View File
@@ -3,16 +3,18 @@ module github.com/xiaoqidun/ofdgo
go 1.26.0
require (
github.com/emmansun/gmsm v0.45.0
github.com/go-fonts/latin-modern v0.3.3
github.com/andybalholm/brotli v1.2.7
github.com/emmansun/gmsm v0.45.2
github.com/go-text/typesetting v0.3.5
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893
github.com/xiaoqidun/jbig2 v1.1.4
github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f
github.com/xiaoqidun/j2kgo v1.0.2
github.com/xiaoqidun/jbig2 v1.1.8
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0
golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0
golang.org/x/sys v0.49.0
golang.org/x/text v0.43.0
)
@@ -23,9 +25,10 @@ require (
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/benoitkugler/textlayout v0.3.2 // indirect
github.com/benoitkugler/textprocessing v0.0.6 // indirect
github.com/ebitengine/purego v0.11.2 // indirect
github.com/go-fonts/latin-modern v0.3.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 // indirect
@@ -33,7 +36,6 @@ require (
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/yuin/goldmark v1.8.5 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/knuth v0.6.0 // indirect
modernc.org/token v1.1.0 // indirect
star-tex.org/x/tex v0.7.1 // indirect
+16 -12
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/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
github.com/emmansun/gmsm v0.45.0 h1:PU/6ObyR3g05YN3jn1L5GZDowzyAi8Xhkq4svzYNyiA=
github.com/emmansun/gmsm v0.45.0/go.mod h1:HgLSvmdePGLo+Fo2PNgdTQU6lqAepSyriSQJRpECY6w=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/emmansun/gmsm v0.45.2 h1:GUqMkdHDrEvxqcRtIfhkyqMzgwtiQsnCVYtioOdYlQc=
github.com/emmansun/gmsm v0.45.2/go.mod h1:RfnfrzyB9XbDi73nF1NHbJeqJj+a6pg/pXr5lrZKMok=
github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
@@ -50,20 +52,22 @@ github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 h1:ZdkidVdpLW13BQ9a+
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388/go.mod h1:C/WY5lmWfMtPFYYBTd3Lzdn4FTLr+RxlIeiBNye+/os=
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c h1:eXJXpQAo8l0AdVnsjJctG38GlBnHnwHfhLOTLYxZYNE=
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c/go.mod h1:K9Ux3cwFtyAFRITZ46g7v+ORXgI3ENtQa5rL9yjNz5Y=
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 h1:phNTWRYLLTlPpUgxkd6FDmWLlrOzpEpQAqq1ryLXYS8=
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6/go.mod h1:ipESUihcEdgRBhIwTRHmBTOMamccMQrp4iCxfa1aFws=
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 h1:z7YAbjYz/bmG0AUx2pqmClRxUI2aCxP0rQMJAMcdmok=
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918/go.mod h1:ipESUihcEdgRBhIwTRHmBTOMamccMQrp4iCxfa1aFws=
github.com/tdewolff/minify/v2 v2.24.17 h1:6AbitfVyq0M7aW6i+XL7+49DeTQZwloOMs9O574arBg=
github.com/tdewolff/minify/v2 v2.24.17/go.mod h1:kVqn9vxXUKtlHexSNrWbYePqioOT5mc4ou/KVSMpfCM=
github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8feI=
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 h1:jmQlBO7R+bcimOMs9jjyDWR1FqiPNGtZqA/V1WiYfPA=
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/jbig2 v1.1.4 h1:LF6J1si4JY+TszTh9+m4KAg9/ba0JjfXoGtQTQC2S8c=
github.com/xiaoqidun/jbig2 v1.1.4/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7 h1:VFrEkWzhLLgoMZ4FNH/uN/Mdfo/TSGKzIPjqCL4m3J4=
github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7/go.mod h1:fXz1emtF1YCpcIv+fglDZstjkHxjS2l3G4DVMgUzVnk=
github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f h1:wek1tEiTjf0i5eT6w6ILJLEPwdXP+TaH3qtRBOO1xeI=
github.com/xiaoqidun/guigo v0.0.0-20261030153939-c39abf593a0f/go.mod h1:bXz+dPMVntPIVwePTTuMXt8Q0z1YoUGuROQSsXyUEfY=
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU=
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+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)
}
+119 -80
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
@@ -30,16 +30,16 @@ import (
// cffDict 使用float64存储所有数值,以统一处理整数和实数
type cffDict map[int][]float64
// wrapCFFToOTF 将CFF裸数据包装为OpenType字体格式
// WrapCFFToOTF 将CFF裸数据包装为OpenType字体格式
// 入参: cffData CFF字体数据
// 返回: []byte OTF字体数据, map[rune]uint16 字符映射, error 错误信息
func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
func WrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
numGlyphs, err := parseCFFAndCountGlyphs(cffData)
if err != nil {
return nil, nil, err
}
mapping := getCmapFromCFF(cffData, int(numGlyphs))
sanitized, err := sanitizeCFF(cffData)
sanitized, err := SanitizeCFF(cffData)
if err != nil {
return nil, nil, err
}
@@ -70,10 +70,10 @@ func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
tables["maxp"] = buildCFFMaxpTable(uint16(numGlyphs))
tables["OS/2"] = buildOS2Table()
tables["name"] = buildNameTable()
tables["post"] = buildPostTable()
tables["post"] = BuildPostTable()
tables["hmtx"] = buildHmtxTable(widths)
tables["cmap"] = buildCmapTable(uint16(numGlyphs), mapping)
data, err := serializeOTF(tables)
tables["cmap"] = BuildCmapTable(uint16(numGlyphs), mapping)
data, err := SerializeOTF(tables)
return data, mapping, err
}
@@ -81,13 +81,13 @@ func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) {
// 入参: data CFF字体数据
// 返回: []byte 标准化后的CFF数据, error 错误信息
func normalizeCFFCharstrings(data []byte) ([]byte, error) {
return normalizeCFFCharstringsAt(data, 0)
return NormalizeCFFCharstringsAt(data, 0)
}
// normalizeCFFCharstringsAt 统一CFF设计坐标,现有OpenType字体使用head表的设计单位
// NormalizeCFFCharstringsAt 统一CFF设计坐标,现有OpenType字体使用head表的设计单位
// 入参: data CFF字体数据, unitsPerEm 设计单位,0时由字体矩阵确定
// 返回: []byte 标准化后的CFF数据, error 错误信息
func normalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
func NormalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
if len(data) < 4 || int(data[2]) >= len(data) {
return nil, fmt.Errorf("invalid CFF header")
}
@@ -131,6 +131,14 @@ func normalizeCFFCharstringsAt(data []byte, unitsPerEm uint16) ([]byte, error) {
if err != nil {
return nil, err
}
if _, cid := dict[1230]; !cid {
for op := 1231; op <= 1238; op++ {
if _, exists := dict[op]; exists {
delete(dict, op)
changed = true
}
}
}
if transform != nil {
scale := 1 / float64(unitsPerEm)
dict[1207] = []float64{scale, 0, 0, scale, 0, 0}
@@ -214,10 +222,10 @@ func replaceCFFCharstrings(data []byte, dict cffDict, topStart, topSize, charSta
return result
}
// sanitizeCFF 尝试清洗CFF数据,转换CID字体并合并FontMatrix
// SanitizeCFF 尝试清洗CFF数据,转换CID字体并合并FontMatrix
// 入参: data 原始CFF数据
// 返回: []byte 清洗后的CFF数据, error 错误信息
func sanitizeCFF(data []byte) ([]byte, error) {
func SanitizeCFF(data []byte) ([]byte, error) {
if !isBareCFFData(data) {
return nil, fmt.Errorf("invalid CFF header")
}
@@ -260,6 +268,21 @@ func sanitizeCFF(data []byte) ([]byte, error) {
if kind == 1 {
return normalizeCFFType1(data, topDict)
}
private, err := readType2Private(data, topDict[18], &type2Font{})
if err != nil {
return nil, err
}
count := len(private)
if err := normalizeCFFPrivate(private); err != nil {
return nil, err
}
if len(private) != count {
font, err := readType2Font(data)
if err != nil {
return nil, err
}
return rebuildNamedCFF(data, font, font.chars, nil)
}
return data, nil
}
fdArrOff, err := cffDictOffset(data, topDict, 1236)
@@ -316,10 +339,9 @@ func sanitizeCFF(data []byte) ([]byte, error) {
localSubrData = data[start:end]
}
charStringsData := data[charStringsOff:charEnd]
delete(topDict, 1230)
delete(topDict, 1236)
delete(topDict, 1237)
delete(topDict, 1234)
for op := 1230; op <= 1238; op++ {
delete(topDict, op)
}
delete(topDict, 15)
delete(topDict, 16)
var finalPrivData []byte
@@ -342,17 +364,21 @@ func sanitizeCFF(data []byte) ([]byte, error) {
return encodeSanitizedCFF(data[:hdrSize], nameIndexData, topDict, stringIndexData, globalSubrIndexData, charStringsData, finalPrivData, localSubrData), nil
}
// normalizeCFFPrivate 移除旧版CFF中不生效的缺省字段,拒绝需要额外解释的取值
// normalizeCFFPrivate 移除废弃的加粗阈值及未加密标记,拒绝加密字形程序
// 入参: dict 私有字典
// 返回: error 不支持的旧版行为
func normalizeCFFPrivate(dict cffDict) error {
for op, value := range map[int]float64{1215: 0, 1216: -1} {
if values, ok := dict[op]; ok {
if len(values) != 1 || values[0] != value {
return fmt.Errorf("unsupported legacy CFF private operator %d", op)
if values, ok := dict[1215]; ok {
if len(values) != 1 || !finite(values[0]) {
return fmt.Errorf("invalid legacy CFF bold threshold")
}
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
}
@@ -397,9 +423,9 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict
return nil, fmt.Errorf("invalid CFF FDArray count")
}
fonts := make([]type2Font, fdCount)
var type1Fonts []*type1Program
var type1Fonts []*Type1Program
if kind == 1 {
type1Fonts = make([]*type1Program, fdCount)
type1Fonts = make([]*Type1Program, fdCount)
}
privateDicts := make([]cffDict, fdCount)
matrices := make([][6]float64, fdCount)
@@ -475,10 +501,9 @@ func sanitizeMultiFDCFF(data []byte, hdrSize int, nameIndexData []byte, topDict
}
inlined[gid] = output.Bytes()
}
delete(topDict, 1230)
delete(topDict, 1236)
delete(topDict, 1237)
delete(topDict, 1234)
for op := 1230; op <= 1238; op++ {
delete(topDict, op)
}
delete(topDict, 15)
delete(topDict, 16)
delete(topDict, 1206)
@@ -725,7 +750,7 @@ func parseCFFReal(data []byte) (string, int) {
return sb.String(), i
}
// encodeCFFDict 编码CFF字典 (仅使用float64操作数)
// encodeCFFDict 编码CFF字典,将字体类型标识置于首项
// 入参: dict CFF字典映射
// 返回: []byte 编码后的字典数据
func encodeCFFDict(dict cffDict) []byte {
@@ -735,6 +760,13 @@ func encodeCFFDict(dict cffDict) []byte {
keys = append(keys, k)
}
sort.Ints(keys)
for index, op := range keys {
if op == 1220 || op == 1230 {
copy(keys[1:index+1], keys[:index])
keys[0] = op
break
}
}
for _, op := range keys {
vals := dict[op]
if len(vals) == 0 && (op >= 6 && op <= 9 || op == 1212 || op == 1213) {
@@ -819,10 +851,8 @@ func encodeNumberCFF(buf *bytes.Buffer, val float64) {
// 返回: []byte 编码后的索引数据
func encodeCFFIndex(items []([]byte)) []byte {
count := len(items)
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint16(count))
if count == 0 {
return buf.Bytes()
return []byte{0, 0}
}
totalSize := 0
for _, item := range items {
@@ -838,25 +868,21 @@ func encodeCFFIndex(items []([]byte)) []byte {
if totalSize+1 > 16777215 {
offSize = 4
}
buf.WriteByte(byte(offSize))
base := 3 + (count+1)*offSize
data := make([]byte, base+totalSize)
binary.BigEndian.PutUint16(data, uint16(count))
data[2] = byte(offSize)
offset := 1
putOffset(buf, offset, offSize)
for _, item := range items {
offset += len(item)
putOffset(buf, offset, offSize)
for i := 0; i <= count; i++ {
for j := 0; j < offSize; j++ {
data[3+i*offSize+offSize-1-j] = byte(offset >> (8 * j))
}
for _, item := range items {
buf.Write(item)
if i < count {
copy(data[base+offset-1:], items[i])
offset += len(items[i])
}
return buf.Bytes()
}
// putOffset 写入指定大小的偏移量
// 入参: buf 缓冲区, val 偏移值, size 字节大小
func putOffset(buf *bytes.Buffer, val int, size int) {
tmp := make([]byte, 4)
binary.BigEndian.PutUint32(tmp, uint32(val))
buf.Write(tmp[4-size:])
return data
}
// getCFFIndexCount 读取CFF索引的计数和大小
@@ -1035,6 +1061,9 @@ func getCFFCharsetInfo(data []byte, numGlyphs int) ([]int, string, string, int,
if topDictData == nil {
return nil, "", "", 0, false
}
if _, _, _, err := scanCFFIndex(data, stringIndexOff); err != nil {
return nil, "", "", 0, false
}
td := parseCFFDict(topDictData)
registry, ordering := getCFFROS(data, stringIndexOff, td)
actual, err := parseCFFAndCountGlyphs(data)
@@ -1066,13 +1095,24 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
if !ok {
return nil
}
mapping := make(map[rune]uint16)
return cffCharsetMapping(data, sids, registry, ordering, stringIndexOff)
}
// cffCharsetMapping 根据已解析的CFF字符集恢复Unicode映射
// 入参: data CFF数据, sids 字符标识, registry 注册机构, ordering 字符集, stringIndexOff 字符串索引偏移
// 返回: map[rune]uint16 恢复的映射表
func cffCharsetMapping(data []byte, sids []int, registry, ordering string, stringIndexOff int) map[rune]uint16 {
capacity := max(0, len(sids)-1)
if registry == "" || registry == "Adobe" && ordering == "GB1" {
capacity *= 2
}
mapping := make(map[rune]uint16, capacity)
if registry == "Adobe" && ordering == "GB1" {
for gid, cid := range sids {
if gid == 0 {
continue
}
mapping[packedGlyphRune(uint16(gid))] = uint16(gid)
mapping[PackedGlyphRune(uint16(gid))] = uint16(gid)
if r, ok := adobeGB1CIDToUnicode(cid); ok {
mapping[r] = uint16(gid)
}
@@ -1084,7 +1124,7 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
if gid == 0 {
continue
}
mapping[packedGlyphRune(uint16(gid))] = uint16(gid)
mapping[PackedGlyphRune(uint16(gid))] = uint16(gid)
}
return mapping
}
@@ -1099,25 +1139,25 @@ func getCmapFromCFF(data []byte, numGlyphs int) map[rune]uint16 {
}
} else {
idx := sid - 391
name = readStringIndexItem(data, stringIndexOff, idx)
name = string(readStringIndexItem(data, stringIndexOff, idx))
}
r := rune(0)
if name != "" {
r = getUnicodeFromName(name)
}
if r == 0 {
r = packedGlyphRune(uint16(gid))
r = PackedGlyphRune(uint16(gid))
}
mapping[packedGlyphRune(uint16(gid))] = uint16(gid)
mapping[PackedGlyphRune(uint16(gid))] = uint16(gid)
mapping[r] = uint16(gid)
}
return mapping
}
// getCFFCIDRuneMap 获取CID到包装字体字符的映射
// GetCFFCIDRuneMap 获取CID到包装字体字符的映射
// 入参: data CFF或OpenType字体数据
// 返回: map[uint16]rune CID映射
func getCFFCIDRuneMap(data []byte) map[uint16]rune {
func GetCFFCIDRuneMap(data []byte) map[uint16]rune {
cffData := getCFFData(data)
if cffData == nil {
return nil
@@ -1126,17 +1166,17 @@ func getCFFCIDRuneMap(data []byte) map[uint16]rune {
if err != nil {
return nil
}
sids, registry, _, _, ok := getCFFCharsetInfo(cffData, numGlyphs)
sids, registry, ordering, stringIndexOff, ok := getCFFCharsetInfo(cffData, numGlyphs)
if !ok || registry == "" {
return nil
}
mapping := getCmapFromCFF(cffData, numGlyphs)
mapping := cffCharsetMapping(cffData, sids, registry, ordering, stringIndexOff)
if len(mapping) == 0 {
return nil
}
gidRunes := make(map[uint16]rune)
for run, gid := range mapping {
if run != packedGlyphRune(gid) {
if run != PackedGlyphRune(gid) {
gidRunes[gid] = run
}
}
@@ -1186,7 +1226,7 @@ func getCFFData(data []byte) []byte {
}
offset := int(binary.BigEndian.Uint32(data[pos+8 : pos+12]))
length := int(binary.BigEndian.Uint32(data[pos+12 : pos+16]))
if offset < 0 || length < 0 || offset+length > len(data) {
if offset < 0 || length < 0 || offset > len(data) || length > len(data)-offset {
return nil
}
return data[offset : offset+length]
@@ -1268,31 +1308,29 @@ func parseCFFCharset(data []byte, offset, count int) ([]int, error) {
// readStringIndexItem 读取CFF字符串索引项
// 入参: data CFF数据, offset 索引偏移, idx 索引号
// 返回: string 读取的字符串
func readStringIndexItem(data []byte, offset int, idx int) string {
if offset >= len(data) {
return ""
// 返回: []byte 共享原始数据的只读字符串,索引无效时为nil
func readStringIndexItem(data []byte, offset int, idx int) []byte {
if offset < 0 || offset > len(data)-2 || idx < 0 {
return nil
}
count := int(binary.BigEndian.Uint16(data[offset:]))
if idx >= count || offset > len(data)-3 {
return nil
}
offSize := int(data[offset+2])
if idx >= count {
return ""
if offSize < 1 || offSize > 4 || count+1 > (len(data)-offset-3)/offSize {
return nil
}
offArrayStart := offset + 3
p1 := offArrayStart + idx*offSize
p2 := p1 + offSize
if p2+offSize > len(data) {
return ""
}
loc1 := readCFFOffset(data, p1, offSize)
loc2 := readCFFOffset(data, p2, offSize)
dataStart := offArrayStart + (count+1)*offSize
start := dataStart + loc1 - 1
length := loc2 - loc1
if start < 0 || start+length > len(data) {
return ""
if loc1 < 1 || loc2 < loc1 || loc2-1 > len(data)-dataStart {
return nil
}
return string(data[start : start+length])
return data[dataStart+loc1-1 : dataStart+loc2-1]
}
// getCFFROS 读取CID字体ROS信息
@@ -1316,7 +1354,7 @@ func getCFFSIDString(data []byte, stringIndexOff int, sid int) string {
return cffStandardStrings[sid]
}
if sid > 390 {
return readStringIndexItem(data, stringIndexOff, sid-391)
return string(readStringIndexItem(data, stringIndexOff, sid-391))
}
return ""
}
@@ -1348,16 +1386,17 @@ func adobeGB1CIDToUnicode(cid int) (rune, bool) {
if row < 16 || row > 87 || cell < 1 || cell > 94 {
return 0, false
}
gbk := []byte{byte(row + 0xA0), byte(cell + 0xA0)}
decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(gbk)
if err != nil || len(decoded) == 0 {
gbk := [2]byte{byte(row + 0xA0), byte(cell + 0xA0)}
var decoded [2 * utf8.UTFMax]byte
nDst, nSrc, err := simplifiedchinese.GBK.NewDecoder().Transform(decoded[:], gbk[:], true)
if err != nil || nDst == 0 || nSrc != len(gbk) {
return 0, false
}
rs := []rune(string(decoded))
if len(rs) != 1 {
r, size := utf8.DecodeRune(decoded[:nDst])
if size != nDst {
return 0, false
}
return rs[0], true
return r, true
}
// getUnicodeFromName 根据字形名称获取对应的Unicode字符
+11 -117
View File
@@ -12,24 +12,23 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
"encoding/binary"
"fmt"
"maps"
"math"
"github.com/go-text/typesetting/font/cff"
"github.com/go-text/typesetting/font/opentype"
"github.com/tdewolff/font"
"github.com/go-text/typesetting/font/opentype/tables"
)
// defaultCFF2Font 将CFF2默认实例转换为静态OpenType字体,保留字形编号和三次轮廓
// DefaultCFF2Font 将CFF2默认实例转换为静态OpenType字体,保留字形编号和三次轮廓
// 入参: data 独立字体数据
// 返回: []byte 字体数据,非CFF2原样返回, error 解析或编码错误
func defaultCFF2Font(data []byte) (result []byte, err error) {
func DefaultCFF2Font(data []byte) (result []byte, err error) {
if len(data) < 12 || string(data[:4]) != "OTTO" {
return data, nil
}
@@ -43,7 +42,7 @@ func defaultCFF2Font(data []byte) (result []byte, err error) {
if !found {
return data, nil
}
table, err := fontFileTables(data, 0)
table, err := FileTables(data, 0)
if err != nil || table["CFF2"] == nil {
return data, err
}
@@ -90,7 +89,7 @@ func defaultCFF2Font(data []byte) (result []byte, err error) {
}
chars := make([][]byte, count)
for gid := range chars {
segments, _, err := program.LoadGlyph(uint16(gid), nil)
segments, _, err := program.LoadGlyph(tables.GlyphID(gid), nil)
if err != nil {
return nil, fmt.Errorf("CFF2 glyph %d: %w", gid, err)
}
@@ -107,7 +106,7 @@ func defaultCFF2Font(data []byte) (result []byte, err error) {
for _, tag := range []string{"CFF2", "fvar", "avar", "STAT", "HVAR", "VVAR", "MVAR", "DSIG"} {
delete(table, tag)
}
return serializeOTF(table)
return SerializeOTF(table)
}
// cffOutlineProgram 将默认设计轮廓编码为无子程序的Type2字形
@@ -166,16 +165,17 @@ func staticCIDFont(table map[string][]byte, chars [][]byte) ([]byte, error) {
if units < 16 || units > 16384 {
return nil, fmt.Errorf("invalid CFF design units")
}
name := fontFaceInfo(table["name"], 0).PostScriptName
name := FaceInfo(table["name"], 0).PostScriptName
if name == "" {
name = "OpenTypeInstance"
}
names := encodeCFFIndex([][]byte{[]byte(name)})
strings := encodeCFFIndex([][]byte{[]byte("Adobe"), []byte("Identity")})
charset := []byte{0}
cids := make([]int, len(chars))
for gid := 1; gid < len(chars); gid++ {
charset = binary.BigEndian.AppendUint16(charset, uint16(gid))
cids[gid] = gid
}
charset := encodeCFFSubsetCharset(cids)
selects := []byte{3, 0, 1, 0, 0, 0}
selects = binary.BigEndian.AppendUint16(selects, uint16(len(chars)))
fonts := encodeCFFIndex([][]byte{encodeCFFDict(cffDict{18: {0, 0}})})
@@ -203,109 +203,3 @@ func staticCIDFont(table map[string][]byte, chars [][]byte) ([]byte, error) {
}
return nil, fmt.Errorf("CFF offsets did not converge")
}
// staticCFFPrograms 读取无子程序和私有字典的CID字形,其他CFF保留原有保存策略
// 入参: sfnt 已解析字体
// 返回: [][]byte 独立字形程序,不符合条件时为空
func staticCFFPrograms(sfnt *font.SFNT) [][]byte {
data := sfnt.Tables["CFF "]
if len(data) < 4 {
return nil
}
_, end, err := readType2Index(data, int(data[2]))
if err != nil {
return nil
}
tops, end, err := readType2Index(data, end)
if err != nil || len(tops) != 1 {
return nil
}
_, end, err = readType2Index(data, end)
if err != nil {
return nil
}
globals, _, err := readType2Index(data, end)
if err != nil || len(globals) != 0 {
return nil
}
dict, err := readCFFDict(tops[0])
if err != nil || len(dict[1230]) != 3 {
return nil
}
matrix, err := readCFFMatrix(dict, [6]float64{.001, 0, 0, .001, 0, 0})
if err != nil || matrix[0] != matrix[3] || matrix[1] != 0 || matrix[2] != 0 || matrix[4] != 0 || matrix[5] != 0 || math.Abs(matrix[0]*float64(sfnt.UnitsPerEm())-1) > 1e-6 {
return nil
}
offset, err := cffDictOffset(data, dict, 1236)
if err != nil {
return nil
}
fds, _, err := readType2Index(data, offset)
if err != nil || len(fds) != 1 {
return nil
}
fd, err := readCFFDict(fds[0])
if err != nil || len(fd) != 1 || len(fd[18]) != 2 || fd[18][0] != 0 {
return nil
}
offset, err = cffDictOffset(data, dict, 17)
if err != nil {
return nil
}
chars, _, err := readType2Index(data, offset)
if err != nil || len(chars) != int(sfnt.NumGlyphs()) {
return nil
}
return chars
}
// subsetStaticCFFFont 裁剪静态CID字形并保留原编号、字符映射和度量
// 入参: sfnt 已解析字体, glyphs 使用的字形
// 返回: []byte 字体子集, error 裁剪错误
func subsetStaticCFFFont(sfnt *font.SFNT, glyphs []uint16) ([]byte, error) {
chars := staticCFFPrograms(sfnt)
if chars == nil {
return nil, fmt.Errorf("CFF font cannot be subset safely")
}
keep := make(map[uint16]bool, len(glyphs)+1)
keep[0] = true
for _, gid := range glyphs {
if int(gid) >= len(chars) {
return nil, fmt.Errorf("CFF glyph index out of range")
}
keep[gid] = true
}
empty := make(map[uint16][]byte)
for gid := range chars {
if !keep[uint16(gid)] {
width := sfnt.GlyphAdvance(uint16(gid))
program := empty[width]
if program == nil {
var err error
program, err = cffOutlineProgram(nil, width)
if err != nil {
return nil, err
}
empty[width] = program
}
chars[gid] = program
}
}
table := maps.Clone(sfnt.Tables)
var err error
table["CFF "], err = staticCIDFont(table, chars)
if err != nil {
return nil, err
}
mapping := make(map[rune]uint16)
for gid := range keep {
for _, char := range sfnt.Cmap.ToUnicode(gid) {
mapping[char] = gid
}
}
table["cmap"] = buildCmapTable(sfnt.NumGlyphs(), mapping)
for _, tag := range []string{"DSIG", "GSUB", "GPOS", "GDEF"} {
delete(table, tag)
}
return serializeOTF(table)
}
+239
View File
@@ -0,0 +1,239 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"encoding/binary"
"fmt"
"math"
)
// cidSubsetFont 保存CID字体裁剪所需的字典、映射和执行资源
type cidSubsetFont struct {
prefix []byte
strings [][]byte
top cffDict
dicts []cffDict
private []cffDict
fonts []*type2Font
selects []int
charset []byte
}
// readCIDSubsetFont 读取CID字体的原始字典及逐字形归属,不合并字体字典
// 入参: data CFF数据
// 返回: *cidSubsetFont 裁剪资源, error 结构错误或不支持的字典
func readCIDSubsetFont(data []byte) (*cidSubsetFont, error) {
if !isBareCFFData(data) {
return nil, fmt.Errorf("invalid CFF header")
}
names, end, err := readType2Index(data, int(data[2]))
if err != nil || len(names) != 1 {
return nil, fmt.Errorf("invalid CFF name index")
}
tops, end, err := readType2Index(data, end)
if err != nil || len(tops) != 1 {
return nil, fmt.Errorf("invalid CFF top index")
}
strings, end, err := readType2Index(data, end)
if err != nil {
return nil, err
}
globals, _, err := readType2Index(data, end)
if err != nil {
return nil, err
}
top, err := readCFFDict(tops[0])
if err != nil {
return nil, err
}
ros := top[1230]
if len(ros) != 3 {
return nil, fmt.Errorf("invalid CFF ROS")
}
for index, value := range ros {
limit := float64(math.MaxInt32)
if index < 2 {
limit = float64(min(64999, 390+len(strings)))
}
if !finite(value) || value < 0 || value > limit || value != math.Trunc(value) {
return nil, fmt.Errorf("invalid CFF ROS operand")
}
}
for op := range top {
switch op {
case 0, 1, 2, 3, 4, 5, 13, 14, 15, 17, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1230, 1231, 1232, 1233, 1234, 1236, 1237:
default:
return nil, fmt.Errorf("unsupported CID CFF top operator %d", op)
}
}
if value, ok := top[1206]; ok && (len(value) != 1 || value[0] != 2) {
return nil, fmt.Errorf("unsupported CFF charstring type")
}
if _, err := readCFFMatrix(top, [6]float64{.001, 0, 0, .001, 0, 0}); err != nil {
return nil, err
}
charOffset, err := cffDictOffset(data, top, 17)
if err != nil {
return nil, err
}
chars, _, err := readType2Index(data, charOffset)
if err != nil || len(chars) == 0 {
return nil, fmt.Errorf("invalid CFF charstrings")
}
charsetOffset, err := cffDictOffset(data, top, 15)
if err != nil || charsetOffset <= 2 {
return nil, fmt.Errorf("invalid CID CFF charset offset")
}
cids, err := parseCFFCharset(data, charsetOffset, len(chars))
if err != nil {
return nil, err
}
fdOffset, err := cffDictOffset(data, top, 1236)
if err != nil {
return nil, err
}
fds, _, err := readType2Index(data, fdOffset)
if err != nil || len(fds) == 0 || len(fds) > 256 {
return nil, fmt.Errorf("invalid CFF font dictionary count")
}
selectOffset, err := cffDictOffset(data, top, 1237)
if err != nil {
return nil, err
}
selects, err := readType2FDSelect(data, selectOffset, len(chars), len(fds))
if err != nil {
return nil, err
}
if data[selectOffset] == 3 && binary.BigEndian.Uint16(data[selectOffset+3:]) != 0 {
return nil, fmt.Errorf("CFF FDSelect must include glyph zero")
}
font := &cidSubsetFont{
prefix: append(bytes.Clone(data[:int(data[2])]), encodeCFFIndex(names)...),
strings: strings, top: top, selects: selects, charset: encodeCFFSubsetCharset(cids),
dicts: make([]cffDict, len(fds)), private: make([]cffDict, len(fds)), fonts: make([]*type2Font, len(fds)),
}
for index, fd := range fds {
dict, err := readCFFDict(fd)
if err != nil {
return nil, err
}
for op := range dict {
switch op {
case 18, 1207, 1238:
default:
return nil, fmt.Errorf("unsupported CFF font dictionary operator %d", op)
}
}
if _, err := readCFFMatrix(dict, [6]float64{1, 0, 0, 1, 0, 0}); err != nil {
return nil, err
}
if len(dict[18]) != 2 {
return nil, fmt.Errorf("missing CFF private dictionary")
}
resource := &type2Font{data: data, chars: chars, globals: globals, seed: 1, cid: true}
private, err := readType2Private(data, dict[18], resource)
if err != nil {
return nil, err
}
if _, err := encodeCFFSubsetPrivate(private); err != nil {
return nil, err
}
font.dicts[index], font.private[index], font.fonts[index] = dict, private, resource
}
return font, nil
}
// write 重建CID字体偏移,保留CID映射、字体矩阵及各字典的私有参数
// 入参: chars 裁剪后的字形程序
// 返回: []byte CFF数据, error 偏移或字典错误
func (f *cidSubsetFont) write(chars [][]byte) ([]byte, error) {
suffix := append(encodeCFFIndex(f.strings), encodeCFFIndex(f.fonts[0].globals)...)
selects := encodeCFFSubsetFDSelect(f.selects)
programs := encodeCFFIndex(chars)
private := make([][]byte, len(f.fonts))
locals := make([][]byte, len(f.fonts))
fds := make([][]byte, len(f.fonts))
for index, font := range f.fonts {
var err error
private[index], err = encodeCFFSubsetPrivate(f.private[index])
if err != nil {
return nil, err
}
if _, ok := f.private[index][19]; ok {
locals[index] = encodeCFFIndex(font.locals)
}
fds[index] = encodeCFFDict(f.dicts[index])
}
for range 16 {
top := encodeCFFIndex([][]byte{encodeCFFDict(f.top)})
array := encodeCFFIndex(fds)
offset := len(f.prefix) + len(top) + len(suffix)
f.top[15] = []float64{float64(offset)}
offset += len(f.charset)
f.top[1237] = []float64{float64(offset)}
offset += len(selects)
f.top[17] = []float64{float64(offset)}
offset += len(programs)
f.top[1236] = []float64{float64(offset)}
offset += len(array)
for index := range f.fonts {
f.dicts[index][18] = []float64{float64(len(private[index])), float64(offset)}
fds[index] = encodeCFFDict(f.dicts[index])
offset += len(private[index]) + len(locals[index])
}
updatedTop := encodeCFFIndex([][]byte{encodeCFFDict(f.top)})
updatedArray := encodeCFFIndex(fds)
if len(updatedTop) != len(top) || len(updatedArray) != len(array) {
continue
}
result := make([]byte, 0, offset)
for _, part := range [][]byte{f.prefix, updatedTop, suffix, f.charset, selects, programs, updatedArray} {
result = append(result, part...)
}
for index := range f.fonts {
result = append(result, private[index]...)
result = append(result, locals[index]...)
}
return result, nil
}
return nil, fmt.Errorf("CID CFF offsets did not converge")
}
// encodeCFFSubsetFDSelect 选择较短的逐字形或分段格式保存字体字典归属
// 入参: selects 字形所属字典
// 返回: []byte FDSelect数据
func encodeCFFSubsetFDSelect(selects []int) []byte {
ranges := []byte{3, 0, 0}
count := 0
for gid, fd := range selects {
if gid == 0 || fd != selects[gid-1] {
ranges = binary.BigEndian.AppendUint16(ranges, uint16(gid))
ranges = append(ranges, byte(fd))
count++
}
}
binary.BigEndian.PutUint16(ranges[1:], uint16(count))
ranges = binary.BigEndian.AppendUint16(ranges, uint16(len(selects)))
if len(ranges) < len(selects)+1 {
return ranges
}
result := make([]byte, len(selects)+1)
for gid, fd := range selects {
result[gid+1] = byte(fd)
}
return result
}
+339
View File
@@ -0,0 +1,339 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"encoding/binary"
"fmt"
"slices"
)
// compactCFFGlyphs 重排已裁剪字体的字形,同步字符集、编码和字体字典归属
// 入参: data 已裁剪的CFF数据, keep 保留的原字形编号
// 返回: []byte 紧凑CFF数据, []uint16 新编号对应的原编号, error 结构或编码错误
func compactCFFGlyphs(data []byte, keep map[uint16]bool) ([]byte, []uint16, error) {
ids := []uint16{0}
for gid, used := range keep {
if used && gid != 0 {
ids = append(ids, gid)
}
}
slices.Sort(ids)
font, err := readType2Font(data)
if err == nil {
sids, _, _, _, ok := getCFFCharsetInfo(data, len(font.chars))
if !ok {
return nil, nil, fmt.Errorf("invalid CFF charset")
}
_, end, _ := readType2Index(data, int(data[2]))
tops, _, _ := readType2Index(data, end)
dict, _ := readCFFDict(tops[0])
encoding, err := cffSubsetEncoding(data, dict)
if err != nil {
return nil, nil, err
}
if encoding != nil {
codes, err := readCFFSubsetEncoding(encoding, sids)
if err != nil {
return nil, nil, err
}
ordered := []uint16{0}
seen := map[uint16]bool{0: true}
for _, gid := range codes {
if keep[gid] && !seen[gid] {
ordered = append(ordered, gid)
seen[gid] = true
}
}
for _, gid := range ids {
if !seen[gid] {
ordered = append(ordered, gid)
}
}
ids = ordered
}
chars := make([][]byte, len(ids))
for index, gid := range ids {
if int(gid) >= len(font.chars) {
return nil, nil, fmt.Errorf("CFF glyph index out of range")
}
chars[index] = font.chars[gid]
}
result, err := rebuildNamedCFF(data, font, chars, ids)
return result, ids, err
}
cid, err := readCIDSubsetFont(data)
if err != nil {
return nil, nil, err
}
cids, _, _, _, ok := getCFFCharsetInfo(data, len(cid.selects))
if !ok {
return nil, nil, fmt.Errorf("invalid CID CFF charset")
}
chars := make([][]byte, len(ids))
selected := make([]int, len(ids))
selects := make([]int, len(ids))
dicts, private, fonts := []cffDict{}, []cffDict{}, []*type2Font{}
remap := make(map[int]int)
for index, gid := range ids {
if int(gid) >= len(cid.selects) {
return nil, nil, fmt.Errorf("CFF glyph index out of range")
}
fd := cid.selects[gid]
mapped, exists := remap[fd]
if !exists {
mapped = len(fonts)
remap[fd] = mapped
dicts = append(dicts, cid.dicts[fd])
private = append(private, cid.private[fd])
fonts = append(fonts, cid.fonts[fd])
}
chars[index], selected[index], selects[index] = cid.fonts[fd].chars[gid], cids[gid], mapped
}
cid.dicts, cid.private, cid.fonts = dicts, private, fonts
cid.charset, cid.selects = encodeCFFSubsetCharset(selected), selects
result, err := cid.write(chars)
return result, ids, err
}
// compactCFFTables 重建按字形编号索引的度量和字符映射,不保留旧编号的布局表
// 入参: font 原字体, cff 紧凑CFF数据, ids 新编号对应的原编号
// 返回: []byte 独立OpenType字体, error 封装错误
func compactCFFTables(font *SFNT, cff []byte, ids []uint16) ([]byte, error) {
tables := make(map[string][]byte)
for _, tag := range []string{"head", "hhea", "maxp", "OS/2", "post", "name", "vhea", "gasp"} {
if data := font.Tables[tag]; data != nil {
tables[tag] = bytes.Clone(data)
}
}
var hmtx, vmtx []byte
mapping := make(map[rune]uint16)
for index, gid := range ids {
hmtx = binary.BigEndian.AppendUint16(hmtx, font.GlyphAdvance(gid))
hmtx = binary.BigEndian.AppendUint16(hmtx, uint16(font.sideBearing(gid, false)))
if font.vertical != 0 {
vmtx = binary.BigEndian.AppendUint16(vmtx, font.GlyphVerticalAdvance(gid))
vmtx = binary.BigEndian.AppendUint16(vmtx, uint16(font.sideBearing(gid, true)))
}
for _, char := range font.glyphCharacters(gid) {
mapping[char] = uint16(index)
}
}
tables["CFF "], tables["hmtx"] = cff, hmtx
tables["cmap"] = BuildCmapTable(uint16(len(ids)), mapping)
binary.BigEndian.PutUint16(tables["hhea"][34:], uint16(len(ids)))
binary.BigEndian.PutUint16(tables["maxp"][4:], uint16(len(ids)))
if len(tables["post"]) >= 32 {
tables["post"] = tables["post"][:32]
binary.BigEndian.PutUint32(tables["post"], 0x30000)
}
if font.vertical != 0 {
tables["vmtx"] = vmtx
binary.BigEndian.PutUint16(tables["vhea"][34:], uint16(len(ids)))
}
if data := font.Tables["VORG"]; data != nil {
var err error
tables["VORG"], err = compactCFFVerticalOrigins(data, font.NumGlyphs(), ids)
if err != nil {
return nil, err
}
}
return SerializeOTF(tables)
}
// compactCFFVerticalOrigins 重排CFF字体的逐字形纵向原点,保留默认原点
// 入参: data VORG表, count 原字形数, ids 新编号对应的原编号
// 返回: []byte 重建的VORG表, error 表结构或记录顺序错误
func compactCFFVerticalOrigins(data []byte, count uint16, ids []uint16) ([]byte, error) {
if len(data) < 8 || binary.BigEndian.Uint32(data) != 0x10000 {
return nil, fmt.Errorf("invalid CFF vertical origin header")
}
length := int(binary.BigEndian.Uint16(data[6:]))
if length > (len(data)-8)/4 {
return nil, fmt.Errorf("truncated CFF vertical origins")
}
origins := make(map[uint16]uint16, length)
previous := -1
for index := range length {
pos := 8 + index*4
gid := binary.BigEndian.Uint16(data[pos:])
if gid >= count || int(gid) <= previous {
return nil, fmt.Errorf("invalid CFF vertical origin glyph")
}
origins[gid] = binary.BigEndian.Uint16(data[pos+2:])
previous = int(gid)
}
result := bytes.Clone(data[:8])
for index, gid := range ids {
if origin, ok := origins[gid]; ok {
result = binary.BigEndian.AppendUint16(result, uint16(index))
result = binary.BigEndian.AppendUint16(result, origin)
}
}
binary.BigEndian.PutUint16(result[6:], uint16((len(result)-8)/4))
return result, nil
}
// encodeCFFSubsetCharset 选择最短的标准字符集格式,保持SID或CID及字形顺序
// 入参: ids 按字形编号排列的SID或CID,包含.notdef
// 返回: []byte 字符集数据
func encodeCFFSubsetCharset(ids []int) []byte {
sizes := [3]int{1 + 2*(len(ids)-1), 1, 1}
for first := 1; first < len(ids); {
end := first + 1
for end < len(ids) && ids[end] == ids[end-1]+1 {
end++
}
sizes[1] += 3 * ((end - first + 255) / 256)
sizes[2] += 4
first = end
}
format := 0
for index, size := range sizes {
if size < sizes[format] {
format = index
}
}
result := make([]byte, 1, sizes[format])
result[0] = byte(format)
for first := 1; first < len(ids); {
end := first + 1
if format != 0 {
limit := len(ids)
if format == 1 {
limit = min(limit, first+256)
}
for end < limit && ids[end] == ids[end-1]+1 {
end++
}
}
result = binary.BigEndian.AppendUint16(result, uint16(ids[first]))
if format == 1 {
result = append(result, byte(end-first-1))
} else if format == 2 {
result = binary.BigEndian.AppendUint16(result, uint16(end-first-1))
}
first = end
}
return result
}
// readCFFSubsetEncoding 解析自定义编码及补充映射,校验重复编码和字形归属
// 入参: data 编码数据, sids 原字符集
// 返回: [256]uint16 编码对应的字形编号, error 编码结构错误
func readCFFSubsetEncoding(data []byte, sids []int) ([256]uint16, error) {
var codes [256]uint16
if len(data) < 2 {
return codes, fmt.Errorf("truncated CFF encoding")
}
pos, gid := 2, 1
for index := 0; index < int(data[1]); index++ {
if pos >= len(data) {
return codes, fmt.Errorf("truncated CFF encoding")
}
first, count := int(data[pos]), 1
pos++
switch data[0] & 0x7f {
case 0:
case 1:
if pos >= len(data) {
return codes, fmt.Errorf("truncated CFF encoding range")
}
count += int(data[pos])
pos++
default:
return codes, fmt.Errorf("invalid CFF encoding format")
}
if first+count > len(codes) || gid+count > len(sids) {
return codes, fmt.Errorf("CFF encoding range out of bounds")
}
for code := first; code < first+count; code++ {
if codes[code] != 0 {
return codes, fmt.Errorf("duplicate CFF encoding")
}
codes[code] = uint16(gid)
gid++
}
}
if data[0]&0x80 != 0 {
if pos >= len(data) {
return codes, fmt.Errorf("truncated CFF encoding supplements")
}
count := int(data[pos])
pos++
if count > (len(data)-pos)/3 {
return codes, fmt.Errorf("truncated CFF encoding supplements")
}
for range count {
code, sid := data[pos], int(binary.BigEndian.Uint16(data[pos+1:]))
pos += 3
mapped := slices.Index(sids[:gid], sid)
if codes[code] != 0 || mapped <= 0 {
return codes, fmt.Errorf("invalid CFF supplemental glyph")
}
codes[code] = uint16(mapped)
}
}
if data[0]&0x7f > 1 || pos != len(data) {
return codes, fmt.Errorf("invalid CFF encoding length or format")
}
return codes, nil
}
// encodeCFFSubsetEncoding 按新字形顺序重建主编码及同字形的补充编码
// 入参: codes 原编码映射, sids 原字符集, ids 新编号对应的原编号
// 返回: []byte 编码数据, error 字形顺序不满足编码约束
func encodeCFFSubsetEncoding(codes [256]uint16, sids []int, ids []uint16) ([]byte, error) {
var base, supplements []byte
encodings := make(map[uint16][]byte)
for code, gid := range codes {
if gid != 0 {
encodings[gid] = append(encodings[gid], byte(code))
}
}
unencoded := false
for _, gid := range ids[1:] {
values := encodings[gid]
if len(values) == 0 {
unencoded = true
} else if unencoded {
return nil, fmt.Errorf("CFF encoded glyph follows unencoded glyph")
} else {
base = append(base, values[0])
for _, code := range values[1:] {
supplements = append(supplements, code)
supplements = binary.BigEndian.AppendUint16(supplements, uint16(sids[gid]))
}
}
}
var result []byte
if len(base) == 256 {
for index, code := range base {
if int(code) != index {
return nil, fmt.Errorf("CFF encoding exceeds format zero capacity")
}
}
result = []byte{1, 1, 0, 255}
} else {
result = append([]byte{0, byte(len(base))}, base...)
}
if len(supplements) != 0 {
result[0] |= 0x80
result = append(result, byte(len(supplements)/3))
result = append(result, supplements...)
}
return result, nil
}
@@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import "strings"
import (
"strings"
)
// cffExpertCharset 按CFF规范附录C保存Expert字符集的SID顺序
var cffExpertCharset = [...]uint16{
+325
View File
@@ -0,0 +1,325 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"fmt"
"maps"
)
// subsetCFFFont 裁剪CFF字体,保留组合组件和度量,按需重排字形
// 入参: sfnt 已解析字体, glyphs 使用的字形, preserve 是否保留原字形编号
// 返回: []byte 字体子集, error 裁剪错误
func subsetCFFFont(sfnt *SFNT, glyphs []uint16, preserve bool) ([]byte, error) {
keep := map[uint16]bool{0: true}
for _, gid := range glyphs {
if gid >= sfnt.NumGlyphs() {
return nil, fmt.Errorf("CFF glyph index out of range")
}
keep[gid] = true
}
data, err := subsetCFFCharstrings(sfnt.Tables["CFF "], keep)
if err != nil {
return bytes.Clone(sfnt.Write()), nil
}
if !preserve {
if compact, ids, err := compactCFFGlyphs(data, keep); err == nil {
return compactCFFTables(sfnt, compact, ids)
}
}
tables := maps.Clone(sfnt.Tables)
tables["CFF "] = data
mapping := make(map[rune]uint16)
for gid := range keep {
for _, char := range sfnt.glyphCharacters(gid) {
mapping[char] = gid
}
}
tables["cmap"] = BuildCmapTable(sfnt.NumGlyphs(), mapping)
for _, tag := range []string{"DSIG", "GSUB", "GPOS", "GDEF"} {
delete(tables, tag)
}
return SerializeOTF(tables)
}
// subsetCFFCharstrings 清空未引用的Type2轮廓及子程序,保留原字宽、提示和编号
// 入参: data CFF数据, keep 保留的字形,调用后包含组合组件
// 返回: []byte 重建的CFF数据, error 结构或依赖错误
func subsetCFFCharstrings(data []byte, keep map[uint16]bool) ([]byte, error) {
font, err := readType2Font(data)
if err != nil {
cid, err := readCIDSubsetFont(data)
if err != nil {
return nil, err
}
chars, err := subsetType2Programs(cid.fonts, cid.selects, keep)
if err != nil {
return nil, err
}
return cid.write(chars)
}
chars, err := subsetType2Programs([]*type2Font{font}, nil, keep)
if err != nil {
return nil, err
}
return rebuildNamedCFF(data, font, chars, nil)
}
// subsetType2Programs 按字形所属字典收集子程序依赖并清空未使用的轮廓
// 入参: fonts 执行资源, selects 字形所属字典,单字典时可为空, keep 保留的字形
// 返回: [][]byte 字形程序, error 执行或字宽错误
func subsetType2Programs(fonts []*type2Font, selects []int, keep map[uint16]bool) ([][]byte, error) {
keep[0] = true
programs := fonts[0].chars
locals := make([][]bool, len(fonts))
empty := make([]map[float64][]byte, len(fonts))
for index, font := range fonts {
locals[index] = make([]bool, len(font.locals))
empty[index] = make(map[float64][]byte)
}
globals := make([]bool, len(fonts[0].globals))
var stack [48]float64
for gid := range keep {
if int(gid) >= len(programs) {
return nil, fmt.Errorf("CFF glyph index out of range")
}
fd := 0
if selects != nil {
fd = selects[gid]
}
font := fonts[fd]
steps := 0
state := type2State{font: font, args: stack[:0], width: font.def, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep, localUsage: locals[fd], globalUsage: globals}
if _, err := state.run(font.chars[gid], 0); err != nil {
return nil, err
}
}
chars := make([][]byte, len(programs))
for gid, program := range programs {
if keep[uint16(gid)] {
chars[gid] = program
continue
}
fd := 0
if selects != nil {
fd = selects[gid]
}
font := fonts[fd]
steps := 0
state := type2State{font: font, args: stack[:0], width: font.def, widthOnly: true, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep}
if _, err := state.run(program, 0); err != nil {
return nil, err
}
if encoded, ok := empty[fd][state.width]; ok {
chars[gid] = encoded
continue
}
var encoded bytes.Buffer
if state.width != font.def {
value := state.width - font.nominal
divisor := 1
if value < -32768 || value >= 32768 {
divisor = 2
}
for range divisor {
if err := encodeType2Number(&encoded, value/float64(divisor)); err != nil {
return nil, err
}
}
if parseNumberType2(encoded.Bytes(), 0)*float64(divisor) != value {
return nil, fmt.Errorf("CFF width cannot be represented exactly")
}
if divisor == 2 {
encoded.Write([]byte{12, 10})
}
}
encoded.WriteByte(14)
chars[gid] = encoded.Bytes()
empty[fd][state.width] = chars[gid]
}
globalPrograms := subsetCFFSubroutines(fonts[0].globals, globals)
for index, font := range fonts {
font.locals = subsetCFFSubroutines(font.locals, locals[index])
font.globals = globalPrograms
}
return chars, nil
}
// subsetCFFSubroutines 将未引用的子程序替换为空返回,保持调用编号及偏移基数
// 入参: programs 子程序索引, used 引用标记
// 返回: [][]byte 裁剪后的子程序索引
func subsetCFFSubroutines(programs [][]byte, used []bool) [][]byte {
result := make([][]byte, len(programs))
empty := []byte{11}
for index, program := range programs {
if used[index] {
result[index] = program
} else {
result[index] = empty
}
}
return result
}
// rebuildNamedCFF 重建非CID的CFF偏移,保留字符集、编码和私有提示参数
// 入参: data 原始CFF数据, font 执行资源, chars 替换后的字形程序, ids 字形重排顺序,nil时保留原编号
// 返回: []byte 重建的CFF数据, error 不支持的结构
func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte, ids []uint16) ([]byte, error) {
names, end, err := readType2Index(data, int(data[2]))
if err != nil {
return nil, err
}
tops, end, err := readType2Index(data, end)
if err != nil || len(tops) != 1 {
return nil, fmt.Errorf("invalid CFF top dictionary")
}
strings, _, err := readType2Index(data, end)
if err != nil {
return nil, err
}
dict, err := readCFFDict(tops[0])
if err != nil {
return nil, err
}
for op := range dict {
switch op {
case 0, 1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1222, 1223:
default:
return nil, fmt.Errorf("unsupported CFF top operator %d", op)
}
}
sids, _, _, _, ok := getCFFCharsetInfo(data, len(font.chars))
if !ok {
return nil, fmt.Errorf("invalid CFF charset")
}
encoding, err := cffSubsetEncoding(data, dict)
if err != nil {
return nil, err
}
if ids != nil {
if encoding != nil {
codes, err := readCFFSubsetEncoding(encoding, sids)
if err != nil {
return nil, err
}
encoding, err = encodeCFFSubsetEncoding(codes, sids, ids)
if err != nil {
return nil, err
}
}
selected := make([]int, len(ids))
for index, gid := range ids {
selected[index] = sids[gid]
}
sids = selected
}
charset := encodeCFFSubsetCharset(sids)
private, err := readType2Private(data, dict[18], &type2Font{})
if err != nil {
return nil, err
}
if err := normalizeCFFPrivate(private); err != nil {
return nil, err
}
privateData, err := encodeCFFSubsetPrivate(private)
if err != nil {
return nil, err
}
prefix := bytes.Clone(data[:int(data[2])])
prefix = append(prefix, encodeCFFIndex(names)...)
suffix := encodeCFFIndex(strings)
suffix = append(suffix, encodeCFFIndex(font.globals)...)
encodedChars := encodeCFFIndex(chars)
var top []byte
for attempt := 0; attempt < 16; attempt++ {
top = encodeCFFIndex([][]byte{encodeCFFDict(dict)})
offset := len(prefix) + len(top) + len(suffix)
updated := maps.Clone(dict)
updated[15] = []float64{float64(offset)}
offset += len(charset)
if encoding != nil {
updated[16] = []float64{float64(offset)}
}
offset += len(encoding)
updated[17] = []float64{float64(offset)}
updated[18] = []float64{float64(len(privateData)), float64(offset + len(encodedChars))}
encoded := encodeCFFIndex([][]byte{encodeCFFDict(updated)})
if len(encoded) == len(top) {
result := prefix
for _, part := range [][]byte{encoded, suffix, charset, encoding, encodedChars, privateData, encodeCFFIndex(font.locals)} {
result = append(result, part...)
}
return result, nil
}
dict = updated
}
return nil, fmt.Errorf("CFF dictionary offsets did not converge")
}
// encodeCFFSubsetPrivate 保留私有字典参数,将局部子程序安排在字典之后
// 入参: private 私有字典
// 返回: []byte 字典数据, error 不支持的参数或偏移错误
func encodeCFFSubsetPrivate(private cffDict) ([]byte, error) {
for op := range private {
switch op {
case 6, 7, 8, 9, 10, 11, 19, 20, 21, 1209, 1210, 1211, 1212, 1213, 1214, 1217, 1218, 1219:
default:
return nil, fmt.Errorf("unsupported CFF private operator %d", op)
}
}
for range 16 {
data := encodeCFFDict(private)
value, ok := private[19]
if !ok || len(value) == 1 && value[0] == float64(len(data)) {
return data, nil
}
private[19] = []float64{float64(len(data))}
}
return nil, fmt.Errorf("CFF private offset did not converge")
}
// cffSubsetEncoding 读取自定义CFF编码及补充映射,预定义编码无需复制
// 入参: data CFF数据, dict 顶层字典
// 返回: []byte 编码数据, error 编码格式错误
func cffSubsetEncoding(data []byte, dict cffDict) ([]byte, error) {
values := dict[16]
if values == nil || len(values) == 1 && (values[0] == 0 || values[0] == 1) {
return nil, nil
}
start, err := cffDictOffset(data, dict, 16)
if err != nil || start > len(data)-2 {
return nil, fmt.Errorf("invalid CFF encoding offset")
}
end := start + 2
switch data[start] & 0x7f {
case 0:
end += int(data[start+1])
case 1:
end += 2 * int(data[start+1])
default:
return nil, fmt.Errorf("invalid CFF encoding format")
}
if data[start]&0x80 != 0 {
if end >= len(data) {
return nil, fmt.Errorf("truncated CFF encoding supplements")
}
end += 1 + 3*int(data[end])
}
if end > len(data) {
return nil, fmt.Errorf("truncated CFF encoding")
}
return data[start:end], nil
}
@@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import "fmt"
import (
"fmt"
)
// cffCharstringType 读取CFF字形程序类型,缺省为Type2
// 入参: dict 顶层字典
@@ -32,13 +34,13 @@ func cffCharstringType(dict cffDict) (int, error) {
// readCFFType1Program 读取Type1字形及无偏置局部子程序,保留源字形顺序
// 入参: data CFF字体, chars 字形程序, private 私有字典范围, cid 是否为CID字体
// 返回: *type1Program 执行资源, error 字符集或私有字典错误
func readCFFType1Program(data []byte, chars [][]byte, private []float64, cid bool) (*type1Program, error) {
// 返回: *Type1Program 执行资源, error 字符集或私有字典错误
func readCFFType1Program(data []byte, chars [][]byte, private []float64, cid bool) (*Type1Program, error) {
resources := &type2Font{}
if _, err := readType2Private(data, private, resources); err != nil {
return nil, err
}
program := &type1Program{subrs: resources.locals, lenIV: -1}
program := &Type1Program{subrs: resources.locals, lenIV: -1}
if cid {
return program, nil
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
@@ -31,10 +31,10 @@ type cmapSegment struct {
offset uint16
}
// normalizeCmap 转换扩展字符子表,保留各编码记录及原字形编号
// NormalizeCmap 转换扩展字符子表,保留各编码记录及原字形编号
// 入参: data 字符映射表
// 返回: []byte 等价字符映射表, error 子表错误
func normalizeCmap(data []byte) ([]byte, error) {
func NormalizeCmap(data []byte) ([]byte, error) {
if len(data) < 4 {
return data, nil
}
@@ -76,7 +76,7 @@ func normalizeCmap(data []byte) ([]byte, error) {
if err != nil {
return nil, err
}
table := buildCmapTable(0, mapping)
table := BuildCmapTable(0, mapping)
if language > 65535 {
table = buildCmapTableFormat12(0, mapping)
}
@@ -255,17 +255,17 @@ func parseCmapFormat2(data []byte) (map[rune]uint16, error) {
return mapping, nil
}
// packedGlyphRune 获取包装字体字符
// PackedGlyphRune 获取包装字体字符
// 入参: gid 字形ID
// 返回: rune 包装字体字符
func packedGlyphRune(gid uint16) rune {
func PackedGlyphRune(gid uint16) rune {
return 0xF0000 + rune(gid)
}
// parseCmapMappings 解析cmap字符映射
// ParseCmapMappings 解析cmap字符映射
// 入参: data cmap表数据
// 返回: map[rune]uint16 字符到字形映射
func parseCmapMappings(data []byte) map[rune]uint16 {
func ParseCmapMappings(data []byte) map[rune]uint16 {
if len(data) < 4 {
return nil
}
@@ -427,18 +427,18 @@ func parseCmapFormat12(data []byte, result map[rune]uint16) {
}
}
// addPackedGlyphMapping 添加字形ID私有映射
// AddPackedGlyphMapping 添加字形ID私有映射
// 入参: mapping 字符映射, numGlyphs 字形数量
func addPackedGlyphMapping(mapping map[rune]uint16, numGlyphs uint16) {
func AddPackedGlyphMapping(mapping map[rune]uint16, numGlyphs uint16) {
for i := uint16(0); i < numGlyphs; i++ {
mapping[packedGlyphRune(i)] = i
mapping[PackedGlyphRune(i)] = i
}
}
// buildCmapTable 构建格式4或12字符映射,省略缺字映射
// BuildCmapTable 构建格式4或12字符映射,省略缺字映射
// 入参: numGlyphs 字形数量, mapping 字符映射
// 返回: []byte cmap表数据
func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
func BuildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
if shouldBuildCmapFormat12(mapping) {
return buildCmapTableFormat12(numGlyphs, mapping)
}
+234
View File
@@ -0,0 +1,234 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"encoding/binary"
"maps"
"slices"
"unicode/utf8"
"github.com/go-text/typesetting/font/opentype/tables"
)
// subsetCmapVariations 裁剪Unicode变体映射,将非默认变体的字形加入保留集合
// 入参: data 字符映射表, chars 使用字符, glyphs 保留字形, count 字形总数
// 返回: []byte 格式14子表,无变体时为空, bool 映射是否有效
func subsetCmapVariations(data []byte, chars map[rune]bool, glyphs map[uint16]bool, count uint16) ([]byte, bool) {
if len(data) < 4 {
return nil, false
}
n := int(binary.BigEndian.Uint16(data[2:]))
if n > (len(data)-4)/8 {
return nil, false
}
var source []byte
for i := 0; i < n; i++ {
record := data[4+i*8:]
offset := uint64(binary.BigEndian.Uint32(record[4:]))
if offset < uint64(4+n*8) || offset+2 > uint64(len(data)) {
return nil, false
}
if binary.BigEndian.Uint16(data[offset:]) != 14 {
continue
}
if source != nil || binary.BigEndian.Uint16(record) != 0 || binary.BigEndian.Uint16(record[2:]) != 5 || offset+10 > uint64(len(data)) {
return nil, false
}
length := uint64(binary.BigEndian.Uint32(data[offset+2:]))
if length < 10 || offset+length > uint64(len(data)) {
return nil, false
}
source = data[offset : offset+length]
}
if source == nil {
return nil, true
}
return subsetCmapVariationTable(source, chars, glyphs, count)
}
// subsetCmapVariationTable 按使用字符筛选默认及非默认变体,保持选择符和映射有序
// 入参: data 格式14子表, chars 使用字符, glyphs 保留字形, count 字形总数
// 返回: []byte 裁剪后的子表, bool 子表是否有效
func subsetCmapVariationTable(data []byte, chars map[rune]bool, glyphs map[uint16]bool, count uint16) ([]byte, bool) {
n := uint64(binary.BigEndian.Uint32(data[6:]))
header := 10 + n*11
if header > uint64(len(data)) {
return nil, false
}
used := slices.Sorted(maps.Keys(chars))
var records, payload []byte
previous := rune(-1)
for i := uint64(0); i < n; i++ {
raw := data[10+i*11:]
for j, size := range []uint64{4, 5} {
offset := uint64(binary.BigEndian.Uint32(raw[3+j*4:]))
if offset == 0 {
continue
}
if offset < header || offset+4 > uint64(len(data)) {
return nil, false
}
entries := uint64(binary.BigEndian.Uint32(data[offset:]))
if offset+4+entries*size > uint64(len(data)) {
return nil, false
}
}
record, _, err := tables.ParseVariationSelector(raw, data)
selector := cmapVariationRune(record.VarSelector)
if err != nil || !utf8.ValidRune(selector) || selector <= previous || !validCmapVariation(record, count) {
return nil, false
}
previous = selector
if !chars[selector] {
continue
}
defaults := subsetCmapDefaultVariations(record.DefaultUVS.Ranges, used)
var alternatives []byte
for _, entry := range record.NonDefaultUVS.Ranges {
if chars[cmapVariationRune(entry.UnicodeValue)] {
alternatives = append(alternatives, entry.UnicodeValue[:]...)
alternatives = binary.BigEndian.AppendUint16(alternatives, uint16(entry.GlyphID))
glyphs[uint16(entry.GlyphID)] = true
}
}
if len(defaults)+len(alternatives) == 0 {
continue
}
start := len(records)
records = append(records, record.VarSelector[:]...)
records = append(records, make([]byte, 8)...)
for j, entries := range [][]byte{defaults, alternatives} {
if len(entries) == 0 {
continue
}
binary.BigEndian.PutUint32(records[start+3+j*4:], uint32(len(payload)+1))
payload = binary.BigEndian.AppendUint32(payload, uint32(len(entries)/(4+j)))
payload = append(payload, entries...)
}
}
if len(records) == 0 {
return nil, true
}
if uint64(len(records))+uint64(len(payload))+10 > 1<<32-1 {
return nil, false
}
for i := 0; i < len(records); i += 11 {
for _, j := range []int{3, 7} {
offset := binary.BigEndian.Uint32(records[i+j:])
if offset != 0 {
binary.BigEndian.PutUint32(records[i+j:], offset-1+uint32(10+len(records)))
}
}
}
out := make([]byte, 10, 10+len(records)+len(payload))
binary.BigEndian.PutUint16(out, 14)
binary.BigEndian.PutUint32(out[2:], uint32(cap(out)))
binary.BigEndian.PutUint32(out[6:], uint32(len(records)/11))
out = append(out, records...)
return append(out, payload...), true
}
// validCmapVariation 检查变体映射的字符范围、排序、互斥关系及字形编号
// 入参: record 选择符记录, count 字形总数
// 返回: bool 映射是否有效
func validCmapVariation(record tables.VariationSelector, count uint16) bool {
defaults := record.DefaultUVS.Ranges
previous := rune(-1)
for _, entry := range defaults {
first := cmapVariationRune(entry.StartUnicodeValue)
last := first + rune(entry.AdditionalCount)
if first <= previous || !utf8.ValidRune(first) || !utf8.ValidRune(last) || first < 0xd800 && last >= 0xd800 {
return false
}
previous = last
}
previous = -1
index := 0
for _, entry := range record.NonDefaultUVS.Ranges {
char := cmapVariationRune(entry.UnicodeValue)
if char <= previous || !utf8.ValidRune(char) || uint16(entry.GlyphID) >= count {
return false
}
previous = char
for index < len(defaults) && cmapVariationRune(defaults[index].StartUnicodeValue)+rune(defaults[index].AdditionalCount) < char {
index++
}
if index < len(defaults) && cmapVariationRune(defaults[index].StartUnicodeValue) <= char {
return false
}
}
return true
}
// subsetCmapDefaultVariations 筛选默认变体的使用字符,将连续字符合并为格式14范围
// 入参: ranges 默认变体范围, chars 已排序的使用字符
// 返回: []byte 范围记录,不含数量字段
func subsetCmapDefaultVariations(ranges []tables.UnicodeRange, chars []rune) []byte {
var out []byte
index := 0
last := rune(-2)
for _, char := range chars {
for index < len(ranges) && cmapVariationRune(ranges[index].StartUnicodeValue)+rune(ranges[index].AdditionalCount) < char {
index++
}
if index == len(ranges) {
break
}
if cmapVariationRune(ranges[index].StartUnicodeValue) > char {
continue
}
if char == last+1 && out[len(out)-1] < 255 {
out[len(out)-1]++
} else {
out = append(out, byte(char>>16), byte(char>>8), byte(char), 0)
}
last = char
}
return out
}
// cmapVariationRune 读取变体映射中的24位Unicode码点
// 入参: value 三字节码点
// 返回: rune Unicode码点
func cmapVariationRune(value [3]byte) rune {
return rune(value[0])<<16 | rune(value[1])<<8 | rune(value[2])
}
// buildCmapWithVariations 在生成的Unicode映射中插入格式14记录,保持编码记录有序
// 入参: count 字形总数, mapping 字符映射, variations 格式14子表
// 返回: []byte 字符映射表
func buildCmapWithVariations(count uint16, mapping map[rune]uint16, variations []byte) []byte {
base := BuildCmapTable(count, mapping)
if len(variations) == 0 {
return base
}
payload := base[binary.BigEndian.Uint32(base[8:]):]
out := make([]byte, 28, 28+len(payload)+len(variations))
binary.BigEndian.PutUint16(out[2:], 3)
encoding := uint16(3)
if binary.BigEndian.Uint16(payload) == 12 {
encoding = 4
}
binary.BigEndian.PutUint16(out[6:], encoding)
binary.BigEndian.PutUint32(out[8:], 28)
binary.BigEndian.PutUint16(out[14:], 5)
binary.BigEndian.PutUint32(out[16:], uint32(28+len(payload)))
last := 4 + 8*(int(binary.BigEndian.Uint16(base[2:]))-1)
copy(out[20:28], base[last:last+8])
binary.BigEndian.PutUint32(out[24:], 28)
out = append(out, payload...)
return append(out, variations...)
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
"unicode/utf8"
)
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
type Face struct {
Index int `json:"index"`
FullName string `json:"fullName"`
Family string `json:"family"`
Style string `json:"style"`
PostScriptName string `json:"postscriptName"`
Names []string `json:"names"`
MenuFamily string `json:"menuFamily"`
MenuStyle string `json:"menuStyle"`
FamilyKey string `json:"familyKey"`
Bold bool `json:"bold"`
Italic bool `json:"italic"`
}
// FaceInfo 从名称表读取字体信息
// 入参: data 名称表数据, index 零起始字体索引
// 返回: Face 字体信息
func FaceInfo(data []byte, index int) Face {
return faceInfoLanguage(data, index, 0xffff, 0xffff)
}
// faceInfoLanguage 优先读取指定平台和语言的显示名称,保留标识及全部别名
// 入参: data 名称表数据, index 字体索引, platform 平台标识, language 语言标识
// 返回: Face 字体信息
func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
records, values := fontNameValues(data)
name := func(ids ...uint16) string {
for _, id := range ids {
for _, record := range records {
if record.Name == id {
if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" {
return value
}
}
}
}
return ""
}
localized := func(ids ...uint16) string {
for _, id := range ids {
for _, record := range records {
if record.Platform == platform && record.Language == language && record.Name == id {
if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" {
return value
}
}
}
}
return name(ids...)
}
key := ""
if family, version := name(1), name(5); family != "" && version != "" {
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
}
family, style := localized(1), localized(2)
if family == "" {
family = localized(16, 4, 6)
}
if style == "" {
style = localized(17)
}
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
}
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
// 返回: Face 字体信息
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
face := faceInfoLanguage(tables["name"], index, platform, language)
key := face.FamilyKey
if head := tables["head"]; len(head) >= 46 {
key += string(head[4:8])
style := binary.BigEndian.Uint16(head[44:])
face.Bold, face.Italic = style&1 != 0, style&2 != 0
}
if os2 := tables["OS/2"]; len(os2) >= 64 {
key += string(os2[58:62])
style := binary.BigEndian.Uint16(os2[62:])
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
}
if face.FamilyKey != "" {
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
}
return face
}
// FileCount 校验字体或集合头并读取字体数量
// 入参: data OpenType数据
// 返回: int 字体数量, error 格式错误
func FileCount(data []byte) (int, error) {
if len(data) < 12 {
return 0, fmt.Errorf("invalid font header")
}
switch string(data[:4]) {
case "\x00\x01\x00\x00", "OTTO", "true":
return 1, nil
case "ttcf":
version := binary.BigEndian.Uint32(data[4:])
count := uint64(binary.BigEndian.Uint32(data[8:]))
if (version != 0x00010000 && version != 0x00020000) || count == 0 || count > uint64(len(data)-12)/4 {
return 0, fmt.Errorf("invalid font collection header")
}
return int(count), nil
default:
return 0, fmt.Errorf("invalid OpenType header")
}
}
// ReadFaces 读取已解封装字体或集合的名称信息
// 入参: data OpenType数据
// 返回: []Face 字体列表, error 格式或名称表错误
func ReadFaces(data []byte) ([]Face, error) {
return ReadLocalizedFaces(data, 0xffff, 0xffff)
}
// ReadLocalizedFaces 读取指定平台和语言的字体名称,缺失时沿用名称表顺序
// 入参: data OpenType数据, platform 名称表平台标识, language 名称表语言标识
// 返回: []Face 字体列表, error 格式或名称表错误
func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error) {
count, err := FileCount(data)
if err != nil {
return nil, err
}
faces := make([]Face, 0, count)
for index := 0; index < count; index++ {
tables, err := FileTables(data, index)
if err != nil {
return nil, err
}
face := TableFaceInfo(tables, index, platform, language)
if face.FullName == "" {
return nil, fmt.Errorf("font %d has no usable name", index)
}
faces = append(faces, face)
}
return faces, nil
}
// ExtractFace 提取已解封装字体中的集合项,规范字符映射并保留原始表
// 入参: data OpenType数据, index 零起始字体索引
// 返回: []byte 独立字体数据, error 格式或索引错误
func ExtractFace(data []byte, index int) ([]byte, error) {
var err error
if bytes.HasPrefix(data, []byte("ttcf")) {
data, err = ExtractCollectionFont(data, index)
if err != nil {
return nil, err
}
index = 0
}
tables, err := FileTables(data, index)
if err != nil {
return nil, err
}
cmap, err := NormalizeCmap(tables["cmap"])
if err != nil {
return nil, err
}
if !bytes.Equal(cmap, tables["cmap"]) {
tables["cmap"] = cmap
return SerializeOTF(tables)
}
return bytes.Clone(data), nil
}
// MissingGlyphs 检查独立字体的字符覆盖,忽略段落换行并按首次出现顺序去重
// 入参: data 独立OpenType数据, text 待检查文字
// 返回: string 缺失字符, error 字体或文字格式错误
func MissingGlyphs(data []byte, text string) (string, error) {
if !utf8.ValidString(text) {
return "", fmt.Errorf("text must be UTF-8")
}
data, err := DefaultCFF2Font(data)
if err != nil {
return "", err
}
sfnt, err := ParseSFNT(data)
if err != nil {
return "", err
}
seen := make(map[rune]bool)
var missing strings.Builder
for _, char := range text {
if char == '\r' || char == '\n' || seen[char] {
continue
}
seen[char] = true
if sfnt.GlyphIndex(char) == 0 {
missing.WriteRune(char)
}
}
return missing.String(), nil
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"encoding/binary"
@@ -21,12 +21,16 @@ import (
"math"
"sync"
"github.com/tdewolff/font"
"github.com/go-text/typesetting/font/cff"
"github.com/go-text/typesetting/font/opentype"
)
// sfntOutliner 缓存字体程序并提取独立字形轮廓
type sfntOutliner struct {
font *font.SFNT
// Outliner 缓存字体程序并提取独立字形轮廓
type Outliner struct {
Font *SFNT
cffOnce sync.Once
cff *cff.CFF
cffErr error
once sync.Once
program *ttInterpreter
err error
@@ -42,12 +46,52 @@ type ttGlyph struct {
warning error
}
// path 按TrueType指令和隐含点规则生成闭合轮廓,CFF保留原有解析
// Path 按字体设计坐标提取闭合轮廓,TrueType保留字形指令处理
// 入参: path 路径接收器, glyph 字形编号, scale 字体单位缩放
// 返回: error 字形解析错误
func (f *sfntOutliner) path(path font.Pather, glyph uint16, scale float64) error {
if !f.font.IsTrueType {
return f.font.GlyphPath(path, glyph, 0, 0, 0, scale, font.NoHinting)
func (f *Outliner) Path(path glyphPather, glyph uint16, scale float64) error {
type point struct{ X, Y float64 }
if !f.Font.IsTrueType {
f.cffOnce.Do(func() {
data, err := SanitizeCFF(f.Font.Tables["CFF "])
if err == nil {
data, err = NormalizeCFFCharstringsAt(data, f.Font.UnitsPerEm())
}
if err != nil {
f.cffErr = err
return
}
f.cff, f.cffErr = cff.Parse(data)
})
if f.cffErr != nil {
return f.cffErr
}
segments, _, err := f.cff.LoadGlyph(glyph)
if err != nil {
return err
}
open := false
for _, segment := range segments {
p := segment.Args
switch segment.Op {
case opentype.SegmentOpMoveTo:
if open {
path.Close()
}
path.MoveTo(float64(p[0].X)*scale, float64(p[0].Y)*scale)
open = true
case opentype.SegmentOpLineTo:
path.LineTo(float64(p[0].X)*scale, float64(p[0].Y)*scale)
case opentype.SegmentOpCubeTo:
path.CubeTo(float64(p[0].X)*scale, float64(p[0].Y)*scale, float64(p[1].X)*scale, float64(p[1].Y)*scale, float64(p[2].X)*scale, float64(p[2].Y)*scale)
default:
return fmt.Errorf("invalid CFF curve")
}
}
if open {
path.Close()
}
return nil
}
contour, err := f.glyph(glyph, make(map[uint16]bool))
if err != nil {
@@ -64,23 +108,23 @@ func (f *sfntOutliner) path(path font.Pather, glyph uint16, scale float64) error
first = last + 1
continue
}
point := func(i int) Point {
return Point{X: float64(points[i].x) * scale / 64, Y: float64(points[i].y) * scale / 64}
coordinate := func(i int) point {
return point{X: float64(points[i].x) * scale / 64, Y: float64(points[i].y) * scale / 64}
}
start, end := point(first), point(last)
start, end := coordinate(first), coordinate(last)
begin, stop := first, last
if points[first].on {
begin++
} else if points[last].on {
start, stop = end, last-1
} else {
start = Point{X: (start.X + end.X) / 2, Y: (start.Y + end.Y) / 2}
start = point{X: (start.X + end.X) / 2, Y: (start.Y + end.Y) / 2}
}
path.MoveTo(start.X, start.Y)
var control Point
var control point
pending := false
for i := begin; i <= stop; i++ {
p := point(i)
p := coordinate(i)
if points[i].on {
if pending {
path.QuadTo(control.X, control.Y, p.X, p.Y)
@@ -107,14 +151,14 @@ func (f *sfntOutliner) path(path font.Pather, glyph uint16, scale float64) error
// glyph 解码简单或复合字形并执行对应字节码,不改写字体资源
// 入参: id 字形编号, active 递归访问集合
// 返回: ttGlyph 字形轮廓, error 字形错误
func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error) {
func (f *Outliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error) {
var result ttGlyph
if id >= f.font.NumGlyphs() || active[id] {
if id >= f.Font.NumGlyphs() || active[id] {
return result, fmt.Errorf("invalid TrueType component reference %d", id)
}
active[id] = true
defer delete(active, id)
data, err := trueTypeGlyphData(f.font.Tables, id)
data, err := trueTypeGlyphData(f.Font.Tables, id)
if err != nil {
return result, err
}
@@ -146,14 +190,14 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
if len(program) == 0 {
return result, nil
}
outlineOnly, err := ttOutlineOnly(f.font.Tables, program)
outlineOnly, err := ttOutlineOnly(f.Font.Tables, program)
if err != nil {
return result, err
}
if outlineOnly {
return result, nil
}
f.once.Do(func() { f.program, f.err = newTTInterpreter(f.font.Tables, f.font.UnitsPerEm()) })
f.once.Do(func() { f.program, f.err = newTTInterpreter(f.Font.Tables, f.Font.UnitsPerEm()) })
if f.err != nil {
if ttRecoverableHint(f.err) {
result.warning = f.err
@@ -182,16 +226,16 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
// phantomPoints 按水平和垂直度量生成字形末尾的四个虚拟点
// 入参: id 字形编号, data 完整字形头
// 返回: [4]ttPoint 左右边距与上下原点
func (f *sfntOutliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.font.Hmtx.LeftSideBearing(id))
func (f *Outliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.Font.sideBearing(id, false))
top := int32(int16(binary.BigEndian.Uint16(data[8:])))
if f.font.Vmtx != nil {
top += int32(f.font.Vmtx.TopSideBearing(id))
if f.Font.vertical != 0 {
top += int32(f.Font.sideBearing(id, true))
}
return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.font.GlyphAdvance(id))) * 64, ox: (left + int32(f.font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64}}
return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.Font.GlyphAdvance(id))) * 64, ox: (left + int32(f.Font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.Font.GlyphVerticalAdvance(id))) * 64}}
}
// ttRecoverableHint 判断栈参数、指令引用或控制流错误是否允许保留执行前轮廓
// ttRecoverableHint 判断栈参数、区点轮廓及函数引用或控制流错误是否允许保留执行前轮廓
// 入参: err 指令错误
// 返回: bool 是否可恢复
func ttRecoverableHint(err error) bool {
@@ -231,7 +275,7 @@ func trueTypeGlyphData(tables map[string][]byte, id uint16) ([]byte, error) {
// recordGlyphWarning 记录字形恢复提示,允许并发提取轮廓
// 入参: id 字形编号, warning 恢复原因
func (f *sfntOutliner) recordGlyphWarning(id uint16, warning error) {
func (f *Outliner) recordGlyphWarning(id uint16, warning error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.warnings == nil {
@@ -240,10 +284,10 @@ func (f *sfntOutliner) recordGlyphWarning(id uint16, warning error) {
f.warnings[id] = warning
}
// glyphWarning 返回字形恢复为执行前轮廓的原因
// GlyphWarning 返回字形恢复为执行前轮廓的原因
// 入参: id 字形编号
// 返回: error 恢复原因,无恢复时为空
func (f *sfntOutliner) glyphWarning(id uint16) error {
func (f *Outliner) GlyphWarning(id uint16) error {
f.mu.Lock()
defer f.mu.Unlock()
return f.warnings[id]
@@ -339,7 +383,7 @@ func readTTSimple(data []byte, count int) (ttGlyph, []byte, error) {
// composite 合成分量变换和点匹配,保留分量顺序及父级字节码
// 入参: glyph 父字形编号, data 复合字形数据, phantoms 父字形虚拟点, active 递归访问集合
// 返回: ttGlyph 字形轮廓, []byte 字节码, error 数据错误
func (f *sfntOutliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint, active map[uint16]bool) (ttGlyph, []byte, error) {
func (f *Outliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint, active map[uint16]bool) (ttGlyph, []byte, error) {
var result ttGlyph
pos := 10
total := -1
@@ -432,7 +476,7 @@ func (f *sfntOutliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint,
} else {
if total < 0 {
var err error
total, err = trueTypePointCount(f.font.Tables, glyph, map[uint16]int{}, map[uint16]bool{})
total, err = trueTypePointCount(f.Font.Tables, glyph, map[uint16]int{}, map[uint16]bool{})
if err != nil {
return result, nil, err
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"encoding/binary"
@@ -25,7 +25,7 @@ import (
// errTTStackUnderflow 表示字形程序缺少必需的栈参数
var errTTStackUnderflow = errors.New("TrueType instruction stack underflow")
// errTTInvalidReference 表示字形指令引用不存在的点或函数
// errTTInvalidReference 表示字形指令引用不存在的区、点、轮廓或函数
var errTTInvalidReference = errors.New("invalid TrueType instruction reference")
// errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围
@@ -380,7 +380,8 @@ func (v *ttInterpreter) execute(code []byte, depth int) error {
case op >= 0x13 && op <= 0x16:
z := v.pop()
if z < 0 || z > 1 {
return fmt.Errorf("invalid TrueType zone")
v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, z)
break
}
if op == 0x16 {
g.zone = [3]int32{z, z, z}
@@ -854,7 +855,7 @@ func (v *ttInterpreter) shift(op byte) {
if op <= 0x35 {
contour := v.pop()
if contour < 0 || int64(contour) >= int64(len(v.ends)) {
v.err = fmt.Errorf("invalid TrueType contour reference")
v.err = fmt.Errorf("%w: contour %d", errTTInvalidReference, contour)
return
}
if contour > 0 {
@@ -864,7 +865,7 @@ func (v *ttInterpreter) shift(op byte) {
} else {
target = v.pop()
if target < 0 || target > 1 {
v.err = fmt.Errorf("invalid TrueType zone")
v.err = fmt.Errorf("%w: zone %d", errTTInvalidReference, target)
return
}
to = len(v.zones[target])
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
@@ -22,19 +22,19 @@ import (
"slices"
)
// errTTInvalidLocations 表示字形索引不满足递增偏移要求
var errTTInvalidLocations = errors.New("invalid TrueType glyph locations")
// ErrTTInvalidLocations 表示字形索引不满足递增偏移要求
var ErrTTInvalidLocations = errors.New("invalid TrueType glyph locations")
// normalizeTrueTypeLocations 验证物理字形边界后按原编号重排,不改变空字形和分量引用
// NormalizeTrueTypeLocations 验证物理字形边界后按原编号重排,不改变空字形和分量引用
// 入参: tables 独占字体表目录,表内容只读
// 返回: bool 是否重建索引, error 无法可靠恢复的索引或轮廓错误
func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
func NormalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
if _, ok := tables["glyf"]; !ok {
return false, nil
}
head, maxp, loca, glyf := tables["head"], tables["maxp"], tables["loca"], tables["glyf"]
if len(head) < 54 || len(maxp) < 6 {
return false, fmt.Errorf("%w: missing header", errTTInvalidLocations)
return false, fmt.Errorf("%w: missing header", ErrTTInvalidLocations)
}
count := int(binary.BigEndian.Uint16(maxp[4:]))
width := 2
@@ -43,10 +43,10 @@ func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
case 1:
width = 4
default:
return false, fmt.Errorf("%w: format", errTTInvalidLocations)
return false, fmt.Errorf("%w: format", ErrTTInvalidLocations)
}
if count == 0 || len(loca) < (count+1)*width {
return false, fmt.Errorf("%w: truncated index", errTTInvalidLocations)
return false, fmt.Errorf("%w: truncated index", ErrTTInvalidLocations)
}
location := func(i int) uint64 {
if width == 2 {
@@ -58,7 +58,7 @@ func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
for i := range count + 1 {
offset := location(i)
if offset > uint64(len(glyf)) {
return false, fmt.Errorf("%w: offset %d outside table", errTTInvalidLocations, i)
return false, fmt.Errorf("%w: offset %d outside table", ErrTTInvalidLocations, i)
}
ordered = ordered && previous <= offset
previous = offset
@@ -83,31 +83,31 @@ func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
continue
}
if _, duplicate := bodies[start]; duplicate {
return false, fmt.Errorf("%w: ambiguous shared offset %d", errTTInvalidLocations, start)
return false, fmt.Errorf("%w: ambiguous shared offset %d", ErrTTInvalidLocations, start)
}
index, _ := slices.BinarySearch(physical, start)
if index+1 >= len(physical) {
return false, fmt.Errorf("%w: missing glyph %d body", errTTInvalidLocations, id)
return false, fmt.Errorf("%w: missing glyph %d body", ErrTTInvalidLocations, id)
}
data := glyf[start:physical[index+1]]
size, refs, err := trueTypeGlyphSize(data, count)
if err != nil {
return false, fmt.Errorf("%w: glyph %d: %w", errTTInvalidLocations, id, err)
return false, fmt.Errorf("%w: glyph %d: %w", ErrTTInvalidLocations, id, err)
}
if len(data)-size > 3 || len(bytes.Trim(data[size:], "\x00")) != 0 {
return false, fmt.Errorf("%w: ambiguous glyph %d padding", errTTInvalidLocations, id)
return false, fmt.Errorf("%w: ambiguous glyph %d padding", ErrTTInvalidLocations, id)
}
bodies[start], components[id] = data[:size], refs
total += (uint64(size) + 3) &^ 3
if total > uint64(^uint(0)>>1) || total > uint64(^uint32(0)) {
return false, fmt.Errorf("%w: rebuilt table exceeds capacity", errTTInvalidLocations)
return false, fmt.Errorf("%w: rebuilt table exceeds capacity", ErrTTInvalidLocations)
}
}
state := make([]uint8, count)
var visit func(int) error
visit = func(id int) error {
if state[id] == 1 {
return fmt.Errorf("%w: cyclic component %d", errTTInvalidLocations, id)
return fmt.Errorf("%w: cyclic component %d", ErrTTInvalidLocations, id)
}
if state[id] == 2 {
return nil
+806
View File
@@ -0,0 +1,806 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"path"
"slices"
"sort"
"strings"
)
// 字体匹配按精确、部分和模糊匹配排列,零值表示未匹配
const (
MatchNone = iota
MatchExact
MatchPartial
MatchFuzzy
)
// matchRules 保存常用字体的名称、文件名及系统字体匹配规则
var matchRules = normalizeMatchRules([]matchRule{
{
Keys: []string{"小标宋", "方正小标宋", "xiaobiaosong", "fzxiaobiaosong", "fzxbs"},
Names: []string{"小标宋体", "方正小标宋简体", "FZXiaoBiaoSong-B05", "FZXiaoBiaoSong-B05S"},
Files: []string{"FZXBSJW.TTF", "fzxbs*.ttf", "fzxiaobiaosong*.ttf", "xiaobiaosong*.ttf"},
System: "FZXiaoBiaoSong-B05S",
NoSyntheticBold: true,
},
{
Keys: []string{"宋体", "新宋体", "simsun", "nsimsun", "simsunextb", "song", "songti", "stsong", "书宋", "方正书宋"},
Names: []string{"宋体", "新宋体", "SimSun", "NSimSun", "SongTi", "STSong"},
Files: []string{"simsun.ttc", "simsun.ttf", "nsimsun.ttf", "STSONG.TTF"},
System: "SimSun",
},
{
Keys: []string{"黑体", "hei", "simhei", "heiti", "sthei", "方正黑体"},
Names: []string{"黑体", "SimHei", "HeiTi", "STHeiti", "方正黑体简体"},
Files: []string{"simhei.ttf", "STXIHEI.TTF", "FZHT_GB18030.TTF", "fzht*.ttf"},
System: "SimHei",
},
{
Keys: []string{"楷体", "kai", "kaiti", "simkai", "stkaiti", "方正楷体"},
Names: []string{"楷体", "KaiTi", "SimKai", "STKaiti", "方正楷体简体"},
Files: []string{"simkai.ttf", "kaiti.ttf", "STKAITI.TTF", "FZKTJ.TTF", "fzkai*.ttf", "fzkt*.ttf"},
System: "KaiTi",
},
{
Keys: []string{"仿宋", "fang", "fangsong", "simfang", "stfangsong", "方正仿宋"},
Names: []string{"仿宋", "FangSong", "SimFang", "STFangsong", "方正仿宋简体"},
Files: []string{"simfang.ttf", "fangsong.ttf", "STFANGSO.TTF", "FZFSJ.TTF", "fzfs*.ttf"},
System: "FangSong",
},
{
Keys: []string{"微软雅黑", "microsoftyahei", "yahei", "msyh"},
Names: []string{"微软雅黑", "Microsoft YaHei", "YaHei", "MSYH"},
Files: []string{"msyh.ttc", "msyh.ttf"},
System: "Microsoft YaHei",
},
{
Keys: []string{"arialblack"},
Names: []string{"Arial Black", "ArialBlack"},
Files: []string{"ariblk.ttf", "arial black*.ttf", "arialblack*.ttf"},
System: "Arial Black",
},
{
Keys: []string{"arial"},
Names: []string{"Arial"},
Files: []string{"arial.ttf", "arial*.ttf"},
System: "Arial",
},
{
Keys: []string{"helvetica"},
Names: []string{"Helvetica", "Arial"},
Files: []string{"arial.ttf", "arial*.ttf"},
System: "Arial",
},
{
Keys: []string{"couriernew", "courier"},
Names: []string{"Courier New", "CourierNew", "Courier"},
Files: []string{"cour.ttf", "courier new*.ttf", "courier*.ttf"},
System: "Courier New",
},
{
Keys: []string{"segoeui"},
Names: []string{"Segoe UI", "SegoeUI"},
Files: []string{"segoeui.ttf", "segoe*.ttf"},
System: "Segoe UI",
},
{
Keys: []string{"timesnewroman", "times"},
Names: []string{"Times New Roman", "TimesNewRoman"},
Files: []string{"times.ttf", "times new roman*.ttf", "times*.ttf"},
System: "Times New Roman",
},
})
// fontNameReplacer 移除字体名称匹配时忽略的分隔符
var fontNameReplacer = strings.NewReplacer(" ", "", "-", "", "_", "", "(", "", ")", "", "(", "", ")", "")
// matchRule 字体匹配规则
type matchRule struct {
Keys []string
Names []string
Files []string
System string
NoSyntheticBold bool
}
// FileCandidate 字体文件候选
type FileCandidate struct {
Name string
Face int
base string
lowerBase string
filename string
normalized string
names []string
fold string
}
// patternMatcher 字体文件匹配器
type patternMatcher struct {
pattern string
lowerPattern string
stem string
aliases []string
priority int
}
// FileMatch 字体文件匹配结果
type FileMatch struct {
Name string
Face int
Priority int
Rank int
styleRank int
sortName string
}
// fileKey 字体文件与集合索引的去重键
type fileKey struct {
Name string
Face int
}
// NormalizeName 规范化字体名称
// 入参: name 字体名称
// 返回: string 规范化后的字体名称
func NormalizeName(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
name = path.Base(strings.TrimSpace(name))
if IsFontFileName(name) {
name = strings.TrimSuffix(name, path.Ext(name))
}
name = strings.ToLower(strings.TrimSpace(name))
return fontNameReplacer.Replace(name)
}
// IsFontFileName 判断是否为字体文件名
// 入参: name 文件名
// 返回: bool 是否为字体文件
func IsFontFileName(name string) bool {
switch strings.ToLower(path.Ext(name)) {
case ".ttf", ".otf", ".ttc", ".otc":
return true
default:
return false
}
}
// AppendFontName 追加字体名称
// 入参: names 字体名称列表, seen 去重映射, name 字体名称
// 返回: []string 字体名称列表
func AppendFontName(names []string, seen map[string]bool, name string) []string {
name = strings.TrimSpace(name)
if name == "" {
return names
}
key := NormalizeName(name)
if key == "" || seen[key] {
return names
}
seen[key] = true
return append(names, name)
}
// normalizeMatchRules 预处理固定字体规则的匹配名称,保留候选名称及顺序
// 入参: rules 字体匹配规则
// 返回: []matchRule 预处理后的规则
func normalizeMatchRules(rules []matchRule) []matchRule {
for i := range rules {
rule := &rules[i]
keys := append(append(rule.Keys, rule.Names...), rule.System)
rule.Keys = nil
for _, key := range keys {
key = NormalizeName(key)
if key != "" && !slices.Contains(rule.Keys, key) {
rule.Keys = append(rule.Keys, key)
}
}
}
return rules
}
// CandidateNames 获取字体候选名称
// 入参: names 字体名称列表
// 返回: []string 字体候选名称
func CandidateNames(names ...string) []string {
return candidateNamesByLevel(MatchFuzzy, names...)
}
// ExactCandidateNames 获取精确字体候选名称
// 入参: names 字体名称列表
// 返回: []string 精确字体候选名称
func ExactCandidateNames(names ...string) []string {
return candidateNamesByLevel(MatchExact, names...)
}
// qualifiedNames 获取保留名称后缀的字体候选
// 入参: name 字体名称
// 返回: []string 保留后缀的候选名称
func qualifiedNames(name string) []string {
name = NormalizeName(name)
key := NormalizeName(name)
var names []string
seen := make(map[string]bool)
for _, rule := range matchRules {
if ruleMatchLevel(rule, key) != MatchPartial {
continue
}
prefix := ""
for _, alias := range rule.Keys {
if len(alias) > len(prefix) && strings.HasPrefix(name, alias) {
prefix = alias
}
}
suffix := strings.TrimPrefix(name, prefix)
for _, alias := range append([]string{rule.System}, rule.Names...) {
names = AppendFontName(names, seen, NormalizeName(alias)+suffix)
}
}
return names
}
// candidateNamesByLevel 获取指定等级内的字体候选名称
// 入参: maxLevel 最大匹配等级, names 字体名称列表
// 返回: []string 字体候选名称
func candidateNamesByLevel(maxLevel int, names ...string) []string {
var result []string
seen := make(map[string]bool)
var bases []string
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
result = AppendFontName(result, seen, name)
bases = append(bases, name)
}
levels := matchLevels(bases)
for level := MatchExact; level <= maxLevel; level++ {
for i := range bases {
for j, rule := range matchRules {
if levels[i*len(matchRules)+j] == level {
result = AppendFontName(result, seen, rule.System)
for _, item := range rule.Names {
result = AppendFontName(result, seen, item)
}
}
}
}
}
return result
}
// patternStem 获取字体匹配模式主干
// 入参: pattern 字体文件匹配模式
// 返回: string 字体匹配模式主干
func patternStem(pattern string) string {
stem := strings.TrimSuffix(pattern, "*")
if IsFontFileName(stem) {
stem = strings.TrimSuffix(stem, path.Ext(stem))
}
if index := strings.IndexAny(stem, "*?["); index >= 0 {
stem = stem[:index]
}
return NormalizeName(stem)
}
// FilePatterns 获取字体文件匹配模式
// 入参: names 字体名称列表
// 返回: []string 字体文件匹配模式
func FilePatterns(names ...string) []string {
var result []string
seen := make(map[string]bool)
levels := matchLevels(names)
for level := MatchExact; level <= MatchFuzzy; level++ {
for _, name := range candidateNamesAtLevel(level, names, levels) {
result = appendPattern(result, seen, name+"*")
}
for i := range names {
for j, rule := range matchRules {
if levels[i*len(matchRules)+j] == level {
for _, item := range rule.Files {
result = appendPattern(result, seen, item)
}
}
}
}
}
return result
}
// SystemNames 获取系统字体名称
// 入参: names 字体名称列表
// 返回: []string 系统字体名称
func SystemNames(names ...string) []string {
var result []string
seen := make(map[string]bool)
for _, name := range names {
for _, item := range candidateNamesByLevel(MatchPartial, name) {
result = AppendFontName(result, seen, item)
}
}
return result
}
// candidateNamesAtLevel 获取指定等级的字体候选名称
// 入参: level 匹配等级, names 字体名称列表, levels 名称与规则的匹配等级
// 返回: []string 字体候选名称
func candidateNamesAtLevel(level int, names []string, levels []int) []string {
var result []string
seen := make(map[string]bool)
for i, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if level == MatchExact {
result = AppendFontName(result, seen, name)
}
for j, rule := range matchRules {
if levels[i*len(matchRules)+j] == level {
result = AppendFontName(result, seen, rule.System)
for _, item := range rule.Names {
result = AppendFontName(result, seen, item)
}
}
}
}
return result
}
// DefaultSystemNames 获取默认系统字体名称
// 返回: []string 默认系统字体名称
func DefaultSystemNames() []string {
return []string{
"SimSun", "Microsoft YaHei", "SimHei", "KaiTi", "FangSong",
"Noto Sans CJK SC", "Source Han Sans SC", "Arial", "Segoe UI", "Times New Roman",
}
}
// NoSyntheticBold 判断字体是否禁用合成粗体
// 入参: names 字体名称列表
// 返回: bool 是否禁用合成粗体
func NoSyntheticBold(names ...string) bool {
for _, name := range names {
name = NormalizeName(name)
for _, rule := range matchRules {
if !rule.NoSyntheticBold {
continue
}
level := ruleMatchLevel(rule, name)
if level >= MatchExact && level <= MatchPartial {
return true
}
}
}
return false
}
// matchLevels 按名称和规则顺序计算匹配等级
// 入参: names 字体名称列表
// 返回: []int 名称与规则的匹配等级
func matchLevels(names []string) []int {
levels := make([]int, len(names)*len(matchRules))
for i, name := range names {
name = NormalizeName(name)
for j, rule := range matchRules {
levels[i*len(matchRules)+j] = ruleMatchLevel(rule, name)
}
}
return levels
}
// ruleMatchLevel 获取字体规则匹配等级
// 入参: rule 字体匹配规则, name 规范化后的字体名称
// 返回: int 匹配等级
func ruleMatchLevel(rule matchRule, name string) int {
if name == "" {
return MatchNone
}
level := MatchNone
for _, item := range rule.Keys {
if name == item {
return MatchExact
}
if strings.HasPrefix(name, item) {
level = MatchPartial
} else if level == MatchNone && strings.Contains(name, item) {
level = MatchFuzzy
}
}
return level
}
// appendPattern 追加字体文件匹配模式
// 入参: patterns 匹配模式列表, seen 去重映射, pattern 匹配模式
// 返回: []string 匹配模式列表
func appendPattern(patterns []string, seen map[string]bool, pattern string) []string {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return patterns
}
key := strings.ToLower(pattern)
if seen[key] {
return patterns
}
seen[key] = true
return append(patterns, pattern)
}
// AppendFileNames 建立各字体的内部名称与文件别名匹配候选
// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称
// 返回: []FileCandidate 字体候选列表
func AppendFileNames(candidates []FileCandidate, index int, faces [][]string) []FileCandidate {
file := candidates[index]
replaced := false
for face, names := range faces {
file.Face = face
file.names = make([]string, len(names))
for i, name := range names {
file.names[i] = NormalizeName(name)
}
for _, name := range file.names {
file.normalized = name
if !replaced {
candidates[index] = file
replaced = true
} else {
candidates = append(candidates, file)
}
}
}
return candidates
}
// CollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项
// 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体
// 返回: int 零起始字体索引
func CollectionIndex(data []byte, names []string, bold, italic bool) int {
return NameIndex(FileNames(bytes.NewReader(data)), names, bold, italic)
}
// NameIndex 按名称与样式选择字体索引,未匹配时保留首项
// 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体
// 返回: int 零起始字体索引
func NameIndex(faces [][]string, names []string, bold, italic bool) int {
candidates := AppendFileNames([]FileCandidate{{}}, 0, faces)
if matches := FileMatches(candidates, names, bold, italic); len(matches) > 0 {
return matches[0].Face
}
return 0
}
// FileMatches 匹配字体文件候选
// 入参: candidates 字体候选, names 字体名称, bold 是否粗体, italic 是否斜体
// 返回: []FileMatch 按优先级排列的字体匹配
func FileMatches(candidates []FileCandidate, names []string, bold, italic bool) []FileMatch {
matches := make([]FileMatch, 0, len(candidates))
seen := make(map[fileKey]int, len(candidates))
if len(names) == 0 {
for _, name := range DefaultSystemNames() {
matches = append(matches, FileMatches(candidates, []string{name}, bold, italic)...)
}
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].Rank < matches[j].Rank
})
unique := matches[:0]
for _, match := range matches {
key := fileKey{strings.ToLower(match.Name), match.Face}
if _, ok := seen[key]; !ok {
seen[key] = len(unique)
unique = append(unique, match)
}
}
return unique
}
for i, name := range names {
if name == "" {
continue
}
for j, alias := range append([]string{name}, qualifiedNames(name)...) {
matcher := patternMatcher{stem: NormalizeName(alias), priority: (i-len(names))*2 + min(j, 1)}
if matcher.stem == "" {
continue
}
for _, file := range candidates {
if rank := matcher.rankCandidate(file); rank == MatchExact {
appendFileMatch(&matches, seen, matcher, file, rank, bold, italic)
}
}
}
}
for _, matcher := range newPatternMatchers(FilePatterns(names...)) {
for _, file := range candidates {
rank := matcher.rankCandidate(file)
appendFileMatch(&matches, seen, matcher, file, rank, bold, italic)
}
}
sortFileMatches(matches)
return matches
}
// FileCandidates 筛选字体文件候选
// 入参: names 文件名列表, base 文件名提取函数
// 返回: []FileCandidate 字体文件候选列表
func FileCandidates(names []string, base func(string) string) []FileCandidate {
files := make([]FileCandidate, 0, len(names))
for _, name := range names {
if IsFontFileName(name) {
baseName := base(name)
normalized := NormalizeName(strings.TrimSuffix(baseName, path.Ext(baseName)))
files = append(files, FileCandidate{
Name: name,
base: baseName,
lowerBase: strings.ToLower(baseName),
filename: normalized,
normalized: normalized,
fold: strings.ToLower(name),
})
}
}
return files
}
// newPatternMatcher 创建字体文件匹配器
// 入参: pattern 匹配模式
// 返回: patternMatcher 字体文件匹配器
func newPatternMatcher(pattern string) patternMatcher {
matcher := patternMatcher{
pattern: pattern,
lowerPattern: strings.ToLower(pattern),
stem: patternStem(pattern),
}
if matcher.stem != "" {
for _, alias := range ExactCandidateNames(matcher.stem) {
alias = NormalizeName(alias)
if alias != "" {
matcher.aliases = append(matcher.aliases, alias)
}
}
}
return matcher
}
// newPatternMatchers 创建字体文件匹配器列表
// 入参: patterns 匹配模式列表
// 返回: []patternMatcher 字体文件匹配器列表
func newPatternMatchers(patterns []string) []patternMatcher {
matchers := make([]patternMatcher, 0, len(patterns))
for priority, pattern := range patterns {
matcher := newPatternMatcher(pattern)
matcher.priority = priority
matchers = append(matchers, matcher)
}
return matchers
}
// rankCandidate 获取字体文件候选匹配等级
// 入参: file 字体文件候选
// 返回: int 匹配等级
func (m patternMatcher) rankCandidate(file FileCandidate) int {
exactPath, _ := path.Match(m.pattern, file.base)
lowerPath, _ := path.Match(m.lowerPattern, file.lowerBase)
if m.stem == "" {
if exactPath || lowerPath {
return MatchFuzzy
}
return MatchNone
}
name := m.candidateName(file)
if name == m.stem {
return MatchExact
}
for _, alias := range m.aliases {
if name == alias {
return MatchExact
}
}
if fileKnownStyleSuffix(m.styleSuffixNormalized(name)) {
return MatchExact
}
if strings.HasPrefix(name, m.stem) {
return MatchPartial
}
for _, alias := range m.aliases {
if strings.HasPrefix(name, alias) {
return MatchPartial
}
}
if exactPath || lowerPath || strings.Contains(name, m.stem) {
return MatchFuzzy
}
for _, alias := range m.aliases {
if strings.Contains(name, alias) {
return MatchFuzzy
}
}
return MatchNone
}
// candidateName 保留同族内部名称的区分,只有无关联时使用调用方文件别名
// 入参: file 字体文件候选
// 返回: string 用于统一匹配名称与样式的规范名称
func (m patternMatcher) candidateName(file FileCandidate) string {
if len(file.names) == 0 || file.base == "" || m.stem == "" {
return file.normalized
}
for _, name := range file.names {
if strings.HasPrefix(name, m.stem) {
return file.normalized
}
for _, alias := range m.aliases {
if strings.HasPrefix(name, alias) {
return file.normalized
}
}
}
return file.filename
}
// styleSuffixNormalized 获取规范字体名称的样式后缀
// 入参: name 规范字体名称
// 返回: string 样式后缀
func (m patternMatcher) styleSuffixNormalized(name string) string {
if m.stem == "" || name == "" {
return ""
}
if strings.HasPrefix(name, m.stem) {
return strings.TrimPrefix(name, m.stem)
}
for _, alias := range m.aliases {
if strings.HasPrefix(name, alias) {
return strings.TrimPrefix(name, alias)
}
}
return ""
}
// appendFileMatch 追加字体文件匹配结果
// 入参: matches 匹配结果, seen 已匹配文件, matcher 匹配器, file 字体文件候选, rank 匹配等级, bold 是否粗体, italic 是否斜体
func appendFileMatch(matches *[]FileMatch, seen map[fileKey]int, matcher patternMatcher, file FileCandidate, rank int, bold, italic bool) {
if rank == MatchNone {
return
}
suffix := matcher.styleSuffixNormalized(matcher.candidateName(file))
if rank == MatchFuzzy {
suffix = matcher.styleSuffixNormalized(NormalizeName(file.base))
}
next := FileMatch{
Name: file.Name,
Face: file.Face,
Priority: matcher.priority,
Rank: rank,
styleRank: fileStyleRank(suffix, bold, italic),
sortName: file.lowerBase,
}
key := fileKey{file.fold, file.Face}
if index, ok := seen[key]; ok {
if fileMatchLess(next, (*matches)[index]) {
(*matches)[index] = next
}
return
}
seen[key] = len(*matches)
*matches = append(*matches, next)
}
// sortFileMatches 排序字体文件匹配结果
// 入参: matches 匹配结果
func sortFileMatches(matches []FileMatch) {
sort.SliceStable(matches, func(i, j int) bool {
return fileMatchLess(matches[i], matches[j])
})
}
// fileMatchLess 判断字体文件匹配结果优先级
// 入参: left 左侧匹配结果, right 右侧匹配结果
// 返回: bool 左侧是否优先
func fileMatchLess(left, right FileMatch) bool {
if left.Priority != right.Priority {
return left.Priority < right.Priority
}
if left.Rank != right.Rank {
return left.Rank < right.Rank
}
if left.styleRank != right.styleRank {
return left.styleRank < right.styleRank
}
return left.sortName < right.sortName
}
// FileMatchNames 获取字体文件匹配名称
// 入参: matches 匹配结果
// 返回: []string 字体文件列表
func FileMatchNames(matches []FileMatch) []string {
names := make([]string, 0, len(matches))
seen := make(map[string]bool, len(matches))
for _, match := range matches {
key := strings.ToLower(match.Name)
if !seen[key] {
seen[key] = true
names = append(names, match.Name)
}
}
return names
}
// fileStyleRank 获取字体文件样式匹配等级
// 入参: suffix 样式后缀, bold 是否粗体, italic 是否斜体
// 返回: int 样式匹配等级
func fileStyleRank(suffix string, bold, italic bool) int {
fileBold, fileItalic := fileStyleFromSuffix(suffix)
rank := 0
if fileBold != bold {
rank += 2
}
if fileItalic != italic {
rank += 2
}
if !bold && !italic && !fileBold && !fileItalic && suffix != "" && !fileRegularSuffix(suffix) {
rank++
}
return rank
}
// fileStyleFromSuffix 获取样式后缀对应的字体样式
// 入参: suffix 样式后缀
// 返回: bool 是否粗体, bool 是否斜体
func fileStyleFromSuffix(suffix string) (bool, bool) {
bold := suffix == "b" ||
suffix == "bd" ||
suffix == "bold" ||
suffix == "bi" ||
suffix == "bolditalic" ||
suffix == "boldoblique"
italic := suffix == "i" ||
suffix == "it" ||
suffix == "italic" ||
suffix == "oblique" ||
suffix == "bi" ||
suffix == "bolditalic" ||
suffix == "boldoblique"
return bold, italic
}
// fileRegularSuffix 判断是否为常规样式后缀
// 入参: suffix 样式后缀
// 返回: bool 是否为常规样式后缀
func fileRegularSuffix(suffix string) bool {
switch suffix {
case "r", "regular", "normal", "常规":
return true
default:
return false
}
}
// fileKnownStyleSuffix 判断是否为已知样式后缀
// 入参: suffix 样式后缀
// 返回: bool 是否为已知样式后缀
func fileKnownStyleSuffix(suffix string) bool {
switch suffix {
case "r", "regular", "normal", "常规",
"b", "bd", "bold",
"i", "it", "italic", "oblique",
"bi", "bolditalic", "boldoblique":
return true
default:
return false
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
// glyphPather 接收字体设计坐标中的轮廓指令
type glyphPather interface {
MoveTo(float64, float64)
LineTo(float64, float64)
QuadTo(float64, float64, float64, float64)
CubeTo(float64, float64, float64, float64, float64, float64)
Close()
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
@@ -33,35 +33,10 @@ type fontNameRecord struct {
Offset uint16
}
// appendFontFileNames 建立各字体的内部名称与文件别名匹配候选
// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称
// 返回: []fontFileCandidate 字体候选列表
func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate {
file := candidates[index]
replaced := false
for face, names := range faces {
file.face = face
file.names = make([]string, len(names))
for i, name := range names {
file.names[i] = fontNormalizeName(name)
}
for _, name := range file.names {
file.normalized = name
if !replaced {
candidates[index] = file
replaced = true
} else {
candidates = append(candidates, file)
}
}
}
return candidates
}
// fontFileNames 按字体索引读取字体或集合中各项的名称
// FileNames 按字体索引读取字体或集合中各项的名称
// 入参: file 字体数据
// 返回: [][]string 各字体名称及带样式的字体族名称
func fontFileNames(file io.ReaderAt) [][]string {
func FileNames(file io.ReaderAt) [][]string {
var header [12]byte
if _, err := file.ReadAt(header[:], 0); err != nil {
return nil
@@ -84,24 +59,6 @@ func fontFileNames(file io.ReaderAt) [][]string {
return faces
}
// fontCollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项
// 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体
// 返回: int 零起始字体索引
func fontCollectionIndex(data []byte, names []string, bold, italic bool) int {
return fontNameIndex(fontFileNames(bytes.NewReader(data)), names, bold, italic)
}
// fontNameIndex 按名称与样式选择字体索引,未匹配时保留首项
// 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体
// 返回: int 零起始字体索引
func fontNameIndex(faces [][]string, names []string, bold, italic bool) int {
candidates := appendFontFileNames([]fontFileCandidate{{}}, 0, faces)
if matches := fontFileMatches(candidates, names, bold, italic); len(matches) > 0 {
return matches[0].face
}
return 0
}
// fontFaceNames 读取指定字体表目录中的名称
// 入参: file 字体数据, offset 字体表目录偏移
// 返回: []string 字体名称及带样式的字体族名称
@@ -129,16 +86,23 @@ func fontFaceNames(file io.ReaderAt, offset int64) []string {
if err != nil {
return nil
}
return fontNamesFromTable(data)
return NamesFromTable(data)
}
return nil
}
// fontNamesFromTable 解析字体名称表
// NamesFromTable 解析字体名称表
// 入参: data 名称表数据
// 返回: []string 字体名称列表
func fontNamesFromTable(data []byte) []string {
func NamesFromTable(data []byte) []string {
records, values := fontNameValues(data)
return fontNamesFromValues(records, values)
}
// fontNamesFromValues 从已解码记录生成去重名称,保留原有顺序及样式匹配
// 入参: records 名称记录, values 已解码的名称文本
// 返回: []string 字体名称列表
func fontNamesFromValues(records []fontNameRecord, values map[[4]uint16]string) []string {
var names []string
seen := make(map[string]bool)
for _, record := range records {
@@ -154,7 +118,7 @@ func fontNamesFromTable(data []byte) []string {
default:
continue
}
names = appendFontName(names, seen, name)
names = AppendFontName(names, seen, name)
}
return names
}
@@ -178,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
values := make(map[[4]uint16]string)
for _, record := range records {
switch record.Name {
case 1, 2, 4, 6, 16, 17:
case 1, 2, 4, 5, 6, 8, 16, 17:
default:
continue
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"encoding/binary"
)
// FixDataAggressive 激进修复字体数据
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
// 入参: data 原始字体数据, fixCmap 是否修复cmap, fixName 是否修复name
// 返回: bool 是否修复, []byte 修复后数据, map[rune]uint16 字符映射, bool 是否缺失cmap, error 错误信息
func FixDataAggressive(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uint16, bool, error) {
if len(data) < 4 {
return false, data, nil, false, nil
}
tag := string(data[0:4])
u32Tag := binary.BigEndian.Uint32(data[0:4])
isTrueType := u32Tag == 0x00010000 || tag == "true"
isOpenType := tag == "OTTO"
isTTC := tag == "ttcf"
if isTTC {
return false, data, nil, false, nil
}
if isBareCFFData(data) {
otf, mapping, err := WrapCFFToOTF(data)
if err != nil {
return false, data, nil, false, err
}
return true, otf, mapping, false, nil
}
if !isTrueType && !isOpenType {
return false, data, nil, false, nil
}
fixed, newData, mapping, mc, err := fixTrueType(data, fixCmap, fixName)
return fixed, newData, mapping, mc, err
}
// SFNTData 保留字体原始表数据和时间,重新计算目录与校验和
// 入参: tables 原始字体表
// 返回: []byte 独立SFNT数据
func SFNTData(tables map[string][]byte) []byte {
data, _ := SerializeOTF(tables)
return data
}
// isBareCFFData 检查是否为CFF裸字体数据
// 入参: data 字体数据
// 返回: bool 是否为CFF裸数据
func isBareCFFData(data []byte) bool {
if len(data) < 4 {
return false
}
if data[0] != 1 || data[1] != 0 {
return false
}
hdrSize := int(data[2])
offSize := int(data[3])
return 4 <= hdrSize && hdrSize <= len(data) && 1 <= offSize && offSize <= 4
}
+460
View File
@@ -0,0 +1,460 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"encoding/binary"
"fmt"
"iter"
"maps"
"slices"
"sync"
"unicode/utf8"
"github.com/go-text/typesetting/font/opentype/tables"
)
// SFNT 保存独立字体表和基础度量,不包含绘图库对象
type SFNT struct {
Tables map[string][]byte
IsTrueType, IsCFF bool
cmaps []sfntCmap
units, count, horizontal, vertical uint16
reverseOnce sync.Once
reverse map[uint16][]rune
}
// sfntCmap 保存编码记录及字符映射,连续区间与共享子表不重复展开
type sfntCmap struct {
platform, encoding, format uint16
mapping map[rune]uint16
groups []byte
entries uint64
duplicate bool
}
// ParseSFNT 校验独立字体的必需表、度量范围及字符映射
// 入参: data 独立SFNT数据
// 返回: *SFNT 字体, error 格式错误
func ParseSFNT(data []byte) (*SFNT, error) {
table, err := FileTables(data, 0)
if err != nil {
return nil, err
}
return ParseSFNTTables(table)
}
// ParseSFNTTables 校验字体表并解析度量和字符映射,借用表数据且不修改其内容
// 入参: table 字体表
// 返回: *SFNT 字体, error 格式错误
func ParseSFNTTables(table map[string][]byte) (*SFNT, error) {
head, maxp, hhea := table["head"], table["maxp"], table["hhea"]
if len(head) != 54 || len(maxp) < 6 || len(hhea) < 36 {
return nil, fmt.Errorf("missing or truncated font metrics")
}
f := &SFNT{Tables: maps.Clone(table), IsTrueType: table["glyf"] != nil, IsCFF: table["CFF "] != nil}
f.units, f.count = binary.BigEndian.Uint16(head[18:]), binary.BigEndian.Uint16(maxp[4:])
f.horizontal = binary.BigEndian.Uint16(hhea[34:])
if f.units < 16 || f.units > 16384 || f.count == 0 || f.IsTrueType == f.IsCFF || !validSFNTMetrics(table["hmtx"], f.count, f.horizontal) {
return nil, fmt.Errorf("invalid font outlines or horizontal metrics")
}
if vhea := table["vhea"]; vhea != nil {
if len(vhea) < 36 {
return nil, fmt.Errorf("truncated vertical metrics header")
}
f.vertical = binary.BigEndian.Uint16(vhea[34:])
if !validSFNTMetrics(table["vmtx"], f.count, f.vertical) {
return nil, fmt.Errorf("invalid vertical metrics")
}
}
if f.IsTrueType {
format := binary.BigEndian.Uint16(head[50:])
if format > 1 || len(table["loca"]) < (int(f.count)+1)*(2+2*int(format)) {
return nil, fmt.Errorf("invalid glyph location table")
}
}
cmaps, err := readSFNTCmaps(table["cmap"])
if err != nil {
return nil, err
}
f.cmaps = cmaps
for _, cmap := range f.cmaps {
if cmap.duplicate {
continue
}
for _, glyph := range cmap.pairs() {
if glyph >= f.count {
return nil, fmt.Errorf("cmap glyph index out of range")
}
}
}
return f, nil
}
// validSFNTMetrics 校验完整度量记录和末尾边距数组的范围
// 入参: data 度量表, count 字形数, metrics 完整度量记录数
// 返回: bool 是否有效
func validSFNTMetrics(data []byte, count, metrics uint16) bool {
return metrics != 0 && metrics <= count && len(data) >= int(metrics)*4+int(count-metrics)*2
}
// readSFNTCmaps 校验各编码子表并保留原顺序,共享子表只解析一次
// 入参: data cmap表
// 返回: []sfntCmap 编码记录, error 表结构错误
func readSFNTCmaps(data []byte) ([]sfntCmap, error) {
data, err := NormalizeCmap(data)
if err != nil {
return nil, err
}
if _, _, err := tables.ParseCmap(data); err != nil {
return nil, err
}
count := int(binary.BigEndian.Uint16(data[2:]))
result := make([]sfntCmap, 0, count)
parsed := make(map[uint32]sfntCmap)
for i := range count {
record := data[4+8*i:]
offset := binary.BigEndian.Uint32(record[4:])
if uint64(offset)+2 > uint64(len(data)) {
return nil, fmt.Errorf("invalid cmap offset")
}
sub := data[offset:]
format := binary.BigEndian.Uint16(sub)
cmap, found := parsed[offset]
if !found {
cmap.mapping = make(map[rune]uint16)
var err error
switch format {
case 0:
parseCmapFormat0(sub, cmap.mapping)
case 4:
parseCmapFormat4(sub, cmap.mapping)
case 6:
parseCmapFormat6(sub, cmap.mapping)
case 12:
if len(sub) >= 16 {
length := uint64(binary.BigEndian.Uint32(sub[4:]))
count := uint64(binary.BigEndian.Uint32(sub[12:]))
if length >= 16 && length <= uint64(len(sub)) && count <= (length-16)/12 {
cmap.groups = sub[16 : 16+12*count]
for offset := 0; offset < len(cmap.groups); offset += 12 {
first := binary.BigEndian.Uint32(cmap.groups[offset:])
last := binary.BigEndian.Uint32(cmap.groups[offset+4:])
if first > last || offset != 0 && first <= binary.BigEndian.Uint32(cmap.groups[offset-8:]) {
cmap.groups = nil
parseCmapFormat12(sub, cmap.mapping)
break
}
}
if cmap.groups != nil {
cmap.mapping = nil
for range cmap.pairs() {
cmap.entries++
}
}
}
}
case 2:
cmap.mapping, err = parseCmapFormat2(sub)
case 8, 10, 13:
cmap.mapping, err = parseCmapExtended(sub)
case 14:
default:
return nil, fmt.Errorf("unsupported cmap format %d", format)
}
if err != nil {
return nil, err
}
parsed[offset] = cmap
}
cmap.platform, cmap.encoding, cmap.format, cmap.duplicate = binary.BigEndian.Uint16(record), binary.BigEndian.Uint16(record[2:]), format, found
result = append(result, cmap)
}
return result, nil
}
// pairs 按原映射返回字符与字形,连续编码段不展开存储
// 返回: iter.Seq2[rune, uint16] 非零有效字符映射
func (c sfntCmap) pairs() iter.Seq2[rune, uint16] {
return func(yield func(rune, uint16) bool) {
for char, glyph := range c.mapping {
if !yield(char, glyph) {
return
}
}
for offset := 0; offset+12 <= len(c.groups); offset += 12 {
group := c.groups[offset:]
first, last, glyph := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:]), binary.BigEndian.Uint32(group[8:])
if first > last || first > utf8.MaxRune || glyph > 65535 {
continue
}
end := min(uint64(last), uint64(first)+65535-uint64(glyph), uint64(utf8.MaxRune))
for char := uint64(first); char <= end; char++ {
id := uint16(uint64(glyph) + char - uint64(first))
if id != 0 && utf8.ValidRune(rune(char)) && !yield(rune(char), id) {
return
}
}
}
}
}
// lookup 查找原字符映射,连续编码段使用有序区间查询
// 入参: char 字符
// 返回: uint16 字形编号, bool 是否存在映射
func (c sfntCmap) lookup(char rune) (uint16, bool) {
if glyph, ok := c.mapping[char]; ok {
return glyph, true
}
if !utf8.ValidRune(char) {
return 0, false
}
low, high := 0, len(c.groups)/12
for low < high {
middle := low + (high-low)/2
group := c.groups[middle*12:]
first, last := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:])
if uint32(char) < first {
high = middle
} else if uint32(char) > last {
low = middle + 1
} else {
glyph := uint64(binary.BigEndian.Uint32(group[8:])) + uint64(uint32(char)-first)
return uint16(glyph), glyph > 0 && glyph <= 65535
}
}
return 0, false
}
// UnitsPerEm 返回字体设计单位
// 返回: uint16 每em单位数
func (f *SFNT) UnitsPerEm() uint16 { return f.units }
// NumGlyphs 返回字形总数
// 返回: uint16 字形数
func (f *SFNT) NumGlyphs() uint16 { return f.count }
// CmapEntries 返回各编码记录的映射项总数,共享子表按记录分别计数
// 返回: uint64 用于缓存预算的映射项数量
func (f *SFNT) CmapEntries() uint64 {
var count uint64
for _, cmap := range f.cmaps {
count += uint64(len(cmap.mapping)) + cmap.entries
}
return count
}
// GlyphIndex 按编码表顺序查找字符,不替换缺失字形
// 入参: char 字符
// 返回: uint16 字形编号
func (f *SFNT) GlyphIndex(char rune) uint16 {
for _, cmap := range f.cmaps {
if cmap.duplicate {
continue
}
if gid, ok := cmap.lookup(char); ok && gid != 0 {
return gid
}
}
return 0
}
// glyphCharacters 返回指向指定字形的全部字符,连续区间按需反查
// 入参: gid 字形编号
// 返回: []rune 排序后的字符
func (f *SFNT) glyphCharacters(gid uint16) []rune {
f.reverseOnce.Do(func() {
f.reverse = make(map[uint16][]rune)
seen := make(map[rune]bool)
for _, cmap := range f.cmaps {
if cmap.duplicate {
continue
}
for char, glyph := range cmap.mapping {
if glyph != 0 && !seen[char] && f.GlyphIndex(char) == glyph {
f.reverse[glyph] = append(f.reverse[glyph], char)
seen[char] = true
}
}
}
for _, chars := range f.reverse {
slices.Sort(chars)
}
})
if gid == 0 {
return nil
}
chars := slices.Clone(f.reverse[gid])
for _, cmap := range f.cmaps {
if cmap.duplicate {
continue
}
for offset := 0; offset+12 <= len(cmap.groups); offset += 12 {
group := cmap.groups[offset:]
first, last, glyph := binary.BigEndian.Uint32(group), binary.BigEndian.Uint32(group[4:]), binary.BigEndian.Uint32(group[8:])
if first > last || uint32(gid) < glyph {
continue
}
char := uint64(first) + uint64(uint32(gid)-glyph)
if char <= uint64(last) && char <= utf8.MaxRune && utf8.ValidRune(rune(char)) && f.GlyphIndex(rune(char)) == gid {
chars = append(chars, rune(char))
}
}
}
slices.Sort(chars)
return slices.Compact(chars)
}
// GlyphAdvance 返回水平字宽
// 入参: gid 字形编号
// 返回: uint16 字宽
func (f *SFNT) GlyphAdvance(gid uint16) uint16 {
if gid >= f.count {
return 0
}
return binary.BigEndian.Uint16(f.Tables["hmtx"][int(min(gid, f.horizontal-1))*4:])
}
// GlyphVerticalAdvance 返回纵向字距,无垂直度量时使用em高度
// 入参: gid 字形编号
// 返回: uint16 纵向字距
func (f *SFNT) GlyphVerticalAdvance(gid uint16) uint16 {
if f.vertical == 0 {
return f.units
}
if gid >= f.count {
return 0
}
return binary.BigEndian.Uint16(f.Tables["vmtx"][int(min(gid, f.vertical-1))*4:])
}
// sideBearing 返回指定方向的字形边距
// 入参: gid 字形编号, vertical 是否读取顶部边距
// 返回: int16 边距
func (f *SFNT) sideBearing(gid uint16, vertical bool) int16 {
data, count := f.Tables["hmtx"], f.horizontal
if vertical {
data, count = f.Tables["vmtx"], f.vertical
}
if count == 0 || gid >= f.count {
return 0
}
offset := int(gid)*4 + 2
if gid >= count {
offset = int(count)*4 + int(gid-count)*2
}
return int16(binary.BigEndian.Uint16(data[offset:]))
}
// VerticalMetrics 依据OS/2排版标志选择行度量,缺失时使用hhea
// 返回: uint16 上升高度, uint16 下降高度, uint16 行间隙
func (f *SFNT) VerticalMetrics() (uint16, uint16, uint16) {
hhea, os2 := f.Tables["hhea"], f.Tables["OS/2"]
a, d, gap := int(int16(binary.BigEndian.Uint16(hhea[4:]))), int(int16(binary.BigEndian.Uint16(hhea[6:]))), int(int16(binary.BigEndian.Uint16(hhea[8:])))
if len(os2) >= 78 {
if binary.BigEndian.Uint16(os2[62:])&0x80 != 0 {
ta, td := int(int16(binary.BigEndian.Uint16(os2[68:]))), int(int16(binary.BigEndian.Uint16(os2[70:])))
if ta > 0 && td < 0 {
a, d, gap = ta, td, int(int16(binary.BigEndian.Uint16(os2[72:])))
}
} else {
wa, wd := int(binary.BigEndian.Uint16(os2[74:])), int(binary.BigEndian.Uint16(os2[76:]))
if wa != 0 && wd != 0 {
gap, a, d = a-d+gap-wa-wd, wa, -wd
}
}
}
return uint16(max(0, a)), uint16(max(0, -d)), uint16(min(65535, max(0, gap)))
}
// Write 保留原始表数据并重新计算字体校验和
// 返回: []byte 独立字体
func (f *SFNT) Write() []byte { data, _ := SerializeOTF(f.Tables); return data }
// glyphDependencies 批量收集复合字形依赖,校验引用、记录长度和嵌套深度
// 入参: glyphs 字形编号
// 返回: []uint16 依赖字形, error 字形错误
func (f *SFNT) glyphDependencies(glyphs ...uint16) ([]uint16, error) {
heights, active := make(map[uint16]int), make(map[uint16]bool)
var result []uint16
var visit func(uint16) error
visit = func(id uint16) error {
if id >= f.count || active[id] || len(active) > 64 {
return fmt.Errorf("invalid composite glyph reference")
}
if height := heights[id]; height != 0 {
if len(active)+height > 65 {
return fmt.Errorf("invalid composite glyph reference")
}
return nil
}
heights[id], active[id] = 1, true
defer delete(active, id)
data, err := trueTypeGlyphData(f.Tables, id)
if err != nil {
return err
}
if len(data) == 0 {
return nil
}
if len(data) < 10 {
return fmt.Errorf("truncated glyph header")
}
if int16(binary.BigEndian.Uint16(data)) >= 0 {
return nil
}
for pos := 10; ; {
if len(data)-pos < 4 {
return fmt.Errorf("truncated composite glyph")
}
flags, child := binary.BigEndian.Uint16(data[pos:]), binary.BigEndian.Uint16(data[pos+2:])
pos += 6
if flags&1 != 0 {
pos += 2
}
switch flags & 0xc8 {
case 8:
pos += 2
case 0x40:
pos += 4
case 0x80:
pos += 8
case 0:
default:
return fmt.Errorf("invalid composite transform")
}
if pos > len(data) {
return fmt.Errorf("truncated composite transform")
}
if heights[child] == 0 {
result = append(result, child)
}
if err := visit(child); err != nil {
return err
}
heights[id] = max(heights[id], heights[child]+1)
if flags&0x20 == 0 {
break
}
}
return nil
}
for _, id := range glyphs {
if err := visit(id); err != nil {
return nil, err
}
}
return result, nil
}
+460
View File
@@ -0,0 +1,460 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"encoding/binary"
"fmt"
"maps"
"slices"
"github.com/go-text/typesetting/font/opentype/tables"
)
// Subsettable 判断默认资源后端能否无损裁剪该字体
// 入参: sfnt 已解析字体
// 返回: bool 是否支持裁剪
func Subsettable(sfnt *SFNT) bool {
if fontEmbeddingFlags(sfnt)&0x0100 != 0 || slices.ContainsFunc([]string{"COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) {
return false
}
if sfnt.IsCFF {
if sfnt.Tables["fvar"] != nil {
return false
}
font, err := readType2Font(sfnt.Tables["CFF "])
if err == nil {
return len(font.chars) == int(sfnt.NumGlyphs())
}
cid, err := readCIDSubsetFont(sfnt.Tables["CFF "])
return err == nil && len(cid.fonts[0].chars) == int(sfnt.NumGlyphs())
}
return sfnt.IsTrueType && (sfnt.Tables["fvar"] == nil || variableSFNTSubsettable(sfnt))
}
// fontEmbeddingFlags 读取OpenType的嵌入标志,未提供OS/2表时返回零
// 入参: sfnt 已解析字体
// 返回: uint16 嵌入标志
func fontEmbeddingFlags(sfnt *SFNT) uint16 {
if table := sfnt.Tables["OS/2"]; len(table) >= 10 {
return binary.BigEndian.Uint16(table[8:10])
}
return 0
}
// ValidateEmbedding 校验字体是否允许作为可编辑轮廓字体嵌入
// 入参: data 独立OpenType字体
// 返回: error 字体解析或嵌入限制
func ValidateEmbedding(data []byte) error {
sfnt, err := ParseSFNT(data)
if err != nil {
return err
}
flags := fontEmbeddingFlags(sfnt)
if flags&0x0200 != 0 || flags&0x000e != 0 && flags&0x0008 == 0 {
return fmt.Errorf("font does not permit editable embedding")
}
return nil
}
// SFNTName 获取字体中的名称
// 入参: sfnt 字体, names 名称类型,按顺序查找
// 返回: string 字体名称
func SFNTName(sfnt *SFNT, names ...uint16) string {
records, values := fontNameValues(sfnt.Tables["name"])
for _, name := range names {
for _, record := range records {
if record.Name != name {
continue
}
if value := values[[4]uint16{record.Platform, record.Encoding, record.Language, name}]; value != "" {
return value
}
}
}
return ""
}
// SubsetSFNT 按实际用字裁剪字体,保留字符映射、名称及度量
// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号
// 返回: []byte 字体子集, error 错误信息
func SubsetSFNT(data []byte, glyphs []uint16, mapped bool) ([]byte, error) {
data, err := DefaultCFF2Font(data)
if err != nil {
return nil, err
}
sfnt, err := ParseSFNT(data)
if err != nil {
return nil, err
}
if fontEmbeddingFlags(sfnt)&0x0100 != 0 {
return bytes.Clone(data), nil
}
if sfnt.IsCFF {
count, err := parseCFFAndCountGlyphs(sfnt.Tables["CFF "])
if err != nil {
return nil, err
}
if count != int(sfnt.NumGlyphs()) {
return nil, fmt.Errorf("CFF glyph count does not match maxp")
}
return subsetCFFFont(sfnt, glyphs, mapped)
}
if sfnt.Tables["fvar"] != nil {
return subsetVariableSFNTFont(sfnt, glyphs)
}
if mapped {
usage := newUsage()
for _, id := range glyphs {
usage.Glyphs[id] = true
for _, char := range sfnt.glyphCharacters(id) {
usage.Characters[char] = true
}
}
tables := make(map[string][]byte)
for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "vhea", "vmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} {
if table := sfnt.Tables[tag]; table != nil {
tables[tag] = table
}
}
fixed, err := SerializeOTF(tables)
if err != nil {
return nil, err
}
sfnt.Tables = tables
if subset := subsetSourceSFNT(sfnt, usage, len(fixed)); subset != nil {
return subset, nil
}
return fixed, nil
}
return subsetCompactSFNTFont(sfnt, glyphs)
}
// SubsetSourceSFNT 裁剪静态TrueType或CFF原有字体,保留字形编号、组合依赖、度量和提示指令
// 字形替换结果一并保留,集合、可变、彩色和未知表保持原样
// 入参: data 字体数据, usage 全包用字记录
// 返回: []byte 更小的字体子集,无确定收益时为空
func SubsetSourceSFNT(data []byte, usage *Usage) []byte {
if usage == nil || usage.Unsafe || len(usage.Characters)+len(usage.Glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) {
return nil
}
fontTables, err := FileTables(data, 0)
if err != nil {
return nil
}
for tag := range fontTables {
switch tag {
case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "CFF ", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "VORG", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "GSUB", "meta", "DSIG", "FFTM", "PCLT":
default:
return nil
}
}
sfnt, err := ParseSFNTTables(fontTables)
if err != nil {
return nil
}
return subsetSourceSFNT(sfnt, usage, len(data))
}
// subsetSourceSFNT 复用已校验的字体表裁剪字形,仅返回小于原编码的结果
// 入参: sfnt 已解析字体, usage 用字记录, size 原编码长度
// 返回: []byte 更小的字体子集,无确定收益时为空
func subsetSourceSFNT(sfnt *SFNT, usage *Usage, size int) []byte {
if usage == nil || usage.Unsafe || len(usage.Characters)+len(usage.Glyphs) == 0 || !sfnt.IsTrueType && !sfnt.IsCFF || fontEmbeddingFlags(sfnt)&0x0100 != 0 {
return nil
}
var unicodeTables []sfntCmap
for _, record := range sfnt.cmaps {
if record.format == 14 {
continue
}
if record.platform == 0 || record.platform == 3 && (record.encoding == 1 || record.encoding == 10) {
unicodeTables = append(unicodeTables, record)
}
}
if len(unicodeTables) == 0 {
return nil
}
glyphs := maps.Clone(usage.Glyphs)
if glyphs == nil {
glyphs = make(map[uint16]bool)
}
glyphs[0] = true
mapping := make(map[rune]uint16, len(usage.Characters))
for char := range usage.Characters {
id := sfnt.GlyphIndex(char)
for _, cmap := range unicodeTables {
if alternate, ok := cmap.lookup(char); ok && alternate != id {
return nil
}
}
glyphs[id] = true
if id != 0 {
mapping[char] = id
}
}
variations, ok := subsetCmapVariations(sfnt.Tables["cmap"], usage.Characters, glyphs, sfnt.NumGlyphs())
if !ok || !retainSFNTSubstitutions(sfnt.Tables["GSUB"], glyphs, sfnt.NumGlyphs()) {
return nil
}
if sfnt.IsCFF {
count, err := parseCFFAndCountGlyphs(sfnt.Tables["CFF "])
if err != nil || count != int(sfnt.NumGlyphs()) {
return nil
}
cff, err := subsetCFFCharstrings(sfnt.Tables["CFF "], glyphs)
if err != nil {
return nil
}
tables := maps.Clone(sfnt.Tables)
tables["CFF "] = cff
tables["cmap"] = buildCmapWithVariations(sfnt.NumGlyphs(), mapping, variations)
delete(tables, "DSIG")
result, err := SerializeOTF(tables)
if err != nil || len(result) >= size {
return nil
}
return result
}
for id := 0; id < int(sfnt.NumGlyphs()); id++ {
if _, err := trueTypeGlyphData(sfnt.Tables, uint16(id)); err != nil {
return nil
}
}
dependencies, err := sfnt.glyphDependencies(slices.Collect(maps.Keys(glyphs))...)
if err != nil {
return nil
}
for _, dependency := range dependencies {
glyphs[dependency] = true
}
glyfSize := 0
for id, retained := range glyphs {
if retained {
data, _ := trueTypeGlyphData(sfnt.Tables, id)
glyfSize += len(data) + len(data)%2
}
}
locaFormat := binary.BigEndian.Uint16(sfnt.Tables["head"][50:])
glyf := make([]byte, 0, glyfSize)
loca := make([]byte, 0, (int(sfnt.NumGlyphs())+1)*(2+2*int(locaFormat)))
for id := 0; id <= int(sfnt.NumGlyphs()); id++ {
if locaFormat == 0 {
loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2))
} else {
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
}
if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] {
data, _ := trueTypeGlyphData(sfnt.Tables, uint16(id))
glyf = append(glyf, data...)
if len(glyf)%2 != 0 {
glyf = append(glyf, 0)
}
}
}
tables := maps.Clone(sfnt.Tables)
tables["glyf"], tables["loca"] = glyf, loca
tables["cmap"] = buildCmapWithVariations(sfnt.NumGlyphs(), mapping, variations)
tables["head"] = bytes.Clone(tables["head"])
clear(tables["head"][8:12])
delete(tables, "DSIG")
result, err := SerializeOTF(tables)
if err != nil || len(result) >= size {
return nil
}
return result
}
// retainSFNTSubstitutions 保留全部替换结果,使原字形替换表在裁剪后仍可使用
// 入参: data 替换表, glyphs 待保留字形, count 字形总数
// 返回: bool 替换表及结果编号是否有效
func retainSFNTSubstitutions(data []byte, glyphs map[uint16]bool, count uint16) bool {
if len(data) == 0 {
return true
}
if len(data) < 10 || binary.BigEndian.Uint16(data) != 1 || binary.BigEndian.Uint16(data[2:]) > 1 {
return false
}
if binary.BigEndian.Uint16(data[2:]) == 1 && (len(data) < 14 || binary.BigEndian.Uint32(data[10:]) != 0) {
return false
}
layout, _, err := tables.ParseLayout(data)
if err != nil {
return false
}
for _, lookup := range layout.LookupList.Lookups {
subtables, err := lookup.AsGSUBLookups()
if err != nil {
return false
}
for _, subtable := range subtables {
if extension, ok := subtable.(tables.ExtensionSubs); ok {
if extension.ExtensionOffset < 8 || uint64(extension.ExtensionOffset) > uint64(len(extension.RawData)) {
return false
}
subtable, err = extension.Resolve()
if err != nil {
return false
}
}
if !retainSFNTSubstitution(subtable, glyphs, count) {
return false
}
}
}
return true
}
// retainSFNTSubstitution 收集替换子表的输出字形,上下文替换的结果由被引用的查找表提供
// 入参: subtable 替换子表, glyphs 待保留字形, count 字形总数
// 返回: bool 子表类型及输出字形是否有效
func retainSFNTSubstitution(subtable tables.GSUBLookup, glyphs map[uint16]bool, count uint16) bool {
var outputs []tables.GlyphID
switch subtable := subtable.(type) {
case tables.SingleSubs:
switch data := subtable.Data.(type) {
case tables.SingleSubstData1:
for id := uint16(0); id < count; id++ {
if _, ok := data.Coverage.Index(tables.GlyphID(id)); ok {
outputs = append(outputs, tables.GlyphID(id+uint16(data.DeltaGlyphID)))
}
}
case tables.SingleSubstData2:
outputs = data.SubstituteGlyphIDs
default:
return false
}
case tables.MultipleSubs:
for _, sequence := range subtable.Sequences {
outputs = append(outputs, sequence.SubstituteGlyphIDs...)
}
case tables.AlternateSubs:
for _, set := range subtable.AlternateSets {
outputs = append(outputs, set.AlternateGlyphIDs...)
}
case tables.LigatureSubs:
for _, set := range subtable.LigatureSets {
for _, ligature := range set.Ligatures {
outputs = append(outputs, ligature.LigatureGlyph)
}
}
case tables.ReverseChainSingleSubs:
outputs = subtable.SubstituteGlyphIDs
case tables.ContextualSubs, tables.ChainedContextualSubs:
default:
return false
}
for _, id := range outputs {
if uint16(id) >= count {
return false
}
glyphs[uint16(id)] = true
}
return true
}
// subsetCompactSFNTFont 重排新建静态字体的字形,同步复合引用、字符映射和度量
// 入参: sfnt 字体, glyphs 使用字形
// 返回: []byte 紧凑字体子集, error 字形数据错误
func subsetCompactSFNTFont(sfnt *SFNT, glyphs []uint16) ([]byte, error) {
ids := []uint16{0}
remap := map[uint16]uint16{0: 0}
for _, id := range glyphs {
if id >= sfnt.NumGlyphs() {
return nil, fmt.Errorf("font glyph index out of range")
}
if _, ok := remap[id]; !ok {
remap[id] = uint16(len(ids))
ids = append(ids, id)
}
}
dependencies, err := sfnt.glyphDependencies(ids...)
if err != nil {
return nil, err
}
for _, id := range dependencies {
if _, ok := remap[id]; !ok {
remap[id] = uint16(len(ids))
ids = append(ids, id)
}
}
table := make(map[string][]byte)
for _, tag := range []string{"head", "hhea", "maxp", "OS/2", "post", "name", "cvt ", "fpgm", "prep", "gasp", "vhea"} {
if data := sfnt.Tables[tag]; data != nil {
table[tag] = bytes.Clone(data)
}
}
var glyf, loca, hmtx, vmtx []byte
mapping := make(map[rune]uint16)
for _, id := range ids {
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
data, err := trueTypeGlyphData(sfnt.Tables, id)
if err != nil {
return nil, err
}
data = bytes.Clone(data)
if len(data) >= 10 && int16(binary.BigEndian.Uint16(data)) < 0 {
for pos := 10; ; {
flags := binary.BigEndian.Uint16(data[pos:])
child := binary.BigEndian.Uint16(data[pos+2:])
binary.BigEndian.PutUint16(data[pos+2:], remap[child])
pos += 6
if flags&1 != 0 {
pos += 2
}
switch flags & 0xc8 {
case 8:
pos += 2
case 0x40:
pos += 4
case 0x80:
pos += 8
}
if flags&0x20 == 0 {
break
}
}
}
glyf = append(glyf, data...)
for len(glyf)%4 != 0 {
glyf = append(glyf, 0)
}
hmtx = binary.BigEndian.AppendUint16(hmtx, sfnt.GlyphAdvance(id))
hmtx = binary.BigEndian.AppendUint16(hmtx, uint16(sfnt.sideBearing(id, false)))
if sfnt.vertical != 0 {
vmtx = binary.BigEndian.AppendUint16(vmtx, sfnt.GlyphVerticalAdvance(id))
vmtx = binary.BigEndian.AppendUint16(vmtx, uint16(sfnt.sideBearing(id, true)))
}
for _, char := range sfnt.glyphCharacters(id) {
mapping[char] = remap[id]
}
}
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
table["glyf"], table["loca"], table["hmtx"] = glyf, loca, hmtx
table["cmap"] = BuildCmapTable(uint16(len(ids)), mapping)
binary.BigEndian.PutUint16(table["head"][50:], 1)
binary.BigEndian.PutUint16(table["hhea"][34:], uint16(len(ids)))
binary.BigEndian.PutUint16(table["maxp"][4:], uint16(len(ids)))
if len(table["post"]) >= 32 {
table["post"] = table["post"][:32]
binary.BigEndian.PutUint32(table["post"], 0x30000)
}
if sfnt.vertical != 0 {
table["vmtx"] = vmtx
binary.BigEndian.PutUint16(table["vhea"][34:], uint16(len(ids)))
}
return SerializeOTF(table)
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"context"
@@ -23,14 +23,14 @@ import (
"github.com/xiaoqidun/pdfgo"
)
// symbolFontProgram 为标准符号字体补齐Unicode映射,保留字形编号和轮廓
// SymbolProgram 为标准符号字体补齐Unicode映射,保留字形编号和轮廓
// 入参: ctx 取消上下文, data 独立字体数据, name 标准字体名,为空时从字体名称表识别
// 返回: []byte 字体数据, map[string]uint16 标准字形编号,不适用时为空, error 映射或取消错误
func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, map[string]uint16, error) {
func SymbolProgram(ctx context.Context, data []byte, name string) ([]byte, map[string]uint16, error) {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
tables, err := fontFileTables(data, 0)
tables, err := FileTables(data, 0)
if err != nil {
return data, nil, nil
}
@@ -57,7 +57,7 @@ func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, m
if err != nil {
return nil, nil, err
}
original := parseCmapMappings(tables["cmap"])
original := ParseCmapMappings(tables["cmap"])
mapping := make(map[rune]uint16)
names := make(map[string]uint16)
for _, glyph := range glyphs {
@@ -90,8 +90,8 @@ func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, m
if !legacy || len(mapping) == 0 {
return data, names, nil
}
tables["cmap"] = buildCmapTable(0, mapping)
program, err := serializeOTF(tables)
tables["cmap"] = BuildCmapTable(0, mapping)
program, err := SerializeOTF(tables)
return program, names, err
}
@@ -99,8 +99,8 @@ func symbolFontProgram(ctx context.Context, data []byte, name string) ([]byte, m
// 入参: data 名称表数据
// 返回: string 标准字体名,无法识别时为空
func symbolFontName(data []byte) string {
for _, name := range fontNamesFromTable(data) {
switch fontNormalizeName(name) {
for _, name := range NamesFromTable(data) {
switch NormalizeName(name) {
case "symbol", "symbolmt", "symbolregular":
return "Symbol"
case "zapfdingbats", "zapfdingbatsregular", "itczapfdingbats":
+46 -11
View File
@@ -12,19 +12,50 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
"encoding/binary"
)
// NormalizeOS2 去除已知版本OS/2表的冗余零尾部,保留所有标准字段
// 入参: data 原始表数据
// 返回: []byte 规范化表数据,未知版本、截断或非零尾部保持原样
func NormalizeOS2(data []byte) []byte {
if len(data) < 2 {
return data
}
var size int
switch binary.BigEndian.Uint16(data) {
case 0:
size = 78
case 1:
size = 86
case 2, 3, 4:
size = 96
case 5:
size = 100
default:
return data
}
if len(data) <= size {
return data
}
for _, value := range data[size:] {
if value != 0 {
return data
}
}
return data[:size]
}
// fixTrueType 尝试修复TrueType/OpenType字体数据
// 入参: data 原始字体数据, fixCmap 是否修复cmap, fixName 是否修复name
// 返回: bool 是否修复, []byte 修复后数据, map[rune]uint16 字符映射, bool 是否缺失cmap, error 错误信息
func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uint16, bool, error) {
original := data
data, err := defaultCFF2Font(data)
data, err := DefaultCFF2Font(data)
if err != nil {
return false, original, nil, false, err
}
@@ -58,7 +89,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
}
if existingTables["CFF "] != nil {
isCFFSfnt = true
sanitized, err := sanitizeCFF(existingTables["CFF "])
sanitized, err := SanitizeCFF(existingTables["CFF "])
if err != nil {
return false, data, nil, false, err
}
@@ -66,7 +97,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
if head := existingTables["head"]; len(head) >= 20 {
units = binary.BigEndian.Uint16(head[18:20])
}
normalized, err := normalizeCFFCharstringsAt(sanitized, units)
normalized, err := NormalizeCFFCharstringsAt(sanitized, units)
if err != nil {
return false, data, nil, false, err
}
@@ -75,7 +106,7 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
malformedDirectory = true
}
}
cmap, err := normalizeCmap(existingTables["cmap"])
cmap, err := NormalizeCmap(existingTables["cmap"])
if err != nil {
return false, data, nil, false, err
}
@@ -83,6 +114,10 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
existingTables["cmap"] = cmap
malformedDirectory = true
}
if os2 := NormalizeOS2(existingTables["OS/2"]); len(os2) != len(existingTables["OS/2"]) {
existingTables["OS/2"] = os2
malformedDirectory = true
}
missingHead := existingTables["head"] == nil
if !missingHead {
head := existingTables["head"]
@@ -193,12 +228,12 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
descender = int16(binary.BigEndian.Uint16(hhea[6:8]))
}
if missingOS2 {
newTables["OS/2"] = buildOS2TableWithMetrics(ascender, descender)
newTables["OS/2"] = BuildOS2TableWithMetrics(ascender, descender)
}
var mapping map[rune]uint16
if fixCmap {
if !missingCmap {
mapping = parseCmapMappings(newTables["cmap"])
mapping = ParseCmapMappings(newTables["cmap"])
}
if len(mapping) == 0 && isCFFSfnt {
mapping = getCmapFromCFF(newTables["CFF "], int(numGlyphs))
@@ -206,16 +241,16 @@ func fixTrueType(data []byte, fixCmap, fixName bool) (bool, []byte, map[rune]uin
if len(mapping) == 0 {
mapping = make(map[rune]uint16)
}
addPackedGlyphMapping(mapping, numGlyphs)
newTables["cmap"] = buildCmapTable(numGlyphs, mapping)
AddPackedGlyphMapping(mapping, numGlyphs)
newTables["cmap"] = BuildCmapTable(numGlyphs, mapping)
}
if missingName && fixName {
newTables["name"] = buildNameTable()
}
if missingPost {
newTables["post"] = buildPostTable()
newTables["post"] = BuildPostTable()
}
finalData, err := serializeOTF(newTables)
finalData, err := SerializeOTF(newTables)
if err != nil {
return false, data, nil, missingCmap, err
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
@@ -41,8 +41,8 @@ var type1StandardNames = func() [256]string {
return names
}()
// type1Program 保存解密后的Type1字形与子程序
type type1Program struct {
// Type1Program 保存解密后的Type1字形与子程序
type Type1Program struct {
glyphs []type1Glyph
subrs [][]byte
lenIV int
@@ -63,7 +63,7 @@ type type1Scanner struct {
// type1Outline 保存字形指令的操作数栈及轮廓位置
type type1Outline struct {
program *type1Program
program *Type1Program
steps *int
active map[string]bool
args []float64
@@ -84,24 +84,24 @@ type type1Outline struct {
path bytes.Buffer
}
// parseType1Program 读取Type1字体的字形程序及子程序
// ParseType1Program 读取Type1字体的字形程序及子程序
// 入参: data PFA或PFB字体数据
// 返回: type1Program 字形和子程序, error 错误信息
func parseType1Program(data []byte) (type1Program, error) {
// 返回: Type1Program 字形和子程序, error 错误信息
func ParseType1Program(data []byte) (Type1Program, error) {
var err error
data, err = flattenType1(data)
if err != nil {
return type1Program{}, err
return Type1Program{}, err
}
matrix, start, err := readType1Header(data)
if err != nil {
return type1Program{}, err
return Type1Program{}, err
}
for start < len(data) && type1Space(data[start]) {
start++
}
if start == len(data) {
return type1Program{}, fmt.Errorf("empty Type1 eexec data")
return Type1Program{}, fmt.Errorf("empty Type1 eexec data")
}
encrypted := data[start:]
if type1HexEncoded(encrypted) {
@@ -121,15 +121,15 @@ func parseType1Program(data []byte) (type1Program, error) {
}
encrypted = make([]byte, hex.DecodedLen(len(digits)))
if _, err := hex.Decode(encrypted, digits); err != nil {
return type1Program{}, fmt.Errorf("invalid Type1 eexec encoding: %w", err)
return Type1Program{}, fmt.Errorf("invalid Type1 eexec encoding: %w", err)
}
}
if len(encrypted) < 4 {
return type1Program{}, fmt.Errorf("invalid Type1 eexec data")
return Type1Program{}, fmt.Errorf("invalid Type1 eexec data")
}
decrypted := type1Decrypt(encrypted, 55665)[4:]
scanner := type1Scanner{data: decrypted}
program := type1Program{lenIV: 4, matrix: matrix}
program := Type1Program{lenIV: 4, matrix: matrix}
section := ""
for {
token := scanner.next()
@@ -140,7 +140,7 @@ func parseType1Program(data []byte) (type1Program, error) {
case "/lenIV":
value, err := strconv.Atoi(scanner.next())
if err != nil || value < -1 {
return type1Program{}, fmt.Errorf("invalid Type1 lenIV")
return Type1Program{}, fmt.Errorf("invalid Type1 lenIV")
}
program.lenIV = value
case "/Subrs":
@@ -157,18 +157,18 @@ func parseType1Program(data []byte) (type1Program, error) {
}
index, err := strconv.Atoi(scanner.next())
if err != nil || index < 0 || index > len(decrypted) {
return type1Program{}, fmt.Errorf("invalid Type1 subroutine index")
return Type1Program{}, fmt.Errorf("invalid Type1 subroutine index")
}
length, err := strconv.Atoi(scanner.next())
if err != nil {
return type1Program{}, fmt.Errorf("invalid Type1 subroutine length")
return Type1Program{}, fmt.Errorf("invalid Type1 subroutine length")
}
if !type1BinaryStart(scanner.next()) {
return type1Program{}, fmt.Errorf("missing Type1 subroutine data")
return Type1Program{}, fmt.Errorf("missing Type1 subroutine data")
}
raw, err := scanner.binary(length)
if err != nil {
return type1Program{}, err
return Type1Program{}, err
}
for len(program.subrs) <= index {
program.subrs = append(program.subrs, nil)
@@ -185,33 +185,91 @@ func parseType1Program(data []byte) (type1Program, error) {
continue
}
if !type1BinaryStart(scanner.next()) {
return type1Program{}, fmt.Errorf("missing Type1 glyph data")
return Type1Program{}, fmt.Errorf("missing Type1 glyph data")
}
raw, err := scanner.binary(length)
if err != nil {
return type1Program{}, err
return Type1Program{}, err
}
program.glyphs = append(program.glyphs, type1Glyph{name: name, data: raw})
}
}
if len(program.glyphs) == 0 {
return type1Program{}, fmt.Errorf("Type1 font has no glyphs")
return Type1Program{}, fmt.Errorf("Type1 font has no glyphs")
}
for index := range program.subrs {
if program.subrs[index] == nil {
continue
}
program.subrs[index], err = type1CharString(program.subrs[index], program.lenIV)
if err != nil {
return type1Program{}, err
return Type1Program{}, err
}
}
for index := range program.glyphs {
program.glyphs[index].data, err = type1CharString(program.glyphs[index].data, program.lenIV)
if err != nil {
return type1Program{}, fmt.Errorf("Type1 glyph %s: %w", program.glyphs[index].name, err)
return Type1Program{}, fmt.Errorf("Type1 glyph %s: %w", program.glyphs[index].name, err)
}
}
return program, nil
}
// NormalizePDFType1 修复完整加密分段之后的PFB明文尾部,不补齐加密或字形数据
// 入参: data 原始PFB程序
// 返回: []byte 原程序或独立修复容器, bool 是否修复, error 容器或程序错误
func NormalizePDFType1(data []byte) ([]byte, bool, error) {
if len(data) < 6 || data[0] != 0x80 {
return data, false, nil
}
binarySeen := false
for offset := 0; offset+2 <= len(data) && data[offset] == 0x80; {
kind := data[offset+1]
if kind == 3 {
return data, false, nil
}
if offset+6 > len(data) || kind != 1 && kind != 2 {
return data, false, fmt.Errorf("incomplete PFB segment header")
}
length := uint64(binary.LittleEndian.Uint32(data[offset+2:]))
remaining := len(data) - offset - 6
if binarySeen && kind == 1 && remaining == 0 {
result := append(bytes.Clone(data[:offset]), 0x80, 3)
if _, err := ParseType1Program(result); err != nil {
return nil, false, err
}
return result, true, nil
}
if length > uint64(remaining) {
return nil, false, fmt.Errorf("incomplete PFB encrypted or text segment")
}
if binarySeen && kind == 1 && length == 0 {
tail := data[offset+6:]
if len(tail) < 2 || !bytes.Equal(tail[len(tail)-2:], []byte{0x80, 3}) {
return data, false, fmt.Errorf("unbounded PFB text trailer")
}
text := bytes.TrimSpace(tail[:len(tail)-2])
if !bytes.HasSuffix(text, []byte("cleartomark")) {
return data, false, fmt.Errorf("PFB trailer lacks cleartomark")
}
for _, b := range bytes.TrimSuffix(text, []byte("cleartomark")) {
if b != '0' && !type1Space(b) {
return data, false, fmt.Errorf("unexpected PFB text trailer")
}
}
result := bytes.Clone(data)
binary.LittleEndian.PutUint32(result[offset+2:], uint32(len(tail)-2))
if _, err := ParseType1Program(result); err != nil {
return nil, false, err
}
return result, true, nil
}
binarySeen = binarySeen || kind == 2
offset += 6 + int(length)
}
return data, false, nil
}
// readType1Header 读取明文字体矩阵及加密数据起点
// 入参: data Type1程序
// 返回: []float64 字体矩阵, int 加密起点, error 头部错误
@@ -426,7 +484,7 @@ func type1BinaryStart(token string) bool {
// outline 将Type1字形指令转换为保留三次曲线的Type2程序
// 入参: glyph 字形数据
// 返回: []byte Type2字形程序, error 错误信息
func (p *type1Program) outline(glyph type1Glyph) ([]byte, error) {
func (p *Type1Program) outline(glyph type1Glyph) ([]byte, error) {
state := type1Outline{program: p, active: map[string]bool{glyph.name: true}}
ended, err := state.run(glyph.data, 0)
if err != nil {
@@ -840,10 +898,10 @@ func encodeType2Number(target *bytes.Buffer, value float64) error {
return nil
}
// toCFF 将Type1字形转换为保持三次轮廓的CFF字体
// ToCFF 将Type1字形转换为保持三次轮廓的CFF字体
// 入参: name 字体名称
// 返回: []byte CFF数据, error 错误信息
func (p *type1Program) toCFF(name string) ([]byte, error) {
func (p *Type1Program) ToCFF(name string) ([]byte, error) {
if len(p.glyphs) > math.MaxUint16 || name == "" {
return nil, fmt.Errorf("invalid Type1 font identity")
}
@@ -907,9 +965,9 @@ func (p *type1Program) toCFF(name string) ([]byte, error) {
return data, nil
}
// glyphIDs 返回Type1名称到转换后字形编号的对应关系
// GlyphIDs 返回Type1名称到转换后字形编号的对应关系
// 返回: map[string]uint16 字形编号
func (p *type1Program) glyphIDs() map[string]uint16 {
func (p *Type1Program) GlyphIDs() map[string]uint16 {
ids := map[string]uint16{".notdef": 0}
next := uint16(1)
for _, glyph := range p.glyphs {
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
@@ -30,6 +30,7 @@ type type2Font struct {
names map[string]int
nominal, def float64
seed uint64
cid bool
}
// type2State 按Type2规范保存跨子程序共享的执行状态
@@ -54,6 +55,9 @@ type type2State struct {
output *bytes.Buffer
seed uint64
steps *int
dependencies map[uint16]bool
localUsage []bool
globalUsage []bool
}
// readType2Index 读取并校验CFF索引的全部偏移,不复制程序字节
@@ -335,13 +339,18 @@ func (s *type2State) run(data []byte, depth int) (bool, error) {
value := s.args[len(s.args)-1]
s.args = s.args[:len(s.args)-1]
subrs := s.font.locals
usage := s.localUsage
if op == 29 {
subrs = s.font.globals
usage = s.globalUsage
}
value += float64(cffSubrBias(len(subrs)))
if value < 0 || value >= float64(len(subrs)) || value != math.Trunc(value) {
return false, fmt.Errorf("invalid Type2 subroutine number")
}
if usage != nil {
usage[int(value)] = true
}
ended, err := s.run(subrs[int(value)], depth+1)
if ended || err != nil {
return ended, err
@@ -481,6 +490,9 @@ func (s *type2State) run(data []byte, depth int) (bool, error) {
// 入参: code 标准字符码, x 横向偏移, y 纵向偏移
// 返回: error 组件或指令错误
func (s *type2State) appendComponent(code, x, y float64) error {
if s.font.cid {
return fmt.Errorf("CID CFF cannot use StandardEncoding components")
}
if code < 0 || code > 255 || code != math.Trunc(code) || type1StandardNames[int(code)] == "" {
return fmt.Errorf("invalid Type2 component code")
}
@@ -499,7 +511,10 @@ func (s *type2State) appendComponent(code, x, y float64) error {
if !ok {
return fmt.Errorf("missing Type2 component %s", name)
}
child := type2State{font: s.font, matrix: s.matrix, width: s.font.def, component: true, output: s.output, originX: x, originY: y, outX: s.outX, outY: s.outY, seed: s.font.seed + uint64(gid), steps: s.steps, stripHints: s.stripHints}
if s.dependencies != nil {
s.dependencies[uint16(gid)] = true
}
child := type2State{font: s.font, matrix: s.matrix, width: s.font.def, component: true, output: s.output, originX: x, originY: y, outX: s.outX, outY: s.outY, seed: s.font.seed + uint64(gid), steps: s.steps, stripHints: s.stripHints, dependencies: s.dependencies, localUsage: s.localUsage, globalUsage: s.globalUsage}
_, err := child.run(s.font.chars[gid], 0)
if err != nil {
return fmt.Errorf("invalid Type2 component %s: %w", name, err)
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"encoding/binary"
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"fmt"
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"fmt"
@@ -27,6 +27,9 @@ func (s *type2State) calculate(op int) error {
count := 0
switch op {
case 1223:
if s.dependencies != nil {
return fmt.Errorf("random Type2 program cannot be subset safely")
}
s.seed = uint64(uint32(s.seed*1664525 + 1013904223))
return s.push(float64(s.seed+1) / 4294967296)
case 1205, 1209, 1214, 1218, 1221, 1226, 1227, 1229:
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
// Usage 指定子集需要保留的字符、字形及引用是否存在不确定性
type Usage struct {
Characters map[rune]bool
Glyphs map[uint16]bool
Unsafe bool
}
// newUsage 创建具有可写字符和字形集合的子集输入
// 返回: *Usage 用字信息
func newUsage() *Usage {
return &Usage{Characters: make(map[rune]bool), Glyphs: make(map[uint16]bool)}
}
+187 -85
View File
@@ -12,46 +12,42 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"bytes"
"encoding/binary"
"fmt"
"math"
"sort"
)
// otfTableRecord OTF表记录结构
// 字段: tag 标签, checksum 校验和, offset 偏移, length 长度, data 数据
type otfTableRecord struct {
tag string
checksum uint32
offset int
length int
data []byte
// finite 判断数值是否有限
// 入参: value 数值
// 返回: bool 是否有限
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// extractCollectionFont 提取集合中的单个OpenType字体,保留表内容并重新计算校验和
// ExtractCollectionFont 提取集合中的单个OpenType字体,保留表内容并重新计算校验和
// 入参: data 集合数据, index 字体索引
// 返回: []byte 单字体数据, error 错误信息
func extractCollectionFont(data []byte, index int) ([]byte, error) {
tables, err := fontFileTables(data, index)
func ExtractCollectionFont(data []byte, index int) ([]byte, error) {
tables, err := FileTables(data, index)
if err != nil {
return nil, err
}
if len(tables["head"]) < 12 {
return nil, fmt.Errorf("invalid head font table")
}
tables["head"] = bytes.Clone(tables["head"])
clear(tables["head"][8:12])
return serializeOTF(tables)
return SerializeOTF(tables)
}
// fontFileTables 读取单字体或集合指定项的表目录,不复制表数据
// FileTables 读取单字体或集合指定项的表目录,不复制表数据
// 入参: data 字体数据, index 字体索引
// 返回: map[string][]byte 字体表, error 错误信息
func fontFileTables(data []byte, index int) (map[string][]byte, error) {
count, err := fontFileCount(data)
func FileTables(data []byte, index int) (map[string][]byte, error) {
count, err := FileCount(data)
if err != nil {
return nil, err
}
@@ -91,23 +87,127 @@ func fontFileTables(data []byte, index int) (map[string][]byte, error) {
return tables, nil
}
// NormalizePDFProgram 修复可确定的PDF内嵌SFNT封装,不补造TrueType轮廓或字形编号
// 入参: data 原始字体程序
// 返回: []byte 原程序或独立修复程序, bool 是否修复, error 无法确认的目录或度量错误
func NormalizePDFProgram(data []byte) ([]byte, bool, error) {
if len(data) < 12 || string(data[:4]) != "OTTO" && string(data[:4]) != "\x00\x01\x00\x00" && string(data[:4]) != "true" {
return data, false, nil
}
count := int(binary.BigEndian.Uint16(data[4:6]))
if count == 0 || count > (len(data)-12)/16 {
return nil, false, fmt.Errorf("unrecoverable PDF font directory size")
}
first := len(data)
for n := 0; n < count; n++ {
record := data[12+n*16:]
start, size := uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
if size != 0 && start >= 12 && start+size <= uint64(len(data)) {
first = min(first, int(start))
}
}
changed := false
if first < 12+count*16 {
if (first-12)%16 != 0 {
return nil, false, fmt.Errorf("ambiguous PDF font directory overlap")
}
actual := (first - 12) / 16
for n := 0; n < count; n++ {
record := data[12+n*16:]
printable := true
for _, b := range record[:4] {
printable = printable && b >= 32 && b <= 126
}
if printable != (n < actual) {
return nil, false, fmt.Errorf("ambiguous PDF font table count")
}
if n < actual {
start, size := uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
if size != 0 && (start < uint64(first) || start+size > uint64(len(data))) {
return nil, false, fmt.Errorf("incomplete PDF font table")
}
}
}
data = bytes.Clone(data)
binary.BigEndian.PutUint16(data[4:6], uint16(actual))
changed = true
}
tables, err := FileTables(data, 0)
if err != nil {
return nil, false, err
}
if head := tables["head"]; len(head) > 54 && len(head) <= 57 {
tables["head"] = head[:54]
changed = true
}
if hhea := tables["hhea"]; len(hhea) > 36 && len(hhea) <= 39 && binary.BigEndian.Uint32(hhea) == 0x00010000 {
tables["hhea"] = hhea[:36]
changed = true
}
if maxp := tables["maxp"]; tables["glyf"] != nil && len(maxp) > 32 && len(maxp) <= 35 && binary.BigEndian.Uint32(maxp) == 0x00010000 {
tables["maxp"] = maxp[:32]
changed = true
}
if post := tables["post"]; len(post) > 32 && binary.BigEndian.Uint32(post) == 0x00030000 {
tables["post"] = post[:32]
changed = true
}
if cvt := tables["cvt "]; len(cvt)%2 != 0 && cvt[len(cvt)-1] == 0 {
tables["cvt "] = cvt[:len(cvt)-1]
changed = true
}
if len(tables["CFF "]) != 0 && len(tables["head"]) == 0 && len(tables["maxp"]) == 0 && len(tables["hhea"]) == 0 && len(tables["hmtx"]) == 0 {
program, _, err := WrapCFFToOTF(tables["CFF "])
return program, true, err
}
if maxp := tables["maxp"]; len(tables["CFF "]) != 0 && tables["glyf"] == nil && len(maxp) >= 6 && (binary.BigEndian.Uint32(maxp) == 0x00010000 || binary.BigEndian.Uint32(maxp) == 0x00005000 && len(maxp) != 6) {
count, err := parseCFFAndCountGlyphs(tables["CFF "])
if err != nil {
return nil, false, err
}
if count != int(binary.BigEndian.Uint16(maxp[4:6])) {
program, _, err := WrapCFFToOTF(tables["CFF "])
if err != nil {
return nil, false, err
}
rebuilt, err := FileTables(program, 0)
if err != nil {
return nil, false, err
}
if len(tables["head"]) < 20 || !bytes.Equal(tables["head"][18:20], rebuilt["head"][18:20]) {
return nil, false, fmt.Errorf("CFF and SFNT design units differ")
}
return program, true, nil
}
tables["maxp"] = buildCFFMaxpTable(uint16(count))
changed = true
}
if !changed {
return data, false, nil
}
program, err := SerializeOTF(tables)
return program, true, err
}
// calcTableChecksum 计算字体表校验和
// 入参: data 表数据
// 返回: uint32 校验和
func calcTableChecksum(data []byte) uint32 {
var sum uint32
length := len(data)
for i := 0; i < length; i += 4 {
if i+4 <= length {
sum += binary.BigEndian.Uint32(data[i : i+4])
} else {
var val uint32
rem := data[i:]
for j, b := range rem {
val |= uint32(b) << (24 - 8*j)
var a, b, c, d uint32
for len(data) >= 16 {
a += binary.BigEndian.Uint32(data[:4])
b += binary.BigEndian.Uint32(data[4:8])
c += binary.BigEndian.Uint32(data[8:12])
d += binary.BigEndian.Uint32(data[12:16])
data = data[16:]
}
sum += 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
}
@@ -190,13 +290,13 @@ func buildTrueTypeMaxpTable(numGlyphs uint16) []byte {
// buildOS2Table 构建OS/2表 (使用默认Metrics)
// 返回: []byte OS/2表数据
func buildOS2Table() []byte {
return buildOS2TableWithMetrics(800, -200)
return BuildOS2TableWithMetrics(800, -200)
}
// buildOS2TableWithMetrics 构建OS/2表
// BuildOS2TableWithMetrics 构建OS/2表
// 入参: ascender 上升部, descender 下降部
// 返回: []byte OS/2表数据
func buildOS2TableWithMetrics(ascender, descender int16) []byte {
func BuildOS2TableWithMetrics(ascender, descender int16) []byte {
os2 := new(bytes.Buffer)
binary.Write(os2, binary.BigEndian, uint16(3))
binary.Write(os2, binary.BigEndian, int16(500))
@@ -252,9 +352,9 @@ func buildNameTable() []byte {
return buf.Bytes()
}
// buildPostTable 构建post表 (版本3.0, 无字形名称)
// BuildPostTable 构建post表 (版本3.0, 无字形名称)
// 返回: []byte post表数据
func buildPostTable() []byte {
func BuildPostTable() []byte {
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint32(0x00030000))
binary.Write(buf, binary.BigEndian, uint32(0))
@@ -280,68 +380,70 @@ func buildHmtxTable(widths []uint16) []byte {
return buf.Bytes()
}
// serializeOTF 序列化OpenType字体结构
// SerializeOTF 序列化OpenType字体结构,一次分配输出,不修改输入表数据
// 复用各表校验和计算整份字体的调整值,不重复扫描表数据
// 入参: tables 表数据映射
// 返回: []byte 完整字体数据, error 错误信息
func serializeOTF(tables map[string][]byte) ([]byte, error) {
numTables := uint16(len(tables))
func SerializeOTF(tables map[string][]byte) ([]byte, error) {
if len(tables) > math.MaxUint16/16 {
return nil, fmt.Errorf("too many font tables")
}
headerSize := 12 + 16*len(tables)
size := uint64(headerSize)
tags := make([]string, 0, len(tables))
for tag, data := range tables {
if len(tag) != 4 {
return nil, fmt.Errorf("invalid font table tag %q", tag)
}
if tag == "head" && len(data) < 12 {
return nil, fmt.Errorf("invalid head font table")
}
size += (uint64(len(data)) + 3) &^ 3
if size > math.MaxUint32 || size > uint64(math.MaxInt) {
return nil, fmt.Errorf("font table data too large")
}
tags = append(tags, tag)
}
sort.Strings(tags)
data := make([]byte, int(size))
numTables := len(tables)
entrySelector := 0
for 1<<(entrySelector+1) <= int(numTables) {
for 1<<(entrySelector+1) <= numTables {
entrySelector++
}
searchRange := 1 << (entrySelector + 4)
rangeShift := int(numTables)*16 - searchRange
buf := new(bytes.Buffer)
rangeShift := numTables*16 - searchRange
if tables["CFF "] != nil || tables["CFF2"] != nil {
buf.WriteString("OTTO")
copy(data, "OTTO")
} else {
binary.Write(buf, binary.BigEndian, uint32(0x00010000))
binary.BigEndian.PutUint32(data, 0x00010000)
}
binary.Write(buf, binary.BigEndian, numTables)
binary.Write(buf, binary.BigEndian, uint16(searchRange))
binary.Write(buf, binary.BigEndian, uint16(entrySelector))
binary.Write(buf, binary.BigEndian, uint16(rangeShift))
var tags []string
for t := range tables {
tags = append(tags, t)
}
sort.Strings(tags)
headerSize := 12 + 16*int(numTables)
offset := headerSize
var records []otfTableRecord
for _, tag := range tags {
data := tables[tag]
pad := (4 - (len(data) % 4)) % 4
padded := make([]byte, len(data)+pad)
copy(padded, data)
if tag == "head" && len(data) >= 12 {
binary.BigEndian.PutUint16(data[4:], uint16(numTables))
binary.BigEndian.PutUint16(data[6:], uint16(searchRange))
binary.BigEndian.PutUint16(data[8:], uint16(entrySelector))
binary.BigEndian.PutUint16(data[10:], uint16(rangeShift))
offset, adjustment := headerSize, 0
var checksum uint32
for i, tag := range tags {
table := tables[tag]
length := (len(table) + 3) &^ 3
padded := data[offset : offset+length]
copy(padded, table)
if tag == "head" {
clear(padded[8:12])
adjustment = offset + 8
}
cs := calcTableChecksum(padded)
records = append(records, otfTableRecord{tag, cs, offset, len(data), padded})
offset += len(padded)
record := data[12+i*16 : 28+i*16]
copy(record, tag)
tableChecksum := calcTableChecksum(padded)
checksum += tableChecksum
binary.BigEndian.PutUint32(record[4:], tableChecksum)
binary.BigEndian.PutUint32(record[8:], uint32(offset))
binary.BigEndian.PutUint32(record[12:], uint32(len(table)))
offset += length
}
for _, r := range records {
buf.WriteString(r.tag)
binary.Write(buf, binary.BigEndian, r.checksum)
binary.Write(buf, binary.BigEndian, uint32(r.offset))
binary.Write(buf, binary.BigEndian, uint32(r.length))
if adjustment != 0 {
binary.BigEndian.PutUint32(data[adjustment:], 0xB1B0AFBA-checksum-calcTableChecksum(data[:headerSize]))
}
for _, r := range records {
buf.Write(r.data)
}
fullData := buf.Bytes()
for _, r := range records {
if r.tag == "head" {
adjOffset := r.offset + 8
if adjOffset+4 <= len(fullData) {
binary.BigEndian.PutUint32(fullData[adjOffset:], 0)
cs := calcTableChecksum(fullData)
checksumAdj := 0xB1B0AFBA - cs
binary.BigEndian.PutUint32(fullData[adjOffset:], checksumAdj)
}
break
}
}
return fullData, nil
return data, nil
}
@@ -12,19 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package font
import (
"encoding/binary"
"fmt"
"github.com/tdewolff/font"
"maps"
"slices"
)
// variableSFNTSubsettable 核对TrueType可变轴与字形变化表的基本结构
// 入参: sfnt 已解析字体
// 返回: bool 是否具备可裁剪的变化数据
func variableSFNTSubsettable(sfnt *font.SFNT) bool {
func variableSFNTSubsettable(sfnt *SFNT) bool {
fvar, gvar := sfnt.Tables["fvar"], sfnt.Tables["gvar"]
if len(fvar) < 16 || len(gvar) < 20 || binary.BigEndian.Uint32(fvar) != 0x10000 || binary.BigEndian.Uint32(gvar) != 0x10000 {
return false
@@ -38,32 +38,30 @@ func variableSFNTSubsettable(sfnt *font.SFNT) bool {
// subsetVariableSFNTFont 保留字形编号及可变轴,仅裁剪未使用的轮廓和字形变化数据
// 入参: sfnt 已解析字体, glyphs 使用的字形
// 返回: []byte 字体子集, error 字形或变化表错误
func subsetVariableSFNTFont(sfnt *font.SFNT, glyphs []uint16) ([]byte, error) {
func subsetVariableSFNTFont(sfnt *SFNT, glyphs []uint16) ([]byte, error) {
if !variableSFNTSubsettable(sfnt) {
return nil, fmt.Errorf("invalid TrueType variation tables")
}
usage := newEditorFontUsage()
usage.glyphs[0] = true
usage := newUsage()
usage.Glyphs[0] = true
for _, gid := range glyphs {
if gid >= sfnt.NumGlyphs() {
return nil, fmt.Errorf("variable font glyph index out of range")
}
usage.glyphs[gid] = true
for _, char := range sfnt.Cmap.ToUnicode(gid) {
usage.chars[char] = true
usage.Glyphs[gid] = true
for _, char := range sfnt.glyphCharacters(gid) {
usage.Characters[char] = true
}
}
for gid := range usage.glyphs {
dependencies, err := sfnt.Glyf.Dependencies(gid)
dependencies, err := sfnt.glyphDependencies(slices.Collect(maps.Keys(usage.Glyphs))...)
if err != nil {
return nil, err
}
for _, dependency := range dependencies {
usage.glyphs[dependency] = true
usage.Glyphs[dependency] = true
}
}
mapping := make(map[rune]uint16, len(usage.chars))
for char := range usage.chars {
mapping := make(map[rune]uint16, len(usage.Characters))
for char := range usage.Characters {
mapping[char] = sfnt.GlyphIndex(char)
}
table := make(map[string][]byte)
@@ -72,18 +70,18 @@ func subsetVariableSFNTFont(sfnt *font.SFNT, glyphs []uint16) ([]byte, error) {
table[tag] = data
}
}
table["cmap"] = buildCmapTable(sfnt.NumGlyphs(), mapping)
static, err := serializeOTF(table)
table["cmap"] = BuildCmapTable(sfnt.NumGlyphs(), mapping)
static, err := SerializeOTF(table)
if err != nil {
return nil, err
}
if subset := subsetSourceSFNTFont(static, usage); subset != nil {
table, err = fontFileTables(subset, 0)
if subset := SubsetSourceSFNT(static, usage); subset != nil {
table, err = FileTables(subset, 0)
if err != nil {
return nil, err
}
}
table["gvar"], err = subsetGlyphVariations(sfnt.Tables["gvar"], usage.glyphs)
table["gvar"], err = subsetGlyphVariations(sfnt.Tables["gvar"], usage.Glyphs)
if err != nil {
return nil, err
}
@@ -92,7 +90,7 @@ func subsetVariableSFNTFont(sfnt *font.SFNT, glyphs []uint16) ([]byte, error) {
table[tag] = data
}
}
return serializeOTF(table)
return SerializeOTF(table)
}
// subsetGlyphVariations 按原字形编号重建gvar偏移表,保留共享元组及使用字形的完整变化数据
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package font
import (
"bytes"
"compress/zlib"
"encoding/binary"
"fmt"
"io"
"sort"
"github.com/andybalholm/brotli"
)
// fontContainerLimit 限制压缩字体展开后的表数据总量
const fontContainerLimit = 256 << 20
// woff2Tags 保存WOFF2表目录的标准标签顺序
var woff2Tags = [...]string{
"cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post",
"cvt ", "fpgm", "glyf", "loca", "prep", "CFF ", "VORG", "EBDT",
"EBLC", "gasp", "hdmx", "kern", "LTSH", "PCLT", "VDMX", "vhea",
"vmtx", "BASE", "GDEF", "GPOS", "GSUB", "EBSC", "JSTF", "MATH",
"CBDT", "CBLC", "COLR", "CPAL", "SVG ", "sbix", "acnt", "avar",
"bdat", "bloc", "bsln", "cvar", "fdsc", "feat", "fmtx", "fvar",
"gvar", "hsty", "just", "lcar", "mort", "morx", "opbd", "prop",
"trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill",
}
// DecodeWOFF2 展开未变换的WOFF2表,字体集合及字形变换交由绘图适配器处理
// 入参: data WOFF2文件
// 返回: []byte OpenType数据, bool 是否已处理, error 封装错误
func DecodeWOFF2(data []byte) ([]byte, bool, error) {
if len(data) < 48 || string(data[:4]) != "wOF2" || uint64(binary.BigEndian.Uint32(data[8:])) != uint64(len(data)) {
return nil, true, fmt.Errorf("invalid WOFF2 header")
}
if string(data[4:8]) == "ttcf" {
return nil, false, nil
}
count := int(binary.BigEndian.Uint16(data[12:]))
if count == 0 || count > (len(data)-48)/2 {
return nil, true, fmt.Errorf("invalid WOFF2 table count")
}
tags := make([]string, 0, count)
sizes := make([]uint32, 0, count)
table := make(map[string][]byte, count)
pos, total, transformed := 48, uint64(0), false
for range count {
if pos >= len(data) {
return nil, true, fmt.Errorf("truncated WOFF2 directory")
}
flags := data[pos]
pos++
var tag string
if index := flags & 63; index == 63 {
if len(data)-pos < 4 {
return nil, true, fmt.Errorf("truncated WOFF2 tag")
}
tag = string(data[pos : pos+4])
pos += 4
} else {
tag = woff2Tags[index]
}
if _, exists := table[tag]; exists {
return nil, true, fmt.Errorf("duplicate WOFF2 table %q", tag)
}
table[tag] = nil
size, err := readWOFF2Base128(data, &pos)
if err != nil {
return nil, true, err
}
version := flags >> 6
changed := version != 0
if tag == "glyf" || tag == "loca" {
if version != 0 && version != 3 {
return nil, true, fmt.Errorf("invalid WOFF2 glyph transform")
}
changed = version == 0
} else if version != 0 && (tag != "hmtx" || version != 1) {
return nil, true, fmt.Errorf("invalid WOFF2 table transform %q", tag)
}
if changed {
transformed = true
size, err = readWOFF2Base128(data, &pos)
if err != nil {
return nil, true, err
}
if tag == "loca" && size != 0 {
return nil, true, fmt.Errorf("invalid WOFF2 loca length")
}
}
total += uint64(size)
if total+uint64(12+19*count) > fontContainerLimit {
return nil, true, fmt.Errorf("WOFF2 font exceeds size limit")
}
tags = append(tags, tag)
sizes = append(sizes, size)
}
end := uint64(pos) + uint64(binary.BigEndian.Uint32(data[20:]))
if end > uint64(len(data)) {
return nil, true, fmt.Errorf("invalid WOFF2 compressed length")
}
if err := validateWOFFBlocks(data, end, 28); err != nil {
return nil, true, err
}
if transformed {
return nil, false, nil
}
reader := brotli.NewReader(bytes.NewReader(data[pos:end]))
body, err := io.ReadAll(io.LimitReader(reader, int64(total)+1))
if err != nil {
return nil, true, fmt.Errorf("decode WOFF2 data: %w", err)
}
if uint64(len(body)) != total {
return nil, true, fmt.Errorf("invalid WOFF2 expanded length")
}
for i, tag := range tags {
size := int(sizes[i])
table[tag], body = body[:size], body[size:]
}
decoded, err := SerializeOTF(table)
return decoded, true, err
}
// readWOFF2Base128 读取WOFF2无符号变长整数,拒绝溢出和前导零
// 入参: data 编码数据, pos 当前读取位置
// 返回: uint32 整数值, error 编码错误
func readWOFF2Base128(data []byte, pos *int) (uint32, error) {
var value uint32
for i := range 5 {
if *pos >= len(data) {
return 0, fmt.Errorf("truncated WOFF2 integer")
}
b := data[*pos]
*pos++
if i == 0 && b == 0x80 || value > 0x1ffffff {
return 0, fmt.Errorf("invalid WOFF2 integer")
}
value = value<<7 | uint32(b&127)
if b&128 == 0 {
return value, nil
}
}
return 0, fmt.Errorf("invalid WOFF2 integer")
}
// DecodeWOFF 展开WOFF表数据,不解析或更改字体轮廓
// 入参: data WOFF文件
// 返回: []byte OpenType数据, error 封装错误
func DecodeWOFF(data []byte) ([]byte, error) {
if len(data) < 44 || uint64(binary.BigEndian.Uint32(data[8:])) != uint64(len(data)) || binary.BigEndian.Uint16(data[14:]) != 0 {
return nil, fmt.Errorf("invalid WOFF header")
}
count := int(binary.BigEndian.Uint16(data[12:]))
if count == 0 || count > (len(data)-44)/20 {
return nil, fmt.Errorf("invalid WOFF table count")
}
table := make(map[string][]byte, count)
ranges := make([][2]uint64, 0, count)
total := uint64(12 + 16*count)
for i := range count {
record := data[44+i*20:]
tag := string(record[:4])
offset, packed, size := uint64(binary.BigEndian.Uint32(record[4:])), uint64(binary.BigEndian.Uint32(record[8:])), uint64(binary.BigEndian.Uint32(record[12:]))
total += (size + 3) &^ 3
if _, exists := table[tag]; exists || offset < uint64(44+20*count) || offset%4 != 0 || packed > size || offset+packed > uint64(len(data)) || total > fontContainerLimit {
return nil, fmt.Errorf("invalid WOFF table %q", tag)
}
body := data[offset : offset+packed]
if packed < size {
reader, err := zlib.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
body, err = io.ReadAll(io.LimitReader(reader, int64(size)+1))
reader.Close()
if err != nil {
return nil, err
}
if uint64(len(body)) != size {
return nil, fmt.Errorf("invalid WOFF expanded length")
}
}
table[tag] = body
ranges = append(ranges, [2]uint64{offset, offset + packed})
}
if total != uint64(binary.BigEndian.Uint32(data[16:])) {
return nil, fmt.Errorf("invalid WOFF SFNT size")
}
sort.Slice(ranges, func(i, j int) bool { return ranges[i][0] < ranges[j][0] })
end := uint64(44 + 20*count)
for _, interval := range ranges {
if interval[0] != (end+3)&^3 {
return nil, fmt.Errorf("invalid WOFF table ranges")
}
end = interval[1]
}
if err := validateWOFFBlocks(data, end, 24); err != nil {
return nil, err
}
return SerializeOTF(table)
}
// validateWOFFBlocks 校验附加数据块的边界及对齐,字体元数据不写入输出
// 入参: data 字体文件, end 字体数据结束位置, fields 元数据偏移字段位置
// 返回: error 附加块错误
func validateWOFFBlocks(data []byte, end uint64, fields int) error {
for _, pos := range []int{fields, fields + 12} {
offset, length := uint64(binary.BigEndian.Uint32(data[pos:])), uint64(binary.BigEndian.Uint32(data[pos+4:]))
if offset == 0 {
if length != 0 || pos == fields && binary.BigEndian.Uint32(data[pos+8:]) != 0 {
return fmt.Errorf("invalid WOFF optional block")
}
continue
}
if length == 0 || offset != (end+3)&^3 || offset+length > uint64(len(data)) {
return fmt.Errorf("invalid WOFF block range")
}
for _, b := range data[end:offset] {
if b != 0 {
return fmt.Errorf("invalid WOFF padding")
}
}
end = offset + length
}
if end > uint64(len(data)) || uint64(len(data)) > (end+3)&^3 {
return fmt.Errorf("invalid WOFF trailing data")
}
for _, b := range data[end:] {
if b != 0 {
return fmt.Errorf("invalid WOFF padding")
}
}
return nil
}
+31 -20
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package geom
import (
"fmt"
@@ -27,33 +27,33 @@ type geometryArc struct {
// Curves 按毫米误差上限将端点式椭圆弧转换为三次贝塞尔曲线,其他指令保持原样
// 入参: tolerance 正有限误差上限
// 返回: GeometryPath 独立路径, error 参数错误
func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
if err := p.validate(); err != nil {
// 返回: Path 独立路径, error 参数错误
func (p Path) Curves(tolerance float64) (Path, error) {
if err := p.Validate(); err != nil {
return nil, err
}
if !rasterPositive(tolerance) {
if !positive(tolerance) {
return nil, fmt.Errorf("invalid curve tolerance")
}
result := make(GeometryPath, 0, len(p))
result := make(Path, 0, len(p))
var current, start Point
for _, s := range p {
switch s.Verb {
case GeometryMove:
case Move:
start = s.End
case GeometryArc:
curves, err := arcCurves(current, s, tolerance)
case Arc:
curves, err := ArcCurves(current, s, tolerance)
if err != nil {
return nil, err
}
result = append(result, curves...)
current = s.End
continue
case GeometryClose:
case ClosePath:
result = append(result, s)
current = start
continue
case GeometryLine, GeometryQuad, GeometryCubic:
case Line, Quad, Cubic:
default:
return nil, fmt.Errorf("unsupported geometry command %d", s.Verb)
}
@@ -63,10 +63,10 @@ func (p GeometryPath) Curves(tolerance float64) (GeometryPath, error) {
return result, nil
}
// arcCurves 按标准端点参数求椭圆中心,只在绘制边界近似弧线
// ArcCurves 按标准端点参数求椭圆中心,只在绘制边界近似弧线
// 入参: start 起点, arc 弧线, tolerance 毫米误差上限
// 返回: GeometryPath 贝塞尔片段, error 无效弧线
func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPath, error) {
// 返回: Path 贝塞尔片段, error 无效弧线
func ArcCurves(start Point, arc Segment, tolerance float64) (Path, error) {
a, err := centerArc(start, arc)
if err != nil {
return nil, err
@@ -75,7 +75,7 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
return nil, nil
}
if arc.RadiusX == 0 || arc.RadiusY == 0 {
return GeometryPath{{Verb: GeometryLine, End: arc.End}}, nil
return Path{{Verb: Line, End: arc.End}}, nil
}
radius := math.Max(math.Hypot(a.u.X, a.u.Y), math.Hypot(a.v.X, a.v.Y))
step := math.Min(math.Pi/2, math.Pow(tolerance/radius*40000, 1.0/6))
@@ -88,12 +88,12 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
point := func(x, y float64) Point {
return Point{a.center.X + a.u.X*x + a.v.X*y, a.center.Y + a.u.Y*x + a.v.Y*y}
}
result := make(GeometryPath, 0, count)
result := make(Path, 0, count)
for i := 0; i < count; i++ {
sa, ca := math.Sincos(a.theta + float64(i)*step)
sb, cb := math.Sincos(a.theta + float64(i+1)*step)
k := 4.0 / 3 * math.Tan(step/4)
result = append(result, GeometrySegment{Verb: GeometryCubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
result = append(result, Segment{Verb: Cubic, Control1: point(ca-k*sa, sa+k*ca), Control2: point(cb+k*sb, sb-k*cb), End: point(cb, sb)})
}
result[len(result)-1].End = arc.End
return result, nil
@@ -102,7 +102,7 @@ func arcCurves(start Point, arc GeometrySegment, tolerance float64) (GeometryPat
// centerArc 将端点式圆弧解析为中心式,统一半径修正和方向语义
// 入参: start 起点, arc 弧线
// 返回: geometryArc 中心式参数, error 无效参数或数值溢出
func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
func centerArc(start Point, arc Segment) (geometryArc, error) {
rx, ry := arc.RadiusX, arc.RadiusY
if !finite(rx) || !finite(ry) || rx < 0 || ry < 0 || !finite(arc.Rotation) ||
!finite(start.X) || !finite(start.Y) || !finite(arc.End.X) || !finite(arc.End.Y) {
@@ -116,7 +116,7 @@ func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
}
angle := math.Mod(arc.Rotation, 360) * math.Pi / 180
sine, cosine := math.Sincos(angle)
dx, dy := (start.X-arc.End.X)/2, (start.Y-arc.End.Y)/2
dx, dy := arcCoordinateMean(start.X, -arc.End.X), arcCoordinateMean(start.Y, -arc.End.Y)
x, y := cosine*dx+sine*dy, -sine*dx+cosine*dy
scale := math.Hypot(x/rx, y/ry)
if scale > 1 {
@@ -139,13 +139,24 @@ func centerArc(start Point, arc GeometrySegment) (geometryArc, error) {
if !arc.Sweep && delta > 0 {
delta -= 2 * math.Pi
}
center := Point{cosine*cx - sine*cy + (start.X+arc.End.X)/2, sine*cx + cosine*cy + (start.Y+arc.End.Y)/2}
center := Point{cosine*cx - sine*cy + arcCoordinateMean(start.X, arc.End.X), sine*cx + cosine*cy + arcCoordinateMean(start.Y, arc.End.Y)}
if !finite(center.X) || !finite(center.Y) || !finite(rx) || !finite(ry) || !finite(delta) || !finite(theta) || delta == 0 {
return geometryArc{}, fmt.Errorf("invalid arc extent")
}
return geometryArc{center, Point{rx * cosine, rx * sine}, Point{-ry * sine, ry * cosine}, theta, delta}, nil
}
// arcCoordinateMean 计算有限坐标均值,仅在求和溢出时先减半,避免微小坐标提前下溢
// 入参: a 第一个坐标, b 第二个坐标
// 返回: float64 坐标均值
func arcCoordinateMean(a, b float64) float64 {
value := (a + b) / 2
if math.IsInf(value, 0) {
return a/2 + b/2
}
return value
}
// point 计算椭圆参数对应的坐标
// 入参: angle 参数角
// 返回: Point 椭圆上的点
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package geom
import (
"fmt"
@@ -22,8 +22,8 @@ import (
// Bounds 通过解析极值计算直线、贝塞尔和椭圆弧的轴对齐边界
// 不进行曲线展开且不使用Tolerance,结果受float64舍入误差影响,不保证向外舍入
// 返回: Box 浮点路径范围, error 无效路径、弧线或数值溢出
func (p GeometryPath) Bounds() (Box, error) {
if err := p.validate(); err != nil {
func (p Path) Bounds() (Box, error) {
if err := p.Validate(); err != nil {
return Box{}, err
}
left, top, right, bottom := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
@@ -34,11 +34,11 @@ func (p GeometryPath) Bounds() (Box, error) {
var current, start Point
for _, s := range p {
switch s.Verb {
case GeometryMove:
case Move:
start = s.End
case GeometryClose:
case ClosePath:
s.End = start
case GeometryArc:
case Arc:
a, err := centerArc(current, s)
if err != nil {
return Box{}, err
@@ -52,9 +52,9 @@ func (p GeometryPath) Bounds() (Box, error) {
}
}
}
case GeometryQuad, GeometryCubic:
case Quad, Cubic:
for _, axis := range [][4]float64{{current.X, s.Control1.X, s.Control2.X, s.End.X}, {current.Y, s.Control1.Y, s.Control2.Y, s.End.Y}} {
if s.Verb == GeometryQuad {
if s.Verb == Quad {
axis[2] = 0
}
scale := math.Max(math.Abs(axis[0]), math.Max(math.Abs(axis[1]), math.Max(math.Abs(axis[2]), math.Abs(axis[3]))))
@@ -64,19 +64,19 @@ func (p GeometryPath) Bounds() (Box, error) {
}
}
var roots []float64
if s.Verb == GeometryQuad {
if s.Verb == Quad {
roots = geometryRoots(0, axis[0]-2*axis[1]+axis[3], axis[1]-axis[0])
} else {
roots = geometryRoots(-axis[0]+3*axis[1]-3*axis[2]+axis[3], 2*(axis[0]-2*axis[1]+axis[2]), axis[1]-axis[0])
}
for _, t := range roots {
if t > 0 && t < 1 {
q := geometryLerp(current, s.Control1, t)
if s.Verb == GeometryQuad {
q = geometryLerp(q, geometryLerp(s.Control1, s.End, t), t)
q := Lerp(current, s.Control1, t)
if s.Verb == Quad {
q = Lerp(q, Lerp(s.Control1, s.End, t), t)
} else {
r := geometryLerp(s.Control1, s.Control2, t)
q = geometryLerp(geometryLerp(q, r, t), geometryLerp(r, geometryLerp(s.Control2, s.End, t), t), t)
r := Lerp(s.Control1, s.Control2, t)
q = Lerp(Lerp(q, r, t), Lerp(r, Lerp(s.Control2, s.End, t), t), t)
}
add(q)
}
@@ -121,9 +121,9 @@ func geometryRoots(a, b, c float64) []float64 {
return []float64{q / a, c / q}
}
// geometryLerp 线性插值坐标
// Lerp 线性插值坐标
// 入参: a 起点, b 终点, t 插值比例
// 返回: Point 插值坐标
func geometryLerp(a, b Point, t float64) Point {
func Lerp(a, b Point, t float64) Point {
return Point{(1-t)*a.X + t*b.X, (1-t)*a.Y + t*b.Y}
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package geom
import "math"
// polygonPoint 允许同结构的后端顶点直接参与裁剪,避免整片坐标转换
type polygonPoint interface {
~struct{ X, Y float64 }
}
// ClipPolygon 按非退化凸窗口逐边裁剪多边形,保留原顶点绕向,不修改输入
// 入参: a 绘制顶点, b 已通过ConvexPolygon检查的裁剪顶点,均不含重复闭合点
// 返回: []P 交集顶点,少于三个顶点时不构成区域, bool 是否满足数值精度
func ClipPolygon[P polygonPoint](a, b []P) ([]P, bool) {
for i, start := range b {
origin, end := Point(start), Point(b[(i+1)%len(b)])
dx, dy := end.X-origin.X, end.Y-origin.Y
length := math.Hypot(dx, dy)
if length == 0 {
continue
}
dx, dy = dx/length, dy/length
distance := func(p P) float64 { return convexDistance(origin, Point(p), dx, dy) }
sign := 1.0
for _, p := range b {
if d := distance(p); d != 0 {
sign = math.Copysign(1, d)
break
}
}
if len(a) == 0 {
break
}
var clipped []P
appendPoint := func(p P) {
if len(clipped) == 0 || p != clipped[len(clipped)-1] {
clipped = append(clipped, p)
}
}
previous := a[len(a)-1]
previousDistance := sign * distance(previous)
if !finite(previousDistance) {
return nil, false
}
for _, p := range a {
d := sign * distance(p)
if !finite(d) {
return nil, false
}
if (d >= 0) != (previousDistance >= 0) {
scale := math.Max(math.Abs(d), math.Abs(previousDistance))
fraction := (previousDistance / scale) / (previousDistance/scale - d/scale)
intersection := Lerp(Point(previous), Point(p), fraction)
if dx == 0 {
intersection.X = origin.X
}
if dy == 0 {
intersection.Y = origin.Y
}
appendPoint(P(intersection))
}
if d >= 0 {
appendPoint(p)
}
previous, previousDistance = p, d
}
if len(clipped) > 1 && clipped[0] == clipped[len(clipped)-1] {
clipped = clipped[:len(clipped)-1]
}
a = clipped
}
return a, true
}
// ConvexPolygon 检查所有顶点位于各边的同一侧,不修改输入
// 入参: points 不含重复闭合点的独立顶点
// 返回: []P 原顶点,非有限距离或异侧顶点返回nil, bool 是否为非退化凸多边形
func ConvexPolygon[P polygonPoint](points []P) ([]P, bool) {
positive := false
for i, start := range points {
origin, end := Point(start), Point(points[(i+1)%len(points)])
dx, dy := end.X-origin.X, end.Y-origin.Y
length := math.Hypot(dx, dy)
if !finite(length) {
return nil, false
}
if length == 0 {
continue
}
dx, dy = dx/length, dy/length
sign := 0.0
for _, p := range points {
d := convexDistance(origin, Point(p), dx, dy)
if !finite(d) || sign*d < 0 {
return nil, false
}
if d != 0 {
sign, positive = math.Copysign(1, d), true
}
}
}
return points, positive
}
// convexDistance 按浮点乘法误差界识别共线点,不使用固定坐标容差
// 入参: start 边起点, point 待测顶点, dx 横向单位分量, dy 纵向单位分量
// 返回: float64 顶点到边的有向距离
func convexDistance(start, point Point, dx, dy float64) float64 {
x, y := point.X-start.X, point.Y-start.Y
distance := math.FMA(dx, y, -dy*x)
errorBound := (math.Abs(dx*y) + math.Abs(dy*x)) * 0x1p-49
if finite(errorBound) && math.Abs(distance) <= errorBound {
return 0
}
return distance
}
@@ -12,14 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package geom
import "math"
import (
"math"
)
// geometryConvexContains 判断单个非退化凸直线区域是否包含另一闭合直线区域
// ConvexContains 判断单个非退化凸直线区域是否包含另一闭合直线区域
// 入参: outer 外部区域, inner 内部区域
// 返回: bool 是否完全包含,曲线及多轮廓返回false
func geometryConvexContains(outer, inner GeometryPath) bool {
func ConvexContains(outer, inner Path) bool {
a, b := geometryPolygonPoints(outer), geometryPolygonPoints(inner)
if len(a) < 3 || len(b) < 3 {
return false
@@ -68,12 +70,12 @@ func geometryConvexContains(outer, inner GeometryPath) bool {
// geometryPolygonPoints 读取单个显式闭合直线轮廓的顶点,不修改原路径
// 入参: path 直线轮廓
// 返回: []Point 独立顶点,其他路径返回nil
func geometryPolygonPoints(path GeometryPath) []Point {
if len(path) < 4 || path[0].Verb != GeometryMove || path[len(path)-1].Verb != GeometryClose {
func geometryPolygonPoints(path Path) []Point {
if len(path) < 4 || path[0].Verb != Move || path[len(path)-1].Verb != ClosePath {
return nil
}
for i, segment := range path[:len(path)-1] {
if i > 0 && segment.Verb != GeometryLine || !finite(segment.End.X) || !finite(segment.End.Y) {
if i > 0 && segment.Verb != Line || !finite(segment.End.X) || !finite(segment.End.Y) {
return nil
}
}
+197
View File
@@ -0,0 +1,197 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package geom
import (
"math"
)
// IdentityMatrix 单位矩阵
var IdentityMatrix = Matrix{1, 0, 0, 1, 0, 0}
// Box 矩形区域
type Box struct {
X, Y, W, H float64
}
// Point 平面坐标点
type Point struct {
X, Y float64
}
// Matrix 2D仿射变换矩阵
type Matrix struct {
a, b, c, d, e, f float64
}
// MatrixFromValues 从a、b、c、d、e、f构造矩阵,不经过文本格式化
// 入参: values 矩阵分量
// 返回: Matrix 变换矩阵
func MatrixFromValues(values [6]float64) Matrix {
return Matrix{a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5]}
}
// Values 返回a、b、c、d、e、f的独立副本,保留原始浮点精度
// 返回: [6]float64 矩阵分量
func (m Matrix) Values() [6]float64 {
return [6]float64{m.a, m.b, m.c, m.d, m.e, m.f}
}
// Multiply 矩阵乘法 (m * o)
// 入参: o 右侧矩阵
// 返回: Matrix 结果矩阵
func (m Matrix) Multiply(o Matrix) Matrix {
return Matrix{
a: m.a*o.a + m.c*o.b,
b: m.b*o.a + m.d*o.b,
c: m.a*o.c + m.c*o.d,
d: m.b*o.c + m.d*o.d,
e: m.a*o.e + m.c*o.f + m.e,
f: m.b*o.e + m.d*o.f + m.f,
}
}
// Transform 应用变换矩阵
// 入参: x X坐标, y Y坐标
// 返回: float64 变换后X, float64 变换后Y
func (m Matrix) Transform(x, y float64) (float64, float64) {
nx := m.a*x + m.c*y + m.e
ny := m.b*x + m.d*y + m.f
return nx, ny
}
// TransformBox 获取矩形经仿射变换后的轴对齐边界
// 入参: box 原始矩形范围
// 返回: Box 变换后的轴对齐边界
func (m Matrix) TransformBox(box Box) Box {
x, y := m.Transform(box.X, box.Y)
left, top, right, bottom := x, y, x, y
for _, point := range [][2]float64{{box.X + box.W, box.Y}, {box.X, box.Y + box.H}, {box.X + box.W, box.Y + box.H}} {
x, y := m.Transform(point[0], point[1])
left, top, right, bottom = math.Min(left, x), math.Min(top, y), math.Max(right, x), math.Max(bottom, y)
}
return Box{X: left, Y: top, W: right - left, H: bottom - top}
}
// Invert 求仿射矩阵的逆变换
// 返回: Matrix 逆矩阵,失败时返回单位矩阵, bool 是否存在有限逆矩阵
func (m Matrix) Invert() (Matrix, bool) {
for _, value := range m.Values() {
if !finite(value) {
return IdentityMatrix, false
}
}
det, ds := matrixProductDifference(m.a, m.d, m.b, m.c)
x, xs := matrixProductDifference(m.c, m.f, m.d, m.e)
y, ys := matrixProductDifference(m.b, m.e, m.a, m.f)
if det != 0 && ds && xs && ys {
n := Matrix{a: m.d / det, b: -m.b / det, c: -m.c / det, d: m.a / det, e: x / det, f: y / det}
valid := true
for _, value := range n.Values() {
valid = valid && finite(value)
}
if valid {
return n, true
}
}
determinant, exponent := matrixProductSum(m.a, m.d, -m.b, m.c)
if determinant == 0 {
return IdentityMatrix, false
}
divide := func(value float64, power int) float64 {
mantissa, shift := math.Frexp(value)
return math.Ldexp(mantissa/determinant, power+shift-exponent)
}
x, xe := matrixProductSum(m.c, m.f, -m.d, m.e)
y, ye := matrixProductSum(m.b, m.e, -m.a, m.f)
n := Matrix{a: divide(m.d, 0), b: divide(-m.b, 0), c: divide(-m.c, 0), d: divide(m.a, 0), e: divide(x, xe), f: divide(y, ye)}
for _, value := range n.Values() {
if !finite(value) {
return IdentityMatrix, false
}
}
return n, true
}
// matrixProductDifference 补偿乘积相减的舍入残差,检查溢出、下溢及严重相消
// 入参: a 第一个乘数, b 第二个乘数, c 第三个乘数, d 第四个乘数
// 返回: float64 乘积之差, bool 是否可直接用于求逆
func matrixProductDifference(a, b, c, d float64) (float64, bool) {
x, y := a*b, c*d
value := x - y
if !finite(value) || x == 0 && a != 0 && b != 0 || y == 0 && c != 0 && d != 0 {
return value, false
}
if x != 0 && y != 0 {
value = math.FMA(a, b, -y) + math.FMA(-c, d, y)
}
return value, x == 0 && y == 0 || math.Abs(value) >= 0x1p-1022 && math.Abs(value) >= 0x1p-48*math.Max(math.Abs(x), math.Abs(y))
}
// matrixProductSum 以尾数及指数累加两个乘积,保留乘法舍入残差
// 入参: a 第一个乘数, b 第二个乘数, c 第三个乘数, d 第四个乘数
// 返回: float64 结果尾数, int 二进制指数
func matrixProductSum(a, b, c, d float64) (float64, int) {
a, ae := math.Frexp(a)
b, be := math.Frexp(b)
c, ce := math.Frexp(c)
d, de := math.Frexp(d)
x, y := a*b, c*d
xe, ye := ae+be, ce+de
power := max(xe, ye)
if x == 0 {
power = ye
} else if y == 0 {
power = xe
}
value := math.Ldexp(x, xe-power) + math.Ldexp(y, ye-power)
value += math.Ldexp(math.FMA(a, b, -x), xe-power) + math.Ldexp(math.FMA(c, d, -y), ye-power)
value, shift := math.Frexp(value)
return value, power + shift
}
// TranslationMatrix 构造平移矩阵
// 入参: x X轴偏移, y Y轴偏移
// 返回: Matrix 平移矩阵
func TranslationMatrix(x, y float64) Matrix {
return Matrix{a: 1, d: 1, e: x, f: y}
}
// YScale 获取Y轴缩放比例
// 返回: float64 缩放比例
func (m Matrix) YScale() float64 {
return math.Hypot(m.c, m.d)
}
// Equal 比较毫米坐标,使用固定绝对误差,不受绘图库全局精度影响
// 入参: a 第一个坐标, b 第二个坐标
// 返回: bool 是否在容差内相等
func Equal(a, b float64) bool {
return math.Abs(a-b) <= 1e-10
}
// AxisAligned 判断变换是否保持坐标轴平行,含直角旋转与镜像
// 入参: m 仿射变换矩阵
// 返回: bool 是否为非退化的轴对齐变换
func AxisAligned(m Matrix) bool {
return m.b == 0 && m.c == 0 && m.a != 0 && m.d != 0 || m.a == 0 && m.d == 0 && m.b != 0 && m.c != 0
}
// Vector 变换位移,不包含平移分量
// 入参: m 仿射变换矩阵, x 原始横向位移, y 原始纵向位移
// 返回: float64 变换后的X位移, float64 变换后的Y位移
func Vector(m Matrix, x, y float64) (float64, float64) {
return m.a*x + m.c*y, m.b*x + m.d*y
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package geom
import (
"math"
)
// finite 判断数值是否有限
// 入参: value 数值
// 返回: bool 是否有限
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// positive 判断正有限数
// 入参: v 待检查数值
// 返回: bool 是否为正有限数
func positive(v float64) bool { return v > 0 && !math.IsInf(v, 0) && !math.IsNaN(v) }
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package geom
import (
"fmt"
"math"
)
// 几何路径指令,包括直线、曲线、椭圆弧和闭合
const (
Move Verb = iota
Line
Quad
Cubic
Arc
ClosePath
)
// Path 保存独立于绘图库的几何路径,不扁平化曲线或舍入坐标
// 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点
type Path []Segment
// Verb 表示几何路径指令
type Verb uint8
// Segment 保存路径端点、贝塞尔控制点或端点式椭圆弧参数
// 弧线Rotation单位为度,Sweep为true表示顺时针,Large表示大弧
type Segment struct {
Verb Verb
End Point
Control1, Control2 Point
RadiusX, RadiusY float64
Rotation float64
Large, Sweep bool
}
// StrokeOptions 描述描边参数,长度与路径坐标采用相同单位
// Width须为正有限数;Cap和Join使用标准线帽和连接样式名称,空值分别采用Butt和Miter
// MiterLimit为尖角长度与半线宽的比值上限,由调用方设置默认值
// MiterLimitSet供调用方区分显式零值与默认值,不参与几何计算
// DashOffset为虚线起始偏移,允许负值;Dashes交替指定实线段和间隔长度,奇数项按重复一遍处理
// Tolerance为曲线展开的误差目标,0采用0.001
type StrokeOptions struct {
Width float64
Cap, Join string
MiterLimit float64
MiterLimitSet bool
DashOffset float64
Tolerance float64
Dashes []float64
}
// Close 为填充轮廓补齐隐式闭合边,不改变源路径
// 入参: path 填充路径
// 返回: Path 显式闭合路径
func Close(path Path) Path {
open, missing := false, false
for _, segment := range path {
if segment.Verb == Move && open {
missing = true
break
}
open = segment.Verb != ClosePath
}
if !missing && !open {
return path
}
result := make(Path, 0, len(path)+1)
open = false
for _, segment := range path {
if segment.Verb == Move && open {
result = append(result, Segment{Verb: ClosePath})
}
result = append(result, segment)
open = segment.Verb != ClosePath
}
if open {
result = append(result, Segment{Verb: ClosePath})
}
return result
}
// ValidateStroke 校验各后端共用的描边参数
// 入参: options 描边样式
// 返回: error 无效样式
func ValidateStroke(options StrokeOptions) error {
if !finite(options.Width) || options.Width <= 0 || !finite(options.Tolerance) || options.Tolerance < 0 || !finite(options.DashOffset) || !finite(options.MiterLimit) || options.MiterLimit < 0 {
return fmt.Errorf("invalid stroke width or tolerance")
}
if options.Cap != "" && options.Cap != "Butt" && options.Cap != "Round" && options.Cap != "Square" {
return fmt.Errorf("invalid stroke cap %q", options.Cap)
}
if options.Join != "" && options.Join != "Miter" && options.Join != "Round" && options.Join != "Bevel" {
return fmt.Errorf("invalid stroke join %q", options.Join)
}
for _, dash := range options.Dashes {
if !finite(dash) || dash < 0 {
return fmt.Errorf("invalid stroke dash")
}
}
return nil
}
// Rectangle 构建页面坐标矩形,保留输入精度
// 入参: box 矩形范围
// 返回: Path 闭合路径
func Rectangle(box Box) Path {
return Path{
{Verb: Move, End: Point{X: box.X, Y: box.Y}},
{Verb: Line, End: Point{X: box.X + box.W, Y: box.Y}},
{Verb: Line, End: Point{X: box.X + box.W, Y: box.Y + box.H}},
{Verb: Line, End: Point{X: box.X, Y: box.Y + box.H}},
{Verb: ClosePath, End: Point{X: box.X, Y: box.Y}},
}
}
// RectangleBounds 识别单个轴对齐矩形,不用包围盒代替实际路径
// 入参: path 填充路径
// 返回: Box 矩形范围, bool 是否为非退化矩形
func RectangleBounds(path Path) (Box, bool) {
if len(path) > 0 && path[len(path)-1].Verb == ClosePath {
path = path[:len(path)-1]
}
if len(path) == 5 && path[4].Verb == Line && path[4].End == path[0].End {
path = path[:4]
}
if len(path) != 4 || path[0].Verb != Move || path[1].Verb != Line || path[2].Verb != Line || path[3].Verb != Line {
return Box{}, false
}
a, b, c, d := path[0].End, path[1].End, path[2].End, path[3].End
for _, point := range [...]Point{a, b, c, d} {
if !finite(point.X) || !finite(point.Y) {
return Box{}, false
}
}
if a.X == c.X || a.Y == c.Y {
return Box{}, false
}
if !(a.X == b.X && b.Y == c.Y && c.X == d.X && d.Y == a.Y || a.Y == b.Y && b.X == c.X && c.Y == d.Y && d.X == a.X) {
return Box{}, false
}
width, height := math.Abs(c.X-a.X), math.Abs(c.Y-a.Y)
if !finite(width) || !finite(height) {
return Box{}, false
}
return Box{X: min(a.X, c.X), Y: min(a.Y, c.Y), W: width, H: height}, true
}
@@ -12,13 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package geom
import (
"fmt"
"math"
"slices"
)
// UnsupportedError 表示当前几何实现无法展开的描边形态
type UnsupportedError string
// geometryPolyline 保存展开后的子路径,独立记录闭合状态
type geometryPolyline struct {
points []Point
@@ -26,16 +30,22 @@ type geometryPolyline struct {
direction Point
}
// Stroke 将曲线、虚线及线帽连接转换为非零填充轮廓
// Error 返回不支持的描边原因
// 返回: string 错误信息
func (err UnsupportedError) Error() string {
return string(err)
}
// Stroke 按端点和转角样式将曲线、虚线转换为非零填充轮廓
// 曲线按Tolerance展开,默认误差为0.001毫米,重叠轮廓保持同向绕数
// 结果未做布尔整理,按非零规则填充,区域运算需能处理重叠子路径的后端
// 入参: options 描边样式
// 返回: GeometryPath 描边轮廓, error 无效参数或精度限制
func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
if err := validateStroke(options); err != nil {
// 返回: Path 描边轮廓, error 无效参数或精度限制
func (p Path) Stroke(options StrokeOptions) (Path, error) {
if err := ValidateStroke(options); err != nil {
return nil, err
}
if err := p.validate(); err != nil {
if err := p.Validate(); err != nil {
return nil, err
}
if options.Width/2 == 0 {
@@ -68,7 +78,7 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
if err != nil {
return nil, err
}
var result GeometryPath
var result Path
for _, line := range lines {
parts := []geometryPolyline{line}
if len(dashes) != 0 {
@@ -87,7 +97,7 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
}
}
}
if err := result.validate(); err != nil {
if err := result.Validate(); err != nil {
return nil, err
}
return result, nil
@@ -96,7 +106,7 @@ func (p GeometryPath) Stroke(options StrokeOptions) (GeometryPath, error) {
// strokePolylines 自适应展开贝塞尔,弦长亏损同时约束虚线累计长度误差
// 入参: tolerance 展开误差
// 返回: []geometryPolyline 子路径, error 展开限制
func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, error) {
func (p Path) strokePolylines(tolerance float64) ([]geometryPolyline, error) {
curves, err := p.Curves(tolerance / 2)
if err != nil {
return nil, err
@@ -144,26 +154,26 @@ func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, er
if depth == 24 {
return fmt.Errorf("stroke curve tolerance cannot be met")
}
work := append([]Point(nil), points...)
left, right := make([]Point, len(points)), make([]Point, len(points))
var work, left, right [4]Point
copy(work[:], points)
for n := len(points); n > 0; n-- {
left[len(points)-n], right[n-1] = work[0], work[n-1]
for i := 0; i < n-1; i++ {
work[i] = geometryLerp(work[i], work[i+1], .5)
work[i] = Lerp(work[i], work[i+1], .5)
}
}
if err := flatten(left, budget/2, depth+1); err != nil {
if err := flatten(left[:len(points)], budget/2, depth+1); err != nil {
return err
}
return flatten(right, budget/2, depth+1)
return flatten(right[:len(points)], budget/2, depth+1)
}
for _, s := range curves {
switch s.Verb {
case GeometryMove:
case Move:
flush()
current, start = s.End, s.End
line.points = []Point{current}
case GeometryClose:
case ClosePath:
if drawn {
if current != start {
line.points = append(line.points, start)
@@ -175,11 +185,12 @@ func (p GeometryPath) strokePolylines(tolerance float64) ([]geometryPolyline, er
line.points = []Point{current}
default:
drawn = true
points := []Point{current}
if s.Verb == GeometryQuad || s.Verb == GeometryCubic {
var buffer [4]Point
points := append(buffer[:0], current)
if s.Verb == Quad || s.Verb == Cubic {
points = append(points, s.Control1)
}
if s.Verb == GeometryCubic {
if s.Verb == Cubic {
points = append(points, s.Control2)
}
points = append(points, s.End)
@@ -236,7 +247,7 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g
return nil, fmt.Errorf("stroke dash subdivision limit exceeded")
}
if pattern[index] == 0 && index%2 == 0 && len(run) == 0 {
result = append(result, geometryPolyline{points: []Point{geometryLerp(a, b, at/length)}, direction: Point{(b.X - a.X) / length, (b.Y - a.Y) / length}})
result = append(result, geometryPolyline{points: []Point{Lerp(a, b, at/length)}, direction: Point{(b.X - a.X) / length, (b.Y - a.Y) / length}})
}
index = (index + 1) % len(pattern)
remaining = pattern[index]
@@ -254,10 +265,10 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g
steps++
if index%2 == 0 {
if len(run) == 0 {
run = append(run, geometryLerp(a, b, at/length))
run = append(run, Lerp(a, b, at/length))
direction = Point{(b.X - a.X) / length, (b.Y - a.Y) / length}
}
end := geometryLerp(a, b, (at+step)/length)
end := Lerp(a, b, (at+step)/length)
if end != run[len(run)-1] {
run = append(run, end)
}
@@ -283,8 +294,8 @@ func (line geometryPolyline) dash(pattern []float64, total, offset float64) ([]g
// stroke 追加同向矩形、连接楔形及圆盘组成的非零填充区域
// 入参: result 已有轮廓, options 描边样式
// 返回: GeometryPath 填充轮廓, error 不支持的退化方形线帽
func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions) (GeometryPath, error) {
// 返回: Path 填充轮廓, error 不支持的退化方头端点
func (line geometryPolyline) stroke(result Path, options StrokeOptions) (Path, error) {
half := options.Width / 2
polygon := func(points ...Point) {
scale := 0.0
@@ -307,29 +318,29 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions)
points[i], points[j] = points[j], points[i]
}
}
result = append(result, GeometrySegment{Verb: GeometryMove, End: points[0]})
result = append(result, Segment{Verb: Move, End: points[0]})
for _, p := range points[1:] {
result = append(result, GeometrySegment{Verb: GeometryLine, End: p})
result = append(result, Segment{Verb: Line, End: p})
}
result = append(result, GeometrySegment{Verb: GeometryClose})
result = append(result, Segment{Verb: ClosePath})
}
disk := func(p Point) {
result = append(result, GeometrySegment{Verb: GeometryMove, End: Point{p.X + half, p.Y}},
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X - half, p.Y}},
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X + half, p.Y}},
GeometrySegment{Verb: GeometryClose})
result = append(result, Segment{Verb: Move, End: Point{p.X + half, p.Y}},
Segment{Verb: Arc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X - half, p.Y}},
Segment{Verb: Arc, RadiusX: half, RadiusY: half, Sweep: true, End: Point{p.X + half, p.Y}},
Segment{Verb: ClosePath})
}
points := line.points
if len(points) == 1 {
if line.closed {
return nil, fmt.Errorf("directionless closed stroke: %w", ErrBackendUnavailable)
return nil, UnsupportedError("directionless closed stroke")
}
if options.Cap == "Round" {
disk(points[0])
} else if options.Cap == "Square" {
d := line.direction
if d == (Point{}) {
return nil, fmt.Errorf("directionless square stroke cap: %w", ErrBackendUnavailable)
return nil, UnsupportedError("directionless square stroke cap")
}
p := points[0]
u, v := Point{d.X * half, d.Y * half}, Point{-d.Y * half, d.X * half}
@@ -337,7 +348,22 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions)
}
return result, nil
}
directions := make([]Point, len(points)-1)
segments := len(points) - 1
joins := segments - 1
caps := 0
if line.closed {
joins++
} else if options.Cap == "Round" {
caps = 8
} else if options.Cap == "Square" {
caps = 10
}
joinSize := 4
if options.Join == "" || options.Join == "Miter" {
joinSize = 5
}
result = slices.Grow(result, min(segments*5+joins*joinSize+caps, 1<<20-len(result)))
directions := make([]Point, segments)
for i := range directions {
a, b := points[i], points[i+1]
length := math.Hypot(b.X-a.X, b.Y-a.Y)
@@ -364,16 +390,13 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions)
if cross < 0 {
x, y = y, x
}
result = append(result, GeometrySegment{Verb: GeometryMove, End: p},
GeometrySegment{Verb: GeometryLine, End: x},
GeometrySegment{Verb: GeometryArc, RadiusX: half, RadiusY: half, Sweep: true, End: y},
GeometrySegment{Verb: GeometryClose})
result = append(result, Segment{Verb: Move, End: p},
Segment{Verb: Line, End: x},
Segment{Verb: Arc, RadiusX: half, RadiusY: half, Sweep: true, End: y},
Segment{Verb: ClosePath})
return
}
limit := options.MiterLimit
if limit == 0 {
limit = defaultMiterLimit
}
if options.Join == "" || options.Join == "Miter" {
t := ((y.X-x.X)*b.Y - (y.Y-x.Y)*b.X) / cross
m := Point{x.X + t*a.X, x.Y + t*a.Y}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package geom
import (
"fmt"
@@ -22,9 +22,9 @@ import (
// Transform 精确变换控制点和椭圆轴,退化弧沿投影极值分段保留折返
// 入参: matrix 仿射变换
// 返回: GeometryPath 独立路径, error 无效路径或数值溢出
func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
if err := p.validate(); err != nil {
// 返回: Path 独立路径, error 无效路径或数值溢出
func (p Path) Transform(matrix Matrix) (Path, error) {
if err := p.Validate(); err != nil {
return nil, err
}
for _, value := range matrix.Values() {
@@ -33,17 +33,14 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
}
}
matrixScale := math.Max(math.Max(math.Abs(matrix.a), math.Abs(matrix.b)), math.Max(math.Abs(matrix.c), math.Abs(matrix.d)))
determinant := 0.0
if matrixScale != 0 {
determinant = (matrix.a/matrixScale)*(matrix.d/matrixScale) - (matrix.b/matrixScale)*(matrix.c/matrixScale)
}
determinant, _ := matrixProductSum(matrix.a, matrix.d, -matrix.b, matrix.c)
transform := func(p Point) Point {
x, y := matrix.Transform(p.X, p.Y)
return Point{x, y}
}
var result GeometryPath
var result Path
if len(p) > 0 {
result = make(GeometryPath, 0, len(p))
result = make(Path, 0, len(p))
}
var current, start Point
for _, s := range p {
@@ -51,15 +48,15 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
original := s.End
s.End = transform(s.End)
switch s.Verb {
case GeometryMove:
case Move:
start = original
case GeometryQuad:
case Quad:
s.Control1 = transform(s.Control1)
case GeometryCubic:
case Cubic:
s.Control1, s.Control2 = transform(s.Control1), transform(s.Control2)
case GeometryClose:
case ClosePath:
original, s.End = start, transform(start)
case GeometryArc:
case Arc:
a, err := centerArc(current, originalSegment)
if err != nil {
return nil, err
@@ -69,11 +66,11 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
continue
}
if s.RadiusX == 0 || s.RadiusY == 0 {
s = GeometrySegment{Verb: GeometryLine, End: s.End}
s = Segment{Verb: Line, End: s.End}
} else {
a.center = transform(a.center)
a.u.X, a.u.Y = matrixVector(matrix, a.u.X, a.u.Y)
a.v.X, a.v.Y = matrixVector(matrix, a.v.X, a.v.Y)
a.u.X, a.u.Y = Vector(matrix, a.u.X, a.u.Y)
a.v.X, a.v.Y = Vector(matrix, a.v.X, a.v.Y)
scale := math.Max(math.Hypot(a.u.X, a.u.Y), math.Hypot(a.v.X, a.v.Y))
if !finite(scale) {
return nil, fmt.Errorf("arc transform overflow")
@@ -82,7 +79,7 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
if matrixScale != 0 {
return nil, fmt.Errorf("arc transform precision exhausted")
}
s = GeometrySegment{Verb: GeometryLine, End: s.End}
s = Segment{Verb: Line, End: s.End}
} else {
ux, uy, vx, vy := a.u.X/scale, a.u.Y/scale, a.v.X/scale, a.v.Y/scale
det := ux*vy - uy*vx
@@ -100,9 +97,9 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
}
slices.Sort(fractions)
for _, f := range slices.Compact(fractions) {
result = append(result, GeometrySegment{Verb: GeometryLine, End: a.point(a.theta + f*a.delta)})
result = append(result, Segment{Verb: Line, End: a.point(a.theta + f*a.delta)})
}
s = GeometrySegment{Verb: GeometryLine, End: s.End}
s = Segment{Verb: Line, End: s.End}
} else {
xx, yy, xy := ux*ux+vx*vx, uy*uy+vy*vy, ux*uy+vx*vy
radius := math.Sqrt((xx + yy + math.Hypot(xx-yy, 2*xy)) / 2)
@@ -121,32 +118,32 @@ func (p GeometryPath) Transform(matrix Matrix) (GeometryPath, error) {
result = append(result, s)
current = original
}
if err := result.validate(); err != nil {
if err := result.Validate(); err != nil {
return nil, err
}
return result, nil
}
// validate 校验公共路径指令、子路径起点和有限坐标
// Validate 校验公共路径指令、子路径起点和有限坐标
// 返回: error 无效路径
func (p GeometryPath) validate() error {
func (p Path) Validate() error {
started := false
for _, s := range p {
points := [3]Point{s.End, s.Control1, s.Control2}
count := 1
switch s.Verb {
case GeometryMove:
case Move:
started = true
case GeometryLine:
case GeometryQuad:
case Line:
case Quad:
count = 2
case GeometryCubic:
case Cubic:
count = 3
case GeometryArc:
case Arc:
if !finite(s.RadiusX) || !finite(s.RadiusY) || !finite(s.Rotation) || s.RadiusX < 0 || s.RadiusY < 0 {
return fmt.Errorf("invalid arc parameters")
}
case GeometryClose:
case ClosePath:
count = 0
default:
return fmt.Errorf("unsupported geometry command %d", s.Verb)
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bufio"
"encoding/binary"
"fmt"
"image"
"io"
"math/bits"
)
// bmpColorMask 保存一个BMP颜色分量的位域位置和取值范围
type bmpColorMask struct {
mask uint32
shift uint
Max uint32
}
// DecodeConfig 读取图片尺寸,BMP和TIFF只读取布局信息,不解码像素
// 入参: input 图片输入流
// 返回: image.Config 图片配置, string 图片格式, error 头部错误
func DecodeConfig(input io.Reader) (image.Config, string, error) {
buffer := bufio.NewReader(input)
prefix, _ := buffer.Peek(34)
if IsTIFF(prefix) {
layout, err := readTIFFLayout(buffer)
if err != nil {
return image.Config{}, "tiff", err
}
size := layout.DisplaySize()
return image.Config{Width: size.X, Height: size.Y}, "tiff", nil
}
if len(prefix) == 34 && string(prefix[:2]) == "BM" {
header := binary.LittleEndian.Uint32(prefix[14:18])
bpp := binary.LittleEndian.Uint16(prefix[28:30])
compression := binary.LittleEndian.Uint32(prefix[30:34])
if (bpp == 16 || bpp == 32 && compression == 3) && (header == 40 || header == 52 || header == 56 || header == 108 || header == 124) {
size := int(header) + 14
if header == 40 && compression == 3 {
size += 12
}
data := make([]byte, size)
if _, err := io.ReadFull(buffer, data); err != nil {
return image.Config{}, "bmp", err
}
config, err := decodeBMPConfig(data)
return config, "bmp", err
}
}
return image.DecodeConfig(buffer)
}
// sample 将位域采样值映射到八位通道,缺省透明度为不透明
// 入参: value 编码像素
// 返回: uint8 通道值
func (m bmpColorMask) sample(value uint32) uint8 {
if m.mask == 0 {
return 255
}
return uint8(uint64((value&m.mask)>>m.shift) * 255 / uint64(m.Max))
}
// BMPColorMasks 读取BMP真彩色通道布局,校验位域连续性、重叠及像素范围
// 入参: data 已校验头部的BMP数据, offset 像素偏移, bpp 像素位数, compression 编码方式
// 返回: [4]bmpColorMask 红绿蓝和透明度通道, error 格式错误
func BMPColorMasks(data []byte, offset int, bpp uint16, compression uint32) ([4]bmpColorMask, error) {
var result [4]bmpColorMask
if bpp != 16 && bpp != 24 && bpp != 32 {
return result, fmt.Errorf("unsupported bmp depth")
}
masks := [4]uint32{0xff0000, 0xff00, 0xff}
if bpp == 16 {
masks = [4]uint32{0x7c00, 0x3e0, 0x1f}
}
switch compression {
case 0:
case 3:
if bpp == 24 {
return result, fmt.Errorf("invalid bmp bitfield depth")
}
header := binary.LittleEndian.Uint32(data[14:18])
if offset < 66 || len(data) < 66 || header != 40 && header < 52 {
return result, fmt.Errorf("truncated bmp color masks")
}
for i := range 3 {
masks[i] = binary.LittleEndian.Uint32(data[54+i*4:])
}
if header >= 56 {
masks[3] = binary.LittleEndian.Uint32(data[66:70])
}
default:
return result, fmt.Errorf("unsupported bmp compression")
}
var used uint32
for i, mask := range masks {
if mask == 0 {
if i < 3 {
return result, fmt.Errorf("empty bmp color mask")
}
continue
}
shift := uint(bits.TrailingZeros32(mask))
maximum := mask >> shift
if mask&used != 0 || maximum&(maximum+1) != 0 || uint64(mask)>>bpp != 0 {
return result, fmt.Errorf("invalid bmp color mask")
}
result[i] = bmpColorMask{mask: mask, shift: shift, Max: maximum}
used |= mask
}
return result, nil
}
+340
View File
@@ -0,0 +1,340 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"compress/zlib"
"context"
"encoding/binary"
"fmt"
"hash/crc32"
"image"
"image/color"
"io"
"math"
"github.com/xiaoqidun/pdfgo"
)
// colorImage 保留源采样及独立的十六位显示像素
type colorImage struct {
*image.NRGBA64
source image.Image
}
// pngColor 保存PNG校准空间及缺省sRGB传递函数
type pngColor struct {
space *pdfgo.ColorSpace
linearSRGB bool
}
// pngColorProfile 读取PNG颜色元数据,ICC优先于sRGB及独立校准参数
// 入参: ctx 取消上下文, data 原始编码
// 返回: []byte ICC编码, *pngColor 校准参数, error 格式或配置错误
func pngColorProfile(ctx context.Context, data []byte) ([]byte, *pngColor, error) {
const profileLimit = 16 << 20
chunks := make(map[string][]byte)
for at := 8; at < len(data); {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
if len(data)-at < 8 {
return nil, nil, fmt.Errorf("truncated PNG chunk")
}
kind := string(data[at+4 : at+8])
if kind == "IDAT" || kind == "PLTE" {
break
}
if len(data)-at < 12 {
return nil, nil, fmt.Errorf("truncated PNG chunk")
}
size := uint64(binary.BigEndian.Uint32(data[at:]))
if size > uint64(len(data)-at-12) {
return nil, nil, fmt.Errorf("invalid PNG chunk length")
}
end := at + 8 + int(size)
switch kind {
case "iCCP", "sRGB", "gAMA", "cHRM":
if _, ok := chunks[kind]; ok {
return nil, nil, fmt.Errorf("invalid PNG %s chunk order", kind)
}
if crc32.ChecksumIEEE(data[at+4:end]) != binary.BigEndian.Uint32(data[end:]) {
return nil, nil, fmt.Errorf("invalid PNG %s checksum", kind)
}
chunks[kind] = data[at+8 : end]
}
at = end + 4
if kind == "IEND" {
break
}
}
if chunk, ok := chunks["iCCP"]; ok {
name := bytes.IndexByte(chunk, 0)
if name < 1 || name > 79 || len(chunk) < name+3 || chunk[name+1] != 0 {
return nil, nil, fmt.Errorf("invalid PNG ICC header")
}
for i, c := range chunk[:name] {
if c < 32 || c > 126 && c < 161 || c == 32 && (i == 0 || i == name-1 || chunk[i-1] == 32) {
return nil, nil, fmt.Errorf("invalid PNG ICC name")
}
}
input := bytes.NewReader(chunk[name+2:])
reader, err := zlib.NewReader(input)
if err != nil {
return nil, nil, err
}
profile, err := io.ReadAll(io.LimitReader(contextInput{ReadCloser: reader, ctx: ctx}, profileLimit+1))
closeErr := reader.Close()
if err != nil {
return nil, nil, err
}
if closeErr != nil {
return nil, nil, closeErr
}
if len(profile) > profileLimit {
return nil, nil, fmt.Errorf("PNG ICC profile exceeds size limit")
}
if input.Len() != 0 {
return nil, nil, fmt.Errorf("trailing PNG ICC data")
}
kind := "RGB "
if len(data) > 25 && (data[25] == 0 || data[25] == 4) {
kind = "GRAY"
}
if len(profile) < 132 || string(profile[16:20]) != kind {
return nil, nil, fmt.Errorf("ICC source model does not match PNG color space %q", kind)
}
return profile, nil, nil
}
if chunk, ok := chunks["sRGB"]; ok {
if len(chunk) != 1 || chunk[0] > 3 {
return nil, nil, fmt.Errorf("invalid PNG sRGB intent")
}
return nil, nil, nil
}
if len(chunks) == 0 {
return nil, nil, nil
}
coordinates := [8]float64{0.3127, 0.3290, 0.6400, 0.3300, 0.3000, 0.6000, 0.1500, 0.0600}
if chunk, ok := chunks["cHRM"]; ok {
if len(chunk) != 32 {
return nil, nil, fmt.Errorf("invalid PNG chromaticities")
}
for i := range coordinates {
coordinates[i] = float64(binary.BigEndian.Uint32(chunk[i*4:])) / 100000
}
}
var xyz [4][3]float64
for i := range xyz {
x, y := coordinates[2*i], coordinates[2*i+1]
if y <= 0 || x < 0 || x+y > 1 {
return nil, nil, fmt.Errorf("invalid PNG chromaticities")
}
xyz[i] = [3]float64{x / y, 1, (1 - x - y) / y}
}
white := xyz[0]
det := func(a, b, c [3]float64) float64 {
return a[0]*(b[1]*c[2]-b[2]*c[1]) - b[0]*(a[1]*c[2]-a[2]*c[1]) + c[0]*(a[1]*b[2]-a[2]*b[1])
}
d := det(xyz[1], xyz[2], xyz[3])
if math.Abs(d) < 1e-12 {
return nil, nil, fmt.Errorf("degenerate PNG chromaticities")
}
scale := [3]float64{det(white, xyz[2], xyz[3]) / d, det(xyz[1], white, xyz[3]) / d, det(xyz[1], xyz[2], white) / d}
var matrix [9]float64
for i := range 3 {
for j := range 3 {
matrix[3*i+j] = xyz[i+1][j] * scale[i]
}
}
gamma := [3]float64{1, 1, 1}
linear := true
if chunk, ok := chunks["gAMA"]; ok {
if len(chunk) != 4 || binary.BigEndian.Uint32(chunk) == 0 {
return nil, nil, fmt.Errorf("invalid PNG gamma")
}
g := 100000 / float64(binary.BigEndian.Uint32(chunk))
gamma = [3]float64{g, g, g}
linear = false
}
space, err := pdfgo.NewCalRGBColorSpace(white, [3]float64{}, gamma, matrix)
if err != nil {
return nil, nil, err
}
return nil, &pngColor{space: space, linearSRGB: linear}, nil
}
// jpegColorProfile 读取JPEG分段ICC配置,拒绝缺段、重复及模型不匹配
// 入参: ctx 取消上下文, data JPEG编码, model 源颜色模型
// 返回: []byte 独立配置编码,未声明时为nil, error 配置或取消错误
func jpegColorProfile(ctx context.Context, data []byte, model color.Model) ([]byte, error) {
var parts [255][]byte
count, found, total := 0, 0, 0
for at := 2; at < len(data); {
if err := ctx.Err(); err != nil {
return nil, err
}
if data[at] != 0xff {
return nil, fmt.Errorf("invalid JPEG marker")
}
for at < len(data) && data[at] == 0xff {
at++
}
if at == len(data) {
return nil, fmt.Errorf("truncated JPEG marker")
}
marker := data[at]
at++
if marker == 0xda || marker == 0xd9 {
break
}
if marker == 0x01 || marker >= 0xd0 && marker <= 0xd7 {
continue
}
if len(data)-at < 2 {
return nil, fmt.Errorf("truncated JPEG segment")
}
size := int(binary.BigEndian.Uint16(data[at:]))
if size < 2 || size > len(data)-at {
return nil, fmt.Errorf("invalid JPEG segment length")
}
segment := data[at+2 : at+size]
at += size
if marker != 0xe2 || !bytes.HasPrefix(segment, []byte("ICC_PROFILE\x00")) {
continue
}
if len(segment) <= 14 {
return nil, fmt.Errorf("truncated JPEG color profile")
}
index, n := int(segment[12]), int(segment[13])
if index == 0 || index > n || count != 0 && count != n || parts[index-1] != nil {
return nil, fmt.Errorf("invalid JPEG color profile sequence")
}
count, found = n, found+1
parts[index-1] = segment[14:]
total += len(segment) - 14
}
if found == 0 {
return nil, nil
}
if found != count {
return nil, fmt.Errorf("incomplete JPEG color profile")
}
profile := make([]byte, 0, total)
for _, part := range parts[:count] {
profile = append(profile, part...)
}
kind := "RGB "
if model == color.CMYKModel {
kind = "CMYK"
} else if model == color.GrayModel {
kind = "GRAY"
}
if len(profile) < 132 || string(profile[16:20]) != kind {
return nil, fmt.Errorf("ICC source model does not match JPEG color space %q", kind)
}
return profile, nil
}
// jpegDisplayImage 统一JPEG显示变换,保留源分量并精确复用相邻同色的ICC结果
// 入参: ctx 取消上下文, source 源像素, profile 颜色配置,可为nil
// 返回: image.Image 显示像素, error 变换或取消错误
func jpegDisplayImage(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
cmyk, ok := source.(*image.CMYK)
if !ok {
if profile != nil {
return applyTIFFColorProfile(ctx, source, profile)
}
return source, nil
}
bounds := cmyk.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx() > math.MaxInt/8/bounds.Dy() {
return nil, fmt.Errorf("JPEG color image dimensions exceed capacity")
}
pixels := image.NewNRGBA64(bounds)
var intent pdfgo.Name
if profile != nil {
intent = profile.DefaultIntent()
}
var previous color.CMYK
var previousPixel color.NRGBA64
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)&255 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
ink := cmyk.CMYKAt(x, y)
if profile == nil {
white := 255 - int(ink.K)
pixels.SetNRGBA64(x, y, color.NRGBA64{
R: uint16(max(0, white-int(ink.C))) * 257,
G: uint16(max(0, white-int(ink.M))) * 257,
B: uint16(max(0, white-int(ink.Y))) * 257,
A: 65535,
})
continue
}
if previousPixel.A != 0 && ink == previous {
pixels.SetNRGBA64(x, y, previousPixel)
continue
}
values := [4]float64{float64(ink.C) / 255, float64(ink.M) / 255, float64(ink.Y) / 255, float64(ink.K) / 255}
rgb, err := profile.SRGB(values[:], intent)
if err != nil {
return nil, err
}
previous = ink
previousPixel = color.NRGBA64{R: colorWord(rgb[0]), G: colorWord(rgb[1]), B: colorWord(rgb[2]), A: 65535}
pixels.SetNRGBA64(x, y, previousPixel)
}
}
return &colorImage{NRGBA64: pixels, source: source}, nil
}
// colorWord 将归一化颜色舍入为十六位分量
// 入参: value 颜色分量
// 返回: uint16 十六位分量
func colorWord(value float64) uint16 {
return uint16(math.Round(max(0, min(1, value)) * 65535))
}
// cmykGrayAt 从原始四色采样计算蒙版灰度,不应用显示配置
// 入参: x 横坐标, y 纵坐标
// 返回: color.Gray 蒙版灰度
func (p *planarTIFFImage) cmykGrayAt(x, y int) color.Gray {
if !image.Pt(x, y).In(p.bounds) {
return color.Gray{}
}
ink := p.inksAt(x, y)
maximum := uint32(65535)
if p.alpha == 1 {
maximum = uint32(ink[4])
if maximum == 0 {
return color.Gray{}
}
}
black := maximum - min(uint32(ink[3]), maximum)
var rgb [3]uint32
for i := range rgb {
rgb[i] = (maximum - min(uint32(ink[i]), maximum)) * black / maximum
if p.alpha != 1 {
rgb[i] = rgb[i] * uint32(ink[4]) / 65535
}
}
return color.Gray{Y: uint8((19595*rgb[0] + 38470*rgb[1] + 7471*rgb[2] + 1<<15) >> 24)}
}
+211
View File
@@ -0,0 +1,211 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"io"
"sync"
"sync/atomic"
"github.com/xiaoqidun/pdfgo"
)
// EncodedImage 保留PNG或JPEG原始编码,首次读取像素时解码
// Bytes返回只读数据,Image返回共享只读像素,解码错误由Image返回
type EncodedImage struct {
data []byte
format string
config image.Config
profile []byte
pngColor *pngColor
result atomic.Pointer[encodedImageResult]
mu sync.Mutex
pending chan struct{}
}
// encodedImageResult 保存已完成的像素解码结果,不保存取消或超时错误
type encodedImageResult struct {
image image.Image
err error
}
// NewEncodedImage 校验PNG或JPEG头信息后复制输入,不解码像素
// 入参: data 原始编码
// 返回: *EncodedImage 惰性图片, error 头信息或格式错误
func NewEncodedImage(data []byte) (*EncodedImage, error) {
img, err := newEncodedImage(data)
if err != nil {
return nil, err
}
img.data = bytes.Clone(data)
return img, nil
}
// Bytes 返回只读原始编码,调用方不得修改
// 返回: []byte 原始编码
func (img *EncodedImage) Bytes() []byte { return img.data }
// MIME 返回图片媒体类型
// 返回: string 媒体类型
func (img *EncodedImage) MIME() string { return "image/" + img.format }
// ColorModel 返回显示像素的颜色模型,不解码像素
// 返回: color.Model 颜色模型
func (img *EncodedImage) ColorModel() color.Model {
if img.profile != nil || img.pngColor != nil || img.config.ColorModel == color.CMYKModel {
return color.NRGBA64Model
}
return img.config.ColorModel
}
// Bounds 返回图片范围,不解码像素
// 返回: image.Rectangle 图片范围
func (img *EncodedImage) Bounds() image.Rectangle {
return image.Rect(0, 0, img.config.Width, img.config.Height)
}
// Image 首次解码图片并缓存结果或错误,支持并发读取
// 返回: image.Image 只读像素, error 解码错误
func (img *EncodedImage) Image() (image.Image, error) {
if result := img.result.Load(); result != nil {
return result.image, result.err
}
return img.ImageContext(context.Background())
}
// ImageContext 解码并共享只读像素,取消或超时后可重试,nil使用后台上下文
// 并发调用等待正在进行的解码,等待方取消不影响其他调用
// 入参: ctx 取消上下文
// 返回: image.Image 只读像素, error 解码或取消错误
func (img *EncodedImage) ImageContext(ctx context.Context) (image.Image, error) {
if ctx == nil {
ctx = context.Background()
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
if result := img.result.Load(); result != nil {
return result.image, result.err
}
img.mu.Lock()
if img.result.Load() != nil {
img.mu.Unlock()
continue
}
pending := img.pending
if pending == nil {
pending = make(chan struct{})
img.pending = pending
img.mu.Unlock()
return img.decodeContext(ctx, pending)
}
img.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-pending:
}
}
}
// decodeContext 执行本次解码,退出时唤醒等待方,只缓存完整结果及稳定错误
// 入参: ctx 取消上下文, pending 本次解码的完成通知
// 返回: image.Image 只读像素, error 解码或取消错误
func (img *EncodedImage) decodeContext(ctx context.Context, pending chan struct{}) (image.Image, error) {
defer func() {
img.mu.Lock()
img.pending = nil
close(pending)
img.mu.Unlock()
}()
pixels, _, err := image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(img.data)), ctx: ctx})
if err == nil && (img.format == "jpeg" || img.profile != nil) {
var profile *pdfgo.ColorSpace
if img.profile != nil {
profile, err = pdfgo.NewICCColorSpace(img.profile)
}
if err == nil {
pixels, err = jpegDisplayImage(ctx, pixels, profile)
}
}
if err == nil && img.pngColor != nil {
pixels, err = applyImageColorProfile(ctx, pixels, img.pngColor.space, img.pngColor.linearSRGB)
}
if cancelErr := ctx.Err(); cancelErr != nil {
return nil, cancelErr
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
img.result.Store(&encodedImageResult{image: pixels, err: err})
return pixels, err
}
// At 返回像素颜色,解码失败时返回透明色,绘制前应通过Image检查错误
// 入参: x 横坐标, y 纵坐标
// 返回: color.Color 像素颜色
func (img *EncodedImage) At(x, y int) color.Color {
decoded, err := img.Image()
if err != nil {
return color.Transparent
}
return decoded.At(x, y)
}
// newEncodedImage 接管编码数据并读取头信息
// 入参: data 原始编码
// 返回: *EncodedImage 惰性图片, error 头信息或格式错误
func newEncodedImage(data []byte) (*EncodedImage, error) {
return newEncodedImageContext(context.Background(), data)
}
// newEncodedImageContext 读取PNG或JPEG头信息,元数据扫描期间响应取消,不复制输入或解码像素
// 入参: ctx 取消上下文, data 只读图片编码
// 返回: *EncodedImage 惰性图片, error 头信息、格式或取消错误
func newEncodedImageContext(ctx context.Context, data []byte) (*EncodedImage, error) {
config, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if cancelErr := ctx.Err(); cancelErr != nil {
return nil, cancelErr
}
if err != nil {
return nil, err
}
if format != "jpeg" && format != "png" {
return nil, fmt.Errorf("unsupported encoded image %q", format)
}
img := &EncodedImage{data: data, format: format, config: config}
if format == "jpeg" {
img.profile, err = jpegColorProfile(ctx, data, config.ColorModel)
if err != nil {
return nil, err
}
}
if format == "png" {
img.profile, img.pngColor, err = pngColorProfile(ctx, data)
if err != nil {
return nil, err
}
}
return img, nil
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
// NormalizeFormat 标准化印章媒体类型
// 入参: s 原始媒体类型
// 返回: string 标准媒体类型
func NormalizeFormat(s string) string {
switch s {
case "jpg":
return "jpeg"
case "bmp":
return "bmp"
case "jb2", "gbig2":
return "jbig2"
default:
return s
}
}
+827
View File
@@ -0,0 +1,827 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
_ "image/gif"
"io"
_ "github.com/xiaoqidun/j2kgo"
_ "github.com/xiaoqidun/jbig2"
_ "golang.org/x/image/bmp"
"golang.org/x/image/tiff"
)
// transparentEdgeImage 共享只读像素并按需计算透明边缘和采样边距
type transparentEdgeImage struct {
source image.Image
rect image.Rectangle
}
// transparentEdgeImage64 按需补齐十六位像素边缘,保留原始只读图像
type transparentEdgeImage64 struct {
source image.Image
rect image.Rectangle
}
// Bounds 返回含采样边距的图像边界
// 返回: image.Rectangle 图像边界
func (s *transparentEdgeImage) Bounds() image.Rectangle { return s.rect }
// ColorModel 返回非预乘颜色模型
// 返回: color.Model 颜色模型
func (s *transparentEdgeImage) ColorModel() color.Model { return color.NRGBAModel }
// NRGBAAt 读取原始像素或按邻域补齐透明边缘,边界外透明
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.NRGBA 非预乘颜色
func (s *transparentEdgeImage) NRGBAAt(x, y int) color.NRGBA {
if !image.Pt(x, y).In(s.rect) {
return color.NRGBA{}
}
b := s.source.Bounds()
sx, sy := b.Min.X+min(max(x-1, 0), b.Dx()-1), b.Min.Y+min(max(y-1, 0), b.Dy()-1)
c := NRGBAAt(s.source, sx, sy)
if c.A == 0 {
if edge, ok := transparentEdgeColor(s.source, sx, sy); ok {
c = edge
}
}
if x == 0 || y == 0 || x == s.rect.Max.X-1 || y == s.rect.Max.Y-1 {
c = transparentPaddingColor(c)
}
return c
}
// At 返回非预乘像素颜色
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.Color 像素颜色
func (s *transparentEdgeImage) At(x, y int) color.Color { return s.NRGBAAt(x, y) }
// RGBA64At 返回预乘十六位像素,供采样器无装箱读取
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.RGBA64 预乘颜色
func (s *transparentEdgeImage) RGBA64At(x, y int) color.RGBA64 {
r, g, b, a := s.NRGBAAt(x, y).RGBA()
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
}
// Bounds 返回含采样边距的图像边界
// 返回: image.Rectangle 图像边界
func (s *transparentEdgeImage64) Bounds() image.Rectangle { return s.rect }
// ColorModel 返回十六位非预乘颜色模型
// 返回: color.Model 颜色模型
func (s *transparentEdgeImage64) ColorModel() color.Model { return color.NRGBA64Model }
// NRGBA64At 读取十六位原始颜色,透明邻域使用最小非零透明度保留边缘颜色
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.NRGBA64 非预乘颜色
func (s *transparentEdgeImage64) NRGBA64At(x, y int) color.NRGBA64 {
if !image.Pt(x, y).In(s.rect) {
return color.NRGBA64{}
}
b := s.source.Bounds()
sx, sy := b.Min.X+min(max(x-1, 0), b.Dx()-1), b.Min.Y+min(max(y-1, 0), b.Dy()-1)
c := NRGBA64At(s.source, sx, sy)
if c.A == 0 {
var best color.NRGBA64
for ny := max(sy-1, b.Min.Y); ny < min(sy+2, b.Max.Y); ny++ {
for nx := max(sx-1, b.Min.X); nx < min(sx+2, b.Max.X); nx++ {
v := NRGBA64At(s.source, nx, ny)
if v.A > best.A {
best = v
}
}
}
if best.A != 0 {
best.A = 1
c = best
}
}
if x == 0 || y == 0 || x == s.rect.Max.X-1 || y == s.rect.Max.Y-1 {
c.A = 1
}
return c
}
// At 返回非预乘十六位像素颜色
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.Color 像素颜色
func (s *transparentEdgeImage64) At(x, y int) color.Color { return s.NRGBA64At(x, y) }
// RGBA64At 返回保留原采样精度的预乘颜色
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.RGBA64 预乘颜色
func (s *transparentEdgeImage64) RGBA64At(x, y int) color.RGBA64 {
r, g, b, a := s.NRGBA64At(x, y).RGBA()
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
}
// Decode 解码图片数据
// 入参: data 图片数据
// 返回: image.Image 图片对象, string 图片格式, error 错误信息
func Decode(data []byte) (image.Image, string, error) {
return DecodeContext(context.Background(), data)
}
// DecodeContext 解码图片并传递取消状态,PNG和JPEG仍保留惰性像素解码
// 入参: ctx 取消上下文, data 只读图片编码
// 返回: image.Image 图片对象, string 图片格式, error 解码或取消错误
func DecodeContext(ctx context.Context, data []byte) (img image.Image, format string, err error) {
if err := ctx.Err(); err != nil {
return nil, "", err
}
defer func() {
if cancelErr := ctx.Err(); cancelErr != nil {
img, err = nil, cancelErr
}
}()
if IsJPEG(data) || IsPNG(data) {
img, err := newEncodedImageContext(ctx, data)
if err != nil {
return nil, "", err
}
return img, img.format, nil
}
if IsTIFF(data) {
layout, err := ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
if err != nil {
return nil, "tiff", err
}
img, err := DecodeTIFF(ctx, data, layout)
return img, "tiff", err
}
img, format, err = image.Decode(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if err == nil {
return img, NormalizeFormat(format), nil
}
if ctx.Err() != nil {
return nil, "", ctx.Err()
}
if img, err := DecodeBMPContext(ctx, data); err == nil {
return img, "bmp", nil
}
return nil, "", err
}
// DecodePixelsContext 完整解码图片,确保像素可读取并传递取消状态
// 入参: ctx 取消上下文, data 图片编码数据
// 返回: image.Image 解码像素, string 图片格式, error 解码或取消错误
func DecodePixelsContext(ctx context.Context, data []byte) (image.Image, string, error) {
img, format, err := DecodeContext(ctx, data)
if err != nil {
return nil, format, err
}
pixels, err := PixelDataContext(ctx, img)
return pixels, format, err
}
// IsJPEG 判断是否为JPEG图片数据
// 入参: data 图片数据
// 返回: bool 是否为JPEG图片数据
func IsJPEG(data []byte) bool {
return len(data) >= 2 && data[0] == 0xFF && data[1] == 0xD8
}
// IsPNG 判断是否为PNG图片数据
// 入参: data 图片数据
// 返回: bool 是否为PNG图片数据
func IsPNG(data []byte) bool {
return bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n"))
}
// DecodeConfigData 解码图片尺寸
// 入参: data 图片数据
// 返回: image.Config 图片尺寸, string 图片格式, error 错误信息
func DecodeConfigData(data []byte) (image.Config, string, error) {
return DecodeConfigDataContext(context.Background(), data)
}
// DecodeConfigDataContext 读取图片尺寸和颜色模型,传递布局解析中的取消状态
// 入参: ctx 取消上下文, data 图片数据
// 返回: image.Config 图片配置, string 图片格式, error 读取或取消错误
func DecodeConfigDataContext(ctx context.Context, data []byte) (config image.Config, format string, err error) {
if err := ctx.Err(); err != nil {
return image.Config{}, "", err
}
defer func() {
if cancelErr := ctx.Err(); cancelErr != nil {
config, err = image.Config{}, cancelErr
}
}()
if IsTIFF(data) {
layout, err := ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
if err != nil {
return image.Config{}, "tiff", err
}
profile, err := readTIFFColorProfile(ctx, data, layout)
if err != nil {
return image.Config{}, "tiff", err
}
var cfg image.Config
if layout.needsSampleView() {
img, _, err := tiffPlaneDirectories(ctx, data, layout)
if err != nil {
return image.Config{}, "tiff", err
}
cfg.ColorModel = img.ColorModel()
} else {
input := newTIFFDirectoryInput(data, layout.Offset)
input.ctx = ctx
cfg, err = tiff.DecodeConfig(input)
if err != nil {
return image.Config{}, "tiff", err
}
}
if profile != nil || layout.Transparency != nil {
cfg.ColorModel = color.NRGBA64Model
}
if layout.Transparency != nil {
if _, err := readTIFFColorProfile(ctx, data, *layout.Transparency); err != nil {
return image.Config{}, "tiff", err
}
if _, _, err := tiffPlaneDirectories(ctx, data, *layout.Transparency); err != nil {
return image.Config{}, "tiff", err
}
}
size := layout.DisplaySize()
cfg.Width, cfg.Height = size.X, size.Y
return cfg, "tiff", nil
}
cfg, format, err := image.DecodeConfig(contextInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx})
if err == nil {
return cfg, NormalizeFormat(format), nil
}
if cfg, err := decodeBMPConfig(data); err == nil {
return cfg, "bmp", nil
}
return image.Config{}, "", err
}
// decodeBMPConfig 解码BMP尺寸
// 入参: data 图片数据
// 返回: image.Config 图片尺寸, error 错误信息
func decodeBMPConfig(data []byte) (image.Config, error) {
offset, width, height, bpp, compression, err := ParseBMPHeader(data)
if err != nil {
return image.Config{}, err
}
if offset <= 0 || width <= 0 || height == 0 {
return image.Config{}, fmt.Errorf("unsupported bmp")
}
if _, err := BMPColorMasks(data, offset, bpp, compression); err != nil {
return image.Config{}, err
}
if height < 0 {
height = -height
}
return image.Config{ColorModel: color.NRGBAModel, Width: width, Height: height}, nil
}
// decodeBMPImage 解码BMP图片
// 入参: data 图片数据
// 返回: image.Image 图片对象, error 错误信息
func decodeBMPImage(data []byte) (image.Image, error) {
return DecodeBMPContext(context.Background(), data)
}
// DecodeBMPContext 解码BMP真彩色像素,逐行检查取消
// 入参: ctx 取消上下文, data 图片数据
// 返回: image.Image 图片对象, error 格式或取消错误
func DecodeBMPContext(ctx context.Context, data []byte) (image.Image, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
offset, width, height, bpp, compression, err := ParseBMPHeader(data)
if err != nil {
return nil, err
}
if offset > len(data) {
return nil, fmt.Errorf("invalid bmp pixel offset")
}
masks, err := BMPColorMasks(data, offset, bpp, compression)
if err != nil {
return nil, err
}
topDown := height < 0
if topDown {
height = -height
}
if width <= 0 || height <= 0 {
return nil, fmt.Errorf("invalid bmp size")
}
stride := ((int64(width)*int64(bpp) + 31) / 32) * 4
if stride > int64(len(data)-offset)/int64(height) {
return nil, fmt.Errorf("truncated bmp")
}
if width > int(^uint(0)>>1)/4/height {
return nil, fmt.Errorf("bmp dimensions exceed image capacity")
}
rowStride := int(stride)
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
if err := ctx.Err(); err != nil {
return nil, err
}
srcY := y
if !topDown {
srcY = height - 1 - y
}
row := data[offset+srcY*rowStride:]
for x := 0; x < width; x++ {
switch bpp {
case 16, 32:
var value uint32
if bpp == 16 {
value = uint32(binary.LittleEndian.Uint16(row[x*2:]))
} else {
value = binary.LittleEndian.Uint32(row[x*4:])
}
p := y*img.Stride + x*4
for i, mask := range masks {
img.Pix[p+i] = mask.sample(value)
}
case 24:
p := x * 3
img.SetNRGBA(x, y, color.NRGBA{R: row[p+2], G: row[p+1], B: row[p], A: 255})
}
}
}
return img, nil
}
// ParseBMPHeader 解析BMP文件头
// 入参: data 图片数据
// 返回: int 像素偏移, int 宽度, int 高度, uint16 位数, uint32 压缩方式, error 错误信息
func ParseBMPHeader(data []byte) (int, int, int, uint16, uint32, error) {
if len(data) < 54 || string(data[:2]) != "BM" {
return 0, 0, 0, 0, 0, fmt.Errorf("not bmp")
}
offset := int(binary.LittleEndian.Uint32(data[10:14]))
dibSize := binary.LittleEndian.Uint32(data[14:18])
if dibSize < 40 {
return 0, 0, 0, 0, 0, fmt.Errorf("unsupported bmp")
}
if uint64(dibSize)+14 > uint64(len(data)) || offset < 0 || uint64(offset) < uint64(dibSize)+14 {
return 0, 0, 0, 0, 0, fmt.Errorf("invalid bmp pixel offset")
}
width := int(int32(binary.LittleEndian.Uint32(data[18:22])))
height := int(int32(binary.LittleEndian.Uint32(data[22:26])))
planes := binary.LittleEndian.Uint16(data[26:28])
bpp := binary.LittleEndian.Uint16(data[28:30])
compression := binary.LittleEndian.Uint32(data[30:34])
if planes != 1 || width <= 0 || height == 0 || int64(height) == -int64(^uint(0)>>1)-1 {
return 0, 0, 0, 0, 0, fmt.Errorf("invalid bmp")
}
return offset, width, height, bpp, compression, nil
}
// PixelSource 获取图片像素源
// 入参: img 图片对象
// 返回: image.Image 图片像素源
func PixelSource(img image.Image) image.Image {
if decoded, err := PixelData(img); err == nil {
return decoded
}
return img
}
// PixelData 获取像素并保留惰性解码错误,供绘制和像素处理入口校验
// 入参: img 图片对象
// 返回: image.Image 解码像素, error 解码错误
func PixelData(img image.Image) (image.Image, error) {
if src, ok := img.(interface{ Image() (image.Image, error) }); ok {
return src.Image()
}
return img, nil
}
// PixelDataContext 获取像素并传递取消状态,兼容不接收上下文的图像实现
// 入参: ctx 取消上下文,nil使用后台上下文, img 图片对象
// 返回: image.Image 解码像素, error 解码或取消错误
func PixelDataContext(ctx context.Context, img image.Image) (image.Image, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
if src, ok := img.(interface {
ImageContext(context.Context) (image.Image, error)
}); ok {
return src.ImageContext(ctx)
}
pixels, err := PixelData(img)
if cancelErr := ctx.Err(); cancelErr != nil {
return nil, cancelErr
}
return pixels, err
}
// GrayAt 获取图片灰度像素
// 入参: img 图片对象, x X坐标, y Y坐标
// 返回: color.Gray 灰度像素
func GrayAt(img image.Image, x, y int) color.Gray {
switch src := img.(type) {
case *EncodedImage:
pixels, err := src.Image()
if err != nil {
return color.Gray{}
}
return GrayAt(pixels, x, y)
case *colorImage:
return GrayAt(src.source, x, y)
case *maskedTIFFImage:
if src.alphaAt(x, y) == 0 {
return color.Gray{}
}
point := src.samplePoint(src.source.Bounds(), x, y)
return GrayAt(src.source, point.X, point.Y)
case *transparentEdgeImage:
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
return color.Gray{}
}
bounds := src.source.Bounds()
return GrayAt(src.source, bounds.Min.X+x-1, bounds.Min.Y+y-1)
case *transparentEdgeImage64:
if x <= src.rect.Min.X || y <= src.rect.Min.Y || x >= src.rect.Max.X-1 || y >= src.rect.Max.Y-1 {
return color.Gray{}
}
bounds := src.source.Bounds()
return GrayAt(src.source, bounds.Min.X+x-1, bounds.Min.Y+y-1)
case *orientedTIFFImage:
point, ok := src.sourcePoint(x, y)
if !ok {
return color.Gray{}
}
return GrayAt(src.source, point.X, point.Y)
case *planarTIFFImage:
if src.cmyk {
return src.cmykGrayAt(x, y)
}
}
if src, ok := img.(*image.Gray); ok {
return src.GrayAt(x, y)
}
if src, ok := img.(*image.Paletted); ok {
return color.GrayModel.Convert(src.At(x, y)).(color.Gray)
}
if src, ok := img.(image.RGBA64Image); ok {
r, g, b, _ := src.RGBA64At(x, y).RGBA()
return color.Gray{Y: uint8((19595*r + 38470*g + 7471*b + 1<<15) >> 24)}
}
return color.GrayModel.Convert(img.At(x, y)).(color.Gray)
}
// NRGBAAt 读取八位非预乘像素,缩减位深时保留原始颜色分量
// 入参: img 图片对象, x X坐标, y Y坐标
// 返回: color.NRGBA NRGBA像素
func NRGBAAt(img image.Image, x, y int) color.NRGBA {
switch src := img.(type) {
case *image.NRGBA:
return src.NRGBAAt(x, y)
case *image.NRGBA64:
c := src.NRGBA64At(x, y)
return color.NRGBA{R: uint8(c.R >> 8), G: uint8(c.G >> 8), B: uint8(c.B >> 8), A: uint8(c.A >> 8)}
case interface{ NRGBAAt(int, int) color.NRGBA }:
return src.NRGBAAt(x, y)
case interface{ NRGBA64At(int, int) color.NRGBA64 }:
c := src.NRGBA64At(x, y)
return color.NRGBA{R: uint8(c.R >> 8), G: uint8(c.G >> 8), B: uint8(c.B >> 8), A: uint8(c.A >> 8)}
case *image.RGBA, *image.RGBA64, *image.Gray, *image.Gray16, *image.Alpha, *image.Alpha16, *image.CMYK, *image.YCbCr, *image.NYCbCrA:
c := src.(image.RGBA64Image).RGBA64At(x, y)
if c.A == 0 {
return color.NRGBA{}
}
r, g, b, a := uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A)
if a != 0xffff {
r = r * 0xffff / a
g = g * 0xffff / a
b = b * 0xffff / a
}
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
}
value := img.At(x, y)
if c, ok := value.(color.NRGBA64); ok {
return color.NRGBA{R: uint8(c.R >> 8), G: uint8(c.G >> 8), B: uint8(c.B >> 8), A: uint8(c.A >> 8)}
}
return color.NRGBAModel.Convert(value).(color.NRGBA)
}
// AlphaAt 读取十六位透明度,原生图像避免颜色装箱
// 入参: img 图像, x 横坐标, y 纵坐标
// 返回: uint32 十六位透明度
func AlphaAt(img image.Image, x, y int) uint32 {
switch source := img.(type) {
case *maskedTIFFImage:
return source.alphaAt(x, y)
case *image.YCbCr, *image.CMYK, *image.Gray, *image.Gray16:
return 65535
case *image.NYCbCrA:
if !image.Pt(x, y).In(source.Rect) {
return 0
}
return uint32(source.A[source.AOffset(x, y)]) * 257
case *orientedTIFFImage:
point, ok := source.sourcePoint(x, y)
if !ok {
return 0
}
return AlphaAt(source.source, point.X, point.Y)
case *planarTIFFImage:
if !image.Pt(x, y).In(source.bounds) {
return 0
}
if source.alpha != 0 {
index := 3
if source.cmyk {
index = 4
} else if source.gray || len(source.palette) != 0 {
index = 1
}
return uint32(source.sampleAt(index, x, y))
}
if len(source.palette) != 0 {
index := uint32(source.sampleAt(0, x, y)) * uint32(len(source.palette)-1) / 65535
return uint32(source.palette[index].A)
}
return 65535
case *image.RGBA:
if !(image.Point{X: x, Y: y}).In(source.Rect) {
return 0
}
return uint32(source.Pix[source.PixOffset(x, y)+3]) * 257
case *image.RGBA64:
if !(image.Point{X: x, Y: y}).In(source.Rect) {
return 0
}
i := source.PixOffset(x, y) + 6
return uint32(source.Pix[i])<<8 | uint32(source.Pix[i+1])
case *image.NRGBA:
return uint32(source.NRGBAAt(x, y).A) * 257
case *image.NRGBA64:
return uint32(source.NRGBA64At(x, y).A)
case *image.Alpha:
return uint32(source.AlphaAt(x, y).A) * 257
case *image.Alpha16:
return uint32(source.Alpha16At(x, y).A)
}
if source, ok := img.(image.RGBA64Image); ok {
return uint32(source.RGBA64At(x, y).A)
}
_, _, _, alpha := img.At(x, y).RGBA()
return alpha
}
// HighPrecision 判断颜色模型及调色板是否需要保留十六位采样
// 入参: img 图片对象
// 返回: bool 是否使用十六位颜色或透明度
func HighPrecision(img image.Image) bool {
model := img.ColorModel()
if palette, ok := model.(color.Palette); ok {
for _, value := range palette {
switch value.(type) {
case color.RGBA, color.NRGBA, color.Gray, color.Alpha, color.CMYK, color.YCbCr, color.NYCbCrA:
default:
pixel := color.NRGBA64Model.Convert(value).(color.NRGBA64)
if pixel.R%257 != 0 || pixel.G%257 != 0 || pixel.B%257 != 0 || pixel.A%257 != 0 {
return true
}
}
}
return false
}
return model == color.NRGBA64Model || model == color.RGBA64Model || model == color.Gray16Model || model == color.Alpha16Model
}
// NRGBA64At 读取十六位非预乘像素,保留透明像素颜色,原生图像避免颜色装箱
// 入参: img 图片对象, x X坐标, y Y坐标
// 返回: color.NRGBA64 NRGBA64像素
func NRGBA64At(img image.Image, x, y int) color.NRGBA64 {
switch src := img.(type) {
case interface{ NRGBA64At(int, int) color.NRGBA64 }:
return src.NRGBA64At(x, y)
case *image.NRGBA:
c := src.NRGBAAt(x, y)
return color.NRGBA64{R: uint16(c.R) * 257, G: uint16(c.G) * 257, B: uint16(c.B) * 257, A: uint16(c.A) * 257}
case *image.Gray:
v := uint16(src.GrayAt(x, y).Y) * 257
return color.NRGBA64{R: v, G: v, B: v, A: 0xffff}
case *image.Gray16:
c := src.Gray16At(x, y)
return color.NRGBA64{R: c.Y, G: c.Y, B: c.Y, A: 0xffff}
case *image.Alpha, *image.Alpha16:
c := src.(image.RGBA64Image).RGBA64At(x, y)
if c.A == 0 {
return color.NRGBA64{}
}
return color.NRGBA64{R: 0xffff, G: 0xffff, B: 0xffff, A: c.A}
case *image.RGBA, *image.RGBA64, *image.CMYK, *image.YCbCr, *image.NYCbCrA:
c := src.(image.RGBA64Image).RGBA64At(x, y)
if c.A == 0 {
return color.NRGBA64{}
}
r, g, b, a := uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A)
if a != 0xffff {
r = r * 0xffff / a
g = g * 0xffff / a
b = b * 0xffff / a
}
return color.NRGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: c.A}
}
value := img.At(x, y)
if c, ok := value.(color.NRGBA); ok {
return color.NRGBA64{R: uint16(c.R) * 257, G: uint16(c.G) * 257, B: uint16(c.B) * 257, A: uint16(c.A) * 257}
}
return color.NRGBA64Model.Convert(value).(color.NRGBA64)
}
// transparentEdgeColor 获取透明像素相邻的可见颜色
// 入参: source 图片对象, x X坐标, y Y坐标
// 返回: color.NRGBA 颜色, bool 是否存在
func transparentEdgeColor(source image.Image, x, y int) (color.NRGBA, bool) {
img, ok := source.(*image.NRGBA)
if !ok {
bounds := source.Bounds()
var best color.NRGBA
for ny := max(y-1, bounds.Min.Y); ny < min(y+2, bounds.Max.Y); ny++ {
for nx := max(x-1, bounds.Min.X); nx < min(x+2, bounds.Max.X); nx++ {
if nx == x && ny == y {
continue
}
pixel := NRGBAAt(source, nx, ny)
if pixel.A > best.A {
best = pixel
if best.A == 255 {
best.A = 1
return best, true
}
}
}
}
if best.A == 0 {
return color.NRGBA{}, false
}
best.A = 1
return best, true
}
bounds := img.Bounds()
best, alpha := 0, uint8(0)
left, right := max(x-1, bounds.Min.X), min(x+2, bounds.Max.X)
scan:
for ny := max(y-1, bounds.Min.Y); ny < min(y+2, bounds.Max.Y); ny++ {
offset := img.PixOffset(left, ny)
for nx := left; nx < right; nx++ {
if (nx != x || ny != y) && img.Pix[offset+3] > alpha {
best, alpha = offset, img.Pix[offset+3]
if alpha == 255 {
break scan
}
}
offset += 4
}
}
if alpha == 0 {
return color.NRGBA{}, false
}
return color.NRGBA{R: img.Pix[best], G: img.Pix[best+1], B: img.Pix[best+2], A: 1}, true
}
// transparentPaddingColor 获取透明补齐颜色
// 入参: c 边缘颜色
// 返回: color.NRGBA 补齐颜色
func transparentPaddingColor(c color.NRGBA) color.NRGBA {
c.A = 1
return c
}
// WithTransparentEdge 扫描透明边缘并共享只读像素,保留原图采样精度
// 入参: ctx 取消上下文,nil使用后台上下文, img 图片对象
// 返回: image.Image 补边图像, int 补边像素数, error 解码或取消错误
func WithTransparentEdge(ctx context.Context, img image.Image) (image.Image, int, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
if img == nil {
return img, 0, nil
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w == 0 || h == 0 {
return img, 0, nil
}
if src, ok := img.(*EncodedImage); ok && src.MIME() == "image/jpeg" {
if src.profile != nil || src.config.ColorModel == color.CMYKModel {
pixels, err := src.ImageContext(ctx)
return pixels, 0, err
}
return img, 0, nil
}
source, err := PixelDataContext(ctx, img)
if err != nil {
return nil, 0, err
}
if encoded, ok := img.(*EncodedImage); ok && (encoded.profile != nil || encoded.pngColor != nil) {
img = source
}
switch source.(type) {
case *image.Gray, *image.Gray16, *image.YCbCr, *image.CMYK:
return img, 0, nil
}
hasZero, hasVisible := false, false
var pixels []byte
var stride, step int
wideAlpha := false
switch src := source.(type) {
case *image.NRGBA:
pixels, stride, step = src.Pix, src.Stride, 4
case *image.RGBA:
pixels, stride, step = src.Pix, src.Stride, 4
case *image.Alpha:
pixels, stride, step = src.Pix, src.Stride, 1
case *image.NRGBA64:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
case *image.RGBA64:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
case *image.Alpha16:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 2, true
case *colorImage:
pixels, stride, step, wideAlpha = src.Pix, src.Stride, 8, true
}
if pixels != nil {
hasZero = pixels[step-1] == 0 && (!wideAlpha || pixels[step-2] == 0)
hasVisible = !hasZero
scan:
for y := 0; y < h; y++ {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
offset := y*stride + step - 1
for x := 0; x < w; x++ {
if x&255 == 0 {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
}
if (pixels[offset] == 0 && (!wideAlpha || pixels[offset-1] == 0)) != hasZero {
hasZero, hasVisible = true, true
break scan
}
offset += step
}
}
} else {
for y := bounds.Min.Y; y < bounds.Max.Y && !(hasZero && hasVisible); y++ {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)&255 == 0 {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
}
if AlphaAt(source, x, y) == 0 {
hasZero = true
} else {
hasVisible = true
}
if hasZero && hasVisible {
break
}
}
}
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
if !hasZero || !hasVisible {
return img, 0, nil
}
if HighPrecision(source) {
return &transparentEdgeImage64{source: source, rect: image.Rect(0, 0, w+2, h+2)}, 1, nil
}
return &transparentEdgeImage{source: source, rect: image.Rect(0, 0, w+2, h+2)}, 1, nil
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"context"
"io"
)
// contextInput 在读取图像编码时检查调用方取消状态
type contextInput struct {
io.ReadCloser
ctx context.Context
}
// Read 检查取消状态后读取原始数据
// 入参: data 数据缓冲区
// 返回: int 读取字节数, error 读取或取消错误
func (r contextInput) Read(data []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.ReadCloser.Read(data)
}
+422
View File
@@ -0,0 +1,422 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"hash/crc32"
"image"
"math"
"math/big"
)
// PNGDensityChunkSize 为PNG像素密度块占用的字节数
const PNGDensityChunkSize = 21
// Density 保存横纵像素密度,Unit为0表示比例、1表示每米像素数
type Density struct {
X, Y float64
Unit byte
}
// PageInfo 图片方向与像素密度,不改变原始编码
type PageInfo struct {
Orientation int
XDPI, YDPI float64
}
// PageMetadataContext 读取图片方向与密度,扫描目录或数据段时响应取消
// TIFF方向由解码器处理,JPEG的EXIF密度优先于JFIF
// 入参: ctx 取消上下文, data 图片数据, format 图像格式, dpi 缺少物理密度时使用的DPI
// 返回: PageInfo 方向与横纵DPI, error 元数据或取消错误
func PageMetadataContext(ctx context.Context, data []byte, format string, dpi float64) (PageInfo, error) {
info := PageInfo{Orientation: 1, XDPI: dpi, YDPI: dpi}
if err := ctx.Err(); err != nil {
return info, err
}
var exif []byte
var directory uint32
scaleX, scaleY := 1.0, 1.0
if format == "tiff" {
layout, err := ReadTIFFLayoutContext(ctx, bytes.NewReader(data))
if err != nil {
return info, err
}
directory = layout.Offset
exif = data
original, display := layout.size(), layout.DisplaySize()
scaleX, scaleY = float64(display.X)/float64(original.X), float64(display.Y)/float64(original.Y)
}
if format == "bmp" && len(data) >= 54 && binary.LittleEndian.Uint32(data[14:18]) >= 40 {
x := int32(binary.LittleEndian.Uint32(data[38:42]))
y := int32(binary.LittleEndian.Uint32(data[42:46]))
if x > 0 && y > 0 {
info.XDPI, info.YDPI = float64(x)*0.0254, float64(y)*0.0254
}
}
if format == "png" {
for offset := 8; offset+12 <= len(data); {
if err := ctx.Err(); err != nil {
return info, err
}
n := uint64(binary.BigEndian.Uint32(data[offset:]))
if n > uint64(len(data)-offset-12) {
return info, fmt.Errorf("truncated PNG chunk")
}
end := offset + 8 + int(n)
kind, payload := string(data[offset+4:offset+8]), data[offset+8:end]
if kind == "pHYs" || kind == "eXIf" {
if crc32.ChecksumIEEE(data[offset+4:end]) != binary.BigEndian.Uint32(data[end:]) {
return info, fmt.Errorf("invalid PNG %s checksum", kind)
}
}
if kind == "pHYs" && len(payload) == 9 {
x, y := float64(binary.BigEndian.Uint32(payload)), float64(binary.BigEndian.Uint32(payload[4:]))
if x > 0 && y > 0 {
if payload[8] == 1 {
info.XDPI, info.YDPI = x*0.0254, y*0.0254
} else if payload[8] == 0 {
info.YDPI = dpi * y / x
}
}
} else if kind == "eXIf" {
exif = payload
}
offset = end + 4
if kind == "IEND" {
break
}
}
} else if format == "jpeg" {
for offset := 2; offset < len(data); {
if err := ctx.Err(); err != nil {
return info, err
}
if data[offset] != 0xff {
return info, fmt.Errorf("invalid JPEG marker")
}
for offset < len(data) && data[offset] == 0xff {
offset++
}
if offset >= len(data) {
break
}
marker := data[offset]
offset++
if marker == 0xda || marker == 0xd9 {
break
}
if marker == 0x01 || marker >= 0xd0 && marker <= 0xd7 {
continue
}
if offset+2 > len(data) {
return info, fmt.Errorf("truncated JPEG marker")
}
n := int(binary.BigEndian.Uint16(data[offset:]))
if n < 2 || n > len(data)-offset {
return info, fmt.Errorf("truncated JPEG segment")
}
payload := data[offset+2 : offset+n]
if marker == 0xe0 && len(payload) >= 14 && bytes.Equal(payload[:5], []byte("JFIF\x00")) {
x, y := float64(binary.BigEndian.Uint16(payload[8:])), float64(binary.BigEndian.Uint16(payload[10:]))
if x > 0 && y > 0 {
switch payload[7] {
case 0:
info.YDPI = dpi * y / x
case 1:
info.XDPI, info.YDPI = x, y
case 2:
info.XDPI, info.YDPI = x*2.54, y*2.54
}
}
} else if marker == 0xe1 && bytes.HasPrefix(payload, []byte("Exif\x00\x00")) {
exif = payload[6:]
}
offset += n
}
}
if len(exif) != 0 {
orientation, x, y, err := imageExifMetadata(exif, dpi, directory)
if err != nil {
return info, err
}
info.Orientation = orientation
if (format == "jpeg" || format == "tiff") && x > 0 && y > 0 {
info.XDPI, info.YDPI = x, y
}
if format == "tiff" {
if orientation >= 5 {
info.XDPI, info.YDPI = info.YDPI, info.XDPI
}
info.Orientation = 1
}
}
info.XDPI, info.YDPI = info.XDPI*scaleX, info.YDPI*scaleY
return info, ctx.Err()
}
// imageExifMetadata 只读取主图IFD中的方向和密度,不跟随缩略图与其他目录
// 入参: data TIFF或EXIF数据, dpi 无绝对密度单位时的横向DPI,零值不换算, directory 目录偏移,零值使用首目录
// 返回: int EXIF方向值,未声明时为1, float64 横向DPI, float64 纵向DPI, error 元数据错误
func imageExifMetadata(data []byte, dpi float64, directory uint32) (int, float64, float64, error) {
invalid := fmt.Errorf("invalid EXIF image metadata")
if len(data) < 8 {
return 0, 0, 0, invalid
}
var order binary.ByteOrder
switch string(data[:2]) {
case "II":
order = binary.LittleEndian
case "MM":
order = binary.BigEndian
default:
return 0, 0, 0, invalid
}
if order.Uint16(data[2:]) != 42 {
return 0, 0, 0, invalid
}
offset := uint64(order.Uint32(data[4:]))
if directory != 0 {
offset = uint64(directory)
}
if offset < 8 || offset+2 > uint64(len(data)) {
return 0, 0, 0, invalid
}
count := uint64(order.Uint16(data[offset:]))
if count*12+offset+6 > uint64(len(data)) {
return 0, 0, 0, invalid
}
orientation, unit := 1, 2
var x, y float64
for i := uint64(0); i < count; i++ {
entry := data[offset+2+i*12 : offset+14+i*12]
tag, kind, count := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:])
switch tag {
case 0x112, 0x128:
if kind != 3 || count != 1 {
return 0, 0, 0, invalid
}
value := int(order.Uint16(entry[8:]))
if tag == 0x112 {
if value < 1 || value > 8 {
return 0, 0, 0, invalid
}
orientation = value
} else {
unit = value
}
case 0x11a, 0x11b:
at := uint64(order.Uint32(entry[8:]))
if kind != 5 || count != 1 || at+8 > uint64(len(data)) {
return 0, 0, 0, invalid
}
n, d := order.Uint32(data[at:]), order.Uint32(data[at+4:])
if n > 0 && d > 0 {
if tag == 0x11a {
x = float64(n) / float64(d)
} else {
y = float64(n) / float64(d)
}
}
}
}
if unit == 3 {
x, y = x*2.54, y*2.54
} else if unit == 1 && dpi > 0 && x > 0 && y > 0 {
x, y = dpi, dpi*y/x
} else if unit != 2 {
x, y = 0, 0
}
return orientation, x, y, nil
}
// BMPDensity 提取已校验BMP头中的每米像素数
// 入参: data 含至少40字节信息头的BMP编码
// 返回: Density 像素密度, bool 密度是否可保留
func BMPDensity(data []byte) (Density, bool) {
xdpi, ydpi := int32(binary.LittleEndian.Uint32(data[38:42])), int32(binary.LittleEndian.Uint32(data[42:46]))
if xdpi < 0 || ydpi < 0 {
return Density{}, false
}
return Density{X: float64(xdpi), Y: float64(ydpi), Unit: 1}, true
}
// TIFFConversionDensity 校验可转换的TIFF字段并提取规范化后的像素密度
// 色彩配置、预乘透明度、扩展字段及其他目录需保留原始编码
// 入参: data TIFF编码, layout 已校验的主图布局
// 返回: Density PNG像素密度, bool 是否可完整转换
func TIFFConversionDensity(data []byte, layout TIFFLayout) (Density, bool) {
var density Density
var order binary.ByteOrder = binary.LittleEndian
if data[0] == 'M' {
order = binary.BigEndian
}
if layout.Next != 0 || layout.Offset != order.Uint32(data[4:]) || layout.Reduced {
return density, false
}
offset := int(layout.Offset)
count := int(order.Uint16(data[offset:]))
unit := uint32(2)
var resolutions [2][2]uint32
for i := range count {
entry := data[offset+2+i*12 : offset+14+i*12]
tag, kind, number := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:])
value := order.Uint32(entry[8:])
if kind == 3 && number == 1 {
value = uint32(order.Uint16(entry[8:]))
}
switch tag {
case 256, 257, 258, 259, 273, 274, 277, 278, 279, 320, 322, 323, 324, 325:
case 262:
if value == 5 {
return density, false
}
case 254:
if value != 0 {
return density, false
}
case 255:
if value != 1 {
return density, false
}
case 266:
if kind != 3 || number != 1 || value != 1 && value != 2 {
return density, false
}
case 284:
if kind != 3 || number != 1 || value != 1 && value != 2 {
return density, false
}
case 292, 293:
mask := uint32(7)
if tag == 293 {
mask = 2
}
if kind != 4 || number != 1 || value & ^mask != 0 {
return density, false
}
case 317:
if kind != 3 || number != 1 || value != 1 && value != 2 {
return density, false
}
case 338:
if kind != 3 || number != 1 || value != 2 {
return density, false
}
case 339:
if kind != 3 || number == 0 || number > 4 {
return density, false
}
values := entry[8:]
if number > 2 {
if uint64(value)+uint64(number)*2 > uint64(len(data)) {
return density, false
}
values = data[value:]
}
for j := uint32(0); j < number; j++ {
if order.Uint16(values[j*2:]) != 1 {
return density, false
}
}
case 282, 283:
if kind != 5 || number != 1 || uint64(value)+8 > uint64(len(data)) {
return density, false
}
resolutions[tag-282] = [2]uint32{order.Uint32(data[value:]), order.Uint32(data[value+4:])}
case 296:
if kind != 3 || number != 1 || value < 1 || value > 3 {
return density, false
}
unit = value
default:
return density, false
}
}
if resolutions == [2][2]uint32{} {
return density, true
}
x, y := resolutions[0], resolutions[1]
if x[0] == 0 || x[1] == 0 || y[0] == 0 || y[1] == 0 {
return density, false
}
if unit == 1 {
a, b := uint64(x[0])*uint64(y[1]), uint64(y[0])*uint64(x[1])
divisor, next := a, b
for next != 0 {
divisor, next = next, divisor%next
}
density.X, density.Y = float64(a/divisor), float64(b/divisor)
} else {
factor := 1 / 0.0254
if unit == 3 {
factor = 100
}
density = Density{X: float64(x[0]) / float64(x[1]) * factor, Y: float64(y[0]) / float64(y[1]) * factor, Unit: 1}
}
if math.Round(density.X) < 1 || math.Round(density.Y) < 1 || math.Round(density.X) > math.MaxInt32 || math.Round(density.Y) > math.MaxInt32 {
return density, false
}
if layout.Orientation >= 5 {
density.X, density.Y = density.Y, density.X
}
return density, true
}
// Scale 按最终像素尺寸保留绝对密度或精确相对比例
// 入参: before 原始像素尺寸, after 最终像素尺寸
// 返回: Density 缩放后密度, bool 相对比例是否可完整保留
func (density Density) Scale(before, after image.Point) (Density, bool) {
hasDensity := density.X != 0 || density.Y != 0
if hasDensity && before != after {
if density.Unit == 0 {
ratio := new(big.Rat).SetFrac64(int64(density.X)*int64(after.X), int64(density.Y)*int64(after.Y))
ratio.Mul(ratio, new(big.Rat).SetFrac64(int64(before.Y), int64(before.X)))
if ratio.Num().BitLen() > 31 || ratio.Denom().BitLen() > 31 {
return density, false
}
density.X, density.Y = float64(ratio.Num().Int64()), float64(ratio.Denom().Int64())
} else {
density.X *= float64(after.X) / float64(before.X)
density.Y *= float64(after.Y) / float64(before.Y)
}
}
return density, true
}
// WithPNGDensity 在无密度块的PNG头后插入pHYs,保留源编码不变
// 入参: data 完整PNG编码, density 像素密度
// 返回: []byte 带密度的编码, bool 密度是否可完整保留
func WithPNGDensity(data []byte, density Density) ([]byte, bool) {
x, y := math.Round(density.X), math.Round(density.Y)
if x > math.MaxInt32 || y > math.MaxInt32 || density.X > 0 && x < 1 || density.Y > 0 && y < 1 {
return nil, false
}
chunk := make([]byte, 21)
binary.BigEndian.PutUint32(chunk, 9)
copy(chunk[4:], "pHYs")
binary.BigEndian.PutUint32(chunk[8:], uint32(x))
binary.BigEndian.PutUint32(chunk[12:], uint32(y))
chunk[16] = density.Unit
binary.BigEndian.PutUint32(chunk[17:], crc32.ChecksumIEEE(chunk[4:17]))
output := make([]byte, 0, len(data)+PNGDensityChunkSize)
output = append(output, data[:33]...)
output = append(output, chunk...)
output = append(output, data[33:]...)
return output, true
}
+487
View File
@@ -0,0 +1,487 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"image"
"image/color"
"math"
"slices"
"golang.org/x/image/draw"
"golang.org/x/image/math/f64"
)
// rasterScaleBufferLimit 限制整像素缩放器的浮点中间缓冲,超出时使用逐行采样
const rasterScaleBufferLimit = 8 << 20
// rasterAxisSample 保存目标像素及其归一化源采样权重
type rasterAxisSample struct {
pixel int
first int
weights []float64
}
// rasterImagePixel 保存未取整或裁切的预乘颜色累积值
type rasterImagePixel struct {
r, g, b, a float64
}
// rasterImageRow 保存正在累积的目标行
type rasterImageRow struct {
sample rasterAxisSample
values []rasterImagePixel
}
// rasterImageSource 为通用图片提供预乘十六位采样
type rasterImageSource struct{ image.Image }
// RGBA64At 读取通用颜色的原始预乘分量
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.RGBA64 预乘颜色
func (s rasterImageSource) RGBA64At(x, y int) color.RGBA64 {
r, g, b, a := s.At(x, y).RGBA()
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
}
// drawRasterAxisImage 轴向缩放复用逐行采样,细轴按面积覆盖并保留镜像与小数坐标
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作
// 返回: bool 是否使用轴向采样
func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool {
sx, sy := math.Abs(m[0]), math.Abs(m[3])
if m[1] != 0 || m[2] != 0 || sx == 0 || sy == 0 {
return false
}
for _, v := range m {
if math.IsNaN(v) || math.IsInf(v, 0) {
return false
}
}
b := src.Bounds()
if b.Empty() {
return true
}
r := TransformBounds(b, m, dst.Bounds())
if r.Empty() {
return true
}
if float64(r.Dx())*math.Min(float64(r.Dy()), math.Ceil(4*math.Max(1, sy))+2) > rasterScaleBufferLimit/32 {
if sx*float64(b.Dx()) < 1 || sy*float64(b.Dy()) < 1 {
return drawRasterFootprintImage(dst, src, m, op, r)
}
return false
}
if !rasterFootprintWeightsFit(b, r, m) {
return drawRasterFootprintImage(dst, src, m, op, r)
}
xs := rasterImageAxis(r.Min.X, r.Max.X, b.Min.X, b.Max.X, m[0], m[4])
ys := rasterImageAxis(r.Min.Y, r.Max.Y, b.Min.Y, b.Max.Y, m[3], m[5])
if len(xs) == 0 || len(ys) == 0 {
return true
}
if m[3] < 0 {
slices.Reverse(ys)
}
pixels, ok := src.(image.RGBA64Image)
if !ok {
pixels = rasterImageSource{src}
}
width := len(xs)
horizontal := make([]rasterImagePixel, width)
output := image.NewRGBA64(image.Rect(0, 0, width, 1))
var active []rasterImageRow
var free [][]rasterImagePixel
next := 0
for y := ys[0].first; next < len(ys) || len(active) != 0; y++ {
if len(active) == 0 && y < ys[next].first {
y = ys[next].first
}
for next < len(ys) && ys[next].first == y {
var row []rasterImagePixel
if n := len(free); n != 0 {
row, free = free[n-1], free[:n-1]
clear(row)
} else {
row = make([]rasterImagePixel, width)
}
active = append(active, rasterImageRow{sample: ys[next], values: row})
next++
}
rasterImageHorizontal(horizontal, pixels, xs, y)
n := 0
for _, row := range active {
weight := row.sample.weights[y-row.sample.first]
if weight != 0 {
for x, value := range horizontal {
p := &row.values[x]
p.r += value.r * weight
p.g += value.g * weight
p.b += value.b * weight
p.a += value.a * weight
}
}
if y+1 == row.sample.first+len(row.sample.weights) {
rasterImageWriteRow(dst, output, row.values, xs[0].pixel, row.sample.pixel, op)
free = append(free, row.values)
} else {
active[n] = row
n++
}
}
active = active[:n]
}
return true
}
// rasterImageAxis 按连续源坐标生成CatmullRom权重,亚像素细轴使用面积覆盖权重
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
// 返回: []rasterAxisSample 轴向采样表
func rasterImageAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample {
if math.Abs(scale)*float64(limit-source) < 1 {
return rasterImageFootprintAxis(first, end, source, limit, scale, offset)
}
result := make([]rasterAxisSample, 0, end-first)
argument := math.Min(1, math.Abs(scale))
support := draw.CatmullRom.Support / argument
count := 0
for pixel := first; pixel < end; pixel++ {
center := (float64(pixel) + .5 - offset) / scale
if center < float64(source) || center >= float64(limit) {
continue
}
center -= .5
start := int(max(float64(source), math.Floor(center-support)))
stop := int(min(float64(limit), math.Ceil(center+support)))
count += stop - start
result = append(result, rasterAxisSample{pixel: pixel, first: start})
}
buffer := make([]float64, count)
for index := range result {
sample := &result[index]
center := (float64(sample.pixel)+.5-offset)/scale - .5
start := sample.first
stop := int(min(float64(limit), math.Ceil(center+support)))
n := stop - start
weights := buffer[:n:n]
buffer = buffer[n:]
total := 0.0
for i := range weights {
distance := math.Abs((float64(start+i) - center) * argument)
if distance < draw.CatmullRom.Support {
weights[i] = draw.CatmullRom.At(distance)
total += weights[i]
}
}
for i := range weights {
weights[i] /= total
}
sample.weights = weights
}
return result
}
// rasterFootprintWeightsFit 检查细轴权重表的合计预算,乘法前排除溢出
// 入参: source 源边界, target 目标范围, m 像素变换
// 返回: bool 权重表是否符合预算
func rasterFootprintWeightsFit(source, target image.Rectangle, m [6]float64) bool {
budget := rasterScaleBufferLimit / 8
for _, axis := range [...]struct {
source, target int
scale float64
}{{source.Dx(), target.Dx(), m[0]}, {source.Dy(), target.Dy(), m[3]}} {
if math.Abs(axis.scale)*float64(axis.source) < 1 && axis.target > 0 {
n, samples := axis.source, min(2, axis.target)
if n > budget/samples {
return false
}
budget -= n * samples
}
}
return true
}
// rasterImageWeightSpan 提供单个目标像素的源范围和权重,不分配整轴权重表
// 入参: pixel 目标像素, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
// 返回: int 源起点, int 源终点, func(int)float64 源权重
func rasterImageWeightSpan(pixel, source, limit int, scale, offset float64) (int, int, func(int) float64) {
if math.Abs(scale)*float64(limit-source) < 1 {
left, right := (float64(pixel)-offset)/scale, (float64(pixel+1)-offset)/scale
if right < left {
left, right = right, left
}
if right <= float64(source) || left >= float64(limit) {
return 0, 0, nil
}
return source, limit, func(x int) float64 {
return math.Max(0, math.Min(float64(x)+1, right)-math.Max(float64(x), left)) * math.Abs(scale)
}
}
center := (float64(pixel) + .5 - offset) / scale
if center < float64(source) || center >= float64(limit) {
return 0, 0, nil
}
center -= .5
argument := math.Min(1, math.Abs(scale))
support := draw.CatmullRom.Support / argument
start := int(max(float64(source), math.Floor(center-support)))
stop := int(min(float64(limit), math.Ceil(center+support)))
weight := func(x int) float64 {
distance := math.Abs((float64(x) - center) * argument)
if distance < draw.CatmullRom.Support {
return draw.CatmullRom.At(distance)
}
return 0
}
var total float64
for x := start; x < stop; x++ {
total += weight(x)
}
return start, stop, func(x int) float64 { return weight(x) / total }
}
// drawRasterFootprintImage 在细轴权重超预算时按原累加顺序逐点绘制
// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作, target 目标范围
// 返回: bool 是否完成绘制
func drawRasterFootprintImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op, target image.Rectangle) bool {
pixels, ok := src.(image.RGBA64Image)
if !ok {
pixels = rasterImageSource{src}
}
b := src.Bounds()
output := image.NewRGBA64(image.Rect(0, 0, 1, 1))
var value [1]rasterImagePixel
for y := target.Min.Y; y < target.Max.Y; y++ {
firstY, endY, wy := rasterImageWeightSpan(y, b.Min.Y, b.Max.Y, m[3], m[5])
if firstY == endY {
continue
}
for x := target.Min.X; x < target.Max.X; x++ {
firstX, endX, wx := rasterImageWeightSpan(x, b.Min.X, b.Max.X, m[0], m[4])
if firstX == endX {
continue
}
value[0] = rasterImagePixel{}
for sy := firstY; sy < endY; sy++ {
var row rasterImagePixel
for sx := firstX; sx < endX; sx++ {
p, weight := pixels.RGBA64At(sx, sy), wx(sx)
if weight != 0 {
row.r += float64(p.R) * weight
row.g += float64(p.G) * weight
row.b += float64(p.B) * weight
row.a += float64(p.A) * weight
}
}
if weight := wy(sy); weight != 0 {
value[0].r += row.r * weight
value[0].g += row.g * weight
value[0].b += row.b * weight
value[0].a += row.a * weight
}
}
rasterImageWriteRow(dst, output, value[:], x, y, op)
}
}
return true
}
// rasterImageFootprintAxis 对不足一个目标像素的整轴保留真实交叠面积及预乘透明度
// 入参: first 目标起点, end 目标终点, source 源起点, limit 源终点, scale 缩放比例, offset 平移距离
// 返回: []rasterAxisSample 轴向覆盖权重
func rasterImageFootprintAxis(first, end, source, limit int, scale, offset float64) []rasterAxisSample {
lo, hi := float64(source)*scale+offset, float64(limit)*scale+offset
if hi < lo {
lo, hi = hi, lo
}
result := make([]rasterAxisSample, 0, min(2, end-first))
for pixel := first; pixel < end; pixel++ {
if math.Min(float64(pixel+1), hi) > math.Max(float64(pixel), lo) {
result = append(result, rasterAxisSample{pixel: pixel, first: source})
}
}
buffer := make([]float64, len(result)*(limit-source))
for index := range result {
sample := &result[index]
left, right := (float64(sample.pixel)-offset)/scale, (float64(sample.pixel+1)-offset)/scale
if right < left {
left, right = right, left
}
n := limit - source
sample.weights, buffer = buffer[:n:n], buffer[n:]
for i := range sample.weights {
x := float64(source + i)
sample.weights[i] = math.Max(0, math.Min(x+1, right)-math.Max(x, left)) * math.Abs(scale)
}
}
return result
}
// rasterImageHorizontal 按源列复用像素,保留各目标像素的权重累加顺序与中间精度
// 入参: row 横向缓冲, src 源像素, samples 采样表, y 源行坐标
func rasterImageHorizontal(row []rasterImagePixel, src image.RGBA64Image, samples []rasterAxisSample, y int) {
clear(row)
first, last := samples[0], samples[len(samples)-1]
reversed := first.first > last.first || first.first == last.first && len(first.weights) > len(last.weights)
start := min(first.first, last.first)
end := max(first.first+len(first.weights), last.first+len(last.weights))
active, next := 0, 0
for x := start; x < end; x++ {
for next < len(samples) {
index := next
if reversed {
index = len(samples) - next - 1
}
if samples[index].first > x {
break
}
next++
}
for active < next {
index := active
if reversed {
index = len(samples) - active - 1
}
if x < samples[index].first+len(samples[index].weights) {
break
}
active++
}
p := src.RGBA64At(x, y)
r, g, b, a := float64(p.R), float64(p.G), float64(p.B), float64(p.A)
for column := active; column < next; column++ {
index := column
if reversed {
index = len(samples) - column - 1
}
sample := samples[index]
weight := sample.weights[x-sample.first]
if weight != 0 {
value := &row[index]
value.r += r * weight
value.g += g * weight
value.b += b * weight
value.a += a * weight
}
}
}
}
// rasterImageWriteRow 在两轴采样完成后取整并使用标准预乘合成
// 入参: dst 目标图像, output 行像素缓冲, values 累积颜色, x 横向起点, y 目标行坐标, op 合成操作
func rasterImageWriteRow(dst draw.Image, output *image.RGBA64, values []rasterImagePixel, x, y int, op draw.Op) {
for i, value := range values {
r := rasterImageComponent(value.r, value.a)
g := rasterImageComponent(value.g, value.a)
b := rasterImageComponent(value.b, value.a)
a := rasterImageComponent(value.a, value.a)
pixel := output.Pix[i*8 : i*8+8 : i*8+8]
pixel[0], pixel[1] = uint8(r>>8), uint8(r)
pixel[2], pixel[3] = uint8(g>>8), uint8(g)
pixel[4], pixel[5] = uint8(b>>8), uint8(b)
pixel[6], pixel[7] = uint8(a>>8), uint8(a)
}
draw.Draw(dst, image.Rect(x, y, x+len(values), y+1), output, image.Point{}, op)
}
// rasterImageComponent 按预乘透明度裁切并取整十六位分量
// 入参: value 采样分量, alpha 采样透明度
// 返回: uint16 输出分量
func rasterImageComponent(value, alpha float64) uint16 {
if value > alpha || math.IsNaN(alpha) {
value = alpha
}
value += .5
if value <= 0 {
return 0
}
if value >= 65535 {
return 65535
}
return uint16(value)
}
// DrawPixels 对轴向缩放使用可分离采样,其余变换保留通用路径
// 入参: dst 目标图像, src 源图像, m 按a、b、c、d、e、f排列的像素变换, op 合成操作
func DrawPixels(dst draw.Image, src image.Image, m [6]float64, op draw.Op) {
b := src.Bounds()
if dr, ok := rasterScaleBounds(b, m); ok {
if dr.Intersect(dst.Bounds()).Empty() {
return
}
draw.CatmullRom.Scale(dst, dr, src, b, op, nil)
return
}
if drawRasterAxisImage(dst, src, m, op) {
return
}
src = rasterTransformSource(src, TransformBounds(b, m, dst.Bounds()))
draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, src, b, op, nil)
}
// rasterTransformSource 为放大的通用图像缓存十六位预乘像素,限制临时缓冲大小
// 入参: src 源图像, affected 目标采样区域
// 返回: image.Image 等值采样图像
func rasterTransformSource(src image.Image, affected image.Rectangle) image.Image {
switch src.(type) {
case *image.RGBA, *image.RGBA64:
return src
}
b := src.Bounds()
if b.Empty() || affected.Empty() || b.Dx() > rasterScaleBufferLimit/8/b.Dy() || float64(affected.Dx())*float64(affected.Dy()) < float64(b.Dx())*float64(b.Dy()) {
return src
}
pixels := image.NewRGBA64(b)
draw.Draw(pixels, b, src, b.Min, draw.Src)
return pixels
}
// rasterScaleBounds 检查变换是否可无几何取整地使用缩放器,并限制其中间缓冲
// 入参: b 源边界, m 像素变换
// 返回: image.Rectangle 缩放边界, bool 是否适用
func rasterScaleBounds(b image.Rectangle, m [6]float64) (image.Rectangle, bool) {
if b.Empty() || m[1] != 0 || m[2] != 0 || m[0] <= 0 || m[0] > 1 || m[3] <= 0 || m[3] > 1 || m[0] == 1 && m[3] == 1 {
return image.Rectangle{}, false
}
x0, y0 := float64(m[0]*float64(b.Min.X))+m[4], float64(m[3]*float64(b.Min.Y))+m[5]
x1, y1 := float64(m[0]*float64(b.Max.X))+m[4], float64(m[3]*float64(b.Max.Y))+m[5]
for _, v := range [4]float64{x0, y0, x1, y1} {
if v != math.Floor(v) || v < math.MinInt32 || v > math.MaxInt32 {
return image.Rectangle{}, false
}
}
if x1 <= x0 || y1 <= y0 || b.Dx() > math.MaxInt32 || b.Dy() > math.MaxInt32 || (x1-x0)*float64(b.Dy()) > rasterScaleBufferLimit/32 {
return image.Rectangle{}, false
}
return image.Rect(int(x0), int(y0), int(x1), int(y1)), true
}
// TransformBounds 计算采样器可能写入的像素范围,并在整数转换前裁至目标边界
// 入参: source 原始像素边界, m 像素变换, target 目标边界
// 返回: image.Rectangle 受影响的目标像素区域
func TransformBounds(source image.Rectangle, m [6]float64, target image.Rectangle) image.Rectangle {
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
for _, p := range [4]image.Point{source.Min, {X: source.Max.X, Y: source.Min.Y}, {X: source.Min.X, Y: source.Max.Y}, source.Max} {
x := math.Floor(float64(m[0]*float64(p.X)) + float64(m[2]*float64(p.Y)) + m[4])
y := math.Floor(float64(m[1]*float64(p.X)) + float64(m[3]*float64(p.Y)) + m[5])
minX, minY = math.Min(minX, x), math.Min(minY, y)
maxX, maxY = math.Max(maxX, x+1), math.Max(maxY, y+1)
}
minX, minY = math.Max(minX, float64(target.Min.X)), math.Max(minY, float64(target.Min.Y))
maxX, maxY = math.Min(maxX, float64(target.Max.X)), math.Min(maxY, float64(target.Max.Y))
if minX >= maxX || minY >= maxY {
return image.Rectangle{}
}
return image.Rect(int(minX), int(minY), int(maxX), int(maxY))
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"image"
"image/color"
)
// StorageCost 预留十六位像素空间,并计入编码、调色板与底层像素缓冲容量
// 包装图像可实现BackingImage() image.Image,使底层共享缓冲计入预算
// 入参: img 图片资源, limit 缓存预算
// 返回: int 估算字节数或超预算标记
func StorageCost(img image.Image, limit int) int {
cost := 256
w, h := img.Bounds().Dx(), img.Bounds().Dy()
if limit < cost || w <= 0 || h <= 0 || w > (limit-cost)/8/h {
return limit + 1
}
pixels := w * h * 8
if encoded, ok := img.(*EncodedImage); ok {
if encoded.pngColor != nil {
if limit-cost < 512 {
return limit + 1
}
cost += 512
}
if cap(encoded.profile) > limit-cost {
return limit + 1
}
cost += cap(encoded.profile)
if encoded.profile != nil || encoded.pngColor != nil || encoded.config.ColorModel == color.CMYKModel {
pixelSize := 12
if encoded.config.ColorModel == color.RGBA64Model || encoded.config.ColorModel == color.NRGBA64Model {
pixelSize = 16
}
if w > (limit-cost)/pixelSize/h {
return limit + 1
}
pixels = w * h * pixelSize
}
if result := encoded.result.Load(); result != nil && result.image != nil {
pixels = max(pixels, StorageCost(result.image, limit-cost))
}
if cap(encoded.data) > limit-cost-pixels {
return limit + 1
}
return cost + pixels + cap(encoded.data)
}
var buffers [5]int
switch source := img.(type) {
case *colorImage:
buffers[0] = cap(source.Pix)
buffers[1] = StorageCost(source.source, limit-cost)
case *image.RGBA:
buffers[0] = cap(source.Pix)
case *image.RGBA64:
buffers[0] = cap(source.Pix)
case *image.NRGBA:
buffers[0] = cap(source.Pix)
case *image.NRGBA64:
buffers[0] = cap(source.Pix)
case *image.Gray:
buffers[0] = cap(source.Pix)
case *image.Gray16:
buffers[0] = cap(source.Pix)
case *image.Alpha:
buffers[0] = cap(source.Pix)
case *image.Alpha16:
buffers[0] = cap(source.Pix)
case *image.CMYK:
buffers[0] = cap(source.Pix)
case *image.Paletted:
if cap(source.Palette) > (limit-cost)/16 {
return limit + 1
}
buffers[0], buffers[1] = cap(source.Pix), cap(source.Palette)*16
case *image.YCbCr:
buffers = [5]int{cap(source.Y), cap(source.Cb), cap(source.Cr)}
case *image.NYCbCrA:
buffers = [5]int{cap(source.Y), cap(source.Cb), cap(source.Cr), cap(source.A)}
case *planarTIFFImage:
if cap(source.palette) > (limit-cost)/8 {
return limit + 1
}
cost += cap(source.palette) * 8
if cap(source.planes) > (limit-cost)/16 || len(source.planes) > len(buffers) {
return limit + 1
}
cost += cap(source.planes) * 16
for i, plane := range source.planes {
switch plane := plane.(type) {
case *image.Gray:
buffers[i] = cap(plane.Pix)
case *image.Gray16:
buffers[i] = cap(plane.Pix)
default:
buffers[i] = StorageCost(plane, limit-cost)
}
}
case *tiffFaxImage:
if cap(source.blocks) > (limit-cost)/16 {
return limit + 1
}
cost += cap(source.blocks) * 16
for _, block := range source.blocks {
size := StorageCost(block, limit-cost)
if size > limit-cost {
return limit + 1
}
cost += size
}
case *orientedTIFFImage:
buffers[0] = StorageCost(source.source, limit-cost)
case *maskedTIFFImage:
buffers[0] = StorageCost(source.source, limit-cost)
buffers[1] = StorageCost(source.mask, limit-cost)
case *transparentEdgeImage:
buffers[0] = StorageCost(source.source, limit-cost)
case *transparentEdgeImage64:
buffers[0] = StorageCost(source.source, limit-cost)
case interface{ BackingImage() image.Image }:
buffers[0] = StorageCost(source.BackingImage(), limit-cost)
}
storage := 0
for _, size := range buffers {
if size > limit-cost-storage {
return limit + 1
}
storage += size
}
if pixels > limit-cost {
return limit + 1
}
return cost + max(pixels, storage)
}
+433
View File
@@ -0,0 +1,433 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"io"
)
// ErrTIFFRandomAccess 表示目录链需要回读,仅流式输入需重新打开资源
var ErrTIFFRandomAccess = errors.New("TIFF directory requires random access")
// TIFFLayout 保存TIFF目录的图像布局及关联透明蒙版
type TIFFLayout struct {
Width, Height int
TileWidth int
TileHeight int
Orientation int
Offset, Next uint32
Reduced, mask bool
Planar bool
auxiliary bool
Samples uint32
depth uint32
fillOrder uint32
compression uint32
predictor uint32
photometric uint32
profileOffset uint32
profileSize uint32
t4, t6 uint32
Transparency *TIFFLayout
}
// tiffDirectoryInput 只替换解码视图中的首目录偏移,不复制或改写原始资源
type tiffDirectoryInput struct {
reader *bytes.Reader
directory [4]byte
ctx context.Context
}
// orientedTIFFImage 按方向映射像素坐标,复用原图数据与颜色精度
type orientedTIFFImage struct {
source image.Image
TIFFLayout
}
// IsTIFF 识别标准TIFF字节序与版本标识
// 入参: data 文件头或完整编码
// 返回: bool 是否为TIFF 6.0编码
func IsTIFF(data []byte) bool {
return len(data) >= 4 && (string(data[:4]) == "II\x2a\x00" || string(data[:4]) == "MM\x00\x2a")
}
// readTIFFLayout 选择主图及其蒙版,允许缩略图并拒绝循环和多个主图
// 入参: input 从文件头开始的输入流
// 返回: TIFFLayout 存储尺寸与方向, error 文件结构或方向错误
func readTIFFLayout(input io.Reader) (TIFFLayout, error) {
return ReadTIFFLayoutContext(context.Background(), input)
}
// ReadTIFFLayoutContext 扫描目录链并选择主图,读取期间响应取消
// 入参: ctx 取消上下文, input 从文件头开始的输入流
// 返回: TIFFLayout 存储尺寸与方向, error 结构、读取或取消错误
func ReadTIFFLayoutContext(ctx context.Context, input io.Reader) (TIFFLayout, error) {
var result TIFFLayout
if err := ctx.Err(); err != nil {
return result, err
}
var header [8]byte
if _, err := io.ReadFull(input, header[:]); err != nil {
return result, err
}
if !IsTIFF(header[:]) {
return result, fmt.Errorf("invalid TIFF header")
}
var order binary.ByteOrder = binary.LittleEndian
if header[0] == 'M' {
order = binary.BigEndian
}
offset := int64(order.Uint32(header[4:]))
position := int64(8)
var seen map[uint32]bool
var transparency *TIFFLayout
for offset != 0 {
if seen != nil && seen[uint32(offset)] {
return result, fmt.Errorf("cyclic TIFF directory chain")
}
layout, end, err := readTIFFDirectory(ctx, input, order, offset, position)
if err != nil {
return result, err
}
if layout.mask {
if !layout.Reduced {
if transparency != nil {
return result, fmt.Errorf("multiple TIFF transparency masks")
}
transparency = &layout
}
} else if !layout.Reduced || layout.Next == 0 && seen == nil {
if result.Offset != 0 {
return result, fmt.Errorf("multiple TIFF pages are not supported in OFD resources")
}
result = layout
}
if layout.Next != 0 {
if seen == nil {
seen = make(map[uint32]bool)
}
seen[uint32(offset)] = true
}
offset, position = int64(layout.Next), end
}
if err := ctx.Err(); err != nil {
return TIFFLayout{}, err
}
if result.Offset == 0 {
return result, fmt.Errorf("missing TIFF main image")
}
result.Transparency = transparency
if transparency != nil {
size := result.DisplaySize()
if size.X > int(^uint(0)>>1)/8/size.Y {
return TIFFLayout{}, fmt.Errorf("TIFF mask image dimensions exceed capacity")
}
}
return result, nil
}
// readTIFFDirectory 读取目录中的图像布局和子文件类型,不读取像素
// 入参: ctx 取消上下文, input 输入流, order 字节序, offset 目录偏移, position 当前流位置
// 返回: TIFFLayout 目录布局, int64 读取后的流位置, error 结构、读取或取消错误
func readTIFFDirectory(ctx context.Context, input io.Reader, order binary.ByteOrder, offset, position int64) (TIFFLayout, int64, error) {
var result TIFFLayout
if err := ctx.Err(); err != nil {
return result, position, err
}
var header [4]byte
if offset < 8 {
return result, position, fmt.Errorf("invalid TIFF directory offset")
}
if seeker, ok := input.(io.Seeker); ok {
if _, err := seeker.Seek(offset, io.SeekStart); err != nil {
return result, position, err
}
} else {
if offset < position {
return result, position, ErrTIFFRandomAccess
}
reader := contextInput{ReadCloser: io.NopCloser(input), ctx: ctx}
if _, err := io.CopyN(io.Discard, reader, offset-position); err != nil {
return result, position, err
}
}
if _, err := io.ReadFull(input, header[:2]); err != nil {
return result, position, err
}
count := int(order.Uint16(header[:2]))
result.Orientation, result.Offset, result.Samples, result.fillOrder = 1, uint32(offset), 1, 1
result.depth = 1
result.compression = 1
result.predictor = 1
var entry [12]byte
var previous uint16
var subfile, legacy uint32
var hasSubfile bool
var invalidMaskDepth bool
for i := range count {
if err := ctx.Err(); err != nil {
return result, position, err
}
if _, err := io.ReadFull(input, entry[:]); err != nil {
return result, position, err
}
tag, kind, number := order.Uint16(entry[:2]), order.Uint16(entry[2:4]), order.Uint32(entry[4:8])
if i > 0 && tag <= previous {
return result, position, fmt.Errorf("invalid TIFF directory order")
}
previous = tag
if tag == 338 {
result.auxiliary = kind != 3 || number != 1 || order.Uint16(entry[8:]) == 0
}
if tag == 34675 {
if kind != 7 || number < 132 {
return result, position, fmt.Errorf("invalid TIFF color profile")
}
result.profileOffset, result.profileSize = order.Uint32(entry[8:]), number
continue
}
if tag == 258 {
invalidMaskDepth = kind != 3 || number != 1
if !invalidMaskDepth {
result.depth = uint32(order.Uint16(entry[8:10]))
}
}
if tag != 254 && tag != 255 && tag != 256 && tag != 257 && tag != 259 && tag != 262 && tag != 266 && tag != 274 && tag != 277 && tag != 284 && tag != 292 && tag != 293 && tag != 317 && tag != 322 && tag != 323 {
continue
}
if number != 1 || kind != 3 && kind != 4 || (tag == 255 || tag == 259 || tag == 262 || tag == 266 || tag == 274 || tag == 277 || tag == 284 || tag == 317) && kind != 3 || (tag == 254 || tag == 292 || tag == 293) && kind != 4 {
return result, position, fmt.Errorf("invalid TIFF image layout")
}
value := uint64(order.Uint32(entry[8:12]))
if kind == 3 {
value = uint64(order.Uint16(entry[8:10]))
}
if (tag == 256 || tag == 257 || tag == 322 || tag == 323) && (value == 0 || value > uint64(int(^uint(0)>>1))) {
return result, position, fmt.Errorf("invalid TIFF dimensions")
}
switch tag {
case 254:
subfile, hasSubfile = uint32(value), true
case 255:
legacy = uint32(value)
case 256:
result.Width = int(value)
case 257:
result.Height = int(value)
case 259:
result.compression = uint32(value)
case 262:
result.photometric = uint32(value)
result.mask = value == 4
case 266:
if value != 1 && value != 2 {
return result, position, fmt.Errorf("invalid TIFF fill order")
}
result.fillOrder = uint32(value)
case 274:
if value < 1 || value > 8 {
return result, position, fmt.Errorf("invalid TIFF orientation")
}
result.Orientation = int(value)
case 277:
if value == 0 {
return result, position, fmt.Errorf("invalid TIFF sample count")
}
result.Samples = uint32(value)
case 284:
if value != 1 && value != 2 {
return result, position, fmt.Errorf("invalid TIFF planar configuration")
}
result.Planar = value == 2
case 292:
result.t4 = uint32(value)
case 293:
result.t6 = uint32(value)
case 317:
result.predictor = uint32(value)
case 322:
result.TileWidth = int(value)
case 323:
result.TileHeight = int(value)
}
}
if _, err := io.ReadFull(input, header[:4]); err != nil {
return result, position, err
}
result.Next = order.Uint32(header[:4])
if hasSubfile {
result.Reduced, result.mask = subfile&1 != 0, result.mask || subfile&4 != 0
} else {
result.Reduced = legacy == 2
}
if result.Width == 0 || result.Height == 0 {
return result, position, fmt.Errorf("missing TIFF dimensions")
}
if (result.TileWidth == 0) != (result.TileHeight == 0) || result.TileWidth%16 != 0 || result.TileHeight%16 != 0 {
return result, position, fmt.Errorf("invalid TIFF tile dimensions")
}
if result.mask && (invalidMaskDepth || result.photometric != 4 || result.Samples != 1 || result.depth != 1) {
return result, position, fmt.Errorf("invalid TIFF transparency mask")
}
return result, offset + 6 + int64(count)*12, nil
}
// newTIFFDirectoryInput 创建以指定目录作为主图的只读解码视图
// 入参: data 已校验的TIFF编码, offset 主图目录偏移
// 返回: *tiffDirectoryInput 随机访问输入
func newTIFFDirectoryInput(data []byte, offset uint32) *tiffDirectoryInput {
input := &tiffDirectoryInput{reader: bytes.NewReader(data)}
var order binary.ByteOrder = binary.LittleEndian
if data[0] == 'M' {
order = binary.BigEndian
}
order.PutUint32(input.directory[:], offset)
return input
}
// patch 替换读取片段中与首目录指针重叠的部分
// 入参: data 读取结果, offset 文件偏移
func (r *tiffDirectoryInput) patch(data []byte, offset int64) {
for i := range r.directory {
at := int64(4+i) - offset
if at >= 0 && at < int64(len(data)) {
data[at] = r.directory[i]
}
}
}
// Read 读取图像编码并覆盖首目录指针
// 入参: data 接收缓冲
// 返回: int 读取字节数, error 读取错误
func (r *tiffDirectoryInput) Read(data []byte) (int, error) {
if r.ctx != nil {
if err := r.ctx.Err(); err != nil {
return 0, err
}
}
offset := r.reader.Size() - int64(r.reader.Len())
n, err := r.reader.Read(data)
r.patch(data[:n], offset)
return n, err
}
// ReadAt 随机读取图像编码并覆盖首目录指针
// 入参: data 接收缓冲, offset 文件偏移
// 返回: int 读取字节数, error 读取错误
func (r *tiffDirectoryInput) ReadAt(data []byte, offset int64) (int, error) {
if r.ctx != nil {
if err := r.ctx.Err(); err != nil {
return 0, err
}
}
n, err := r.reader.ReadAt(data, offset)
r.patch(data[:n], offset)
return n, err
}
// size 返回应用方向后的显示尺寸
// 返回: image.Point 像素宽高
func (l TIFFLayout) size() image.Point {
if l.Orientation >= 5 {
return image.Pt(l.Height, l.Width)
}
return image.Pt(l.Width, l.Height)
}
// DisplaySize 返回同时保留主图和透明蒙版分辨率的显示尺寸
// 返回: image.Point 保留高分辨率蒙版边缘的像素宽高
func (l TIFFLayout) DisplaySize() image.Point {
size := l.size()
if l.Transparency != nil {
mask := l.Transparency.size()
size.X, size.Y = max(size.X, mask.X), max(size.Y, mask.Y)
}
return size
}
// Bounds 返回从零开始的显示区域
// 返回: image.Rectangle 显示区域
func (s *orientedTIFFImage) Bounds() image.Rectangle {
return image.Rectangle{Max: s.size()}
}
// ColorModel 返回原始颜色模型
// 返回: color.Model 颜色模型
func (s *orientedTIFFImage) ColorModel() color.Model { return s.source.ColorModel() }
// sourcePoint 将显示坐标映射为存储坐标
// 入参: x 横向显示坐标, y 纵向显示坐标
// 返回: image.Point 原始坐标, bool 是否位于显示区域内
func (s *orientedTIFFImage) sourcePoint(x, y int) (image.Point, bool) {
if !image.Pt(x, y).In(s.Bounds()) {
return image.Point{}, false
}
w, h := s.Width, s.Height
switch s.Orientation {
case 2:
x = w - 1 - x
case 3:
x, y = w-1-x, h-1-y
case 4:
y = h - 1 - y
case 5:
x, y = y, x
case 6:
x, y = y, h-1-x
case 7:
x, y = w-1-y, h-1-x
case 8:
x, y = w-1-y, x
}
b := s.source.Bounds()
return image.Pt(b.Min.X+x, b.Min.Y+y), true
}
// At 返回原图颜色,不重新采样或量化
// 入参: x 横向显示坐标, y 纵向显示坐标
// 返回: color.Color 像素颜色,区域外透明
func (s *orientedTIFFImage) At(x, y int) color.Color {
p, ok := s.sourcePoint(x, y)
if !ok {
return color.NRGBA64{}
}
return s.source.At(p.X, p.Y)
}
// NRGBA64At 读取非预乘十六位颜色,保留透明像素中的原始颜色
// 入参: x 横向显示坐标, y 纵向显示坐标
// 返回: color.NRGBA64 像素颜色
func (s *orientedTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
p, ok := s.sourcePoint(x, y)
if !ok {
return color.NRGBA64{}
}
return NRGBA64At(s.source, p.X, p.Y)
}
// RGBA64At 返回预乘十六位像素,保留原始颜色精度
// 入参: x 横向显示坐标, y 纵向显示坐标
// 返回: color.RGBA64 像素颜色
func (s *orientedTIFFImage) RGBA64At(x, y int) color.RGBA64 {
r, g, b, a := s.At(x, y).RGBA()
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"cmp"
"context"
"encoding/binary"
"fmt"
"image"
"io"
"math/bits"
"slices"
"sort"
"golang.org/x/image/tiff"
)
// tiffBitOrderInput 仅在读取像素块时反转字节内位序,保留目录和原始数据
type tiffBitOrderInput struct {
source io.ReaderAt
ranges [][2]int64
position int64
}
// tiffBitOrderRanges 收集低位优先的未压缩一位图像块,压缩传真位序由解码器处理
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 已校验的主图布局
// 返回: [][2]int64 按偏移排序且不重叠的字节区间, error 字段或布局错误
func tiffBitOrderRanges(ctx context.Context, data []byte, layout TIFFLayout) ([][2]int64, error) {
if layout.fillOrder != 2 {
return nil, nil
}
var order binary.ByteOrder = binary.LittleEndian
if data[0] == 'M' {
order = binary.BigEndian
}
offset := int(layout.Offset)
count := int(order.Uint16(data[offset:]))
entries := make(map[uint16][]byte, count)
for i := range count {
entry := data[offset+2+i*12 : offset+14+i*12]
entries[order.Uint16(entry)] = entry
}
compression := entries[259]
method := uint16(1)
if compression != nil {
if order.Uint16(compression[2:]) != 3 || order.Uint32(compression[4:]) != 1 {
return nil, fmt.Errorf("invalid TIFF compression field")
}
method = order.Uint16(compression[8:])
}
if method == 3 || method == 4 {
return nil, nil
}
if method != 1 {
return nil, fmt.Errorf("unsupported TIFF fill order for compression %d", method)
}
if entry := entries[258]; entry != nil {
if order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != layout.Samples {
return nil, fmt.Errorf("invalid TIFF sample depths")
}
for i := uint32(0); i < layout.Samples; i++ {
depth, err := tiffPlaneValue(data, order, entry, i)
if err != nil || depth != 1 {
return nil, fmt.Errorf("unsupported TIFF fill order for sample depth")
}
}
}
locations, lengths := entries[273], entries[279]
value := func(tag uint16, fallback uint32) (uint32, error) {
entry := entries[tag]
if entry == nil {
return fallback, nil
}
if order.Uint32(entry[4:]) != 1 {
return 0, fmt.Errorf("invalid TIFF scalar field %d", tag)
}
return tiffPlaneValue(data, order, entry, 0)
}
rows, err := value(278, ^uint32(0))
if err != nil || rows == 0 {
return nil, fmt.Errorf("invalid TIFF rows per strip")
}
expected := (uint64(layout.Height)-1)/uint64(rows) + 1
if entries[322] != nil {
locations, lengths = entries[324], entries[325]
width, err := value(322, 0)
if err != nil || width == 0 || width%16 != 0 {
return nil, fmt.Errorf("invalid TIFF tile width")
}
height, err := value(323, 0)
if err != nil || height == 0 || height%16 != 0 {
return nil, fmt.Errorf("invalid TIFF tile height")
}
expected = ((uint64(layout.Width)-1)/uint64(width) + 1) * ((uint64(layout.Height)-1)/uint64(height) + 1)
if locations != nil && order.Uint16(locations[2:]) != 4 {
return nil, fmt.Errorf("invalid TIFF tile offsets")
}
}
if layout.Planar {
if expected > uint64(^uint32(0))/uint64(layout.Samples) {
return nil, fmt.Errorf("too many TIFF image blocks")
}
expected *= uint64(layout.Samples)
}
if locations == nil || lengths == nil {
return nil, fmt.Errorf("missing TIFF block arrays")
}
blocks := order.Uint32(locations[4:])
if blocks == 0 || uint64(blocks) != expected || order.Uint32(lengths[4:]) != blocks {
return nil, fmt.Errorf("invalid TIFF block count")
}
var ranges [][2]int64
for i := uint32(0); i < blocks; i++ {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, err
}
}
at, err := tiffPlaneValue(data, order, locations, i)
if err != nil {
return nil, err
}
size, err := tiffPlaneValue(data, order, lengths, i)
if err != nil || size == 0 || uint64(at)+uint64(size) > uint64(len(data)) {
return nil, fmt.Errorf("truncated TIFF image block")
}
ranges = append(ranges, [2]int64{int64(at), int64(at) + int64(size)})
}
slices.SortFunc(ranges, func(a, b [2]int64) int { return cmp.Compare(a[0], b[0]) })
merged := ranges[:0]
for _, span := range ranges {
if len(merged) != 0 && span[0] <= merged[len(merged)-1][1] {
merged[len(merged)-1][1] = max(merged[len(merged)-1][1], span[1])
} else {
merged = append(merged, span)
}
}
return merged, nil
}
// decodeTIFFBitOrder 使用只读视图校正位序,不分配整幅编码的副本
// 入参: input 原始解码视图, ranges 需要反转位序的像素区间
// 返回: image.Image 解码图像, error 解码错误
func decodeTIFFBitOrder(input interface {
io.Reader
io.ReaderAt
}, ranges [][2]int64) (image.Image, error) {
if len(ranges) == 0 {
return tiff.Decode(input)
}
return tiff.Decode(&tiffBitOrderInput{source: input, ranges: ranges})
}
// Read 顺序读取并校正像素字节的位序
// 入参: data 接收缓冲
// 返回: int 读取字节数, error 读取错误
func (r *tiffBitOrderInput) Read(data []byte) (int, error) {
n, err := r.ReadAt(data, r.position)
r.position += int64(n)
return n, err
}
// ReadAt 按文件偏移读取,只反转实际读取到的像素字节
// 入参: data 接收缓冲, offset 文件偏移
// 返回: int 读取字节数, error 读取错误
func (r *tiffBitOrderInput) ReadAt(data []byte, offset int64) (int, error) {
n, err := r.source.ReadAt(data, offset)
index := sort.Search(len(r.ranges), func(i int) bool { return r.ranges[i][1] > offset })
for ; index < len(r.ranges) && r.ranges[index][0] < offset+int64(n); index++ {
span := r.ranges[index]
for i := max(int64(0), span[0]-offset); i < min(int64(n), span[1]-offset); i++ {
data[i] = bits.Reverse8(data[i])
}
}
return n, err
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"math/bits"
"github.com/xiaoqidun/pdfgo"
)
// tiffFaxImage 组合独立解码的传真条带或瓦片,保留紧凑的一位像素存储
type tiffFaxImage struct {
blocks []image.Image
bounds image.Rectangle
width int
height int
across int
}
// decodeTIFFFax 按TIFF传真选项解码各图像块,复用PDFGo的T.4与T.6解码器
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 已校验的主图布局
// 返回: image.Image 图像, error 字段、解码或取消错误
func decodeTIFFFax(ctx context.Context, data []byte, layout TIFFLayout) (image.Image, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
if layout.Samples != 1 || layout.compression == 3 && layout.t4 & ^uint32(7) != 0 || layout.compression == 4 && layout.t6 & ^uint32(2) != 0 {
return nil, fmt.Errorf("invalid TIFF fax options")
}
var order binary.ByteOrder = binary.LittleEndian
if data[0] == 'M' {
order = binary.BigEndian
}
offset := int(layout.Offset)
count := int(order.Uint16(data[offset:]))
entries := make(map[uint16][]byte, count)
for i := range count {
entry := data[offset+2+i*12 : offset+14+i*12]
entries[order.Uint16(entry)] = entry
}
value := func(tag uint16, fallback uint32) (uint32, error) {
entry := entries[tag]
if entry == nil {
return fallback, nil
}
if order.Uint32(entry[4:]) != 1 {
return 0, fmt.Errorf("invalid TIFF scalar field %d", tag)
}
return tiffPlaneValue(data, order, entry, 0)
}
for _, tag := range []uint16{258, 317, 339} {
v, err := value(tag, 1)
if err != nil || v != 1 || entries[tag] != nil && order.Uint16(entries[tag][2:]) != 3 {
return nil, fmt.Errorf("invalid TIFF fax sample field %d", tag)
}
}
photo, err := value(262, 2)
if err != nil || photo > 1 && photo != 4 || entries[338] != nil {
return nil, fmt.Errorf("invalid TIFF fax color model")
}
width := uint32(layout.Width)
height, err := value(278, ^uint32(0))
if err != nil || height == 0 {
return nil, fmt.Errorf("invalid TIFF rows per strip")
}
height = min(height, uint32(layout.Height))
locations, lengths := entries[273], entries[279]
tiled := entries[322] != nil
if tiled {
width, err = value(322, 0)
if err != nil || width == 0 || width%16 != 0 {
return nil, fmt.Errorf("invalid TIFF tile width")
}
height, err = value(323, 0)
if err != nil || height == 0 || height%16 != 0 {
return nil, fmt.Errorf("invalid TIFF tile height")
}
locations, lengths = entries[324], entries[325]
if locations != nil && order.Uint16(locations[2:]) != 4 {
return nil, fmt.Errorf("invalid TIFF tile offsets")
}
}
if uint64(width) > uint64(int(^uint(0)>>1)) || uint64(height) > uint64(int(^uint(0)>>1)) || locations == nil || lengths == nil {
return nil, fmt.Errorf("invalid TIFF fax blocks")
}
across := (uint64(layout.Width)-1)/uint64(width) + 1
down := (uint64(layout.Height)-1)/uint64(height) + 1
blocks := across * down
if blocks != uint64(order.Uint32(locations[4:])) || blocks != uint64(order.Uint32(lengths[4:])) {
return nil, fmt.Errorf("invalid TIFF fax block count")
}
result := &tiffFaxImage{bounds: image.Rect(0, 0, layout.Width, layout.Height), width: int(width), height: int(height), across: int(across)}
for i := uint64(0); i < blocks; i++ {
if err := ctx.Err(); err != nil {
return nil, err
}
at, err := tiffPlaneValue(data, order, locations, uint32(i))
if err != nil {
return nil, err
}
size, err := tiffPlaneValue(data, order, lengths, uint32(i))
if err != nil || size == 0 || uint64(at)+uint64(size) > uint64(len(data)) {
return nil, fmt.Errorf("truncated TIFF fax block")
}
encoded := data[uint64(at) : uint64(at)+uint64(size)]
if layout.fillOrder == 2 {
reversed := make([]byte, len(encoded))
for start := 0; start < len(encoded); {
if err := ctx.Err(); err != nil {
return nil, err
}
end := start + min(32768, len(encoded)-start)
for j := start; j < end; j++ {
reversed[j] = bits.Reverse8(encoded[j])
}
start = end
}
encoded = reversed
}
rows := height
if !tiled {
rows = min(height, uint32(uint64(layout.Height)-i*uint64(height)))
}
k := pdfgo.Integer(0)
if layout.compression == 4 {
k = -1
} else if layout.compression == 3 && layout.t4&1 != 0 {
k = 1
}
params := pdfgo.Dictionary{
"K": k, "Columns": pdfgo.Integer(width), "Rows": pdfgo.Integer(rows),
"EndOfBlock": pdfgo.Boolean(false), "EndOfLine": pdfgo.Boolean(layout.compression == 3),
"EncodedByteAlign": pdfgo.Boolean(layout.compression == 2), "BlackIs1": pdfgo.Boolean(photo == 1 || photo == 4),
}
object := &pdfgo.Image{Width: int(width), Height: int(rows), BitsPerComponent: 1, ColorSpace: pdfgo.Name("DeviceGray"), Stream: &pdfgo.Stream{Dictionary: pdfgo.Dictionary{"Filter": pdfgo.Name("CCITTFaxDecode"), "DecodeParms": params}, Data: encoded}}
pixels, err := object.DecodeSamplesContext(ctx)
if err != nil {
return nil, fmt.Errorf("TIFF fax block %d: %w", i, err)
}
result.blocks = append(result.blocks, pixels)
}
if err := ctx.Err(); err != nil {
return nil, err
}
return result, nil
}
// Bounds 返回主图显示区域,不包含边缘瓦片的填充像素
// 返回: image.Rectangle 图像区域
func (s *tiffFaxImage) Bounds() image.Rectangle { return s.bounds }
// ColorModel 返回二值图像使用的灰度颜色模型
// 返回: color.Model 灰度模型
func (s *tiffFaxImage) ColorModel() color.Model { return color.GrayModel }
// At 读取所属条带或瓦片中的像素,区域外返回黑色
// 入参: x 横向像素坐标, y 纵向像素坐标
// 返回: color.Color 像素颜色
func (s *tiffFaxImage) At(x, y int) color.Color {
if !image.Pt(x, y).In(s.bounds) {
return color.Gray{}
}
block := s.blocks[(y/s.height)*s.across+x/s.width]
return block.At(x%s.width, y%s.height)
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"context"
"image"
"image/color"
)
// maskedTIFFImage 将独立透明蒙版叠加到主图,复用两个目录的只读像素
type maskedTIFFImage struct {
source, mask image.Image
bounds image.Rectangle
}
// DecodeTIFF 解码主图及透明蒙版,保留颜色精度与高分辨率蒙版
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 主图布局
// 返回: image.Image 图像, error 解码或取消错误
func DecodeTIFF(ctx context.Context, data []byte, layout TIFFLayout) (image.Image, error) {
source, err := decodeTIFFPixels(ctx, data, layout)
if err != nil || layout.Transparency == nil {
return source, err
}
mask, err := decodeTIFFPixels(ctx, data, *layout.Transparency)
if err != nil {
return nil, err
}
return &maskedTIFFImage{source: source, mask: mask, bounds: image.Rectangle{Max: layout.DisplaySize()}}, nil
}
// Bounds 返回主图和蒙版共同覆盖的像素区域
// 返回: image.Rectangle 显示区域
func (s *maskedTIFFImage) Bounds() image.Rectangle { return s.bounds }
// ColorModel 返回保留原图采样精度的非预乘颜色模型
// 返回: color.Model 颜色模型
func (s *maskedTIFFImage) ColorModel() color.Model { return color.NRGBA64Model }
// samplePoint 将显示坐标映射到主图或蒙版的像素中心
// 入参: bounds 采样图像范围, x 横坐标, y 纵坐标
// 返回: image.Point 采样坐标
func (s *maskedTIFFImage) samplePoint(bounds image.Rectangle, x, y int) image.Point {
size := s.bounds.Size()
x, y = x-s.bounds.Min.X, y-s.bounds.Min.Y
if bounds.Size() == size {
return bounds.Min.Add(image.Pt(x, y))
}
return image.Pt(
bounds.Min.X+int((uint64(x)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X)),
bounds.Min.Y+int((uint64(y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y)),
)
}
// alphaAt 读取蒙版与主图透明度,不解算主图颜色
// 入参: x 横坐标, y 纵坐标
// 返回: uint32 十六位透明度
func (s *maskedTIFFImage) alphaAt(x, y int) uint32 {
if !image.Pt(x, y).In(s.bounds) {
return 0
}
mask := s.samplePoint(s.mask.Bounds(), x, y)
if NRGBA64At(s.mask, mask.X, mask.Y).R == 0 {
return 0
}
source := s.samplePoint(s.source.Bounds(), x, y)
return AlphaAt(s.source, source.X, source.Y)
}
// NRGBA64At 按像素中心读取主图及蒙版,零位隐藏原图并保留隐藏颜色
// 入参: x 横坐标, y 纵坐标
// 返回: color.NRGBA64 非预乘十六位颜色
func (s *maskedTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
if !image.Pt(x, y).In(s.bounds) {
return color.NRGBA64{}
}
source := s.samplePoint(s.source.Bounds(), x, y)
pixel := NRGBA64At(s.source, source.X, source.Y)
mask := s.samplePoint(s.mask.Bounds(), x, y)
if NRGBA64At(s.mask, mask.X, mask.Y).R == 0 {
pixel.A = 0
}
return pixel
}
// At 返回非预乘颜色,区域外透明
// 入参: x 横坐标, y 纵坐标
// 返回: color.Color 像素颜色
func (s *maskedTIFFImage) At(x, y int) color.Color { return s.NRGBA64At(x, y) }
// RGBA64At 返回预乘十六位颜色,供采样器直接读取
// 入参: x 横坐标, y 纵坐标
// 返回: color.RGBA64 预乘颜色
func (s *maskedTIFFImage) RGBA64At(x, y int) color.RGBA64 {
r, g, b, a := s.NRGBA64At(x, y).RGBA()
return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)}
}
+807
View File
@@ -0,0 +1,807 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"io"
"math"
"github.com/xiaoqidun/pdfgo"
)
// tiffPlaneInput 将单分量目录附加到只读视图,像素仍从原始编码读取
type tiffPlaneInput struct {
source, directory []byte
position int64
order binary.ByteOrder
ctx context.Context
}
// planarTIFFImage 组合颜色与Alpha分量,保留交错步长并按需映射调色板
type planarTIFFImage struct {
planes []image.Image
bounds image.Rectangle
depth uint32
packed [5]uint32
offsets [5]int
alpha uint32
gray bool
cmyk bool
dotRange [4][2]uint16
palette []color.NRGBA64
stride int
invert bool
predictorWidth int
}
// needsSampleView 判断是否需通过分量视图处理存储布局,供尺寸读取和像素解码共用
// 返回: bool 是否需要分量视图
func (layout TIFFLayout) needsSampleView() bool {
return layout.mask || layout.auxiliary || layout.photometric == 5 || layout.Planar && layout.Samples > 1 || layout.Samples == 2 || layout.depth < 8 && (layout.Samples > 1 || layout.depth > 1 || layout.predictor == 2)
}
// DecodedPixelBytes 估算每像素解码缓冲,低位深分量按逐位展开后的字节数计量
// 返回: uint64 每像素预算字节数
func (layout TIFFLayout) DecodedPixelBytes() uint64 {
samples := uint64(layout.Samples)
profile := uint64(0)
if layout.profileSize != 0 {
profile = 8
}
if layout.Planar {
if layout.photometric == 5 && samples > 4 {
return 20 + profile
}
return 16 + profile
}
if !layout.needsSampleView() {
return 8 + profile
}
bytes := uint64(1)
if layout.depth == 2 || layout.depth == 4 {
bytes = uint64(layout.depth)
} else if layout.depth > 8 {
bytes = 2
}
return max(uint64(8), samples*bytes) + profile
}
// decodeTIFFPixels 按存储方式解码指定目录并统一颜色和显示方向
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 目录布局
// 返回: image.Image 图像, error 解码或取消错误
func decodeTIFFPixels(ctx context.Context, data []byte, layout TIFFLayout) (image.Image, error) {
profile, err := readTIFFColorProfile(ctx, data, layout)
if err != nil {
return nil, err
}
var ranges [][2]int64
if layout.compression < 2 || layout.compression > 4 {
ranges, err = tiffBitOrderRanges(ctx, data, layout)
if err != nil {
return nil, err
}
}
var pixels image.Image
if layout.compression >= 2 && layout.compression <= 4 {
pixels, err = decodeTIFFFax(ctx, data, layout)
if err != nil {
return nil, err
}
} else if layout.needsSampleView() {
result, directories, err := tiffPlaneDirectories(ctx, data, layout)
if err != nil {
return nil, err
}
for _, directory := range directories {
input := &tiffPlaneInput{source: data, directory: directory, order: binary.LittleEndian, ctx: ctx}
if data[0] == 'M' {
input.order = binary.BigEndian
}
plane, err := decodeTIFFBitOrder(input, ranges)
if err != nil {
return nil, err
}
if result.predictorWidth != 0 && (result.stride > 1 || result.packed[len(result.planes)] != 0) {
if err := restoreTIFFPrediction(ctx, plane, max(result.stride, 1), result.predictorWidth, result.packed[len(result.planes)]); err != nil {
return nil, err
}
}
result.planes = append(result.planes, plane)
}
pixels = result
} else {
input := newTIFFDirectoryInput(data, layout.Offset)
input.ctx = ctx
var err error
pixels, err = decodeTIFFBitOrder(input, ranges)
if err != nil {
return nil, err
}
}
if profile != nil {
pixels, err = applyTIFFColorProfile(ctx, pixels, profile)
if err != nil {
return nil, err
}
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, err
}
}
if layout.Orientation != 1 {
pixels = &orientedTIFFImage{source: pixels, TIFFLayout: layout}
}
return pixels, nil
}
// tiffPlaneDirectories 为颜色、Alpha或调色板索引构建单通道目录,跳过无显示用途的独立平面
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 已校验的主图布局
// 返回: *planarTIFFImage 像素布局, [][]byte 各分量目录, error 字段或布局错误
func tiffPlaneDirectories(ctx context.Context, data []byte, layout TIFFLayout) (*planarTIFFImage, [][]byte, error) {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
}
if uint64(len(data))+65536*12+8 > math.MaxUint32 {
return nil, nil, fmt.Errorf("TIFF plane directory offset exceeds format limit")
}
var order binary.ByteOrder = binary.LittleEndian
if data[0] == 'M' {
order = binary.BigEndian
}
offset := int(layout.Offset)
count := int(order.Uint16(data[offset:]))
entries := make(map[uint16][]byte, count)
for i := range count {
entry := data[offset+2+i*12 : offset+14+i*12]
entries[order.Uint16(entry)] = entry
}
value := func(tag uint16, fallback uint32) (uint32, error) {
entry := entries[tag]
if entry == nil {
return fallback, nil
}
if order.Uint32(entry[4:]) != 1 {
return 0, fmt.Errorf("invalid TIFF scalar field %d", tag)
}
return tiffPlaneValue(data, order, entry, 0)
}
samples, err := value(277, 1)
if err != nil || samples < 1 || samples > math.MaxUint16 {
return nil, nil, fmt.Errorf("invalid TIFF plane count")
}
photo, err := value(262, math.MaxUint32)
if err != nil || photo > 5 {
return nil, nil, fmt.Errorf("unsupported TIFF planar color model")
}
compression, err := value(259, 1)
if err != nil {
return nil, nil, err
}
fill, err := value(266, 1)
if err != nil || fill != 1 && (fill != 2 || compression != 1 && (compression < 2 || compression > 4)) {
return nil, nil, fmt.Errorf("unsupported TIFF plane fill order")
}
predictor, err := value(317, 1)
if err != nil || predictor != 1 && predictor != 2 {
return nil, nil, fmt.Errorf("unsupported TIFF plane predictor")
}
result := &planarTIFFImage{bounds: image.Rect(0, 0, layout.Width, layout.Height), gray: photo < 2 || photo == 4, invert: photo == 0, cmyk: photo == 5}
if result.cmyk {
for _, tag := range []uint16{332, 334} {
want := uint32(1)
if tag == 334 {
want = 4
}
got, err := value(tag, want)
if err != nil || got != want || entries[tag] != nil && order.Uint16(entries[tag][2:]) != 3 {
return nil, nil, fmt.Errorf("unsupported TIFF ink configuration")
}
}
}
planes := samples
if !layout.Planar && samples > 1 {
result.stride = int(samples)
planes = 1
}
colors := uint32(3)
if result.cmyk {
colors = 4
} else if photo != 2 {
colors = 1
}
if samples != colors {
entry := entries[338]
if samples < colors || entry == nil || order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != samples-colors {
return nil, nil, fmt.Errorf("invalid TIFF extra samples")
}
for i := uint32(0); i < samples-colors; i++ {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
}
alpha, err := tiffPlaneValue(data, order, entry, i)
if err != nil || alpha > 2 || alpha != 0 && result.alpha != 0 {
return nil, nil, fmt.Errorf("unsupported TIFF extra samples")
}
if alpha != 0 {
result.alpha, result.offsets[colors] = alpha, int(i)
}
}
} else if entries[338] != nil {
return nil, nil, fmt.Errorf("unexpected TIFF extra samples")
}
width, err := value(322, 0)
if err != nil {
return nil, nil, err
}
blocks := uint64(0)
locations, lengths := uint16(273), uint16(279)
if width != 0 {
height, err := value(323, 0)
if err != nil || height == 0 || width%16 != 0 || height%16 != 0 {
return nil, nil, fmt.Errorf("invalid TIFF tile dimensions")
}
blocks = ((uint64(layout.Width)-1)/uint64(width) + 1) * ((uint64(layout.Height)-1)/uint64(height) + 1)
locations, lengths = 324, 325
} else {
rows, err := value(278, math.MaxUint32)
if err != nil || rows == 0 {
return nil, nil, fmt.Errorf("invalid TIFF rows per strip")
}
blocks = (uint64(layout.Height)-1)/uint64(rows) + 1
}
if blocks > math.MaxUint32/uint64(planes) {
return nil, nil, fmt.Errorf("too many TIFF plane blocks")
}
for _, tag := range []uint16{locations, lengths} {
entry := entries[tag]
if entry == nil || uint64(order.Uint32(entry[4:])) != blocks*uint64(planes) || tag == 324 && order.Uint16(entry[2:]) != 4 {
return nil, nil, fmt.Errorf("invalid TIFF plane block count")
}
}
for i := uint32(0); i < uint32(blocks)*planes; i++ {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
}
at, err := tiffPlaneValue(data, order, entries[locations], i)
if err != nil {
return nil, nil, err
}
size, err := tiffPlaneValue(data, order, entries[lengths], i)
if err != nil || size == 0 || uint64(at)+uint64(size) > uint64(len(data)) {
return nil, nil, fmt.Errorf("truncated TIFF plane block")
}
}
for _, tag := range []uint16{258, 339} {
if entry := entries[tag]; entry != nil && (order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != samples) {
return nil, nil, fmt.Errorf("invalid TIFF sample array")
}
}
var directories [][]byte
var firstDepth uint32
for plane := uint32(0); plane < samples; plane++ {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
}
slot := plane
visible := plane < colors || result.alpha != 0 && plane == colors+uint32(result.offsets[colors])
if !visible && result.stride == 0 {
continue
}
if plane >= colors {
slot = colors
}
depth := uint32(1)
if entry := entries[258]; entry != nil {
depth, err = tiffPlaneValue(data, order, entry, plane)
}
if err != nil || depth != 1 && depth != 2 && depth != 4 && depth != 8 && depth != 16 {
return nil, nil, fmt.Errorf("unsupported TIFF plane depth")
}
if plane == 0 {
firstDepth = depth
} else if result.stride > 1 && depth != firstDepth {
return nil, nil, fmt.Errorf("unsupported TIFF mixed interleaved sample depths")
}
if !visible {
continue
}
result.depth = max(result.depth, depth)
if result.cmyk && plane < 4 {
low, high := uint32(0), uint32(1<<depth-1)
if entry := entries[336]; entry != nil {
n := order.Uint32(entry[4:])
if kind := order.Uint16(entry[2:]); kind != 1 && kind != 3 || n != 2 && n != 2*samples {
return nil, nil, fmt.Errorf("invalid TIFF dot range")
}
i := plane * 2
if n == 2 {
i = 0
}
if order.Uint16(entry[2:]) == 1 {
values := entry[8:]
if n > 4 {
at := uint64(order.Uint32(values))
if at+uint64(n) > uint64(len(data)) {
return nil, nil, fmt.Errorf("truncated TIFF dot range")
}
values = data[at : at+uint64(n)]
}
low, high = uint32(values[i]), uint32(values[i+1])
} else {
low, err = tiffPlaneValue(data, order, entry, i)
if err != nil {
return nil, nil, err
}
high, err = tiffPlaneValue(data, order, entry, i+1)
}
if err != nil || low == high || low > 1<<depth-1 || high > 1<<depth-1 {
return nil, nil, fmt.Errorf("invalid TIFF dot range")
}
}
result.dotRange[plane] = [2]uint16{uint16(low * 65535 / (1<<depth - 1)), uint16(high * 65535 / (1<<depth - 1))}
}
factor := uint64(max(result.stride, 1))
if depth == 1 && predictor == 2 {
result.packed[slot] = 1
}
if depth == 2 || depth == 4 {
result.packed[slot] = depth
factor *= uint64(depth)
if compression == 2 || compression == 3 || compression == 4 {
return nil, nil, fmt.Errorf("unsupported TIFF packed plane compression")
}
}
pixelBytes := factor
if depth > 8 {
pixelBytes *= 2
}
if uint64(layout.Width)*pixelBytes > uint64(math.MaxInt)/uint64(layout.Height) || uint64(layout.Width)*factor > math.MaxUint32 || uint64(width)*factor > math.MaxUint32 {
return nil, nil, fmt.Errorf("TIFF plane dimensions exceed format limit")
}
if predictor == 2 && (result.stride > 1 || depth < 8) {
result.predictorWidth = layout.Width
if width != 0 {
result.predictorWidth = int(width)
}
}
if entry := entries[339]; entry != nil {
format, err := tiffPlaneValue(data, order, entry, plane)
if err != nil || format != 1 {
return nil, nil, fmt.Errorf("unsupported TIFF sample format")
}
}
if photo == 3 && plane == 0 {
entry := entries[320]
if depth > 8 || entry == nil || order.Uint16(entry[2:]) != 3 || order.Uint32(entry[4:]) != 3<<depth {
return nil, nil, fmt.Errorf("invalid TIFF color map")
}
n := uint32(1) << depth
result.palette = make([]color.NRGBA64, n)
for i := range n {
var components [3]uint16
for c := range components {
v, err := tiffPlaneValue(data, order, entry, uint32(c)*n+i)
if err != nil {
return nil, nil, err
}
components[c] = uint16(v)
if v%257 != 0 {
result.depth = 16
}
}
result.palette[i] = color.NRGBA64{R: components[0], G: components[1], B: components[2], A: 65535}
}
}
if result.stride > 1 && plane != 0 {
continue
}
directory := make([]byte, 2, 6+count*12)
for i := range count {
if ctx != nil {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
}
entry := bytes.Clone(data[offset+2+i*12 : offset+14+i*12])
tag := order.Uint16(entry)
if tag == 338 || tag == 320 || tag >= 332 && tag <= 337 || tag == 34675 {
continue
}
scalar := uint32(0)
switch tag {
case 258:
scalar = depth
if result.packed[slot] != 0 {
scalar = 1
}
case 256, 322:
if factor != 1 {
original, err := tiffPlaneValue(data, order, entry, 0)
if err != nil {
return nil, nil, err
}
order.PutUint16(entry[2:], 4)
order.PutUint32(entry[4:], 1)
order.PutUint32(entry[8:], original*uint32(factor))
}
case 262:
scalar = 1
case 277, 284, 339:
scalar = 1
case 317:
if predictor == 2 && (result.stride > 1 || depth < 8) {
order.PutUint16(entry[8:], 1)
}
case locations, lengths:
entry, err = tiffPlaneArray(data, order, entry, uint32(blocks)*plane, uint32(blocks))
if err != nil {
return nil, nil, err
}
}
if tag == 258 || tag == 262 || tag == 277 || tag == 284 || tag == 339 {
order.PutUint16(entry[2:], 3)
order.PutUint32(entry[4:], 1)
clear(entry[8:])
order.PutUint16(entry[8:], uint16(scalar))
}
directory = append(directory, entry...)
}
order.PutUint16(directory, uint16((len(directory)-2)/12))
directories = append(directories, append(directory, 0, 0, 0, 0))
}
return result, directories, nil
}
// restoreTIFFPrediction 在解码缓冲内恢复水平差分,各行及瓦片独立计算
// 入参: ctx 取消上下文,可为nil, pixels 单通道解码视图, stride 每像素分量数, width 原始条带或瓦片宽度, packed 展开为一位采样的原位深,零表示未展开
// 返回: error 图像类型或取消错误
func restoreTIFFPrediction(ctx context.Context, pixels image.Image, stride, width int, packed uint32) error {
if ctx == nil {
ctx = context.Background()
}
var data []byte
var pitch, step int
switch pixels := pixels.(type) {
case *image.Gray:
data, pitch, step = pixels.Pix, pixels.Stride, max(int(packed), 1)
case *image.Gray16:
data, pitch, step = pixels.Pix, pixels.Stride, 2
default:
return fmt.Errorf("unsupported TIFF predictor image")
}
bounds := pixels.Bounds()
columns := bounds.Dx() / max(int(packed), 1)
block := width * stride
for y := range bounds.Dy() {
if err := ctx.Err(); err != nil {
return err
}
row := data[y*pitch : y*pitch+columns*step]
for start := 0; start < columns; {
end := start + min(block, columns-start)
for x := start + stride; x < end; {
if err := ctx.Err(); err != nil {
return err
}
limit := x + min(4096, end-x)
for ; x < limit; x++ {
at, previous := x*step, (x-stride)*step
if packed != 0 {
var current, prior uint16
for bit := range step {
current = current<<1 | uint16(row[at+bit]>>7)
prior = prior<<1 | uint16(row[previous+bit]>>7)
}
value := current + prior
for bit := range step {
row[at+bit] = byte(value>>uint(step-1-bit)&1) * 255
}
} else if step == 2 {
value := binary.BigEndian.Uint16(row[at:]) + binary.BigEndian.Uint16(row[previous:])
binary.BigEndian.PutUint16(row[at:], value)
} else {
row[at] += row[previous]
}
}
}
start = end
}
}
return ctx.Err()
}
// tiffPlaneValue 读取SHORT或LONG数组中的一项,校验完整数组范围
// 入参: data 原编码, order 字节序, entry 目录项, index 元素序号
// 返回: uint32 元素值, error 类型或越界错误
func tiffPlaneValue(data []byte, order binary.ByteOrder, entry []byte, index uint32) (uint32, error) {
kind, count := order.Uint16(entry[2:]), order.Uint32(entry[4:])
if kind != 3 && kind != 4 || index >= count {
return 0, fmt.Errorf("invalid TIFF plane array")
}
size := uint64(2)
if kind == 4 {
size = 4
}
values := entry[8:]
if size*uint64(count) > 4 {
at := uint64(order.Uint32(values))
if at+size*uint64(count) > uint64(len(data)) {
return 0, fmt.Errorf("truncated TIFF plane array")
}
values = data[at:]
}
if size == 2 {
return uint32(order.Uint16(values[uint64(index)*size:])), nil
}
return order.Uint32(values[uint64(index)*size:]), nil
}
// tiffPlaneArray 将块索引缩减为指定分量的范围,较长数组继续借用原编码
// 入参: data 原编码, order 字节序, entry 目录项副本, first 起始序号, count 元素数
// 返回: []byte 修改后的目录项, error 类型或越界错误
func tiffPlaneArray(data []byte, order binary.ByteOrder, entry []byte, first, count uint32) ([]byte, error) {
if _, err := tiffPlaneValue(data, order, entry, first+count-1); err != nil {
return nil, err
}
size := uint32(2)
if order.Uint16(entry[2:]) == 4 {
size = 4
}
if uint64(count)*uint64(size) <= 4 {
var values [4]byte
for i := uint32(0); i < count; i++ {
value, _ := tiffPlaneValue(data, order, entry, first+i)
if size == 2 {
order.PutUint16(values[i*size:], uint16(value))
} else {
order.PutUint32(values[i*size:], value)
}
}
copy(entry[8:], values[:])
} else {
order.PutUint32(entry[8:], order.Uint32(entry[8:])+first*size)
}
order.PutUint32(entry[4:], count)
return entry, nil
}
// Read 从组合视图顺序读取
// 入参: data 接收缓冲
// 返回: int 字节数, error 读取或取消错误
func (r *tiffPlaneInput) Read(data []byte) (int, error) {
n, err := r.ReadAt(data, r.position)
r.position += int64(n)
return n, err
}
// ReadAt 从原始编码或附加目录读取并替换首目录指针
// 入参: data 接收缓冲, offset 文件偏移
// 返回: int 字节数, error 读取或取消错误
func (r *tiffPlaneInput) ReadAt(data []byte, offset int64) (int, error) {
if r.ctx != nil {
if err := r.ctx.Err(); err != nil {
return 0, err
}
}
if offset < 0 {
return 0, fmt.Errorf("negative TIFF read offset")
}
start := int64(len(r.source))
directory := start + start%2
written := 0
if offset < start {
written = copy(data, r.source[offset:])
}
if offset+int64(written) == start && start != directory && written < len(data) {
data[written] = 0
written++
}
if at := offset + int64(written) - directory; at >= 0 && at < int64(len(r.directory)) {
written += copy(data[written:], r.directory[at:])
}
var pointer [4]byte
r.order.PutUint32(pointer[:], uint32(directory))
for i, value := range pointer {
if at := int64(4+i) - offset; at >= 0 && at < int64(written) {
data[at] = value
}
}
if written != len(data) {
return written, io.EOF
}
return written, nil
}
// Bounds 返回存储坐标范围
// 返回: image.Rectangle 图像范围
func (p *planarTIFFImage) Bounds() image.Rectangle { return p.bounds }
// ColorModel 按透明度类型和最高分量精度返回颜色模型
// 返回: color.Model 颜色模型
func (p *planarTIFFImage) ColorModel() color.Model {
if p.gray && p.alpha == 0 {
if p.depth > 8 {
return color.Gray16Model
}
return color.GrayModel
}
if p.alpha == 1 {
if p.depth > 8 || p.cmyk {
return color.RGBA64Model
}
return color.RGBAModel
}
if p.depth > 8 || p.cmyk {
return color.NRGBA64Model
}
return color.NRGBAModel
}
// components 直接读取灰度分量,保留原始透明度语义
// 入参: x 横坐标, y 纵坐标
// 返回: [4]uint16 红、绿、蓝和透明度分量
func (p *planarTIFFImage) components(x, y int) [4]uint16 {
if !image.Pt(x, y).In(p.bounds) {
return [4]uint16{}
}
if p.cmyk {
ink := p.inksAt(x, y)
maximum := uint32(65535)
if p.alpha == 1 {
maximum = uint32(ink[4])
if maximum == 0 {
return [4]uint16{}
}
}
var components [4]float64
for i := range components {
components[i] = float64(min(uint32(ink[i]), maximum)) / float64(maximum)
}
rgb, _ := (&pdfgo.ColorSpace{Model: "DeviceCMYK"}).SRGB(components[:], "")
var values [4]uint16
for i := range 3 {
values[i] = uint16(math.Round(rgb[i] * float64(maximum)))
}
values[3] = ink[4]
return values
}
values := [4]uint16{0, 0, 0, 65535}
count := 3
if p.gray || len(p.palette) != 0 {
count = 1
}
if p.alpha != 0 {
count++
}
for i := range count {
value := p.sampleAt(i, x, y)
if i == 0 && p.invert {
value = 65535 - value
}
if len(p.palette) != 0 && i == 0 {
c := p.palette[uint32(value)*uint32(len(p.palette)-1)/65535]
values = [4]uint16{c.R, c.G, c.B, c.A}
} else if p.gray && i == 0 {
values[0], values[1], values[2] = value, value, value
} else if p.gray || len(p.palette) != 0 {
values[3] = value
} else {
values[i] = value
}
}
if p.alpha == 1 && values[3] == 0 {
return [4]uint16{}
}
return values
}
// sampleAt 读取一个分量并扩展为十六位,交错布局复用同一像素缓冲
// 入参: i 分量序号, x 横坐标, y 纵坐标
// 返回: uint16 归一化采样值
func (p *planarTIFFImage) sampleAt(i, x, y int) uint16 {
plane, sx := p.planes[0], x
if p.stride > 1 {
sx = x*p.stride + i + p.offsets[i]
} else {
plane = p.planes[i]
}
switch plane := plane.(type) {
case *image.Gray:
if bits := p.packed[i]; bits != 0 {
var value uint16
for bit := range int(bits) {
value = value<<1 | uint16(plane.GrayAt(sx*int(bits)+bit, y).Y>>7)
}
return value * (65535 / uint16(1<<bits-1))
}
return uint16(plane.GrayAt(sx, y).Y) * 257
case *image.Gray16:
return plane.Gray16At(sx, y).Y
default:
v, _, _, _ := plane.At(sx, y).RGBA()
return uint16(v)
}
}
// inksAt 按网点范围读取CMYK覆盖量,范围外采样按端点截断,透明度独立归一化
// 入参: x 横坐标, y 纵坐标
// 返回: [5]uint16 青、品红、黄、黑及透明度
func (p *planarTIFFImage) inksAt(x, y int) [5]uint16 {
values := [5]uint16{0, 0, 0, 0, 65535}
for i := range 4 {
value := int64(p.sampleAt(i, x, y))
low, high := int64(p.dotRange[i][0]), int64(p.dotRange[i][1])
values[i] = uint16(max(0, min(65535, (value-low)*65535/(high-low))))
}
if p.alpha != 0 {
values[4] = p.sampleAt(4, x, y)
}
return values
}
// At 返回组合后的颜色,关联透明度保留预乘语义
// 入参: x 横坐标, y 纵坐标
// 返回: color.Color 像素颜色
func (p *planarTIFFImage) At(x, y int) color.Color {
values := p.components(x, y)
if p.alpha == 1 {
return color.RGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
}
return color.NRGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
}
// NRGBA64At 读取独立透明度颜色,保留非预乘分量中的隐藏颜色
// 入参: x 横坐标, y 纵坐标
// 返回: color.NRGBA64 像素颜色
func (p *planarTIFFImage) NRGBA64At(x, y int) color.NRGBA64 {
values := p.components(x, y)
if p.alpha == 1 && values[3] != 65535 {
if values[3] == 0 {
return color.NRGBA64{}
}
for i := range 3 {
values[i] = uint16(uint32(values[i]) * 65535 / uint32(values[3]))
}
}
return color.NRGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
}
// RGBA64At 返回预乘十六位颜色,不分配临时颜色对象
// 入参: x 横坐标, y 纵坐标
// 返回: color.RGBA64 像素颜色
func (p *planarTIFFImage) RGBA64At(x, y int) color.RGBA64 {
values := p.components(x, y)
if p.alpha != 1 && values[3] != 65535 {
for i := range 3 {
values[i] = uint16(uint32(values[i]) * uint32(values[3]) / 65535)
}
}
return color.RGBA64{R: values[0], G: values[1], B: values[2], A: values[3]}
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package image
import (
"context"
"fmt"
"image"
"image/color"
"math"
"strings"
"github.com/xiaoqidun/pdfgo"
)
// parseTIFFColorProfile 校验ICC源模型,保留原有模型错误并复用浮点颜色变换
// 入参: kind 源颜色模型, data ICC编码
// 返回: *pdfgo.ColorSpace 颜色变换, error 模型或配置错误
func parseTIFFColorProfile(kind string, data []byte) (*pdfgo.ColorSpace, error) {
model := map[string]string{"GRAY": "GRAY", "RGB": "RGB ", "CMYK": "CMYK"}[strings.ToUpper(kind)]
if len(data) < 132 || model == "" || string(data[16:20]) != model {
return nil, fmt.Errorf("ICC source model does not match image color space %q", kind)
}
return pdfgo.NewICCColorSpace(data)
}
// readTIFFColorProfile 读取主图的ICC配置,校验颜色模型和数据范围
// 入参: ctx 取消上下文,可为nil, data TIFF编码, layout 主图布局
// 返回: *pdfgo.ColorSpace 颜色配置,未声明时为nil, error 配置或取消错误
func readTIFFColorProfile(ctx context.Context, data []byte, layout TIFFLayout) (*pdfgo.ColorSpace, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
if layout.profileSize == 0 {
return nil, nil
}
at, size := uint64(layout.profileOffset), uint64(layout.profileSize)
if at+size > uint64(len(data)) {
return nil, fmt.Errorf("truncated TIFF color profile")
}
kind := ""
switch layout.photometric {
case 0, 1:
kind = "GRAY"
case 2, 3:
kind = "RGB"
case 5:
kind = "CMYK"
default:
return nil, fmt.Errorf("unsupported TIFF profile color model")
}
profile, err := parseTIFFColorProfile(kind, data[at:at+size])
if cancelErr := ctx.Err(); cancelErr != nil {
return nil, cancelErr
}
return profile, err
}
// applyTIFFColorProfile 将源颜色转换为十六位sRGB,保留独立透明度和隐藏颜色
// 入参: ctx 取消上下文,可为nil, source 解码图像, profile 已校验的颜色配置
// 返回: image.Image 独立透明度图像, error 颜色变换或取消错误
func applyTIFFColorProfile(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace) (image.Image, error) {
return applyImageColorProfile(ctx, source, profile, false)
}
// applyImageColorProfile 按源配置转换显示像素,精确复用连续同色结果
// 入参: ctx 取消上下文, source 源像素, profile 颜色空间, linearSRGB 是否先解码sRGB传递函数
// 返回: image.Image 十六位显示图像, error 变换或取消错误
func applyImageColorProfile(ctx context.Context, source image.Image, profile *pdfgo.ColorSpace, linearSRGB bool) (image.Image, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
bounds := source.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx() > math.MaxInt/8/bounds.Dy() {
return nil, fmt.Errorf("TIFF profile image dimensions exceed capacity")
}
cmyk, _ := source.(*planarTIFFImage)
if cmyk != nil && !cmyk.cmyk {
cmyk = nil
}
model := source.ColorModel()
premultiplied := model == color.RGBAModel || model == color.RGBA64Model
table := newTIFFProfileTable(source)
components, intent := profile.Components(), profile.DefaultIntent()
result := image.NewNRGBA64(bounds)
var previous [4]float64
var previousPixel color.NRGBA64
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if (x-bounds.Min.X)&255 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
slot := -1
if len(table) != 0 {
index, alpha := tiffProfileSample(source, x, y, len(table))
slot = index
if pixel := table[index]; pixel.A != 0 {
pixel.A = alpha
result.SetNRGBA64(x, y, pixel)
continue
}
}
var values [4]float64
var alpha uint16
if cmyk != nil {
ink := cmyk.inksAt(x, y)
alpha = ink[4]
if cmyk.alpha == 1 && alpha == 0 {
continue
}
maximum := float64(65535)
if cmyk.alpha == 1 {
maximum = float64(alpha)
}
for i := range values {
values[i] = min(1, float64(ink[i])/maximum)
}
} else if premultiplied {
var pixel color.RGBA64
if native, ok := source.(image.RGBA64Image); ok {
pixel = native.RGBA64At(x, y)
} else {
pixel = color.RGBA64Model.Convert(source.At(x, y)).(color.RGBA64)
}
alpha = pixel.A
if alpha == 0 {
continue
}
for i, value := range [3]uint16{pixel.R, pixel.G, pixel.B} {
values[i] = min(1, float64(value)/float64(alpha))
}
} else {
pixel := NRGBA64At(source, x, y)
alpha = pixel.A
values = [4]float64{float64(pixel.R) / 65535, float64(pixel.G) / 65535, float64(pixel.B) / 65535}
}
if previousPixel.A != 0 && values == previous {
pixel := previousPixel
pixel.A = alpha
result.SetNRGBA64(x, y, pixel)
continue
}
previous = values
if linearSRGB {
for i := range 3 {
if values[i] <= 0.04045 {
values[i] /= 12.92
} else {
values[i] = math.Pow((values[i]+0.055)/1.055, 2.4)
}
}
}
rgb, err := profile.SRGB(values[:components], intent)
if err != nil {
return nil, err
}
var channels [3]uint16
for i, value := range rgb {
channels[i] = uint16(math.Round(max(0, min(1, value)) * 65535))
}
previousPixel = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535}
if slot >= 0 {
table[slot] = color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: 65535}
}
result.SetNRGBA64(x, y, color.NRGBA64{R: channels[0], G: channels[1], B: channels[2], A: alpha})
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &colorImage{NRGBA64: result, source: source}, nil
}
// newTIFFProfileTable 为离散颜色分配查找表,预乘颜色及小图保持逐像素变换
// 入参: source 已解码且尺寸有效的图像
// 返回: []color.NRGBA64 颜色表,Alpha非零表示已有结果,不适用时为nil
func newTIFFProfileTable(source image.Image) []color.NRGBA64 {
count := 0
switch source := source.(type) {
case *image.Gray:
count = 256
case *image.Gray16:
count = 65536
case *image.Paletted:
count = len(source.Palette)
case *planarTIFFImage:
if source.alpha != 1 {
if len(source.palette) != 0 {
count = len(source.palette)
} else if source.gray {
count = 256
if source.packed[0] != 0 {
count = 1 << source.packed[0]
} else if _, ok := source.planes[0].(*image.Gray16); ok {
count = 65536
}
}
}
}
if count == 0 || count >= source.Bounds().Dx()*source.Bounds().Dy() {
return nil
}
return make([]color.NRGBA64, count)
}
// tiffProfileSample 读取查找表索引与独立透明度,颜色键不包含透明度
// 入参: source 适用查找表的图像, x 横坐标, y 纵坐标, count 颜色表长度
// 返回: int 颜色索引, uint16 透明度
func tiffProfileSample(source image.Image, x, y, count int) (int, uint16) {
switch source := source.(type) {
case *image.Gray:
return int(source.GrayAt(x, y).Y), 65535
case *image.Gray16:
return int(source.Gray16At(x, y).Y), 65535
case *image.Paletted:
index := source.ColorIndexAt(x, y)
_, _, _, alpha := source.Palette[index].RGBA()
return int(index), uint16(alpha)
case *planarTIFFImage:
index := int(uint32(source.sampleAt(0, x, y)) * uint32(count-1) / 65535)
alpha := uint16(65535)
if source.alpha != 0 {
alpha = source.sampleAt(1, x, y)
}
return index, alpha
}
return 0, 0
}
+680
View File
@@ -0,0 +1,680 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package threed
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"math"
)
// DefaultBytes 限制默认解码数据规模,不包括调用方已有输入及运行时开销
const DefaultBytes = 256 << 20
// U3DOptions 控制U3D解码规模,MaxDecodedBytes为0时采用256MiB上限
type U3DOptions struct {
MaxDecodedBytes int64
}
// U3DModel 保存独立于PDF的U3D场景及动作数据,不执行动作或读取外部文件
type U3DModel struct {
Major, Minor int16
DefinedUnits bool
Units float64
Meshes []U3DMesh
Nodes []U3DNode
Materials []U3DMaterial
Shaders []U3DShader
Lights []U3DLight
Views []U3DViewResource
Textures []U3DTexture
Motions []U3DMotion
Metadata []U3DMetadata
}
// U3DMetadata 保留块所属名称与原始元数据,数据不与输入共享存储
type U3DMetadata struct {
Block uint32
Name string
Data []byte
}
// U3DNode 保存组、模型、光源或视图节点,父节点矩阵按列存储
type U3DNode struct {
Name, Kind, Resource string
Parents []U3DParent
Visibility uint32
Shaders [][]string
LineShaders [][]string
PointShaders [][]string
View *U3DView
BoneWeights []U3DBoneWeights
Animations []U3DAnimation
}
// U3DParent 保存父节点名称及局部到父节点的变换,空名称引用默认节点
type U3DParent struct {
Name string
Transform [16]float32
}
// U3DMaterial 保存材质参数,Attributes各位决定相应参数是否生效
type U3DMaterial struct {
Name string
Attributes uint32
Ambient, Diffuse, Specular, Emissive [3]float32
Reflectivity, Opacity float32
}
// U3DShader 保存材质与八个纹理通道,Textures中的空项表示未启用
type U3DShader struct {
Name, Material string
Attributes, AlphaFunction, Blend uint32
RenderPass uint32
AlphaReference float32
Textures [8]*U3DTextureLayer
}
// U3DLight 保存环境、方向、点或聚光资源,Kind依次为0至3
type U3DLight struct {
Name string
Attributes uint32
Kind byte
Color, Attenuation [3]float32
SpotAngle, Intensity float32
}
// u3dBlock 保存校验后的块边界,数据切片仅在解码期间引用输入
type u3dBlock struct {
kind uint32
data, meta []byte
}
// u3dDecoder 保存单次解码预算及场景索引,不跨调用缓存输入
type u3dDecoder struct {
ctx context.Context
model U3DModel
remaining uint64
plain bool
meshes map[string]*u3dMeshDeclaration
meshOrder []string
nodes map[string]int
palettes map[uint32]map[string]bool
textures map[string]int
}
// DecodeU3D 按ECMA-363解码静态几何、骨架、场景节点及材质、纹理图像等资源
// 保留动作轨道和动画修饰器,不在解码时求值;绘制能力由RenderU3D另行校验
// 渐进网格尚未支持
// 点集球面预测暂限不超过两个非对跖方向,不推测多方向合并次序
// 入参: ctx 取消上下文, data U3D数据, options 解码限制
// 返回: *U3DModel 独立场景数据, error 格式、能力、预算或取消错误
func DecodeU3D(ctx context.Context, data []byte, options U3DOptions) (*U3DModel, error) {
if ctx == nil || options.MaxDecodedBytes < 0 {
return nil, fmt.Errorf("invalid U3D decode options")
}
if err := ctx.Err(); err != nil {
return nil, err
}
limit := options.MaxDecodedBytes
if limit == 0 {
limit = DefaultBytes
}
d := u3dDecoder{ctx: ctx, remaining: uint64(limit), meshes: make(map[string]*u3dMeshDeclaration), nodes: make(map[string]int), palettes: make(map[uint32]map[string]bool)}
header, next, err := u3dReadBlock(data, 0)
if err != nil {
return nil, err
}
if header.kind != 0x00443355 {
return nil, fmt.Errorf("invalid U3D file header")
}
r := u3dValues{data: header.data}
version, profile := r.u32(), r.u32()
d.model.Major, d.model.Minor = int16(version), int16(version>>16)
if d.model.Major > 0 || profile & ^uint32(14) != 0 {
return nil, fmt.Errorf("unsupported U3D version or profile")
}
declarationEnd := uint64(r.u32())
fileSize := uint64(r.u32())
fileSize |= uint64(r.u32()) << 32
if r.u32() != 106 {
return nil, fmt.Errorf("unsupported U3D character encoding")
}
d.plain = profile&4 != 0
d.model.DefinedUnits, d.model.Units = profile&8 != 0, 1
if d.model.DefinedUnits {
unitBytes := r.take(8)
if r.err == nil {
d.model.Units = math.Float64frombits(binary.LittleEndian.Uint64(unitBytes))
if !finite(d.model.Units) || d.model.Units <= 0 {
return nil, fmt.Errorf("invalid U3D units")
}
}
}
if err := r.done(); err != nil {
return nil, err
}
if fileSize != uint64(len(data)) || declarationEnd < uint64(next) || declarationEnd > fileSize || declarationEnd%4 != 0 {
return nil, fmt.Errorf("invalid U3D file size")
}
if err := d.metadata(header, ""); err != nil {
return nil, err
}
priority := uint32(0)
for offset := next; offset < len(data); {
if err := ctx.Err(); err != nil {
return nil, err
}
block, end, err := u3dReadBlock(data, offset)
if err != nil {
return nil, fmt.Errorf("U3D block at %d: %w", offset, err)
}
continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c || block.kind == 0xffffff3e || block.kind == 0xffffff3f || block.kind == 0xffffff5c
if uint64(offset) < declarationEnd && (uint64(end) > declarationEnd || continuation) || uint64(offset) >= declarationEnd && !continuation {
return nil, fmt.Errorf("invalid U3D declaration boundary at %d", offset)
}
if block.kind == 0xffffff15 {
r := u3dValues{data: block.data}
value := r.u32()
if err := r.done(); err != nil {
return nil, err
}
if value == 0 || value < priority || value > 0x7fffffff {
return nil, fmt.Errorf("invalid U3D block priority")
}
priority = value
err = d.metadata(block, "")
} else if block.kind == 0xffffff14 {
err = d.chain(block)
} else {
_, err = d.block(block, "", -1, 0)
}
if err != nil {
return nil, fmt.Errorf("U3D block %#x at %d: %w", block.kind, offset, err)
}
offset = end
}
for _, name := range d.meshOrder {
mesh := d.meshes[name]
if !mesh.decoded && mesh.counts[1] != 0 {
return nil, fmt.Errorf("missing U3D base mesh %q", name)
}
d.model.Meshes = append(d.model.Meshes, mesh.mesh)
}
if err := d.checkTextures(); err != nil {
return nil, err
}
if err := d.checkParents(); err != nil {
return nil, err
}
if err := d.checkBoneWeights(); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &d.model, nil
}
// u3dReadBlock 按64位长度计算校验块数据与零填充,避免长度溢出
// 入参: data 块序列, offset 块起点
// 返回: u3dBlock 块, int 下一块起点, error 边界或填充错误
func u3dReadBlock(data []byte, offset int) (u3dBlock, int, error) {
if offset < 0 || offset > len(data) || len(data)-offset < 12 {
return u3dBlock{}, 0, io.ErrUnexpectedEOF
}
header := data[offset : offset+12]
size, meta := uint64(binary.LittleEndian.Uint32(header[4:])), uint64(binary.LittleEndian.Uint32(header[8:]))
start := uint64(offset) + 12
metaStart := start + ((size + 3) &^ 3)
end := metaStart + ((meta + 3) &^ 3)
if end > uint64(len(data)) {
return u3dBlock{}, 0, io.ErrUnexpectedEOF
}
for _, padding := range [][]byte{data[start+size : metaStart], data[metaStart+meta : end]} {
for _, value := range padding {
if value != 0 {
return u3dBlock{}, 0, fmt.Errorf("invalid U3D padding")
}
}
}
return u3dBlock{binary.LittleEndian.Uint32(header), data[start : start+size], data[metaStart : metaStart+meta]}, int(end), nil
}
// reserve 在分配解码数组前检查总预算及取消状态
// 入参: count 元素数量, size 元素规模上界
// 返回: error 预算或取消错误
func (d *u3dDecoder) reserve(count, size uint64) error {
if err := d.ctx.Err(); err != nil {
return err
}
if size == 0 || count > d.remaining/size || count > uint64(^uint(0)>>1)/size {
return fmt.Errorf("U3D decoded data exceeds limit")
}
d.remaining -= count * size
return nil
}
// metadata 校验元数据结构并保留原始数据,不解释或执行其中的动作
// 入参: block 数据块, name 所属资源名
// 返回: error 结构、预算或取消错误
func (d *u3dDecoder) metadata(block u3dBlock, name string) error {
if len(block.meta) == 0 {
return nil
}
if err := d.reserve(uint64(len(block.meta))+128, 1); err != nil {
return err
}
r := u3dValues{data: block.meta}
count := r.u32()
if uint64(count) > uint64(len(block.meta))/8 {
return fmt.Errorf("invalid U3D metadata count")
}
for range count {
if err := d.ctx.Err(); err != nil {
return err
}
flags := r.u32()
r.text()
if flags&1 != 0 {
size := r.u32()
if uint64(size) > uint64(len(r.data)-r.pos) {
return io.ErrUnexpectedEOF
}
r.take(int(size))
} else {
r.text()
}
if r.err != nil {
return r.err
}
}
if err := r.done(); err != nil {
return err
}
d.model.Metadata = append(d.model.Metadata, U3DMetadata{block.kind, name, bytes.Clone(block.meta)})
return nil
}
// chain 解析单层修饰链并校验名称、链类型与修饰顺序
// 入参: block 修饰链块
// 返回: error 结构、能力或资源错误
func (d *u3dDecoder) chain(block u3dBlock) error {
if err := d.reserve(256, 1); err != nil {
return err
}
r := u3dValues{data: block.data}
name, kind, flags := r.text(), r.u32(), r.u32()
if r.err != nil {
return r.err
}
if name == "" {
return fmt.Errorf("cannot redefine U3D default resource")
}
if kind > 2 || flags & ^uint32(3) != 0 {
return fmt.Errorf("invalid U3D modifier chain")
}
if flags&1 != 0 {
for range 4 {
r.f32()
}
}
if flags&2 != 0 {
for range 6 {
r.f32()
}
}
for r.pos%4 != 0 && r.err == nil {
if r.u8() != 0 {
return fmt.Errorf("invalid U3D chain padding")
}
}
count := r.u32()
if r.err != nil {
return r.err
}
if uint64(count) > uint64(len(r.data)-r.pos)/12 {
return fmt.Errorf("invalid U3D modifier count")
}
for index := uint32(0); index < count; index++ {
if err := d.ctx.Err(); err != nil {
return err
}
nested, next, err := u3dReadBlock(r.data, r.pos)
if err != nil {
return err
}
actual, err := d.block(nested, name, int(kind), index)
if err != nil {
return err
}
if actual != name {
return fmt.Errorf("U3D modifier name mismatch")
}
r.pos = next
}
if err := r.done(); err != nil {
return err
}
return d.metadata(block, name)
}
// block 解析支持的静态场景块,不丢弃未实现的修饰器或几何
// 入参: block 数据块, owner 所属链名, chain 链类别,负值表示独立块, index 修饰位置
// 返回: string 资源名, error 格式、能力或资源错误
func (d *u3dDecoder) block(block u3dBlock, owner string, chain int, index uint32) (string, error) {
if err := d.ctx.Err(); err != nil {
return "", err
}
if err := d.reserve(uint64(len(block.data))+256, 1); err != nil {
return "", err
}
r := u3dValues{data: block.data}
name := r.text()
if r.err != nil {
return "", r.err
}
if name == "" {
return "", fmt.Errorf("cannot redefine U3D default resource")
}
var err error
switch block.kind {
case 0xffffff21, 0xffffff22, 0xffffff23, 0xffffff24:
if chain >= 0 && (chain != 0 || index != 0) {
return name, fmt.Errorf("invalid U3D node chain")
}
err = d.node(&r, block.kind, name)
case 0xffffff31:
if chain >= 0 && (chain != 1 || index != 0) {
return name, fmt.Errorf("invalid U3D mesh chain")
}
err = d.meshDeclaration(&r, name)
case 0xffffff3b:
if chain >= 0 {
return name, fmt.Errorf("invalid U3D mesh continuation location")
}
err = d.baseMesh(&r, name)
case 0xffffff36, 0xffffff37:
if chain >= 0 && (chain != 1 || index != 0) {
return name, fmt.Errorf("invalid U3D primitive chain")
}
err = d.primitiveDeclaration(&r, name, block.kind == 0xffffff36)
case 0xffffff3e, 0xffffff3f:
if chain >= 0 {
return name, fmt.Errorf("invalid U3D primitive continuation location")
}
err = d.primitiveContinuation(&r, name, block.kind == 0xffffff3e)
case 0xffffff43, 0xffffff44:
if (chain != 0 && chain != 1) || index == 0 || owner != name || r.u32() != index {
return name, fmt.Errorf("invalid U3D animation or bone weight modifier chain")
}
if _, exists := d.nodes[name]; chain == 0 && !exists || chain == 1 && d.meshes[name] == nil {
return name, fmt.Errorf("missing U3D animation or bone weight modifier target")
}
if block.kind == 0xffffff43 {
err = d.animation(&r, name, chain, index)
} else {
err = d.boneWeights(&r, name, chain, index)
}
case 0xffffff45:
if chain != 0 || index == 0 || owner != name || r.u32() != index {
return name, fmt.Errorf("invalid U3D shading modifier chain")
}
err = d.shading(&r, name)
case 0xffffff51, 0xffffff52, 0xffffff53, 0xffffff54, 0xffffff56:
if chain >= 0 {
return name, fmt.Errorf("invalid U3D palette resource location")
}
err = d.resource(&r, block.kind, name)
case 0xffffff55:
if chain >= 0 && (chain != 2 || index != 0) {
return name, fmt.Errorf("invalid U3D texture chain")
}
err = d.textureDeclaration(&r, name)
case 0xffffff5c:
if chain >= 0 {
return name, fmt.Errorf("invalid U3D texture continuation location")
}
err = d.textureContinuation(&r, name)
default:
return name, fmt.Errorf("unsupported U3D block %#x", block.kind)
}
if err != nil {
return name, err
}
if err := r.done(); err != nil {
return name, err
}
return name, d.metadata(block, name)
}
// node 读取节点与父变换,允许引用后续声明的父节点
// 入参: r 字段读取器, kind 节点类型, name 节点名
// 返回: error 结构或预算错误
func (d *u3dDecoder) node(r *u3dValues, kind uint32, name string) error {
if _, exists := d.nodes[name]; exists {
return fmt.Errorf("duplicate U3D node %q", name)
}
node := U3DNode{Name: name, Kind: "Group"}
count := r.u32()
if uint64(count) > uint64(len(r.data)-r.pos)/66 {
return io.ErrUnexpectedEOF
}
if err := d.reserve(uint64(count), 96); err != nil {
return err
}
node.Parents = make([]U3DParent, int(count))
for index := range node.Parents {
if index%256 == 0 {
if err := d.ctx.Err(); err != nil {
return err
}
}
node.Parents[index].Name = r.text()
for element := range node.Parents[index].Transform {
node.Parents[index].Transform[element] = r.f32()
}
}
if kind == 0xffffff22 {
node.Kind, node.Resource, node.Visibility = "Model", r.text(), r.u32()
if node.Visibility > 3 {
return fmt.Errorf("invalid U3D model visibility")
}
} else if kind == 0xffffff23 {
node.Kind, node.Resource = "Light", r.text()
} else if kind == 0xffffff24 {
node.Kind, node.Resource = "View", r.text()
var err error
node.View, err = d.view(r)
if err != nil {
return err
}
}
d.nodes[name] = len(d.model.Nodes)
d.model.Nodes = append(d.model.Nodes, node)
return r.err
}
// shading 分别读取网格、线集和点集着色列表,保留列表内各着色器的先后次序
// 入参: r 字段读取器, name 目标节点名
// 返回: error 结构、能力或预算错误
func (d *u3dDecoder) shading(r *u3dValues, name string) error {
index, exists := d.nodes[name]
if !exists || d.model.Nodes[index].Kind != "Model" {
return fmt.Errorf("missing U3D shading target")
}
flags := r.u32()
if flags & ^uint32(7) != 0 {
return fmt.Errorf("unsupported U3D shading modifier target")
}
count := r.u32()
if uint64(count) > uint64(len(r.data)-r.pos)/4 {
return io.ErrUnexpectedEOF
}
if err := d.reserve(uint64(count), 24); err != nil {
return err
}
lists := make([][]string, int(count))
for index := range lists {
n := r.u32()
if uint64(n) > uint64(len(r.data)-r.pos)/2 {
return io.ErrUnexpectedEOF
}
if err := d.reserve(uint64(n), 16); err != nil {
return err
}
lists[index] = make([]string, int(n))
for j := range lists[index] {
if j%256 == 0 {
if err := d.ctx.Err(); err != nil {
return err
}
}
lists[index][j] = r.text()
}
}
if flags&1 != 0 {
d.model.Nodes[index].Shaders = lists
}
if flags&2 != 0 {
d.model.Nodes[index].LineShaders = lists
}
if flags&4 != 0 {
d.model.Nodes[index].PointShaders = lists
}
return r.err
}
// resource 读取独立材质、着色器、光源、视图和动作资源
// 入参: r 字段读取器, kind 资源类型, name 资源名
// 返回: error 结构或能力错误
func (d *u3dDecoder) resource(r *u3dValues, kind uint32, name string) error {
palette := d.palettes[kind]
if palette == nil {
palette = make(map[string]bool)
d.palettes[kind] = palette
}
if palette[name] {
return fmt.Errorf("duplicate U3D resource %q", name)
}
palette[name] = true
switch kind {
case 0xffffff56:
return d.motion(r, name)
case 0xffffff52:
return d.viewResource(r, name)
case 0xffffff54:
material := U3DMaterial{Name: name, Attributes: r.u32()}
if material.Attributes & ^uint32(63) != 0 {
return fmt.Errorf("invalid U3D material attributes")
}
for _, color := range []*[3]float32{&material.Ambient, &material.Diffuse, &material.Specular, &material.Emissive} {
for index := range color {
color[index] = r.f32()
}
}
material.Reflectivity, material.Opacity = r.f32(), r.f32()
d.model.Materials = append(d.model.Materials, material)
case 0xffffff53:
shader := U3DShader{Name: name, Attributes: r.u32(), AlphaReference: r.f32(), AlphaFunction: r.u32(), Blend: r.u32(), RenderPass: r.u32()}
channels, alpha := r.u32(), r.u32()
shader.Material = r.text()
if shader.Attributes & ^uint32(7) != 0 || shader.AlphaFunction < 0x610 || shader.AlphaFunction > 0x617 || shader.Blend < 0x604 || shader.Blend > 0x607 || channels & ^uint32(255) != 0 || alpha & ^channels != 0 {
return fmt.Errorf("invalid U3D shader parameters")
}
for channel := range shader.Textures {
if channels&(1<<channel) == 0 {
continue
}
if err := d.reserve(1, 192); err != nil {
return err
}
layer := &U3DTextureLayer{Texture: r.text(), Intensity: r.f32(), Blend: r.u8(), BlendSource: r.u8(), BlendConstant: r.f32(), Mode: r.u8(), Alpha: alpha&(1<<channel) != 0}
for i := range layer.Transform {
layer.Transform[i] = r.f32()
}
for i := range layer.WrapTransform {
layer.WrapTransform[i] = r.f32()
}
layer.Repeat = r.u8()
if r.err != nil {
return r.err
}
if layer.Blend > 3 || layer.BlendSource > 1 || layer.Mode > 4 || layer.Repeat > 3 {
return fmt.Errorf("invalid U3D texture layer")
}
shader.Textures[channel] = layer
}
d.model.Shaders = append(d.model.Shaders, shader)
case 0xffffff51:
light := U3DLight{Name: name, Attributes: r.u32(), Kind: r.u8()}
if light.Attributes & ^uint32(7) != 0 || light.Kind > 3 {
return fmt.Errorf("invalid U3D light parameters")
}
for index := range light.Color {
light.Color[index] = r.f32()
}
if r.f32() != 1 {
return fmt.Errorf("invalid U3D light reserved parameter")
}
for index := range light.Attenuation {
light.Attenuation[index] = r.f32()
}
light.SpotAngle, light.Intensity = r.f32(), r.f32()
d.model.Lights = append(d.model.Lights, light)
}
return r.err
}
// checkParents 迭代检查场景父子环路,不递归展开多父节点的实例组合
// 返回: error 环路或取消错误
func (d *u3dDecoder) checkParents() error {
state := make([]byte, len(d.model.Nodes))
type frame struct{ node, parent int }
var stack []frame
for index := range d.model.Nodes {
if state[index] != 0 {
continue
}
stack = append(stack[:0], frame{node: index})
state[index] = 1
for len(stack) != 0 {
if err := d.ctx.Err(); err != nil {
return err
}
current := &stack[len(stack)-1]
parents := d.model.Nodes[current.node].Parents
if current.parent == len(parents) {
state[current.node] = 2
stack = stack[:len(stack)-1]
continue
}
parent, exists := d.nodes[parents[current.parent].Name]
current.parent++
if !exists || state[parent] == 2 {
continue
}
if state[parent] == 1 {
return fmt.Errorf("cyclic U3D node parents")
}
state[parent] = 1
stack = append(stack, frame{node: parent})
}
}
return nil
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package threed
import (
"context"
"fmt"
"io"
)
// U3DAnimation 保存ECMA-363第9.7.3节的动画修饰器,Index为链内位置
// Attributes的1、2、4、8分别表示启动、锁定根骨骼、单轨播放和动作间平滑过渡
// TimeScale为整体时间倍率,BlendTime以毫秒计;Motions为空时使用无轨道的默认动作
type U3DAnimation struct {
Index, Attributes uint32
TimeScale, BlendTime float32
Motions []U3DAnimationMotion
}
// U3DAnimationMotion 保存动画中的动作引用,空名称引用默认动作
// Attributes的1、2分别表示循环和同步结束,TimeOffset以毫秒计,TimeScale为动作时间倍率
type U3DAnimationMotion struct {
Name string
Attributes uint32
TimeOffset, TimeScale float32
}
// animation 读取动画参数及有序动作引用,不在解码时调度或求值
// 入参: r 字段读取器, name 所属链名, chain 链类型, index 链内位置
// 返回: error 结构、参数、预算或取消错误
func (d *u3dDecoder) animation(r *u3dValues, name string, chain int, index uint32) error {
animation := U3DAnimation{Index: index, Attributes: r.u32(), TimeScale: r.f32()}
count := r.u32()
if r.err != nil {
return r.err
}
if len(r.data)-r.pos < 4 || uint64(count) > uint64(len(r.data)-r.pos-4)/14 {
return io.ErrUnexpectedEOF
}
if err := d.reserve(uint64(count), 32); err != nil {
return err
}
if count != 0 {
animation.Motions = make([]U3DAnimationMotion, int(count))
}
for i := range animation.Motions {
if i%256 == 0 {
if err := d.ctx.Err(); err != nil {
return err
}
}
motion := &animation.Motions[i]
motion.Name, motion.Attributes = r.text(), r.u32()
motion.TimeOffset, motion.TimeScale = r.f32(), r.f32()
if r.err != nil {
return r.err
}
}
animation.BlendTime = r.f32()
if err := r.done(); err != nil {
return err
}
if err := animation.validate(d.ctx); err != nil {
return err
}
if chain == 1 {
mesh := &d.meshes[name].mesh
mesh.Animations = append(mesh.Animations, animation)
} else {
node := &d.model.Nodes[d.nodes[name]]
node.Animations = append(node.Animations, animation)
}
return nil
}
// validate 校验动画属性及有限数值,保留零倍率与反向播放倍率
// 入参: ctx 取消上下文
// 返回: error 参数或取消错误
func (animation U3DAnimation) validate(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
if animation.Index == 0 || animation.Attributes & ^uint32(15) != 0 || !finite(float64(animation.TimeScale)) || !finite(float64(animation.BlendTime)) || animation.BlendTime < 0 {
return fmt.Errorf("invalid U3D animation parameters")
}
for i, motion := range animation.Motions {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
if motion.Attributes & ^uint32(3) != 0 || !finite(float64(motion.TimeOffset)) || !finite(float64(motion.TimeScale)) {
return fmt.Errorf("invalid U3D animation motion parameters")
}
}
return nil
}
// checkAnimations 校验全部动作引用,仅对接入场景的修饰器检查求值能力
// 返回: error 引用、能力或取消错误
func (r *Renderer) checkAnimations() error {
check := func(animations []U3DAnimation, evaluate bool) error {
for _, animation := range animations {
if err := animation.validate(r.ctx); err != nil {
return err
}
for i, motion := range animation.Motions {
if i%256 == 0 {
if err := r.ctx.Err(); err != nil {
return err
}
}
if motion.Name == "" {
continue
}
index, exists := r.motionIndex[motion.Name]
if !exists {
return fmt.Errorf("missing U3D animation motion %q", motion.Name)
}
if evaluate && len(animation.Motions) > 1 && len(r.model.Motions[index].Tracks) != 0 {
return fmt.Errorf("unsupported U3D animation evaluation for motion %q", motion.Name)
}
}
}
return nil
}
var active []bool
if r.connected != nil {
if err := r.Reserve(uint64(len(r.model.Meshes)), 1); err != nil {
return err
}
defer func() { r.remaining += uint64(len(active)) }()
active = make([]bool, len(r.model.Meshes))
for i, node := range r.model.Nodes {
if i%256 == 0 {
if err := r.ctx.Err(); err != nil {
return err
}
}
if r.connected[i] && node.Kind == "Model" {
if mesh, exists := r.meshes[node.Resource]; exists {
active[mesh] = true
}
}
}
}
for i := range r.model.Meshes {
if err := r.ctx.Err(); err != nil {
return err
}
if err := check(r.model.Meshes[i].Animations, active == nil || active[i]); err != nil {
return err
}
}
for i := range r.model.Nodes {
if err := r.ctx.Err(); err != nil {
return err
}
if err := check(r.model.Nodes[i].Animations, r.connected == nil || r.connected[i]); err != nil {
return err
}
}
return r.ctx.Err()
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"encoding/binary"
@@ -46,6 +46,27 @@ type u3dHistogram struct {
total uint32
}
// finish 校验压缩块的冲刷字段及结束位置
// 返回: error 截断、冲刷或多余数据错误
func (r *u3dBits) finish() error {
if r.err != nil {
return r.err
}
if r.pos > uint64(len(r.data))*8 {
return io.ErrUnexpectedEOF
}
if r.u32() != 0 {
return fmt.Errorf("invalid U3D compression flush")
}
if r.err != nil {
return r.err
}
if (r.pos-16+7)/8 != uint64(len(r.data)) {
return fmt.Errorf("unexpected U3D compressed block data")
}
return nil
}
// take 读取定长字段,失败后不继续推进位置
// 入参: size 字节数
// 返回: []byte 字段数据,失败时为空
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package threed
import (
"fmt"
"math"
)
// DepthRange 计算可见模型几何的相机深度范围,不为自动裁剪复制顶点
// 返回: float64 最近深度, float64 最远深度, bool 是否含几何, error 几何或取消错误
func (r *Renderer) DepthRange() (float64, float64, bool, error) {
minimum, maximum := math.Inf(1), math.Inf(-1)
for i, instance := range r.scene {
if i%256 == 0 {
if err := r.ctx.Err(); err != nil {
return 0, 0, false, err
}
}
node := r.model.Nodes[instance.node]
index, ok := r.meshes[node.Resource]
if node.Kind != "Model" || instance.modelVisibility(&node) == 0 || instance.opacity == 0 || !ok {
continue
}
mesh := &r.model.Meshes[index]
matrix := r.toCamera.multiply(instance.world)
for j := range len(mesh.Faces) + len(mesh.Lines) + len(mesh.Points) {
_, corners := mesh.primitive(j)
if j%256 == 0 {
if err := r.ctx.Err(); err != nil {
return 0, 0, false, err
}
}
for _, corner := range corners {
if uint64(corner.Position) >= uint64(len(mesh.Positions)) {
return 0, 0, false, fmt.Errorf("invalid U3D position index")
}
p := mesh.Positions[corner.Position]
z := matrix[2]*float64(p[0]) + matrix[6]*float64(p[1]) + matrix[10]*float64(p[2]) + matrix[14]
if !finite(z) {
return 0, 0, false, fmt.Errorf("invalid U3D camera depth")
}
minimum, maximum = min(minimum, z), max(maximum, z)
}
}
}
for i, section := range r.sections {
if i%256 == 0 {
if err := r.ctx.Err(); err != nil {
return 0, 0, false, err
}
}
if section.opacity > 0 {
for _, corner := range section.corners {
minimum, maximum = min(minimum, corner.point[2]), max(maximum, corner.point[2])
}
}
}
if math.IsInf(minimum, 1) {
return 0, 0, false, nil
}
return minimum, maximum, true, nil
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package threed
import (
"math"
)
// finite 判断数值是否有限
// 入参: value 数值
// 返回: bool 是否有限
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"fmt"
@@ -47,7 +47,7 @@ type u3dRenderStyle struct {
// collectLights 收集本轮根节点下启用的光源实例
// 返回: error 参数、预算或取消错误
func (r *u3dRender) collectLights() error {
func (r *Renderer) collectLights() error {
if r.lighting != nil {
return nil
}
@@ -63,10 +63,13 @@ func (r *u3dRender) collectLights() error {
continue
}
j, ok := r.lightIndex[node.Resource]
if !ok {
if !ok && node.Resource != "" {
continue
}
light := r.model.Lights[j]
light := U3DLight{Attributes: 1, Color: [3]float32{.75, .75, .75}}
if ok {
light = r.model.Lights[j]
}
if light.Attributes&1 == 0 {
continue
}
@@ -98,7 +101,7 @@ func (r *u3dRender) collectLights() error {
}
if len(r.lights) == cap(r.lights) {
capacity := max(8, cap(r.lights)*2)
if err := r.reserve(uint64(capacity), 128); err != nil {
if err := r.Reserve(uint64(capacity), 128); err != nil {
return err
}
values := make([]u3dRenderLight, len(r.lights), capacity)
@@ -114,7 +117,7 @@ func (r *u3dRender) collectLights() error {
// overrideLighting 校验并建立替代光源,不依赖模型光源节点
// 入参: lighting 灯光覆盖,空值保留模型灯光
// 返回: error 参数、预算或取消错误
func (r *u3dRender) overrideLighting(lighting *U3DLighting) error {
func (r *Renderer) overrideLighting(lighting *U3DLighting) error {
if lighting == nil {
return nil
}
@@ -123,7 +126,7 @@ func (r *u3dRender) overrideLighting(lighting *U3DLighting) error {
return fmt.Errorf("invalid U3D diffuse offset")
}
}
if err := r.reserve(uint64(len(lighting.Lights)), 128); err != nil {
if err := r.Reserve(uint64(len(lighting.Lights)), 128); err != nil {
return err
}
r.lights = make([]u3dRenderLight, len(lighting.Lights))
@@ -152,11 +155,21 @@ func (r *u3dRender) overrideLighting(lighting *U3DLighting) error {
return nil
}
// style 解析着色器与材质,未启用的材质项使用固定默认值
// shaderPass 返回着色器的绘制轮次位图,默认着色器仅参与第一轮
// 入参: name 着色器名
// 返回: uint32 绘制轮次位图
func (r *Renderer) shaderPass(name string) uint32 {
if index, exists := r.shaders[name]; exists {
return r.model.Shaders[index].RenderPass
}
return 1
}
// style 解析着色器与材质,缺失资源采用ECMA-363第8.4节的默认资源
// 入参: name 着色器名
// 返回: u3dRenderStyle 有效绘制参数, error 参数错误
func (r *u3dRender) style(name string) (u3dRenderStyle, error) {
style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, AlphaFunction: 0x617, RenderPass: math.MaxUint32}, ambient: [3]float64{.2, .2, .2}, diffuse: [3]float64{.8, .8, .8}, opacity: 1}
func (r *Renderer) style(name string) (u3dRenderStyle, error) {
style := u3dRenderStyle{shader: U3DShader{Attributes: 1, Blend: 0x606, AlphaFunction: 0x617, RenderPass: 1}, ambient: [3]float64{.75, .75, .75}, opacity: 1}
if index, ok := r.shaders[name]; ok {
style.shader = r.model.Shaders[index]
}
@@ -166,6 +179,7 @@ func (r *u3dRender) style(name string) (u3dRenderStyle, error) {
}
if index, ok := r.materials[shader.Material]; ok {
material := r.model.Materials[index]
style.ambient, style.diffuse = [3]float64{.2, .2, .2}, [3]float64{.8, .8, .8}
if material.Attributes > 63 {
return style, fmt.Errorf("invalid U3D render material")
}
@@ -201,7 +215,7 @@ func (r *u3dRender) style(name string) (u3dRenderStyle, error) {
// shade 按世界坐标计算逐顶点漫反射、镜面与环境光
// 入参: position 顶点, normal 法线, style 材质, diffuse 顶点漫反射, specular 顶点镜面颜色
// 返回: [4]float64 直通RGBA, error 参数或取消错误
func (r *u3dRender) shade(position, normal u3dVector, style u3dRenderStyle, diffuse, specular *[4]float32) ([4]float64, error) {
func (r *Renderer) shade(position, normal u3dVector, style u3dRenderStyle, diffuse, specular *[4]float32) ([4]float64, error) {
color := [4]float64{style.diffuse[0], style.diffuse[1], style.diffuse[2], style.opacity}
if style.shader.Attributes&4 != 0 {
if diffuse != nil {
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"fmt"
@@ -22,7 +22,7 @@ import (
// drawPrimitives 绘制作者点集及线集,独立着色且不参与面的背向剔除
// 入参: instance 场景实例, node 模型节点, mesh 属性数组, positions 世界位置, normals 世界法线, pass 绘制轮次, passIndex 轮次索引
// 返回: error 几何、资源或取消错误
func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3DMesh, positions, normals []u3dVector, pass U3DViewPass, passIndex int) error {
func (r *Renderer) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3DMesh, positions, normals []u3dVector, pass U3DViewPass, passIndex int) error {
if r.depthOnly {
return nil
}
@@ -68,15 +68,13 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
if err := r.ctx.Err(); err != nil {
return err
}
style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: math.MaxUint32}}
style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: r.shaderPass(name)}}
var err error
if shaded {
style, err = r.style(name)
if err != nil {
return err
}
} else if index, ok := r.shaders[name]; ok {
style.shader.RenderPass = r.model.Shaders[index].RenderPass
}
if style.shader.RenderPass&(1<<passIndex) == 0 {
continue
@@ -84,15 +82,22 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
if instance.opacity >= 0 || r.opacity >= 0 {
style.shader.Blend = 0x606
}
textures, err := r.shaderTextures(style.shader)
if err != nil {
return err
}
for i, corner := range corners {
var normal u3dVector
if shaded && !mesh.ExcludeNormals {
normal = normals[corner.Normal]
}
if err := textures.coordinates(&vertices[i], mesh, shading, corner, normal); err != nil {
return err
}
vertices[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
if !shaded {
continue
}
var normal u3dVector
if !mesh.ExcludeNormals {
normal = normals[corner.Normal]
}
var diffuse, specular *[4]float32
if shading.Attributes&1 != 0 {
diffuse = &mesh.Diffuse[corner.Diffuse]
@@ -106,8 +111,9 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
}
if instance.opacity >= 0 {
vertices[i].color[3] = instance.opacity
vertices[i].opacityOverride = true
}
if r.opacity >= 0 {
if r.opacity >= 0 && textures == nil {
vertices[i].color[3] *= r.opacity
}
}
@@ -152,7 +158,7 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3
// lineIntersection 绘制作者线段与剖切面的交点,不绘制共面线段
// 入参: line 相机空间线段, index 剖切面索引
// 返回: error 数值、资源或取消错误
func (r *u3dRender) lineIntersection(line [2]u3dVertex, index int) error {
func (r *Renderer) lineIntersection(line [2]u3dVertex, index int) error {
section := r.sections[index]
a, b := u3dPlaneDistance(section.plane, line[0].point), u3dPlaneDistance(section.plane, line[1].point)
if !finite(a) || !finite(b) {
@@ -195,7 +201,7 @@ func (r *u3dRender) lineIntersection(line [2]u3dVertex, index int) error {
// frustumPlanes 返回保留正侧的六个相机视锥面
// 返回: [6][4]float64 近、远、左右及上下平面
func (r *u3dRender) frustumPlanes() [6][4]float64 {
func (r *Renderer) frustumPlanes() [6][4]float64 {
w, h := r.viewport[2]/2, r.viewport[3]/2
planes := [6][4]float64{{0, 0, 1, -r.camera.Near}, {0, 0, -1, r.camera.Far}, {r.camera.ScaleX, 0, 0, w + r.camera.ShiftX}, {-r.camera.ScaleX, 0, 0, w - r.camera.ShiftX}, {0, r.camera.ScaleY, 0, h - r.camera.ShiftY}, {0, -r.camera.ScaleY, 0, h + r.camera.ShiftY}}
if r.camera.Perspective {
@@ -226,7 +232,7 @@ func u3dClipFraction(a, b float64) float64 {
return a / (a + b)
}
// u3dInterpolate 在相机空间插值坐标与未预乘颜色
// u3dInterpolate 在相机空间插值位置、颜色与纹理输入
// 入参: a 起点, b 终点, t 比例
// 返回: u3dVertex 插值顶点
func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex {
@@ -237,13 +243,25 @@ func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex {
for i := range result.color {
result.color[i] = a.color[i]*(1-t) + b.color[i]*t
}
result.textureMask = a.textureMask | b.textureMask
result.opacityOverride = a.opacityOverride
if result.textureMask != 0 {
for layer := range result.texture {
if result.textureMask&(1<<layer) == 0 {
continue
}
for c := range result.texture[layer] {
result.texture[layer][c] = a.texture[layer][c]*(1-t) + b.texture[layer][c]*t
}
}
}
return result
}
// sectionIntersection 绘制原三角面与指定剖切面的交线,其他剖切面仍参与裁剪
// 入参: triangle 原三角面, index 剖切面索引
// 返回: error 数值、资源或取消错误
func (r *u3dRender) sectionIntersection(triangle [3]u3dVertex, index int) error {
func (r *Renderer) sectionIntersection(triangle [3]u3dVertex, index int) error {
if err := r.ctx.Err(); err != nil {
return err
}
@@ -334,7 +352,7 @@ func u3dClipLine(a, b *u3dVertex, plane [4]float64) (bool, error) {
// rasterSectionLine 绘制单像素不透明交线,透视深度使用倒数插值
// 入参: a 已裁剪起点, b 已裁剪终点, rgb 交线颜色, normal 原面法线, origin 原面上一点
// 返回: error 数值、资源或取消错误
func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, origin u3dVector) error {
func (r *Renderer) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, origin u3dVector) error {
a.color = [4]float64{rgb[0], rgb[1], rgb[2], 1}
b.color = a.color
return r.rasterLine(a, b, normal, origin, U3DShader{Blend: 0x606}, U3DViewPass{})
@@ -343,7 +361,11 @@ func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, or
// rasterLine 绘制单像素线段,颜色与深度按透视插值,面深度偏移防止自身遮挡
// 入参: a 已裁剪起点, b 已裁剪终点, normal 面法线, origin 面上点, shader 着色器, pass 绘制轮次
// 返回: error 数值、资源或取消错误
func (r *u3dRender) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
func (r *Renderer) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
textures, err := r.shaderTextures(shader)
if err != nil {
return err
}
cx := r.viewport[0] + r.viewport[2]/2 + r.camera.ShiftX
cy := r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY
planes := [4][4]float64{{r.camera.ScaleX, 0, 0, cx}, {-r.camera.ScaleX, 0, 0, float64(r.output.Rect.Dx()) - cx}, {0, -r.camera.ScaleY, 0, cy}, {0, r.camera.ScaleY, 0, float64(r.output.Rect.Dy()) - cy}}
@@ -413,6 +435,9 @@ func (r *u3dRender) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader
depth -= 8 * math.Abs(depth-math.Nextafter(depth, math.Inf(-1)))
if depth <= r.frame[pixel].depth {
vertex := u3dInterpolate(a, b, weight)
if err := r.textureColor(textures, &vertex.color, &vertex.texture, vertex.point, vertex.opacityOverride); err != nil {
return err
}
visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass)
if err != nil {
return err
@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"fmt"
"io"
)
// U3D线集增量编码的算术编码上下文
@@ -94,9 +93,11 @@ func (d *u3dDecoder) primitiveDeclaration(r *u3dValues, name string, points bool
return fmt.Errorf("invalid U3D reserved primitive parameter")
}
}
if r.u32() != 0 {
return fmt.Errorf("unsupported U3D skeletal primitive set")
bones, err := d.skeleton(r)
if err != nil {
return err
}
decl.mesh.Bones = bones
if err := r.done(); err != nil {
return err
}
@@ -210,11 +211,11 @@ func (s *u3dLineState) accumulateCorners(mesh *U3DMesh, shadingIndex uint32, cor
}
// value 按指定上下文和位宽读取点集或线集字段
// 入参: context 压缩上下文, small 是否为8位字段
// 入参: ctx 压缩上下文, small 是否为8位字段
// 返回: uint32 字段值
func (r *u3dLineReader) value(context int, small bool) uint32 {
func (r *u3dLineReader) value(ctx int, small bool) uint32 {
if r.bits != nil {
return r.bits.dynamicValue(&r.hist[context], small)
return r.bits.dynamicValue(&r.hist[ctx], small)
}
if small {
return uint32(r.values.u8())
@@ -444,17 +445,8 @@ func (d *u3dDecoder) primitiveContinuation(r *u3dValues, name string, points boo
}
}
if b := reader.bits; b != nil {
if b.pos > uint64(len(b.data))*8 {
return io.ErrUnexpectedEOF
}
if b.u32() != 0 {
return fmt.Errorf("invalid U3D primitive compression flush")
}
if b.err != nil {
return b.err
}
if (b.pos-16+7)/8 != uint64(len(b.data)) {
return fmt.Errorf("unexpected U3D primitive compressed data")
if err := b.finish(); err != nil {
return err
}
r.pos = len(r.data)
}
+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 threed
import (
"fmt"
"math"
)
// u3dTextureInverse 计算包裹变换的逆矩阵,保留非仿射齐次分量
// 入参: matrix 按列排列的包裹变换
// 返回: [16]float64 逆矩阵, error 奇异矩阵或非有限结果
func u3dTextureInverse(matrix [16]float32) ([16]float64, error) {
var rows [4][8]float64
for row := range 4 {
for col := range 4 {
rows[row][col] = float64(matrix[col*4+row])
if !finite(rows[row][col]) {
return [16]float64{}, fmt.Errorf("invalid U3D texture wrap transform")
}
}
rows[row][row+4] = 1
}
for col := range 4 {
pivot := col
for row := col + 1; row < 4; row++ {
if math.Abs(rows[row][col]) > math.Abs(rows[pivot][col]) {
pivot = row
}
}
if rows[pivot][col] == 0 {
return [16]float64{}, fmt.Errorf("singular U3D texture wrap transform")
}
rows[col], rows[pivot] = rows[pivot], rows[col]
divisor := rows[col][col]
for i := range 8 {
rows[col][i] /= divisor
}
for row := range 4 {
if row == col {
continue
}
factor := rows[row][col]
for i := range 8 {
rows[row][i] -= factor * rows[col][i]
}
}
}
var result [16]float64
for row := range 4 {
for col := range 4 {
value := rows[row][col+4]
if !finite(value) {
return [16]float64{}, fmt.Errorf("invalid U3D inverse texture wrap transform")
}
result[col*4+row] = value
}
}
return result, nil
}
// generated 将插值后的位置或法线映射到纹理,再应用纹理变换
// 经线未定义时取0,球心纬度取1/2,反射方向奇点取图像中心
// 入参: channel 通道索引, input 模型位置或相机法线, position 相机空间位置
// 返回: [3]float64 齐次纹理坐标, error 非有限值或无效投影
func (s *u3dTextureSet) generated(channel int, input [3]float64, position u3dVector) ([3]float64, error) {
layer := s.layers[channel]
for _, value := range input {
if !finite(value) {
return [3]float64{}, fmt.Errorf("invalid U3D texture mapping input")
}
}
uv := [4]float64{0, 0, 0, 1}
if layer.Mode == 4 {
incident := u3dVector{0, 0, 1}
if s.perspective {
incident = position.unit()
}
normal := u3dVector(input).unit()
factor := 2 * normal.dot(incident)
reflected := incident.sub(u3dVector{factor * normal[0], factor * normal[1], factor * normal[2]})
denominator := 2 * math.Hypot(math.Hypot(reflected[0], reflected[1]), 1-reflected[2])
uv[0], uv[1] = .5, .5
if denominator != 0 {
uv[0] += reflected[0] / denominator
uv[1] -= reflected[1] / denominator
}
} else {
var point [4]float64
for row := range 4 {
point[row] = s.wrap[channel][12+row]
for col, value := range input {
point[row] += s.wrap[channel][col*4+row] * value
}
}
for i := range 3 {
point[i] /= point[3]
if !finite(point[i]) {
return [3]float64{}, fmt.Errorf("invalid U3D texture wrap projection")
}
}
switch layer.Mode {
case 1:
uv[0], uv[1] = point[0], point[1]
case 2, 3:
if point[0] != 0 || point[1] != 0 {
uv[0] = math.Atan2(point[1], point[0]) / (2 * math.Pi)
if uv[0] < 0 {
uv[0]++
}
}
uv[1] = point[2]
if layer.Mode == 3 {
direction := (u3dVector{point[0], point[1], point[2]}).unit()
uv[1] = math.Acos(min(1, max(-1, direction[2]))) / math.Pi
}
}
}
var result [3]float64
for component, row := range [3]int{0, 1, 3} {
for col, value := range uv {
result[component] += float64(layer.Transform[col*4+row]) * value
}
if !finite(result[component]) {
return [3]float64{}, fmt.Errorf("invalid U3D generated texture coordinate")
}
}
return result, nil
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"fmt"
@@ -29,6 +29,9 @@ type U3DMesh struct {
Faces []U3DFace
Lines []U3DLine
Points []U3DPoint
Bones []U3DBone
BoneWeights []U3DBoneWeights
Animations []U3DAnimation
Quality [3]uint32
InverseQuantization [5]float32
NormalCrease, NormalUpdate, NormalTolerance float32
@@ -76,7 +79,7 @@ type u3dMeshDeclaration struct {
// primitive 访问三角面、线段或点的着色编号和角点,不复制属性数组
// 入参: index 图元下标,依次为三角面、线段和点
// 返回: uint32 着色编号, []U3DCorner 只读角点
// 返回: uint32 着色编号, []U3DCorner 原数组中的角点切片
func (m *U3DMesh) primitive(index int) (uint32, []U3DCorner) {
if index < len(m.Faces) {
return m.Faces[index].Shading, m.Faces[index].Corners[:]
@@ -116,16 +119,20 @@ func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error {
decl.mesh.InverseQuantization[index] = r.f32()
}
decl.mesh.NormalCrease, decl.mesh.NormalUpdate, decl.mesh.NormalTolerance = r.f32(), r.f32(), r.f32()
bones := r.u32()
if r.err != nil {
return r.err
}
if minimum > maximum || maximum != decl.counts[1] {
return fmt.Errorf("invalid U3D mesh resolution")
}
if minimum != maximum || bones != 0 {
return fmt.Errorf("unsupported U3D progressive or skeletal mesh")
if minimum != maximum {
return fmt.Errorf("unsupported U3D progressive mesh")
}
bones, err := d.skeleton(r)
if err != nil {
return err
}
decl.mesh.Bones = bones
if minimum == 0 {
for _, count := range decl.counts[:6] {
if count != 0 {
@@ -247,10 +254,16 @@ func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error {
var histogram u3dHistogram
var compressed *u3dBits
if !d.plain && counts[0] != 0 {
if err := d.reserve(1, 1<<20); err != nil {
compressed = newU3DBits(r.data[r.pos:])
var charged uint64
compressed.reserve = func(count, size uint64) error {
if err := d.reserve(count, size); err != nil {
return err
}
compressed = newU3DBits(r.data[r.pos:])
charged += count * size
return nil
}
defer func() { d.remaining += charged }()
}
readIndex := func(count uint32) (uint32, error) {
var value uint32
@@ -321,17 +334,8 @@ func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error {
}
}
if compressed != nil {
if compressed.pos > uint64(len(compressed.data))*8 {
return io.ErrUnexpectedEOF
}
if compressed.u32() != 0 {
return fmt.Errorf("invalid U3D compression flush")
}
if compressed.err != nil {
return compressed.err
}
if (compressed.pos-16+7)/8 != uint64(len(compressed.data)) {
return fmt.Errorf("unexpected U3D compressed block data: decoded %d bits, stored %d bytes", compressed.pos-16, len(compressed.data))
if err := compressed.finish(); err != nil {
return err
}
r.pos = len(r.data)
}
@@ -12,16 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"fmt"
"math"
)
// U3DRenderMode 定义静态几何的呈现方式,边线和顶点使用单像素覆盖
type U3DRenderMode uint8
const (
U3DRenderSolid U3DRenderMode = iota // 实体
U3DRenderSolidWireframe // 实体线框
@@ -38,18 +35,29 @@ const (
U3DRenderShadedIllustration // 着色插图
)
// U3DRenderMode 定义静态几何的呈现方式,边线和顶点使用单像素覆盖
type U3DRenderMode uint8
// u3dPrimitiveKey 区分共享点线的颜色及混合状态,避免透明边点重复叠加
type u3dPrimitiveKey struct {
a, b u3dVertex
a, b [7]float64
blend uint32
function uint32
reference float32
}
// u3dTexturedPrimitiveKey 额外区分纹理坐标及纹理层,不扩大普通边点缓存
type u3dTexturedPrimitiveKey struct {
base u3dPrimitiveKey
coordinates [2][8][3]float64
layers [8]*U3DTextureLayer
opacityOverride bool
}
// uniquePrimitive 在当前网格内去重着色边点,缓存按条目计入绘制预算
// 入参: a 起点或顶点, b 终点或相同顶点, shader 着色器
// 返回: bool 是否首次绘制, error 预算或取消错误
func (r *u3dRender) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, error) {
func (r *Renderer) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, error) {
if r.primitives == nil {
return true, nil
}
@@ -59,18 +67,33 @@ func (r *u3dRender) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, err
if left[i] != right[i] {
if left[i] > right[i] {
a, b = b, a
left, right = right, left
}
break
}
}
key := u3dPrimitiveKey{a: a, b: b, blend: shader.Blend}
key := u3dPrimitiveKey{a: left, b: right, blend: shader.Blend}
if shader.Attributes&2 != 0 {
key.function, key.reference = shader.AlphaFunction, shader.AlphaReference
}
if shader.Textures != ([8]*U3DTextureLayer{}) {
textured := u3dTexturedPrimitiveKey{base: key, coordinates: [2][8][3]float64{a.texture, b.texture}, layers: shader.Textures, opacityOverride: a.opacityOverride}
if _, ok := r.texturedPrimitives[textured]; ok {
return false, nil
}
if err := r.Reserve(1, 1024); err != nil {
return false, err
}
if r.texturedPrimitives == nil {
r.texturedPrimitives = make(map[u3dTexturedPrimitiveKey]struct{})
}
r.texturedPrimitives[textured] = struct{}{}
return true, nil
}
if _, ok := r.primitives[key]; ok {
return false, nil
}
if err := r.reserve(1, 512); err != nil {
if err := r.Reserve(1, 512); err != nil {
return false, err
}
r.primitives[key] = struct{}{}
@@ -92,7 +115,7 @@ func (mode U3DRenderMode) silhouette() bool {
// modeTriangle 根据当前模式绘制原三角面、原始边或顶点,不为裁剪边界生成网格边
// 入参: triangle 相机空间顶点, shader 着色器, pass 绘制轮次
// 返回: error 数值、资源或取消错误
func (r *u3dRender) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error {
func (r *Renderer) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error {
if r.depthOnly || r.mode == U3DRenderSolid || r.mode.silhouette() {
return r.sectionTriangle(triangle, shader, pass)
}
@@ -148,7 +171,7 @@ func (r *u3dRender) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U
// modeLine 对原始网格边应用剖切与视锥裁剪,再按像素深度绘制
// 入参: a 起点, b 终点, normal 面法线, origin 面上点, shader 着色器, pass 绘制轮次
// 返回: error 数值、资源或取消错误
func (r *u3dRender) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
func (r *Renderer) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
for i, section := range r.sections {
if i%256 == 0 {
if err := r.ctx.Err(); err != nil {
@@ -175,7 +198,7 @@ func (r *u3dRender) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3
// modePoint 绘制原始顶点,不在裁剪边界补充顶点
// 入参: vertex 顶点, shader 着色器, pass 绘制轮次
// 返回: error 数值、资源或取消错误
func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPass) error {
func (r *Renderer) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPass) error {
if err := r.ctx.Err(); err != nil {
return err
}
@@ -221,6 +244,13 @@ func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPa
if vertex.point[2] > r.frame[pixel].depth {
return nil
}
textures, err := r.shaderTextures(shader)
if err != nil {
return err
}
if err := r.textureColor(textures, &vertex.color, &vertex.texture, vertex.point, vertex.opacityOverride); err != nil {
return err
}
visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass)
if err != nil || !visible {
return err
@@ -232,7 +262,7 @@ func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPa
// drawBounds 在模型局部空间计算包围盒,再应用实例变换,不以世界坐标轴重建包围盒
// 入参: instance 场景实例, node 模型节点, mesh 网格, pass 绘制轮次, passIndex 轮次索引
// 返回: error 几何、数值、预算或取消错误
func (r *u3dRender) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error {
func (r *Renderer) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error {
minimum := u3dVector{math.Inf(1), math.Inf(1), math.Inf(1)}
maximum := u3dVector{math.Inf(-1), math.Inf(-1), math.Inf(-1)}
visible := false
@@ -251,11 +281,10 @@ func (r *u3dRender) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMes
}
}
if uint64(shading) >= uint64(len(shaders)) {
visible = true
visible = visible || passIndex == 0
} else if !visible {
for _, name := range shaders[shading] {
index, ok := r.shaders[name]
if !ok || r.model.Shaders[index].RenderPass&(1<<passIndex) != 0 {
if r.shaderPass(name)&(1<<passIndex) != 0 {
visible = true
break
}
+188
View File
@@ -0,0 +1,188 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package threed
import (
"fmt"
"math"
)
// U3DMotion 保存骨骼或节点动作轨道,解码不执行动画
type U3DMotion struct {
Name string
TimeInverseQuantization, RotationInverseQuantization float32
Tracks []U3DMotionTrack
}
// U3DMotionTrack 保存命名轨道及其关键帧,保持文件中的时间与帧次序
type U3DMotionTrack struct {
Name string
DisplacementInverseQuantization, ScaleInverseQuantization float32
Frames []U3DKeyFrame
}
// U3DKeyFrame 保存关键帧时间及相对父项的变换,Rotation按W、X、Y、Z排列
type U3DKeyFrame struct {
Time float32
Displacement, Scale [3]float32
Rotation [4]float32
}
// motion 读取ECMA-363第9.8.6节的动作资源,首尾帧保留未量化数值
// 入参: values 字段读取器, name 资源名
// 返回: error 结构、数值、预算或取消错误
func (d *u3dDecoder) motion(values *u3dValues, name string) error {
r := u3dResourceReader{values: values, decoder: d}
defer r.release()
count := r.u32()
motion := U3DMotion{Name: name, TimeInverseQuantization: r.f32(), RotationInverseQuantization: r.f32()}
if err := r.err(); err != nil {
return err
}
if motion.TimeInverseQuantization < 0 || motion.RotationInverseQuantization < 0 {
return fmt.Errorf("invalid U3D motion quantization")
}
if err := d.reserve(uint64(count), 48); err != nil {
return err
}
motion.Tracks = make([]U3DMotionTrack, int(count))
for index := range motion.Tracks {
if err := d.ctx.Err(); err != nil {
return err
}
track := &motion.Tracks[index]
track.Name = r.text()
frames := r.u32()
track.DisplacementInverseQuantization, track.ScaleInverseQuantization = r.f32(), r.f32()
if err := r.err(); err != nil {
return err
}
if track.DisplacementInverseQuantization < 0 || track.ScaleInverseQuantization < 0 {
return fmt.Errorf("invalid U3D motion track quantization")
}
if err := d.reserve(uint64(frames), 44); err != nil {
return err
}
track.Frames = make([]U3DKeyFrame, int(frames))
for i := range track.Frames {
if i%256 == 0 {
if err := d.ctx.Err(); err != nil {
return err
}
}
frame := &track.Frames[i]
if i == 0 || i == len(track.Frames)-1 {
frame.Time = r.f32()
for component := range frame.Displacement {
frame.Displacement[component] = r.f32()
}
for component := range frame.Rotation {
frame.Rotation[component] = r.f32()
}
for component := range frame.Scale {
frame.Scale[component] = r.f32()
}
} else if err := r.motionFrame(frame, track.Frames[i-1], &motion, track); err != nil {
return err
}
if err := r.err(); err != nil {
return err
}
}
}
if err := r.done(); err != nil {
return err
}
d.model.Motions = append(d.model.Motions, motion)
return nil
}
// motionFrame 按前一帧预测时间、位移和缩放,并右乘旋转差分四元数
// 入参: frame 目标帧, previous 前一帧, motion 动作量化参数, track 轨道量化参数
// 返回: error 符号、数值或读取错误
func (r *u3dResourceReader) motionFrame(frame *U3DKeyFrame, previous U3DKeyFrame, motion *U3DMotion, track *U3DMotionTrack) error {
time, err := r.motionDifference([]float32{previous.Time}, motion.TimeInverseQuantization, 0)
if err != nil {
return err
}
frame.Time = time[0]
position, err := r.motionDifference(previous.Displacement[:], track.DisplacementInverseQuantization, 2)
if err != nil {
return err
}
copy(frame.Displacement[:], position[:])
signs := r.value(4, true)
if signs > 15 {
return fmt.Errorf("invalid U3D motion rotation signs")
}
var difference [4]float64
var squared float64
for component := 1; component < 4; component++ {
value := float64(r.value(5, false)) * float64(motion.RotationInverseQuantization)
if signs&(1<<component) != 0 {
value = -value
}
difference[component] = value
squared += value * value
}
if err := r.err(); err != nil {
return err
}
if squared > 1 {
return fmt.Errorf("invalid U3D motion rotation difference")
}
difference[0] = math.Sqrt(1 - squared)
if signs&1 != 0 {
difference[0] = -difference[0]
}
p := previous.Rotation
w, x, y, z := float64(p[0]), float64(p[1]), float64(p[2]), float64(p[3])
a, b, c, e := difference[0], difference[1], difference[2], difference[3]
frame.Rotation = [4]float32{
float32(w*a - x*b - y*c - z*e),
float32(w*b + x*a + y*e - z*c),
float32(w*c - x*e + y*a + z*b),
float32(w*e + x*c - y*b + z*a),
}
for _, value := range frame.Rotation {
if !finite(float64(value)) {
return fmt.Errorf("invalid U3D reconstructed rotation")
}
}
scale, err := r.motionDifference(previous.Scale[:], track.ScaleInverseQuantization, 6)
copy(frame.Scale[:], scale[:])
return err
}
// motionDifference 用共享幅值上下文恢复一至三个差分分量
// 入参: previous 前一帧分量, scale 逆量化系数, ctx 符号上下文,下一项为幅值上下文
// 返回: [3]float32 重建分量, error 符号、数值或读取错误
func (r *u3dResourceReader) motionDifference(previous []float32, scale float32, ctx int) (values [3]float32, err error) {
signs := r.value(ctx, true)
if signs >= 1<<len(previous) {
return values, fmt.Errorf("invalid U3D motion difference signs")
}
for index, prior := range previous {
difference := float64(r.value(ctx+1, false)) * float64(scale)
if signs&(1<<index) != 0 {
difference = -difference
}
values[index] = float32(float64(prior) + difference)
if !finite(float64(values[index])) {
return values, fmt.Errorf("invalid U3D reconstructed motion value")
}
}
return values, r.err()
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
package threed
import (
"fmt"
@@ -39,7 +39,7 @@ type u3dOutlineEdge struct {
// outlineFace 收集当前绘制轮次中的原始网格边,正面法线在同一半圆内取角度极值
// 入参: outline 共享边索引, world 世界坐标, vertices 相机顶点, front 是否正面, visible 是否绘制面, shaders 着色列表, passIndex 轮次
// 返回: error 数值、预算或取消错误
func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertices [3]u3dVertex, front, visible bool, shaders []string, passIndex int) error {
func (r *Renderer) outlineFace(outline *u3dOutline, world [3]u3dVector, vertices [3]u3dVertex, front, visible bool, shaders []string, passIndex int) error {
active := false
for i, name := range shaders {
if i%128 == 0 {
@@ -47,8 +47,7 @@ func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertice
return err
}
}
index, ok := r.shaders[name]
if !ok || r.model.Shaders[index].RenderPass&(1<<passIndex) != 0 {
if r.shaderPass(name)&(1<<passIndex) != 0 {
active = true
break
}
@@ -76,7 +75,7 @@ func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertice
key := [2]u3dVector{a, b}
index, exists := outline.index[key]
if !exists {
if err := r.reserve(1, 1024); err != nil {
if err := r.Reserve(1, 1024); err != nil {
return err
}
index = len(outline.edges)
@@ -117,7 +116,7 @@ func (r *u3dRender) outlineFace(outline *u3dOutline, world [3]u3dVector, vertice
// drawOutline 绘制外缘、正背面交界和达到夹角的共享边,沿用像素深度去除遮挡
// 入参: outline 共享边索引, pass 绘制轮次
// 返回: error 数值、资源或取消错误
func (r *u3dRender) drawOutline(outline *u3dOutline, pass U3DViewPass) error {
func (r *Renderer) drawOutline(outline *u3dOutline, pass U3DViewPass) error {
shader := U3DShader{Blend: 0x606}
color := [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
for i, edge := range outline.edges {
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff. Show more