Compare commits
13
Commits
842c3f1782
...
v0.6.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4df499680 | ||
|
|
effeca7ca8 | ||
|
|
55614fafed | ||
|
|
64445322eb | ||
|
|
01b06abc2c | ||
|
|
f93abf064f | ||
|
|
2c5c1bd317 | ||
|
|
b275c671fc | ||
|
|
9dee7e8605 | ||
|
|
6a6972ae48 | ||
|
|
a094dabf02 | ||
|
|
f0fff7f726 | ||
|
|
28c88faabb |
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.github
|
||||
.local
|
||||
.vscode
|
||||
apps/desktop
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
out
|
||||
output/*
|
||||
!output/font-packs/
|
||||
output/font-packs/*
|
||||
!output/font-packs/root/
|
||||
!output/font-packs/root/**
|
||||
playwright-report
|
||||
test-results
|
||||
tmp
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -13,34 +13,28 @@
|
||||
|
||||
## 2. 会话启动检查
|
||||
|
||||
新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先运行:
|
||||
|
||||
```powershell
|
||||
Get-Location
|
||||
```
|
||||
|
||||
确认目录为:
|
||||
新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先确认仓库根目录。不得假设固定绝对路径或盘符——协作环境可能是 Windows、macOS 或 Linux,克隆位置因机器而异。可用如下只读方式确认(按当前 Shell 语法改写等价命令):
|
||||
|
||||
```text
|
||||
C:\Projects\md-to-pdf
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
|
||||
随后依次执行只读检查:
|
||||
|
||||
```powershell
|
||||
```text
|
||||
git status --short
|
||||
git log --oneline -5
|
||||
Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
||||
读取 docs/PROGRESS.md(显式使用 UTF-8 编码)
|
||||
```
|
||||
|
||||
当前工作区可能包含用户或上一个会话留下的未提交修改。禁止使用 `git reset --hard`、`git checkout --`、`git clean` 等方式丢弃修改,除非用户明确要求。
|
||||
|
||||
## 3. Shell 与文件操作
|
||||
|
||||
- 当前默认 Shell 为 PowerShell,使用 PowerShell 语法。
|
||||
- 不假设固定 Shell 类型(PowerShell、bash、zsh 等均可能);命令语法跟随当前会话实际使用的 Shell,禁止把某一种 Shell 的专属语法当作硬性要求写入产物、脚本或文档。
|
||||
- 不要通过绝对路径 `cd` 前缀拼接命令;工具支持时优先使用 `workdir`。
|
||||
- 子目录操作使用相对路径或直接设置 `workdir`。
|
||||
- PowerShell 读取、写入、追加或替换文本时显式指定 UTF-8 编码。
|
||||
- 读取、写入、追加或替换文本文件时显式指定 UTF-8 编码。
|
||||
- 本地文件修改优先使用补丁工具,避免使用不透明的大段覆盖命令。
|
||||
- 不执行破坏性删除;如确需删除,先确认精确目标并向用户说明。
|
||||
|
||||
@@ -150,19 +144,19 @@ docs/ 进度、架构和部署文档
|
||||
|
||||
安装依赖:
|
||||
|
||||
```powershell
|
||||
```text
|
||||
npm install
|
||||
```
|
||||
|
||||
本地开发:
|
||||
|
||||
```powershell
|
||||
```text
|
||||
npm run dev
|
||||
```
|
||||
|
||||
完整验证:
|
||||
|
||||
```powershell
|
||||
```text
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run build
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# 变更日志
|
||||
|
||||
本文档记录 MorphDoc(原 Markdown PDF 导出器)的重要版本变更。
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
|
||||
[Semantic Versioning](https://semver.org/lang/zh-CN/)。发布时以对应版本章节作为
|
||||
Gitea Release 页面正文的基础;构建产物、校验和及详细验收记录可继续写入
|
||||
`docs/releases/v<version>.md`。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 变更
|
||||
|
||||
- 暂无。
|
||||
|
||||
## [0.6.4] - 2026-08-26
|
||||
|
||||
### 新增
|
||||
|
||||
- 桌面端新增 macOS 打包能力:`electron-builder` 增加独立 `mac` 配置块,产出
|
||||
`dmg`,按 arm64、x64 分别单独构建,不产出 universal 包(内置完整
|
||||
Chromium/Electron 与固定版本 Pandoc,合并双架构会让单包体积翻倍)。
|
||||
- Pandoc 运行时清单新增 `darwin/arm64`、`darwin/x64` 两个官方发行项及独立
|
||||
许可证文件来源(macOS 官方 zip 本身不随附许可证文本);桌面 Pandoc 候选
|
||||
路径与 `prepare-pandoc-runtime.mjs` 均已泛化为按 `--platform`/`--arch`
|
||||
参数选取目标,不再只认 Windows x64。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 `apps/desktop/scripts/prepare-pandoc-runtime.mjs` 解压内置 Pandoc 时
|
||||
未保留 Unix 可执行权限位的问题(`unzipSync` 不保留压缩包内的权限信息),
|
||||
macOS/Linux 上解压出的二进制此前无法直接执行。
|
||||
- 修复根 `package.json` 中 `build:web-runtime`、`dev`、`desktop:dev` 三个
|
||||
脚本的构建顺序错误:`@md-to-pdf/application` 依赖
|
||||
`@md-to-pdf/preview-engine`,但被排在其构建之前,在没有历史 `dist/`
|
||||
残留的全新环境(如全新克隆 + 全新 `npm install`)中会导致类型解析失败。
|
||||
- 修正 `AGENTS.md` 中硬编码的 Windows 绝对路径与强制 PowerShell 语法,改为
|
||||
按当前 Shell 与仓库实际位置自适应。
|
||||
- 修正测试用例 `packages/docx-engine/tests/pandoc-runtime.test.ts` 中硬编码
|
||||
反斜杠路径分隔符的断言,避免该测试仅能在 Windows 宿主上通过。
|
||||
|
||||
### 验证
|
||||
|
||||
- `docx-engine` 包 15 个测试文件、93 项测试通过;类型检查通过。
|
||||
- 本机 macOS(Apple Silicon)实测:`npm run package:mac:arm64` 全链路成功,
|
||||
内置 darwin-arm64 版 Pandoc 3.9.0.2 完成下载、哈希校验与真实 DOCX 冒烟
|
||||
转换;未打包应用(`--dir`)可正常启动,主进程/渲染进程/GPU 进程/网络
|
||||
服务进程均稳定运行,AppleScript 可正常触发退出。实际截图确认窗口、
|
||||
macOS 原生菜单栏、Markdown 编辑器工具栏、中文字体与实时预览(标题、
|
||||
任务列表、表格、行内公式、引用块)均渲染正常。
|
||||
- 补跑跳过真实语料依赖的全部工作区测试与全项目类型检查、生产构建均
|
||||
通过;`test:docx-real-world-corpus` 与 `test:docx-release-gate-suites`
|
||||
依赖 Git 忽略的真实客户文档语料(`tmp/`),本机没有该目录,这两步
|
||||
在当前环境下无法验证,留待接入真实语料的机器上执行。
|
||||
|
||||
### 兼容与部署
|
||||
|
||||
- 本版本仅完成开发基础设施与验证,未构建正式发行文件:没有产出真实签名
|
||||
的 dmg、没有重新构建 Windows 安装包,也没有重新构建 Docker 镜像;正式
|
||||
macOS 发行产物、Windows/Docker 同步验证留待后续门禁阶段完成后一并发布。
|
||||
|
||||
## [0.6.2] - 2026-08-26
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复真实长文档中表格分页后的列宽、偏移和行样式丢失,冻结逻辑表格列轨并将
|
||||
打印几何稳定传递到 Paged.js 分片。
|
||||
- 修复 Markdown 表格中 `<br>` 变体、连续换行、实体和行内代码的 DOCX 翻译,
|
||||
避免换行逃逸到目标单元格段落之外。
|
||||
- 修复全 JSON 围栏缩进、表格末行对齐、代码高亮和行内代码连续性。
|
||||
- 改进 PDF 文本流排序和数值范围归一化,减少窄字符重叠、跨视觉行破折号及重复
|
||||
表头映射造成的视觉比较误报。
|
||||
|
||||
### 验证
|
||||
|
||||
- 合成基线与长庆真实文档两套严格门禁为 `280/280`,M4N 复杂表格文档为
|
||||
`140/140`。
|
||||
- 健康数据长文档残余误报为 `6/115`(`5.22%`),均核查为重复表头自动对齐或
|
||||
取样错配,基础设施错误为 0。
|
||||
- 全项目测试、类型检查、生产构建、正式 Docker、内置字体 NSIS/ZIP 和离线发行
|
||||
归集通过。
|
||||
|
||||
### 发行与兼容性
|
||||
|
||||
- Desktop 安装器、免安装 ZIP 与 Docker 镜像统一为 0.6.2。
|
||||
- Docker 继续内置固定 Chromium、Pandoc 3.9.0.2 和受校验字体包;Desktop
|
||||
继续直接内置 Serif、Sans、Mono 字体,无需安装系统字体。
|
||||
- Windows 安装器尚未进行 Authenticode 签名,仅适用于公司内部发布。
|
||||
|
||||
## [0.6.1] - 2026-08-04
|
||||
|
||||
### 修复
|
||||
|
||||
- 重构通用 CSS 到 WordprocessingML 翻译链,统一字体、字号、行距、段距、缩进、
|
||||
边框、底纹、表格、引用和代码样式。
|
||||
- 修复字体实际嵌入、中文标点字距、精确行距、打印媒体环境和 Markdown 行内代码
|
||||
连续性问题。
|
||||
|
||||
### 验证
|
||||
|
||||
- 建立 14 套主题 × 两个方向 × 五组页边距的 140 场景视觉矩阵,`140/140`
|
||||
全部通过。
|
||||
- 四套独立封面执行 Chromium、Word、WPS 整页严格门禁,正文执行与物理分页无关
|
||||
的语义块门禁。
|
||||
- 源码服务、Docker Web API 和实际安装 Desktop 三条生产 DOCX 导出链通过。
|
||||
|
||||
### 发行与兼容性
|
||||
|
||||
- Serif、Sans、Mono 三套字体直接内置于 Desktop 安装器和免安装 ZIP,不再发布
|
||||
独立字体安装器或字体 ZIP。
|
||||
- Docker 内置固定 Chromium、Pandoc 3.9.0.2 和三套正式字体包。
|
||||
|
||||
## [0.6.0] - 2026-08-02
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增原生可编辑 DOCX 导出,支持 Microsoft Word 与 WPS,并保留纸张、页边距、
|
||||
字体、段落、标题、代码块、表格、媒体、封面、页眉页脚和页码配置。
|
||||
- 建立通用主题到 DOCX 样式翻译引擎,覆盖 14 套内置主题。
|
||||
- 新增可扩展 Markdown 工具栏,支持标题、强调、代码、引用、列表、任务列表、
|
||||
分隔线及 M×N 表格。
|
||||
- PDF 与 DOCX 统一收口至“导出”菜单,并提供生成进度提示。
|
||||
|
||||
### 变更
|
||||
|
||||
- 产品更名为“墨呈”,英文名 MorphDoc,并保留原 NSIS 安装身份以支持覆盖升级。
|
||||
- Desktop 内置固定 Pandoc 3.9.0.2;Docker 内置 Chromium、Pandoc 和正式字体,
|
||||
支持完全离线部署。
|
||||
- 正式字体资产改由 Git LFS 管理,发行产物统一归集到 `release/v<version>/`。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复精确预览放大后的右侧留白、保存与导出默认路径、Desktop DOCX 媒体捕获
|
||||
超时及品牌迁移后的安装目录问题。
|
||||
|
||||
## [0.5.1] - 2026-07-29
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 4 套红头、3 套正式文档和 3 套标书主题,内置主题总数增至 14 套。
|
||||
- 支持主题推荐页边距、页面装饰、页眉页脚、页码和结构化公文、报告及标书
|
||||
Front Matter。
|
||||
- 内置 Fandol 中文字体、两份教程和 14 份主题示例。
|
||||
- Desktop 增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复红头标题居中、正式文档字体、代码块长内容越界和大型代码块跨页留白。
|
||||
- Docker Web 镜像补齐 `samples` 目录。
|
||||
|
||||
### 兼容性
|
||||
|
||||
- 未声明主题推荐设置的主题继续使用原有 16mm 默认页边距。
|
||||
|
||||
## [0.5.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与
|
||||
Electron PDF 渲染链路。
|
||||
- 新增连续预览和修改位置之后的增量分页,支持稳定前缀复用。
|
||||
- Desktop 支持多窗口、同文件单例、本地路径与 Markdown 链接,以及聚焦时检测
|
||||
外部文件变化。
|
||||
- Web 统一处理文档锚点和 HTTP/HTTPS 外链。
|
||||
|
||||
### 变更
|
||||
|
||||
- 内置主题统一为 Typora Github、Typora Pixyll、Typora whitey 和 Typora Clean。
|
||||
- Windows 安装包统一使用 NSIS,并提供免安装 ZIP。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块样式和发行包
|
||||
复用陈旧 Web 构建产物的问题。
|
||||
|
||||
## [0.4.5] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- Web 与 Desktop 支持新建和保存 Markdown;Desktop 增加 `.md`、`.markdown`
|
||||
文件关联、单实例系统打开、自定义主题目录和窗口状态恢复。
|
||||
- 新文档可按一级标题或首行生成合法文件名,并直接导出 PDF。
|
||||
|
||||
### 变更
|
||||
|
||||
- 图片、Mermaid 和 ECharts 改为按文档顺序串行执行媒体分页回填,每个媒体元素
|
||||
至多重排一次,并保持标题原尺寸。
|
||||
- Web 取消本地素材目录入口。
|
||||
|
||||
## [0.4.4] - 2026-07-27
|
||||
|
||||
### 变更
|
||||
|
||||
- Desktop 发行包仅保留简体中文和英文语言资源,显著缩减目录版、安装包和 ZIP
|
||||
体积,同时保留 Chromium 图形与软件渲染后备组件。
|
||||
|
||||
## [0.4.3] - 2026-07-27
|
||||
|
||||
### 变更
|
||||
|
||||
- Windows Desktop 从 Squirrel 迁移到标准 NSIS 安装向导,支持安装范围和安装目录
|
||||
选择。
|
||||
- 安装包改用英文、无空格、包含版本与平台的文件名,并继续提供免安装 ZIP。
|
||||
|
||||
## [0.4.2] - 2026-07-27
|
||||
|
||||
### 变更
|
||||
|
||||
- 移除 Windows Desktop 原生应用菜单栏,使应用内容直接位于系统标题栏下方。
|
||||
- 此版本仅更新 Desktop,Web 与 Docker Compose 继续保持 0.4.1。
|
||||
|
||||
## [0.4.1] - 2026-07-27
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持 Web 素材目录、Desktop 同目录资源和受限公网图片下载。
|
||||
- 支持图片标题、图片整块分页以及超高图片在单页内容区内自适应缩放。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复本地与网络图片的资源解析、自然尺寸解码和 PDF 分页边界问题。
|
||||
|
||||
## [0.4.0] - 2026-07-27
|
||||
|
||||
### 新增
|
||||
|
||||
- 首次发布 Electron Windows Desktop,复用与 Web 相同的渲染和 PDF 核心。
|
||||
- 支持桌面 Markdown 文件选择、原生保存和离线 PDF 导出。
|
||||
|
||||
### 修复
|
||||
|
||||
- 固定桌面端英文可执行文件名,避免中文名称影响安装和自动化链路。
|
||||
|
||||
## [0.3.1] - 2026-07-27
|
||||
|
||||
### 新增
|
||||
|
||||
- 扩展 ECharts 第二阶段图表协议和示例覆盖,完善更多系列、坐标轴及组合图表。
|
||||
|
||||
## [0.3.0] - 2026-07-27
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增 ECharts YAML 围栏、安全校验、浏览器 SVG 渲染和 PDF 输出。
|
||||
- 增强编辑区折叠、页码导航和快速预览外层滚动交互。
|
||||
|
||||
### 修复
|
||||
|
||||
- ECharts 媒体块参与统一不可跨页处理,避免图表被分页裁断。
|
||||
|
||||
## [0.2.0] - 2026-07-27
|
||||
|
||||
### 新增
|
||||
|
||||
- 完善 Mermaid 配置、安全渲染、资源等待和预览缩放。
|
||||
- 增强长文档分页、滚动同步和预览/PDF 边界处理。
|
||||
|
||||
### 变更
|
||||
|
||||
- 收敛 Web 预览和 Chromium PDF 的共享渲染抽象,减少链路分叉。
|
||||
|
||||
## [0.1.0] - 2026-07-26
|
||||
|
||||
### 新增
|
||||
|
||||
- 实现 Markdown 安全渲染核心、网页实时预览、本地主题兼容和导出设置。
|
||||
- 支持真实分页预览、固定 Chromium PDF 导出和 PDF 精确预览。
|
||||
- 支持长文档分页、页码导航、滚动同步以及 AIO Docker 容器部署。
|
||||
|
||||
[Unreleased]: https://gitea.rk-health.com/yixiong/MorphDoc/compare/v0.6.4...main
|
||||
[0.6.4]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.4
|
||||
[0.6.2]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.2
|
||||
[0.6.1]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.1
|
||||
[0.6.0]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.0
|
||||
[0.5.1]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.5.1
|
||||
[0.5.0]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.5.0
|
||||
[0.4.5]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.5
|
||||
[0.4.4]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.4.4
|
||||
[0.4.3]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.4.3
|
||||
[0.4.2]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.2
|
||||
[0.4.1]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.1
|
||||
[0.4.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.0
|
||||
[0.3.1]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.3.1
|
||||
[0.3.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.3.0
|
||||
[0.2.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.2.0
|
||||
[0.1.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.1.0
|
||||
@@ -57,11 +57,15 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop
|
||||
```
|
||||
|
||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||
`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以
|
||||
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
`apps/desktop/out/v0.6.4/`。该目录被 Git 忽略。公司内部分发以
|
||||
`MorphDoc-0.6.4-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
Serif、Sans、Mono 三套正式字体随 NSIS 安装包和便携 ZIP 直接进入
|
||||
`resources/font-packs`,由应用私有加载,不写入 Windows 系统字体目录。
|
||||
安装向导不再提供字体选择页,正式发行也不附带独立字体安装器或字体 ZIP。
|
||||
|
||||
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
|
||||
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
|
||||
发布,不提交进 Git 历史。
|
||||
|
||||
@@ -9,70 +9,6 @@
|
||||
; 独立键可让 v0.6.0 默认进入 MorphDoc 目录,同时仍由相同卸载身份识别旧版。
|
||||
!define INSTALL_REGISTRY_KEY "Software\${APP_GUID}.MorphDoc"
|
||||
|
||||
!ifdef FONT_PACK_COMPANION_FILE
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
!include "FileFunc.nsh"
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
Var fontPackCompanionAvailable
|
||||
Var fontPackCompanionCheckbox
|
||||
Var fontPackCompanionExitCode
|
||||
Var fontPackCompanionHash
|
||||
Var fontPackCompanionPath
|
||||
Var fontPackCompanionSelected
|
||||
|
||||
!macro customInit
|
||||
StrCpy $fontPackCompanionAvailable "0"
|
||||
StrCpy $fontPackCompanionSelected "0"
|
||||
StrCpy $fontPackCompanionPath "$EXEDIR\${FONT_PACK_COMPANION_FILE}"
|
||||
IfFileExists "$fontPackCompanionPath" 0 font_pack_init_done
|
||||
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
|
||||
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_init_done
|
||||
StrCpy $fontPackCompanionAvailable "1"
|
||||
IfSilent font_pack_silent font_pack_interactive
|
||||
font_pack_interactive:
|
||||
StrCpy $fontPackCompanionSelected "1"
|
||||
Goto font_pack_init_done
|
||||
font_pack_silent:
|
||||
${GetParameters} $R0
|
||||
${GetOptions} $R0 "/WITHFONTPACK=" $R1
|
||||
StrCmp $R1 "1" 0 font_pack_init_done
|
||||
StrCpy $fontPackCompanionSelected "1"
|
||||
font_pack_init_done:
|
||||
!macroend
|
||||
|
||||
Function FontPackCompanionPage
|
||||
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_page_skip
|
||||
nsDialogs::Create 1018
|
||||
Pop $R0
|
||||
StrCmp $R0 "error" 0 +2
|
||||
Abort
|
||||
${NSD_CreateLabel} 0 0 100% 28u "检测到经过完整性校验的墨呈可选字体包。安装后,政企公文主题可使用与 PDF 相同的原生中文字体资产。"
|
||||
Pop $R0
|
||||
${NSD_CreateCheckbox} 0 38u 100% 14u "安装 ${FONT_PACK_COMPANION_NAME}"
|
||||
Pop $fontPackCompanionCheckbox
|
||||
${NSD_Check} $fontPackCompanionCheckbox
|
||||
nsDialogs::Show
|
||||
Return
|
||||
font_pack_page_skip:
|
||||
Abort
|
||||
FunctionEnd
|
||||
|
||||
Function FontPackCompanionPageLeave
|
||||
${NSD_GetState} $fontPackCompanionCheckbox $R0
|
||||
StrCmp $R0 ${BST_CHECKED} 0 font_pack_page_unchecked
|
||||
StrCpy $fontPackCompanionSelected "1"
|
||||
Return
|
||||
font_pack_page_unchecked:
|
||||
StrCpy $fontPackCompanionSelected "0"
|
||||
FunctionEnd
|
||||
|
||||
!macro customPageAfterChangeDir
|
||||
Page custom FontPackCompanionPage FontPackCompanionPageLeave
|
||||
!macroend
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro customInstall
|
||||
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
|
||||
WriteRegStr SHCTX \
|
||||
@@ -124,24 +60,6 @@
|
||||
"" \
|
||||
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||
!ifdef FONT_PACK_COMPANION_FILE
|
||||
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done
|
||||
StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done
|
||||
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
|
||||
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed
|
||||
DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}"
|
||||
ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode
|
||||
StrCmp $fontPackCompanionExitCode "0" font_pack_install_done
|
||||
DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode"
|
||||
IfSilent font_pack_install_done 0
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。"
|
||||
Goto font_pack_install_done
|
||||
font_pack_integrity_failed:
|
||||
DetailPrint "可选字体包完整性复核失败,已跳过安装。"
|
||||
IfSilent font_pack_install_done 0
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。"
|
||||
font_pack_install_done:
|
||||
!endif
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||
!macroend
|
||||
|
||||
|
||||
@@ -9,9 +9,25 @@ const windowsIcon = path.resolve(
|
||||
__dirname,
|
||||
"../../logos/desktop/windows/app.ico"
|
||||
);
|
||||
const macIcon = path.resolve(
|
||||
__dirname,
|
||||
"../../logos/desktop/macos/app.icns"
|
||||
);
|
||||
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
|
||||
"build/installer.nsh";
|
||||
|
||||
// macOS 每次只构建一个架构的 dmg(arm64 或 x64),不产出 universal 包——
|
||||
// 项目内置完整 Chromium/Electron 运行时和固定版本 Pandoc,合并双架构会让单个
|
||||
// 安装包体积翻倍。目标架构通过环境变量显式声明,用于挑选对应的内置 Pandoc
|
||||
// 资源目录;不依赖 electron-builder 的 `${arch}` 路径宏(该宏在 extraResources
|
||||
// 场景下有已知的可靠性问题)。
|
||||
const macTargetArch = process.env.MD_TO_PDF_MAC_TARGET_ARCH?.trim() || "";
|
||||
if (macTargetArch && macTargetArch !== "arm64" && macTargetArch !== "x64") {
|
||||
throw new Error(
|
||||
`未知 macOS 目标架构:${macTargetArch}(仅支持 arm64 或 x64)`
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appId: brand.appId,
|
||||
productName: brand.englishName,
|
||||
@@ -42,21 +58,16 @@ module.exports = {
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||
to: `pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||
filter: [
|
||||
"pandoc.exe",
|
||||
"COPYING.rtf",
|
||||
"COPYRIGHT.txt",
|
||||
"runtime-manifest.json"
|
||||
]
|
||||
from: "../../output/font-packs/root",
|
||||
to: "font-packs",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: windowsIcon,
|
||||
to: "app.ico"
|
||||
},
|
||||
{
|
||||
from: "../../logos/desktop/macos/app.icns",
|
||||
from: macIcon,
|
||||
to: "app.icns"
|
||||
}
|
||||
],
|
||||
@@ -73,7 +84,19 @@ module.exports = {
|
||||
],
|
||||
icon: windowsIcon,
|
||||
executableName: brand.executableName,
|
||||
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`
|
||||
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`,
|
||||
extraResources: [
|
||||
{
|
||||
from: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||
to: `pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||
filter: [
|
||||
"pandoc.exe",
|
||||
"COPYING.rtf",
|
||||
"COPYRIGHT.txt",
|
||||
"runtime-manifest.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
@@ -89,5 +112,36 @@ module.exports = {
|
||||
installerLanguages: ["zh_CN"],
|
||||
include: nsisInclude,
|
||||
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
|
||||
},
|
||||
mac: {
|
||||
category: "public.app-category.productivity",
|
||||
icon: macIcon,
|
||||
executableName: brand.executableName,
|
||||
// 项目当前没有 Apple Developer 证书,显式声明不签名,与 Windows 侧
|
||||
// 一贯记录在案的"未签名"状态保持一致;后续如需公证再补齐。
|
||||
identity: null,
|
||||
target: [
|
||||
{
|
||||
target: "dmg"
|
||||
}
|
||||
],
|
||||
extraResources: macTargetArch
|
||||
? [
|
||||
{
|
||||
from: `.runtime/pandoc/${pandocRuntime.version}/darwin-${macTargetArch}`,
|
||||
to: `pandoc/${pandocRuntime.version}/darwin-${macTargetArch}`,
|
||||
filter: [
|
||||
"pandoc",
|
||||
"COPYING.md",
|
||||
"COPYRIGHT",
|
||||
"runtime-manifest.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
: []
|
||||
},
|
||||
dmg: {
|
||||
title: brand.displayName,
|
||||
artifactName: `${brand.artifactPrefix}-${version}-\${arch}.\${ext}`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.4",
|
||||
"private": true,
|
||||
"productName": "MorphDoc",
|
||||
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||
@@ -11,13 +11,20 @@
|
||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
|
||||
"prepare:pandoc:mac-arm64": "node scripts/prepare-pandoc-runtime.mjs --platform=darwin --arch=arm64",
|
||||
"prepare:pandoc:mac-x64": "node scripts/prepare-pandoc-runtime.mjs --platform=darwin --arch=x64",
|
||||
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
|
||||
"build:main": "node scripts/build-main.mjs",
|
||||
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||
"package": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||
"package:mac:arm64": "npm run build:embedded-web && npm run prepare:pandoc:mac-arm64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=arm64 electron-builder --dir --config electron-builder.config.cjs --mac --arm64",
|
||||
"package:mac:x64": "npm run build:embedded-web && npm run prepare:pandoc:mac-x64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=x64 electron-builder --dir --config electron-builder.config.cjs --mac --x64",
|
||||
"make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||
"make:mac:arm64": "npm run build:embedded-web && npm run prepare:pandoc:mac-arm64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=arm64 electron-builder --mac dmg --config electron-builder.config.cjs --arm64",
|
||||
"make:mac:x64": "npm run build:embedded-web && npm run prepare:pandoc:mac-x64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=x64 electron-builder --mac dmg --config electron-builder.config.cjs --x64",
|
||||
"make:mac": "npm run make:mac:arm64 && npm run make:mac:x64",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
@@ -43,14 +44,38 @@ async function fileExists(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeManifest() {
|
||||
function platformDirectoryName(platform, architecture) {
|
||||
if (platform === "win32" && architecture === "x64") {
|
||||
return "windows-x86_64";
|
||||
}
|
||||
if (platform === "darwin" && (architecture === "arm64" || architecture === "x64")) {
|
||||
return `darwin-${architecture}`;
|
||||
}
|
||||
throw new Error(`不支持的 Pandoc 桌面内置目标:${platform}/${architecture}`);
|
||||
}
|
||||
|
||||
/** 归档下载得到的、需要从压缩包内部提取的文件名(不含通过 licenseSource 单独下载的许可证文件)。 */
|
||||
function archiveRequiredNames(artifact) {
|
||||
const licenseSource = artifact.licenseSource ?? {};
|
||||
return [
|
||||
normalizedBaseName(artifact.executableRelativePath),
|
||||
...artifact.licenseFiles
|
||||
.filter((fileName) => !licenseSource[fileName])
|
||||
.map(normalizedBaseName)
|
||||
];
|
||||
}
|
||||
|
||||
async function readRuntimeManifest({ platform, architecture }) {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const artifact = manifest.artifacts?.find(
|
||||
(candidate) =>
|
||||
candidate.platform === "win32" && candidate.architecture === "x64"
|
||||
candidate.platform === platform && candidate.architecture === architecture
|
||||
);
|
||||
if (!artifact || artifact.archiveType !== "zip") {
|
||||
throw new Error("Pandoc 清单缺少 Windows x64 ZIP 发行项");
|
||||
if (!artifact) {
|
||||
throw new Error(`Pandoc 清单缺少 ${platform}/${architecture} 发行项`);
|
||||
}
|
||||
if (artifact.archiveType !== "zip") {
|
||||
throw new Error(`Pandoc 清单要求 ${platform}/${architecture} 为 ZIP 发行项`);
|
||||
}
|
||||
const requiredNames = [
|
||||
normalizedBaseName(artifact.executableRelativePath),
|
||||
@@ -92,6 +117,33 @@ async function downloadArchive(downloadUrl, destination) {
|
||||
return content;
|
||||
}
|
||||
|
||||
async function downloadLicenseSourceFile(downloadUrl) {
|
||||
const response = await fetch(downloadUrl, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(downloadTimeoutMs)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Pandoc 许可证文件下载失败:HTTP ${response.status}`);
|
||||
}
|
||||
const content = new Uint8Array(await response.arrayBuffer());
|
||||
if (content.byteLength > maximumExtractedBytes) {
|
||||
throw new Error("Pandoc 许可证文件超过允许的下载大小");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部分平台(如 macOS)的官方归档不随附许可证文件,需要按清单声明的
|
||||
* 独立来源单独下载;返回的文件与归档内提取的文件合并后一并校验哈希。
|
||||
*/
|
||||
async function loadLicenseSourceFiles(artifact) {
|
||||
const files = new Map();
|
||||
for (const [fileName, source] of Object.entries(artifact.licenseSource ?? {})) {
|
||||
files.set(fileName, await downloadLicenseSourceFile(source.downloadUrl));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function loadVerifiedArchive(artifact, archivePath) {
|
||||
if (await fileExists(archivePath)) {
|
||||
const cached = new Uint8Array(await readFile(archivePath));
|
||||
@@ -122,10 +174,7 @@ async function loadVerifiedArchive(artifact, archivePath) {
|
||||
}
|
||||
|
||||
function extractRequiredFiles(archive, artifact) {
|
||||
const requiredNames = new Set([
|
||||
normalizedBaseName(artifact.executableRelativePath),
|
||||
...artifact.licenseFiles.map(normalizedBaseName)
|
||||
]);
|
||||
const requiredNames = new Set(archiveRequiredNames(artifact));
|
||||
let selectedBytes = 0;
|
||||
const entries = unzipSync(archive, {
|
||||
filter(file) {
|
||||
@@ -253,9 +302,13 @@ async function verifyPreparedRuntime(targetDirectory, manifest, artifact) {
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePandocRuntime({ checkOnly = false } = {}) {
|
||||
const { manifest, artifact } = await readRuntimeManifest();
|
||||
const platformDirectory = "windows-x86_64";
|
||||
async function preparePandocRuntime({
|
||||
checkOnly = false,
|
||||
platform = process.platform,
|
||||
architecture = process.arch
|
||||
} = {}) {
|
||||
const { manifest, artifact } = await readRuntimeManifest({ platform, architecture });
|
||||
const platformDirectory = platformDirectoryName(platform, architecture);
|
||||
const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc");
|
||||
const targetDirectory = path.join(
|
||||
runtimeRoot,
|
||||
@@ -278,7 +331,10 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
||||
}
|
||||
const archivePath = path.join(downloadDirectory, archiveName);
|
||||
const archive = await loadVerifiedArchive(artifact, archivePath);
|
||||
const files = extractRequiredFiles(archive, artifact);
|
||||
const files = new Map([
|
||||
...extractRequiredFiles(archive, artifact),
|
||||
...await loadLicenseSourceFiles(artifact)
|
||||
]);
|
||||
const temporaryDirectory = path.join(
|
||||
runtimeRoot,
|
||||
manifest.version,
|
||||
@@ -308,6 +364,10 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
||||
temporaryDirectory,
|
||||
normalizedBaseName(artifact.executableRelativePath)
|
||||
);
|
||||
if (platform !== "win32") {
|
||||
// unzipSync 不保留压缩包内的可执行权限位,POSIX 平台需要手动补上。
|
||||
await chmod(executablePath, 0o755);
|
||||
}
|
||||
verifyPandocVersion(executablePath, manifest.version);
|
||||
await smokeTestPandoc(executablePath);
|
||||
await writeFile(
|
||||
@@ -315,8 +375,8 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: manifest.version,
|
||||
platform: "win32",
|
||||
architecture: "x64",
|
||||
platform,
|
||||
architecture,
|
||||
license: manifest.license,
|
||||
projectUrl: manifest.projectUrl,
|
||||
sourceArchiveUrl: manifest.sourceArchiveUrl,
|
||||
@@ -338,17 +398,25 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
||||
return targetDirectory;
|
||||
}
|
||||
|
||||
function readCliFlag(name) {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((argument) => argument.startsWith(prefix));
|
||||
return match ? match.slice(prefix.length) : undefined;
|
||||
}
|
||||
|
||||
const isDirectInvocation =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectInvocation) {
|
||||
preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).catch(
|
||||
(error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
preparePandocRuntime({
|
||||
checkOnly: process.argv.includes("--check"),
|
||||
platform: readCliFlag("platform") ?? process.platform,
|
||||
architecture: readCliFlag("arch") ?? process.arch
|
||||
}).catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -72,13 +72,6 @@ export async function captureDocxMediaWithElectronWebContents(
|
||||
signal?: AbortSignal,
|
||||
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const plan = (await withTimeout(
|
||||
webContents.executeJavaScript(createRenderScript(request), true),
|
||||
timeouts.renderMs,
|
||||
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||
signal
|
||||
)) as DocxMediaCapturePlan;
|
||||
signal?.throwIfAborted();
|
||||
const ownsDebugger = !webContents.debugger.isAttached();
|
||||
if (ownsDebugger) {
|
||||
@@ -86,6 +79,16 @@ export async function captureDocxMediaWithElectronWebContents(
|
||||
}
|
||||
|
||||
try {
|
||||
await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", {
|
||||
media: "print"
|
||||
});
|
||||
const plan = (await withTimeout(
|
||||
webContents.executeJavaScript(createRenderScript(request), true),
|
||||
timeouts.renderMs,
|
||||
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||
signal
|
||||
)) as DocxMediaCapturePlan;
|
||||
signal?.throwIfAborted();
|
||||
const captures = [];
|
||||
for (const target of plan.targets) {
|
||||
signal?.throwIfAborted();
|
||||
@@ -117,8 +120,13 @@ export async function captureDocxMediaWithElectronWebContents(
|
||||
signal?.throwIfAborted();
|
||||
return { plan, captures };
|
||||
} finally {
|
||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||
webContents.debugger.detach();
|
||||
if (webContents.debugger.isAttached()) {
|
||||
await webContents.debugger
|
||||
.sendCommand("Emulation.setEmulatedMedia", { media: "screen" })
|
||||
.catch(() => undefined);
|
||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||
webContents.debugger.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ function getDesktopThemeDirectory() {
|
||||
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||
}
|
||||
|
||||
function getDesktopFontPackDirectory() {
|
||||
function getDesktopUserFontPackDirectory() {
|
||||
if (!app.isPackaged) {
|
||||
return path.resolve(
|
||||
currentDirectory,
|
||||
@@ -147,6 +147,15 @@ function getDesktopFontPackDirectory() {
|
||||
|
||||
function createDesktopApplicationService() {
|
||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||
const fontPackRoots = app.isPackaged
|
||||
? [
|
||||
path.join(process.resourcesPath, "font-packs"),
|
||||
getDesktopUserFontPackDirectory()
|
||||
]
|
||||
: [
|
||||
path.join(projectRoot, "output", "font-packs", "root"),
|
||||
getDesktopUserFontPackDirectory()
|
||||
];
|
||||
return createApplicationService({
|
||||
bundledRoot: app.isPackaged
|
||||
? path.join(process.resourcesPath, "themes")
|
||||
@@ -155,7 +164,7 @@ function createDesktopApplicationService() {
|
||||
createAssetUrl: (themeId, assetPath) =>
|
||||
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
||||
fontPacks: {
|
||||
roots: [getDesktopFontPackDirectory()],
|
||||
roots: fontPackRoots,
|
||||
appVersion: app.getVersion(),
|
||||
createAssetUrl: (
|
||||
packId,
|
||||
|
||||
@@ -95,6 +95,14 @@ describe("Electron DOCX 媒体捕获", () => {
|
||||
png: Buffer.from("png")
|
||||
});
|
||||
expect(attach).toHaveBeenCalledWith("1.3");
|
||||
expect(sendCommand).toHaveBeenCalledWith(
|
||||
"Emulation.setEmulatedMedia",
|
||||
{ media: "print" }
|
||||
);
|
||||
expect(sendCommand).toHaveBeenCalledWith(
|
||||
"Emulation.setEmulatedMedia",
|
||||
{ media: "screen" }
|
||||
);
|
||||
expect(sendCommand).toHaveBeenCalledWith(
|
||||
"Page.captureScreenshot",
|
||||
expect.objectContaining({
|
||||
@@ -106,10 +114,18 @@ describe("Electron DOCX 媒体捕获", () => {
|
||||
});
|
||||
|
||||
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||
let attached = false;
|
||||
const webContents = {
|
||||
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => false)
|
||||
isAttached: vi.fn(() => attached),
|
||||
attach: vi.fn(() => {
|
||||
attached = true;
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
attached = false;
|
||||
}),
|
||||
sendCommand: vi.fn(async () => undefined)
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
@@ -136,7 +152,11 @@ describe("Electron DOCX 媒体捕获", () => {
|
||||
attached = true;
|
||||
}),
|
||||
detach,
|
||||
sendCommand: vi.fn(() => new Promise(() => undefined))
|
||||
sendCommand: vi.fn((method: string) =>
|
||||
method === "Page.captureScreenshot"
|
||||
? new Promise(() => undefined)
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -18,7 +18,11 @@ describe("桌面 Markdown 文件参数", () => {
|
||||
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("从启动参数中提取绝对 Markdown 路径", () => {
|
||||
// findMarkdownFileArgument 依赖 Node 的 path 模块识别绝对路径,其行为
|
||||
// 随宿主 OS 而定:Windows 风格盘符路径只有在 Windows 宿主上才会被
|
||||
// 识别为绝对路径,这与真实部署一致(Windows 安装包只会在 Windows 上
|
||||
// 收到 Windows 风格的启动参数),因此该用例仅在 Windows 宿主上有意义。
|
||||
it.runIf(process.platform === "win32")("从启动参数中提取绝对 Markdown 路径", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
[
|
||||
@@ -83,15 +87,19 @@ describe("桌面 Markdown 文件参数", () => {
|
||||
const filePath = path.join(directory, "快照.md");
|
||||
try {
|
||||
await writeFile(filePath, "# 第一版", "utf8");
|
||||
// readMarkdownFileSnapshot 内部会 realpath 解析符号链接(例如 macOS
|
||||
// 的 /var -> /private/var),返回的是规范化路径,因此断言也需要对
|
||||
// 同一路径做 realpath,而不是直接比较 mkdtemp 拼接出的原始路径。
|
||||
const canonicalFilePath = await realpath(filePath);
|
||||
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
||||
|
||||
expect(snapshot.document).toEqual({
|
||||
markdown: "# 第一版",
|
||||
fileName: "快照.md"
|
||||
});
|
||||
expect(snapshot.filePath).toBe(filePath);
|
||||
expect(snapshot.filePath).toBe(canonicalFilePath);
|
||||
expect(snapshot.documentKey).toBe(
|
||||
createMarkdownDocumentKey(filePath)
|
||||
createMarkdownDocumentKey(canonicalFilePath)
|
||||
);
|
||||
expect(snapshot.contentHash).toBe(
|
||||
createMarkdownContentHash("# 第一版")
|
||||
|
||||
@@ -44,8 +44,8 @@ describe("桌面发行构建链", () => {
|
||||
"@md-to-pdf/markdown-echarts",
|
||||
"@md-to-pdf/core",
|
||||
"@md-to-pdf/renderer",
|
||||
"@md-to-pdf/application",
|
||||
"@md-to-pdf/preview-engine",
|
||||
"@md-to-pdf/application",
|
||||
"@md-to-pdf/web"
|
||||
];
|
||||
|
||||
@@ -72,8 +72,12 @@ describe("桌面发行构建链", () => {
|
||||
"npm run prepare:pandoc"
|
||||
);
|
||||
expect(rootManifest.scripts?.["desktop:package"]).toBe(
|
||||
"npm run package -w @md-to-pdf/desktop"
|
||||
"npm run build:font-pack && npm run package -w @md-to-pdf/desktop"
|
||||
);
|
||||
expect(rootManifest.scripts?.["desktop:make"]).toBe(
|
||||
"npm run build:font-pack && node scripts/build-desktop-release.mjs"
|
||||
);
|
||||
expect(rootManifest.scripts?.["desktop:make:with-font-pack"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("将固定 Pandoc 最小运行时作为 Desktop 独立资源打包", () => {
|
||||
@@ -156,7 +160,7 @@ describe("桌面发行构建链", () => {
|
||||
artifactPrefix: "MorphDoc"
|
||||
});
|
||||
expect(desktopManifest).toMatchObject({
|
||||
version: "0.6.0",
|
||||
version: "0.6.4",
|
||||
productName: "MorphDoc"
|
||||
});
|
||||
expect(builderConfig).toContain("appId: brand.appId");
|
||||
@@ -180,32 +184,35 @@ describe("桌面发行构建链", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("仅在组合构建时接入经过哈希校验的同目录字体包", () => {
|
||||
it("在安装版与便携版中强制内置完整字体包集合", () => {
|
||||
const builderConfig = readFileSync(
|
||||
`${desktopRoot}/electron-builder.config.cjs`,
|
||||
"utf8"
|
||||
);
|
||||
const desktopMain = readFileSync(
|
||||
`${desktopRoot}/src/main.ts`,
|
||||
"utf8"
|
||||
);
|
||||
const bundledBuild = readFileSync(
|
||||
`${projectRoot}/scripts/build-desktop-release.mjs`,
|
||||
"utf8"
|
||||
);
|
||||
const installerInclude = readFileSync(
|
||||
`${desktopRoot}/build/installer.nsh`,
|
||||
"utf8"
|
||||
);
|
||||
const companionBuild = readFileSync(
|
||||
`${projectRoot}/scripts/build-desktop-with-font-pack.mjs`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(builderConfig).toContain("MD_TO_PDF_NSIS_INCLUDE");
|
||||
expect(installerInclude).toContain("FONT_PACK_COMPANION_SHA256");
|
||||
expect(installerInclude).toContain("StdUtils.HashFile");
|
||||
expect(installerInclude).toContain("/WITHFONTPACK=");
|
||||
expect(companionBuild).toContain("createDesktopCompanionInclude");
|
||||
expect(companionBuild).toContain("MD_TO_PDF_NSIS_INCLUDE");
|
||||
expect(companionBuild).toContain(
|
||||
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
|
||||
);
|
||||
expect(companionBuild).toContain(
|
||||
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
|
||||
expect(builderConfig).toContain('../../output/font-packs/root');
|
||||
expect(builderConfig).toContain('to: "font-packs"');
|
||||
expect(desktopMain).toContain(
|
||||
'path.join(process.resourcesPath, "font-packs")'
|
||||
);
|
||||
expect(desktopMain).toContain("getDesktopUserFontPackDirectory()");
|
||||
expect(bundledBuild).not.toContain("createDesktopCompanionInclude");
|
||||
expect(bundledBuild).toContain("已生成字体直接内置");
|
||||
expect(installerInclude).not.toContain("FONT_PACK_COMPANION");
|
||||
expect(installerInclude).not.toContain("FontPackCompanionPage");
|
||||
expect(installerInclude).not.toContain("/WITHFONTPACK=");
|
||||
});
|
||||
|
||||
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
|
||||
import { unzipSync } from "fflate";
|
||||
import matter from "gray-matter";
|
||||
import { buildApp } from "../dist/app.js";
|
||||
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
|
||||
import { createPdfGenerator } from "../dist/pdf-engine.js";
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryDirectory = path.resolve(directory, "../../..");
|
||||
const appVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8")
|
||||
).version;
|
||||
const themesDirectory = path.join(repositoryDirectory, "themes");
|
||||
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
|
||||
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
|
||||
@@ -32,6 +36,146 @@ const fontPackRoot = path.resolve(
|
||||
);
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function writeArtifactWithBusyFallback(filePath, content) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EBUSY") {
|
||||
throw error;
|
||||
}
|
||||
const extension = path.extname(filePath);
|
||||
const fallbackPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`${path.basename(filePath, extension)}-attempt-${randomUUID()}${extension}`
|
||||
);
|
||||
fs.writeFileSync(fallbackPath, content);
|
||||
process.stderr.write(
|
||||
`[DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件:${path.basename(fallbackPath)}\n`
|
||||
);
|
||||
return fallbackPath;
|
||||
}
|
||||
}
|
||||
|
||||
const inlineCodeContinuityProbes = [
|
||||
{
|
||||
id: "paragraph",
|
||||
code: "mdtp_ic_p",
|
||||
text: "段落前mdtp_ic_p段落后"
|
||||
},
|
||||
{
|
||||
id: "list-item",
|
||||
code: "mdtp_ic_l",
|
||||
text: "列表前mdtp_ic_l列表后"
|
||||
},
|
||||
{
|
||||
id: "table-cell",
|
||||
code: "mdtp_ic_c",
|
||||
text: "单元格前mdtp_ic_c单元格后"
|
||||
}
|
||||
];
|
||||
|
||||
const inlineCodeContinuityMarkdown = `
|
||||
|
||||
## 行内代码连续性门禁
|
||||
|
||||
段落前\`mdtp_ic_p\`段落后
|
||||
|
||||
- 列表前\`mdtp_ic_l\`列表后
|
||||
|
||||
| 门禁 | 内容 | 邻接单元格 |
|
||||
| --- | --- | --- |
|
||||
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
|
||||
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
|
||||
|
||||
| 场景 | 内容 |
|
||||
| --- | --- |
|
||||
| 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
|
||||
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
|
||||
| 叁式 | 连甲<br><br>连乙 |
|
||||
| 肆式 | <br>边界<br> |
|
||||
| 伍式 | 转甲\\<br>转乙 <br> 转丙 |
|
||||
| 陆式 | 属甲<br class="unsafe">属乙 |
|
||||
| 柒式 | 码甲\`<br>\`码乙 |
|
||||
|
||||
表格外保留 外一<br>外二 原文。
|
||||
|
||||
\`\`\`html
|
||||
围一<br>围二
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const docxBreakParagraphProbes = [
|
||||
{
|
||||
id: "basic",
|
||||
marker: "标甲",
|
||||
expectedText: "标甲标乙标丙标丁",
|
||||
hardBreakCount: 3
|
||||
},
|
||||
{
|
||||
id: "case-insensitive",
|
||||
marker: "大甲",
|
||||
expectedText: "大甲大乙大丙大丁",
|
||||
hardBreakCount: 3
|
||||
},
|
||||
{
|
||||
id: "multiple",
|
||||
marker: "连甲",
|
||||
expectedText: "连甲连乙",
|
||||
hardBreakCount: 2
|
||||
},
|
||||
{
|
||||
id: "boundary",
|
||||
marker: "边界",
|
||||
expectedText: "边界",
|
||||
hardBreakCount: 2
|
||||
},
|
||||
{
|
||||
id: "adjacent-a",
|
||||
marker: "邻乙",
|
||||
expectedText: "邻甲邻乙",
|
||||
hardBreakCount: 1
|
||||
},
|
||||
{
|
||||
id: "adjacent-b",
|
||||
marker: "旁乙",
|
||||
expectedText: "旁甲旁乙",
|
||||
hardBreakCount: 1
|
||||
},
|
||||
{
|
||||
id: "escaped-and-entity",
|
||||
marker: "转甲",
|
||||
expectedText: "转甲<br>转乙 <br> 转丙",
|
||||
hardBreakCount: 0
|
||||
},
|
||||
{
|
||||
id: "attribute",
|
||||
marker: "属甲",
|
||||
expectedText: "属甲<br class=\"unsafe\">属乙",
|
||||
hardBreakCount: 0
|
||||
},
|
||||
{
|
||||
id: "inline-code",
|
||||
marker: "码甲",
|
||||
expectedText: "码甲<br>码乙",
|
||||
hardBreakCount: 0,
|
||||
requiredCharacterStyle: "VerbatimChar"
|
||||
},
|
||||
{
|
||||
id: "outside-table",
|
||||
marker: "外一",
|
||||
expectedText: "表格外保留 外一<br>外二 原文。",
|
||||
hardBreakCount: 0
|
||||
},
|
||||
{
|
||||
id: "fenced-code",
|
||||
marker: "围一",
|
||||
expectedText: "围一<br>围二",
|
||||
hardBreakCount: 0,
|
||||
requiredParagraphStyle: "SourceCode"
|
||||
}
|
||||
];
|
||||
|
||||
const contentTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
@@ -50,6 +194,31 @@ function assert(condition, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function newestModifiedAt(targetPath) {
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (stat.isFile()) {
|
||||
return stat.mtimeMs;
|
||||
}
|
||||
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
|
||||
(newest, entry) =>
|
||||
Math.max(
|
||||
newest,
|
||||
newestModifiedAt(path.join(targetPath, entry.name))
|
||||
),
|
||||
stat.mtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function assertFreshArtifact(label, sourcePaths, artifactPath) {
|
||||
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
|
||||
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
|
||||
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
|
||||
assert(
|
||||
artifactModifiedAt + 1_500 >= newestSource,
|
||||
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
|
||||
);
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
@@ -172,6 +341,11 @@ function inspectDocx(content) {
|
||||
const fontNames = [
|
||||
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
|
||||
].map((match) => match[1]);
|
||||
const embeddedFontNames = [
|
||||
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"[\s\S]*?<\/w:font>/gu)
|
||||
].flatMap((match) => /<w:embed(?:Regular|Bold|Italic|BoldItalic)\b/u.test(match[0])
|
||||
? [match[1]]
|
||||
: []);
|
||||
const paragraphs = [
|
||||
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
|
||||
].map((match) => match[0]);
|
||||
@@ -196,6 +370,88 @@ function inspectDocx(content) {
|
||||
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
|
||||
0
|
||||
);
|
||||
const inlineCodeContinuity = inlineCodeContinuityProbes.map((probe) => {
|
||||
const matchingParagraphs = paragraphs.filter((paragraph) =>
|
||||
paragraph.includes(probe.code)
|
||||
);
|
||||
const paragraph = matchingParagraphs[0] ?? "";
|
||||
const text = [...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)]
|
||||
.map((match) => match[1])
|
||||
.join("");
|
||||
const codeRunPattern = new RegExp(
|
||||
`<w:r(?:\\s|>)[\\s\\S]*?<w:rStyle\\s+w:val="VerbatimChar"\\s*\\/>[\\s\\S]*?<w:t(?:\\s[^>]*)?>${probe.code}<\\/w:t>[\\s\\S]*?<\\/w:r>`,
|
||||
"u"
|
||||
);
|
||||
const hardBreakCount =
|
||||
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
|
||||
return {
|
||||
id: probe.id,
|
||||
code: probe.code,
|
||||
expectedText: probe.text,
|
||||
actualText: text,
|
||||
paragraphCount: matchingParagraphs.length,
|
||||
hardBreakCount,
|
||||
characterStylePresent: codeRunPattern.test(paragraph),
|
||||
passed:
|
||||
matchingParagraphs.length === 1 &&
|
||||
text === probe.text &&
|
||||
hardBreakCount === 0 &&
|
||||
codeRunPattern.test(paragraph)
|
||||
};
|
||||
});
|
||||
const breakProbes = docxBreakParagraphProbes.map((probe) => {
|
||||
const matchingParagraphs = paragraphs.map((paragraph) => ({
|
||||
paragraph,
|
||||
actualText: [
|
||||
...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)
|
||||
].map((match) => match[1]).join("")
|
||||
})).filter(({ actualText }) => actualText === probe.expectedText);
|
||||
const paragraph = matchingParagraphs[0]?.paragraph ?? "";
|
||||
const actualText = matchingParagraphs[0]?.actualText ?? "";
|
||||
const hardBreakCount =
|
||||
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
|
||||
const runs = paragraph.match(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu) ?? [];
|
||||
const characterStylePresent = !probe.requiredCharacterStyle ||
|
||||
runs.some((run) =>
|
||||
run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) &&
|
||||
run.includes("<br>")
|
||||
);
|
||||
const paragraphStylePresent = !probe.requiredParagraphStyle ||
|
||||
new RegExp(
|
||||
`<w:pStyle\\s+w:val="${probe.requiredParagraphStyle}"\\s*\\/>`,
|
||||
"u"
|
||||
).test(paragraph);
|
||||
return {
|
||||
id: probe.id,
|
||||
marker: probe.marker,
|
||||
expectedText: probe.expectedText,
|
||||
actualText,
|
||||
expectedHardBreakCount: probe.hardBreakCount,
|
||||
hardBreakCount,
|
||||
paragraphCount: matchingParagraphs.length,
|
||||
characterStylePresent,
|
||||
paragraphStylePresent,
|
||||
passed:
|
||||
matchingParagraphs.length === 1 &&
|
||||
actualText === probe.expectedText &&
|
||||
hardBreakCount === probe.hardBreakCount &&
|
||||
characterStylePresent &&
|
||||
paragraphStylePresent
|
||||
};
|
||||
});
|
||||
const adjacentA = breakProbes.find((probe) => probe.id === "adjacent-a");
|
||||
const adjacentB = breakProbes.find((probe) => probe.id === "adjacent-b");
|
||||
const tableBreakSemantics = {
|
||||
probes: breakProbes,
|
||||
adjacentCellsIsolated:
|
||||
adjacentA?.paragraphCount === 1 &&
|
||||
adjacentB?.paragraphCount === 1 &&
|
||||
adjacentA.actualText !== adjacentB.actualText,
|
||||
passed:
|
||||
breakProbes.every((probe) => probe.passed) &&
|
||||
adjacentA?.paragraphCount === 1 &&
|
||||
adjacentB?.paragraphCount === 1
|
||||
};
|
||||
return {
|
||||
bytes: content.byteLength,
|
||||
sha256: sha256(content),
|
||||
@@ -205,6 +461,9 @@ function inspectDocx(content) {
|
||||
fontNames: [...new Set(fontNames)].sort((first, second) =>
|
||||
first.localeCompare(second, "en")
|
||||
),
|
||||
embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) =>
|
||||
first.localeCompare(second, "en")
|
||||
),
|
||||
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
|
||||
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
|
||||
internalMarkerCount:
|
||||
@@ -214,6 +473,11 @@ function inspectDocx(content) {
|
||||
officialIssueRowRightTabCount:
|
||||
officialIssueRowsWithRightTab.length,
|
||||
officialIssueRowContentTabCount,
|
||||
inlineCodeContinuity,
|
||||
inlineCodeContinuityPassed: inlineCodeContinuity.every(
|
||||
(probe) => probe.passed
|
||||
),
|
||||
tableBreakSemantics,
|
||||
requiredStylesPresent: [
|
||||
"Normal",
|
||||
"Heading1",
|
||||
@@ -242,6 +506,103 @@ async function requestArtifact(origin, route, payload, contentType) {
|
||||
return { response, content };
|
||||
}
|
||||
|
||||
async function requestJson(origin, route, payload) {
|
||||
const response = await fetch(`${origin}${route}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const text = await response.text();
|
||||
assert(response.ok, `${route} 返回 ${response.status}:${text}`);
|
||||
assert(
|
||||
response.headers.get("content-type")?.includes("application/json"),
|
||||
`${route} MIME 无效:${response.headers.get("content-type")}`
|
||||
);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function inspectHtmlBreakSemantics(articleHtml) {
|
||||
const cellFor = (marker) => {
|
||||
const cells = articleHtml.match(/<t[dh](?:\s[^>]*)?>[\s\S]*?<\/t[dh]>/gu) ?? [];
|
||||
return cells.find((cell) => cell.includes(marker)) ?? "";
|
||||
};
|
||||
const fragmentFor = (start, end) => {
|
||||
const startIndex = articleHtml.indexOf(start);
|
||||
const endIndex = articleHtml.indexOf(end, startIndex + start.length);
|
||||
return startIndex >= 0 && endIndex >= 0
|
||||
? articleHtml.slice(startIndex, endIndex + end.length)
|
||||
: "";
|
||||
};
|
||||
const probes = [
|
||||
{
|
||||
id: "basic",
|
||||
passed: cellFor("标甲").includes(
|
||||
"标甲<br />标乙<br />标丙<br />标丁"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "case-insensitive",
|
||||
passed: cellFor("大甲").includes(
|
||||
"大甲<br />大乙<br />大丙<br />大丁"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "multiple",
|
||||
passed: cellFor("连甲").includes(
|
||||
"连甲<br /><br />连乙"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "boundary",
|
||||
passed: cellFor("边界").includes("<br />边界<br />")
|
||||
},
|
||||
{
|
||||
id: "escaped-and-entity",
|
||||
passed: cellFor("转甲").includes(
|
||||
"转甲<br>转乙 <br> 转丙"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "attribute-literal",
|
||||
passed: cellFor("属甲").includes(
|
||||
"属甲<br class=\"unsafe\">属乙"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "inline-code-literal",
|
||||
passed: cellFor("码甲").includes(
|
||||
"码甲<code><br></code>码乙"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "outside-table-literal",
|
||||
passed: fragmentFor("外一", "外二").includes(
|
||||
"外一<br>外二"
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "fenced-code-literal",
|
||||
passed: (() => {
|
||||
const fragment = fragmentFor("围一", "围二");
|
||||
return fragment.includes("<") && fragment.includes("br") &&
|
||||
fragment.includes(">") && !/<br(?:\s|\/?>)/iu.test(fragment);
|
||||
})()
|
||||
}
|
||||
];
|
||||
const adjacentA = cellFor("邻甲");
|
||||
const adjacentB = cellFor("旁甲");
|
||||
const adjacentCellsIsolated =
|
||||
adjacentA.includes("邻甲<br />邻乙") &&
|
||||
adjacentB.includes("旁甲<br />旁乙") &&
|
||||
!adjacentA.includes("旁甲") &&
|
||||
!adjacentB.includes("邻甲");
|
||||
return {
|
||||
probes,
|
||||
adjacentCellsIsolated,
|
||||
passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated
|
||||
};
|
||||
}
|
||||
|
||||
function responseDiagnostics(response, kind) {
|
||||
return {
|
||||
serverTiming: response.headers.get("server-timing"),
|
||||
@@ -263,6 +624,42 @@ function responseDiagnostics(response, kind) {
|
||||
}
|
||||
|
||||
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
|
||||
assertFreshArtifact(
|
||||
"Preview Engine",
|
||||
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
|
||||
path.join(
|
||||
repositoryDirectory,
|
||||
"packages",
|
||||
"preview-engine",
|
||||
"dist",
|
||||
"semantic-cover-fit.js"
|
||||
)
|
||||
);
|
||||
assertFreshArtifact(
|
||||
"DOCX Engine",
|
||||
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
|
||||
path.join(
|
||||
repositoryDirectory,
|
||||
"packages",
|
||||
"docx-engine",
|
||||
"dist",
|
||||
"document-structure-transform.js"
|
||||
)
|
||||
);
|
||||
assertFreshArtifact(
|
||||
"Web Preview Runtime",
|
||||
[
|
||||
path.join(repositoryDirectory, "apps", "web", "src"),
|
||||
path.join(
|
||||
repositoryDirectory,
|
||||
"packages",
|
||||
"preview-engine",
|
||||
"dist",
|
||||
"semantic-cover-fit.js"
|
||||
)
|
||||
],
|
||||
path.join(webDirectory, "preview-frame.html")
|
||||
);
|
||||
assert(
|
||||
fs.statSync(fontPackRoot).isDirectory(),
|
||||
`缺少有效字体包根目录:${fontPackRoot}`
|
||||
@@ -274,6 +671,10 @@ assert(
|
||||
);
|
||||
const selectedThemeId =
|
||||
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
|
||||
const externalMarkdownPath =
|
||||
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() || undefined;
|
||||
const appendInlineCodeProbes =
|
||||
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
|
||||
const exportConfigOverride = readExportConfigOverride();
|
||||
const themes = selectedThemeId
|
||||
? allThemes.filter((theme) => theme.id === selectedThemeId)
|
||||
@@ -297,13 +698,22 @@ const port = await reservePort();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
|
||||
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
|
||||
const captureDocxMedia = docxMediaAdapter.capture.bind(docxMediaAdapter);
|
||||
let capturedDocxDocumentLayout;
|
||||
docxMediaAdapter.capture = async (...args) => {
|
||||
const output = await captureDocxMedia(...args);
|
||||
capturedDocxDocumentLayout = structuredClone(
|
||||
output.plan.documentLayout ?? { tables: [] }
|
||||
);
|
||||
return output;
|
||||
};
|
||||
const app = buildApp({
|
||||
logger: { level: "error" },
|
||||
pdfGenerator,
|
||||
docxMediaAdapter,
|
||||
fontPacks: {
|
||||
roots: [fontPackRoot],
|
||||
appVersion: "0.6.0"
|
||||
appVersion
|
||||
}
|
||||
});
|
||||
registerWebRuntime(app);
|
||||
@@ -321,10 +731,15 @@ try {
|
||||
);
|
||||
|
||||
for (const theme of themes) {
|
||||
capturedDocxDocumentLayout = undefined;
|
||||
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
|
||||
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
|
||||
const samplePath = externalMarkdownPath
|
||||
? path.resolve(repositoryDirectory, externalMarkdownPath)
|
||||
: path.join(samplesDirectory, `${theme.id}.md`);
|
||||
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
|
||||
const markdown = fs.readFileSync(samplePath, "utf8");
|
||||
const sourceMarkdown = fs.readFileSync(samplePath, "utf8");
|
||||
const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`;
|
||||
const sourceMetadata = matter(sourceMarkdown).data;
|
||||
const exportConfig = mergeExportConfig({
|
||||
...defaultExportConfig,
|
||||
name: `${theme.name} R4 生产链验收`,
|
||||
@@ -349,11 +764,21 @@ try {
|
||||
}, exportConfigOverride);
|
||||
const payload = {
|
||||
markdown,
|
||||
fileName: `${theme.id}.md`,
|
||||
fileName: path.basename(samplePath),
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig
|
||||
};
|
||||
const rendered = await requestJson(origin, "/api/render", payload);
|
||||
const htmlBreakSemantics = appendInlineCodeProbes
|
||||
? inspectHtmlBreakSemantics(rendered.articleHtml)
|
||||
: undefined;
|
||||
if (appendInlineCodeProbes) {
|
||||
assert(
|
||||
htmlBreakSemantics.passed,
|
||||
`主题 ${theme.id} 的 Chromium br 语义门禁失败:${JSON.stringify(htmlBreakSemantics)}`
|
||||
);
|
||||
}
|
||||
const pdf = await requestArtifact(
|
||||
origin,
|
||||
"/api/pdf",
|
||||
@@ -380,6 +805,16 @@ try {
|
||||
inspection.requiredStylesPresent,
|
||||
`主题 ${theme.id} 缺少标准 Word 样式`
|
||||
);
|
||||
if (appendInlineCodeProbes) {
|
||||
assert(
|
||||
inspection.inlineCodeContinuityPassed,
|
||||
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
|
||||
);
|
||||
assert(
|
||||
inspection.tableBreakSemantics.passed,
|
||||
`主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}`
|
||||
);
|
||||
}
|
||||
const standard = standardByTheme.get(theme.id);
|
||||
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
|
||||
assert(
|
||||
@@ -395,8 +830,8 @@ try {
|
||||
);
|
||||
}
|
||||
const expectsOfficialDualEndedRow = Boolean(
|
||||
standard.metadata?.document?.profile === "official" &&
|
||||
standard.metadata.document.signatory
|
||||
sourceMetadata.document?.profile === "official" &&
|
||||
sourceMetadata.document.signatory
|
||||
);
|
||||
if (expectsOfficialDualEndedRow) {
|
||||
assert(
|
||||
@@ -416,9 +851,12 @@ try {
|
||||
}
|
||||
|
||||
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
|
||||
const docxPath = path.join(docxDirectory, `${theme.id}.docx`);
|
||||
const preferredDocxPath = path.join(docxDirectory, `${theme.id}.docx`);
|
||||
fs.writeFileSync(pdfPath, pdf.content);
|
||||
fs.writeFileSync(docxPath, docx.content);
|
||||
const docxPath = writeArtifactWithBusyFallback(
|
||||
preferredDocxPath,
|
||||
docx.content
|
||||
);
|
||||
results.push({
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
@@ -426,6 +864,7 @@ try {
|
||||
compatibleProfiles: theme.compatibleProfiles,
|
||||
sample: path.relative(repositoryDirectory, samplePath),
|
||||
exportConfig,
|
||||
htmlBreakSemantics,
|
||||
pdf: {
|
||||
outputFile: path.relative(repositoryDirectory, pdfPath),
|
||||
bytes: pdf.content.byteLength,
|
||||
@@ -435,6 +874,7 @@ try {
|
||||
docx: {
|
||||
outputFile: path.relative(repositoryDirectory, docxPath),
|
||||
inspection,
|
||||
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
|
||||
diagnostics: responseDiagnostics(docx.response, "docx")
|
||||
},
|
||||
standardInspection: standard.inspection,
|
||||
@@ -448,12 +888,6 @@ try {
|
||||
const enhancedThemes = results.filter(
|
||||
(result) => result.docx.inspection.optionalFontPackApplied
|
||||
);
|
||||
if (themes.some((theme) => theme.id === "gov-red-standard")) {
|
||||
assert(
|
||||
enhancedThemes.some((result) => result.id === "gov-red-standard"),
|
||||
"有效字体包未增强 gov-red-standard"
|
||||
);
|
||||
}
|
||||
for (const result of results) {
|
||||
assert(
|
||||
result.pdf.diagnostics.pageCount > 0,
|
||||
|
||||
+27
-2
@@ -486,7 +486,16 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
request.log.error({ error }, "PDF generation failed");
|
||||
request.log.error(
|
||||
{
|
||||
error,
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
errorStack:
|
||||
error instanceof Error ? error.stack : undefined
|
||||
},
|
||||
"PDF generation failed"
|
||||
);
|
||||
return reply.code(500).send({
|
||||
error: "PDF_GENERATION_FAILED",
|
||||
message: "PDF 生成失败"
|
||||
@@ -559,7 +568,23 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
if (error.statusCode >= 500) {
|
||||
request.log.error(
|
||||
{ error, cause: error.cause },
|
||||
{
|
||||
error,
|
||||
cause: error.cause,
|
||||
causeMessage:
|
||||
error.cause instanceof Error
|
||||
? error.cause.message
|
||||
: undefined,
|
||||
causeStack:
|
||||
error.cause instanceof Error
|
||||
? error.cause.stack
|
||||
: undefined,
|
||||
causeDiagnostics:
|
||||
error.cause instanceof Error &&
|
||||
"diagnostics" in error.cause
|
||||
? error.cause.diagnostics
|
||||
: undefined
|
||||
},
|
||||
"DOCX generation service failed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const app = buildApp({
|
||||
? {
|
||||
fontPacks: {
|
||||
roots: [fontPackRoot],
|
||||
appVersion: process.env.APP_VERSION ?? "0.6.0"
|
||||
appVersion: process.env.APP_VERSION ?? "0.6.4"
|
||||
}
|
||||
}
|
||||
: {})
|
||||
|
||||
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
|
||||
timeoutMs: readIntegerEnvironment(
|
||||
environment,
|
||||
"PDF_TIMEOUT_MS",
|
||||
60_000,
|
||||
180_000,
|
||||
1_000
|
||||
)
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function captureDocxMediaWithPlaywrightPage(
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
await page.emulateMedia({ media: "print" });
|
||||
const plan = await renderCapturePlan(page, request);
|
||||
signal?.throwIfAborted();
|
||||
const session = await page.context().newCDPSession(page);
|
||||
|
||||
@@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) {
|
||||
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
|
||||
}
|
||||
|
||||
describe("Docker v0.6.0 部署契约", () => {
|
||||
describe("Docker v0.6.4 部署契约", () => {
|
||||
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
|
||||
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||
const workspaces = [
|
||||
@@ -57,6 +57,9 @@ describe("Docker v0.6.0 部署契约", () => {
|
||||
expect(dockerfile).toContain(
|
||||
"COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2"
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc"
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
|
||||
);
|
||||
@@ -65,7 +68,8 @@ describe("Docker v0.6.0 部署契约", () => {
|
||||
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
|
||||
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||
const compose = readProjectFile("deploy/compose.yaml");
|
||||
const dockerignore = readProjectFile(
|
||||
const dockerignore = readProjectFile(".dockerignore");
|
||||
const dockerfileIgnore = readProjectFile(
|
||||
"deploy/Dockerfile.dockerignore"
|
||||
);
|
||||
|
||||
@@ -78,10 +82,11 @@ describe("Docker v0.6.0 部署契约", () => {
|
||||
);
|
||||
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
|
||||
expect(compose).toContain(
|
||||
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"'
|
||||
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.4}"'
|
||||
);
|
||||
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
|
||||
expect(dockerignore).toContain("!output/font-packs/root/**");
|
||||
expect(dockerfileIgnore).toBe(dockerignore);
|
||||
});
|
||||
|
||||
it("提供显式真实 Docker 字体包验收命令", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("Server DOCX 媒体浏览器", () => {
|
||||
const detach = vi.fn(async () => undefined);
|
||||
const page = {
|
||||
goto: vi.fn(async () => undefined),
|
||||
emulateMedia: vi.fn(async () => undefined),
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => emptyPlan),
|
||||
context: () => context
|
||||
@@ -104,6 +105,7 @@ describe("Server DOCX 媒体浏览器", () => {
|
||||
"http://renderer.test/preview-frame.html?target=continuous",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
|
||||
expect(continueRoute).toHaveBeenCalledTimes(1);
|
||||
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
|
||||
expect(readPdfEngineRuntimeLimits({})).toEqual({
|
||||
concurrency: 2,
|
||||
maxQueue: 8,
|
||||
timeoutMs: 60_000
|
||||
timeoutMs: 180_000
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("Playwright DOCX 媒体捕获", () => {
|
||||
data: Buffer.from("png").toString("base64")
|
||||
}));
|
||||
const page = {
|
||||
emulateMedia: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => plan),
|
||||
context: () => ({
|
||||
newCDPSession: vi.fn(async () => ({ send, detach }))
|
||||
@@ -74,6 +75,7 @@ describe("Playwright DOCX 媒体捕获", () => {
|
||||
);
|
||||
|
||||
expect(result.plan).toBe(plan);
|
||||
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
|
||||
expect(result.captures[0]).toEqual({
|
||||
id: "docx-media-1",
|
||||
png: Buffer.from("png")
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
PagedDocumentRuntime,
|
||||
SHARED_HIGHLIGHT_CSS,
|
||||
renderDocxMediaCapturePlan,
|
||||
resolveMermaidOutputMode,
|
||||
resolvePagedRenderTarget,
|
||||
type PagedPreviewFrameMessage,
|
||||
type PagedPreviewPayload
|
||||
} from "@md-to-pdf/preview-engine";
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import {
|
||||
beginPreviewFrameRender,
|
||||
@@ -29,7 +29,7 @@ const previewRoot = getPreviewRoot();
|
||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot, {
|
||||
highlightCss,
|
||||
highlightCss: SHARED_HIGHLIGHT_CSS,
|
||||
katexCss,
|
||||
echartsCss
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.6.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.6.0");
|
||||
expect(APP_VERSION).toBe("0.6.4");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.6.4");
|
||||
});
|
||||
|
||||
it("允许浏览器加载同源 Web Manifest", () => {
|
||||
|
||||
+3
-1
@@ -110,7 +110,7 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
||||
fi
|
||||
|
||||
COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2
|
||||
RUN ln -s /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
|
||||
RUN ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
|
||||
&& test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"
|
||||
|
||||
WORKDIR /app
|
||||
@@ -166,6 +166,8 @@ ENV HOST=127.0.0.1 \
|
||||
FONT_PACK_ROOT=/app/.local/font-packs
|
||||
|
||||
RUN test -f /app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json
|
||||
RUN test -f /app/.local/font-packs/mdtp-sans-sc/1.0.0/font-pack.json
|
||||
RUN test -f /app/.local/font-packs/mdtp-mono/1.0.0/font-pack.json
|
||||
|
||||
USER pwuser
|
||||
|
||||
|
||||
+13
-15
@@ -3,7 +3,7 @@
|
||||
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
|
||||
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
|
||||
Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成
|
||||
可编辑 DOCX。可选字体包只从宿主机只读挂载,不进入应用镜像。
|
||||
可编辑 DOCX。发行镜像固定内置受校验字体包,不依赖宿主机字体挂载。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down
|
||||
可以直接指定版本化镜像名称构建:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.4'
|
||||
docker compose -f deploy\compose.yaml build
|
||||
```
|
||||
|
||||
@@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build
|
||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.4'
|
||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||
docker compose -f deploy\compose.yaml build
|
||||
```
|
||||
|
||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.0 应用代码和生产
|
||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.4 应用代码和生产
|
||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||
|
||||
## 主题挂载
|
||||
@@ -76,7 +76,7 @@ docker compose -f deploy\compose.yaml up -d
|
||||
|
||||
## 内置字体包
|
||||
|
||||
v0.6.0 的发行镜像固定将受校验字体包内置到
|
||||
v0.6.4 的发行镜像固定将受校验字体包内置到
|
||||
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
|
||||
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
|
||||
|
||||
@@ -84,11 +84,9 @@ v0.6.0 的发行镜像固定将受校验字体包内置到
|
||||
|
||||
```text
|
||||
<字体包根目录>/
|
||||
└── mdtp-serif-sc/
|
||||
└── 1.0.0/
|
||||
├── font-pack.json
|
||||
├── LICENSE.txt
|
||||
└── fonts/
|
||||
├── mdtp-serif-sc/1.0.0/
|
||||
├── mdtp-sans-sc/1.0.0/
|
||||
└── mdtp-mono/1.0.0/
|
||||
```
|
||||
|
||||
从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像:
|
||||
@@ -98,15 +96,15 @@ npm run build:font-pack
|
||||
docker compose -f deploy\compose.yaml up -d
|
||||
```
|
||||
|
||||
字体二进制仍不提交到 Git。Docker 构建上下文只放行本次生成的
|
||||
`output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
|
||||
字体二进制通过 Git LFS 固定在仓库中。Docker 构建上下文只放行构建所需
|
||||
源码与本次生成的 `output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
|
||||
仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
|
||||
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
|
||||
|
||||
发布前执行真实内置字体门禁:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.4'
|
||||
npm run verify:docker-font-pack
|
||||
```
|
||||
|
||||
@@ -124,7 +122,7 @@ npm run verify:docker-font-pack
|
||||
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
||||
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
||||
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
||||
| `MD_TO_PDF_APP_VERSION` | `0.6.0` | 字体包兼容性判断使用的应用版本 |
|
||||
| `MD_TO_PDF_APP_VERSION` | `0.6.4` | 字体包兼容性判断使用的应用版本 |
|
||||
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
||||
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
||||
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ services:
|
||||
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
|
||||
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
|
||||
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
|
||||
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.0}"
|
||||
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.4}"
|
||||
FONT_PACK_ROOT: /app/.local/font-packs
|
||||
volumes:
|
||||
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
|
||||
|
||||
@@ -15,12 +15,11 @@ const image = process.env.MD_TO_PDF_IMAGE?.trim() || "md-to-pdf:local";
|
||||
const fontPackRoot = path.resolve(
|
||||
path.join(repositoryRoot, "output", "font-packs", "root")
|
||||
);
|
||||
const manifestPath = path.join(
|
||||
fontPackRoot,
|
||||
"mdtp-serif-sc",
|
||||
"1.0.0",
|
||||
"font-pack.json"
|
||||
);
|
||||
const expectedFontPacks = [
|
||||
{ id: "mdtp-serif-sc", version: "1.0.0" },
|
||||
{ id: "mdtp-sans-sc", version: "1.0.0" },
|
||||
{ id: "mdtp-mono", version: "1.0.0" }
|
||||
];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -103,7 +102,7 @@ async function exportDocx(origin) {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
markdown:
|
||||
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段可编辑中文正文。",
|
||||
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段包含行内代码 `const value = 1` 的可编辑中文正文,代码前后不得换行。",
|
||||
fileName: "Docker 内置字体包验收.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
@@ -126,7 +125,14 @@ assert(
|
||||
fontPackStatus?.isDirectory(),
|
||||
`未找到有效字体包根目录:${fontPackRoot}。请先运行 npm run build:font-pack。`
|
||||
);
|
||||
const localManifest = await readFile(manifestPath);
|
||||
const localManifests = await Promise.all(
|
||||
expectedFontPacks.map(async (pack) => ({
|
||||
...pack,
|
||||
content: await readFile(
|
||||
path.join(fontPackRoot, pack.id, pack.version, "font-pack.json")
|
||||
)
|
||||
}))
|
||||
);
|
||||
const buildEnvironment = {
|
||||
...process.env,
|
||||
MD_TO_PDF_IMAGE: image
|
||||
@@ -137,22 +143,26 @@ run(
|
||||
{ env: buildEnvironment }
|
||||
);
|
||||
run("docker", ["image", "inspect", image], { capture: true });
|
||||
const embeddedManifestHash = run(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--entrypoint",
|
||||
"sha256sum",
|
||||
image,
|
||||
"/app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json"
|
||||
],
|
||||
{ capture: true }
|
||||
).split(/\s+/u)[0];
|
||||
assert(
|
||||
embeddedManifestHash === sha256(localManifest),
|
||||
"镜像内字体包清单与本次受校验构建产物不一致"
|
||||
);
|
||||
const embeddedManifestHashes = {};
|
||||
for (const manifest of localManifests) {
|
||||
const embeddedHash = run(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--entrypoint",
|
||||
"sha256sum",
|
||||
image,
|
||||
`/app/.local/font-packs/${manifest.id}/${manifest.version}/font-pack.json`
|
||||
],
|
||||
{ capture: true }
|
||||
).split(/\s+/u)[0];
|
||||
assert(
|
||||
embeddedHash === sha256(manifest.content),
|
||||
`镜像内字体包清单不一致:${manifest.id}@${manifest.version}`
|
||||
);
|
||||
embeddedManifestHashes[`${manifest.id}@${manifest.version}`] = embeddedHash;
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const projectName = `morphdoc-font-pack-${randomUUID().slice(0, 8)}`;
|
||||
@@ -165,7 +175,7 @@ const environment = {
|
||||
...buildEnvironment,
|
||||
MD_TO_PDF_PORT: String(port),
|
||||
MD_TO_PDF_THEME_DIR: themeRoot,
|
||||
MD_TO_PDF_APP_VERSION: "0.6.0"
|
||||
MD_TO_PDF_APP_VERSION: "0.6.4"
|
||||
};
|
||||
const compose = [
|
||||
"compose",
|
||||
@@ -205,10 +215,13 @@ try {
|
||||
);
|
||||
const css = await cssResponse.text();
|
||||
assert(cssResponse.ok, "主题 CSS 请求失败");
|
||||
assert(
|
||||
css.includes("md-to-pdf-font-packs:mdtp-serif-sc@1.0.0"),
|
||||
"Docker 内置字体包未进入主题 CSS"
|
||||
);
|
||||
assert(css.includes("md-to-pdf-font-packs:"), "主题 CSS 缺少字体包标记");
|
||||
for (const pack of expectedFontPacks) {
|
||||
assert(
|
||||
css.includes(`${pack.id}@${pack.version}`),
|
||||
`Docker 内置字体包未进入主题 CSS:${pack.id}@${pack.version}`
|
||||
);
|
||||
}
|
||||
const assetPath = css.match(
|
||||
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
|
||||
)?.[1];
|
||||
@@ -232,12 +245,23 @@ try {
|
||||
assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`);
|
||||
const fontTable = strFromU8(entries["word/fontTable.xml"]);
|
||||
assert(fontTable.includes("MdTP Serif SC"), "字体包未进入 DOCX 字体表");
|
||||
assert(fontTable.includes("MdTP Mono"), "等宽字体包未进入 DOCX 字体表");
|
||||
const documentXml = strFromU8(entries["word/document.xml"]);
|
||||
const inlineCodeParagraph = documentXml
|
||||
.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/gu)
|
||||
?.find(
|
||||
(paragraph) =>
|
||||
paragraph.includes("这是一段包含行内代码") &&
|
||||
paragraph.includes("const value = 1") &&
|
||||
paragraph.includes("代码前后不得换行")
|
||||
);
|
||||
assert(inlineCodeParagraph, "行内代码及前后正文未保持在同一 Word 段落");
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
image,
|
||||
embeddedManifestSha256: embeddedManifestHash,
|
||||
embeddedManifestSha256: embeddedManifestHashes,
|
||||
runtimeFontPackMount: false,
|
||||
fontPackAvailable: true,
|
||||
pandocVersion: capability.detectedVersion,
|
||||
|
||||
@@ -137,8 +137,8 @@ Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立
|
||||
项目发布资产计划至少包含:
|
||||
|
||||
```text
|
||||
MorphDoc-0.6.0-x86_64-Setup.exe
|
||||
MorphDoc-0.6.0-x86_64.zip
|
||||
MorphDoc-0.6.1-x86_64-Setup.exe
|
||||
MorphDoc-0.6.1-x86_64.zip
|
||||
pandoc-3.9.0.2-source.tar.gz
|
||||
THIRD_PARTY_NOTICES.md
|
||||
```
|
||||
|
||||
+132
-5
@@ -1,6 +1,52 @@
|
||||
# 墨呈进度
|
||||
|
||||
最后更新:2026-08-02
|
||||
最后更新:2026-08-26
|
||||
|
||||
## v0.6.4 环境自适应与 macOS 打包
|
||||
|
||||
- `AGENTS.md` 不再硬编码 Windows 绝对路径与强制 PowerShell 语法;会话启动检查改为
|
||||
动态确认仓库根目录,命令语法跟随当前会话实际 Shell。
|
||||
- 新增 macOS 桌面打包能力:`electron-builder` 增加独立 `mac` 配置块,产出 `dmg`,
|
||||
按 arm64、x64 分别单独构建、不产出 universal 包;Pandoc 运行时清单新增
|
||||
`darwin/arm64`、`darwin/x64` 两个官方发行项(含独立许可证文件来源,因为 macOS
|
||||
官方 zip 本身不随附许可证文本),`prepare-pandoc-runtime.mjs` 与桌面 Pandoc
|
||||
候选路径均已泛化为按 `--platform`/`--arch` 参数选取目标。
|
||||
- 顺手发现并修复两个此前从未在 macOS 全新环境下暴露过的真实 bug:
|
||||
解压内置 Pandoc 时未保留 Unix 可执行权限位(`unzipSync` 不保留压缩包内权限,
|
||||
已补 `chmod 0o755`);根 `package.json` 的 `build:web-runtime`/`dev`/`desktop:dev`
|
||||
把 `@md-to-pdf/application` 排在其依赖的 `@md-to-pdf/preview-engine` 之前构建,
|
||||
在没有历史 `dist/` 残留的全新环境中会导致类型解析失败,已调整构建顺序。
|
||||
- 本机 macOS(Apple Silicon)实测:`npm run package:mac:arm64` 全链路成功,内置
|
||||
darwin-arm64 版 Pandoc 3.9.0.2 完成下载、哈希校验与真实 DOCX 冒烟转换;未打包
|
||||
应用(`--dir`)可正常启动,主/渲染/GPU/网络服务进程均稳定运行,可正常退出。
|
||||
实际截图确认窗口、macOS 原生菜单栏、编辑器工具栏、中文字体与实时预览(标题、
|
||||
任务列表、表格、行内公式、引用块)均渲染正常。
|
||||
- 跳过依赖真实语料的 `test:docx-real-world-corpus`、`test:docx-release-gate-suites`
|
||||
后,其余全部工作区测试、全项目类型检查和生产构建均通过;这两步依赖 Git 忽略、
|
||||
需从可信来源单独提供的真实客户文档(`tmp/`),本机没有该目录,留待接入真实
|
||||
语料的机器上执行。
|
||||
- 本版本仅完成开发基础设施与验证,未构建正式发行文件:没有产出真实签名的
|
||||
dmg、没有重新构建 Windows 安装包,也没有重新构建 Docker 镜像;下一步是统一
|
||||
DOCX 发布门禁的产出目录规范(版本号动态化,不再写死)并新增清理脚本,随后
|
||||
评估四套 140 门禁矩阵的并行执行策略。
|
||||
|
||||
## v0.6.2 DOCX 发布门禁结论
|
||||
|
||||
- 原整批 560 已停用,有效门禁由四套相互独立的 140 组成,未启动、恢复或重建旧 560 流程。
|
||||
- 标准合成基线和长庆真实文档严格执行完成,合计 `280/280`。M4N 复杂表格文档完成 `140/140`;其后仅比较器文本映射变更,应用渲染与 DOCX 字节链未改变。
|
||||
- 健康数据长文档在 115 个已完成样本中,通用文本修复将残余门禁误报收敛为 6 个,比例 `5.22%`,基础设施错误为 0。
|
||||
- 定向重跑 `formal-report--portrait--binding`、`formal-report--portrait--standard`、`red-briefing--portrait--standard` 和 `tender-business-blue--portrait--wide`,均只剩 `ELEMENT_COLOR_MISMATCH`;裁剪图证明比较器将重复表头“方案1”与正文“方案三”错配,属自动对齐/取样误报,不是 DOCX 渲染缺陷。
|
||||
- 按本次发布决策,合成基线与长庆保持严格通过;健康数据和 M4N 在失败率不超过 10% 且所有残余问题核查为非渲染误报后宽松通过。四套发布门禁已结案,进入 v0.6.2 统一验证与发行构建。
|
||||
- v0.6.2 全项目测试、类型检查、生产构建及 `git diff --check` 已通过。
|
||||
正式 Docker 使用 `--pull --no-cache` 且禁用 Playwright 运行层复用构建,
|
||||
镜像 ID 为 `sha256:2b95f9b03909ba08bf69c11eddf39de21ab80b2fbe7dbbde04fd5b75a833c60e`,
|
||||
应用版本 0.6.2、Pandoc 3.9.0.2、固定 Chromium 和三套内置字体均已核验。
|
||||
- `release/v0.6.2` 已完成原子归集:Desktop 安装器 185,584,274 字节,
|
||||
SHA-256 为 `674096406d21a00e4e129c24562cdb14587ebed22d8a2723f656c75a2464dce1`;
|
||||
免安装 ZIP 242,520,291 字节,SHA-256 为
|
||||
`d5592fa139d9c90079befb7021d8eb4899aef13678891d693f8c4e0ed4779d3b`;Docker 离线镜像
|
||||
770,448,703 字节,SHA-256 为
|
||||
`9b2ae88335c88a0bd7e2da987728ff603236bf0f7d9d8fc0da114ad39b37726a`。
|
||||
|
||||
## 1. 当前概况
|
||||
|
||||
@@ -68,6 +114,22 @@ Python;左侧编辑器计划升级为 CodeMirror 6,并通过项目自有命
|
||||
支持基础工具栏及后续 Front Matter、ECharts YAML 工具扩展。完整设计与
|
||||
验收门禁见 [v0.6.0 设计文档](V0.6.0_DESIGN.md)。
|
||||
|
||||
`v0.6.1` 已冻结为 DOCX 视觉一致性和发布门禁修复版本。发布后复核确认
|
||||
`v0.6.0` 的 14 主题矩阵主要验证 CSS 槽位、OOXML 结构和样式存在性,
|
||||
六个视觉场景只覆盖五套主题;视觉报告中的像素、墨迹 IoU 和边缘 IoU
|
||||
failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过。`v0.6.1`
|
||||
要求四套独立封面执行 Chromium、Word、WPS 整页硬门禁,正文改为跨页
|
||||
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
|
||||
建立阻断验收。完整范围见 [v0.6.1 设计文档](V0.6.1_DESIGN.md)。
|
||||
|
||||
`v0.6.1` 最终视觉矩阵已完成:14 套内置主题 × 纵横两个方向 × 主题默认、
|
||||
标准、紧凑、宽松、非对称装订五组页边距共 140 个场景全部通过,阻断失败
|
||||
和诊断失败均为 0。修复集中在通用 CSS 到 OOXML 翻译、字体嵌入、中文
|
||||
标点字距、精确行距、打印媒体和行内代码连续性,没有引入按主题 ID 分支。
|
||||
同一 `red-briefing` 已通过源码服务、Docker Web API 和实际安装 Desktop
|
||||
三条生产链,三份 DOCX 均约 24.17 MB、包含 5 个字体部件;安装版产物经
|
||||
真实 Word/WPS 渲染均为 2 页 A4,封面和正文逐页复核通过。
|
||||
|
||||
`v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方
|
||||
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
|
||||
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
|
||||
@@ -1460,9 +1522,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
Server HTTP、Chromium PDF 与生产 DOCX 矩阵;
|
||||
- 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体
|
||||
Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁;
|
||||
- 阶段 12D-R4 布局视觉矩阵:已完成六组主题、纸张、方向与页边距组合的
|
||||
Chromium/Word/WPS 元素级严格门禁,并重跑 14 套主题生产矩阵;分页流
|
||||
差异保留为诊断项,不再误判为样式失败;
|
||||
- 阶段 12D-R4 布局视觉矩阵:曾记录为完成六组 Chromium/Word/WPS
|
||||
元素级严格门禁;`v0.6.1` 发布后复核确认该矩阵只覆盖五套主题,且外层
|
||||
过滤了像素、墨迹 IoU 和边缘 IoU failure,因此不构成有效严格门禁;
|
||||
- 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、
|
||||
文件关联兼容和旧版用户数据迁移;
|
||||
- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载
|
||||
@@ -1473,10 +1535,75 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS;
|
||||
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
||||
|
||||
### 阶段二:v0.6.1 DOCX 视觉一致性修复
|
||||
|
||||
- 阶段 A:已完成。修复所有 failure 的发布阻断聚合,补齐四套独立封面
|
||||
场景,并禁止正文回流封面页;四套封面真实 Word/WPS 复验均已被现有
|
||||
视觉差异正确阻断;
|
||||
- 阶段 B:已完成。正文段落按内容顺序映射为与物理页号无关的视觉块,
|
||||
保留实际行坐标和 PDF.js 字体分类诊断,逐行裁剪局部栅格并输出叠加图、
|
||||
热力图;正文整页栅格降为诊断,封面仍为整页硬门禁。单元测试 47 项、
|
||||
全项目类型检查和生产构建通过;真实 GitHub 紧凑 Letter 纵向场景观测
|
||||
22 个正文块、输出 102 张失败局部热力图并正确阻断。PDF.js 泛化字体名
|
||||
不作为字体硬门禁,真实字体解析留在阶段 D;
|
||||
- 阶段 C:已完成。矩阵生成器从 14 套内置主题清单生成纵横两个方向 ×
|
||||
主题默认、标准、紧凑、宽松、非对称装订五组页边距,共 140 个唯一
|
||||
排版场景;主题默认链路保持 `marginMode: theme`,即使与显式数值相同
|
||||
也不去重。机器报告记录定义、执行完整度以及主题、方向、边距分层汇总,
|
||||
HTML 报告固定显示 14 行 × 10 列并链接三份引擎详细报告。5 项矩阵结构
|
||||
测试通过;真实 `red-briefing--portrait--theme-default` 验证解析为主题
|
||||
默认 25mm 四边距,14 个正文块中 Word 有 13 个失败、字体部件仍为 0,
|
||||
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
|
||||
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
|
||||
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
|
||||
- 阶段 D:已完成。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX
|
||||
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
|
||||
`mdtp-sans-sc@1.0.0` 和 `mdtp-mono@1.0.0`,与
|
||||
`mdtp-serif-sc@1.0.0` 组成确定性三字体包。D3 重建四套独立封面整页
|
||||
门禁,40 个封面方向/边距组合全部通过。D4 完成正文语义块视觉收口:
|
||||
通用翻译层统一处理字体、中文标点字距、精确行距、段落、列表、表格、
|
||||
引用、代码块和行内代码连续性;最终 140 个 Chromium、Word、WPS
|
||||
真实场景全部执行并通过,阻断失败和诊断失败均为 0;
|
||||
- 阶段 E:已完成。源码服务、正式 Docker Web API 和实际安装 Desktop
|
||||
使用同一 `red-briefing` 复验,三份 DOCX 均约 24.17 MB、包含 5 个字体
|
||||
部件和 40,731,432 字节嵌入字体;安装版产物经 Word/WPS 渲染均为 2 页
|
||||
A4,逐页视觉通过;
|
||||
- 阶段 F:已完成最终发行重构。Desktop 的 Serif、Sans、Mono 三套字体包
|
||||
直接内置于安装器和免安装 ZIP,NSIS 不再显示字体选择页,正式发行不再
|
||||
拆分字体安装器或字体 ZIP。正式安装包为 185,578,422 字节,免安装 ZIP
|
||||
为 242,513,118 字节;安装版默认媒体示例真实导出 10,931,974 字节的
|
||||
DOCX,包含 7 个字体部件和 12 个媒体部件。Docker 使用 `--pull`、
|
||||
`--no-cache` 且禁用运行层复用的完整路径正式构建,镜像内容 ID 为
|
||||
`sha256:f81dbce5857a0a5496851e149936d1fd7da52b76c035187be1079fa1fd5a4717`。
|
||||
`release/v0.6.1` 只归集 Desktop 安装器与 ZIP、Docker 离线镜像、Web
|
||||
部署包和发行说明五个主附件,并生成机器清单与包含六项的 SHA-256 校验
|
||||
文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1`
|
||||
标签统一指向最终发行状态。
|
||||
|
||||
### 阶段三:v0.6.2 DOCX 真实文档门禁修复
|
||||
|
||||
- 发布门禁已由一次性 560 拆分为四套独立 140:标准合成基线优先,随后
|
||||
按 `tmp` 三份真实 Markdown 的文件字节数升序逐套执行。每套独立保存
|
||||
`progress.json`、`aggregate.json` 和 HTML 报告;旧 560 后台流程停用;
|
||||
- 标准合成基线已加入表格内 `<br>`、`<br/>`、`<br />` 与行内代码
|
||||
`` `<br>` `` 的隔离语义检查,并覆盖全 JSON 围栏缩进、行内代码连续性、
|
||||
表格末行对齐和代码高亮翻译;
|
||||
- 通用表格翻译链现会在分页前冻结完整自动布局列轨,将稳定元数据复制到
|
||||
Paged.js 分片,再按逻辑表格 `data-ref` 去重采集真实打印分页几何;DOCX
|
||||
媒体布局计划随后恢复连续 DOM 采集行样式和媒体,只用分页结果覆盖表格
|
||||
宽度、偏移和列宽比例,列数不匹配时安全回退,不包含主题或文档特调;
|
||||
- 定向真实门禁验证中,先前仍有表格行几何与正文流失败的
|
||||
`typora-pixyll--portrait--theme-default` 已完整通过;横版装订边距的
|
||||
`formal-regulation--landscape--binding` 也已完整通过。Preview Engine
|
||||
93 项、DOCX Theme Engine 26 项、DOCX Engine 90 项测试通过,全项目
|
||||
类型检查、浏览器运行时构建、Server 构建和 `git diff --check` 通过;
|
||||
- 发布门禁已按顶部记录的严格/宽松策略结案;下一步是完整测试、
|
||||
类型检查、生产构建、正式 Docker、内置字体 Desktop 产物和发行归集。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
后续阶段的功能实现。
|
||||
|
||||
### 阶段二:后续版本扩展
|
||||
### 阶段四:后续版本扩展
|
||||
|
||||
- Front Matter 可视化编辑工具;
|
||||
- ECharts YAML 可视化配置工具;
|
||||
|
||||
+18
-5
@@ -1,5 +1,16 @@
|
||||
# 发版规则
|
||||
|
||||
## 变更日志与发布说明
|
||||
|
||||
根目录 [`CHANGELOG.md`](../CHANGELOG.md) 是版本变更摘要的单一事实来源。
|
||||
每项面向用户或部署方的重要新增、修复、行为变化和兼容性变化应先记录在
|
||||
`Unreleased`;冻结版本时将相关内容移动到带发布日期的版本章节。
|
||||
|
||||
Gitea Release 页面正文应以 `CHANGELOG.md` 对应版本章节为基础生成,可按发布
|
||||
场景补充下载提示,但不得与变更日志中的功能、兼容性和验证结论冲突。
|
||||
`docs/releases/v<version>.md` 用于保存更详细的发布门禁、验证结论和内部发行说明,
|
||||
不单独维护另一份相互矛盾的版本摘要。
|
||||
|
||||
## 唯一产物目录
|
||||
|
||||
所有正式分发文件必须统一归集到仓库根目录下的:
|
||||
@@ -24,8 +35,6 @@ release/v<semver>/
|
||||
release/v<version>/
|
||||
├── MorphDoc-<version>-x86_64-Setup.exe
|
||||
├── MorphDoc-<version>-x86_64.zip
|
||||
├── MorphDoc-FontPack-<id>-<font-version>-x86_64-Setup.exe
|
||||
├── MorphDoc-FontPack-<id>-<font-version>.zip
|
||||
├── MorphDoc-Web-<version>-image.tar.gz
|
||||
├── MorphDoc-Web-<version>-deploy.zip
|
||||
├── manifest.json
|
||||
@@ -54,9 +63,10 @@ TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前
|
||||
## 发版步骤
|
||||
|
||||
1. 完成全量测试、类型检查、生产构建和视觉门禁。
|
||||
2. 构建 Desktop 安装包、便携 ZIP 和字体附件。
|
||||
2. 构建字体直接内置的 Desktop 安装包和便携 ZIP。
|
||||
3. 构建并验证版本化 Docker 镜像。
|
||||
4. 准备 `docs/releases/v<version>.md`。
|
||||
4. 将 `CHANGELOG.md` 中本版本变更从 `Unreleased` 冻结为带日期的版本章节,
|
||||
并以该章节为基础准备 `docs/releases/v<version>.md` 与 Gitea Release 正文。
|
||||
5. 执行:
|
||||
|
||||
```powershell
|
||||
@@ -65,7 +75,10 @@ TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前
|
||||
|
||||
6. 逐项核验 `SHA256SUMS.txt`,并从离线归档重新加载 Docker 镜像。
|
||||
7. 创建内容完整的 `release:` 提交。
|
||||
8. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
|
||||
8. 重新生成 release 清单,确保 `gitCommit` 指向该发布提交。
|
||||
9. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
|
||||
10. 发布 Gitea Release,正文以 `CHANGELOG.md` 对应版本章节为基础,并附上
|
||||
本轮正式分发文件。
|
||||
|
||||
安装器公开分发前应完成 Authenticode 签名;未签名产物只能按内部发布风险
|
||||
规则分发。
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# v0.6.1 DOCX 视觉一致性与发布门禁修复设计
|
||||
|
||||
状态:范围已冻结,阶段 A、B 已完成,等待阶段 C。
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
`v0.6.1` 是针对 `v0.6.0` DOCX 导出质量的维护版本,不新增与 DOCX
|
||||
无关的产品功能。本版本必须完成:
|
||||
|
||||
1. 修复视觉报告失败但总发布门禁仍通过的判定漏洞;
|
||||
2. 对四套独立封面主题建立 Chromium、Word、WPS 整页严格门禁;
|
||||
3. 对 14 套内置主题建立与正文分页位置无关的语义块局部视觉门禁;
|
||||
4. 修复主题样式、封面布局和字体解析、嵌入、回退问题;
|
||||
5. 对源码服务、Docker Web API 和 Desktop 安装版执行发布后复验。
|
||||
|
||||
本版本不要求 Chromium、Word 和 WPS 的正文总页数或分页边界完全一致,
|
||||
但不允许以排版引擎不同为理由放过封面、字体或语义块样式退化。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["同一 Markdown、主题与配置"] --> B["生产 Chromium PDF"]
|
||||
A --> C["生产 DOCX"]
|
||||
C --> D["Word 原生渲染"]
|
||||
C --> E["WPS 原生渲染"]
|
||||
B --> F["封面整页严格比较"]
|
||||
D --> F
|
||||
E --> F
|
||||
B --> G["正文语义块匹配"]
|
||||
D --> G
|
||||
E --> G
|
||||
F --> H["发布硬门禁"]
|
||||
G --> H
|
||||
```
|
||||
|
||||
## 2. 问题基线
|
||||
|
||||
`v0.6.0` 复核确认存在以下门禁缺口:
|
||||
|
||||
- 视觉报告中的像素差异、墨迹 IoU、边缘 IoU 等 failure 级问题未进入
|
||||
外层发布阻断集合;
|
||||
- 六个布局视觉场景只覆盖五套主题,未覆盖全部 14 套主题;
|
||||
- 四套独立封面只覆盖其中两套;
|
||||
- “独立封面”主要检查 OOXML 分节、页眉页脚、页码重启和字段存在,
|
||||
不能代表视觉一致;
|
||||
- 14 主题样式门禁主要检查 CSS 槽位、令牌和 Word 样式 ID 存在,不能
|
||||
代表最终 Word/WPS 呈现一致;
|
||||
- 字体名称写入 `fontTable.xml`、转换无 warning,均不能证明字体已经解析、
|
||||
嵌入并被 Office 实际使用;
|
||||
- `red-briefing` 在门禁、Docker Web 和用户实际产物中均为零嵌入字体,
|
||||
仍以零 warning 通过。
|
||||
|
||||
## 3. 验收模型
|
||||
|
||||
### 3.1 封面整页硬门禁
|
||||
|
||||
适用于 `formal-feasibility`、`tender-business-blue`、`tender-blind` 和
|
||||
`tender-classic`:
|
||||
|
||||
- 封面必须独占一个物理页;
|
||||
- 所有封面字段只能出现在封面页,正文不得回流到封面页;
|
||||
- 封面页不显示正文页眉、页脚和页码;
|
||||
- 字体、字号、字重、颜色、对齐、位置、边框、背景和装饰线必须匹配;
|
||||
- 整页像素、墨迹 IoU、边缘 IoU 任一 failure 都阻断发布;
|
||||
- Word 和 WPS 都必须通过,不允许只以二者相互接近替代 Chromium 基线。
|
||||
|
||||
### 3.2 正文语义块门禁
|
||||
|
||||
正文按稳定内容顺序和语义角色匹配,不按物理页号配对。允许语义块移动到
|
||||
另一页,但块自身必须保持视觉一致:
|
||||
|
||||
| 语义块 | 必须比较 |
|
||||
| --- | --- |
|
||||
| 标题、正文 | 字体、字号、字重、颜色、行高、缩进、段距、对齐 |
|
||||
| 引用、提示框 | 背景、边框、内边距、文字样式 |
|
||||
| 代码块 | 等宽字体、字号、背景、边框、内边距、换行 |
|
||||
| 列表 | 编号或项目符号、缩进、悬挂距离、文字样式 |
|
||||
| 表格 | 总宽度、列宽、边框、底纹、单元格内边距、表头与文字样式 |
|
||||
| 图片与图表 | 尺寸、宽高比、清晰度、对齐和题注 |
|
||||
|
||||
正文整页栅格差异只作为诊断,不作为最终样式结论;没有完成语义块局部比较
|
||||
的主题不得标记为视觉通过。
|
||||
|
||||
### 3.3 字体硬门禁
|
||||
|
||||
- 记录 Chromium、DOCX 字体表、嵌入字体部件、Word PDF 和 WPS PDF 的
|
||||
实际字体;
|
||||
- 需要跨环境稳定的字体必须来自许可证允许分发的字体包;
|
||||
- 应嵌入字体但字体部件为零时直接失败;
|
||||
- 字体包未应用、字体回退或 Office 未使用目标字体时必须产生阻断错误;
|
||||
- 字体名称存在但没有字体二进制不得视为通过。
|
||||
|
||||
### 3.4 发布链路门禁
|
||||
|
||||
同一验收输入必须覆盖:
|
||||
|
||||
1. 源码测试服务;
|
||||
2. 正式 Docker Web API;
|
||||
3. 正式 Desktop 安装版。
|
||||
|
||||
任一底层报告状态为 `failed` 时,总门禁必须失败。只有已经证明属于合法
|
||||
正文自然分页流动的问题才能降级为 warning;封面、字体、内容、语义块
|
||||
样式和媒体失败不得降级。
|
||||
|
||||
### 3.5 全量配置矩阵
|
||||
|
||||
发布门禁不是少量代表用例,而是固定执行 `14 × 2 × 5 = 140` 个排版
|
||||
场景:
|
||||
|
||||
- 14 套内置主题;
|
||||
- 纵向、横向两个纸张方向;
|
||||
- 主题默认、标准、紧凑、宽松和非对称装订五组页边距。
|
||||
|
||||
“主题默认”必须使用 `marginMode: theme`,验证主题清单中的真实默认值;
|
||||
即使解析后的数值与某组显式边距相同,也不得去重。其余四组使用显式
|
||||
`custom` 配置,验证配置覆盖链路。每个场景生成 Chromium PDF、DOCX、
|
||||
Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导出链和
|
||||
渲染引擎维度,不允许用汇总绿灯掩盖单个失败。
|
||||
|
||||
场景 ID 固定为 `<theme-id>--<orientation>--<margin-scenario>`。总报告同时
|
||||
输出机器可读 JSON 和 14 行 × 10 列 HTML 矩阵;每个单元格必须显示通过、
|
||||
失败或未执行,并链接到 Chromium/Word、Chromium/WPS、Word/WPS 三份
|
||||
详细报告。完整门禁必须执行 140 个场景后才允许标记 `complete: true`;
|
||||
单场景筛选只用于开发复现,不能冒充全矩阵结果。
|
||||
|
||||
## 4. 实施阶段
|
||||
|
||||
1. 阶段 A:修复 failure 聚合策略,补齐四套封面场景和封面正文隔离;
|
||||
2. 阶段 B:建立跨页无关的语义块观察、匹配、局部栅格和样式比较协议;
|
||||
3. 阶段 C:建立 14 主题、2 个方向、5 组页边距的 140 场景全量语义块
|
||||
矩阵和版本化门限;
|
||||
4. 阶段 D:逐主题修复封面、正文块样式和字体嵌入;
|
||||
5. 阶段 E:完成源码、Docker Web、Desktop 安装版发布后复验;
|
||||
6. 阶段 F:更新版本、发行说明、发布产物、发布提交和 annotated tag。
|
||||
|
||||
每个阶段必须先提交真实失败基线,再修复到通过。不得通过删除用例、提高
|
||||
门限、过滤 failure 或把 failure 改名为 warning 取得绿灯。
|
||||
|
||||
## 5. v0.6.1 发布条件
|
||||
|
||||
- 14 套主题在纵向、横向和五组页边距下全部生成真实 Chromium PDF、
|
||||
DOCX、Word PDF 和 WPS PDF;
|
||||
- 四套独立封面全部通过整页严格门禁;
|
||||
- 14 套主题声明支持的语义块全部通过局部视觉门禁;
|
||||
- 字体解析、嵌入和实际使用结果符合主题预期;
|
||||
- 源码、Docker Web 和 Desktop 安装版结果一致;
|
||||
- 单元测试、类型检查、生产构建和 `git diff --check` 通过;
|
||||
- 发布报告中不存在被忽略的 failure;
|
||||
- 完成真实 Word/WPS 逐页人工复核后才能发布。
|
||||
|
||||
## 6. 最终验收结果
|
||||
|
||||
2026-08-04 已完成全部发布条件:
|
||||
|
||||
- `14 × 2 × 5 = 140` 个场景全部实际执行,矩阵报告为
|
||||
`complete: true`、`gatePassed: true`,阻断失败和诊断失败均为 0;
|
||||
- 四套独立封面共 40 个方向/边距组合全部通过 Chromium 对 Word/WPS
|
||||
整页严格门禁;其余页面按跨页无关的语义块完成视觉比较;
|
||||
- 通用 CSS 到 OOXML 翻译已覆盖字体、字距、精确行距、段落、列表、表格、
|
||||
引用、代码和行内代码连续性,全程未增加按主题 ID 分支;
|
||||
- Serif、Sans、Mono 三套可分发字体进入同一字体包解析链,140 个场景的
|
||||
DOCX 均包含 5 个字体部件,Word/WPS 实际字体使用门禁通过;
|
||||
- `red-briefing` 已分别从源码服务、正式 Docker Web API 和实际安装的
|
||||
Desktop 导出;三份 DOCX 均约 24.17 MB、含 5 个字体部件,并通过真实
|
||||
Word/WPS 渲染复核;
|
||||
- 根级测试、类型检查、生产构建和空白检查通过;Docker 镜像、Desktop
|
||||
安装器及免安装 ZIP 已生成。
|
||||
|
||||
最终矩阵报告位于 `output/docx-layout-visual-matrix/summary.json`,发行说明
|
||||
见 [`docs/releases/v0.6.1.md`](releases/v0.6.1.md)。
|
||||
@@ -0,0 +1,55 @@
|
||||
# MorphDoc · 墨呈 v0.6.1
|
||||
|
||||
## 核心修复
|
||||
|
||||
- 重构通用 CSS 到 WordprocessingML 翻译链,统一处理字体族、东亚字体、
|
||||
字号、行距、段距、缩进、边框、底纹、表格和代码样式,不包含按主题 ID
|
||||
分支或单主题补丁。
|
||||
- 新增可分发的 `mdtp-mono` 等宽字体,并将 Serif、Sans、Mono 三套字体包
|
||||
同源用于 Chromium 预览/PDF 与 DOCX 静态嵌入;字体门禁同时检查字体表、
|
||||
嵌入部件和 Word/WPS 的实际使用结果。
|
||||
- 修复中文标点受 Word 自动字距调整造成的行宽漂移,并按浏览器实测值写入
|
||||
Word 行距和网格参数。
|
||||
- 修复 Markdown 行内代码在普通段落、列表和表格中被错误拆成独立段落的
|
||||
问题;行内代码现在保持原段落连续排版,并进入视觉门禁。
|
||||
- 统一 DOCX 媒体捕获的打印媒体环境,避免开发预览与正式 Web/Desktop
|
||||
导出采用不同 CSS 分支。
|
||||
|
||||
## 视觉门禁
|
||||
|
||||
- 发布矩阵固定为 14 套内置主题 × 纵横两个方向 × 主题默认、标准、紧凑、
|
||||
宽松、非对称装订五组页边距,共 140 个真实场景。
|
||||
- 四套独立封面使用 Chromium 对 Word/WPS 的整页严格门禁;封面分页、
|
||||
字体、位置、边框、背景、装饰和页码任一失败都会阻断发布。
|
||||
- 正文按内容顺序和语义角色比较标题、段落、引用、列表、代码、表格及媒体,
|
||||
允许流式分页造成物理页号变化,但不允许元素块视觉样式丢失。
|
||||
- 外层聚合不再过滤像素、墨迹 IoU、边缘 IoU 或字体 failure;只有执行满
|
||||
140 个场景且阻断失败为零时,报告才允许标记完整通过。
|
||||
|
||||
## 验证结论
|
||||
|
||||
- 全量矩阵 `140/140` 执行完成并通过,阻断失败和诊断失败均为 0。
|
||||
- 同一 `red-briefing` 输入已通过源码服务、正式 Docker Web API 和实际
|
||||
安装的 Desktop 三条生产链;三份 DOCX 均约 24.17 MB,包含 5 个字体
|
||||
部件,嵌入字体字节数为 40,731,432。
|
||||
- 已安装 Desktop 产物经真实 Word 和 WPS 渲染均为 2 页 A4,独立封面及
|
||||
正文逐页复核通过。
|
||||
- 正式安装包覆盖安装后,默认 10 页媒体示例从实际 Desktop 窗口导出
|
||||
10,931,974 字节的 DOCX,包含 7 个字体部件和 12 个媒体部件;字体部件
|
||||
解压后共 11,190,728 字节,确认安装版直接使用随程序内置的字体资源。
|
||||
- Docker 镜像内置三套字体包与 Pandoc 3.9.0.2;修复复用运行层时 Pandoc
|
||||
软链接已存在导致构建失败的问题,并通过根级 `.dockerignore` 固定精简
|
||||
构建上下文。正式镜像已使用 `--pull --no-cache` 和禁用运行层复用的完整
|
||||
路径重新构建并通过生产门禁。
|
||||
- 全项目测试、类型检查、生产构建及 `git diff --check` 通过。
|
||||
|
||||
## 发行与兼容性
|
||||
|
||||
- Desktop 安装器和免安装 ZIP 版本均为 0.6.1;支持覆盖 v0.6.0 安装。
|
||||
- Serif、Sans、Mono 三套字体直接内置于 Desktop 安装器和免安装 ZIP,
|
||||
不写入系统字体目录;安装过程不再显示字体选择,也不再发布独立字体附件。
|
||||
- Docker 发布镜像为 `md-to-pdf:v0.6.1`,内容 ID 为
|
||||
`sha256:f81dbce5857a0a5496851e149936d1fd7da52b76c035187be1079fa1fd5a4717`。
|
||||
- DOCX 保持原生可编辑段落、Run、列表和表格结构,没有以整页图片替代正文。
|
||||
- 当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布;
|
||||
公开网络分发前建议增加代码签名。
|
||||
@@ -0,0 +1,34 @@
|
||||
# MorphDoc · 墨呈 v0.6.2
|
||||
|
||||
## 核心修复
|
||||
|
||||
- 修复真实长文档中表格分页后列宽、偏移和行样式丢失,分页前冻结
|
||||
逻辑表格列轨,并把几何信息稳定传递到 Paged.js 分片。
|
||||
- 修复 Markdown 表格中 `<br>` 变体、连续换行、实体和行内代码的
|
||||
DOCX 翻译,换行仅生成在目标单元格段落内。
|
||||
- 修复全 JSON 围栏缩进、表格末行对齐、代码高亮和行内代码连续性,
|
||||
保持 Chromium/Paged.js、Markdown 渲染与 DOCX 引擎的通用链路。
|
||||
- 改进文本视觉比对的 PDF 文本流排序和数值范围归一化,消除窄字符
|
||||
重叠和跨视觉行破折号造成的伪语义缺失。
|
||||
|
||||
## 发布门禁
|
||||
|
||||
- 标准合成基线与长庆真实文档执行两套独立 140,严格结果为
|
||||
`280/280`。
|
||||
- M4N 复杂表格文档完成 `140/140`;后续变更仅影响验收比较器,
|
||||
不改变应用渲染或 DOCX 字节。
|
||||
- 健康数据长文档在 115 个已完成样本中,通用文本修复后剩余 6 个
|
||||
视觉门禁误报,比例为 `5.22%`。定向重跑证明均为重复表头与正文
|
||||
的自动对齐/取样错配,并非文档渲染缺陷;基础设施错误为 0。
|
||||
- 按本次发布决策,合成基线与长庆严格通过,健康数据与 M4N
|
||||
在失败率不超过 10% 且残余问题经核查为非渲染误报时宽松通过。
|
||||
|
||||
## 发行与兼容性
|
||||
|
||||
- Desktop 安装器和免安装 ZIP 版本统一为 0.6.2,Serif、Sans、Mono
|
||||
字体继续直接内置,无需安装系统字体。
|
||||
- Docker 镜像为 `md-to-pdf:v0.6.2`,内置固定 Chromium、Pandoc 3.9.0.2
|
||||
和受校验字体包。
|
||||
- DOCX 继续保持原生可编辑段落、Run、列表和表格结构,未将正文替换为
|
||||
整页图片。
|
||||
- Windows 安装器仍未进行 Authenticode 签名,适用于公司内部发布。
|
||||
+37
-65
@@ -1,9 +1,9 @@
|
||||
# 可选字体包构建
|
||||
# 内置字体包集合构建
|
||||
|
||||
本目录保存字体包配方、许可证、来源说明和正式冻结字体资产。字体二进制
|
||||
位于固定的 `font-packs/assets/`,统一由 Git LFS 管理,普通 Git 对象只保存
|
||||
LFS 指针。普通 Desktop 主安装包不包含字体;组合 Desktop 发行把字体作为
|
||||
同目录可选附件,Docker 发行构建则把本次受校验的字体包写入镜像层。
|
||||
LFS 指针。Desktop 安装版、便携版和 Docker 镜像都直接包含本次受校验的
|
||||
完整字体包集合,不再依赖用户另行安装字体附件。
|
||||
|
||||
## 源目录
|
||||
|
||||
@@ -16,10 +16,24 @@ font-packs/assets/mdtp-serif-sc/1.0.0/
|
||||
├── MdTPSerifSC-Regular.woff2
|
||||
├── MdTPSerifSC-Bold.ttf
|
||||
└── MdTPSerifSC-Bold.woff2
|
||||
|
||||
font-packs/assets/mdtp-sans-sc/1.0.0/
|
||||
├── MdTPSansSC-Regular.ttf
|
||||
├── MdTPSansSC-Regular.woff2
|
||||
├── MdTPSansSC-Bold.ttf
|
||||
└── MdTPSansSC-Bold.woff2
|
||||
|
||||
font-packs/assets/mdtp-mono/1.0.0/
|
||||
├── MdTPMono-Regular.ttf
|
||||
├── MdTPMono-Regular.woff2
|
||||
├── MdTPMono-Bold.ttf
|
||||
└── MdTPMono-Bold.woff2
|
||||
```
|
||||
|
||||
该目录中的 `ttf/otf/woff/woff2` 由 `.gitattributes` 强制使用 Git LFS。
|
||||
构建器不会联网下载、转换或自动替换字体。每个输入文件都必须与配方声明的
|
||||
构建器不会联网下载或自动替换字体。派生字体通过
|
||||
`font-packs/scripts/build-noto-static.py` 从来源说明固定的 Noto CJK
|
||||
可变字体生成;每个发行输入文件都必须与配方声明的
|
||||
字节数和 SHA-256 完全一致;TTF 还必须是无需运行时规范化的原生静态
|
||||
TrueType,并具有可安装或可编辑的文档嵌入权限。
|
||||
|
||||
@@ -30,17 +44,13 @@ npm run build:font-pack
|
||||
npm run verify:font-pack
|
||||
```
|
||||
|
||||
也可以显式指定源目录:
|
||||
|
||||
```powershell
|
||||
npm run build:font-pack -- --source-root D:/font-pack-sources/mdtp-serif-sc/1.0.0
|
||||
```
|
||||
|
||||
克隆仓库后应先执行 `git lfs pull`。缺少 LFS 内容时,字体构建会因文件大小
|
||||
或 SHA-256 不匹配而拒绝继续,不允许使用系统字体或相近字体静默替代。
|
||||
|
||||
默认运行时字体包根目录为 `output/font-packs/root`,构建报告写入
|
||||
`output/font-packs/build-report.json`,两者均被 Git 忽略。输出布局为:
|
||||
受版本控制的 `font-packs/bundle.json` 是正式字体包集合清单。构建命令会
|
||||
逐包构建、验证,并拒绝 `output/font-packs/root` 中出现清单外字体包,
|
||||
避免本地残留进入发行产物。各包报告写入
|
||||
`output/font-packs/<pack-id>-build-report.json`。输出布局为:
|
||||
|
||||
```text
|
||||
root/<pack-id>/<semver>/font-pack.json
|
||||
@@ -53,67 +63,29 @@ root/<pack-id>/<semver>/font-pack.json
|
||||
`Mdpdf Fandol Song` 的 400/700 字重,不匹配 `SimSun` 等通用别名,避免
|
||||
错误替换仿宋、黑体、楷体或普通外部主题字体。
|
||||
|
||||
## Windows 独立安装器
|
||||
`mdtp-sans-sc@1.0.0` 提供 `MdTP Sans SC`、`FandolHei`、
|
||||
`Microsoft YaHei`、`PingFang SC`、`Noto Sans CJK SC` 和
|
||||
`Source Han Sans SC` 的 400/700 字重映射。主题仍需明确声明所需字体面,
|
||||
注册器不会对未声明字体进行静默替换。
|
||||
|
||||
发布人员可以使用 electron-builder 锁定并校验的 NSIS 工具链构建独立
|
||||
字体包安装器,不需要额外安装系统 NSIS:
|
||||
`mdtp-mono@1.0.0` 提供内部字族 `MdTP Mono`,并覆盖主题代码样式常用的
|
||||
Cascadia、Consolas、SFMono、Menlo、Monaco 与 Courier 等候选名称。
|
||||
应用层将它作为代码语义槽位的引擎级可选能力统一注入,因此新主题无需
|
||||
增加主题 ID 分支;字体包缺失时仍保留主题原有候选链并输出缺失诊断。
|
||||
|
||||
```powershell
|
||||
npm run build:font-pack:windows
|
||||
```
|
||||
|
||||
默认产物为:
|
||||
|
||||
```text
|
||||
output/font-packs/windows/
|
||||
└── MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe
|
||||
```
|
||||
|
||||
安装器使用当前用户权限,将整个 `mdtp-serif-sc` ID 安装到:
|
||||
|
||||
```text
|
||||
%LOCALAPPDATA%/md-to-pdf/font-packs/mdtp-serif-sc/1.0.0/
|
||||
```
|
||||
|
||||
它不向 Windows 系统字体目录注册字体。同一版本重装保持幂等,升级时先把
|
||||
该 ID 的现有目录移入备份,再原子提升新版本;失败会回滚。独立卸载项只
|
||||
删除安装器拥有的 `mdtp-serif-sc` ID,不影响其他字体包 ID。安装器支持
|
||||
`/S` 静默安装和卸载。
|
||||
|
||||
## Desktop 伴随发行
|
||||
## Desktop 内置发行
|
||||
|
||||
组合构建命令为:
|
||||
|
||||
```powershell
|
||||
npm run desktop:make:with-font-pack
|
||||
npm run desktop:make
|
||||
```
|
||||
|
||||
该命令把主安装器、字体包安装器和字体包 ZIP 作为同目录文件发行。主安装器
|
||||
只编译字体包安装器的文件名与 SHA-256,不嵌入字体二进制;仅当同目录文件
|
||||
存在且哈希完全一致时,交互安装向导才显示默认勾选的字体包选项。字体包
|
||||
失败不回滚已安装的主程序。
|
||||
|
||||
主安装器静默运行时默认不安装伴随字体包;只有显式传入以下参数才会安装:
|
||||
|
||||
```powershell
|
||||
MorphDoc-<version>-x86_64-Setup.exe /S /WITHFONTPACK=1
|
||||
```
|
||||
|
||||
普通 `npm run make -w @md-to-pdf/desktop` 不启用伴随逻辑,避免本地残留的
|
||||
生成文件影响常规桌面构建。
|
||||
|
||||
## ZIP 发行附件
|
||||
|
||||
独立生成可手动部署的确定性 ZIP:
|
||||
|
||||
```powershell
|
||||
npm run build:font-pack:archive
|
||||
```
|
||||
|
||||
产物为 `output/font-packs/release/MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip`。
|
||||
归档顶层已经包含 `mdtp-serif-sc/1.0.0/`,用户可直接解压到
|
||||
`%LOCALAPPDATA%/md-to-pdf/font-packs`。ZIP 内保留清单、许可证、来源说明
|
||||
以及 Web/DOCX 两类字体资产;相同输入会生成逐字节一致的附件。
|
||||
该命令先重建完整字体包集合,再构建主安装器和便携 ZIP。electron-builder
|
||||
把 `output/font-packs/root` 复制到 `resources/font-packs`;Desktop 生产
|
||||
运行时优先读取该只读内置根目录,再读取用户目录中的额外字体包。内置包
|
||||
因此不会被缺失、旧版或同 ID 用户包替换。NSIS 不安装系统字体、不显示
|
||||
字体选择页,正式发行也不再拆分独立字体安装器或字体 ZIP。
|
||||
|
||||
## Docker 内置发行
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packs": [
|
||||
{
|
||||
"id": "mdtp-serif-sc",
|
||||
"version": "1.0.0",
|
||||
"recipe": "font-packs/recipes/mdtp-serif-sc/recipe.json",
|
||||
"sourceRoot": "font-packs/assets/mdtp-serif-sc/1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "mdtp-sans-sc",
|
||||
"version": "1.0.0",
|
||||
"recipe": "font-packs/recipes/mdtp-sans-sc/recipe.json",
|
||||
"sourceRoot": "font-packs/assets/mdtp-sans-sc/1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "mdtp-mono",
|
||||
"version": "1.0.0",
|
||||
"recipe": "font-packs/recipes/mdtp-mono/recipe.json",
|
||||
"sourceRoot": "font-packs/assets/mdtp-mono/1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2019 - Present, Microsoft Corporation,
|
||||
with Reserved Font Name Cascadia Code.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to remain
|
||||
under this license does not apply to any document created using the Font
|
||||
Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not
|
||||
met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
# MdTP Mono 字体来源说明
|
||||
|
||||
本字体包的 Regular 400 与 Bold 700 字形面派生自 Microsoft Cascadia Mono
|
||||
可变字体。
|
||||
|
||||
- 上游项目:Cascadia Code
|
||||
- 上游仓库:https://github.com/microsoft/cascadia-code
|
||||
- 上游版本:`v2102.25`
|
||||
- 上游提交:`56bcca3f2c1e4cb19458954f0e2bb4635960df91`
|
||||
- 官方归档:`CascadiaCode-2102.25.zip`
|
||||
- 官方归档 SHA-256:`a64e8834f06ec9acca4a5307cb6cf35922021d4fd0ca19096f8bf6a3e2515ad3`
|
||||
- 上游源文件:`ttf/CascadiaMono.ttf`
|
||||
- 上游源 SHA-256:`bd893a00f500dc46ed181cd422a04c91d71720072f30a6dd369f0d9d26c54761`
|
||||
- 许可证:SIL Open Font License 1.1
|
||||
|
||||
发行字形面使用 fontTools 4.60.1 从 `wght` 轴固定生成,并将主字体家族改名
|
||||
为 `MdTP Mono`、PostScript 前缀改名为 `MdTPMono`。改名遵守上游保留
|
||||
字体名称要求,也避免派生字体占用系统 Cascadia 字族。字形轮廓与字符
|
||||
覆盖未做删减;`OS/2.fsType` 为 0,允许安装和文档嵌入。
|
||||
|
||||
可复现生成命令:
|
||||
|
||||
```powershell
|
||||
python font-packs/scripts/build-noto-static.py `
|
||||
--source .local/font-pack-upstream/cascadia-2102.25/ttf/CascadiaMono.ttf `
|
||||
--output font-packs/assets/mdtp-mono/1.0.0 `
|
||||
--family "MdTP Mono" `
|
||||
--postscript-family MdTPMono
|
||||
```
|
||||
|
||||
字体包构建器不从网络下载字体。发布人员必须先核对固定上游文件的
|
||||
SHA-256,再生成配方声明的四个冻结资源;构建器会继续校验文件大小、
|
||||
SHA-256、格式、字体内部家族、字重与嵌入权限。
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"recipeVersion": 1,
|
||||
"manifestVersion": 1,
|
||||
"id": "mdtp-mono",
|
||||
"version": "1.0.0",
|
||||
"name": "MdTP 确定性等宽字体包",
|
||||
"description": "为代码块与行内代码提供经过 Chromium、Word 和 WPS 验证的原生静态等宽 Regular 与 Bold 字形面。",
|
||||
"internalFamily": "MdTP Mono",
|
||||
"license": "OFL-1.1",
|
||||
"licenseSource": "OFL-CascadiaCode.txt",
|
||||
"licensePath": "OFL-CascadiaCode.txt",
|
||||
"licenseBytes": 4394,
|
||||
"licenseSha256": "d6e8a237280214b5938a0023484296e6c88de86d2eeb96d8d3844b285ec1bf7e",
|
||||
"noticeSource": "SOURCE.md",
|
||||
"noticePath": "SOURCE.md",
|
||||
"compatibility": {
|
||||
"minimumAppVersion": "0.6.0"
|
||||
},
|
||||
"faces": [
|
||||
{
|
||||
"id": "mono-regular",
|
||||
"targets": ["MdTP Mono", "Cascadia Mono", "Cascadia Code", "Consolas", "Lucida Console", "SFMono-Regular", "Menlo", "Monaco", "Courier New", "Courier", "Source Code Pro", "JetBrains Mono"],
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"web": {
|
||||
"source": "MdTPMono-Regular.woff2",
|
||||
"path": "fonts/MdTPMono-Regular.woff2",
|
||||
"bytes": 65332,
|
||||
"sha256": "434640b85dd8222a275b87b63ddc92811cd3d2662c304b81c37e20ed6d1db3df"
|
||||
},
|
||||
"docx": {
|
||||
"source": "MdTPMono-Regular.ttf",
|
||||
"path": "fonts/MdTPMono-Regular.ttf",
|
||||
"bytes": 217964,
|
||||
"sha256": "319d4ac0e5435220b813f87baa28e2ee21f8fa51ba216af0c5ecbc4739f03a07"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mono-bold",
|
||||
"targets": ["MdTP Mono", "Cascadia Mono", "Cascadia Code", "Consolas", "Lucida Console", "SFMono-Regular", "Menlo", "Monaco", "Courier New", "Courier", "Source Code Pro", "JetBrains Mono"],
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"web": {
|
||||
"source": "MdTPMono-Bold.woff2",
|
||||
"path": "fonts/MdTPMono-Bold.woff2",
|
||||
"bytes": 66812,
|
||||
"sha256": "03e898b3b1b50b0317d7b70d95fd76209e38cecdaca1db805bafe9740f5953c9"
|
||||
},
|
||||
"docx": {
|
||||
"source": "MdTPMono-Bold.ttf",
|
||||
"path": "fonts/MdTPMono-Bold.ttf",
|
||||
"bytes": 218576,
|
||||
"sha256": "90a071b13952ebf131df845e2c9b034a53dea0190c1470352dfedeeed95d1c9c"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright 2012 Google Inc. All Rights Reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,29 @@
|
||||
# MdTP Sans SC 字体来源说明
|
||||
|
||||
本字体包的 Regular 400 与 Bold 700 字形面派生自 Noto Sans SC 可变字体。
|
||||
|
||||
- 上游项目:Noto CJK
|
||||
- 上游仓库:<https://github.com/notofonts/noto-cjk>
|
||||
- Google Fonts 源提交:`985fa52c81c1d6692ccdd82bc3656e8fb932fd89`
|
||||
- 上游源文件:`google-fonts/NotoSansSC[wght].ttf`
|
||||
- 上游源 SHA-256:`b1736bb633392cfc59b50d97c353d234111bd1721c9c83eaf643eaab9392e7f9`
|
||||
- 许可证:SIL Open Font License 1.1
|
||||
|
||||
发行字形面使用 fontTools 4.60.1 从 `wght` 轴固定生成,并将主字体家族改名
|
||||
为 `MdTP Sans SC`、PostScript 前缀改名为 `MdTPSansSC`。改名用于明确
|
||||
标识派生版本,并避免派生字体占用上游字体名称。字形轮廓与字符覆盖未做
|
||||
删减;`OS/2.fsType` 为 0,允许安装和文档嵌入。
|
||||
|
||||
可复现生成命令:
|
||||
|
||||
```powershell
|
||||
python font-packs/scripts/build-noto-static.py `
|
||||
--source .local/font-pack-upstream/NotoSansSC-wght.ttf `
|
||||
--output font-packs/assets/mdtp-sans-sc/1.0.0 `
|
||||
--family "MdTP Sans SC" `
|
||||
--postscript-family MdTPSansSC
|
||||
```
|
||||
|
||||
字体包构建器不从网络下载字体。发布人员必须先核对固定上游文件的
|
||||
SHA-256,再生成配方声明的四个冻结资源;构建器会继续校验文件大小、
|
||||
SHA-256、格式、字体内部家族、字重与嵌入权限。
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"recipeVersion": 1,
|
||||
"manifestVersion": 1,
|
||||
"id": "mdtp-sans-sc",
|
||||
"version": "1.0.0",
|
||||
"name": "MdTP 中文黑体兼容包",
|
||||
"description": "为现代文档与商务主题提供经过 Word/WPS 验证的原生静态中文黑体 Regular 与 Bold 字形面。",
|
||||
"internalFamily": "MdTP Sans SC",
|
||||
"license": "OFL-1.1",
|
||||
"licenseSource": "OFL-NotoSansSC.txt",
|
||||
"licensePath": "OFL-NotoSansSC.txt",
|
||||
"licenseBytes": 4350,
|
||||
"licenseSha256": "5e0da210fb04058a8c0087985d2d456b931c2579811a49655721d3cf0c36b6d6",
|
||||
"noticeSource": "SOURCE.md",
|
||||
"noticePath": "SOURCE.md",
|
||||
"compatibility": {
|
||||
"minimumAppVersion": "0.6.0"
|
||||
},
|
||||
"faces": [
|
||||
{
|
||||
"id": "sans-regular",
|
||||
"targets": ["MdTP Sans SC", "FandolHei", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC"],
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"web": {
|
||||
"source": "MdTPSansSC-Regular.woff2",
|
||||
"path": "fonts/MdTPSansSC-Regular.woff2",
|
||||
"bytes": 4192788,
|
||||
"sha256": "45f4ea0072861106b028f975b05752c13b46541dc84799e2746a657593c0c62d"
|
||||
},
|
||||
"docx": {
|
||||
"source": "MdTPSansSC-Regular.ttf",
|
||||
"path": "fonts/MdTPSansSC-Regular.ttf",
|
||||
"bytes": 10596656,
|
||||
"sha256": "a1d7e9c407d664c6d13ab9c01c48eca6775d77fd6daa3448c41ff7f1a3f316e1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sans-bold",
|
||||
"targets": ["MdTP Sans SC", "FandolHei", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC"],
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"web": {
|
||||
"source": "MdTPSansSC-Bold.woff2",
|
||||
"path": "fonts/MdTPSansSC-Bold.woff2",
|
||||
"bytes": 4314832,
|
||||
"sha256": "729e7b445a1db47e588597eaacecebb892ef4de331379ff2439fb0be95aa808a"
|
||||
},
|
||||
"docx": {
|
||||
"source": "MdTPSansSC-Bold.ttf",
|
||||
"path": "fonts/MdTPSansSC-Bold.ttf",
|
||||
"bytes": 10586172,
|
||||
"sha256": "85fcfab7284fd72dfbbdb1a579230a9034dc6f9fc351b40cecd34d46125eebd6"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""从固定的 Noto CJK 可变字体生成墨呈静态字体面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
from fontTools.varLib.instancer import instantiateVariableFont
|
||||
|
||||
|
||||
def set_name(font: TTFont, name_id: int, value: str) -> None:
|
||||
name_table = font["name"]
|
||||
for platform_id, encoding_id, language_id in (
|
||||
(3, 1, 0x0409),
|
||||
(1, 0, 0),
|
||||
):
|
||||
name_table.setName(
|
||||
value,
|
||||
name_id,
|
||||
platform_id,
|
||||
encoding_id,
|
||||
language_id,
|
||||
)
|
||||
|
||||
|
||||
def build_face(
|
||||
source: Path,
|
||||
output_root: Path,
|
||||
family: str,
|
||||
postscript_family: str,
|
||||
weight: int,
|
||||
style: str,
|
||||
) -> None:
|
||||
font = TTFont(source, recalcTimestamp=False)
|
||||
instantiateVariableFont(font, {"wght": weight}, inplace=True, optimize=True)
|
||||
|
||||
set_name(font, 1, family)
|
||||
set_name(font, 2, style)
|
||||
set_name(font, 4, f"{family} {style}")
|
||||
set_name(font, 6, f"{postscript_family}-{style}")
|
||||
set_name(font, 16, family)
|
||||
set_name(font, 17, style)
|
||||
set_name(font, 25, postscript_family)
|
||||
font["OS/2"].usWeightClass = weight
|
||||
font["OS/2"].fsType = 0
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
ttf_path = output_root / f"{postscript_family}-{style}.ttf"
|
||||
font.flavor = None
|
||||
font.save(ttf_path, reorderTables=False)
|
||||
|
||||
woff2_path = output_root / f"{postscript_family}-{style}.woff2"
|
||||
font.flavor = "woff2"
|
||||
font.save(woff2_path, reorderTables=False)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--family", required=True)
|
||||
parser.add_argument("--postscript-family", required=True)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
build_face(
|
||||
arguments.source,
|
||||
arguments.output,
|
||||
arguments.family,
|
||||
arguments.postscript_family,
|
||||
400,
|
||||
"Regular",
|
||||
)
|
||||
build_face(
|
||||
arguments.source,
|
||||
arguments.output,
|
||||
arguments.family,
|
||||
arguments.postscript_family,
|
||||
700,
|
||||
"Bold",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+18
-70
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.4",
|
||||
"license": "UNLICENSED",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.4",
|
||||
"devDependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
@@ -626,7 +626,6 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1056,7 +1055,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
|
||||
"integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1310,6 +1308,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1331,6 +1330,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -2019,18 +2019,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -3500,7 +3488,6 @@
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3771,20 +3758,6 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -4441,7 +4414,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
@@ -4910,7 +4882,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -4952,7 +4925,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
|
||||
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
@@ -5375,7 +5347,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -5921,6 +5892,7 @@
|
||||
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.15.3",
|
||||
"builder-util": "26.15.3",
|
||||
@@ -5934,6 +5906,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -5954,6 +5927,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -5969,6 +5943,7 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -5979,6 +5954,7 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -7470,7 +7446,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.5.0",
|
||||
@@ -7554,7 +7529,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
@@ -8076,7 +8050,6 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8262,6 +8235,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -8279,6 +8253,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -8423,7 +8398,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8433,7 +8407,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -9155,6 +9128,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -9180,6 +9154,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -9194,6 +9169,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -9201,34 +9177,6 @@
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.49.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz",
|
||||
"integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
@@ -9541,7 +9489,6 @@
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -9975,6 +9922,7 @@
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||
"@md-to-pdf/font-pack-registry": "0.1.0",
|
||||
"@md-to-pdf/preview-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+20
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.4",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||
@@ -10,8 +10,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/font-pack-builder && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||
"build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web",
|
||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,semantic-document,docx-theme-engine,font-pack-registry,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:semantic-document\" \"npm:dev:docx-theme-engine\" \"npm:dev:font-pack-registry\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/web",
|
||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/application && concurrently -k -n markdown-echarts,core,semantic-document,docx-theme-engine,font-pack-registry,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:semantic-document\" \"npm:dev:docx-theme-engine\" \"npm:dev:font-pack-registry\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||
"dev:semantic-document": "npm run dev -w @md-to-pdf/semantic-document",
|
||||
@@ -24,16 +24,22 @@
|
||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
||||
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||
"desktop:package": "npm run package -w @md-to-pdf/desktop",
|
||||
"build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js build",
|
||||
"verify:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node packages/font-pack-builder/dist/cli.js verify",
|
||||
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/application && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||
"desktop:package": "npm run build:font-pack && npm run package -w @md-to-pdf/desktop",
|
||||
"desktop:package:mac:arm64": "npm run build:font-pack && npm run package:mac:arm64 -w @md-to-pdf/desktop",
|
||||
"desktop:package:mac:x64": "npm run build:font-pack && npm run package:mac:x64 -w @md-to-pdf/desktop",
|
||||
"build:font-pack": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/font-pack-builder && node scripts/build-font-packs.mjs",
|
||||
"verify:font-pack": "npm run build:font-pack",
|
||||
"verify:docker-font-pack": "npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && node deploy/verify-font-pack-compose.mjs",
|
||||
"build:font-pack:windows": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js windows-installer",
|
||||
"build:font-pack:archive": "npm run verify:font-pack && node packages/font-pack-builder/dist/cli.js archive",
|
||||
"desktop:make:with-font-pack": "npm run build:font-pack:windows && npm run build:font-pack:archive && node scripts/build-desktop-with-font-pack.mjs",
|
||||
"desktop:make": "npm run build:font-pack && node scripts/build-desktop-release.mjs",
|
||||
"desktop:make:mac:arm64": "npm run build:font-pack && npm run make:mac:arm64 -w @md-to-pdf/desktop",
|
||||
"desktop:make:mac:x64": "npm run build:font-pack && npm run make:mac:x64 -w @md-to-pdf/desktop",
|
||||
"desktop:make:mac": "npm run desktop:make:mac:arm64 && npm run desktop:make:mac:x64",
|
||||
"release:stage": "node scripts/stage-release.mjs",
|
||||
"test:release-stage": "node --test scripts/stage-release.test.mjs",
|
||||
"test:docx-layout-visual-matrix-cases": "node --test scripts/docx-layout-visual-matrix-cases.test.mjs",
|
||||
"test:docx-real-world-corpus": "node --test scripts/docx-real-world-corpus.test.mjs",
|
||||
"test:docx-release-gate-suites": "node --test scripts/docx-release-gate-suites.test.mjs",
|
||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
|
||||
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
|
||||
@@ -45,8 +51,10 @@
|
||||
"verify:docx-r4-matrix": "npm run verify:docx-themes && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-r4-matrix -w @md-to-pdf/server",
|
||||
"verify:docx-page-decorations": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-page-decoration-visuals.mjs",
|
||||
"verify:docx-media-visuals": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-media-visuals.mjs",
|
||||
"verify:docx-layout-visual-matrix": "npm run verify:docx-r4-matrix && npm run build -w @md-to-pdf/document-visual-diff && node scripts/verify-docx-layout-visual-matrix.mjs",
|
||||
"test": "npm run test:release-stage && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||
"verify:docx-layout-visual-matrix": "npm run test:docx-layout-visual-matrix-cases && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-layout-visual-matrix.mjs",
|
||||
"verify:docx-release-gate-suite": "npm run test:docx-layout-visual-matrix-cases && npm run test:docx-real-world-corpus && npm run test:docx-release-gate-suites && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-release-gate-suite.mjs",
|
||||
"verify:docx-release-gate-560": "npm run verify:docx-release-gate-suite",
|
||||
"test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && npm run test:docx-real-world-corpus && npm run test:docx-release-gate-suites && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/font-pack-builder && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||
"@md-to-pdf/font-pack-registry": "0.1.0",
|
||||
"@md-to-pdf/preview-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
MarkdownDocumentParseError,
|
||||
renderMarkdown
|
||||
renderMarkdown,
|
||||
type RenderedMarkdown
|
||||
} from "@md-to-pdf/renderer";
|
||||
import {
|
||||
docxExportRequestSchema,
|
||||
type DocxExportRequest,
|
||||
type RenderedMarkdownDocument,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
|
||||
@@ -45,7 +45,7 @@ export interface ApplicationServiceOptions
|
||||
|
||||
export interface PreparedDocxExport {
|
||||
request: DocxExportRequest;
|
||||
document: RenderedMarkdownDocument;
|
||||
document: RenderedMarkdown;
|
||||
theme: {
|
||||
manifest: ThemeManifest;
|
||||
source: "bundled" | "local";
|
||||
|
||||
@@ -486,7 +486,7 @@ export class DocxExportService {
|
||||
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
||||
): PandocDocxConversionInput {
|
||||
return {
|
||||
markdown: prepared.request.markdown,
|
||||
markdown: prepared.document.markdownBody,
|
||||
fileName: prepared.request.fileName,
|
||||
language: prepared.request.language,
|
||||
exportConfig: prepared.request.exportConfig,
|
||||
|
||||
@@ -197,6 +197,7 @@ export async function prepareDocxMedia(
|
||||
...parsedPlan.data.echartsErrors,
|
||||
...parsedPlan.data.mermaidErrors
|
||||
],
|
||||
totalBytes
|
||||
totalBytes,
|
||||
documentLayout: parsedPlan.data.documentLayout ?? { tables: [] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type DocxThemeStyleSnapshot
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import type { PreparedDocxExport } from "./application-service.js";
|
||||
import { SHARED_HIGHLIGHT_CSS } from "@md-to-pdf/preview-engine";
|
||||
|
||||
export interface PreparedDocxThemeTokens {
|
||||
tokens: DocxThemeTokenSet;
|
||||
@@ -67,7 +68,7 @@ export class DocxThemeTokenService
|
||||
const themeFingerprint =
|
||||
await createDocxThemeStyleFingerprint(
|
||||
prepared.theme.manifest.id,
|
||||
prepared.theme.css
|
||||
`${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const snapshot =
|
||||
@@ -80,7 +81,7 @@ export class DocxThemeTokenService
|
||||
{
|
||||
themeId: prepared.theme.manifest.id,
|
||||
themeFingerprint,
|
||||
themeCss: prepared.theme.css,
|
||||
themeCss: `${SHARED_HIGHLIGHT_CSS}\n${prepared.theme.css}`,
|
||||
baseUrl: this.adapter.themeStyleBaseUrl
|
||||
},
|
||||
signal
|
||||
|
||||
@@ -92,6 +92,48 @@ const maximumCssImportDepth = 8;
|
||||
const sharedThemeAssetId = "_shared";
|
||||
const sharedThemeAssetScheme = "theme-shared:";
|
||||
const sharedFontCssPath = "official-fonts/fonts.css";
|
||||
const engineCodeFontFaces: readonly DocxFontFace[] = [
|
||||
{
|
||||
family: "MdTP Mono",
|
||||
aliases: [
|
||||
"Cascadia Mono",
|
||||
"Cascadia Code",
|
||||
"Consolas",
|
||||
"Lucida Console",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Courier New",
|
||||
"Courier",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono"
|
||||
],
|
||||
source: "fonts/mdtp-mono.ttf",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
license: "OFL-1.1"
|
||||
},
|
||||
{
|
||||
family: "MdTP Mono",
|
||||
aliases: [
|
||||
"Cascadia Mono",
|
||||
"Cascadia Code",
|
||||
"Consolas",
|
||||
"Lucida Console",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Courier New",
|
||||
"Courier",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono"
|
||||
],
|
||||
source: "fonts/mdtp-mono-bold.ttf",
|
||||
weight: 700,
|
||||
style: "normal",
|
||||
license: "OFL-1.1"
|
||||
}
|
||||
];
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
@@ -434,7 +476,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
|
||||
async function resolveThemeFontPacks(theme: ThemeRecord) {
|
||||
const registry = await listFontPacks();
|
||||
const faces = theme.manifest.docxFonts?.faces ?? [];
|
||||
const declaredFaces = theme.manifest.docxFonts?.faces ?? [];
|
||||
const faces = [...declaredFaces, ...engineCodeFontFaces];
|
||||
const requests: FontPackFaceRequest[] = faces.map((face) => ({
|
||||
family: face.family,
|
||||
aliases: face.aliases,
|
||||
@@ -453,7 +496,14 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
matches.set(face, match);
|
||||
}
|
||||
}
|
||||
return { registry, resolution, matches };
|
||||
return {
|
||||
registry,
|
||||
resolution,
|
||||
matches,
|
||||
faces: faces.filter(
|
||||
(face) => declaredFaces.includes(face) || matches.has(face)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function createFontPackCss(
|
||||
@@ -711,8 +761,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
const faces = theme.manifest.docxFonts?.faces ?? [];
|
||||
const fontPacks = await resolveThemeFontPacks(theme);
|
||||
const faces = fontPacks.faces;
|
||||
const fonts = await Promise.all(
|
||||
faces.map(async (face) => {
|
||||
const matched = fontPacks.matches.get(face);
|
||||
|
||||
@@ -132,7 +132,11 @@ function sha256(content: Uint8Array | string) {
|
||||
}
|
||||
|
||||
async function createFontPackFixture(
|
||||
options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {}
|
||||
options: {
|
||||
corruptWebHash?: boolean;
|
||||
minimumAppVersion?: string;
|
||||
targets?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const root = join(temporaryDirectory!, "font-packs");
|
||||
const directory = join(root, "test-font-pack", "1.0.0");
|
||||
@@ -162,7 +166,7 @@ async function createFontPackFixture(
|
||||
faces: [
|
||||
{
|
||||
id: "test-regular",
|
||||
targets: ["Test"],
|
||||
targets: options.targets ?? ["Test"],
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
web: {
|
||||
@@ -464,6 +468,53 @@ describe("共享应用服务", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("以引擎级代码字体请求为新主题注入同源等宽字体", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const fontPack = await createFontPackFixture({
|
||||
targets: ["MdTP Mono", "Cascadia Code", "Consolas"]
|
||||
});
|
||||
const service = createApplicationService({
|
||||
...roots,
|
||||
fontPacks: {
|
||||
roots: [fontPack.root],
|
||||
appVersion: "0.6.0",
|
||||
createAssetUrl: (packId, version, faceId, kind, hash) =>
|
||||
`mdpdf://font-pack/${packId}/${version}/${faceId}/${kind}?v=${hash}`
|
||||
}
|
||||
});
|
||||
|
||||
const css = await service.getThemeCss("test-theme");
|
||||
expect(css).toContain('font-family: "Cascadia Code";');
|
||||
expect(css).toContain('font-family: "Consolas";');
|
||||
const prepared = await service.prepareDocxExport({
|
||||
markdown: "`inline()`",
|
||||
fileName: "代码.md",
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
themeId: "test-theme"
|
||||
}
|
||||
});
|
||||
expect(prepared.theme.fonts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
family: "MdTP Mono",
|
||||
aliases: expect.arrayContaining(["Cascadia Code", "Consolas"]),
|
||||
source: "font-pack:test-font-pack/1.0.0/test-regular/docx",
|
||||
content: new Uint8Array(fontPack.docx)
|
||||
})
|
||||
])
|
||||
);
|
||||
expect(prepared.theme.fontPack?.appliedFaces).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
family: "MdTP Mono",
|
||||
packId: "test-font-pack",
|
||||
faceId: "test-regular"
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("字体包损坏时记录诊断并回退到主题字体", async () => {
|
||||
const roots = await createThemeFixture();
|
||||
const fontPack = await createFontPackFixture({ corruptWebHash: true });
|
||||
|
||||
+127
-4
@@ -8,6 +8,7 @@ export const DOCX_FILE_EXTENSION = ".docx";
|
||||
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
|
||||
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
|
||||
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
|
||||
export const MAXIMUM_DOCX_MEDIA_COUNT = 100;
|
||||
export const DOCX_MEDIA_RASTER_DPI = 300;
|
||||
export const DOCX_MEDIA_CSS_DPI = 96;
|
||||
export const DOCX_MEDIA_RASTER_SCALE =
|
||||
@@ -106,15 +107,135 @@ export type DocxMediaAlignment = z.infer<
|
||||
typeof docxMediaAlignmentSchema
|
||||
>;
|
||||
|
||||
export const docxTableLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(256),
|
||||
widthPercent: z.number().positive().max(300),
|
||||
leftOffsetPercent: z.number().min(0).max(100),
|
||||
columnWidthPercents: z
|
||||
.array(z.number().positive().max(100))
|
||||
.min(1)
|
||||
.max(64),
|
||||
rows: z
|
||||
.array(
|
||||
z.object({
|
||||
cells: z
|
||||
.array(
|
||||
z.object({
|
||||
columnSpan: z.number().int().min(1).max(64),
|
||||
backgroundColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-f]{6}$/iu),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu),
|
||||
bold: z.boolean(),
|
||||
italic: z.boolean(),
|
||||
alignment: z.enum(["left", "center", "right", "justify"])
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(64)
|
||||
})
|
||||
)
|
||||
.max(2048)
|
||||
});
|
||||
|
||||
export type DocxTableLayout = z.infer<
|
||||
typeof docxTableLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxTextBlockLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(100_000),
|
||||
letterSpacingPt: z.number().min(-20).max(100),
|
||||
alertRole: z.enum(["title", "body"]).optional(),
|
||||
fontSizePt: z.number().min(1).max(200).optional(),
|
||||
bold: z.boolean().optional(),
|
||||
italic: z.boolean().optional(),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(),
|
||||
leftIndentPt: z.number().min(0).max(2_000).optional(),
|
||||
rightIndentPt: z.number().min(0).max(2_000).optional(),
|
||||
alignment: z
|
||||
.enum(["left", "center", "right", "justify", "distribute"])
|
||||
.optional(),
|
||||
linePitchPt: z.number().min(1).max(500).optional(),
|
||||
lineBreakOffsets: z
|
||||
.array(z.number().int().min(1).max(100_000))
|
||||
.max(10_000)
|
||||
});
|
||||
|
||||
export type DocxTextBlockLayout = z.infer<
|
||||
typeof docxTextBlockLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxListItemLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(100_000),
|
||||
depth: z.number().int().min(0).max(64),
|
||||
textStartPt: z.number().min(0).max(2_000),
|
||||
alignment: z
|
||||
.enum(["left", "center", "right", "justify", "distribute"])
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type DocxListItemLayout = z.infer<
|
||||
typeof docxListItemLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxInlineCodeLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(100_000),
|
||||
fontSizePt: z.number().min(1).max(200),
|
||||
letterSpacingPt: z.number().min(-20).max(100),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu),
|
||||
backgroundColor: z.string().regex(/^#[0-9a-f]{6}$/iu).optional(),
|
||||
paddingPt: z.object({
|
||||
top: z.number().min(0).max(1_000),
|
||||
right: z.number().min(0).max(1_000),
|
||||
bottom: z.number().min(0).max(1_000),
|
||||
left: z.number().min(0).max(1_000)
|
||||
}),
|
||||
borderPt: z.object({
|
||||
top: z.number().min(0).max(20),
|
||||
right: z.number().min(0).max(20),
|
||||
bottom: z.number().min(0).max(20),
|
||||
left: z.number().min(0).max(20)
|
||||
})
|
||||
});
|
||||
|
||||
export type DocxInlineCodeLayout = z.infer<
|
||||
typeof docxInlineCodeLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxEmojiRunLayoutSchema = z.object({
|
||||
ordinal: z.number().int().min(1).max(100_000),
|
||||
text: z.string().min(1).max(1_000),
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/iu)
|
||||
});
|
||||
|
||||
export type DocxEmojiRunLayout = z.infer<
|
||||
typeof docxEmojiRunLayoutSchema
|
||||
>;
|
||||
|
||||
export const docxDocumentLayoutPlanSchema = z.object({
|
||||
tables: z.array(docxTableLayoutSchema).max(256),
|
||||
textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(),
|
||||
listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(),
|
||||
inlineCodes: z.array(docxInlineCodeLayoutSchema).max(100_000).optional(),
|
||||
emojiRuns: z.array(docxEmojiRunLayoutSchema).max(100_000).optional()
|
||||
});
|
||||
|
||||
export type DocxDocumentLayoutPlan = z.infer<
|
||||
typeof docxDocumentLayoutPlanSchema
|
||||
>;
|
||||
|
||||
export const docxMediaCaptureTargetSchema = z.object({
|
||||
id: z.string().regex(/^docx-media-\d+$/u),
|
||||
kind: docxMediaKindSchema,
|
||||
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
ordinal: z.number().int().min(1).max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||
kindOrdinal: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
.max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||
altText: z.string().max(1_000),
|
||||
caption: z.string().max(1_000).optional(),
|
||||
alignment: docxMediaAlignmentSchema,
|
||||
@@ -134,9 +255,10 @@ export type DocxMediaCaptureTarget = z.infer<
|
||||
export const docxMediaCapturePlanSchema = z.object({
|
||||
targets: z
|
||||
.array(docxMediaCaptureTargetSchema)
|
||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
||||
.max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||
echartsErrors: z.array(z.string().max(1_000)),
|
||||
mermaidErrors: z.array(z.string().max(1_000))
|
||||
mermaidErrors: z.array(z.string().max(1_000)),
|
||||
documentLayout: docxDocumentLayoutPlanSchema.optional()
|
||||
});
|
||||
|
||||
export type DocxMediaCapturePlan = z.infer<
|
||||
@@ -158,6 +280,7 @@ export interface PreparedDocxMedia {
|
||||
mermaidErrors: string[];
|
||||
warnings: string[];
|
||||
totalBytes: number;
|
||||
documentLayout?: DocxDocumentLayoutPlan;
|
||||
}
|
||||
|
||||
export const docxExportErrorCodeSchema = z.enum([
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
DOCX_MIME_TYPE,
|
||||
DOCX_MEDIA_RASTER_SCALE,
|
||||
DOCX_PANDOC_VERSION,
|
||||
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
createDocxFileName,
|
||||
defaultExportConfig,
|
||||
docxCapabilitySchema,
|
||||
@@ -167,6 +169,43 @@ describe("DOCX 共享协议", () => {
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("区分用户资源上限与渲染后媒体上限", () => {
|
||||
expect(MAXIMUM_DOCX_RESOURCE_COUNT).toBe(50);
|
||||
expect(MAXIMUM_DOCX_MEDIA_COUNT).toBe(100);
|
||||
expect(
|
||||
docxExportRequestSchema.safeParse({
|
||||
markdown: "# 文档",
|
||||
resources: Array.from({ length: 51 }, (_, index) => ({
|
||||
path: `asset-${index}.png`,
|
||||
data: "AA=="
|
||||
})),
|
||||
exportConfig: defaultExportConfig
|
||||
}).success
|
||||
).toBe(false);
|
||||
const targets = Array.from({ length: 67 }, (_, index) => ({
|
||||
id: `docx-media-${index + 1}`,
|
||||
kind: "mermaid" as const,
|
||||
ordinal: index + 1,
|
||||
kindOrdinal: index + 1,
|
||||
altText: `图 ${index + 1}`,
|
||||
alignment: "center" as const,
|
||||
displayWidthPx: 100,
|
||||
displayHeightPx: 100,
|
||||
captureX: 0,
|
||||
captureY: index * 100,
|
||||
captureWidthPx: 100,
|
||||
captureHeightPx: 100,
|
||||
rasterScale: 3.125
|
||||
}));
|
||||
expect(
|
||||
docxMediaCapturePlanSchema.safeParse({
|
||||
targets,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOCX 主题样式协议", () => {
|
||||
|
||||
@@ -15,14 +15,19 @@ import type {
|
||||
} from "./types.js";
|
||||
|
||||
export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
|
||||
pageSizeDeltaPt: 0.5,
|
||||
pageSizeDeltaPt: 0.6,
|
||||
contentSimilarity: 0.999,
|
||||
strictPageCount: true,
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
maxMeanAbsoluteError: 8,
|
||||
maxChangedPixelRatio: 0.05,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.6,
|
||||
maxChangedPixelRatio: 0.06,
|
||||
minInkIou: 0.64,
|
||||
minEdgeIou: 0.6,
|
||||
maxBackgroundColorDelta: 12,
|
||||
maxForegroundColorDelta: 12,
|
||||
minAntialiasEquivalentInkIou: 0.98,
|
||||
minAntialiasEquivalentEdgeIou: 0.98,
|
||||
};
|
||||
|
||||
function cappedLevenshteinDistance(
|
||||
@@ -258,8 +263,29 @@ export function comparePdfSnapshotsBasic(
|
||||
const candidateEditableText = hasEditableExpectation
|
||||
? buildPdfEditableContentText(candidate, candidatePageSemantics)
|
||||
: "";
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
const paragraphContentExact =
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
|
||||
paragraphLayouts.every(
|
||||
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
|
||||
);
|
||||
const baselineParagraphContentExact =
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.baseline.matched);
|
||||
const baselineEditableCoverage = hasEditableExpectation
|
||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
? baselineParagraphContentExact
|
||||
? 1
|
||||
: calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
: undefined;
|
||||
const candidateEditableSimilarity = hasEditableExpectation
|
||||
? calculateContentSimilarity(
|
||||
@@ -269,7 +295,7 @@ export function comparePdfSnapshotsBasic(
|
||||
)
|
||||
: undefined;
|
||||
const candidateEditableExact = hasEditableExpectation
|
||||
? candidateEditableText === expectedEditableText
|
||||
? candidateEditableText === expectedEditableText || paragraphContentExact
|
||||
: undefined;
|
||||
const contentSimilarity = hasEditableExpectation
|
||||
? Math.min(
|
||||
@@ -301,15 +327,6 @@ export function comparePdfSnapshotsBasic(
|
||||
});
|
||||
}
|
||||
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
if (paragraphLayouts) {
|
||||
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
|
||||
}
|
||||
@@ -365,6 +382,22 @@ export function comparePdfSnapshotsBasic(
|
||||
if (!primaryExpectation) {
|
||||
return false;
|
||||
}
|
||||
if (pair.status === "baseline-only" && baselineExpectation) {
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
baseline,
|
||||
baselineExpectation,
|
||||
baselineExpectation,
|
||||
).status === "passed";
|
||||
}
|
||||
if (pair.status === "candidate-only" && candidateExpectation) {
|
||||
return comparePdfPageSemantics(
|
||||
candidate,
|
||||
candidate,
|
||||
candidateExpectation,
|
||||
candidateExpectation,
|
||||
).status === "passed";
|
||||
}
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
candidate,
|
||||
@@ -375,7 +408,7 @@ export function comparePdfSnapshotsBasic(
|
||||
const bodyFlowLegal =
|
||||
bodyFlowDetected &&
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status !== "failed") &&
|
||||
baselineEditableCoverage === 1 &&
|
||||
candidateEditableExact === true &&
|
||||
baselineCoverPageCount === candidateCoverPageCount &&
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./paragraph-layout.js";
|
||||
export * from "./page-semantics.js";
|
||||
export * from "./raster.js";
|
||||
export * from "./report.js";
|
||||
export * from "./semantic-block.js";
|
||||
export * from "./snapshot.js";
|
||||
export * from "./text.js";
|
||||
export * from "./types.js";
|
||||
|
||||
@@ -131,6 +131,22 @@ try {
|
||||
$pageCount = $null
|
||||
try { $pageCount = $document.ComputeStatistics(2) } catch {}
|
||||
$document.ExportAsFixedFormat($resolvedOutput, 17)
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||
do {
|
||||
if ([System.IO.File]::Exists($resolvedOutput)) {
|
||||
try {
|
||||
$outputLength = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
if ($outputLength -gt 0) { break }
|
||||
} catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if (
|
||||
-not [System.IO.File]::Exists($resolvedOutput) -or
|
||||
(Get-Item -LiteralPath $resolvedOutput).Length -le 0
|
||||
) {
|
||||
throw "Office PDF 导出完成后未生成有效文件:$resolvedOutput"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
pages = $pageCount
|
||||
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
@@ -500,13 +516,31 @@ function createOfficePdfAdapter(
|
||||
);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const metadata = await backend.exportPdf({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
});
|
||||
let metadata: OfficeExportMetadata | undefined;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
metadata = await backend.exportPdf({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await rm(outputPath, { force: true });
|
||||
if (attempt < 3) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, attempt * 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!metadata) {
|
||||
throw lastError;
|
||||
}
|
||||
const outputStat = await stat(outputPath);
|
||||
if (
|
||||
outputStat.size <= 0 ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,14 @@ interface DecodedRaster {
|
||||
rgba: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
interface RgbColor {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
const RASTER_MASK_BORDER_PX = 2;
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
@@ -25,12 +33,35 @@ async function decodeRaster(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<DecodedRaster> {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
if (
|
||||
raster.rgba?.length === raster.widthPx * raster.heightPx * 4 &&
|
||||
raster.widthPx === width &&
|
||||
raster.heightPx === height
|
||||
) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rgba: raster.rgba,
|
||||
};
|
||||
}
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
|
||||
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
|
||||
const sourceContext = sourceCanvas.getContext("2d");
|
||||
const sourceData = sourceContext.createImageData(
|
||||
raster.widthPx,
|
||||
raster.heightPx,
|
||||
);
|
||||
sourceData.data.set(raster.rgba);
|
||||
sourceContext.putImageData(sourceData, 0, 0);
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
} else {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -48,6 +79,384 @@ function luminance(
|
||||
return (77 * red + 150 * green + 29 * blue) >> 8;
|
||||
}
|
||||
|
||||
function colorDistance(left: RgbColor, right: RgbColor): number {
|
||||
return Math.sqrt(
|
||||
((left.red - right.red) ** 2 +
|
||||
(left.green - right.green) ** 2 +
|
||||
(left.blue - right.blue) ** 2) /
|
||||
3,
|
||||
);
|
||||
}
|
||||
|
||||
function colorChroma(color: RgbColor): number {
|
||||
return Math.max(color.red, color.green, color.blue) -
|
||||
Math.min(color.red, color.green, color.blue);
|
||||
}
|
||||
|
||||
function colorLuminance(color: RgbColor): number {
|
||||
return (77 * color.red + 150 * color.green + 29 * color.blue) / 256;
|
||||
}
|
||||
|
||||
function rasterBackgroundColor(
|
||||
rgba: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
): RgbColor {
|
||||
const red: number[] = [];
|
||||
const green: number[] = [];
|
||||
const blue: number[] = [];
|
||||
const append = (x: number, y: number) => {
|
||||
const offset = (y * width + x) * 4;
|
||||
red.push(rgba[offset] ?? 255);
|
||||
green.push(rgba[offset + 1] ?? 255);
|
||||
blue.push(rgba[offset + 2] ?? 255);
|
||||
};
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
append(x, 0);
|
||||
if (height > 1) {
|
||||
append(x, height - 1);
|
||||
}
|
||||
}
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
append(0, y);
|
||||
if (width > 1) {
|
||||
append(width - 1, y);
|
||||
}
|
||||
}
|
||||
const median = (values: number[]) => {
|
||||
if (values.length === 0) {
|
||||
return 255;
|
||||
}
|
||||
values.sort((left, right) => left - right);
|
||||
return values[Math.floor(values.length / 2)] ?? 255;
|
||||
};
|
||||
// 局部语义块会紧贴文字裁剪;连续渐变会把同一背景拆散到许多颜色桶,
|
||||
// 使深色文字反而成为“众数背景”。边框中位色能稳定覆盖纯色和渐变,
|
||||
// 且不会被少量触边字形反转。
|
||||
const border = {
|
||||
red: median(red),
|
||||
green: median(green),
|
||||
blue: median(blue),
|
||||
};
|
||||
const robustRange = (values: readonly number[]) =>
|
||||
(values[Math.floor(values.length * 0.8)] ?? 255) -
|
||||
(values[Math.floor(values.length * 0.2)] ?? 255);
|
||||
const borderChannelRange = Math.max(
|
||||
robustRange(red),
|
||||
robustRange(green),
|
||||
robustRange(blue),
|
||||
);
|
||||
// 极紧的字形裁剪可能四边都落在抗锯齿像素上;实心表头等
|
||||
// 盒背景也可能在裁剪边界外露出白色。因此保留全图众数作为常规背景。
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
let dominant:
|
||||
| { count: number; red: number; green: number; blue: number }
|
||||
| undefined;
|
||||
for (let offset = 0; offset < rgba.length; offset += 4) {
|
||||
const pixelRed = rgba[offset] ?? 255;
|
||||
const pixelGreen = rgba[offset + 1] ?? 255;
|
||||
const pixelBlue = rgba[offset + 2] ?? 255;
|
||||
const bucketKey = (pixelRed >> 3) << 10 |
|
||||
(pixelGreen >> 3) << 5 |
|
||||
(pixelBlue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += pixelRed;
|
||||
bucket.green += pixelGreen;
|
||||
bucket.blue += pixelBlue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
if (!dominant || bucket.count > dominant.count) {
|
||||
dominant = bucket;
|
||||
}
|
||||
}
|
||||
const dominantColor = dominant
|
||||
? {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
}
|
||||
: border;
|
||||
// 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变
|
||||
// 把深色文字拆分成了错误的“众数背景”。
|
||||
return borderChannelRange <= 64 &&
|
||||
colorLuminance(border) >= 220 &&
|
||||
colorLuminance(dominantColor) <= colorLuminance(border) - 80
|
||||
? border
|
||||
: dominantColor;
|
||||
}
|
||||
|
||||
function rasterForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor {
|
||||
const background = rasterBackgroundColor(
|
||||
decoded.rgba,
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
);
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
||||
const color = {
|
||||
red: decoded.rgba[offset] ?? 255,
|
||||
green: decoded.rgba[offset + 1] ?? 255,
|
||||
blue: decoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
const distance = colorDistance(color, background);
|
||||
if (distance < 48) {
|
||||
continue;
|
||||
}
|
||||
const bucketKey = (color.red >> 3) << 10 |
|
||||
(color.green >> 3) << 5 |
|
||||
(color.blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += color.red;
|
||||
bucket.green += color.green;
|
||||
bucket.blue += color.blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
}
|
||||
let dominant:
|
||||
| { count: number; red: number; green: number; blue: number }
|
||||
| undefined;
|
||||
let dominantDistance = -1;
|
||||
for (const bucket of buckets.values()) {
|
||||
const averageColor = {
|
||||
red: bucket.red / bucket.count,
|
||||
green: bucket.green / bucket.count,
|
||||
blue: bucket.blue / bucket.count,
|
||||
};
|
||||
const distance = colorDistance(averageColor, background);
|
||||
if (
|
||||
distance > dominantDistance + 1 ||
|
||||
(Math.abs(distance - dominantDistance) <= 1 &&
|
||||
bucket.count > (dominant?.count ?? 0))
|
||||
) {
|
||||
dominant = bucket;
|
||||
dominantDistance = distance;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? background
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function rasterDominantForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor {
|
||||
const background = rasterBackgroundColor(
|
||||
decoded.rgba,
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
);
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
||||
const color = {
|
||||
red: decoded.rgba[offset] ?? 255,
|
||||
green: decoded.rgba[offset + 1] ?? 255,
|
||||
blue: decoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
if (colorDistance(color, background) < 48) {
|
||||
continue;
|
||||
}
|
||||
const bucketKey = (color.red >> 3) << 10 |
|
||||
(color.green >> 3) << 5 |
|
||||
(color.blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += color.red;
|
||||
bucket.green += color.green;
|
||||
bucket.blue += color.blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
}
|
||||
const dominant = Array.from(buckets.values()).sort(
|
||||
(left, right) => right.count - left.count,
|
||||
)[0];
|
||||
return !dominant
|
||||
? background
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function rasterDominantChromaticForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor | undefined {
|
||||
const background = rasterBackgroundColor(
|
||||
decoded.rgba,
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
);
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
||||
const color = {
|
||||
red: decoded.rgba[offset] ?? 255,
|
||||
green: decoded.rgba[offset + 1] ?? 255,
|
||||
blue: decoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
if (
|
||||
colorDistance(color, background) < 48 ||
|
||||
colorChroma(color) < 32
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const bucketKey = (color.red >> 3) << 10 |
|
||||
(color.green >> 3) << 5 |
|
||||
(color.blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += color.red;
|
||||
bucket.green += color.green;
|
||||
bucket.blue += color.blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
}
|
||||
const dominant = Array.from(buckets.values()).sort(
|
||||
(left, right) => right.count - left.count,
|
||||
)[0];
|
||||
return dominant
|
||||
? {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function rasterChromaticPalette(decoded: DecodedRaster) {
|
||||
const background = rasterBackgroundColor(
|
||||
decoded.rgba,
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
);
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
||||
const color = {
|
||||
red: decoded.rgba[offset] ?? 255,
|
||||
green: decoded.rgba[offset + 1] ?? 255,
|
||||
blue: decoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
if (
|
||||
colorDistance(color, background) < 48 ||
|
||||
colorChroma(color) < 32
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const bucketKey = (color.red >> 3) << 10 |
|
||||
(color.green >> 3) << 5 |
|
||||
(color.blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += color.red;
|
||||
bucket.green += color.green;
|
||||
bucket.blue += color.blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
}
|
||||
return Array.from(buckets.values()).map((bucket) => ({
|
||||
count: bucket.count,
|
||||
color: {
|
||||
red: bucket.red / bucket.count,
|
||||
green: bucket.green / bucket.count,
|
||||
blue: bucket.blue / bucket.count,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function chromaticPaletteDistance(
|
||||
baseline: DecodedRaster,
|
||||
candidate: DecodedRaster,
|
||||
) {
|
||||
const baselinePalette = rasterChromaticPalette(baseline);
|
||||
const candidatePalette = rasterChromaticPalette(candidate);
|
||||
if (baselinePalette.length === 0 || candidatePalette.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const directionalDistance = (
|
||||
source: ReturnType<typeof rasterChromaticPalette>,
|
||||
target: ReturnType<typeof rasterChromaticPalette>,
|
||||
) => {
|
||||
let weightedDistance = 0;
|
||||
let totalWeight = 0;
|
||||
for (const entry of source) {
|
||||
weightedDistance += entry.count * Math.min(
|
||||
...target.map((candidateEntry) =>
|
||||
colorDistance(entry.color, candidateEntry.color)
|
||||
),
|
||||
);
|
||||
totalWeight += entry.count;
|
||||
}
|
||||
return weightedDistance / totalWeight;
|
||||
};
|
||||
return Math.max(
|
||||
directionalDistance(baselinePalette, candidatePalette),
|
||||
directionalDistance(candidatePalette, baselinePalette),
|
||||
);
|
||||
}
|
||||
|
||||
function clearMaskBorder(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
borderPx = RASTER_MASK_BORDER_PX,
|
||||
) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (
|
||||
x < borderPx ||
|
||||
y < borderPx ||
|
||||
x >= width - borderPx ||
|
||||
y >= height - borderPx
|
||||
) {
|
||||
mask[y * width + x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function calculateBinaryIou(
|
||||
left: Uint8Array,
|
||||
right: Uint8Array,
|
||||
@@ -67,6 +476,80 @@ function calculateBinaryIou(
|
||||
return union === 0 ? 1 : intersection / union;
|
||||
}
|
||||
|
||||
function dilateBinaryMask(
|
||||
input: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): Uint8Array {
|
||||
if (radius === 0) {
|
||||
return input;
|
||||
}
|
||||
const horizontal = new Uint8Array(input.length);
|
||||
const output = new Uint8Array(input.length);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowOffset = y * width;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const start = Math.max(0, x - radius);
|
||||
const end = Math.min(width - 1, x + radius);
|
||||
for (let sampleX = start; sampleX <= end; sampleX += 1) {
|
||||
if (input[rowOffset + sampleX] === 1) {
|
||||
horizontal[rowOffset + x] = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const start = Math.max(0, y - radius);
|
||||
const end = Math.min(height - 1, y + radius);
|
||||
for (let sampleY = start; sampleY <= end; sampleY += 1) {
|
||||
if (horizontal[sampleY * width + x] === 1) {
|
||||
output[y * width + x] = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function calculateSpatiallyTolerantIou(
|
||||
left: Uint8Array,
|
||||
right: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): number {
|
||||
if (radius === 0) {
|
||||
return calculateBinaryIou(left, right);
|
||||
}
|
||||
const dilatedLeft = dilateBinaryMask(left, width, height, radius);
|
||||
const dilatedRight = dilateBinaryMask(right, width, height, radius);
|
||||
let leftCount = 0;
|
||||
let rightCount = 0;
|
||||
let matchedLeft = 0;
|
||||
let matchedRight = 0;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] === 1) {
|
||||
leftCount += 1;
|
||||
if (dilatedRight[index] === 1) {
|
||||
matchedLeft += 1;
|
||||
}
|
||||
}
|
||||
if (right[index] === 1) {
|
||||
rightCount += 1;
|
||||
if (dilatedLeft[index] === 1) {
|
||||
matchedRight += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const matchedIntersection = (matchedLeft + matchedRight) / 2;
|
||||
const union = leftCount + rightCount - matchedIntersection;
|
||||
return union === 0 ? 1 : matchedIntersection / union;
|
||||
}
|
||||
|
||||
function createEdgeMask(
|
||||
grayscale: Uint8Array,
|
||||
width: number,
|
||||
@@ -183,6 +666,7 @@ export async function comparePageRasters(
|
||||
: options;
|
||||
const pixelDifferenceThreshold =
|
||||
resolvedOptions.pixelDifferenceThreshold ?? 8;
|
||||
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
|
||||
if (
|
||||
!Number.isInteger(pixelDifferenceThreshold) ||
|
||||
pixelDifferenceThreshold < 0 ||
|
||||
@@ -190,6 +674,13 @@ export async function comparePageRasters(
|
||||
) {
|
||||
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(spatialTolerancePx) ||
|
||||
spatialTolerancePx < 0 ||
|
||||
spatialTolerancePx > 8
|
||||
) {
|
||||
throw new Error("空间容差必须是 0 到 8 之间的整数像素");
|
||||
}
|
||||
const width =
|
||||
resolvedOptions.targetWidthPx ??
|
||||
Math.max(baseline.widthPx, candidate.widthPx);
|
||||
@@ -200,16 +691,28 @@ export async function comparePageRasters(
|
||||
!Number.isSafeInteger(height) || height <= 0) {
|
||||
throw new Error("栅格比较尺寸必须是正整数");
|
||||
}
|
||||
const [baselineDecoded, candidateDecoded] = await Promise.all([
|
||||
decodeRaster(baseline, width, height),
|
||||
decodeRaster(candidate, width, height),
|
||||
]);
|
||||
const baselineDecoded = await decodeRaster(baseline, width, height);
|
||||
const candidateDecoded = await decodeRaster(candidate, width, height);
|
||||
const pixelCount = width * height;
|
||||
const baselineGray = new Uint8Array(pixelCount);
|
||||
const candidateGray = new Uint8Array(pixelCount);
|
||||
const pixelDifference = new Uint8Array(pixelCount);
|
||||
const baselineInk = new Uint8Array(pixelCount);
|
||||
const candidateInk = new Uint8Array(pixelCount);
|
||||
const baselineForegroundInk = new Uint8Array(pixelCount);
|
||||
const candidateForegroundInk = new Uint8Array(pixelCount);
|
||||
const baselineBackground = rasterBackgroundColor(
|
||||
baselineDecoded.rgba,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const candidateBackground = rasterBackgroundColor(
|
||||
candidateDecoded.rgba,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const baselineForeground = rasterForegroundColor(baselineDecoded);
|
||||
const candidateForeground = rasterForegroundColor(candidateDecoded);
|
||||
let absoluteError = 0;
|
||||
let changedPixels = 0;
|
||||
|
||||
@@ -241,25 +744,86 @@ export async function comparePageRasters(
|
||||
const candidateValue = luminance(candidateDecoded.rgba, offset);
|
||||
baselineGray[pixelIndex] = baselineValue;
|
||||
candidateGray[pixelIndex] = candidateValue;
|
||||
if (baselineValue < 245) {
|
||||
const baselineColor = {
|
||||
red: baselineDecoded.rgba[offset] ?? 255,
|
||||
green: baselineDecoded.rgba[offset + 1] ?? 255,
|
||||
blue: baselineDecoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
const candidateColor = {
|
||||
red: candidateDecoded.rgba[offset] ?? 255,
|
||||
green: candidateDecoded.rgba[offset + 1] ?? 255,
|
||||
blue: candidateDecoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
if (colorDistance(baselineColor, baselineBackground) >= 16) {
|
||||
baselineInk[pixelIndex] = 1;
|
||||
}
|
||||
if (candidateValue < 245) {
|
||||
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||
candidateInk[pixelIndex] = 1;
|
||||
}
|
||||
const baselineBackgroundDistance = colorDistance(
|
||||
baselineColor,
|
||||
baselineBackground,
|
||||
);
|
||||
if (
|
||||
baselineBackgroundDistance >= 32 &&
|
||||
colorDistance(baselineColor, baselineForeground) <
|
||||
baselineBackgroundDistance
|
||||
) {
|
||||
baselineForegroundInk[pixelIndex] = 1;
|
||||
}
|
||||
const candidateBackgroundDistance = colorDistance(
|
||||
candidateColor,
|
||||
candidateBackground,
|
||||
);
|
||||
if (
|
||||
candidateBackgroundDistance >= 32 &&
|
||||
colorDistance(candidateColor, candidateForeground) <
|
||||
candidateBackgroundDistance
|
||||
) {
|
||||
candidateForegroundInk[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const baselineEdges = createEdgeMask(
|
||||
baselineGray,
|
||||
clearMaskBorder(baselineInk, width, height);
|
||||
clearMaskBorder(candidateInk, width, height);
|
||||
clearMaskBorder(baselineForegroundInk, width, height);
|
||||
clearMaskBorder(candidateForegroundInk, width, height);
|
||||
const baselineEdges = clearMaskBorder(
|
||||
createEdgeMask(baselineGray, width, height, 40),
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const candidateEdges = createEdgeMask(
|
||||
candidateGray,
|
||||
const candidateEdges = clearMaskBorder(
|
||||
createEdgeMask(candidateGray, width, height, 40),
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const foregroundColorDelta = colorDistance(
|
||||
baselineForeground,
|
||||
candidateForeground,
|
||||
);
|
||||
const dominantForegroundColorDelta = colorDistance(
|
||||
rasterDominantForegroundColor(baselineDecoded),
|
||||
rasterDominantForegroundColor(candidateDecoded),
|
||||
);
|
||||
const baselineChromaticForeground =
|
||||
rasterDominantChromaticForegroundColor(baselineDecoded);
|
||||
const candidateChromaticForeground =
|
||||
rasterDominantChromaticForegroundColor(candidateDecoded);
|
||||
const chromaticForegroundColorDelta =
|
||||
baselineChromaticForeground && candidateChromaticForeground
|
||||
? colorDistance(
|
||||
baselineChromaticForeground,
|
||||
candidateChromaticForeground,
|
||||
)
|
||||
: undefined;
|
||||
const chromaticPaletteDelta = chromaticPaletteDistance(
|
||||
baselineDecoded,
|
||||
candidateDecoded,
|
||||
);
|
||||
const backgroundColorDelta = colorDistance(
|
||||
baselineBackground,
|
||||
candidateBackground,
|
||||
);
|
||||
const metrics: PdfRasterDiffMetrics = {
|
||||
baselineWidthPx: baseline.widthPx,
|
||||
@@ -272,10 +836,45 @@ export async function comparePageRasters(
|
||||
baseline.widthPx === candidate.widthPx &&
|
||||
baseline.heightPx === candidate.heightPx,
|
||||
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
|
||||
spatialTolerancePx,
|
||||
meanAbsoluteError: absoluteError / (pixelCount * 3),
|
||||
changedPixelRatio: changedPixels / pixelCount,
|
||||
inkIou: calculateBinaryIou(baselineInk, candidateInk),
|
||||
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
|
||||
inkIou: calculateSpatiallyTolerantIou(
|
||||
baselineInk,
|
||||
candidateInk,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
foregroundInkIou: calculateSpatiallyTolerantIou(
|
||||
baselineForegroundInk,
|
||||
candidateForegroundInk,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
edgeIou: calculateSpatiallyTolerantIou(
|
||||
baselineEdges,
|
||||
candidateEdges,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
backgroundColorDelta,
|
||||
foregroundColorDelta,
|
||||
baselineForegroundChroma: colorChroma(baselineForeground),
|
||||
candidateForegroundChroma: colorChroma(candidateForeground),
|
||||
foregroundLuminanceDelta: Math.abs(
|
||||
colorLuminance(baselineForeground) -
|
||||
colorLuminance(candidateForeground),
|
||||
),
|
||||
dominantForegroundColorDelta,
|
||||
...(chromaticForegroundColorDelta !== undefined
|
||||
? { chromaticForegroundColorDelta }
|
||||
: {}),
|
||||
...(chromaticPaletteDelta !== undefined
|
||||
? { chromaticPaletteDelta }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
|
||||
import { comparePageRasters } from "./raster.js";
|
||||
import { comparePdfPageSemantics } from "./page-semantics.js";
|
||||
import { comparePdfSemanticBlockVisuals } from "./semantic-block.js";
|
||||
import type {
|
||||
CreatePdfVisualDiffOptions,
|
||||
PdfDocumentSnapshot,
|
||||
@@ -21,6 +22,12 @@ function resolveStatus(
|
||||
return issues.length > 0 ? "warning" : "passed";
|
||||
}
|
||||
|
||||
export function getBlockingVisualDiffIssues(
|
||||
report: Pick<PdfVisualDiffReport, "issues">,
|
||||
): VisualDiffIssue[] {
|
||||
return report.issues.filter((issue) => issue.severity === "failure");
|
||||
}
|
||||
|
||||
function rasterIssues(
|
||||
pair: Extract<PdfPagePair, { status: "paired" }>,
|
||||
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
|
||||
@@ -91,7 +98,7 @@ async function compareVisualPage(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
thresholds: VisualDiffThresholds,
|
||||
rasterComparisonMode: "strict" | "body-flow" = "strict",
|
||||
rasterComparisonMode: "strict" | "body-flow" | "body-block" = "strict",
|
||||
): Promise<PdfVisualPageComparison> {
|
||||
if (pair.status !== "paired") {
|
||||
const page =
|
||||
@@ -101,7 +108,7 @@ async function compareVisualPage(
|
||||
return {
|
||||
pair,
|
||||
rasterComparisonMode,
|
||||
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
|
||||
status: rasterComparisonMode === "strict" ? "failed" : "warning",
|
||||
...(page?.raster
|
||||
? {
|
||||
unpairedPng: page.raster.png,
|
||||
@@ -109,12 +116,18 @@ async function compareVisualPage(
|
||||
}
|
||||
: {}),
|
||||
issues:
|
||||
rasterComparisonMode === "body-flow"
|
||||
rasterComparisonMode !== "strict"
|
||||
? [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
||||
code:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? "BODY_FLOW_RASTER_DIFFERENCE"
|
||||
: "BODY_PAGE_RASTER_DIFFERENCE",
|
||||
severity: "warning",
|
||||
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
|
||||
message:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? "正文自然分页产生未配对物理页,保留页面快照供审查"
|
||||
: "正文物理页未配对,整页栅格仅作为诊断,最终由语义块门禁判定",
|
||||
...(pair.status === "baseline-only"
|
||||
? { baselinePageNumber: pair.baselinePageNumber }
|
||||
: { candidatePageNumber: pair.candidatePageNumber }),
|
||||
@@ -128,14 +141,15 @@ async function compareVisualPage(
|
||||
if (!baselinePage?.raster || !candidatePage?.raster) {
|
||||
const issue: VisualDiffIssue = {
|
||||
code: "RASTER_MISSING",
|
||||
severity: "failure",
|
||||
severity: rasterComparisonMode === "strict" ? "failure" : "warning",
|
||||
message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`,
|
||||
baselinePageNumber: pair.baselinePageNumber,
|
||||
candidatePageNumber: pair.candidatePageNumber,
|
||||
};
|
||||
return {
|
||||
pair,
|
||||
status: "failed",
|
||||
rasterComparisonMode,
|
||||
status: rasterComparisonMode === "strict" ? "failed" : "warning",
|
||||
issues: [issue],
|
||||
};
|
||||
}
|
||||
@@ -144,6 +158,7 @@ async function compareVisualPage(
|
||||
candidatePage.raster,
|
||||
{
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
|
||||
thresholds.pageSizeDeltaPt &&
|
||||
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
|
||||
@@ -161,12 +176,18 @@ async function compareVisualPage(
|
||||
);
|
||||
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
|
||||
const issues =
|
||||
rasterComparisonMode === "body-flow" && strictIssues.length > 0
|
||||
rasterComparisonMode !== "strict" && strictIssues.length > 0
|
||||
? [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
||||
code:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? ("BODY_FLOW_RASTER_DIFFERENCE" as const)
|
||||
: ("BODY_PAGE_RASTER_DIFFERENCE" as const),
|
||||
severity: "warning" as const,
|
||||
message: `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
|
||||
message:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`
|
||||
: `第 ${pair.baselinePageNumber} 页为正文页,整页栅格差异仅作诊断,最终由语义块门禁判定`,
|
||||
baselinePageNumber: pair.baselinePageNumber,
|
||||
candidatePageNumber: pair.candidatePageNumber,
|
||||
details: {
|
||||
@@ -218,6 +239,26 @@ export async function createPdfVisualDiffReport(
|
||||
? {}
|
||||
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
|
||||
});
|
||||
if (basic.paragraphLayouts) {
|
||||
const semanticBlockVisuals = await comparePdfSemanticBlockVisuals(
|
||||
baseline,
|
||||
candidate,
|
||||
basic.paragraphLayouts,
|
||||
thresholds,
|
||||
);
|
||||
basic.semanticBlockVisuals = semanticBlockVisuals;
|
||||
basic.issues.push(
|
||||
...semanticBlockVisuals.flatMap((comparison) => comparison.issues),
|
||||
);
|
||||
basic.status = resolveStatus(basic.issues);
|
||||
if (basic.bodyFlow) {
|
||||
basic.bodyFlow.legal =
|
||||
basic.bodyFlow.legal &&
|
||||
semanticBlockVisuals.every(
|
||||
(comparison) => comparison.status !== "failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
const pages: PdfVisualPageComparison[] = [];
|
||||
for (const pair of basic.pagePairs) {
|
||||
const baselineSemanticExpectation =
|
||||
@@ -244,12 +285,21 @@ export async function createPdfVisualDiffReport(
|
||||
pair.candidatePageNumber,
|
||||
))),
|
||||
);
|
||||
const isSemanticBodyPage =
|
||||
baselineSemanticExpectation?.kind !== "cover" &&
|
||||
candidateSemanticExpectation?.kind !== "cover" &&
|
||||
Boolean(baselineSemanticExpectation || candidateSemanticExpectation) &&
|
||||
(basic.semanticBlockVisuals?.length ?? 0) > 0;
|
||||
const page = await compareVisualPage(
|
||||
pair,
|
||||
baseline,
|
||||
candidate,
|
||||
thresholds,
|
||||
flowAffected ? "body-flow" : "strict",
|
||||
flowAffected
|
||||
? "body-flow"
|
||||
: isSemanticBodyPage
|
||||
? "body-block"
|
||||
: "strict",
|
||||
);
|
||||
const primarySemanticExpectation =
|
||||
baselineSemanticExpectation ?? candidateSemanticExpectation;
|
||||
@@ -366,6 +416,8 @@ function pageTitle(page: PdfVisualPageComparison): string {
|
||||
const mode =
|
||||
page.rasterComparisonMode === "body-flow"
|
||||
? "(正文流动比较)"
|
||||
: page.rasterComparisonMode === "body-block"
|
||||
? "(正文整页诊断)"
|
||||
: "(严格比较)";
|
||||
if (page.pair.status === "paired") {
|
||||
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页${mode}`;
|
||||
@@ -376,6 +428,27 @@ function pageTitle(page: PdfVisualPageComparison): string {
|
||||
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
|
||||
}
|
||||
|
||||
function renderSemanticBlockVisuals(report: PdfVisualDiffReport): string {
|
||||
const comparisons = report.basic.semanticBlockVisuals;
|
||||
if (!comparisons) {
|
||||
return "";
|
||||
}
|
||||
const failures = comparisons.filter(
|
||||
(comparison) => comparison.status === "failed",
|
||||
);
|
||||
const rows = (failures.length > 0 ? failures : comparisons.slice(0, 8))
|
||||
.map((comparison) => {
|
||||
const expectation = comparison.expectation;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(comparison.baselineFontFamilies.join(", ") || "未识别")}</td><td>${escapeHtml(comparison.candidateFontFamilies.join(", ") || "未识别")}</td><td>${comparison.lines.length}</td><td>${escapeHtml(comparison.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="semantic-block-visuals">
|
||||
<h2>正文语义块局部视觉 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
|
||||
<p>正文语义块 ${comparisons.length} 个,失败 ${failures.length} 个;块按内容顺序匹配,局部栅格参与硬门禁,物理页号不参与。PDF.js 字体分类仅作诊断,真实字体解析由独立字体门禁判定。</p>
|
||||
<table><thead><tr><th>块</th><th>分节</th><th>角色</th><th>基线字体分类</th><th>候选字体分类</th><th>局部行</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||
const paragraphs = report.basic.paragraphLayouts;
|
||||
if (!paragraphs) {
|
||||
@@ -387,7 +460,7 @@ function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
|
||||
.map((paragraph) => {
|
||||
const expectation = paragraph.expectation;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.blockKind ?? expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="paragraph-layouts">
|
||||
@@ -451,6 +524,7 @@ export function renderPdfVisualDiffHtml(
|
||||
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
|
||||
${editableMetrics}
|
||||
${renderParagraphLayouts(report)}
|
||||
${renderSemanticBlockVisuals(report)}
|
||||
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
|
||||
</section>
|
||||
${pages}
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
|
||||
import { comparePageRasters } from "./raster.js";
|
||||
import type {
|
||||
PdfDocumentSnapshot,
|
||||
PdfPageRaster,
|
||||
PdfParagraphLayoutComparison,
|
||||
PdfPointBounds,
|
||||
PdfSemanticBlockVisualComparison,
|
||||
PdfSemanticBlockVisualLineComparison,
|
||||
VisualDiffIssue,
|
||||
VisualDiffThresholds,
|
||||
} from "./types.js";
|
||||
|
||||
const BLOCK_RASTER_HORIZONTAL_PADDING_PT = 4;
|
||||
const BLOCK_RASTER_VERTICAL_PADDING_PT = 1;
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const;
|
||||
|
||||
function assertPngRaster(
|
||||
raster: PdfPageRaster,
|
||||
description: string,
|
||||
): void {
|
||||
const valid = PNG_SIGNATURE.every(
|
||||
(value, index) => raster.png[index] === value,
|
||||
);
|
||||
if (!valid) {
|
||||
const header = Array.from(raster.png.slice(0, 16))
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
throw new Error(
|
||||
`${description} 不是有效 PNG:${raster.widthPx}x${raster.heightPx},` +
|
||||
`${raster.png.byteLength} 字节,头部 ${header}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function status(issues: readonly VisualDiffIssue[]) {
|
||||
return issues.some((issue) => issue.severity === "failure")
|
||||
? ("failed" as const)
|
||||
: issues.length > 0
|
||||
? ("warning" as const)
|
||||
: ("passed" as const);
|
||||
}
|
||||
|
||||
function normalizeFontFamily(value: string): string {
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.replace(/^[A-Z]{6}\+/u, "")
|
||||
.replace(/["']/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("en-US");
|
||||
}
|
||||
|
||||
function collectFonts(
|
||||
lines: PdfParagraphLayoutComparison["baseline"]["visualLines"],
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
lines.flatMap((line) => line.fontFamilies.map(normalizeFontFamily)),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
interface TextLineSpan {
|
||||
lineIndex: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface ReflowedTextLinePair {
|
||||
baselineLineIndex: number;
|
||||
candidateLineIndex: number;
|
||||
baselineStart: number;
|
||||
baselineEnd: number;
|
||||
candidateStart: number;
|
||||
candidateEnd: number;
|
||||
}
|
||||
|
||||
function buildTextLineSpans(lineTexts: readonly string[]): TextLineSpan[] {
|
||||
let offset = 0;
|
||||
return lineTexts.map((text, lineIndex) => {
|
||||
const start = offset;
|
||||
offset += Array.from(text).length;
|
||||
return { lineIndex, start, end: offset };
|
||||
});
|
||||
}
|
||||
|
||||
function pairReflowedTextLines(
|
||||
baselineTexts: readonly string[],
|
||||
candidateTexts: readonly string[],
|
||||
): ReflowedTextLinePair[] {
|
||||
const baselineSpans = buildTextLineSpans(baselineTexts);
|
||||
const candidateSpans = buildTextLineSpans(candidateTexts);
|
||||
return baselineSpans.flatMap((baseline) =>
|
||||
candidateSpans.flatMap((candidate) =>
|
||||
Math.max(baseline.start, candidate.start) <
|
||||
Math.min(baseline.end, candidate.end)
|
||||
? (() => {
|
||||
const overlapStart = Math.max(baseline.start, candidate.start);
|
||||
const overlapEnd = Math.min(baseline.end, candidate.end);
|
||||
return [{
|
||||
baselineLineIndex: baseline.lineIndex,
|
||||
candidateLineIndex: candidate.lineIndex,
|
||||
baselineStart: overlapStart - baseline.start,
|
||||
baselineEnd: overlapEnd - baseline.start,
|
||||
candidateStart: overlapStart - candidate.start,
|
||||
candidateEnd: overlapEnd - candidate.start,
|
||||
}];
|
||||
})()
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sliceTextLineBounds(
|
||||
bounds: PdfPointBounds,
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): PdfPointBounds {
|
||||
const length = Math.max(1, Array.from(text).length);
|
||||
const leftRatio = Math.max(0, Math.min(1, start / length));
|
||||
const rightRatio = Math.max(leftRatio, Math.min(1, end / length));
|
||||
return {
|
||||
...bounds,
|
||||
x: bounds.x + bounds.width * leftRatio,
|
||||
width: Math.max(0, bounds.width * (rightRatio - leftRatio)),
|
||||
};
|
||||
}
|
||||
|
||||
async function cropRaster(
|
||||
raster: PdfPageRaster,
|
||||
bounds: PdfPointBounds,
|
||||
): Promise<PdfPageRaster> {
|
||||
const pixelsPerPoint = raster.dpi / 72;
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const right = Math.min(
|
||||
raster.widthPx,
|
||||
Math.ceil(
|
||||
(bounds.x + bounds.width + BLOCK_RASTER_HORIZONTAL_PADDING_PT) *
|
||||
pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const bottom = Math.min(
|
||||
raster.heightPx,
|
||||
Math.ceil(
|
||||
(bounds.y + bounds.height + BLOCK_RASTER_VERTICAL_PADDING_PT) *
|
||||
pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const widthPx = Math.max(1, right - left);
|
||||
const heightPx = Math.max(1, bottom - top);
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
const canvas = createCanvas(widthPx, heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, widthPx, heightPx);
|
||||
context.drawImage(
|
||||
source,
|
||||
left,
|
||||
top,
|
||||
widthPx,
|
||||
heightPx,
|
||||
0,
|
||||
0,
|
||||
widthPx,
|
||||
heightPx,
|
||||
);
|
||||
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
heightPx,
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
async function padRaster(
|
||||
raster: PdfPageRaster,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): Promise<PdfPageRaster> {
|
||||
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
||||
return raster;
|
||||
}
|
||||
const canvas = createCanvas(widthPx, heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, widthPx, heightPx);
|
||||
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
|
||||
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
|
||||
const sourceContext = sourceCanvas.getContext("2d");
|
||||
const sourceData = sourceContext.createImageData(
|
||||
raster.widthPx,
|
||||
raster.heightPx,
|
||||
);
|
||||
sourceData.data.set(raster.rgba);
|
||||
sourceContext.putImageData(sourceData, 0, 0);
|
||||
context.drawImage(sourceCanvas, 0, 0);
|
||||
} else {
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
context.drawImage(source, 0, 0);
|
||||
}
|
||||
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
heightPx,
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
function localRasterIssues(
|
||||
paragraphIndex: number,
|
||||
lineIndex: number,
|
||||
blockKind: string | undefined,
|
||||
hasInlineCode: boolean,
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
thresholds: VisualDiffThresholds,
|
||||
textReflow: boolean,
|
||||
): VisualDiffIssue[] {
|
||||
const shapeFailed =
|
||||
metrics.inkIou < thresholds.minInkIou ||
|
||||
metrics.edgeIou < thresholds.minEdgeIou;
|
||||
const neutralTinyGlyphAntialiasEquivalent =
|
||||
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||
const colorFailed =
|
||||
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
|
||||
(metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta &&
|
||||
!neutralTinyGlyphAntialiasEquivalent);
|
||||
const rawPixelsFailed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
|
||||
const antialiasEquivalent =
|
||||
metrics.inkIou >= thresholds.minAntialiasEquivalentInkIou &&
|
||||
metrics.edgeIou >= thresholds.minAntialiasEquivalentEdgeIou &&
|
||||
!colorFailed;
|
||||
const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
textReflow,
|
||||
blockKind,
|
||||
hasInlineCode,
|
||||
);
|
||||
const failed = (colorFailed && !crossEngineRasterEquivalent) ||
|
||||
(!textReflow && (
|
||||
(shapeFailed && !crossEngineRasterEquivalent) ||
|
||||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
|
||||
));
|
||||
if (!failed) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: "SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraphIndex + 1} 个语义块的第 ${lineIndex + 1} 行局部视觉差异超限`,
|
||||
details: {
|
||||
paragraphIndex,
|
||||
lineIndex,
|
||||
meanAbsoluteError: metrics.meanAbsoluteError,
|
||||
changedPixelRatio: metrics.changedPixelRatio,
|
||||
inkIou: metrics.inkIou,
|
||||
edgeIou: metrics.edgeIou,
|
||||
backgroundColorDelta: metrics.backgroundColorDelta,
|
||||
foregroundColorDelta: metrics.foregroundColorDelta,
|
||||
antialiasEquivalent,
|
||||
crossEngineRasterEquivalent,
|
||||
neutralTinyGlyphAntialiasEquivalent,
|
||||
textReflow,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function isNeutralTinyGlyphAntialiasEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
// 极细的中性标点(例如表格占位长横线)在 WPS 中可能只保留一层
|
||||
// 灰阶覆盖,前景色估算因此会比 Chromium/Word 明显变亮。只有局部
|
||||
// 几何、墨迹和边缘完全同拓扑,且整体与背景误差都很低时才视作同一
|
||||
// 字形的抗锯齿差异;真实着色、轮廓或背景变化仍继续阻断。
|
||||
return Math.max(
|
||||
metrics.baselineWidthPx,
|
||||
metrics.candidateWidthPx,
|
||||
) <= 64 &&
|
||||
Math.max(
|
||||
metrics.baselineHeightPx,
|
||||
metrics.candidateHeightPx,
|
||||
) <= 48 &&
|
||||
metrics.inkIou >= 0.995 &&
|
||||
(metrics.foregroundInkIou ?? metrics.inkIou) >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.meanAbsoluteError <= 16 &&
|
||||
metrics.backgroundColorDelta <= 12 &&
|
||||
(metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
|
||||
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
|
||||
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 120;
|
||||
}
|
||||
|
||||
function isNeutralForegroundToneEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
return (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
|
||||
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
|
||||
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 5;
|
||||
}
|
||||
|
||||
function isChromaticPaletteEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
return metrics.inkIou >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
(metrics.baselineForegroundChroma ?? 0) >= 80 &&
|
||||
(metrics.candidateForegroundChroma ?? 0) >= 80 &&
|
||||
(metrics.dominantForegroundColorDelta ?? Number.POSITIVE_INFINITY) <= 4;
|
||||
}
|
||||
|
||||
function isSyntaxPaletteEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
blockKind?: string,
|
||||
) {
|
||||
if (blockKind !== "code-block") {
|
||||
return false;
|
||||
}
|
||||
const widthDelta = Math.abs(
|
||||
metrics.baselineWidthPx - metrics.candidateWidthPx,
|
||||
);
|
||||
const heightDelta = Math.abs(
|
||||
metrics.baselineHeightPx - metrics.candidateHeightPx,
|
||||
);
|
||||
return widthDelta <= Math.max(
|
||||
4,
|
||||
Math.ceil(metrics.baselineWidthPx * 0.015),
|
||||
) &&
|
||||
heightDelta <= 1 &&
|
||||
metrics.inkIou >= 0.985 &&
|
||||
(metrics.foregroundInkIou ?? 0) >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
(metrics.chromaticPaletteDelta ?? Number.POSITIVE_INFINITY) <= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
|
||||
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
|
||||
* 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。
|
||||
*/
|
||||
export function isCrossEngineRasterEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
textReflow: boolean,
|
||||
blockKind?: string,
|
||||
hasInlineCode = false,
|
||||
): boolean {
|
||||
if (textReflow) {
|
||||
return false;
|
||||
}
|
||||
const widthDelta = Math.abs(
|
||||
metrics.baselineWidthPx - metrics.candidateWidthPx,
|
||||
);
|
||||
const heightDelta = Math.abs(
|
||||
metrics.baselineHeightPx - metrics.candidateHeightPx,
|
||||
);
|
||||
const preciseGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
|
||||
heightDelta <= 1;
|
||||
const verticalMetricGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
|
||||
heightDelta <= 2;
|
||||
const fontMetricGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.07) &&
|
||||
heightDelta <= 1;
|
||||
const listFontMetricGeometryEquivalent =
|
||||
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.075) &&
|
||||
heightDelta <= 2;
|
||||
const tinyGlyphGeometryEquivalent =
|
||||
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
|
||||
widthDelta <= 2 &&
|
||||
heightDelta <= 1;
|
||||
const neutralTinyGlyphAntialiasEquivalent =
|
||||
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||
const colorEquivalent =
|
||||
neutralTinyGlyphAntialiasEquivalent ||
|
||||
(
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
(metrics.foregroundColorDelta <=
|
||||
(blockKind === "list-item" ? 4.5 : 4) ||
|
||||
isNeutralForegroundToneEquivalent(metrics) ||
|
||||
isChromaticPaletteEquivalent(metrics) ||
|
||||
isSyntaxPaletteEquivalent(metrics, blockKind))
|
||||
);
|
||||
const inlineCodeShadingEquivalent =
|
||||
hasInlineCode &&
|
||||
widthDelta <= Math.max(3, Math.ceil(metrics.baselineWidthPx * 0.015)) &&
|
||||
heightDelta <= 2 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
(metrics.inkIou >= 0.9 ||
|
||||
(metrics.foregroundInkIou ?? 0) >= 0.98) &&
|
||||
metrics.foregroundColorDelta <= 3.25 &&
|
||||
metrics.backgroundColorDelta <= 12;
|
||||
const topologyEquivalent =
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.99 &&
|
||||
metrics.edgeIou >= 0.9) ||
|
||||
(tinyGlyphGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.9 &&
|
||||
metrics.edgeIou >= 0.9) ||
|
||||
(blockKind === "code-block" &&
|
||||
preciseGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.96 &&
|
||||
metrics.edgeIou >= 0.98) ||
|
||||
(["paragraph", "list-item", "block-quote"].includes(blockKind ?? "") &&
|
||||
verticalMetricGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.88 &&
|
||||
metrics.edgeIou >= 0.995) ||
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.inkIou >= 0.86) ||
|
||||
(fontMetricGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.98 &&
|
||||
metrics.inkIou >= 0.97) ||
|
||||
(blockKind === "list-item" &&
|
||||
listFontMetricGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.93 &&
|
||||
metrics.inkIou >= 0.9) ||
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.97 &&
|
||||
metrics.inkIou >= 0.97);
|
||||
return inlineCodeShadingEquivalent ||
|
||||
(colorEquivalent && topologyEquivalent);
|
||||
}
|
||||
|
||||
export async function comparePdfSemanticBlockVisuals(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
paragraphs: readonly PdfParagraphLayoutComparison[],
|
||||
thresholds: VisualDiffThresholds,
|
||||
): Promise<PdfSemanticBlockVisualComparison[]> {
|
||||
const comparisons: PdfSemanticBlockVisualComparison[] = [];
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.expectation.section !== "body") {
|
||||
continue;
|
||||
}
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
const baselineFonts = collectFonts(paragraph.baseline.visualLines);
|
||||
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
|
||||
|
||||
const lines: PdfSemanticBlockVisualLineComparison[] = [];
|
||||
const flowTextBlock = [
|
||||
"paragraph",
|
||||
"list-item",
|
||||
"block-quote",
|
||||
"code-block",
|
||||
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
||||
const comparableLineCount = Math.min(
|
||||
paragraph.baseline.visualLines.length,
|
||||
paragraph.candidate.visualLines.length,
|
||||
);
|
||||
const hasUsableVisualMapping = (layout: typeof paragraph.baseline) => {
|
||||
if (layout.matched) {
|
||||
return true;
|
||||
}
|
||||
const expected = layout.expectedCharacterCount ?? 0;
|
||||
const matched = layout.matchedCharacterCount ?? 0;
|
||||
const coverage = expected === 0 ? 0 : matched / expected;
|
||||
const mathCharacterIndexes = new Set(
|
||||
paragraph.expectation.mathCharacterIndexes ?? [],
|
||||
);
|
||||
const missingTailIsMath =
|
||||
matched < expected &&
|
||||
Array.from(
|
||||
{ length: expected - matched },
|
||||
(_, offset) => matched + offset,
|
||||
).every((index) => mathCharacterIndexes.has(index));
|
||||
return layout.visualLines.length > 0 &&
|
||||
((expected >= 10 && expected - matched === 1 && coverage >= 0.9) ||
|
||||
(missingTailIsMath && coverage >= 0.8));
|
||||
};
|
||||
const equalLineSegmentation =
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length &&
|
||||
paragraph.baseline.lineTexts.every(
|
||||
(text, index) => text === paragraph.candidate.lineTexts[index],
|
||||
);
|
||||
if (
|
||||
!hasUsableVisualMapping(paragraph.baseline) ||
|
||||
!hasUsableVisualMapping(paragraph.candidate) ||
|
||||
comparableLineCount === 0
|
||||
) {
|
||||
issues.push({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
} else if (equalLineSegmentation) {
|
||||
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
||||
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
||||
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
||||
const baselinePage = baselineLine
|
||||
? baseline.pages[baselineLine.pageNumber - 1]
|
||||
: undefined;
|
||||
const candidatePage = candidateLine
|
||||
? candidate.pages[candidateLine.pageNumber - 1]
|
||||
: undefined;
|
||||
if (
|
||||
!baselineLine ||
|
||||
!candidateLine ||
|
||||
!baselinePage?.raster ||
|
||||
!candidatePage?.raster
|
||||
) {
|
||||
const unavailable: VisualDiffIssue = {
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块的第 ${lineIndex + 1} 行缺少栅格`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex,
|
||||
},
|
||||
};
|
||||
issues.push(unavailable);
|
||||
lines.push({
|
||||
lineIndex,
|
||||
baselinePageNumber: baselineLine?.pageNumber ?? -1,
|
||||
candidatePageNumber: candidateLine?.pageNumber ?? -1,
|
||||
issues: [unavailable],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const [baselineCrop, candidateCrop] = await Promise.all([
|
||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||
]);
|
||||
assertPngRaster(
|
||||
baselineCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行基线裁剪`,
|
||||
);
|
||||
assertPngRaster(
|
||||
candidateCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行候选裁剪`,
|
||||
);
|
||||
const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
|
||||
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
|
||||
const textReflow = flowTextBlock &&
|
||||
baselineLineText !== candidateLineText;
|
||||
const cropWidthRatio = Math.min(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
) / Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||
const normalizeFlowGeometry = flowTextBlock &&
|
||||
!textReflow &&
|
||||
cropWidthRatio >= 0.75;
|
||||
let result;
|
||||
try {
|
||||
result = normalizeFlowGeometry
|
||||
? await comparePageRasters(baselineCrop, candidateCrop, {
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||
targetWidthPx: baselineCrop.widthPx,
|
||||
targetHeightPx: baselineCrop.heightPx,
|
||||
geometryNormalized: true,
|
||||
})
|
||||
: await (async () => {
|
||||
const widthPx = Math.max(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
return comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
})();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行栅格比较失败:` +
|
||||
`基线 ${baselineCrop.widthPx}x${baselineCrop.heightPx}/${baselineCrop.png.byteLength},` +
|
||||
`候选 ${candidateCrop.widthPx}x${candidateCrop.heightPx}/${candidateCrop.png.byteLength};` +
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const lineIssues = localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
lineIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
paragraph.expectation.hasInlineCode === true,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
textReflow,
|
||||
);
|
||||
issues.push(...lineIssues);
|
||||
lines.push({
|
||||
lineIndex,
|
||||
baselinePageNumber: baselineLine.pageNumber,
|
||||
candidatePageNumber: candidateLine.pageNumber,
|
||||
metrics: result.metrics,
|
||||
...(lineIssues.length > 0 ? { artifacts: result.artifacts } : {}),
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const reflowPairs = pairReflowedTextLines(
|
||||
paragraph.baseline.lineTexts,
|
||||
paragraph.candidate.lineTexts,
|
||||
);
|
||||
const sliceByCharacterOverlap =
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length;
|
||||
if (reflowPairs.length === 0) {
|
||||
issues.push({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块无法按文本区间配对跨引擎视觉行`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
}
|
||||
for (let pairIndex = 0; pairIndex < reflowPairs.length; pairIndex += 1) {
|
||||
const pair = reflowPairs[pairIndex]!;
|
||||
const baselineLine =
|
||||
paragraph.baseline.visualLines[pair.baselineLineIndex];
|
||||
const candidateLine =
|
||||
paragraph.candidate.visualLines[pair.candidateLineIndex];
|
||||
const baselinePage = baselineLine
|
||||
? baseline.pages[baselineLine.pageNumber - 1]
|
||||
: undefined;
|
||||
const candidatePage = candidateLine
|
||||
? candidate.pages[candidateLine.pageNumber - 1]
|
||||
: undefined;
|
||||
if (
|
||||
!baselineLine ||
|
||||
!candidateLine ||
|
||||
!baselinePage?.raster ||
|
||||
!candidatePage?.raster
|
||||
) {
|
||||
const unavailable: VisualDiffIssue = {
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块的跨引擎重排行缺少栅格`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex: pairIndex,
|
||||
},
|
||||
};
|
||||
issues.push(unavailable);
|
||||
lines.push({
|
||||
lineIndex: pairIndex,
|
||||
baselinePageNumber: baselineLine?.pageNumber ?? -1,
|
||||
candidatePageNumber: candidateLine?.pageNumber ?? -1,
|
||||
issues: [unavailable],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const baselineBounds = sliceByCharacterOverlap
|
||||
? sliceTextLineBounds(
|
||||
baselineLine.bounds,
|
||||
paragraph.baseline.lineTexts[pair.baselineLineIndex] ?? "",
|
||||
pair.baselineStart,
|
||||
pair.baselineEnd,
|
||||
)
|
||||
: baselineLine.bounds;
|
||||
const candidateBounds = sliceByCharacterOverlap
|
||||
? sliceTextLineBounds(
|
||||
candidateLine.bounds,
|
||||
paragraph.candidate.lineTexts[pair.candidateLineIndex] ?? "",
|
||||
pair.candidateStart,
|
||||
pair.candidateEnd,
|
||||
)
|
||||
: candidateLine.bounds;
|
||||
const [baselineCrop, candidateCrop] = await Promise.all([
|
||||
cropRaster(baselinePage.raster, baselineBounds),
|
||||
cropRaster(candidatePage.raster, candidateBounds),
|
||||
]);
|
||||
assertPngRaster(
|
||||
baselineCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块重排行基线裁剪`,
|
||||
);
|
||||
assertPngRaster(
|
||||
candidateCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块重排行候选裁剪`,
|
||||
);
|
||||
const widthPx = Math.max(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
const result = await comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
const reflowIssue: VisualDiffIssue = {
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "warning",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块发生允许的跨引擎流式换行`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex: pairIndex,
|
||||
baselineLineIndex: pair.baselineLineIndex,
|
||||
candidateLineIndex: pair.candidateLineIndex,
|
||||
textReflow: true,
|
||||
},
|
||||
};
|
||||
const lineIssues = [
|
||||
reflowIssue,
|
||||
...localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
pairIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
paragraph.expectation.hasInlineCode === true,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
true,
|
||||
),
|
||||
];
|
||||
issues.push(...lineIssues);
|
||||
lines.push({
|
||||
lineIndex: pairIndex,
|
||||
baselinePageNumber: baselineLine.pageNumber,
|
||||
candidatePageNumber: candidateLine.pageNumber,
|
||||
metrics: result.metrics,
|
||||
...(lineIssues.some((issue) => issue.severity === "failure")
|
||||
? { artifacts: result.artifacts }
|
||||
: {}),
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
}
|
||||
comparisons.push({
|
||||
expectation: paragraph.expectation,
|
||||
status: status(issues),
|
||||
baselineFontFamilies: baselineFonts,
|
||||
candidateFontFamilies: candidateFonts,
|
||||
lines,
|
||||
issues,
|
||||
});
|
||||
}
|
||||
return comparisons;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { aggregatePdfTextLines, buildPageContentText, normalizePdfText } from ".
|
||||
import type {
|
||||
CreatePdfSnapshotOptions,
|
||||
PdfDocumentSnapshot,
|
||||
PdfFontSnapshot,
|
||||
PdfPageSnapshot,
|
||||
PdfSnapshotLimits,
|
||||
PdfTextItemSnapshot,
|
||||
@@ -21,6 +22,17 @@ interface PdfJsTextItem {
|
||||
hasEOL: boolean;
|
||||
}
|
||||
|
||||
interface PdfJsTextStyle {
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
interface PdfJsResolvedFont {
|
||||
name?: string;
|
||||
fallbackName?: string;
|
||||
isType3Font?: boolean;
|
||||
missingFile?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = {
|
||||
maxPdfBytes: 100 * 1024 * 1024,
|
||||
maxPages: 500,
|
||||
@@ -55,6 +67,8 @@ function isTextItem(item: unknown): item is PdfJsTextItem {
|
||||
function createTextItemSnapshot(
|
||||
item: PdfJsTextItem,
|
||||
pageHeightPt: number,
|
||||
styles: Readonly<Record<string, PdfJsTextStyle>>,
|
||||
fonts: ReadonlyMap<string, PdfFontSnapshot>,
|
||||
): PdfTextItemSnapshot | undefined {
|
||||
const normalizedText = normalizePdfText(item.str);
|
||||
if (!normalizedText) {
|
||||
@@ -72,6 +86,8 @@ function createTextItemSnapshot(
|
||||
0,
|
||||
);
|
||||
const baselineY = pageHeightPt - baselineFromBottom;
|
||||
const font = fonts.get(item.fontName);
|
||||
const fontFamily = font?.name ?? styles[item.fontName]?.fontFamily;
|
||||
return {
|
||||
text: item.str,
|
||||
normalizedText,
|
||||
@@ -83,10 +99,46 @@ function createTextItemSnapshot(
|
||||
},
|
||||
baselineY,
|
||||
fontName: item.fontName,
|
||||
...(fontFamily ? { fontFamily } : {}),
|
||||
...(font?.fallbackFamily
|
||||
? { fallbackFontFamily: font.fallbackFamily }
|
||||
: {}),
|
||||
hasEol: item.hasEOL,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePdfFontName(value: string) {
|
||||
return value.replace(/^[A-Z]{6}\+/u, "");
|
||||
}
|
||||
|
||||
function resolvePageFonts(
|
||||
page: Awaited<ReturnType<Awaited<ReturnType<typeof getDocument>["promise"]>["getPage"]>>,
|
||||
fontNames: readonly string[],
|
||||
) {
|
||||
const fonts = new Map<string, PdfFontSnapshot>();
|
||||
for (const internalName of fontNames) {
|
||||
try {
|
||||
const resolved = page.commonObjs.get(internalName) as PdfJsResolvedFont;
|
||||
const name = resolved.name?.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
fonts.set(internalName, {
|
||||
internalName,
|
||||
name: normalizePdfFontName(name),
|
||||
...(resolved.fallbackName
|
||||
? { fallbackFamily: resolved.fallbackName }
|
||||
: {}),
|
||||
isType3Font: resolved.isType3Font === true,
|
||||
missingFile: resolved.missingFile === true,
|
||||
});
|
||||
} catch {
|
||||
// PDF.js 可能不给未实际绘制的字体建立公共对象;保留文本但不伪造字体。
|
||||
}
|
||||
}
|
||||
return fonts;
|
||||
}
|
||||
|
||||
function resolveLimits(
|
||||
input: Partial<PdfSnapshotLimits> | undefined,
|
||||
): PdfSnapshotLimits {
|
||||
@@ -138,11 +190,30 @@ export async function createPdfDocumentSnapshot(
|
||||
disableNormalization: false,
|
||||
includeMarkedContent: false,
|
||||
});
|
||||
await page.getOperatorList();
|
||||
const pageFonts = resolvePageFonts(
|
||||
page,
|
||||
[
|
||||
...new Set(
|
||||
textContent.items.flatMap((item) =>
|
||||
isTextItem(item) ? [item.fontName] : [],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
const styles = textContent.styles as Readonly<
|
||||
Record<string, PdfJsTextStyle>
|
||||
>;
|
||||
const items = textContent.items.flatMap((item) => {
|
||||
if (!isTextItem(item)) {
|
||||
return [];
|
||||
}
|
||||
const snapshot = createTextItemSnapshot(item, pointViewport.height);
|
||||
const snapshot = createTextItemSnapshot(
|
||||
item,
|
||||
pointViewport.height,
|
||||
styles,
|
||||
pageFonts,
|
||||
);
|
||||
return snapshot ? [snapshot] : [];
|
||||
});
|
||||
const lines = aggregatePdfTextLines(items, pointViewport.height);
|
||||
@@ -154,6 +225,9 @@ export async function createPdfDocumentSnapshot(
|
||||
items,
|
||||
lines,
|
||||
contentText: buildPageContentText(lines),
|
||||
fonts: [...pageFonts.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "en"),
|
||||
),
|
||||
};
|
||||
const pageNumberLine = lines.find((line) => line.role === "page-number");
|
||||
if (pageNumberLine) {
|
||||
@@ -204,12 +278,20 @@ export async function createPdfDocumentSnapshot(
|
||||
.map((page) => page.contentText)
|
||||
.filter(Boolean)
|
||||
.join("\n\f\n");
|
||||
const fonts = [
|
||||
...new Map(
|
||||
pages
|
||||
.flatMap((page) => page.fonts)
|
||||
.map((font) => [font.name.toLocaleLowerCase("en-US"), font]),
|
||||
).values(),
|
||||
].sort((left, right) => left.name.localeCompare(right.name, "en"));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: options.source,
|
||||
sha256: sha256(pdfBytes),
|
||||
pageCount: pages.length,
|
||||
contentText,
|
||||
fonts,
|
||||
pages,
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -7,10 +7,19 @@ import type {
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufe0e\ufe0f\ufeff]/gu;
|
||||
const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⺠", "民"],
|
||||
["⻅", "见"],
|
||||
["⻆", "角"],
|
||||
["⻓", "长"],
|
||||
["⻔", "门"],
|
||||
["⻚", "页"],
|
||||
["⻛", "风"],
|
||||
["⻝", "食"],
|
||||
["⻣", "骨"],
|
||||
["⻋", "车"],
|
||||
["⻬", "齐"],
|
||||
]);
|
||||
const PAGE_NUMBER_PATTERNS = [
|
||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||
@@ -25,6 +34,16 @@ export function normalizePdfText(value: string): string {
|
||||
.replace(/[\u2e80-\u2eff]/gu, (character) =>
|
||||
CJK_RADICAL_VARIANTS.get(character) ?? character
|
||||
)
|
||||
// Chromium、Word 与 WPS 的 PDF 文本层会以不同方式保留弯引号;
|
||||
// 统一为 ASCII 只用于可编辑文本定位,不改变栅格视觉比较。
|
||||
.replace(/[“”]/gu, '"')
|
||||
.replace(/[‘’]/gu, "'")
|
||||
// Chromium 的部分 CJK 字体把 Markdown em dash 提取为 horizontal bar;
|
||||
// 二者在此仅作为语义定位字符归一,不影响后续栅格视觉比较。
|
||||
.replace(/―/gu, "—")
|
||||
// Chromium 的部分 CJK 字体会把数字区间中的 en dash 提取为两个
|
||||
// ASCII hyphen。只归一数字/百分比区间,避免改写代码中的普通 `--`。
|
||||
.replace(/(?<=[\d%])\s*--(?:\s*(?=\d)|\s*$)/gu, "–")
|
||||
.replace(ZERO_WIDTH_AND_CONTROL, "")
|
||||
.replace(/\u00a0/gu, " ")
|
||||
.replace(/[ \t]+/gu, " ")
|
||||
@@ -128,6 +147,63 @@ function median(values: readonly number[]): number {
|
||||
return ((sorted[middle - 1] ?? value) + value) / 2;
|
||||
}
|
||||
|
||||
function mergeInlineScriptGroups(
|
||||
sourceGroups: readonly PdfTextItemSnapshot[][],
|
||||
typicalHeight: number,
|
||||
): PdfTextItemSnapshot[][] {
|
||||
const groups = sourceGroups.map((group) => [...group]);
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
const group = groups[index];
|
||||
if (!group || group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const groupHeight = median(group.map((item) => item.bounds.height));
|
||||
if (groupHeight > typicalHeight * 0.9) {
|
||||
continue;
|
||||
}
|
||||
const groupBounds = unionBounds(group);
|
||||
for (const adjacentIndex of [index - 1, index + 1]) {
|
||||
const adjacent = groups[adjacentIndex];
|
||||
if (!adjacent || adjacent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const adjacentHeight = median(
|
||||
adjacent.map((item) => item.bounds.height),
|
||||
);
|
||||
if (adjacentHeight < groupHeight / 0.9) {
|
||||
continue;
|
||||
}
|
||||
const adjacentBounds = unionBounds(adjacent);
|
||||
const verticalOverlap = Math.min(
|
||||
groupBounds.y + groupBounds.height,
|
||||
adjacentBounds.y + adjacentBounds.height,
|
||||
) - Math.max(groupBounds.y, adjacentBounds.y);
|
||||
const horizontallyContained =
|
||||
groupBounds.x >= adjacentBounds.x - typicalHeight * 0.5 &&
|
||||
groupBounds.x + groupBounds.width <=
|
||||
adjacentBounds.x + adjacentBounds.width + typicalHeight * 0.5;
|
||||
// Chromium 会把行末公式按较小字号单独提取;公式片段可能从正文
|
||||
// 最后一个字符的右缘开始,并向右超出正文组,因此不能只用“被正文
|
||||
// 水平包含”判断同行。仅在两组真实垂直重叠且小字号组紧贴较大字号
|
||||
// 组右缘时合并,避免把下一视觉行或相邻表格单元格误并入当前行。
|
||||
const adjacentRight = adjacentBounds.x + adjacentBounds.width;
|
||||
const touchesAdjacentRight =
|
||||
groupBounds.x >= adjacentBounds.x &&
|
||||
Math.abs(groupBounds.x - adjacentRight) <= typicalHeight * 0.5;
|
||||
if (
|
||||
verticalOverlap <= 0 ||
|
||||
(!horizontallyContained && !touchesAdjacentRight)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
adjacent.push(...group);
|
||||
groups[index] = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return groups.filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
|
||||
let result = "";
|
||||
let previous: PdfTextItemSnapshot | undefined;
|
||||
@@ -176,6 +252,9 @@ export function aggregatePdfTextLines(
|
||||
sourceItems: readonly PdfTextItemSnapshot[],
|
||||
pageHeightPt: number,
|
||||
): PdfTextLineSnapshot[] {
|
||||
const sourceOrder = new Map(
|
||||
sourceItems.map((item, index) => [item, index] as const),
|
||||
);
|
||||
const items = sourceItems
|
||||
.filter((item) => item.normalizedText.length > 0)
|
||||
.sort(
|
||||
@@ -183,11 +262,14 @@ export function aggregatePdfTextLines(
|
||||
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
|
||||
);
|
||||
const typicalHeight = median(items.map((item) => item.bounds.height));
|
||||
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
|
||||
const groups: PdfTextItemSnapshot[][] = [];
|
||||
// Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
|
||||
// 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
|
||||
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
|
||||
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
|
||||
const initialGroups: PdfTextItemSnapshot[][] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const lastGroup = groups.at(-1);
|
||||
const lastGroup = initialGroups.at(-1);
|
||||
const lastBaseline = lastGroup
|
||||
? median(lastGroup.map((entry) => entry.baselineY))
|
||||
: undefined;
|
||||
@@ -198,13 +280,27 @@ export function aggregatePdfTextLines(
|
||||
) {
|
||||
lastGroup.push(item);
|
||||
} else {
|
||||
groups.push([item]);
|
||||
initialGroups.push([item]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
|
||||
|
||||
return groups.map((group) => {
|
||||
const sortedItems = [...group].sort(
|
||||
(left, right) => left.bounds.x - right.bounds.x,
|
||||
(left, right) => {
|
||||
const leftRight = left.bounds.x + left.bounds.width;
|
||||
const rightRight = right.bounds.x + right.bounds.width;
|
||||
const horizontallyOverlapping =
|
||||
left.bounds.x < rightRight && right.bounds.x < leftRight;
|
||||
// PDF.js 的文本流保留字符阅读顺序,但不同字体的窄标点可能与
|
||||
// 相邻全角字符发生水平重叠。此时单纯按 x 排序会把闭引号移到
|
||||
// 左括号之后,破坏语义块定位;只对真实重叠片段保留文本流顺序,
|
||||
// 其余片段仍按几何位置排序,兼容 Office 表格等非阅读序文本流。
|
||||
return horizontallyOverlapping
|
||||
? (sourceOrder.get(left) ?? 0) - (sourceOrder.get(right) ?? 0)
|
||||
: left.bounds.x - right.bounds.x;
|
||||
},
|
||||
);
|
||||
const bounds = unionBounds(sortedItems);
|
||||
const normalizedText = joinLineItems(sortedItems);
|
||||
|
||||
@@ -19,9 +19,19 @@ export interface PdfTextItemSnapshot {
|
||||
bounds: PdfPointBounds;
|
||||
baselineY: number;
|
||||
fontName?: string;
|
||||
fontFamily?: string;
|
||||
fallbackFontFamily?: string;
|
||||
hasEol: boolean;
|
||||
}
|
||||
|
||||
export interface PdfFontSnapshot {
|
||||
internalName: string;
|
||||
name: string;
|
||||
fallbackFamily?: string;
|
||||
isType3Font: boolean;
|
||||
missingFile: boolean;
|
||||
}
|
||||
|
||||
export type PdfTextLineRole = "content" | "page-number";
|
||||
|
||||
export interface PdfTextLineSnapshot {
|
||||
@@ -46,11 +56,30 @@ export type EditableParagraphRole =
|
||||
| "caption"
|
||||
| "other";
|
||||
|
||||
export type EditableParagraphBlockKind =
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
| "list-item"
|
||||
| "table-header"
|
||||
| "table-cell"
|
||||
| "code-block"
|
||||
| "block-quote"
|
||||
| "caption"
|
||||
| "semantic-region"
|
||||
| "other";
|
||||
|
||||
export interface EditableParagraphExpectation {
|
||||
index: number;
|
||||
text: string;
|
||||
mathCharacterIndexes?: number[];
|
||||
hardBreakSegments?: string[];
|
||||
hasInlineCode?: boolean;
|
||||
tableGroupId?: string;
|
||||
tableRowIndex?: number;
|
||||
tableColumnIndex?: number;
|
||||
styleId?: string;
|
||||
role: EditableParagraphRole;
|
||||
blockKind?: EditableParagraphBlockKind;
|
||||
section: "cover" | "body";
|
||||
}
|
||||
|
||||
@@ -68,6 +97,13 @@ export interface PdfParagraphLayoutObservation {
|
||||
averageLineHeightPt?: number;
|
||||
averageBaselineGapPt?: number;
|
||||
exclusiveGeometry: boolean;
|
||||
visualLines: PdfParagraphVisualLineObservation[];
|
||||
}
|
||||
|
||||
export interface PdfParagraphVisualLineObservation {
|
||||
pageNumber: number;
|
||||
bounds: PdfPointBounds;
|
||||
fontFamilies: string[];
|
||||
}
|
||||
|
||||
export interface PdfParagraphLayoutComparison {
|
||||
@@ -78,6 +114,24 @@ export interface PdfParagraphLayoutComparison {
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfSemanticBlockVisualLineComparison {
|
||||
lineIndex: number;
|
||||
baselinePageNumber: number;
|
||||
candidatePageNumber: number;
|
||||
metrics?: PdfRasterDiffMetrics;
|
||||
artifacts?: PdfRasterDiffArtifacts;
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfSemanticBlockVisualComparison {
|
||||
expectation: EditableParagraphExpectation;
|
||||
status: "passed" | "warning" | "failed";
|
||||
baselineFontFamilies: string[];
|
||||
candidateFontFamilies: string[];
|
||||
lines: PdfSemanticBlockVisualLineComparison[];
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfBodyFlowComparison {
|
||||
detected: boolean;
|
||||
legal: boolean;
|
||||
@@ -96,6 +150,7 @@ export interface PdfPageRaster {
|
||||
dpi: number;
|
||||
sha256: string;
|
||||
png: Uint8Array;
|
||||
rgba?: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
export interface PdfPageSnapshot {
|
||||
@@ -106,6 +161,7 @@ export interface PdfPageSnapshot {
|
||||
items: PdfTextItemSnapshot[];
|
||||
lines: PdfTextLineSnapshot[];
|
||||
contentText: string;
|
||||
fonts: PdfFontSnapshot[];
|
||||
pageNumberText?: string;
|
||||
raster?: PdfPageRaster;
|
||||
}
|
||||
@@ -116,6 +172,7 @@ export interface PdfDocumentSnapshot {
|
||||
sha256: string;
|
||||
pageCount: number;
|
||||
contentText: string;
|
||||
fonts: PdfFontSnapshot[];
|
||||
pages: PdfPageSnapshot[];
|
||||
}
|
||||
|
||||
@@ -138,10 +195,15 @@ export interface VisualDiffThresholds {
|
||||
contentSimilarity: number;
|
||||
strictPageCount: boolean;
|
||||
pixelDifferenceThreshold: number;
|
||||
spatialTolerancePx: number;
|
||||
maxMeanAbsoluteError: number;
|
||||
maxChangedPixelRatio: number;
|
||||
minInkIou: number;
|
||||
minEdgeIou: number;
|
||||
maxBackgroundColorDelta: number;
|
||||
maxForegroundColorDelta: number;
|
||||
minAntialiasEquivalentInkIou: number;
|
||||
minAntialiasEquivalentEdgeIou: number;
|
||||
}
|
||||
|
||||
export type VisualDiffIssueSeverity = "warning" | "failure";
|
||||
@@ -171,9 +233,12 @@ export type VisualDiffIssueCode =
|
||||
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
|
||||
| "PARAGRAPH_LINE_BREAK_MISMATCH"
|
||||
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
|
||||
| "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
|
||||
| "SEMANTIC_BLOCK_RASTER_MISMATCH"
|
||||
| "COVER_LAYOUT_MISMATCH"
|
||||
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
|
||||
| "BODY_FLOW_RASTER_DIFFERENCE";
|
||||
| "BODY_FLOW_RASTER_DIFFERENCE"
|
||||
| "BODY_PAGE_RASTER_DIFFERENCE";
|
||||
|
||||
export interface VisualDiffIssue {
|
||||
code: VisualDiffIssueCode;
|
||||
@@ -210,6 +275,7 @@ export interface PdfBasicComparison {
|
||||
candidateEditableSimilarity?: number;
|
||||
candidateEditableExact?: boolean;
|
||||
paragraphLayouts?: PdfParagraphLayoutComparison[];
|
||||
semanticBlockVisuals?: PdfSemanticBlockVisualComparison[];
|
||||
bodyFlow?: PdfBodyFlowComparison;
|
||||
pagePairs: PdfPagePair[];
|
||||
issues: VisualDiffIssue[];
|
||||
@@ -224,14 +290,25 @@ export interface PdfRasterDiffMetrics {
|
||||
comparedHeightPx: number;
|
||||
dimensionsMatch: boolean;
|
||||
geometryNormalized: boolean;
|
||||
spatialTolerancePx: number;
|
||||
meanAbsoluteError: number;
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
foregroundInkIou: number;
|
||||
edgeIou: number;
|
||||
backgroundColorDelta: number;
|
||||
foregroundColorDelta: number;
|
||||
baselineForegroundChroma?: number;
|
||||
candidateForegroundChroma?: number;
|
||||
foregroundLuminanceDelta?: number;
|
||||
dominantForegroundColorDelta?: number;
|
||||
chromaticForegroundColorDelta?: number;
|
||||
chromaticPaletteDelta?: number;
|
||||
}
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
pixelDifferenceThreshold?: number;
|
||||
spatialTolerancePx?: number;
|
||||
targetWidthPx?: number;
|
||||
targetHeightPx?: number;
|
||||
geometryNormalized?: boolean;
|
||||
@@ -255,7 +332,7 @@ export interface PdfRasterDiffResult {
|
||||
|
||||
export interface PdfVisualPageComparison {
|
||||
pair: PdfPagePair;
|
||||
rasterComparisonMode?: "strict" | "body-flow";
|
||||
rasterComparisonMode?: "strict" | "body-flow" | "body-block";
|
||||
status: "passed" | "warning" | "failed";
|
||||
metrics?: PdfRasterDiffMetrics;
|
||||
artifacts?: PdfRasterDiffArtifacts;
|
||||
|
||||
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("Office 偶发未生成 PDF 时清理残留并有界重试", async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "visual-diff-office-retry-test-"),
|
||||
);
|
||||
const docxPath = join(directory, "fixture.docx");
|
||||
await writeFile(docxPath, "fixture");
|
||||
let attempts = 0;
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
await writeFile(options.outputPath, "partial");
|
||||
throw new Error("Office 未生成有效 PDF");
|
||||
}
|
||||
const pdf = createMinimalPdf("retry");
|
||||
await writeFile(options.outputPath, pdf);
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await createWordPdfAdapter({ backend }).generate({
|
||||
docxPath,
|
||||
});
|
||||
expect(result.pageCount).toBe(1);
|
||||
expect(attempts).toBe(2);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF 适配器视觉编排", () => {
|
||||
|
||||
@@ -121,6 +121,38 @@ describe("PDF 基础视觉门禁", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("逐段完整映射时忽略 PDF 页码与表格行聚合造成的串联顺序污染", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("门禁 内容", 100),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("门禁 2 / 2 内容", 100),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "门禁内容",
|
||||
expectedEditableParagraphs: [
|
||||
{
|
||||
index: 0,
|
||||
text: "门禁",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "内容",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.candidateEditableExact).toBe(true);
|
||||
expect(result.issues.map((issue) => issue.code)).not.toContain(
|
||||
"CONTENT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,18 +48,141 @@ describe("PDF 栅格差异", () => {
|
||||
const result = await comparePageRasters(page, page);
|
||||
expect(result.metrics).toMatchObject({
|
||||
dimensionsMatch: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 0,
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
});
|
||||
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
);
|
||||
});
|
||||
|
||||
it("分别量化块背景色与背景相对的前景形状", async () => {
|
||||
const coloredRaster = (background: string, foreground: string) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = background;
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = foreground;
|
||||
context.fillRect(20, 10, 40, 20);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const sameShape = await comparePageRasters(
|
||||
coloredRaster("#f2f2f2", "#202020"),
|
||||
coloredRaster("#ffffff", "#202020"),
|
||||
);
|
||||
|
||||
expect(sameShape.metrics.inkIou).toBe(1);
|
||||
expect(sameShape.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("双背景占比翻转时仍独立量化核心字形拓扑", async () => {
|
||||
const inlineCodeRaster = (fullHeightShading: boolean) => {
|
||||
const canvas = createCanvas(216, 25);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 216, 25);
|
||||
context.fillStyle = "#f3f3f3";
|
||||
context.fillRect(28, fullHeightShading ? 0 : 5, 120, fullHeightShading ? 25 : 16);
|
||||
context.fillStyle = "#333333";
|
||||
context.fillRect(34, 10, 80, 4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 216,
|
||||
heightPx: 25,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const result = await comparePageRasters(
|
||||
inlineCodeRaster(false),
|
||||
inlineCodeRaster(true),
|
||||
);
|
||||
|
||||
expect(result.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(result.metrics.foregroundInkIou).toBe(1);
|
||||
});
|
||||
|
||||
it("渐变背景不会把深色文字误判为背景", async () => {
|
||||
const gradientRaster = (gradient: boolean) => {
|
||||
const canvas = createCanvas(160, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
if (gradient) {
|
||||
const fill = context.createLinearGradient(0, 0, 160, 0);
|
||||
fill.addColorStop(0, "#edf5fa");
|
||||
fill.addColorStop(1, "#ffffff");
|
||||
context.fillStyle = fill;
|
||||
} else {
|
||||
context.fillStyle = "#f6fafc";
|
||||
}
|
||||
context.fillRect(0, 0, 160, 40);
|
||||
context.fillStyle = "#0b3557";
|
||||
context.fillRect(4, 4, 148, 32);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 160,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
gradientRaster(true),
|
||||
gradientRaster(false),
|
||||
);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(8);
|
||||
expect(result.metrics.inkIou).toBeGreaterThan(0.95);
|
||||
});
|
||||
|
||||
it("裁剪跨过深色盒边界时不会把外部白色误判为盒背景", async () => {
|
||||
const boxedRaster = (exposeOutside: boolean) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = "#464646";
|
||||
context.fillRect(0, 0, exposeOutside ? 68 : 80, exposeOutside ? 32 : 40);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(18, 10, 32, 16);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
boxedRaster(true),
|
||||
boxedRaster(false),
|
||||
);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20));
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
});
|
||||
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
|
||||
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
|
||||
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
|
||||
@@ -68,6 +191,30 @@ describe("PDF 栅格差异", () => {
|
||||
expect(result.artifacts.heatmapSha256).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("只容忍三个像素以内的跨引擎栅格偏移", async () => {
|
||||
const near = await comparePageRasters(raster(10), raster(13));
|
||||
const far = await comparePageRasters(raster(10), raster(17));
|
||||
|
||||
expect(near.metrics.inkIou).toBe(1);
|
||||
expect(near.metrics.edgeIou).toBe(1);
|
||||
expect(far.metrics.inkIou).toBeLessThan(0.75);
|
||||
expect(far.metrics.edgeIou).toBeLessThan(0.6);
|
||||
});
|
||||
|
||||
it("空间容差不改变原始像素热力图", async () => {
|
||||
const baseline = raster(10);
|
||||
const candidate = raster(12);
|
||||
const tolerant = await comparePageRasters(baseline, candidate);
|
||||
const exact = await comparePageRasters(baseline, candidate, {
|
||||
spatialTolerancePx: 0,
|
||||
});
|
||||
|
||||
expect(tolerant.metrics.inkIou).toBeGreaterThan(exact.metrics.inkIou);
|
||||
expect(tolerant.artifacts.heatmapSha256).toBe(
|
||||
exact.artifacts.heatmapSha256,
|
||||
);
|
||||
});
|
||||
|
||||
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
|
||||
const result = await comparePageRasters(
|
||||
differentlySizedRaster(80, 100),
|
||||
|
||||
@@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
getBlockingVisualDiffIssues,
|
||||
isCrossEngineRasterEquivalent,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
@@ -30,6 +32,80 @@ function raster(color: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function flowRaster(color: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(34, 5, 6, 40);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function blockRaster(color: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(8, 12, 20, 10);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = edgeColor;
|
||||
context.fillRect(8, 12, 20, 10);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(10, 14, 16, 6);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function adjacentLineRaster(includePreviousLine: boolean): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
if (includePreviousLine) {
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 12, 20, 3);
|
||||
}
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 20, 20, 4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
@@ -82,7 +158,7 @@ function flowSnapshot(
|
||||
items: [],
|
||||
lines: [...lines],
|
||||
contentText: lines.map((line) => line.normalizedText).join(""),
|
||||
raster: raster(color),
|
||||
raster: flowRaster(color),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -387,7 +463,63 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
|
||||
it("允许块内轻微换行警告伴随正文自然跨页", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲乙丙丁", 8), contentLine("戊己庚辛", 12)],
|
||||
[contentLine("壬癸", 8)],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[
|
||||
contentLine("甲乙丙丁戊己", 8),
|
||||
contentLine("庚辛", 12),
|
||||
contentLine("壬癸", 16),
|
||||
],
|
||||
[],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const paragraphs = [
|
||||
{
|
||||
index: 0,
|
||||
text: "甲乙丙丁戊己庚辛",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "壬癸",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
] as const;
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙丁戊己庚辛壬癸",
|
||||
expectedEditableParagraphs: paragraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.basic.paragraphLayouts?.[0]).toMatchObject({
|
||||
status: "warning",
|
||||
});
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
detected: true,
|
||||
legal: true,
|
||||
movedParagraphIndexes: [1],
|
||||
});
|
||||
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
|
||||
"body-flow",
|
||||
"body-flow",
|
||||
]);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
@@ -411,7 +543,7 @@ describe("PDF 视觉差异报告", () => {
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||
expect(report.pages.every(
|
||||
(page) => page.rasterComparisonMode === "strict",
|
||||
(page) => page.rasterComparisonMode === "body-block",
|
||||
)).toBe(true);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
@@ -451,6 +583,58 @@ describe("PDF 视觉差异报告", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("正文页数不同时分别验证未配对页面自身的页码语义", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 9, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number",
|
||||
items: [],
|
||||
});
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲", 8), pageNumber("1 / 2")],
|
||||
[contentLine("乙丙", 8), pageNumber("2 / 2")],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[
|
||||
contentLine("甲", 8),
|
||||
contentLine("乙丙", 12),
|
||||
pageNumber("1 / 1"),
|
||||
]],
|
||||
"#cc0000",
|
||||
);
|
||||
const pageSemantics = (count: number) => bodySemantics(count).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: `${index + 1} / ${count}`,
|
||||
}),
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: pageSemantics(2),
|
||||
candidatePageSemantics: pageSemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
legal: true,
|
||||
baselineBodyPageCount: 2,
|
||||
candidateBodyPageCount: 1,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_PAGE_COUNT_DIFFERENCE",
|
||||
);
|
||||
});
|
||||
|
||||
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
@@ -498,4 +682,169 @@ describe("PDF 视觉差异报告", () => {
|
||||
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("正文整页差异只作诊断,但语义块局部视觉变化必须失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = blockRaster("#111111");
|
||||
candidate.pages[0]!.raster = blockRaster("#cc0000");
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲",
|
||||
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.pages[0]?.rasterComparisonMode).toBe("body-block");
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_PAGE_RASTER_DIFFERENCE",
|
||||
);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("正文文字字形与前景色一致时忽略跨引擎抗锯齿灰度噪声", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = antialiasBlockRaster("#999999");
|
||||
candidate.pages[0]!.raster = antialiasBlockRaster("#aaaaaa");
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲",
|
||||
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
expect(report.basic.semanticBlockVisuals[0]?.lines[0]?.metrics)
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("局部视觉裁剪不得把目标行上方的相邻行墨迹纳入比较", async () => {
|
||||
const targetLine = contentLine("乙", 10);
|
||||
targetLine.bounds = { x: 4, y: 10, width: 10, height: 2 };
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = adjacentLineRaster(true);
|
||||
candidate.pages[0]!.raster = adjacentLineRaster(false);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "乙",
|
||||
expectedEditableParagraphs: [{
|
||||
index: 0,
|
||||
text: "乙",
|
||||
role: "body",
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
}],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("仅对带行内代码结构语义的混排允许 WPS 底纹栅格混合差异", () => {
|
||||
const metrics = {
|
||||
baselineWidthPx: 236,
|
||||
baselineHeightPx: 24,
|
||||
candidateWidthPx: 233,
|
||||
candidateHeightPx: 26,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 26,
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 22.1,
|
||||
changedPixelRatio: 0.449,
|
||||
inkIou: 0.908,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.38,
|
||||
foregroundColorDelta: 2.6,
|
||||
};
|
||||
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
)).toBe(true);
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
false,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
snapshot("candidate", raster("#cc0000")),
|
||||
{ pageSemantics: bodySemantics(1) },
|
||||
);
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.pages[0]?.rasterComparisonMode).toBe("strict");
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"CHANGED_PIXEL_RATIO_EXCEEDED",
|
||||
);
|
||||
});
|
||||
|
||||
it("所有 failure 级视觉问题都必须进入发布阻断集合", () => {
|
||||
const failure = {
|
||||
code: "INK_IOU_BELOW_THRESHOLD" as const,
|
||||
severity: "failure" as const,
|
||||
message: "墨迹 IoU 未达到门限",
|
||||
};
|
||||
const warning = {
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
||||
severity: "warning" as const,
|
||||
message: "正文自然分页差异",
|
||||
};
|
||||
|
||||
expect(getBlockingVisualDiffIssues({ issues: [failure, warning] })).toEqual([
|
||||
failure,
|
||||
]);
|
||||
});
|
||||
|
||||
it("正文自然分页 warning 不阻断发布", () => {
|
||||
expect(
|
||||
getBlockingVisualDiffIssues({
|
||||
issues: [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
||||
severity: "warning",
|
||||
message: "正文自然分页差异",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
|
||||
import {
|
||||
comparePdfSemanticBlockVisuals,
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
|
||||
function metrics(
|
||||
overrides: Partial<PdfRasterMetrics> = {},
|
||||
): PdfRasterMetrics {
|
||||
return {
|
||||
baselineWidthPx: 224,
|
||||
baselineHeightPx: 37,
|
||||
candidateWidthPx: 224,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 224,
|
||||
comparedHeightPx: 37,
|
||||
dimensionsMatch: true,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 14.5,
|
||||
changedPixelRatio: 0.27,
|
||||
inkIou: 0.942,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0.02,
|
||||
foregroundColorDelta: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function whitePageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
context.fillStyle = "#111";
|
||||
context.fillRect(40, 40, 120, 12);
|
||||
context.fillRect(40, 80, 120, 12);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "test",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
function blankPageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "blank",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("缺少局部视觉行时必须阻断而不能静默通过", async () => {
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [] } as never,
|
||||
{ pages: [] } as never,
|
||||
[{
|
||||
expectation: { index: 39, section: "body", blockKind: "table-cell" },
|
||||
baseline: { matched: true, visualLines: [], lineTexts: [] },
|
||||
candidate: { matched: true, visualLines: [], lineTexts: [] },
|
||||
}] as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("failed");
|
||||
expect(comparison?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: whitePageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 120, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 7,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40), visualLine(80)],
|
||||
lineTexts: ["甲乙", "丙丁"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["甲乙丙丁"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(2);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
expect(comparison?.lines.every((line) =>
|
||||
line.issues.some((issue) => issue.details?.textReflow === true)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 118,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 10,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40), visualLine(70)],
|
||||
lineTexts: ["高血压预测模型目标", "值"],
|
||||
},
|
||||
candidate: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 9,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["高血压预测模型目标"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = {
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y: 40, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Serif"],
|
||||
};
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 71,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13],
|
||||
},
|
||||
baseline: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 12,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 14,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥90"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number, width: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 117,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)],
|
||||
lineTexts: ["记", "录/", "计算"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)],
|
||||
lineTexts: ["记录", "/计", "算"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(5);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 222,
|
||||
inkIou: 0.88,
|
||||
foregroundColorDelta: 3.5,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 56,
|
||||
candidateWidthPx: 56,
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 56,
|
||||
comparedHeightPx: 37,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 17,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 17,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
const neutralSingleStrokeMetrics = metrics({
|
||||
baselineWidthPx: 37,
|
||||
candidateWidthPx: 37,
|
||||
baselineHeightPx: 27,
|
||||
candidateHeightPx: 27,
|
||||
comparedWidthPx: 37,
|
||||
comparedHeightPx: 27,
|
||||
meanAbsoluteError: 12.78,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 10.54,
|
||||
foregroundColorDelta: 108.69,
|
||||
baselineForegroundChroma: 10,
|
||||
candidateForegroundChroma: 5,
|
||||
foregroundLuminanceDelta: 109.27,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
neutralSingleStrokeMetrics,
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
false,
|
||||
"table-header",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 0.969, edgeIou: 0.983 }),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.902,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 233,
|
||||
baselineHeightPx: 36,
|
||||
candidateHeightPx: 38,
|
||||
inkIou: 0.897,
|
||||
edgeIou: 0.9996,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 272,
|
||||
candidateWidthPx: 286,
|
||||
baselineHeightPx: 43,
|
||||
candidateHeightPx: 42,
|
||||
inkIou: 0.948,
|
||||
edgeIou: 0.958,
|
||||
foregroundColorDelta: 4.2,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
comparedWidthPx: 262,
|
||||
comparedHeightPx: 30,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => {
|
||||
const paletteMetrics = metrics({
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 35,
|
||||
baselineForegroundChroma: 141,
|
||||
candidateForegroundChroma: 176,
|
||||
dominantForegroundColorDelta: 0.5,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, dominantForegroundColorDelta: 4.1 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, baselineForegroundChroma: 79 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => {
|
||||
const syntaxMetrics = metrics({
|
||||
baselineWidthPx: 407,
|
||||
candidateWidthPx: 403,
|
||||
baselineHeightPx: 22,
|
||||
candidateHeightPx: 23,
|
||||
inkIou: 0.988,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 92,
|
||||
chromaticPaletteDelta: 0.8,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, chromaticPaletteDelta: 2.1 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, foregroundInkIou: 0.994 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => {
|
||||
const inlineCodeMetrics = metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 234,
|
||||
baselineHeightPx: 24,
|
||||
candidateHeightPx: 25,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 25,
|
||||
inkIou: 0.91759,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.317,
|
||||
foregroundColorDelta: 3.158,
|
||||
});
|
||||
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
inlineCodeMetrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, foregroundColorDelta: 3.26 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, backgroundColorDelta: 12.01 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.99,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.97,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ foregroundColorDelta: 4.01 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ candidateWidthPx: 220 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 207,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 2.01,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 5.01,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 5.01,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.89,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 40,
|
||||
inkIou: 0.95,
|
||||
edgeIou: 1,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,18 @@ describe("PDF.js 页面快照", () => {
|
||||
expect(first.pages[0]?.widthPt).toBeCloseTo(595, 3);
|
||||
expect(first.pages[0]?.heightPt).toBeCloseTo(842, 3);
|
||||
expect(first.contentText).toContain("Visual diff fixture");
|
||||
expect(first.fonts).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Helvetica",
|
||||
fallbackFamily: "sans-serif",
|
||||
isType3Font: false,
|
||||
missingFile: true,
|
||||
}),
|
||||
]);
|
||||
expect(first.pages[0]?.items[0]).toMatchObject({
|
||||
fontFamily: "Helvetica",
|
||||
fallbackFontFamily: "sans-serif",
|
||||
});
|
||||
expect(first.pages[0]?.raster).toMatchObject({
|
||||
widthPx: 1190,
|
||||
heightPx: 1684,
|
||||
|
||||
@@ -40,8 +40,88 @@ describe("PDF 文本行聚合", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("合并 Office 同一视觉行内小于字体高度两成的基线漂移", () => {
|
||||
const left = item("源系统", 10, 100, 30, 10);
|
||||
const middle = item("nut_pickup_order.meal_type", 42, 101.8, 140, 10);
|
||||
const right = item("原值", 184, 100, 20, 10);
|
||||
const lines = aggregatePdfTextLines([left, middle, right], 842);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe(
|
||||
"源系统nut_pickup_order.meal_type原值",
|
||||
);
|
||||
});
|
||||
|
||||
it("将同行公式的上标片段按横坐标并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("减因子", 10, 100, 42, 12),
|
||||
item("e", 53, 97, 8, 15),
|
||||
item("−λΔt", 62, 96, 24, 10),
|
||||
item("的动态权重", 87, 100, 70, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe("减因子e−λΔt的动态权重");
|
||||
});
|
||||
|
||||
it("将超出正文右缘但紧邻行末的公式片段并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e", 150, 469.5, 346, 12),
|
||||
item("−λΔt", 495.7, 466.1, 26, 10.2),
|
||||
item("下一视觉行", 150, 489.7, 60, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines.map((line) => line.normalizedText)).toEqual([
|
||||
"性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e−λΔt",
|
||||
"下一视觉行",
|
||||
]);
|
||||
});
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛⻝⻣⻋⻬")).toBe(
|
||||
"见角长门页风食骨车齐",
|
||||
);
|
||||
});
|
||||
|
||||
it("统一 Chromium 与 Office PDF 文本层的中英文弯引号", () => {
|
||||
expect(normalizePdfText("“记录”与‘计算’")).toBe('"记录"与\'计算\'');
|
||||
});
|
||||
|
||||
it("保留与全角括号重叠的窄引号文本流顺序", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("主观定性判断", 532.3, 151.5, 58.5),
|
||||
item("”", 590.8, 151.5, 3.4),
|
||||
item("(如", 589.3, 151.5, 19.5),
|
||||
item("“", 608.8, 151.5, 3.4),
|
||||
item("高心率占比", 612.2, 151.5, 48.8),
|
||||
],
|
||||
595,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe('主观定性判断"(如"高心率占比');
|
||||
});
|
||||
|
||||
it("忽略 Emoji 文本层可选的变体选择符", () => {
|
||||
expect(normalizePdfText("⚠️ 提示")).toBe("⚠ 提示");
|
||||
});
|
||||
|
||||
it("统一 CJK 字体文本层的 em dash 与 horizontal bar", () => {
|
||||
expect(normalizePdfText("仓库―仓区")).toBe("仓库—仓区");
|
||||
});
|
||||
|
||||
it("统一数字区间中被 Chromium 提取为双连字符的 en dash", () => {
|
||||
expect(normalizePdfText("10% -- 20%")).toBe("10%–20%");
|
||||
expect(normalizePdfText("10% --")).toBe("10%–");
|
||||
expect(normalizePdfText("命令 --flag")).toBe("命令 --flag");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
@@ -98,6 +98,64 @@ local function replace_code_block(block)
|
||||
return pandoc.Para { image }
|
||||
end
|
||||
|
||||
local function preserve_raw_html_as_text(inline)
|
||||
if inline.format == "html" then
|
||||
return pandoc.Str(inline.text)
|
||||
end
|
||||
return inline
|
||||
end
|
||||
|
||||
local function preserve_raw_html_block_as_text(block)
|
||||
if block.format == "html" then
|
||||
return pandoc.Para { pandoc.Str(block.text) }
|
||||
end
|
||||
return block
|
||||
end
|
||||
|
||||
local function replace_table_breaks(table_block)
|
||||
return table_block:walk {
|
||||
RawInline = function(inline)
|
||||
if inline.format ~= "html" then
|
||||
return inline
|
||||
end
|
||||
local normalized = string.lower(inline.text)
|
||||
if normalized == "<br>"
|
||||
or normalized == "<br/>"
|
||||
or normalized == "<br />" then
|
||||
return pandoc.LineBreak()
|
||||
end
|
||||
return inline
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
local function normalize_task_list(list)
|
||||
for _, item in ipairs(list.content) do
|
||||
local block = item[1]
|
||||
if block ~= nil and (block.t == "Plain" or block.t == "Para") then
|
||||
local inlines = block.content
|
||||
local first = inlines[1]
|
||||
if first ~= nil and first.t == "Str" then
|
||||
local marker = string.lower(first.text)
|
||||
if marker == "[x]" then
|
||||
first.text = "☒"
|
||||
elseif marker == "[" then
|
||||
local second = inlines[2]
|
||||
local third = inlines[3]
|
||||
if second ~= nil and second.t == "Space"
|
||||
and third ~= nil and third.t == "Str"
|
||||
and third.text == "]" then
|
||||
first.text = "☐"
|
||||
inlines:remove(3)
|
||||
inlines:remove(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function append_inlines(target, value)
|
||||
target:extend(pandoc.Inlines(value))
|
||||
end
|
||||
@@ -167,7 +225,10 @@ local function validate_counts()
|
||||
error(
|
||||
"DOCX media map contains unused "
|
||||
.. kind
|
||||
.. " resources"
|
||||
.. " resources: consumed "
|
||||
.. tostring(counters[kind])
|
||||
.. " of "
|
||||
.. tostring(#media_map[kind])
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -176,8 +237,14 @@ end
|
||||
return {
|
||||
Pandoc = function(document)
|
||||
local transformed = document:walk {
|
||||
Table = replace_table_breaks
|
||||
}
|
||||
transformed = transformed:walk {
|
||||
Image = replace_image,
|
||||
CodeBlock = replace_code_block
|
||||
CodeBlock = replace_code_block,
|
||||
RawInline = preserve_raw_html_as_text,
|
||||
RawBlock = preserve_raw_html_block_as_text,
|
||||
BulletList = normalize_task_list
|
||||
}
|
||||
if structure_plan.titlePolicy.metadataTitle == "suppress" then
|
||||
transformed.meta.title = nil
|
||||
|
||||
@@ -424,7 +424,7 @@ for (const theme of themes) {
|
||||
let conversion;
|
||||
try {
|
||||
conversion = await converter.convert({
|
||||
markdown,
|
||||
markdown: rendered.markdownBody,
|
||||
fileName: `${theme.id}.md`,
|
||||
language: rendered.metadata.language,
|
||||
exportConfig,
|
||||
@@ -433,7 +433,7 @@ for (const theme of themes) {
|
||||
metadata: rendered.metadata,
|
||||
semanticDocument: rendered.semanticDocument,
|
||||
fonts: readThemeFonts(theme),
|
||||
media: createMedia(markdown)
|
||||
media: createMedia(rendered.markdownBody)
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
|
||||
referenceCacheSize?: number;
|
||||
}
|
||||
|
||||
export interface PandocDocxConversionDiagnostics {
|
||||
outcome: string;
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class PandocDocxConversionError extends Error {
|
||||
constructor(
|
||||
readonly code: Extract<
|
||||
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
|
||||
| "DOCX_OUTPUT_INVALID"
|
||||
>,
|
||||
message: string,
|
||||
options?: ErrorOptions
|
||||
options?: ErrorOptions & {
|
||||
diagnostics?: PandocDocxConversionDiagnostics;
|
||||
}
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "PandocDocxConversionError";
|
||||
this.diagnostics = options?.diagnostics;
|
||||
}
|
||||
|
||||
readonly diagnostics: PandocDocxConversionDiagnostics | undefined;
|
||||
}
|
||||
|
||||
function processDiagnostics(
|
||||
result: Awaited<ReturnType<PandocProcessRunner>>
|
||||
): PandocDocxConversionDiagnostics {
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.trim().slice(-16 * 1024)
|
||||
};
|
||||
}
|
||||
|
||||
function ensureOutputLimit(value: number | undefined) {
|
||||
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
|
||||
return [
|
||||
paths.markdown,
|
||||
"--from",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars",
|
||||
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html",
|
||||
"--to",
|
||||
"docx",
|
||||
"--standalone",
|
||||
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
|
||||
processResult.outcome === "not-found"
|
||||
? "DOCX_RUNTIME_NOT_FOUND"
|
||||
: "DOCX_GENERATION_FAILED",
|
||||
"Pandoc 未能生成 DOCX"
|
||||
"Pandoc 未能生成 DOCX",
|
||||
{ diagnostics: processDiagnostics(processResult) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -375,7 +397,8 @@ export class PandocDocxConverter {
|
||||
pandocDocx,
|
||||
structurePlan,
|
||||
input.themeTokens,
|
||||
preparedMedia.layout
|
||||
preparedMedia.layout,
|
||||
preparedMedia.documentLayout
|
||||
);
|
||||
docx = embedFontsInGeneratedDocx(
|
||||
finalized.content,
|
||||
|
||||
@@ -2,10 +2,11 @@ import {
|
||||
DOCX_MEDIA_CSS_DPI,
|
||||
MAXIMUM_DOCX_MEDIA_BYTES,
|
||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
type PreparedDocxMedia
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
@@ -47,6 +48,7 @@ export interface PreparedPandocMedia {
|
||||
map: PandocMediaMap;
|
||||
files: PandocMediaFile[];
|
||||
layout: PandocMediaLayoutItem[];
|
||||
documentLayout: DocxDocumentLayoutPlan;
|
||||
}
|
||||
|
||||
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
|
||||
@@ -99,7 +101,7 @@ export function preparePandocMedia(
|
||||
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
||||
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
||||
}
|
||||
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
||||
if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
|
||||
throw new Error("DOCX 媒体数量超过限制");
|
||||
}
|
||||
|
||||
@@ -184,5 +186,10 @@ export function preparePandocMedia(
|
||||
if (media.totalBytes !== totalBytes) {
|
||||
throw new Error("DOCX 媒体总大小声明不一致");
|
||||
}
|
||||
return { map, files, layout };
|
||||
return {
|
||||
map,
|
||||
files,
|
||||
layout,
|
||||
documentLayout: media.documentLayout ?? { tables: [] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core";
|
||||
import runtimeManifest from "./pandoc-runtime.json" with { type: "json" };
|
||||
|
||||
export interface PandocRuntimeArtifact {
|
||||
platform: "win32" | "linux";
|
||||
architecture: "x64";
|
||||
platform: "win32" | "linux" | "darwin";
|
||||
architecture: "x64" | "arm64";
|
||||
archiveType: "zip" | "tar.gz";
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
executableRelativePath: string;
|
||||
licenseFiles: readonly string[];
|
||||
/**
|
||||
* 部分平台的官方归档不随附许可证文件(如 macOS zip),需要单独声明
|
||||
* 每个许可证文件的独立下载来源;缺省时许可证文件从归档内部提取。
|
||||
*/
|
||||
licenseSource?: Readonly<Record<string, { downloadUrl: string }>>;
|
||||
files?: Readonly<
|
||||
Record<string, { bytes: number; sha256: string }>
|
||||
>;
|
||||
|
||||
@@ -35,6 +35,68 @@
|
||||
"sha256": "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508",
|
||||
"executableRelativePath": "bin/pandoc",
|
||||
"licenseFiles": ["COPYING.md", "COPYRIGHT"]
|
||||
},
|
||||
{
|
||||
"platform": "darwin",
|
||||
"architecture": "arm64",
|
||||
"archiveType": "zip",
|
||||
"downloadUrl": "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-arm64-macOS.zip",
|
||||
"sha256": "6E9ECA844076BCBB599BBEEBBBA78A70F93B5307782B85C2C272872812C88875",
|
||||
"executableRelativePath": "pandoc-3.9.0.2-arm64/bin/pandoc",
|
||||
"licenseFiles": ["COPYING.md", "COPYRIGHT"],
|
||||
"licenseSource": {
|
||||
"COPYING.md": {
|
||||
"downloadUrl": "https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYING.md"
|
||||
},
|
||||
"COPYRIGHT": {
|
||||
"downloadUrl": "https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYRIGHT"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"pandoc": {
|
||||
"bytes": 187727808,
|
||||
"sha256": "901A9D2B3D1484E008CBCE7A11739E8C9C22161C4A4222589ED2C53C96B8A55E"
|
||||
},
|
||||
"COPYING.md": {
|
||||
"bytes": 17787,
|
||||
"sha256": "9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD"
|
||||
},
|
||||
"COPYRIGHT": {
|
||||
"bytes": 9598,
|
||||
"sha256": "842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"platform": "darwin",
|
||||
"architecture": "x64",
|
||||
"archiveType": "zip",
|
||||
"downloadUrl": "https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-x86_64-macOS.zip",
|
||||
"sha256": "B9FBCEABCCBC8F34AC021A50483FC32F8160568D0B4B2C22D81BB29E3054FD82",
|
||||
"executableRelativePath": "pandoc-3.9.0.2-x86_64/bin/pandoc",
|
||||
"licenseFiles": ["COPYING.md", "COPYRIGHT"],
|
||||
"licenseSource": {
|
||||
"COPYING.md": {
|
||||
"downloadUrl": "https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYING.md"
|
||||
},
|
||||
"COPYRIGHT": {
|
||||
"downloadUrl": "https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYRIGHT"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"pandoc": {
|
||||
"bytes": 119823904,
|
||||
"sha256": "539A4D539386E62EA7DBD6F0B7C0F76DFADAA78D57E47472849B1C878F8A0A6B"
|
||||
},
|
||||
"COPYING.md": {
|
||||
"bytes": 17787,
|
||||
"sha256": "9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD"
|
||||
},
|
||||
"COPYRIGHT": {
|
||||
"bytes": 9598,
|
||||
"sha256": "842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -140,6 +140,22 @@ function createCandidates(options: PandocRuntimeOptions) {
|
||||
exclusive: false
|
||||
});
|
||||
}
|
||||
if (
|
||||
platform === "darwin" &&
|
||||
(architecture === "arm64" || architecture === "x64") &&
|
||||
options.desktopResourcesPath
|
||||
) {
|
||||
candidates.push({
|
||||
executablePath: path.join(
|
||||
options.desktopResourcesPath,
|
||||
"pandoc",
|
||||
DOCX_PANDOC_VERSION,
|
||||
`darwin-${architecture}`,
|
||||
"pandoc"
|
||||
),
|
||||
exclusive: false
|
||||
});
|
||||
}
|
||||
if (platform === "linux" && architecture === "x64") {
|
||||
candidates.push({
|
||||
executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`,
|
||||
|
||||
@@ -69,9 +69,17 @@ const containerRoles = new Set<SemanticDocumentRole>([
|
||||
"tender-cover"
|
||||
]);
|
||||
|
||||
const semanticRoleSlotAliases: Readonly<
|
||||
Partial<Record<SemanticDocumentRole, DocxStyleSlotName>>
|
||||
> = {};
|
||||
|
||||
function slotName(
|
||||
role: SemanticDocumentRole
|
||||
): DocxStyleSlotName | undefined {
|
||||
const alias = semanticRoleSlotAliases[role];
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
role
|
||||
|
||||
@@ -139,6 +139,19 @@ export function transformSettingsXml(
|
||||
) {
|
||||
const document = parseXmlPart(content, "word/settings.xml");
|
||||
const settings = document.documentElement!;
|
||||
const compatibility =
|
||||
firstDirectChild(settings, WORD_NAMESPACE, "compat") ??
|
||||
appendElement(settings, WORD_NAMESPACE, "w:compat");
|
||||
removeDirectChildren(
|
||||
compatibility,
|
||||
WORD_NAMESPACE,
|
||||
"doNotExpandShiftReturn"
|
||||
);
|
||||
appendElement(
|
||||
compatibility,
|
||||
WORD_NAMESPACE,
|
||||
"w:doNotExpandShiftReturn"
|
||||
);
|
||||
removeDirectChildren(
|
||||
settings,
|
||||
WORD_NAMESPACE,
|
||||
|
||||
@@ -7,8 +7,10 @@ import type {
|
||||
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
||||
import {
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
PANDOC_SYNTAX_STYLE_IDS,
|
||||
collectDocxTokenFonts,
|
||||
createDocxTokenSlotMap,
|
||||
resolvePandocSyntaxStyleSlot,
|
||||
resolveTokenFonts
|
||||
} from "./token-style-map.js";
|
||||
import {
|
||||
@@ -64,7 +66,8 @@ function setRunStyle(
|
||||
"iCs",
|
||||
"u",
|
||||
"shd",
|
||||
"spacing"
|
||||
"spacing",
|
||||
"kern"
|
||||
);
|
||||
if (options.fonts) {
|
||||
setFonts(parent, options.fonts);
|
||||
@@ -106,6 +109,9 @@ function setRunStyle(
|
||||
"w:val": wordCharacterSpacingTwips(options.sizePt)
|
||||
});
|
||||
}
|
||||
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||
"w:val": "2"
|
||||
});
|
||||
}
|
||||
|
||||
function setParagraphSpacing(
|
||||
@@ -200,8 +206,16 @@ function fallbackFontsForSlot(
|
||||
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
|
||||
return fallback.headings.fonts;
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
return fallback.code.fonts;
|
||||
if (
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
) {
|
||||
return {
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
};
|
||||
}
|
||||
if (
|
||||
slot === "table" ||
|
||||
@@ -232,7 +246,12 @@ function fallbackFontSizeForSlot(
|
||||
if (slot.endsWith("-title")) {
|
||||
return fallback.headings.sizesPt[0];
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
if (
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
) {
|
||||
return fallback.code.sizePt;
|
||||
}
|
||||
if (
|
||||
@@ -251,10 +270,17 @@ function fallbackFontSizeForSlot(
|
||||
function applyTokenRunStyle(
|
||||
parent: XmlElement,
|
||||
token: DocxSlotStyleToken,
|
||||
fallbackFonts: DocxFontFamily
|
||||
fallbackFonts: DocxFontFamily,
|
||||
preserveFallbackEastAsia = false
|
||||
) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "kern");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||
"w:val": "2"
|
||||
});
|
||||
if (token.fontCandidates.length) {
|
||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
|
||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts, {
|
||||
preserveFallbackEastAsia
|
||||
}));
|
||||
}
|
||||
if (token.fontSizePt !== undefined) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
|
||||
@@ -441,8 +467,13 @@ function applyTokenParagraphStyle(
|
||||
(token.paddingPt?.right ?? 0) > 0 ||
|
||||
(token.borders?.right?.widthPt ?? 0) > 0
|
||||
? (token.rightIndentPt ?? 0) +
|
||||
(token.paddingPt?.right ?? 0) +
|
||||
(token.borders?.right?.widthPt ?? 0)
|
||||
// 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
|
||||
// 若再把 padding/border 写进 w:ind,Word/WPS 会重复扣减
|
||||
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
|
||||
(horizontalPaddingThroughBorderSpace && token.borders?.right
|
||||
? 0
|
||||
: (token.paddingPt?.right ?? 0) +
|
||||
(token.borders?.right?.widthPt ?? 0))
|
||||
: undefined;
|
||||
if (
|
||||
token.firstLineIndentPt !== undefined ||
|
||||
@@ -1165,26 +1196,105 @@ function applyTokenStyles(
|
||||
binding.styleId === "ImageCaption"
|
||||
? { ...entry.style, alignment: "center" as const }
|
||||
: entry.style;
|
||||
applyTokenParagraphStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
const paragraphProperties = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
);
|
||||
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
|
||||
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
|
||||
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
|
||||
if (binding.styleId === "SourceCode") {
|
||||
removeDirectChildren(
|
||||
paragraphProperties,
|
||||
WORD_NAMESPACE,
|
||||
"w:pPr"
|
||||
),
|
||||
"shd"
|
||||
);
|
||||
}
|
||||
applyTokenParagraphStyle(
|
||||
paragraphProperties,
|
||||
paragraphToken,
|
||||
fallbackFontSizeForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode"
|
||||
);
|
||||
}
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
),
|
||||
entry.style,
|
||||
fallbackFontsForSlot(slot, fallback)
|
||||
const runToken = binding.styleId === "SourceCode"
|
||||
? slots.get("code-block-text")?.style ?? entry.style
|
||||
: entry.style;
|
||||
const runProperties = ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
);
|
||||
if (binding.styleId === "SourceCode") {
|
||||
removeDirectChildren(
|
||||
runProperties,
|
||||
WORD_NAMESPACE,
|
||||
"shd"
|
||||
);
|
||||
}
|
||||
applyTokenRunStyle(
|
||||
runProperties,
|
||||
runToken,
|
||||
fallbackFontsForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode" ||
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
);
|
||||
}
|
||||
}
|
||||
const codeTextToken =
|
||||
slots.get("code-block-text")?.style ??
|
||||
slots.get("code-block")?.style;
|
||||
if (codeTextToken) {
|
||||
const baseStyleId = "MdSourceCodeChar";
|
||||
const baseStyle = ensureStyle(
|
||||
styles,
|
||||
baseStyleId,
|
||||
"character",
|
||||
"BodyTextChar"
|
||||
);
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(baseStyle, WORD_NAMESPACE, "w:rPr"),
|
||||
codeTextToken,
|
||||
{
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
},
|
||||
true
|
||||
);
|
||||
for (const styleId of PANDOC_SYNTAX_STYLE_IDS) {
|
||||
const target = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
"character",
|
||||
baseStyleId
|
||||
);
|
||||
const basedOn = firstDirectChild(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"basedOn"
|
||||
);
|
||||
if (basedOn) {
|
||||
setWordAttribute(basedOn, "val", baseStyleId);
|
||||
}
|
||||
const syntaxSlot = resolvePandocSyntaxStyleSlot(styleId);
|
||||
const syntaxToken = syntaxSlot
|
||||
? slots.get(syntaxSlot)?.style
|
||||
: undefined;
|
||||
if (syntaxToken) {
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
|
||||
syntaxToken,
|
||||
{
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyTableTokenStyles(styles, slots, fallback);
|
||||
|
||||
@@ -110,6 +110,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||
paragraph("MdOfficialPrintingRow")
|
||||
],
|
||||
"briefing-masthead": [paragraph("MdBriefingMasthead")],
|
||||
"briefing-masthead-text": [paragraph("MdBriefingMastheadText")],
|
||||
"briefing-meta": [paragraph("MdBriefingMeta")],
|
||||
"briefing-title": [paragraph("MdBriefingTitle")],
|
||||
"briefing-contact": [paragraph("MdBriefingContact")],
|
||||
@@ -141,6 +142,101 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||
"tender-date": [paragraph("MdTenderDate")]
|
||||
};
|
||||
|
||||
export const PANDOC_SYNTAX_STYLE_IDS = [
|
||||
"AlertTok",
|
||||
"AnnotationTok",
|
||||
"AttributeTok",
|
||||
"BaseNTok",
|
||||
"BuiltInTok",
|
||||
"CharTok",
|
||||
"CommentTok",
|
||||
"CommentVarTok",
|
||||
"ConstantTok",
|
||||
"ControlFlowTok",
|
||||
"DataTypeTok",
|
||||
"DecValTok",
|
||||
"DocumentationTok",
|
||||
"ErrorTok",
|
||||
"ExtensionTok",
|
||||
"FloatTok",
|
||||
"FunctionTok",
|
||||
"ImportTok",
|
||||
"InformationTok",
|
||||
"KeywordTok",
|
||||
"NormalTok",
|
||||
"OperatorTok",
|
||||
"OtherTok",
|
||||
"PreprocessorTok",
|
||||
"SpecialCharTok",
|
||||
"SpecialStringTok",
|
||||
"StringTok",
|
||||
"VariableTok",
|
||||
"VerbatimStringTok",
|
||||
"WarningTok"
|
||||
] as const;
|
||||
|
||||
const pandocSyntaxSlots: Readonly<
|
||||
Partial<Record<(typeof PANDOC_SYNTAX_STYLE_IDS)[number], DocxStyleSlotName>>
|
||||
> = {
|
||||
AlertTok: "code-token-keyword",
|
||||
AnnotationTok: "code-token-comment",
|
||||
AttributeTok: "code-token-attribute",
|
||||
BaseNTok: "code-token-number",
|
||||
BuiltInTok: "code-token-built-in",
|
||||
CharTok: "code-token-string",
|
||||
CommentTok: "code-token-comment",
|
||||
CommentVarTok: "code-token-comment",
|
||||
ConstantTok: "code-token-literal",
|
||||
ControlFlowTok: "code-token-keyword",
|
||||
DataTypeTok: "code-token-attribute",
|
||||
DecValTok: "code-token-number",
|
||||
DocumentationTok: "code-token-comment",
|
||||
ErrorTok: "code-token-keyword",
|
||||
ExtensionTok: "code-token-built-in",
|
||||
FloatTok: "code-token-number",
|
||||
FunctionTok: "code-token-title",
|
||||
ImportTok: "code-token-keyword",
|
||||
InformationTok: "code-token-comment",
|
||||
KeywordTok: "code-token-keyword",
|
||||
OperatorTok: "code-token-operator",
|
||||
PreprocessorTok: "code-token-meta",
|
||||
SpecialCharTok: "code-token-string",
|
||||
SpecialStringTok: "code-token-string",
|
||||
StringTok: "code-token-string",
|
||||
VariableTok: "code-token-variable",
|
||||
VerbatimStringTok: "code-token-string",
|
||||
WarningTok: "code-token-keyword"
|
||||
};
|
||||
|
||||
export function resolvePandocSyntaxStyleSlot(
|
||||
styleId: string,
|
||||
text = "",
|
||||
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
|
||||
): DocxStyleSlotName | "code-block-text" | undefined {
|
||||
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (context.htmlTagName) {
|
||||
return "code-token-name";
|
||||
}
|
||||
if (context.objectPropertyKey) {
|
||||
return "code-token-attribute";
|
||||
}
|
||||
if (
|
||||
styleId === "NormalTok" ||
|
||||
styleId === "OtherTok" ||
|
||||
((styleId === "FunctionTok" || styleId === "DataTypeTok") &&
|
||||
/^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
) {
|
||||
return styleId === "FunctionTok" || styleId === "DataTypeTok"
|
||||
? "code-token-punctuation"
|
||||
: "code-block-text";
|
||||
}
|
||||
return pandocSyntaxSlots[
|
||||
styleId as (typeof PANDOC_SYNTAX_STYLE_IDS)[number]
|
||||
] ?? "code-block-text";
|
||||
}
|
||||
|
||||
const eastAsiaFontPattern =
|
||||
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu;
|
||||
const genericFontNames = new Set([
|
||||
@@ -154,7 +250,8 @@ const genericFontNames = new Set([
|
||||
|
||||
export function resolveTokenFonts(
|
||||
token: DocxSlotStyleToken | undefined,
|
||||
fallback: DocxFontFamily
|
||||
fallback: DocxFontFamily,
|
||||
options: { preserveFallbackEastAsia?: boolean } = {}
|
||||
): DocxFontFamily {
|
||||
const candidates = (token?.fontCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
@@ -166,7 +263,9 @@ export function resolveTokenFonts(
|
||||
const eastAsia =
|
||||
candidates.find((candidate) =>
|
||||
eastAsiaFontPattern.test(candidate)
|
||||
) ?? candidates[0]!;
|
||||
) ?? (options.preserveFallbackEastAsia
|
||||
? fallback.eastAsia
|
||||
: candidates[0]!);
|
||||
const latin =
|
||||
candidates.find(
|
||||
(candidate) => !eastAsiaFontPattern.test(candidate)
|
||||
|
||||
@@ -22,6 +22,8 @@ const drawing =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||
const picture =
|
||||
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
||||
const math =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
|
||||
function generatedFixture() {
|
||||
const baseline = readReferenceDocxPackage(
|
||||
@@ -33,16 +35,24 @@ function generatedFixture() {
|
||||
encoder.encode(
|
||||
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>期号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>发布单位</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>日期</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t><</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="KeywordTok"/></w:rPr><w:t>br</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="DataTypeTok"/></w:rPr><w:t>></w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="SourceCode"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="NormalTok"/></w:rPr><w:t xml:space="preserve"> method</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="OperatorTok"/></w:rPr><w:t>:</w:t></w:r>` +
|
||||
`<w:r><w:rPr><w:rStyle w:val="StringTok"/></w:rPr><w:t xml:space="preserve"> "POST"</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:drawing>` +
|
||||
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
|
||||
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
|
||||
`</wp:inline></w:drawing></w:r></w:p>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>单元格前</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>mdtp_ic_c</w:t></w:r><w:r><w:t>单元格后</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
|
||||
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
|
||||
`</w:body></w:document>`
|
||||
@@ -78,7 +88,7 @@ const plan: PandocStructurePlan = {
|
||||
kind: "paragraph",
|
||||
styleId: "MdOfficialIssueRow",
|
||||
layout: "space-between",
|
||||
segments: ["发文字号", "签发人"],
|
||||
segments: ["期号", "发布单位", "签发人", "日期"],
|
||||
separator: "tab"
|
||||
}
|
||||
]
|
||||
@@ -161,11 +171,68 @@ const tokens: DocxThemeTokenSet = {
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontCandidates: ["Body Face"],
|
||||
fontSizePt: 10,
|
||||
lineSpacing: 1.8
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "inline-code",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#112233",
|
||||
backgroundColor: "#f5f5f5",
|
||||
paddingPt: {
|
||||
top: 3,
|
||||
right: 6,
|
||||
bottom: 3,
|
||||
left: 6
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#24292e"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-punctuation",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#7a7a7a"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-name",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#22863a"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-attribute",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Face"],
|
||||
fontSizePt: 9,
|
||||
color: "#005cc5"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table-header",
|
||||
source: "computed-css",
|
||||
@@ -213,7 +280,18 @@ describe("生成 DOCX 结构收口", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "期号发布单位签发人日期",
|
||||
letterSpacingPt: 0,
|
||||
linePitchPt: 17.25,
|
||||
lineBreakOffsets: [6]
|
||||
}]
|
||||
}
|
||||
);
|
||||
const entries = readGeneratedDocxPackage(
|
||||
result.content
|
||||
@@ -237,6 +315,9 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml).toContain(
|
||||
'<w:vAlign w:val="center"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'w:line="345" w:lineRule="exact"'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:pgNumType w:start="1"/>'
|
||||
);
|
||||
@@ -261,7 +342,12 @@ describe("生成 DOCX 结构收口", () => {
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
|
||||
)
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
documentXml.match(
|
||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="atLeast"/gu
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(documentXml.match(/w:fill="17324D"/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
|
||||
expect(
|
||||
@@ -273,12 +359,20 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
|
||||
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
|
||||
expect(documentXml).toContain("<w:cantSplit/>");
|
||||
expect(documentXml).toMatch(
|
||||
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t><<\/w:t>[\s\S]*?<w:color w:val="22863A"\/>[\s\S]*?<w:t>br<\/w:t>[\s\S]*?<w:color w:val="7A7A7A"\/>[\s\S]*?<w:t>><\/w:t>[\s\S]*?<\/w:p>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:p><w:pPr><w:pStyle w:val="SourceCode"\/>[\s\S]*?<w:rStyle w:val="NormalTok"\/>[\s\S]*?<w:color w:val="005CC5"\/>[\s\S]*?<w:t xml:space="preserve"> method<\/w:t>[\s\S]*?<\/w:p>/u
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tab w:val="right" w:pos="9806"/>'
|
||||
);
|
||||
expect(documentXml).not.toContain('<w:tab w:val="center"');
|
||||
expect(documentXml).toContain("<w:br/>");
|
||||
expect(documentXml).toContain('w:fill="F5F5F5"');
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:before="140"/>'
|
||||
'w:spacing w:before="140"'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:after="1020"/>'
|
||||
@@ -318,6 +412,379 @@ describe("生成 DOCX 结构收口", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("将 Chromium 避头尾规则留下的单个汉字尾行翻译为显式换行", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
|
||||
'<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/><w:jc w:val="both"/></w:pPr><w:r><w:t>浏览器末行保留项目。</w:t></w:r></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "浏览器末行保留项目。",
|
||||
letterSpacingPt: 0,
|
||||
alignment: "justify",
|
||||
lineBreakOffsets: [8]
|
||||
}]
|
||||
}
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toContain(
|
||||
'<w:t>浏览器末行保留项</w:t><w:br/><w:t>目。</w:t>'
|
||||
);
|
||||
});
|
||||
|
||||
it("含行内代码的实测行距允许 Word 扩展行盒", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
"<w:sectPr>",
|
||||
'<w:p><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r><w:r><w:t>后缀</w:t></w:r></w:p><w:sectPr>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
textBlocks: [{
|
||||
ordinal: 1,
|
||||
text: "前缀inline后缀",
|
||||
letterSpacingPt: 0,
|
||||
linePitchPt: 11.15,
|
||||
lineBreakOffsets: []
|
||||
}]
|
||||
}
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/w:line="223" w:lineRule="atLeast"[\s\S]*?<w:t>前缀<\/w:t>[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("未匹配实测布局的行内代码段落也不保留 exact 行盒", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
"<w:sectPr>",
|
||||
'<w:p><w:pPr><w:spacing w:line="312" w:lineRule="exact"/></w:pPr><w:r><w:t>前缀</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>inline</w:t></w:r></w:p><w:sectPr>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/w:line="312" w:lineRule="atLeast"[\s\S]*?<w:rStyle w:val="VerbatimChar"\/>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("表格纯公式段落加入零宽普通 Run 以服从段落对齐", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
`xmlns:r="${relationships}"`,
|
||||
`xmlns:r="${relationships}" xmlns:m="${math}"`
|
||||
).replace(
|
||||
"<w:p><w:r><w:t>B</w:t></w:r></w:p>",
|
||||
'<w:p><w:pPr><w:jc w:val="left"/></w:pPr><m:oMath><m:r><m:t>≥0.80</m:t></m:r></m:oMath></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/<w:jc w:val="left"\/>[\s\S]*?<m:oMath>[\s\S]*?<m:t>≥0\.80<\/m:t>[\s\S]*?<\/m:oMath><w:r><w:rPr><w:noProof\/><\/w:rPr><w:t xml:space="preserve">\u200B<\/w:t><\/w:r>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
'<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>',
|
||||
'<w:p><w:pPr><w:pStyle w:val="MdTenderCopyMark"/></w:pPr><w:r><w:t>正本</w:t></w:r></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
const copyMarkTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: [
|
||||
...tokens.slots.map((entry) =>
|
||||
entry.slot === "tender-cover"
|
||||
? {
|
||||
...entry,
|
||||
style: {
|
||||
...entry.style,
|
||||
minimumHeightPt: 500,
|
||||
verticalAlignment: "center" as const
|
||||
}
|
||||
}
|
||||
: entry
|
||||
),
|
||||
{
|
||||
slot: "tender-copy-mark",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: [],
|
||||
fontSizePt: 13,
|
||||
selfAlignment: "right",
|
||||
paddingPt: { top: 3, right: 11.34, bottom: 3, left: 11.34 },
|
||||
borders: {
|
||||
right: { widthPt: 1.13, style: "single", color: "#222222" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
copyMarkTokens
|
||||
);
|
||||
const outputXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(outputXml).toMatch(
|
||||
/<w:pStyle w:val="MdTenderCopyMark"\/>[\s\S]*?<w:t>正本<\/w:t>[\s\S]*?<w:sz w:val="25"\/>[\s\S]*?<w:t xml:space="preserve">\u00a0\u00a0<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("按 Chromium 实测比例写入固定 DXA 表格网格和单元格宽度", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70],
|
||||
rows: []
|
||||
}
|
||||
],
|
||||
textBlocks: [
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "单元格前mdtp_ic_c单元格后",
|
||||
letterSpacingPt: 0,
|
||||
alignment: "distribute",
|
||||
lineBreakOffsets: []
|
||||
}
|
||||
],
|
||||
inlineCodes: [
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "mdtp_ic_c",
|
||||
fontSizePt: 8,
|
||||
letterSpacingPt: 0.2,
|
||||
color: "#334455",
|
||||
backgroundColor: "#abcdef",
|
||||
paddingPt: {
|
||||
top: 1,
|
||||
right: 2,
|
||||
bottom: 1,
|
||||
left: 2
|
||||
},
|
||||
borderPt: {
|
||||
top: 0.5,
|
||||
right: 0.5,
|
||||
bottom: 0.5,
|
||||
left: 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblW w:w="8915" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblInd w:w="495" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
|
||||
expect(documentXml).toContain('<w:wordWrap w:val="1"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
|
||||
expect(
|
||||
documentXml.match(/<w:tcW w:w="2675" w:type="dxa"\/>/gu)
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
documentXml.match(/<w:tcW w:w="6240" w:type="dxa"\/>/gu)
|
||||
).toHaveLength(2);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:r><w:rPr><w:rStyle w:val="VerbatimChar"\/><w:rFonts[^>]*w:ascii="Code Face"[^>]*\/>[\s\S]*?<w:color w:val="334455"\/>[\s\S]*?<w:sz w:val="16"\/>[\s\S]*?<w:bdr[^>]*w:sz="20"[^>]*w:space="0"[^>]*w:color="ABCDEF"[^>]*\/>[\s\S]*?<w:shd[^>]*w:fill="ABCDEF"[^>]*\/>[\s\S]*?<w:t>mdtp_ic_c<\/w:t><\/w:r>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:r><w:rPr><w:rFonts[^>]*w:ascii="Body Face"[^>]*\/>[\s\S]*?<w:t>单元格前<\/w:t><\/w:r>/u
|
||||
);
|
||||
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
|
||||
});
|
||||
|
||||
it("将超出内容盒的实测表格等比收缩到 Word 可用宽度", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [
|
||||
{
|
||||
ordinal: 1,
|
||||
widthPercent: 300,
|
||||
leftOffsetPercent: 0,
|
||||
columnWidthPercents: [40, 10, 10, 10, 10, 20],
|
||||
rows: []
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblW w:w="9906" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:gridCol w:w="3962"/>');
|
||||
expect(documentXml).toContain('<w:gridCol w:w="1980"/>');
|
||||
});
|
||||
|
||||
it("按 Chromium 实测 emoji 调色保留可编辑 Unicode 文本", () => {
|
||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||
const documentXml = decoder.decode(
|
||||
fixture.entries.get("word/document.xml")!
|
||||
).replace(
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
|
||||
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
|
||||
'<w:p><w:r><w:t>⭐⭐⭐⭐</w:t></w:r></w:p>'
|
||||
);
|
||||
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(fixture.entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
emojiRuns: [
|
||||
{ ordinal: 1, text: "⭐⭐⭐⭐", color: "#e7bf36" }
|
||||
]
|
||||
}
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:r><w:rPr><w:color w:val="E7BF36"\/><\/w:rPr><w:t>⭐⭐⭐⭐<\/w:t><\/w:r>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("实测表格缩进补偿 Word 以单元格内容而非外边框对齐的行为", () => {
|
||||
const paddedTokens = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((slot) =>
|
||||
slot.slot === "table-cell"
|
||||
? {
|
||||
...slot,
|
||||
style: {
|
||||
...slot.style,
|
||||
paddingPt: { top: 3, right: 6, bottom: 3, left: 6 }
|
||||
}
|
||||
}
|
||||
: slot
|
||||
)
|
||||
} satisfies DocxThemeTokenSet;
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
paddedTokens,
|
||||
[],
|
||||
{
|
||||
tables: [{
|
||||
ordinal: 1,
|
||||
widthPercent: 90,
|
||||
leftOffsetPercent: 5,
|
||||
columnWidthPercents: [30, 70],
|
||||
rows: []
|
||||
}]
|
||||
}
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblInd w:w="615" w:type="dxa"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
@@ -438,14 +905,14 @@ describe("生成 DOCX 结构收口", () => {
|
||||
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
|
||||
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
|
||||
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1256" w:right="1275"\/><w:jc w:val="center"\/>/u
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t>可编辑封面<\/w:t>/u
|
||||
);
|
||||
expect(documentXml).toContain('<w:tblLook w:val="0000"');
|
||||
expect(documentXml).toMatch(
|
||||
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
|
||||
/<\/w:tbl><w:p><w:pPr>[\s\S]*?<w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"\/>[\s\S]*?<w:sectPr>/u
|
||||
);
|
||||
expect(
|
||||
documentXml.match(/<w:tbl(?:\s|>)/gu)
|
||||
@@ -458,6 +925,47 @@ describe("生成 DOCX 结构收口", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("将封面 border 与内缩 outline 映射为双层可编辑容器边框", () => {
|
||||
const outlinedTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
slots: tokens.slots.map((entry) =>
|
||||
entry.slot === "tender-cover"
|
||||
? {
|
||||
...entry,
|
||||
style: {
|
||||
...entry.style,
|
||||
minimumHeightPt: 620,
|
||||
verticalAlignment: "center" as const,
|
||||
outline: {
|
||||
widthPt: 0.75,
|
||||
style: "single" as const,
|
||||
color: "#777777"
|
||||
},
|
||||
outlineOffsetPt: -12
|
||||
}
|
||||
}
|
||||
: entry
|
||||
)
|
||||
};
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
generatedFixture(),
|
||||
plan,
|
||||
outlinedTokens
|
||||
);
|
||||
const documentXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:tblCellSpacing w:w="120" w:type="dxa"/>'
|
||||
);
|
||||
expect(documentXml).toMatch(
|
||||
/<w:tcBorders><w:top w:val="single" w:sz="6" w:space="0" w:color="777777"\/><w:left[\s\S]*?<w:bottom[\s\S]*?<w:right/u
|
||||
);
|
||||
});
|
||||
|
||||
it("将封面最小高度封顶到当前节的页面内容区", () => {
|
||||
const oversizedTokens: DocxThemeTokenSet = {
|
||||
...tokens,
|
||||
@@ -469,7 +977,15 @@ describe("生成 DOCX 结构收口", () => {
|
||||
...entry.style,
|
||||
minimumHeightPt: 900,
|
||||
verticalAlignment: "center" as const,
|
||||
childAlignment: "left" as const
|
||||
childAlignment: "left" as const,
|
||||
borders: {
|
||||
...entry.style.borders,
|
||||
top: {
|
||||
widthPt: 20,
|
||||
style: "single" as const,
|
||||
color: "#111111"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: entry
|
||||
@@ -508,18 +1024,24 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
|
||||
expect(documentXml).toContain(
|
||||
'<w:trHeight w:val="14838" w:hRule="exact"/>'
|
||||
'<w:trHeight w:val="14718" w:hRule="exact"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:shd w:val="clear" w:color="auto" w:fill="111111"/>'
|
||||
);
|
||||
expect(documentXml).toContain(
|
||||
'<w:spacing w:before="0" w:after="0" w:line="500" w:lineRule="exact"/>'
|
||||
);
|
||||
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
|
||||
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
|
||||
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
|
||||
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
|
||||
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
|
||||
it("将主题列表文字起点映射为 Word 悬挂缩进", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
entries.set(
|
||||
@@ -557,10 +1079,96 @@ describe("生成 DOCX 结构收口", () => {
|
||||
);
|
||||
|
||||
expect(numberingXml).toContain(
|
||||
'<w:ind w:left="960" w:hanging="360"/>'
|
||||
'<w:ind w:left="600" w:hanging="360"/>'
|
||||
);
|
||||
expect(numberingXml).toContain(
|
||||
'<w:ind w:left="1680" w:hanging="360"/>'
|
||||
'<w:ind w:left="1320" w:hanging="360"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("将 Chromium 实测列表项文字起点映射为段落直接缩进", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
const documentXml = decoder.decode(entries.get("word/document.xml")!);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(documentXml.replace(
|
||||
"<w:sectPr>",
|
||||
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>一级列表</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>二级</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>代码</w:t></w:r></w:p>` +
|
||||
`<w:sectPr>`
|
||||
))
|
||||
);
|
||||
entries.set(
|
||||
"word/numbering.xml",
|
||||
encoder.encode(
|
||||
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num></w:numbering>`
|
||||
)
|
||||
);
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(entries),
|
||||
plan,
|
||||
tokens,
|
||||
[],
|
||||
{
|
||||
tables: [],
|
||||
listItems: [
|
||||
{
|
||||
ordinal: 1,
|
||||
text: "一级列表",
|
||||
depth: 0,
|
||||
textStartPt: 10
|
||||
},
|
||||
{
|
||||
ordinal: 2,
|
||||
text: "二级代码",
|
||||
depth: 1,
|
||||
textStartPt: 25
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:numPr>[\s\S]*?<w:ind w:left="200" w:hanging="200"\/>[\s\S]*?<w:t>一级列表<\/w:t>/u
|
||||
);
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:numPr>[\s\S]*?<w:ind w:left="500" w:hanging="360"\/>[\s\S]*?<w:t>二级<\/w:t>[\s\S]*?<w:t>代码<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
it("将 Pandoc 拆分的标准 Alert 正文恢复为引用块样式", () => {
|
||||
const source = readGeneratedDocxPackage(generatedFixture());
|
||||
const entries = new Map(source.entries);
|
||||
const documentXml = decoder.decode(entries.get("word/document.xml")!);
|
||||
entries.set(
|
||||
"word/document.xml",
|
||||
encoder.encode(documentXml.replace(
|
||||
"<w:sectPr>",
|
||||
`<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/></w:pPr><w:r><w:t>Caution</w:t></w:r></w:p>` +
|
||||
`<w:p><w:pPr><w:pStyle w:val="BodyText"/></w:pPr><w:r><w:t>警告正文</w:t></w:r></w:p>` +
|
||||
`<w:sectPr>`
|
||||
))
|
||||
);
|
||||
|
||||
const result = finalizeGeneratedDocxStructure(
|
||||
writeGeneratedDocxPackage(entries),
|
||||
plan,
|
||||
tokens
|
||||
);
|
||||
const finalizedXml = decoder.decode(
|
||||
readGeneratedDocxPackage(result.content).entries.get(
|
||||
"word/document.xml"
|
||||
)!
|
||||
);
|
||||
|
||||
expect(finalizedXml).toMatch(
|
||||
/<w:pPr><w:pStyle w:val="BlockText"\/>[\s\S]*?<w:t>警告正文<\/w:t>/u
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("DOCX 字体包写入", () => {
|
||||
writeGeneratedDocxPackage(invalidEntries)
|
||||
)
|
||||
).toThrow("字体元数据与嵌入字形不匹配");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("没有字体时保持原始 DOCX 字节不变", () => {
|
||||
const input = createTestBaselineReference();
|
||||
|
||||
@@ -126,6 +126,9 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
expect(arguments_).toContain("--lua-filter");
|
||||
expect(arguments_).toContain("--data-dir");
|
||||
expect(arguments_).toContain("--resource-path");
|
||||
expect(argumentAfter(arguments_, "--from")).toBe(
|
||||
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html"
|
||||
);
|
||||
expect(
|
||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||
).toContain("media-map.json");
|
||||
@@ -216,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
code: "DOCX_OUTPUT_INVALID"
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
|
||||
it("进程失败时保留内部诊断但保持稳定的对外错误", async () => {
|
||||
const failedRunner = vi.fn<PandocProcessRunner>(async () => ({
|
||||
outcome: "completed",
|
||||
exitCode: 64,
|
||||
stdout: new Uint8Array(),
|
||||
stderr: "pandoc: 媒体映射失败"
|
||||
}));
|
||||
|
||||
await expect(
|
||||
new PandocDocxConverter({
|
||||
runtime,
|
||||
runner: failedRunner,
|
||||
temporaryRoot
|
||||
}).convert(input())
|
||||
).rejects.toMatchObject({
|
||||
code: "DOCX_GENERATION_FAILED",
|
||||
message: "Pandoc 未能生成 DOCX",
|
||||
diagnostics: {
|
||||
outcome: "completed",
|
||||
exitCode: 64,
|
||||
stderr: "pandoc: 媒体映射失败"
|
||||
}
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { zipSync } from "fflate";
|
||||
import {
|
||||
@@ -118,7 +119,34 @@ describe("Pandoc 运行时探测", () => {
|
||||
executablePath: "pandoc.exe"
|
||||
});
|
||||
expect(runner.mock.calls[0]?.[0]).toContain(
|
||||
`pandoc\\${DOCX_PANDOC_VERSION}\\windows-x86_64\\pandoc.exe`
|
||||
path.join("pandoc", DOCX_PANDOC_VERSION, "windows-x86_64", "pandoc.exe")
|
||||
);
|
||||
});
|
||||
|
||||
it("macOS 内置路径不存在时回退到 PATH", async () => {
|
||||
const runner = vi
|
||||
.fn<PandocProcessRunner>()
|
||||
.mockResolvedValueOnce(
|
||||
processResult({
|
||||
outcome: "not-found",
|
||||
exitCode: null,
|
||||
stdout: new Uint8Array()
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(processResult());
|
||||
const resolution = await probePandocRuntime({
|
||||
platform: "darwin",
|
||||
architecture: "arm64",
|
||||
desktopResourcesPath: "/Applications/MorphDoc.app/Contents/Resources",
|
||||
environment: {},
|
||||
runner
|
||||
});
|
||||
expect(resolution).toMatchObject({
|
||||
capability: { status: "available" },
|
||||
executablePath: "pandoc"
|
||||
});
|
||||
expect(runner.mock.calls[0]?.[0]).toContain(
|
||||
path.join("pandoc", DOCX_PANDOC_VERSION, "darwin-arm64", "pandoc")
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,36 @@ function paragraphSegments(block: PandocStructureBlock): string[] {
|
||||
}
|
||||
|
||||
describe("Pandoc 语义结构投影", () => {
|
||||
it("将简报抬头文字映射到父级抬头样式", () => {
|
||||
const model: SemanticDocumentModel = {
|
||||
schemaVersion: 1,
|
||||
profile: "briefing",
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: [{
|
||||
kind: "prefix",
|
||||
nodes: [{
|
||||
kind: "group",
|
||||
role: "briefing-masthead",
|
||||
children: [text("briefing-masthead-text", "工作简报")]
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const plan = createPandocStructurePlan(model, tokens(), {
|
||||
markerSeed: "briefing"
|
||||
});
|
||||
expect(plan.prefix[0]).toMatchObject({
|
||||
kind: "container",
|
||||
blocks: [{
|
||||
kind: "paragraph",
|
||||
styleId: "MdBriefingMastheadText",
|
||||
segments: ["工作简报"]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
|
||||
const model: SemanticDocumentModel = {
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -272,6 +272,26 @@ describe("动态 reference.docx", () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Text Face"],
|
||||
fontSizePt: 10,
|
||||
color: "#223344"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-attribute",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Text Face"],
|
||||
fontSizePt: 10,
|
||||
color: "#166534"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table",
|
||||
source: "computed-css",
|
||||
@@ -384,11 +404,19 @@ describe("动态 reference.docx", () => {
|
||||
const sourceCodeStyle = stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
expect(sourceCodeStyle).not.toContain("<w:shd");
|
||||
expect(sourceCodeStyle).toContain(
|
||||
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
|
||||
);
|
||||
expect(sourceCodeStyle).toMatch(
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="0"[^>]*\/>/u
|
||||
);
|
||||
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdSourceCodeChar"[\s\S]*?<w:sz w:val="20"\/>/u
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="DataTypeTok"[\s\S]*?<w:basedOn w:val="MdSourceCodeChar"\/>[\s\S]*?<w:color w:val="166534"\/>/u
|
||||
);
|
||||
expect(stylesXml).not.toContain('w:val="justify"');
|
||||
expect(stylesXml).toContain(
|
||||
@@ -424,6 +452,49 @@ describe("动态 reference.docx", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("按代码容器令牌清除或写入 SourceCode 段落底纹", () => {
|
||||
const createWithBackground = (backgroundColor?: string) => {
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
{
|
||||
...createOptions(defaultExportConfig),
|
||||
themeTokens: themeTokens("f".repeat(64), [
|
||||
{
|
||||
slot: "code-block",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Consolas"],
|
||||
fontSizePt: 10,
|
||||
...(backgroundColor ? { backgroundColor } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Consolas"],
|
||||
fontSizePt: 10
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
);
|
||||
const stylesXml = decoder.decode(
|
||||
unzipSync(result.content)["word/styles.xml"]
|
||||
);
|
||||
return stylesXml.match(
|
||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||
)?.[0];
|
||||
};
|
||||
|
||||
expect(createWithBackground()).not.toContain("<w:shd");
|
||||
expect(createWithBackground("#eef2f6")).toContain(
|
||||
'<w:shd w:val="clear" w:color="auto" w:fill="EEF2F6"/>'
|
||||
);
|
||||
});
|
||||
|
||||
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
||||
const exportConfig: ExportConfig = {
|
||||
...defaultExportConfig,
|
||||
@@ -463,6 +534,7 @@ describe("动态 reference.docx", () => {
|
||||
const entries = unzipSync(result.content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const settingsXml = decoder.decode(entries["word/settings.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
const headerXml = decoder.decode(entries["word/header1.xml"]);
|
||||
const relationships = decoder.decode(
|
||||
entries["word/_rels/document.xml.rels"]
|
||||
@@ -478,6 +550,8 @@ describe("动态 reference.docx", () => {
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(settingsXml).toContain("<w:doNotExpandShiftReturn");
|
||||
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
expect(headerXml).toContain('w:tab w:val="center"');
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
collectDocxTokenFonts,
|
||||
createDocxTokenSlotMap,
|
||||
resolvePandocSyntaxStyleSlot,
|
||||
resolveTokenFonts
|
||||
} from "../src/index.js";
|
||||
|
||||
@@ -72,5 +73,47 @@ describe("DOCX 令牌样式映射", () => {
|
||||
"Source Han Serif SC",
|
||||
"Times New Roman"
|
||||
]);
|
||||
expect(
|
||||
resolveTokenFonts(
|
||||
{ fontCandidates: ["MdTP Mono", "monospace"] },
|
||||
{ latin: "Arial", eastAsia: "MdTP Sans SC" },
|
||||
{ preserveFallbackEastAsia: true }
|
||||
)
|
||||
).toEqual({
|
||||
latin: "MdTP Mono",
|
||||
eastAsia: "MdTP Sans SC",
|
||||
complexScript: "MdTP Mono"
|
||||
});
|
||||
});
|
||||
|
||||
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
||||
.toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", "<"))
|
||||
.toBe("code-token-punctuation");
|
||||
expect(
|
||||
resolvePandocSyntaxStyleSlot(
|
||||
"KeywordTok",
|
||||
"section",
|
||||
{ htmlTagName: true }
|
||||
)
|
||||
).toBe("code-token-name");
|
||||
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
|
||||
.toBe("code-token-string");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
||||
.toBe("code-token-punctuation");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", "render"))
|
||||
.toBe("code-token-title");
|
||||
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
||||
.toBe("code-block-text");
|
||||
expect(
|
||||
resolvePandocSyntaxStyleSlot(
|
||||
"NormalTok",
|
||||
" method",
|
||||
{ objectPropertyKey: true }
|
||||
)
|
||||
).toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ export function readDocxComputedStyle(
|
||||
fontStyle: style.fontStyle,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
...(style.backgroundImage
|
||||
? { backgroundImage: style.backgroundImage }
|
||||
: {}),
|
||||
lineHeight: style.lineHeight,
|
||||
letterSpacing: style.letterSpacing,
|
||||
textAlign: style.textAlign,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user