Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4df499680 | ||
|
|
effeca7ca8 | ||
|
|
55614fafed | ||
|
|
64445322eb |
@@ -13,34 +13,28 @@
|
|||||||
|
|
||||||
## 2. 会话启动检查
|
## 2. 会话启动检查
|
||||||
|
|
||||||
新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先运行:
|
新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先确认仓库根目录。不得假设固定绝对路径或盘符——协作环境可能是 Windows、macOS 或 Linux,克隆位置因机器而异。可用如下只读方式确认(按当前 Shell 语法改写等价命令):
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-Location
|
|
||||||
```
|
|
||||||
|
|
||||||
确认目录为:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
C:\Projects\md-to-pdf
|
git rev-parse --show-toplevel
|
||||||
```
|
```
|
||||||
|
|
||||||
随后依次执行只读检查:
|
随后依次执行只读检查:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
git status --short
|
git status --short
|
||||||
git log --oneline -5
|
git log --oneline -5
|
||||||
Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
读取 docs/PROGRESS.md(显式使用 UTF-8 编码)
|
||||||
```
|
```
|
||||||
|
|
||||||
当前工作区可能包含用户或上一个会话留下的未提交修改。禁止使用 `git reset --hard`、`git checkout --`、`git clean` 等方式丢弃修改,除非用户明确要求。
|
当前工作区可能包含用户或上一个会话留下的未提交修改。禁止使用 `git reset --hard`、`git checkout --`、`git clean` 等方式丢弃修改,除非用户明确要求。
|
||||||
|
|
||||||
## 3. Shell 与文件操作
|
## 3. Shell 与文件操作
|
||||||
|
|
||||||
- 当前默认 Shell 为 PowerShell,使用 PowerShell 语法。
|
- 不假设固定 Shell 类型(PowerShell、bash、zsh 等均可能);命令语法跟随当前会话实际使用的 Shell,禁止把某一种 Shell 的专属语法当作硬性要求写入产物、脚本或文档。
|
||||||
- 不要通过绝对路径 `cd` 前缀拼接命令;工具支持时优先使用 `workdir`。
|
- 不要通过绝对路径 `cd` 前缀拼接命令;工具支持时优先使用 `workdir`。
|
||||||
- 子目录操作使用相对路径或直接设置 `workdir`。
|
- 子目录操作使用相对路径或直接设置 `workdir`。
|
||||||
- PowerShell 读取、写入、追加或替换文本时显式指定 UTF-8 编码。
|
- 读取、写入、追加或替换文本文件时显式指定 UTF-8 编码。
|
||||||
- 本地文件修改优先使用补丁工具,避免使用不透明的大段覆盖命令。
|
- 本地文件修改优先使用补丁工具,避免使用不透明的大段覆盖命令。
|
||||||
- 不执行破坏性删除;如确需删除,先确认精确目标并向用户说明。
|
- 不执行破坏性删除;如确需删除,先确认精确目标并向用户说明。
|
||||||
|
|
||||||
@@ -150,19 +144,19 @@ docs/ 进度、架构和部署文档
|
|||||||
|
|
||||||
安装依赖:
|
安装依赖:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
本地开发:
|
本地开发:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
完整验证:
|
完整验证:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
npm test
|
npm test
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run build
|
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,8 +57,8 @@ npm run verify:pandoc-runtime -w @md-to-pdf/desktop
|
|||||||
```
|
```
|
||||||
|
|
||||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
`apps/desktop/out/v0.6.1/`。该目录被 Git 忽略。公司内部分发以
|
`apps/desktop/out/v0.6.4/`。该目录被 Git 忽略。公司内部分发以
|
||||||
`MorphDoc-0.6.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
`MorphDoc-0.6.4-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
提示。
|
提示。
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,25 @@ const windowsIcon = path.resolve(
|
|||||||
__dirname,
|
__dirname,
|
||||||
"../../logos/desktop/windows/app.ico"
|
"../../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() ||
|
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
|
||||||
"build/installer.nsh";
|
"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 = {
|
module.exports = {
|
||||||
appId: brand.appId,
|
appId: brand.appId,
|
||||||
productName: brand.englishName,
|
productName: brand.englishName,
|
||||||
@@ -46,22 +62,12 @@ module.exports = {
|
|||||||
to: "font-packs",
|
to: "font-packs",
|
||||||
filter: ["**/*"]
|
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: windowsIcon,
|
from: windowsIcon,
|
||||||
to: "app.ico"
|
to: "app.ico"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: "../../logos/desktop/macos/app.icns",
|
from: macIcon,
|
||||||
to: "app.icns"
|
to: "app.icns"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -78,7 +84,19 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
icon: windowsIcon,
|
icon: windowsIcon,
|
||||||
executableName: brand.executableName,
|
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: {
|
nsis: {
|
||||||
oneClick: false,
|
oneClick: false,
|
||||||
@@ -94,5 +112,36 @@ module.exports = {
|
|||||||
installerLanguages: ["zh_CN"],
|
installerLanguages: ["zh_CN"],
|
||||||
include: nsisInclude,
|
include: nsisInclude,
|
||||||
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
|
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",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.6.1",
|
"version": "0.6.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"productName": "MorphDoc",
|
"productName": "MorphDoc",
|
||||||
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||||
@@ -11,13 +11,20 @@
|
|||||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||||
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||||
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
|
"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",
|
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
|
||||||
"build:main": "node scripts/build-main.mjs",
|
"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",
|
"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})\"",
|
"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",
|
"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": "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": "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",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||||
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
|
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
|
chmod,
|
||||||
mkdir,
|
mkdir,
|
||||||
readFile,
|
readFile,
|
||||||
rename,
|
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 manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||||
const artifact = manifest.artifacts?.find(
|
const artifact = manifest.artifacts?.find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
candidate.platform === "win32" && candidate.architecture === "x64"
|
candidate.platform === platform && candidate.architecture === architecture
|
||||||
);
|
);
|
||||||
if (!artifact || artifact.archiveType !== "zip") {
|
if (!artifact) {
|
||||||
throw new Error("Pandoc 清单缺少 Windows x64 ZIP 发行项");
|
throw new Error(`Pandoc 清单缺少 ${platform}/${architecture} 发行项`);
|
||||||
|
}
|
||||||
|
if (artifact.archiveType !== "zip") {
|
||||||
|
throw new Error(`Pandoc 清单要求 ${platform}/${architecture} 为 ZIP 发行项`);
|
||||||
}
|
}
|
||||||
const requiredNames = [
|
const requiredNames = [
|
||||||
normalizedBaseName(artifact.executableRelativePath),
|
normalizedBaseName(artifact.executableRelativePath),
|
||||||
@@ -92,6 +117,33 @@ async function downloadArchive(downloadUrl, destination) {
|
|||||||
return content;
|
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) {
|
async function loadVerifiedArchive(artifact, archivePath) {
|
||||||
if (await fileExists(archivePath)) {
|
if (await fileExists(archivePath)) {
|
||||||
const cached = new Uint8Array(await readFile(archivePath));
|
const cached = new Uint8Array(await readFile(archivePath));
|
||||||
@@ -122,10 +174,7 @@ async function loadVerifiedArchive(artifact, archivePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractRequiredFiles(archive, artifact) {
|
function extractRequiredFiles(archive, artifact) {
|
||||||
const requiredNames = new Set([
|
const requiredNames = new Set(archiveRequiredNames(artifact));
|
||||||
normalizedBaseName(artifact.executableRelativePath),
|
|
||||||
...artifact.licenseFiles.map(normalizedBaseName)
|
|
||||||
]);
|
|
||||||
let selectedBytes = 0;
|
let selectedBytes = 0;
|
||||||
const entries = unzipSync(archive, {
|
const entries = unzipSync(archive, {
|
||||||
filter(file) {
|
filter(file) {
|
||||||
@@ -253,9 +302,13 @@ async function verifyPreparedRuntime(targetDirectory, manifest, artifact) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function preparePandocRuntime({ checkOnly = false } = {}) {
|
async function preparePandocRuntime({
|
||||||
const { manifest, artifact } = await readRuntimeManifest();
|
checkOnly = false,
|
||||||
const platformDirectory = "windows-x86_64";
|
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 runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc");
|
||||||
const targetDirectory = path.join(
|
const targetDirectory = path.join(
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
@@ -278,7 +331,10 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
|||||||
}
|
}
|
||||||
const archivePath = path.join(downloadDirectory, archiveName);
|
const archivePath = path.join(downloadDirectory, archiveName);
|
||||||
const archive = await loadVerifiedArchive(artifact, archivePath);
|
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(
|
const temporaryDirectory = path.join(
|
||||||
runtimeRoot,
|
runtimeRoot,
|
||||||
manifest.version,
|
manifest.version,
|
||||||
@@ -308,6 +364,10 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
|||||||
temporaryDirectory,
|
temporaryDirectory,
|
||||||
normalizedBaseName(artifact.executableRelativePath)
|
normalizedBaseName(artifact.executableRelativePath)
|
||||||
);
|
);
|
||||||
|
if (platform !== "win32") {
|
||||||
|
// unzipSync 不保留压缩包内的可执行权限位,POSIX 平台需要手动补上。
|
||||||
|
await chmod(executablePath, 0o755);
|
||||||
|
}
|
||||||
verifyPandocVersion(executablePath, manifest.version);
|
verifyPandocVersion(executablePath, manifest.version);
|
||||||
await smokeTestPandoc(executablePath);
|
await smokeTestPandoc(executablePath);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -315,8 +375,8 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
|||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
platform: "win32",
|
platform,
|
||||||
architecture: "x64",
|
architecture,
|
||||||
license: manifest.license,
|
license: manifest.license,
|
||||||
projectUrl: manifest.projectUrl,
|
projectUrl: manifest.projectUrl,
|
||||||
sourceArchiveUrl: manifest.sourceArchiveUrl,
|
sourceArchiveUrl: manifest.sourceArchiveUrl,
|
||||||
@@ -338,17 +398,25 @@ async function preparePandocRuntime({ checkOnly = false } = {}) {
|
|||||||
return targetDirectory;
|
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 =
|
const isDirectInvocation =
|
||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
if (isDirectInvocation) {
|
if (isDirectInvocation) {
|
||||||
preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).catch(
|
preparePandocRuntime({
|
||||||
(error) => {
|
checkOnly: process.argv.includes("--check"),
|
||||||
console.error(error instanceof Error ? error.message : error);
|
platform: readCliFlag("platform") ?? process.platform,
|
||||||
process.exitCode = 1;
|
architecture: readCliFlag("arch") ?? process.arch
|
||||||
}
|
}).catch((error) => {
|
||||||
);
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -18,7 +18,11 @@ describe("桌面 Markdown 文件参数", () => {
|
|||||||
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
|
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(
|
expect(
|
||||||
findMarkdownFileArgument(
|
findMarkdownFileArgument(
|
||||||
[
|
[
|
||||||
@@ -83,15 +87,19 @@ describe("桌面 Markdown 文件参数", () => {
|
|||||||
const filePath = path.join(directory, "快照.md");
|
const filePath = path.join(directory, "快照.md");
|
||||||
try {
|
try {
|
||||||
await writeFile(filePath, "# 第一版", "utf8");
|
await writeFile(filePath, "# 第一版", "utf8");
|
||||||
|
// readMarkdownFileSnapshot 内部会 realpath 解析符号链接(例如 macOS
|
||||||
|
// 的 /var -> /private/var),返回的是规范化路径,因此断言也需要对
|
||||||
|
// 同一路径做 realpath,而不是直接比较 mkdtemp 拼接出的原始路径。
|
||||||
|
const canonicalFilePath = await realpath(filePath);
|
||||||
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
||||||
|
|
||||||
expect(snapshot.document).toEqual({
|
expect(snapshot.document).toEqual({
|
||||||
markdown: "# 第一版",
|
markdown: "# 第一版",
|
||||||
fileName: "快照.md"
|
fileName: "快照.md"
|
||||||
});
|
});
|
||||||
expect(snapshot.filePath).toBe(filePath);
|
expect(snapshot.filePath).toBe(canonicalFilePath);
|
||||||
expect(snapshot.documentKey).toBe(
|
expect(snapshot.documentKey).toBe(
|
||||||
createMarkdownDocumentKey(filePath)
|
createMarkdownDocumentKey(canonicalFilePath)
|
||||||
);
|
);
|
||||||
expect(snapshot.contentHash).toBe(
|
expect(snapshot.contentHash).toBe(
|
||||||
createMarkdownContentHash("# 第一版")
|
createMarkdownContentHash("# 第一版")
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ describe("桌面发行构建链", () => {
|
|||||||
"@md-to-pdf/markdown-echarts",
|
"@md-to-pdf/markdown-echarts",
|
||||||
"@md-to-pdf/core",
|
"@md-to-pdf/core",
|
||||||
"@md-to-pdf/renderer",
|
"@md-to-pdf/renderer",
|
||||||
"@md-to-pdf/application",
|
|
||||||
"@md-to-pdf/preview-engine",
|
"@md-to-pdf/preview-engine",
|
||||||
|
"@md-to-pdf/application",
|
||||||
"@md-to-pdf/web"
|
"@md-to-pdf/web"
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ describe("桌面发行构建链", () => {
|
|||||||
artifactPrefix: "MorphDoc"
|
artifactPrefix: "MorphDoc"
|
||||||
});
|
});
|
||||||
expect(desktopManifest).toMatchObject({
|
expect(desktopManifest).toMatchObject({
|
||||||
version: "0.6.1",
|
version: "0.6.4",
|
||||||
productName: "MorphDoc"
|
productName: "MorphDoc"
|
||||||
});
|
});
|
||||||
expect(builderConfig).toContain("appId: brand.appId");
|
expect(builderConfig).toContain("appId: brand.appId");
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
|
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
|
||||||
import { unzipSync } from "fflate";
|
import { unzipSync } from "fflate";
|
||||||
|
import matter from "gray-matter";
|
||||||
import { buildApp } from "../dist/app.js";
|
import { buildApp } from "../dist/app.js";
|
||||||
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
|
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
|
||||||
import { createPdfGenerator } from "../dist/pdf-engine.js";
|
import { createPdfGenerator } from "../dist/pdf-engine.js";
|
||||||
@@ -35,6 +36,27 @@ const fontPackRoot = path.resolve(
|
|||||||
);
|
);
|
||||||
const decoder = new TextDecoder();
|
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 = [
|
const inlineCodeContinuityProbes = [
|
||||||
{
|
{
|
||||||
id: "paragraph",
|
id: "paragraph",
|
||||||
@@ -61,11 +83,99 @@ const inlineCodeContinuityMarkdown = `
|
|||||||
|
|
||||||
- 列表前\`mdtp_ic_l\`列表后
|
- 列表前\`mdtp_ic_l\`列表后
|
||||||
|
|
||||||
| 门禁 | 内容 |
|
| 门禁 | 内容 | 邻接单元格 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
|
||||||
|
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
|
||||||
|
|
||||||
|
| 场景 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 |
|
| 壹式 | 标甲<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([
|
const contentTypes = new Map([
|
||||||
[".css", "text/css; charset=utf-8"],
|
[".css", "text/css; charset=utf-8"],
|
||||||
[".html", "text/html; charset=utf-8"],
|
[".html", "text/html; charset=utf-8"],
|
||||||
@@ -289,6 +399,59 @@ function inspectDocx(content) {
|
|||||||
codeRunPattern.test(paragraph)
|
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 {
|
return {
|
||||||
bytes: content.byteLength,
|
bytes: content.byteLength,
|
||||||
sha256: sha256(content),
|
sha256: sha256(content),
|
||||||
@@ -314,6 +477,7 @@ function inspectDocx(content) {
|
|||||||
inlineCodeContinuityPassed: inlineCodeContinuity.every(
|
inlineCodeContinuityPassed: inlineCodeContinuity.every(
|
||||||
(probe) => probe.passed
|
(probe) => probe.passed
|
||||||
),
|
),
|
||||||
|
tableBreakSemantics,
|
||||||
requiredStylesPresent: [
|
requiredStylesPresent: [
|
||||||
"Normal",
|
"Normal",
|
||||||
"Heading1",
|
"Heading1",
|
||||||
@@ -342,6 +506,103 @@ async function requestArtifact(origin, route, payload, contentType) {
|
|||||||
return { response, content };
|
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) {
|
function responseDiagnostics(response, kind) {
|
||||||
return {
|
return {
|
||||||
serverTiming: response.headers.get("server-timing"),
|
serverTiming: response.headers.get("server-timing"),
|
||||||
@@ -410,6 +671,10 @@ assert(
|
|||||||
);
|
);
|
||||||
const selectedThemeId =
|
const selectedThemeId =
|
||||||
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
|
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 exportConfigOverride = readExportConfigOverride();
|
||||||
const themes = selectedThemeId
|
const themes = selectedThemeId
|
||||||
? allThemes.filter((theme) => theme.id === selectedThemeId)
|
? allThemes.filter((theme) => theme.id === selectedThemeId)
|
||||||
@@ -468,9 +733,13 @@ try {
|
|||||||
for (const theme of themes) {
|
for (const theme of themes) {
|
||||||
capturedDocxDocumentLayout = undefined;
|
capturedDocxDocumentLayout = undefined;
|
||||||
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
|
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}`);
|
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
|
||||||
const markdown = `${fs.readFileSync(samplePath, "utf8").trimEnd()}${inlineCodeContinuityMarkdown}`;
|
const sourceMarkdown = fs.readFileSync(samplePath, "utf8");
|
||||||
|
const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`;
|
||||||
|
const sourceMetadata = matter(sourceMarkdown).data;
|
||||||
const exportConfig = mergeExportConfig({
|
const exportConfig = mergeExportConfig({
|
||||||
...defaultExportConfig,
|
...defaultExportConfig,
|
||||||
name: `${theme.name} R4 生产链验收`,
|
name: `${theme.name} R4 生产链验收`,
|
||||||
@@ -495,11 +764,21 @@ try {
|
|||||||
}, exportConfigOverride);
|
}, exportConfigOverride);
|
||||||
const payload = {
|
const payload = {
|
||||||
markdown,
|
markdown,
|
||||||
fileName: `${theme.id}.md`,
|
fileName: path.basename(samplePath),
|
||||||
language: "zh-CN",
|
language: "zh-CN",
|
||||||
resources: [],
|
resources: [],
|
||||||
exportConfig
|
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(
|
const pdf = await requestArtifact(
|
||||||
origin,
|
origin,
|
||||||
"/api/pdf",
|
"/api/pdf",
|
||||||
@@ -526,10 +805,16 @@ try {
|
|||||||
inspection.requiredStylesPresent,
|
inspection.requiredStylesPresent,
|
||||||
`主题 ${theme.id} 缺少标准 Word 样式`
|
`主题 ${theme.id} 缺少标准 Word 样式`
|
||||||
);
|
);
|
||||||
assert(
|
if (appendInlineCodeProbes) {
|
||||||
inspection.inlineCodeContinuityPassed,
|
assert(
|
||||||
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
|
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);
|
const standard = standardByTheme.get(theme.id);
|
||||||
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
|
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
|
||||||
assert(
|
assert(
|
||||||
@@ -545,8 +830,8 @@ try {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const expectsOfficialDualEndedRow = Boolean(
|
const expectsOfficialDualEndedRow = Boolean(
|
||||||
standard.metadata?.document?.profile === "official" &&
|
sourceMetadata.document?.profile === "official" &&
|
||||||
standard.metadata.document.signatory
|
sourceMetadata.document.signatory
|
||||||
);
|
);
|
||||||
if (expectsOfficialDualEndedRow) {
|
if (expectsOfficialDualEndedRow) {
|
||||||
assert(
|
assert(
|
||||||
@@ -566,9 +851,12 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
|
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(pdfPath, pdf.content);
|
||||||
fs.writeFileSync(docxPath, docx.content);
|
const docxPath = writeArtifactWithBusyFallback(
|
||||||
|
preferredDocxPath,
|
||||||
|
docx.content
|
||||||
|
);
|
||||||
results.push({
|
results.push({
|
||||||
id: theme.id,
|
id: theme.id,
|
||||||
name: theme.name,
|
name: theme.name,
|
||||||
@@ -576,6 +864,7 @@ try {
|
|||||||
compatibleProfiles: theme.compatibleProfiles,
|
compatibleProfiles: theme.compatibleProfiles,
|
||||||
sample: path.relative(repositoryDirectory, samplePath),
|
sample: path.relative(repositoryDirectory, samplePath),
|
||||||
exportConfig,
|
exportConfig,
|
||||||
|
htmlBreakSemantics,
|
||||||
pdf: {
|
pdf: {
|
||||||
outputFile: path.relative(repositoryDirectory, pdfPath),
|
outputFile: path.relative(repositoryDirectory, pdfPath),
|
||||||
bytes: pdf.content.byteLength,
|
bytes: pdf.content.byteLength,
|
||||||
|
|||||||
+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({
|
return reply.code(500).send({
|
||||||
error: "PDF_GENERATION_FAILED",
|
error: "PDF_GENERATION_FAILED",
|
||||||
message: "PDF 生成失败"
|
message: "PDF 生成失败"
|
||||||
@@ -559,7 +568,23 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
if (error instanceof DocxExportServiceError) {
|
if (error instanceof DocxExportServiceError) {
|
||||||
if (error.statusCode >= 500) {
|
if (error.statusCode >= 500) {
|
||||||
request.log.error(
|
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"
|
"DOCX generation service failed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const app = buildApp({
|
|||||||
? {
|
? {
|
||||||
fontPacks: {
|
fontPacks: {
|
||||||
roots: [fontPackRoot],
|
roots: [fontPackRoot],
|
||||||
appVersion: process.env.APP_VERSION ?? "0.6.1"
|
appVersion: process.env.APP_VERSION ?? "0.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
|
|||||||
timeoutMs: readIntegerEnvironment(
|
timeoutMs: readIntegerEnvironment(
|
||||||
environment,
|
environment,
|
||||||
"PDF_TIMEOUT_MS",
|
"PDF_TIMEOUT_MS",
|
||||||
60_000,
|
180_000,
|
||||||
1_000
|
1_000
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function readProjectFile(relativePath: string) {
|
|||||||
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
|
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Docker v0.6.1 部署契约", () => {
|
describe("Docker v0.6.4 部署契约", () => {
|
||||||
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
|
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
|
||||||
const dockerfile = readProjectFile("deploy/Dockerfile");
|
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||||
const workspaces = [
|
const workspaces = [
|
||||||
@@ -82,7 +82,7 @@ describe("Docker v0.6.1 部署契约", () => {
|
|||||||
);
|
);
|
||||||
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
|
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
|
||||||
expect(compose).toContain(
|
expect(compose).toContain(
|
||||||
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.1}"'
|
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.4}"'
|
||||||
);
|
);
|
||||||
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
|
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
|
||||||
expect(dockerignore).toContain("!output/font-packs/root/**");
|
expect(dockerignore).toContain("!output/font-packs/root/**");
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
|
|||||||
expect(readPdfEngineRuntimeLimits({})).toEqual({
|
expect(readPdfEngineRuntimeLimits({})).toEqual({
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
maxQueue: 8,
|
maxQueue: 8,
|
||||||
timeoutMs: 60_000
|
timeoutMs: 180_000
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
|||||||
|
|
||||||
describe("应用版本", () => {
|
describe("应用版本", () => {
|
||||||
it("从统一构建版本生成标题徽标", () => {
|
it("从统一构建版本生成标题徽标", () => {
|
||||||
expect(APP_VERSION).toBe("0.6.1");
|
expect(APP_VERSION).toBe("0.6.4");
|
||||||
expect(APP_VERSION_LABEL).toBe("v0.6.1");
|
expect(APP_VERSION_LABEL).toBe("v0.6.4");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("允许浏览器加载同源 Web Manifest", () => {
|
it("允许浏览器加载同源 Web Manifest", () => {
|
||||||
|
|||||||
+6
-6
@@ -38,7 +38,7 @@ docker compose -f deploy\compose.yaml down
|
|||||||
可以直接指定版本化镜像名称构建:
|
可以直接指定版本化镜像名称构建:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.4'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@ docker compose -f deploy\compose.yaml build
|
|||||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1'
|
$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_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.1 应用代码和生产
|
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.4 应用代码和生产
|
||||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||||
|
|
||||||
## 主题挂载
|
## 主题挂载
|
||||||
@@ -76,7 +76,7 @@ docker compose -f deploy\compose.yaml up -d
|
|||||||
|
|
||||||
## 内置字体包
|
## 内置字体包
|
||||||
|
|
||||||
v0.6.1 的发行镜像固定将受校验字体包内置到
|
v0.6.4 的发行镜像固定将受校验字体包内置到
|
||||||
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
|
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
|
||||||
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
|
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ docker compose -f deploy\compose.yaml up -d
|
|||||||
发布前执行真实内置字体门禁:
|
发布前执行真实内置字体门禁:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.1'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.4'
|
||||||
npm run verify:docker-font-pack
|
npm run verify:docker-font-pack
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ npm run verify:docker-font-pack
|
|||||||
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
||||||
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
||||||
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
||||||
| `MD_TO_PDF_APP_VERSION` | `0.6.1` | 字体包兼容性判断使用的应用版本 |
|
| `MD_TO_PDF_APP_VERSION` | `0.6.4` | 字体包兼容性判断使用的应用版本 |
|
||||||
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
||||||
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
||||||
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ services:
|
|||||||
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
|
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
|
||||||
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
|
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
|
||||||
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
|
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
|
||||||
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.1}"
|
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.4}"
|
||||||
FONT_PACK_ROOT: /app/.local/font-packs
|
FONT_PACK_ROOT: /app/.local/font-packs
|
||||||
volumes:
|
volumes:
|
||||||
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
|
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ const environment = {
|
|||||||
...buildEnvironment,
|
...buildEnvironment,
|
||||||
MD_TO_PDF_PORT: String(port),
|
MD_TO_PDF_PORT: String(port),
|
||||||
MD_TO_PDF_THEME_DIR: themeRoot,
|
MD_TO_PDF_THEME_DIR: themeRoot,
|
||||||
MD_TO_PDF_APP_VERSION: "0.6.1"
|
MD_TO_PDF_APP_VERSION: "0.6.4"
|
||||||
};
|
};
|
||||||
const compose = [
|
const compose = [
|
||||||
"compose",
|
"compose",
|
||||||
|
|||||||
+68
-2
@@ -1,6 +1,52 @@
|
|||||||
# 墨呈进度
|
# 墨呈进度
|
||||||
|
|
||||||
最后更新:2026-08-04
|
最后更新: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. 当前概况
|
## 1. 当前概况
|
||||||
|
|
||||||
@@ -1534,10 +1580,30 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1`
|
文件;发布引用由本轮 `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 可视化编辑工具;
|
- Front Matter 可视化编辑工具;
|
||||||
- ECharts YAML 可视化配置工具;
|
- ECharts YAML 可视化配置工具;
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,16 @@
|
|||||||
# 发版规则
|
# 发版规则
|
||||||
|
|
||||||
|
## 变更日志与发布说明
|
||||||
|
|
||||||
|
根目录 [`CHANGELOG.md`](../CHANGELOG.md) 是版本变更摘要的单一事实来源。
|
||||||
|
每项面向用户或部署方的重要新增、修复、行为变化和兼容性变化应先记录在
|
||||||
|
`Unreleased`;冻结版本时将相关内容移动到带发布日期的版本章节。
|
||||||
|
|
||||||
|
Gitea Release 页面正文应以 `CHANGELOG.md` 对应版本章节为基础生成,可按发布
|
||||||
|
场景补充下载提示,但不得与变更日志中的功能、兼容性和验证结论冲突。
|
||||||
|
`docs/releases/v<version>.md` 用于保存更详细的发布门禁、验证结论和内部发行说明,
|
||||||
|
不单独维护另一份相互矛盾的版本摘要。
|
||||||
|
|
||||||
## 唯一产物目录
|
## 唯一产物目录
|
||||||
|
|
||||||
所有正式分发文件必须统一归集到仓库根目录下的:
|
所有正式分发文件必须统一归集到仓库根目录下的:
|
||||||
@@ -54,7 +65,8 @@ TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前
|
|||||||
1. 完成全量测试、类型检查、生产构建和视觉门禁。
|
1. 完成全量测试、类型检查、生产构建和视觉门禁。
|
||||||
2. 构建字体直接内置的 Desktop 安装包和便携 ZIP。
|
2. 构建字体直接内置的 Desktop 安装包和便携 ZIP。
|
||||||
3. 构建并验证版本化 Docker 镜像。
|
3. 构建并验证版本化 Docker 镜像。
|
||||||
4. 准备 `docs/releases/v<version>.md`。
|
4. 将 `CHANGELOG.md` 中本版本变更从 `Unreleased` 冻结为带日期的版本章节,
|
||||||
|
并以该章节为基础准备 `docs/releases/v<version>.md` 与 Gitea Release 正文。
|
||||||
5. 执行:
|
5. 执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -65,6 +77,8 @@ TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前
|
|||||||
7. 创建内容完整的 `release:` 提交。
|
7. 创建内容完整的 `release:` 提交。
|
||||||
8. 重新生成 release 清单,确保 `gitCommit` 指向该发布提交。
|
8. 重新生成 release 清单,确保 `gitCommit` 指向该发布提交。
|
||||||
9. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
|
9. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
|
||||||
|
10. 发布 Gitea Release,正文以 `CHANGELOG.md` 对应版本章节为基础,并附上
|
||||||
|
本轮正式分发文件。
|
||||||
|
|
||||||
安装器公开分发前应完成 Authenticode 签名;未签名产物只能按内部发布风险
|
安装器公开分发前应完成 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 签名,适用于公司内部发布。
|
||||||
Generated
+17
-70
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.6.1",
|
"version": "0.6.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.6.1",
|
"version": "0.6.4",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.6.1",
|
"version": "0.6.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/docx-engine": "0.1.0",
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
@@ -626,7 +626,6 @@
|
|||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -1056,7 +1055,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
|
||||||
"integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
|
"integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.7.0",
|
"@codemirror/state": "^6.7.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1310,6 +1308,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-dirname": "^0.1.0",
|
"cross-dirname": "^0.1.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
@@ -1331,6 +1330,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -2019,18 +2019,6 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -3500,7 +3488,6 @@
|
|||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3771,20 +3758,6 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -4441,7 +4414,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.44",
|
"baseline-browser-mapping": "^2.10.44",
|
||||||
"caniuse-lite": "^1.0.30001806",
|
"caniuse-lite": "^1.0.30001806",
|
||||||
@@ -4910,7 +4882,8 @@
|
|||||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
@@ -4952,7 +4925,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
|
||||||
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
|
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
@@ -5375,7 +5347,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -5921,6 +5892,7 @@
|
|||||||
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"builder-util": "26.15.3",
|
"builder-util": "26.15.3",
|
||||||
@@ -5934,6 +5906,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/asar": "^3.2.1",
|
"@electron/asar": "^3.2.1",
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
@@ -5954,6 +5927,7 @@
|
|||||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.1.2",
|
"graceful-fs": "^4.1.2",
|
||||||
"jsonfile": "^4.0.0",
|
"jsonfile": "^4.0.0",
|
||||||
@@ -5969,6 +5943,7 @@
|
|||||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
@@ -5979,6 +5954,7 @@
|
|||||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
@@ -7470,7 +7446,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
"entities": "^4.5.0",
|
"entities": "^4.5.0",
|
||||||
@@ -7554,7 +7529,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
||||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "^7.1.2",
|
||||||
"@iconify/utils": "^3.0.2",
|
"@iconify/utils": "^3.0.2",
|
||||||
@@ -8076,7 +8050,6 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -8262,6 +8235,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^9.4.0"
|
"commander": "^9.4.0"
|
||||||
},
|
},
|
||||||
@@ -8279,6 +8253,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
@@ -8423,7 +8398,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8433,7 +8407,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9155,6 +9128,7 @@
|
|||||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"rimraf": "~2.6.2"
|
"rimraf": "~2.6.2"
|
||||||
@@ -9180,6 +9154,7 @@
|
|||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -9194,6 +9169,7 @@
|
|||||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.3"
|
"glob": "^7.1.3"
|
||||||
},
|
},
|
||||||
@@ -9201,34 +9177,6 @@
|
|||||||
"rimraf": "bin.js"
|
"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": {
|
"node_modules/thread-stream": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
@@ -9541,7 +9489,6 @@
|
|||||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
+14
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.6.1",
|
"version": "0.6.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
|
"description": "面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"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": "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",
|
"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/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\"",
|
"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:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||||
"dev:semantic-document": "npm run dev -w @md-to-pdf/semantic-document",
|
"dev:semantic-document": "npm run dev -w @md-to-pdf/semantic-document",
|
||||||
@@ -24,15 +24,22 @@
|
|||||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||||
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
"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: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": "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",
|
"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: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",
|
"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",
|
||||||
"desktop:make": "npm run build:font-pack && node scripts/build-desktop-release.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",
|
"release:stage": "node scripts/stage-release.mjs",
|
||||||
"test:release-stage": "node --test scripts/stage-release.test.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-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",
|
"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-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",
|
"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,7 +52,9 @@
|
|||||||
"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-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-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 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-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",
|
||||||
"test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && 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-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"
|
"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": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
MarkdownDocumentParseError,
|
MarkdownDocumentParseError,
|
||||||
renderMarkdown
|
renderMarkdown,
|
||||||
|
type RenderedMarkdown
|
||||||
} from "@md-to-pdf/renderer";
|
} from "@md-to-pdf/renderer";
|
||||||
import {
|
import {
|
||||||
docxExportRequestSchema,
|
docxExportRequestSchema,
|
||||||
type DocxExportRequest,
|
type DocxExportRequest,
|
||||||
type RenderedMarkdownDocument,
|
|
||||||
type ThemeManifest
|
type ThemeManifest
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
|
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
|
||||||
@@ -45,7 +45,7 @@ export interface ApplicationServiceOptions
|
|||||||
|
|
||||||
export interface PreparedDocxExport {
|
export interface PreparedDocxExport {
|
||||||
request: DocxExportRequest;
|
request: DocxExportRequest;
|
||||||
document: RenderedMarkdownDocument;
|
document: RenderedMarkdown;
|
||||||
theme: {
|
theme: {
|
||||||
manifest: ThemeManifest;
|
manifest: ThemeManifest;
|
||||||
source: "bundled" | "local";
|
source: "bundled" | "local";
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export class DocxExportService {
|
|||||||
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
||||||
): PandocDocxConversionInput {
|
): PandocDocxConversionInput {
|
||||||
return {
|
return {
|
||||||
markdown: prepared.request.markdown,
|
markdown: prepared.document.markdownBody,
|
||||||
fileName: prepared.request.fileName,
|
fileName: prepared.request.fileName,
|
||||||
language: prepared.request.language,
|
language: prepared.request.language,
|
||||||
exportConfig: prepared.request.exportConfig,
|
exportConfig: prepared.request.exportConfig,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const DOCX_FILE_EXTENSION = ".docx";
|
|||||||
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
|
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
|
||||||
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
|
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
|
||||||
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
|
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_RASTER_DPI = 300;
|
||||||
export const DOCX_MEDIA_CSS_DPI = 96;
|
export const DOCX_MEDIA_CSS_DPI = 96;
|
||||||
export const DOCX_MEDIA_RASTER_SCALE =
|
export const DOCX_MEDIA_RASTER_SCALE =
|
||||||
@@ -108,7 +109,7 @@ export type DocxMediaAlignment = z.infer<
|
|||||||
|
|
||||||
export const docxTableLayoutSchema = z.object({
|
export const docxTableLayoutSchema = z.object({
|
||||||
ordinal: z.number().int().min(1).max(256),
|
ordinal: z.number().int().min(1).max(256),
|
||||||
widthPercent: z.number().positive().max(100),
|
widthPercent: z.number().positive().max(300),
|
||||||
leftOffsetPercent: z.number().min(0).max(100),
|
leftOffsetPercent: z.number().min(0).max(100),
|
||||||
columnWidthPercents: z
|
columnWidthPercents: z
|
||||||
.array(z.number().positive().max(100))
|
.array(z.number().positive().max(100))
|
||||||
@@ -145,6 +146,13 @@ export const docxTextBlockLayoutSchema = z.object({
|
|||||||
ordinal: z.number().int().min(1).max(100_000),
|
ordinal: z.number().int().min(1).max(100_000),
|
||||||
text: z.string().min(1).max(100_000),
|
text: z.string().min(1).max(100_000),
|
||||||
letterSpacingPt: z.number().min(-20).max(100),
|
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
|
alignment: z
|
||||||
.enum(["left", "center", "right", "justify", "distribute"])
|
.enum(["left", "center", "right", "justify", "distribute"])
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -162,7 +170,10 @@ export const docxListItemLayoutSchema = z.object({
|
|||||||
ordinal: z.number().int().min(1).max(100_000),
|
ordinal: z.number().int().min(1).max(100_000),
|
||||||
text: z.string().min(1).max(100_000),
|
text: z.string().min(1).max(100_000),
|
||||||
depth: z.number().int().min(0).max(64),
|
depth: z.number().int().min(0).max(64),
|
||||||
textStartPt: z.number().min(0).max(2_000)
|
textStartPt: z.number().min(0).max(2_000),
|
||||||
|
alignment: z
|
||||||
|
.enum(["left", "center", "right", "justify", "distribute"])
|
||||||
|
.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DocxListItemLayout = z.infer<
|
export type DocxListItemLayout = z.infer<
|
||||||
@@ -194,11 +205,22 @@ export type DocxInlineCodeLayout = z.infer<
|
|||||||
typeof docxInlineCodeLayoutSchema
|
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({
|
export const docxDocumentLayoutPlanSchema = z.object({
|
||||||
tables: z.array(docxTableLayoutSchema).max(256),
|
tables: z.array(docxTableLayoutSchema).max(256),
|
||||||
textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(),
|
textBlocks: z.array(docxTextBlockLayoutSchema).max(100_000).optional(),
|
||||||
listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(),
|
listItems: z.array(docxListItemLayoutSchema).max(100_000).optional(),
|
||||||
inlineCodes: z.array(docxInlineCodeLayoutSchema).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<
|
export type DocxDocumentLayoutPlan = z.infer<
|
||||||
@@ -208,12 +230,12 @@ export type DocxDocumentLayoutPlan = z.infer<
|
|||||||
export const docxMediaCaptureTargetSchema = z.object({
|
export const docxMediaCaptureTargetSchema = z.object({
|
||||||
id: z.string().regex(/^docx-media-\d+$/u),
|
id: z.string().regex(/^docx-media-\d+$/u),
|
||||||
kind: docxMediaKindSchema,
|
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
|
kindOrdinal: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
.max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||||
altText: z.string().max(1_000),
|
altText: z.string().max(1_000),
|
||||||
caption: z.string().max(1_000).optional(),
|
caption: z.string().max(1_000).optional(),
|
||||||
alignment: docxMediaAlignmentSchema,
|
alignment: docxMediaAlignmentSchema,
|
||||||
@@ -233,7 +255,7 @@ export type DocxMediaCaptureTarget = z.infer<
|
|||||||
export const docxMediaCapturePlanSchema = z.object({
|
export const docxMediaCapturePlanSchema = z.object({
|
||||||
targets: z
|
targets: z
|
||||||
.array(docxMediaCaptureTargetSchema)
|
.array(docxMediaCaptureTargetSchema)
|
||||||
.max(MAXIMUM_DOCX_RESOURCE_COUNT),
|
.max(MAXIMUM_DOCX_MEDIA_COUNT),
|
||||||
echartsErrors: z.array(z.string().max(1_000)),
|
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()
|
documentLayout: docxDocumentLayoutPlanSchema.optional()
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
DOCX_MIME_TYPE,
|
DOCX_MIME_TYPE,
|
||||||
DOCX_MEDIA_RASTER_SCALE,
|
DOCX_MEDIA_RASTER_SCALE,
|
||||||
DOCX_PANDOC_VERSION,
|
DOCX_PANDOC_VERSION,
|
||||||
|
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||||
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
||||||
|
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||||
createDocxFileName,
|
createDocxFileName,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
docxCapabilitySchema,
|
docxCapabilitySchema,
|
||||||
@@ -167,6 +169,43 @@ describe("DOCX 共享协议", () => {
|
|||||||
}).success
|
}).success
|
||||||
).toBe(false);
|
).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 主题样式协议", () => {
|
describe("DOCX 主题样式协议", () => {
|
||||||
|
|||||||
@@ -278,8 +278,14 @@ export function comparePdfSnapshotsBasic(
|
|||||||
paragraphLayouts.every(
|
paragraphLayouts.every(
|
||||||
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
|
(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
|
const baselineEditableCoverage = hasEditableExpectation
|
||||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
? baselineParagraphContentExact
|
||||||
|
? 1
|
||||||
|
: calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||||
: undefined;
|
: undefined;
|
||||||
const candidateEditableSimilarity = hasEditableExpectation
|
const candidateEditableSimilarity = hasEditableExpectation
|
||||||
? calculateContentSimilarity(
|
? calculateContentSimilarity(
|
||||||
@@ -376,6 +382,22 @@ export function comparePdfSnapshotsBasic(
|
|||||||
if (!primaryExpectation) {
|
if (!primaryExpectation) {
|
||||||
return false;
|
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(
|
return comparePdfPageSemantics(
|
||||||
baseline,
|
baseline,
|
||||||
candidate,
|
candidate,
|
||||||
|
|||||||
@@ -131,6 +131,22 @@ try {
|
|||||||
$pageCount = $null
|
$pageCount = $null
|
||||||
try { $pageCount = $document.ComputeStatistics(2) } catch {}
|
try { $pageCount = $document.ComputeStatistics(2) } catch {}
|
||||||
$document.ExportAsFixedFormat($resolvedOutput, 17)
|
$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]@{
|
[pscustomobject]@{
|
||||||
pages = $pageCount
|
pages = $pageCount
|
||||||
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
|
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||||
@@ -500,13 +516,31 @@ function createOfficePdfAdapter(
|
|||||||
);
|
);
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
const metadata = await backend.exportPdf({
|
let metadata: OfficeExportMetadata | undefined;
|
||||||
client: descriptor.client,
|
let lastError: unknown;
|
||||||
progId: descriptor.progId,
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
inputPath,
|
try {
|
||||||
outputPath,
|
metadata = await backend.exportPdf({
|
||||||
timeoutMs,
|
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);
|
const outputStat = await stat(outputPath);
|
||||||
if (
|
if (
|
||||||
outputStat.size <= 0 ||
|
outputStat.size <= 0 ||
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,12 +33,35 @@ async function decodeRaster(
|
|||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
): Promise<DecodedRaster> {
|
): 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 canvas = createCanvas(width, height);
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
context.fillStyle = "#ffffff";
|
context.fillStyle = "#ffffff";
|
||||||
context.fillRect(0, 0, width, height);
|
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 {
|
return {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -65,11 +88,66 @@ function colorDistance(left: RgbColor, right: RgbColor): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function rasterBackgroundColor(
|
||||||
rgba: Uint8ClampedArray,
|
rgba: Uint8ClampedArray,
|
||||||
_width: number,
|
width: number,
|
||||||
_height: number,
|
height: number,
|
||||||
): RgbColor {
|
): 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<
|
const buckets = new Map<
|
||||||
number,
|
number,
|
||||||
{ count: number; red: number; green: number; blue: number }
|
{ count: number; red: number; green: number; blue: number }
|
||||||
@@ -78,10 +156,12 @@ function rasterBackgroundColor(
|
|||||||
| { count: number; red: number; green: number; blue: number }
|
| { count: number; red: number; green: number; blue: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
for (let offset = 0; offset < rgba.length; offset += 4) {
|
for (let offset = 0; offset < rgba.length; offset += 4) {
|
||||||
const red = rgba[offset] ?? 255;
|
const pixelRed = rgba[offset] ?? 255;
|
||||||
const green = rgba[offset + 1] ?? 255;
|
const pixelGreen = rgba[offset + 1] ?? 255;
|
||||||
const blue = rgba[offset + 2] ?? 255;
|
const pixelBlue = rgba[offset + 2] ?? 255;
|
||||||
const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
|
const bucketKey = (pixelRed >> 3) << 10 |
|
||||||
|
(pixelGreen >> 3) << 5 |
|
||||||
|
(pixelBlue >> 3);
|
||||||
const bucket = buckets.get(bucketKey) ?? {
|
const bucket = buckets.get(bucketKey) ?? {
|
||||||
count: 0,
|
count: 0,
|
||||||
red: 0,
|
red: 0,
|
||||||
@@ -89,21 +169,28 @@ function rasterBackgroundColor(
|
|||||||
blue: 0,
|
blue: 0,
|
||||||
};
|
};
|
||||||
bucket.count += 1;
|
bucket.count += 1;
|
||||||
bucket.red += red;
|
bucket.red += pixelRed;
|
||||||
bucket.green += green;
|
bucket.green += pixelGreen;
|
||||||
bucket.blue += blue;
|
bucket.blue += pixelBlue;
|
||||||
buckets.set(bucketKey, bucket);
|
buckets.set(bucketKey, bucket);
|
||||||
if (!dominant || bucket.count > dominant.count) {
|
if (!dominant || bucket.count > dominant.count) {
|
||||||
dominant = bucket;
|
dominant = bucket;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return !dominant
|
const dominantColor = dominant
|
||||||
? { red: 255, green: 255, blue: 255 }
|
? {
|
||||||
: {
|
|
||||||
red: dominant.red / dominant.count,
|
red: dominant.red / dominant.count,
|
||||||
green: dominant.green / dominant.count,
|
green: dominant.green / dominant.count,
|
||||||
blue: dominant.blue / dominant.count,
|
blue: dominant.blue / dominant.count,
|
||||||
};
|
}
|
||||||
|
: border;
|
||||||
|
// 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变
|
||||||
|
// 把深色文字拆分成了错误的“众数背景”。
|
||||||
|
return borderChannelRange <= 64 &&
|
||||||
|
colorLuminance(border) >= 220 &&
|
||||||
|
colorLuminance(dominantColor) <= colorLuminance(border) - 80
|
||||||
|
? border
|
||||||
|
: dominantColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rasterForegroundColor(
|
function rasterForegroundColor(
|
||||||
@@ -172,6 +259,183 @@ function rasterForegroundColor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function clearMaskBorder(
|
||||||
mask: Uint8Array,
|
mask: Uint8Array,
|
||||||
width: number,
|
width: number,
|
||||||
@@ -427,16 +691,16 @@ export async function comparePageRasters(
|
|||||||
!Number.isSafeInteger(height) || height <= 0) {
|
!Number.isSafeInteger(height) || height <= 0) {
|
||||||
throw new Error("栅格比较尺寸必须是正整数");
|
throw new Error("栅格比较尺寸必须是正整数");
|
||||||
}
|
}
|
||||||
const [baselineDecoded, candidateDecoded] = await Promise.all([
|
const baselineDecoded = await decodeRaster(baseline, width, height);
|
||||||
decodeRaster(baseline, width, height),
|
const candidateDecoded = await decodeRaster(candidate, width, height);
|
||||||
decodeRaster(candidate, width, height),
|
|
||||||
]);
|
|
||||||
const pixelCount = width * height;
|
const pixelCount = width * height;
|
||||||
const baselineGray = new Uint8Array(pixelCount);
|
const baselineGray = new Uint8Array(pixelCount);
|
||||||
const candidateGray = new Uint8Array(pixelCount);
|
const candidateGray = new Uint8Array(pixelCount);
|
||||||
const pixelDifference = new Uint8Array(pixelCount);
|
const pixelDifference = new Uint8Array(pixelCount);
|
||||||
const baselineInk = new Uint8Array(pixelCount);
|
const baselineInk = new Uint8Array(pixelCount);
|
||||||
const candidateInk = new Uint8Array(pixelCount);
|
const candidateInk = new Uint8Array(pixelCount);
|
||||||
|
const baselineForegroundInk = new Uint8Array(pixelCount);
|
||||||
|
const candidateForegroundInk = new Uint8Array(pixelCount);
|
||||||
const baselineBackground = rasterBackgroundColor(
|
const baselineBackground = rasterBackgroundColor(
|
||||||
baselineDecoded.rgba,
|
baselineDecoded.rgba,
|
||||||
width,
|
width,
|
||||||
@@ -447,6 +711,8 @@ export async function comparePageRasters(
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
);
|
);
|
||||||
|
const baselineForeground = rasterForegroundColor(baselineDecoded);
|
||||||
|
const candidateForeground = rasterForegroundColor(candidateDecoded);
|
||||||
let absoluteError = 0;
|
let absoluteError = 0;
|
||||||
let changedPixels = 0;
|
let changedPixels = 0;
|
||||||
|
|
||||||
@@ -494,10 +760,34 @@ export async function comparePageRasters(
|
|||||||
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||||
candidateInk[pixelIndex] = 1;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMaskBorder(baselineInk, width, height);
|
clearMaskBorder(baselineInk, width, height);
|
||||||
clearMaskBorder(candidateInk, width, height);
|
clearMaskBorder(candidateInk, width, height);
|
||||||
|
clearMaskBorder(baselineForegroundInk, width, height);
|
||||||
|
clearMaskBorder(candidateForegroundInk, width, height);
|
||||||
const baselineEdges = clearMaskBorder(
|
const baselineEdges = clearMaskBorder(
|
||||||
createEdgeMask(baselineGray, width, height, 40),
|
createEdgeMask(baselineGray, width, height, 40),
|
||||||
width,
|
width,
|
||||||
@@ -509,8 +799,27 @@ export async function comparePageRasters(
|
|||||||
height,
|
height,
|
||||||
);
|
);
|
||||||
const foregroundColorDelta = colorDistance(
|
const foregroundColorDelta = colorDistance(
|
||||||
rasterForegroundColor(baselineDecoded),
|
baselineForeground,
|
||||||
rasterForegroundColor(candidateDecoded),
|
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(
|
const backgroundColorDelta = colorDistance(
|
||||||
baselineBackground,
|
baselineBackground,
|
||||||
@@ -537,6 +846,13 @@ export async function comparePageRasters(
|
|||||||
height,
|
height,
|
||||||
spatialTolerancePx,
|
spatialTolerancePx,
|
||||||
),
|
),
|
||||||
|
foregroundInkIou: calculateSpatiallyTolerantIou(
|
||||||
|
baselineForegroundInk,
|
||||||
|
candidateForegroundInk,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
spatialTolerancePx,
|
||||||
|
),
|
||||||
edgeIou: calculateSpatiallyTolerantIou(
|
edgeIou: calculateSpatiallyTolerantIou(
|
||||||
baselineEdges,
|
baselineEdges,
|
||||||
candidateEdges,
|
candidateEdges,
|
||||||
@@ -546,6 +862,19 @@ export async function comparePageRasters(
|
|||||||
),
|
),
|
||||||
backgroundColorDelta,
|
backgroundColorDelta,
|
||||||
foregroundColorDelta,
|
foregroundColorDelta,
|
||||||
|
baselineForegroundChroma: colorChroma(baselineForeground),
|
||||||
|
candidateForegroundChroma: colorChroma(candidateForeground),
|
||||||
|
foregroundLuminanceDelta: Math.abs(
|
||||||
|
colorLuminance(baselineForeground) -
|
||||||
|
colorLuminance(candidateForeground),
|
||||||
|
),
|
||||||
|
dominantForegroundColorDelta,
|
||||||
|
...(chromaticForegroundColorDelta !== undefined
|
||||||
|
? { chromaticForegroundColorDelta }
|
||||||
|
: {}),
|
||||||
|
...(chromaticPaletteDelta !== undefined
|
||||||
|
? { chromaticPaletteDelta }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
metrics,
|
metrics,
|
||||||
|
|||||||
@@ -14,7 +14,27 @@ import type {
|
|||||||
VisualDiffThresholds,
|
VisualDiffThresholds,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
const BLOCK_RASTER_PADDING_PT = 4;
|
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 {
|
function sha256(bytes: Uint8Array): string {
|
||||||
return createHash("sha256").update(bytes).digest("hex");
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
@@ -48,6 +68,73 @@ function collectFonts(
|
|||||||
].sort();
|
].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(
|
async function cropRaster(
|
||||||
raster: PdfPageRaster,
|
raster: PdfPageRaster,
|
||||||
bounds: PdfPointBounds,
|
bounds: PdfPointBounds,
|
||||||
@@ -55,22 +142,28 @@ async function cropRaster(
|
|||||||
const pixelsPerPoint = raster.dpi / 72;
|
const pixelsPerPoint = raster.dpi / 72;
|
||||||
const left = Math.max(
|
const left = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
Math.floor(
|
||||||
|
(bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const top = Math.max(
|
const top = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
Math.floor(
|
||||||
|
(bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const right = Math.min(
|
const right = Math.min(
|
||||||
raster.widthPx,
|
raster.widthPx,
|
||||||
Math.ceil(
|
Math.ceil(
|
||||||
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
(bounds.x + bounds.width + BLOCK_RASTER_HORIZONTAL_PADDING_PT) *
|
||||||
|
pixelsPerPoint,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const bottom = Math.min(
|
const bottom = Math.min(
|
||||||
raster.heightPx,
|
raster.heightPx,
|
||||||
Math.ceil(
|
Math.ceil(
|
||||||
(bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
(bounds.y + bounds.height + BLOCK_RASTER_VERTICAL_PADDING_PT) *
|
||||||
|
pixelsPerPoint,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const widthPx = Math.max(1, right - left);
|
const widthPx = Math.max(1, right - left);
|
||||||
@@ -91,6 +184,7 @@ async function cropRaster(
|
|||||||
widthPx,
|
widthPx,
|
||||||
heightPx,
|
heightPx,
|
||||||
);
|
);
|
||||||
|
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||||
return {
|
return {
|
||||||
widthPx,
|
widthPx,
|
||||||
@@ -98,6 +192,7 @@ async function cropRaster(
|
|||||||
dpi: raster.dpi,
|
dpi: raster.dpi,
|
||||||
sha256: sha256(png),
|
sha256: sha256(png),
|
||||||
png,
|
png,
|
||||||
|
rgba,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +204,25 @@ async function padRaster(
|
|||||||
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
||||||
return raster;
|
return raster;
|
||||||
}
|
}
|
||||||
const source = await loadImage(Buffer.from(raster.png));
|
|
||||||
const canvas = createCanvas(widthPx, heightPx);
|
const canvas = createCanvas(widthPx, heightPx);
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
context.fillStyle = "#ffffff";
|
context.fillStyle = "#ffffff";
|
||||||
context.fillRect(0, 0, widthPx, heightPx);
|
context.fillRect(0, 0, widthPx, heightPx);
|
||||||
context.drawImage(source, 0, 0);
|
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"));
|
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||||
return {
|
return {
|
||||||
widthPx,
|
widthPx,
|
||||||
@@ -122,6 +230,7 @@ async function padRaster(
|
|||||||
dpi: raster.dpi,
|
dpi: raster.dpi,
|
||||||
sha256: sha256(png),
|
sha256: sha256(png),
|
||||||
png,
|
png,
|
||||||
|
rgba,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +238,7 @@ function localRasterIssues(
|
|||||||
paragraphIndex: number,
|
paragraphIndex: number,
|
||||||
lineIndex: number,
|
lineIndex: number,
|
||||||
blockKind: string | undefined,
|
blockKind: string | undefined,
|
||||||
|
hasInlineCode: boolean,
|
||||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||||
thresholds: VisualDiffThresholds,
|
thresholds: VisualDiffThresholds,
|
||||||
textReflow: boolean,
|
textReflow: boolean,
|
||||||
@@ -136,9 +246,12 @@ function localRasterIssues(
|
|||||||
const shapeFailed =
|
const shapeFailed =
|
||||||
metrics.inkIou < thresholds.minInkIou ||
|
metrics.inkIou < thresholds.minInkIou ||
|
||||||
metrics.edgeIou < thresholds.minEdgeIou;
|
metrics.edgeIou < thresholds.minEdgeIou;
|
||||||
|
const neutralTinyGlyphAntialiasEquivalent =
|
||||||
|
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||||
const colorFailed =
|
const colorFailed =
|
||||||
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
|
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
|
||||||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta;
|
(metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta &&
|
||||||
|
!neutralTinyGlyphAntialiasEquivalent);
|
||||||
const rawPixelsFailed =
|
const rawPixelsFailed =
|
||||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
|
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
|
||||||
@@ -150,8 +263,10 @@ function localRasterIssues(
|
|||||||
metrics,
|
metrics,
|
||||||
textReflow,
|
textReflow,
|
||||||
blockKind,
|
blockKind,
|
||||||
|
hasInlineCode,
|
||||||
);
|
);
|
||||||
const failed = colorFailed || (!textReflow && (
|
const failed = (colorFailed && !crossEngineRasterEquivalent) ||
|
||||||
|
(!textReflow && (
|
||||||
(shapeFailed && !crossEngineRasterEquivalent) ||
|
(shapeFailed && !crossEngineRasterEquivalent) ||
|
||||||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
|
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
|
||||||
));
|
));
|
||||||
@@ -174,12 +289,81 @@ function localRasterIssues(
|
|||||||
foregroundColorDelta: metrics.foregroundColorDelta,
|
foregroundColorDelta: metrics.foregroundColorDelta,
|
||||||
antialiasEquivalent,
|
antialiasEquivalent,
|
||||||
crossEngineRasterEquivalent,
|
crossEngineRasterEquivalent,
|
||||||
|
neutralTinyGlyphAntialiasEquivalent,
|
||||||
textReflow,
|
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 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
|
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
|
||||||
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
|
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
|
||||||
@@ -189,6 +373,7 @@ export function isCrossEngineRasterEquivalent(
|
|||||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||||
textReflow: boolean,
|
textReflow: boolean,
|
||||||
blockKind?: string,
|
blockKind?: string,
|
||||||
|
hasInlineCode = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (textReflow) {
|
if (textReflow) {
|
||||||
return false;
|
return false;
|
||||||
@@ -215,10 +400,27 @@ export function isCrossEngineRasterEquivalent(
|
|||||||
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
|
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
|
||||||
widthDelta <= 2 &&
|
widthDelta <= 2 &&
|
||||||
heightDelta <= 1;
|
heightDelta <= 1;
|
||||||
|
const neutralTinyGlyphAntialiasEquivalent =
|
||||||
|
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||||
const colorEquivalent =
|
const colorEquivalent =
|
||||||
metrics.backgroundColorDelta <= 4 &&
|
neutralTinyGlyphAntialiasEquivalent ||
|
||||||
metrics.foregroundColorDelta <=
|
(
|
||||||
(blockKind === "list-item" ? 4.5 : 4);
|
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 =
|
const topologyEquivalent =
|
||||||
(preciseGeometryEquivalent &&
|
(preciseGeometryEquivalent &&
|
||||||
metrics.inkIou >= 0.99 &&
|
metrics.inkIou >= 0.99 &&
|
||||||
@@ -247,7 +449,8 @@ export function isCrossEngineRasterEquivalent(
|
|||||||
(preciseGeometryEquivalent &&
|
(preciseGeometryEquivalent &&
|
||||||
metrics.edgeIou >= 0.97 &&
|
metrics.edgeIou >= 0.97 &&
|
||||||
metrics.inkIou >= 0.97);
|
metrics.inkIou >= 0.97);
|
||||||
return colorEquivalent && topologyEquivalent;
|
return inlineCodeShadingEquivalent ||
|
||||||
|
(colorEquivalent && topologyEquivalent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function comparePdfSemanticBlockVisuals(
|
export async function comparePdfSemanticBlockVisuals(
|
||||||
@@ -270,14 +473,41 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
"paragraph",
|
"paragraph",
|
||||||
"list-item",
|
"list-item",
|
||||||
"block-quote",
|
"block-quote",
|
||||||
|
"code-block",
|
||||||
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
||||||
const comparableLineCount = Math.min(
|
const comparableLineCount = Math.min(
|
||||||
paragraph.baseline.visualLines.length,
|
paragraph.baseline.visualLines.length,
|
||||||
paragraph.candidate.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 (
|
if (
|
||||||
!paragraph.baseline.matched ||
|
!hasUsableVisualMapping(paragraph.baseline) ||
|
||||||
!paragraph.candidate.matched ||
|
!hasUsableVisualMapping(paragraph.candidate) ||
|
||||||
comparableLineCount === 0
|
comparableLineCount === 0
|
||||||
) {
|
) {
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -286,10 +516,7 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
||||||
details: { paragraphIndex: paragraph.expectation.index },
|
details: { paragraphIndex: paragraph.expectation.index },
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (equalLineSegmentation) {
|
||||||
paragraph.baseline.visualLines.length ===
|
|
||||||
paragraph.candidate.visualLines.length
|
|
||||||
) {
|
|
||||||
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
||||||
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
||||||
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
||||||
@@ -327,41 +554,67 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||||
cropRaster(candidatePage.raster, candidateLine.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 baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
|
||||||
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
|
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
|
||||||
const textReflow = flowTextBlock &&
|
const textReflow = flowTextBlock &&
|
||||||
baselineLineText !== candidateLineText;
|
baselineLineText !== candidateLineText;
|
||||||
const result = flowTextBlock && !textReflow
|
const cropWidthRatio = Math.min(
|
||||||
? await comparePageRasters(baselineCrop, candidateCrop, {
|
baselineCrop.widthPx,
|
||||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
candidateCrop.widthPx,
|
||||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
) / Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||||
targetWidthPx: baselineCrop.widthPx,
|
const normalizeFlowGeometry = flowTextBlock &&
|
||||||
targetHeightPx: baselineCrop.heightPx,
|
!textReflow &&
|
||||||
geometryNormalized: true,
|
cropWidthRatio >= 0.75;
|
||||||
})
|
let result;
|
||||||
: await (async () => {
|
try {
|
||||||
const widthPx = Math.max(
|
result = normalizeFlowGeometry
|
||||||
baselineCrop.widthPx,
|
? await comparePageRasters(baselineCrop, candidateCrop, {
|
||||||
candidateCrop.widthPx,
|
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||||
);
|
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||||
const heightPx = Math.max(
|
targetWidthPx: baselineCrop.widthPx,
|
||||||
baselineCrop.heightPx,
|
targetHeightPx: baselineCrop.heightPx,
|
||||||
candidateCrop.heightPx,
|
geometryNormalized: true,
|
||||||
);
|
})
|
||||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
: await (async () => {
|
||||||
padRaster(baselineCrop, widthPx, heightPx),
|
const widthPx = Math.max(
|
||||||
padRaster(candidateCrop, widthPx, heightPx),
|
baselineCrop.widthPx,
|
||||||
]);
|
candidateCrop.widthPx,
|
||||||
return comparePageRasters(
|
);
|
||||||
baselinePadded,
|
const heightPx = Math.max(
|
||||||
candidatePadded,
|
baselineCrop.heightPx,
|
||||||
thresholds.pixelDifferenceThreshold,
|
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(
|
const lineIssues = localRasterIssues(
|
||||||
paragraph.expectation.index,
|
paragraph.expectation.index,
|
||||||
lineIndex,
|
lineIndex,
|
||||||
paragraph.expectation.blockKind,
|
paragraph.expectation.blockKind,
|
||||||
|
paragraph.expectation.hasInlineCode === true,
|
||||||
result.metrics,
|
result.metrics,
|
||||||
thresholds,
|
thresholds,
|
||||||
textReflow,
|
textReflow,
|
||||||
@@ -376,6 +629,139 @@ export async function comparePdfSemanticBlockVisuals(
|
|||||||
issues: lineIssues,
|
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({
|
comparisons.push({
|
||||||
expectation: paragraph.expectation,
|
expectation: paragraph.expectation,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
VisualPageSemanticExpectation,
|
VisualPageSemanticExpectation,
|
||||||
} from "./types.js";
|
} 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 CJK_RADICAL_VARIANTS = new Map([
|
||||||
["⺠", "民"],
|
["⺠", "民"],
|
||||||
["⻅", "见"],
|
["⻅", "见"],
|
||||||
@@ -16,6 +16,10 @@ const CJK_RADICAL_VARIANTS = new Map([
|
|||||||
["⻔", "门"],
|
["⻔", "门"],
|
||||||
["⻚", "页"],
|
["⻚", "页"],
|
||||||
["⻛", "风"],
|
["⻛", "风"],
|
||||||
|
["⻝", "食"],
|
||||||
|
["⻣", "骨"],
|
||||||
|
["⻋", "车"],
|
||||||
|
["⻬", "齐"],
|
||||||
]);
|
]);
|
||||||
const PAGE_NUMBER_PATTERNS = [
|
const PAGE_NUMBER_PATTERNS = [
|
||||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||||
@@ -30,6 +34,16 @@ export function normalizePdfText(value: string): string {
|
|||||||
.replace(/[\u2e80-\u2eff]/gu, (character) =>
|
.replace(/[\u2e80-\u2eff]/gu, (character) =>
|
||||||
CJK_RADICAL_VARIANTS.get(character) ?? 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(ZERO_WIDTH_AND_CONTROL, "")
|
||||||
.replace(/\u00a0/gu, " ")
|
.replace(/\u00a0/gu, " ")
|
||||||
.replace(/[ \t]+/gu, " ")
|
.replace(/[ \t]+/gu, " ")
|
||||||
@@ -133,6 +147,63 @@ function median(values: readonly number[]): number {
|
|||||||
return ((sorted[middle - 1] ?? value) + value) / 2;
|
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 {
|
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
|
||||||
let result = "";
|
let result = "";
|
||||||
let previous: PdfTextItemSnapshot | undefined;
|
let previous: PdfTextItemSnapshot | undefined;
|
||||||
@@ -181,6 +252,9 @@ export function aggregatePdfTextLines(
|
|||||||
sourceItems: readonly PdfTextItemSnapshot[],
|
sourceItems: readonly PdfTextItemSnapshot[],
|
||||||
pageHeightPt: number,
|
pageHeightPt: number,
|
||||||
): PdfTextLineSnapshot[] {
|
): PdfTextLineSnapshot[] {
|
||||||
|
const sourceOrder = new Map(
|
||||||
|
sourceItems.map((item, index) => [item, index] as const),
|
||||||
|
);
|
||||||
const items = sourceItems
|
const items = sourceItems
|
||||||
.filter((item) => item.normalizedText.length > 0)
|
.filter((item) => item.normalizedText.length > 0)
|
||||||
.sort(
|
.sort(
|
||||||
@@ -188,11 +262,14 @@ export function aggregatePdfTextLines(
|
|||||||
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
|
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
|
||||||
);
|
);
|
||||||
const typicalHeight = median(items.map((item) => item.bounds.height));
|
const typicalHeight = median(items.map((item) => item.bounds.height));
|
||||||
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
|
// Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
|
||||||
const groups: PdfTextItemSnapshot[][] = [];
|
// 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
|
||||||
|
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
|
||||||
|
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
|
||||||
|
const initialGroups: PdfTextItemSnapshot[][] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const lastGroup = groups.at(-1);
|
const lastGroup = initialGroups.at(-1);
|
||||||
const lastBaseline = lastGroup
|
const lastBaseline = lastGroup
|
||||||
? median(lastGroup.map((entry) => entry.baselineY))
|
? median(lastGroup.map((entry) => entry.baselineY))
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -203,13 +280,27 @@ export function aggregatePdfTextLines(
|
|||||||
) {
|
) {
|
||||||
lastGroup.push(item);
|
lastGroup.push(item);
|
||||||
} else {
|
} else {
|
||||||
groups.push([item]);
|
initialGroups.push([item]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
|
||||||
|
|
||||||
return groups.map((group) => {
|
return groups.map((group) => {
|
||||||
const sortedItems = [...group].sort(
|
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 bounds = unionBounds(sortedItems);
|
||||||
const normalizedText = joinLineItems(sortedItems);
|
const normalizedText = joinLineItems(sortedItems);
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export type EditableParagraphBlockKind =
|
|||||||
export interface EditableParagraphExpectation {
|
export interface EditableParagraphExpectation {
|
||||||
index: number;
|
index: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
mathCharacterIndexes?: number[];
|
||||||
|
hardBreakSegments?: string[];
|
||||||
|
hasInlineCode?: boolean;
|
||||||
|
tableGroupId?: string;
|
||||||
|
tableRowIndex?: number;
|
||||||
|
tableColumnIndex?: number;
|
||||||
styleId?: string;
|
styleId?: string;
|
||||||
role: EditableParagraphRole;
|
role: EditableParagraphRole;
|
||||||
blockKind?: EditableParagraphBlockKind;
|
blockKind?: EditableParagraphBlockKind;
|
||||||
@@ -144,6 +150,7 @@ export interface PdfPageRaster {
|
|||||||
dpi: number;
|
dpi: number;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
png: Uint8Array;
|
png: Uint8Array;
|
||||||
|
rgba?: Uint8ClampedArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PdfPageSnapshot {
|
export interface PdfPageSnapshot {
|
||||||
@@ -287,9 +294,16 @@ export interface PdfRasterDiffMetrics {
|
|||||||
meanAbsoluteError: number;
|
meanAbsoluteError: number;
|
||||||
changedPixelRatio: number;
|
changedPixelRatio: number;
|
||||||
inkIou: number;
|
inkIou: number;
|
||||||
|
foregroundInkIou: number;
|
||||||
edgeIou: number;
|
edgeIou: number;
|
||||||
backgroundColorDelta: number;
|
backgroundColorDelta: number;
|
||||||
foregroundColorDelta: number;
|
foregroundColorDelta: number;
|
||||||
|
baselineForegroundChroma?: number;
|
||||||
|
candidateForegroundChroma?: number;
|
||||||
|
foregroundLuminanceDelta?: number;
|
||||||
|
dominantForegroundColorDelta?: number;
|
||||||
|
chromaticForegroundColorDelta?: number;
|
||||||
|
chromaticPaletteDelta?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComparePageRasterOptions {
|
export interface ComparePageRasterOptions {
|
||||||
|
|||||||
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
|
|||||||
await rm(directory, { recursive: true, force: true });
|
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 适配器视觉编排", () => {
|
describe("PDF 适配器视觉编排", () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ describe("PDF 栅格差异", () => {
|
|||||||
meanAbsoluteError: 0,
|
meanAbsoluteError: 0,
|
||||||
changedPixelRatio: 0,
|
changedPixelRatio: 0,
|
||||||
inkIou: 1,
|
inkIou: 1,
|
||||||
|
foregroundInkIou: 1,
|
||||||
edgeIou: 1,
|
edgeIou: 1,
|
||||||
backgroundColorDelta: 0,
|
backgroundColorDelta: 0,
|
||||||
foregroundColorDelta: 0,
|
foregroundColorDelta: 0,
|
||||||
@@ -88,6 +89,96 @@ describe("PDF 栅格差异", () => {
|
|||||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
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 () => {
|
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||||
const result = await comparePageRasters(raster(10), raster(20), {
|
const result = await comparePageRasters(raster(10), raster(20), {
|
||||||
spatialTolerancePx: 0,
|
spatialTolerancePx: 0,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
createPdfVisualDiffReport,
|
createPdfVisualDiffReport,
|
||||||
getBlockingVisualDiffIssues,
|
getBlockingVisualDiffIssues,
|
||||||
|
isCrossEngineRasterEquivalent,
|
||||||
renderPdfVisualDiffHtml,
|
renderPdfVisualDiffHtml,
|
||||||
serializePdfVisualDiffJson,
|
serializePdfVisualDiffJson,
|
||||||
type PdfDocumentSnapshot,
|
type PdfDocumentSnapshot,
|
||||||
@@ -84,6 +85,27 @@ function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -561,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 () => {
|
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||||
text,
|
text,
|
||||||
@@ -664,6 +738,71 @@ describe("PDF 视觉差异报告", () => {
|
|||||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
.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 () => {
|
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||||
const report = await createPdfVisualDiffReport(
|
const report = await createPdfVisualDiffReport(
|
||||||
snapshot("baseline", raster("#111111")),
|
snapshot("baseline", raster("#111111")),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createCanvas } from "@napi-rs/canvas";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
comparePdfSemanticBlockVisuals,
|
||||||
isCrossEngineRasterEquivalent,
|
isCrossEngineRasterEquivalent,
|
||||||
type PdfRasterMetrics,
|
type PdfRasterMetrics,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
@@ -28,7 +30,289 @@ function metrics(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("跨引擎字形栅格等价", () => {
|
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("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
@@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => {
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).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(
|
expect(
|
||||||
isCrossEngineRasterEquivalent(
|
isCrossEngineRasterEquivalent(
|
||||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||||
@@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => {
|
|||||||
"list-item",
|
"list-item",
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).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(
|
expect(
|
||||||
isCrossEngineRasterEquivalent(
|
isCrossEngineRasterEquivalent(
|
||||||
metrics({
|
metrics({
|
||||||
@@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => {
|
|||||||
).toBe(true);
|
).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("拒绝回流、颜色、几何或轮廓变化", () => {
|
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
@@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => {
|
|||||||
expect(
|
expect(
|
||||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||||
).toBe(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(
|
expect(
|
||||||
isCrossEngineRasterEquivalent(
|
isCrossEngineRasterEquivalent(
|
||||||
metrics({
|
metrics({
|
||||||
|
|||||||
@@ -40,9 +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 中的传统户部件归一为简体字符", () => {
|
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||||
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 自动列表装饰符", () => {
|
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||||
|
|||||||
@@ -98,6 +98,64 @@ local function replace_code_block(block)
|
|||||||
return pandoc.Para { image }
|
return pandoc.Para { image }
|
||||||
end
|
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)
|
local function append_inlines(target, value)
|
||||||
target:extend(pandoc.Inlines(value))
|
target:extend(pandoc.Inlines(value))
|
||||||
end
|
end
|
||||||
@@ -167,7 +225,10 @@ local function validate_counts()
|
|||||||
error(
|
error(
|
||||||
"DOCX media map contains unused "
|
"DOCX media map contains unused "
|
||||||
.. kind
|
.. kind
|
||||||
.. " resources"
|
.. " resources: consumed "
|
||||||
|
.. tostring(counters[kind])
|
||||||
|
.. " of "
|
||||||
|
.. tostring(#media_map[kind])
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -176,8 +237,14 @@ end
|
|||||||
return {
|
return {
|
||||||
Pandoc = function(document)
|
Pandoc = function(document)
|
||||||
local transformed = document:walk {
|
local transformed = document:walk {
|
||||||
|
Table = replace_table_breaks
|
||||||
|
}
|
||||||
|
transformed = transformed:walk {
|
||||||
Image = replace_image,
|
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
|
if structure_plan.titlePolicy.metadataTitle == "suppress" then
|
||||||
transformed.meta.title = nil
|
transformed.meta.title = nil
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ for (const theme of themes) {
|
|||||||
let conversion;
|
let conversion;
|
||||||
try {
|
try {
|
||||||
conversion = await converter.convert({
|
conversion = await converter.convert({
|
||||||
markdown,
|
markdown: rendered.markdownBody,
|
||||||
fileName: `${theme.id}.md`,
|
fileName: `${theme.id}.md`,
|
||||||
language: rendered.metadata.language,
|
language: rendered.metadata.language,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
@@ -433,7 +433,7 @@ for (const theme of themes) {
|
|||||||
metadata: rendered.metadata,
|
metadata: rendered.metadata,
|
||||||
semanticDocument: rendered.semanticDocument,
|
semanticDocument: rendered.semanticDocument,
|
||||||
fonts: readThemeFonts(theme),
|
fonts: readThemeFonts(theme),
|
||||||
media: createMedia(markdown)
|
media: createMedia(rendered.markdownBody)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
|
throw new Error(`主题 ${theme.id} 的 DOCX 转换失败`, {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type DocxBorderToken = NonNullable<
|
|||||||
|
|
||||||
const WORDPROCESSING_DRAWING_NAMESPACE =
|
const WORDPROCESSING_DRAWING_NAMESPACE =
|
||||||
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
|
||||||
|
const MATH_NAMESPACE =
|
||||||
|
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||||
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
|
||||||
// Chromium 预留 6pt;Word/WPS 的分节承载段落还需要额外分页保留量。
|
// Chromium 预留 6pt;Word/WPS 的分节承载段落还需要额外分页保留量。
|
||||||
const COVER_PAGE_BREAK_SAFETY_PT = 6;
|
const COVER_PAGE_BREAK_SAFETY_PT = 6;
|
||||||
@@ -2064,9 +2066,10 @@ function applyDirectCharacterStyleTokens(
|
|||||||
const inlineCodeLayouts = documentLayout.inlineCodes ?? [];
|
const inlineCodeLayouts = documentLayout.inlineCodes ?? [];
|
||||||
let inlineCodeCursor = 0;
|
let inlineCodeCursor = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const run of Array.from(
|
const runs = Array.from(
|
||||||
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
||||||
)) {
|
);
|
||||||
|
for (const [runIndex, run] of runs.entries()) {
|
||||||
const properties = firstDirectChild(
|
const properties = firstDirectChild(
|
||||||
run,
|
run,
|
||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
@@ -2099,8 +2102,29 @@ function applyDirectCharacterStyleTokens(
|
|||||||
const isSourceCode = paragraphStyle
|
const isSourceCode = paragraphStyle
|
||||||
? wordAttribute(paragraphStyle, "val") === "SourceCode"
|
? wordAttribute(paragraphStyle, "val") === "SourceCode"
|
||||||
: false;
|
: false;
|
||||||
|
const previousRun = runs[runIndex - 1];
|
||||||
|
const nextRun = runs[runIndex + 1];
|
||||||
|
const isHtmlTagName =
|
||||||
|
isSourceCode &&
|
||||||
|
styleId === "KeywordTok" &&
|
||||||
|
previousRun?.parentNode === run.parentNode &&
|
||||||
|
nextRun?.parentNode === run.parentNode &&
|
||||||
|
/^<\/?$/u.test(previousRun.textContent ?? "") &&
|
||||||
|
/^\/?>$/u.test(nextRun.textContent ?? "");
|
||||||
|
const isObjectPropertyKey =
|
||||||
|
isSourceCode &&
|
||||||
|
styleId === "NormalTok" &&
|
||||||
|
nextRun?.parentNode === run.parentNode &&
|
||||||
|
/^\s*:\s*$/u.test(nextRun.textContent ?? "");
|
||||||
const syntaxSlot = isSourceCode && styleId
|
const syntaxSlot = isSourceCode && styleId
|
||||||
? resolvePandocSyntaxStyleSlot(styleId, run.textContent ?? "")
|
? resolvePandocSyntaxStyleSlot(
|
||||||
|
styleId,
|
||||||
|
run.textContent ?? "",
|
||||||
|
{
|
||||||
|
htmlTagName: isHtmlTagName,
|
||||||
|
objectPropertyKey: isObjectPropertyKey
|
||||||
|
}
|
||||||
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const token = isSourceCode
|
const token = isSourceCode
|
||||||
? syntaxSlot
|
? syntaxSlot
|
||||||
@@ -2157,6 +2181,37 @@ function applyDirectCharacterStyleTokens(
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyMeasuredEmojiRunColors(
|
||||||
|
document: XmlDocument,
|
||||||
|
documentLayout: DocxDocumentLayoutPlan
|
||||||
|
) {
|
||||||
|
const measuredRuns = documentLayout.emojiRuns ?? [];
|
||||||
|
let cursor = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const run of Array.from(
|
||||||
|
document.getElementsByTagNameNS(WORD_NAMESPACE, "r")
|
||||||
|
)) {
|
||||||
|
const text = (run.textContent ?? "").normalize("NFC").trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const measuredIndex = measuredRuns.findIndex(
|
||||||
|
(layout, index) => index >= cursor && layout.text === text
|
||||||
|
);
|
||||||
|
if (measuredIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cursor = measuredIndex + 1;
|
||||||
|
const properties = ensureFirstElement(run, "rPr", "w:rPr");
|
||||||
|
removeDirectChildren(properties, WORD_NAMESPACE, "color");
|
||||||
|
appendElement(properties, WORD_NAMESPACE, "w:color", {
|
||||||
|
"w:val": colorValue(measuredRuns[measuredIndex]!.color)
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
function applyTableCellToken(
|
function applyTableCellToken(
|
||||||
cell: XmlElement,
|
cell: XmlElement,
|
||||||
token: DocxSlotStyleToken
|
token: DocxSlotStyleToken
|
||||||
@@ -2221,15 +2276,24 @@ function applyMeasuredTableGeometry(
|
|||||||
table: XmlElement,
|
table: XmlElement,
|
||||||
properties: XmlElement,
|
properties: XmlElement,
|
||||||
layout: DocxDocumentLayoutPlan["tables"][number],
|
layout: DocxDocumentLayoutPlan["tables"][number],
|
||||||
contentWidthTwips: number
|
contentWidthTwips: number,
|
||||||
|
leadingCellInsetTwips: number
|
||||||
) {
|
) {
|
||||||
const tableWidthTwips = Math.max(
|
const indentTwips = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100) +
|
||||||
|
leadingCellInsetTwips
|
||||||
|
);
|
||||||
|
const requestedTableWidthTwips = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.round(contentWidthTwips * layout.widthPercent / 100)
|
Math.round(contentWidthTwips * layout.widthPercent / 100)
|
||||||
);
|
);
|
||||||
const indentTwips = Math.max(
|
const tableWidthTwips = Math.max(
|
||||||
0,
|
1,
|
||||||
Math.round(contentWidthTwips * layout.leftOffsetPercent / 100)
|
Math.min(
|
||||||
|
requestedTableWidthTwips,
|
||||||
|
Math.max(1, contentWidthTwips - indentTwips)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const columnWidths = measuredColumnWidths(
|
const columnWidths = measuredColumnWidths(
|
||||||
tableWidthTwips,
|
tableWidthTwips,
|
||||||
@@ -2315,7 +2379,11 @@ function applyTables(
|
|||||||
table,
|
table,
|
||||||
properties,
|
properties,
|
||||||
measuredLayout,
|
measuredLayout,
|
||||||
contentWidthTwips
|
contentWidthTwips,
|
||||||
|
pointsToTwips(Math.max(
|
||||||
|
tableCellToken?.paddingPt?.left ?? 0,
|
||||||
|
tableHeaderToken?.paddingPt?.left ?? 0
|
||||||
|
))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
removeDirectChildren(
|
removeDirectChildren(
|
||||||
@@ -2427,8 +2495,15 @@ function applyTables(
|
|||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
"p"
|
"p"
|
||||||
)) {
|
)) {
|
||||||
|
const properties = paragraphProperties(paragraph);
|
||||||
|
const wordWrap = ensureFirstElement(
|
||||||
|
properties,
|
||||||
|
"wordWrap",
|
||||||
|
"w:wordWrap"
|
||||||
|
);
|
||||||
|
setWordAttribute(wordWrap, "val", "1");
|
||||||
const spacing = ensureFirstElement(
|
const spacing = ensureFirstElement(
|
||||||
paragraphProperties(paragraph),
|
properties,
|
||||||
"spacing",
|
"spacing",
|
||||||
"w:spacing"
|
"w:spacing"
|
||||||
);
|
);
|
||||||
@@ -2436,7 +2511,7 @@ function applyTables(
|
|||||||
setWordAttribute(spacing, "after", "0");
|
setWordAttribute(spacing, "after", "0");
|
||||||
for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) {
|
for (const property of ["autoSpaceDE", "autoSpaceDN"] as const) {
|
||||||
const automaticSpacing = ensureFirstElement(
|
const automaticSpacing = ensureFirstElement(
|
||||||
paragraphProperties(paragraph),
|
properties,
|
||||||
property,
|
property,
|
||||||
`w:${property}`
|
`w:${property}`
|
||||||
);
|
);
|
||||||
@@ -2609,6 +2684,18 @@ function applyMeasuredListItemIndents(
|
|||||||
String(Math.min(targetLeft, hanging))
|
String(Math.min(targetLeft, hanging))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (measured.alignment) {
|
||||||
|
const alignment = ensureDirectElement(
|
||||||
|
properties,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:jc"
|
||||||
|
);
|
||||||
|
setWordAttribute(
|
||||||
|
alignment,
|
||||||
|
"val",
|
||||||
|
measured.alignment === "justify" ? "both" : measured.alignment
|
||||||
|
);
|
||||||
|
}
|
||||||
appliedCount += 1;
|
appliedCount += 1;
|
||||||
}
|
}
|
||||||
return appliedCount;
|
return appliedCount;
|
||||||
@@ -2727,6 +2814,33 @@ function measuredLineBreakOffsetsForParagraph(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paragraphHasInlineCode(paragraph: XmlElement) {
|
||||||
|
return Array.from(
|
||||||
|
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "rStyle")
|
||||||
|
).some((style) => wordAttribute(style, "val") === "VerbatimChar");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stabilizeInlineCodeParagraphLineRules(document: XmlDocument) {
|
||||||
|
let count = 0;
|
||||||
|
for (const paragraph of Array.from(
|
||||||
|
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
|
||||||
|
)) {
|
||||||
|
if (!paragraphHasInlineCode(paragraph)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const spacing = directChildren(
|
||||||
|
paragraphProperties(paragraph),
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"spacing"
|
||||||
|
)[0];
|
||||||
|
if (spacing && wordAttribute(spacing, "lineRule") === "exact") {
|
||||||
|
setWordAttribute(spacing, "lineRule", "atLeast");
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
function applyMeasuredTextBlockLineBreaks(
|
function applyMeasuredTextBlockLineBreaks(
|
||||||
document: XmlDocument,
|
document: XmlDocument,
|
||||||
documentLayout: DocxDocumentLayoutPlan
|
documentLayout: DocxDocumentLayoutPlan
|
||||||
@@ -2765,7 +2879,11 @@ function applyMeasuredTextBlockLineBreaks(
|
|||||||
"line",
|
"line",
|
||||||
String(pointsToTwips(measured.linePitchPt))
|
String(pointsToTwips(measured.linePitchPt))
|
||||||
);
|
);
|
||||||
setWordAttribute(spacing, "lineRule", "exact");
|
setWordAttribute(
|
||||||
|
spacing,
|
||||||
|
"lineRule",
|
||||||
|
paragraphHasInlineCode(paragraph) ? "atLeast" : "exact"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const offset of [...measuredLineBreakOffsetsForParagraph(
|
for (const offset of [...measuredLineBreakOffsetsForParagraph(
|
||||||
paragraph,
|
paragraph,
|
||||||
@@ -2781,6 +2899,47 @@ function applyMeasuredTextBlockLineBreaks(
|
|||||||
return appliedCount;
|
return appliedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stabilizeTableMathParagraphAlignment(document: XmlDocument) {
|
||||||
|
let count = 0;
|
||||||
|
for (const paragraph of Array.from(
|
||||||
|
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
|
||||||
|
)) {
|
||||||
|
const parent = paragraph.parentNode;
|
||||||
|
if (
|
||||||
|
!parent ||
|
||||||
|
parent.nodeType !== 1 ||
|
||||||
|
(parent as XmlElement).namespaceURI !== WORD_NAMESPACE ||
|
||||||
|
(parent as XmlElement).localName !== "tc"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const content = Array.from(paragraph.childNodes).filter(
|
||||||
|
(node): node is XmlElement => node.nodeType === 1
|
||||||
|
).filter(
|
||||||
|
(child) =>
|
||||||
|
!(child.namespaceURI === WORD_NAMESPACE && child.localName === "pPr")
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
content.length === 0 ||
|
||||||
|
!content.every(
|
||||||
|
(child) =>
|
||||||
|
child.namespaceURI === MATH_NAMESPACE &&
|
||||||
|
child.localName === "oMath"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
|
||||||
|
const properties = appendElement(run, WORD_NAMESPACE, "w:rPr");
|
||||||
|
appendElement(properties, WORD_NAMESPACE, "w:noProof");
|
||||||
|
const text = appendElement(run, WORD_NAMESPACE, "w:t");
|
||||||
|
text.setAttributeNS(XML_NAMESPACE, "xml:space", "preserve");
|
||||||
|
text.textContent = "\u200B";
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
|
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const entry of tokens.slots) {
|
for (const entry of tokens.slots) {
|
||||||
@@ -3098,6 +3257,99 @@ function disableAutomaticCharacterSpacing(document: XmlDocument) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PANDOC_ALERT_LABELS = new Set([
|
||||||
|
"note",
|
||||||
|
"tip",
|
||||||
|
"important",
|
||||||
|
"warning",
|
||||||
|
"caution"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizePandocAlertParagraphStyles(
|
||||||
|
body: XmlElement,
|
||||||
|
documentLayout: DocxDocumentLayoutPlan
|
||||||
|
) {
|
||||||
|
const children = directChildren(body, WORD_NAMESPACE, "p");
|
||||||
|
const measuredAlerts = (documentLayout.textBlocks ?? []).filter(
|
||||||
|
(block) => block.alertRole !== undefined
|
||||||
|
);
|
||||||
|
let measuredCursor = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const [index, paragraph] of children.entries()) {
|
||||||
|
if (
|
||||||
|
paragraphStyleId(paragraph) !== "FirstParagraph" ||
|
||||||
|
!PANDOC_ALERT_LABELS.has(
|
||||||
|
normalizedLayoutText(paragraphText(paragraph)).toLocaleLowerCase(
|
||||||
|
"en-US"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const content = children[index + 1];
|
||||||
|
if (!content || paragraphStyleId(content) !== "BodyText") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const style = ensureDirectElement(
|
||||||
|
paragraphProperties(content),
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:pStyle"
|
||||||
|
);
|
||||||
|
setWordAttribute(style, "val", "BlockText");
|
||||||
|
for (const [target, role] of [
|
||||||
|
[paragraph, "title"],
|
||||||
|
[content, "body"]
|
||||||
|
] as const) {
|
||||||
|
const text = normalizedLayoutText(paragraphText(target));
|
||||||
|
const relativeMatchIndex = measuredAlerts
|
||||||
|
.slice(measuredCursor)
|
||||||
|
.findIndex(
|
||||||
|
(block) =>
|
||||||
|
block.alertRole === role &&
|
||||||
|
normalizedLayoutText(block.text) === text
|
||||||
|
);
|
||||||
|
if (relativeMatchIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const measuredIndex = measuredCursor + relativeMatchIndex;
|
||||||
|
const measured = measuredAlerts[measuredIndex]!;
|
||||||
|
measuredCursor = measuredIndex + 1;
|
||||||
|
const properties = paragraphProperties(target);
|
||||||
|
const indentation = ensureDirectElement(
|
||||||
|
properties,
|
||||||
|
WORD_NAMESPACE,
|
||||||
|
"w:ind"
|
||||||
|
);
|
||||||
|
if (measured.leftIndentPt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
indentation,
|
||||||
|
"left",
|
||||||
|
String(pointsToTwips(measured.leftIndentPt))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (measured.rightIndentPt !== undefined) {
|
||||||
|
setWordAttribute(
|
||||||
|
indentation,
|
||||||
|
"right",
|
||||||
|
String(pointsToTwips(measured.rightIndentPt))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
applyDirectRunToken(target, {
|
||||||
|
fontCandidates: [],
|
||||||
|
...(measured.fontSizePt !== undefined
|
||||||
|
? { fontSizePt: measured.fontSizePt }
|
||||||
|
: {}),
|
||||||
|
bold: measured.bold,
|
||||||
|
italic: measured.italic,
|
||||||
|
color: measured.color,
|
||||||
|
letterSpacingPt: measured.letterSpacingPt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
export function finalizeGeneratedDocxStructure(
|
export function finalizeGeneratedDocxStructure(
|
||||||
content: Uint8Array,
|
content: Uint8Array,
|
||||||
plan: PandocStructurePlan,
|
plan: PandocStructurePlan,
|
||||||
@@ -3141,6 +3393,7 @@ export function finalizeGeneratedDocxStructure(
|
|||||||
containers.editableContainerTables.size > 0,
|
containers.editableContainerTables.size > 0,
|
||||||
usesEvenAndOddPages
|
usesEvenAndOddPages
|
||||||
);
|
);
|
||||||
|
normalizePandocAlertParagraphStyles(body, documentLayout);
|
||||||
disableAutomaticCharacterSpacing(document);
|
disableAutomaticCharacterSpacing(document);
|
||||||
const final = finalSection(body);
|
const final = finalSection(body);
|
||||||
applyMeasuredTextBlockLineBreaks(document, documentLayout);
|
applyMeasuredTextBlockLineBreaks(document, documentLayout);
|
||||||
@@ -3160,6 +3413,9 @@ export function finalizeGeneratedDocxStructure(
|
|||||||
);
|
);
|
||||||
applyMeasuredTextBlockAlignments(document, documentLayout);
|
applyMeasuredTextBlockAlignments(document, documentLayout);
|
||||||
applyDirectCharacterStyleTokens(document, tokens, documentLayout);
|
applyDirectCharacterStyleTokens(document, tokens, documentLayout);
|
||||||
|
applyMeasuredEmojiRunColors(document, documentLayout);
|
||||||
|
stabilizeInlineCodeParagraphLineRules(document);
|
||||||
|
stabilizeTableMathParagraphAlignment(document);
|
||||||
const mediaDrawingCount = normalizeMediaParagraphs(
|
const mediaDrawingCount = normalizeMediaParagraphs(
|
||||||
document,
|
document,
|
||||||
tokens,
|
tokens,
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export interface PandocDocxConverterOptions {
|
|||||||
referenceCacheSize?: number;
|
referenceCacheSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PandocDocxConversionDiagnostics {
|
||||||
|
outcome: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class PandocDocxConversionError extends Error {
|
export class PandocDocxConversionError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: Extract<
|
readonly code: Extract<
|
||||||
@@ -113,11 +119,26 @@ export class PandocDocxConversionError extends Error {
|
|||||||
| "DOCX_OUTPUT_INVALID"
|
| "DOCX_OUTPUT_INVALID"
|
||||||
>,
|
>,
|
||||||
message: string,
|
message: string,
|
||||||
options?: ErrorOptions
|
options?: ErrorOptions & {
|
||||||
|
diagnostics?: PandocDocxConversionDiagnostics;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
super(message, options);
|
super(message, options);
|
||||||
this.name = "PandocDocxConversionError";
|
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) {
|
function ensureOutputLimit(value: number | undefined) {
|
||||||
@@ -172,7 +193,7 @@ function pandocArguments(paths: {
|
|||||||
return [
|
return [
|
||||||
paths.markdown,
|
paths.markdown,
|
||||||
"--from",
|
"--from",
|
||||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
|
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html",
|
||||||
"--to",
|
"--to",
|
||||||
"docx",
|
"docx",
|
||||||
"--standalone",
|
"--standalone",
|
||||||
@@ -351,7 +372,8 @@ export class PandocDocxConverter {
|
|||||||
processResult.outcome === "not-found"
|
processResult.outcome === "not-found"
|
||||||
? "DOCX_RUNTIME_NOT_FOUND"
|
? "DOCX_RUNTIME_NOT_FOUND"
|
||||||
: "DOCX_GENERATION_FAILED",
|
: "DOCX_GENERATION_FAILED",
|
||||||
"Pandoc 未能生成 DOCX"
|
"Pandoc 未能生成 DOCX",
|
||||||
|
{ diagnostics: processDiagnostics(processResult) }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import {
|
|||||||
DOCX_MEDIA_CSS_DPI,
|
DOCX_MEDIA_CSS_DPI,
|
||||||
MAXIMUM_DOCX_MEDIA_BYTES,
|
MAXIMUM_DOCX_MEDIA_BYTES,
|
||||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||||
|
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
|
||||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||||
type DocxMediaKind,
|
type DocxMediaKind,
|
||||||
type DocxDocumentLayoutPlan,
|
type DocxDocumentLayoutPlan,
|
||||||
@@ -101,7 +101,7 @@ export function preparePandocMedia(
|
|||||||
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
||||||
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
||||||
}
|
}
|
||||||
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
if (media.resources.length > MAXIMUM_DOCX_MEDIA_COUNT) {
|
||||||
throw new Error("DOCX 媒体数量超过限制");
|
throw new Error("DOCX 媒体数量超过限制");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core";
|
|||||||
import runtimeManifest from "./pandoc-runtime.json" with { type: "json" };
|
import runtimeManifest from "./pandoc-runtime.json" with { type: "json" };
|
||||||
|
|
||||||
export interface PandocRuntimeArtifact {
|
export interface PandocRuntimeArtifact {
|
||||||
platform: "win32" | "linux";
|
platform: "win32" | "linux" | "darwin";
|
||||||
architecture: "x64";
|
architecture: "x64" | "arm64";
|
||||||
archiveType: "zip" | "tar.gz";
|
archiveType: "zip" | "tar.gz";
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
executableRelativePath: string;
|
executableRelativePath: string;
|
||||||
licenseFiles: readonly string[];
|
licenseFiles: readonly string[];
|
||||||
|
/**
|
||||||
|
* 部分平台的官方归档不随附许可证文件(如 macOS zip),需要单独声明
|
||||||
|
* 每个许可证文件的独立下载来源;缺省时许可证文件从归档内部提取。
|
||||||
|
*/
|
||||||
|
licenseSource?: Readonly<Record<string, { downloadUrl: string }>>;
|
||||||
files?: Readonly<
|
files?: Readonly<
|
||||||
Record<string, { bytes: number; sha256: string }>
|
Record<string, { bytes: number; sha256: string }>
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -35,6 +35,68 @@
|
|||||||
"sha256": "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508",
|
"sha256": "A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508",
|
||||||
"executableRelativePath": "bin/pandoc",
|
"executableRelativePath": "bin/pandoc",
|
||||||
"licenseFiles": ["COPYING.md", "COPYRIGHT"]
|
"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
|
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") {
|
if (platform === "linux" && architecture === "x64") {
|
||||||
candidates.push({
|
candidates.push({
|
||||||
executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`,
|
executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`,
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ export function transformSettingsXml(
|
|||||||
) {
|
) {
|
||||||
const document = parseXmlPart(content, "word/settings.xml");
|
const document = parseXmlPart(content, "word/settings.xml");
|
||||||
const settings = document.documentElement!;
|
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(
|
removeDirectChildren(
|
||||||
settings,
|
settings,
|
||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
|
|||||||
@@ -467,8 +467,13 @@ function applyTokenParagraphStyle(
|
|||||||
(token.paddingPt?.right ?? 0) > 0 ||
|
(token.paddingPt?.right ?? 0) > 0 ||
|
||||||
(token.borders?.right?.widthPt ?? 0) > 0
|
(token.borders?.right?.widthPt ?? 0) > 0
|
||||||
? (token.rightIndentPt ?? 0) +
|
? (token.rightIndentPt ?? 0) +
|
||||||
(token.paddingPt?.right ?? 0) +
|
// 带边框代码块通过 w:pBdr/w:space 表达 CSS 右内边距。
|
||||||
(token.borders?.right?.widthPt ?? 0)
|
// 若再把 padding/border 写进 w:ind,Word/WPS 会重复扣减
|
||||||
|
// 内容宽度,使本可容纳的等宽代码行在末尾额外回流。
|
||||||
|
(horizontalPaddingThroughBorderSpace && token.borders?.right
|
||||||
|
? 0
|
||||||
|
: (token.paddingPt?.right ?? 0) +
|
||||||
|
(token.borders?.right?.widthPt ?? 0))
|
||||||
: undefined;
|
: undefined;
|
||||||
if (
|
if (
|
||||||
token.firstLineIndentPt !== undefined ||
|
token.firstLineIndentPt !== undefined ||
|
||||||
@@ -1191,12 +1196,23 @@ function applyTokenStyles(
|
|||||||
binding.styleId === "ImageCaption"
|
binding.styleId === "ImageCaption"
|
||||||
? { ...entry.style, alignment: "center" as const }
|
? { ...entry.style, alignment: "center" as const }
|
||||||
: entry.style;
|
: entry.style;
|
||||||
applyTokenParagraphStyle(
|
const paragraphProperties = ensureDirectElement(
|
||||||
ensureDirectElement(
|
target,
|
||||||
target,
|
WORD_NAMESPACE,
|
||||||
|
"w:pPr"
|
||||||
|
);
|
||||||
|
// SourceCode 的主题令牌来自浏览器完整计算样式。背景透明时,
|
||||||
|
// normalizer 会省略 backgroundColor;这里必须先清除基础模板的
|
||||||
|
// 兜底底纹,否则模板灰底会泄漏成主题代码块的整段背景。
|
||||||
|
if (binding.styleId === "SourceCode") {
|
||||||
|
removeDirectChildren(
|
||||||
|
paragraphProperties,
|
||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
"w:pPr"
|
"shd"
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
applyTokenParagraphStyle(
|
||||||
|
paragraphProperties,
|
||||||
paragraphToken,
|
paragraphToken,
|
||||||
fallbackFontSizeForSlot(slot, fallback),
|
fallbackFontSizeForSlot(slot, fallback),
|
||||||
binding.styleId === "SourceCode"
|
binding.styleId === "SourceCode"
|
||||||
@@ -1205,12 +1221,20 @@ function applyTokenStyles(
|
|||||||
const runToken = binding.styleId === "SourceCode"
|
const runToken = binding.styleId === "SourceCode"
|
||||||
? slots.get("code-block-text")?.style ?? entry.style
|
? slots.get("code-block-text")?.style ?? entry.style
|
||||||
: entry.style;
|
: entry.style;
|
||||||
applyTokenRunStyle(
|
const runProperties = ensureDirectElement(
|
||||||
ensureDirectElement(
|
target,
|
||||||
target,
|
WORD_NAMESPACE,
|
||||||
|
"w:rPr"
|
||||||
|
);
|
||||||
|
if (binding.styleId === "SourceCode") {
|
||||||
|
removeDirectChildren(
|
||||||
|
runProperties,
|
||||||
WORD_NAMESPACE,
|
WORD_NAMESPACE,
|
||||||
"w:rPr"
|
"shd"
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
applyTokenRunStyle(
|
||||||
|
runProperties,
|
||||||
runToken,
|
runToken,
|
||||||
fallbackFontsForSlot(slot, fallback),
|
fallbackFontsForSlot(slot, fallback),
|
||||||
binding.styleId === "SourceCode" ||
|
binding.styleId === "SourceCode" ||
|
||||||
|
|||||||
@@ -210,17 +210,25 @@ const pandocSyntaxSlots: Readonly<
|
|||||||
|
|
||||||
export function resolvePandocSyntaxStyleSlot(
|
export function resolvePandocSyntaxStyleSlot(
|
||||||
styleId: string,
|
styleId: string,
|
||||||
text = ""
|
text = "",
|
||||||
|
context: { htmlTagName?: boolean; objectPropertyKey?: boolean } = {}
|
||||||
): DocxStyleSlotName | "code-block-text" | undefined {
|
): DocxStyleSlotName | "code-block-text" | undefined {
|
||||||
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
if (context.htmlTagName) {
|
||||||
|
return "code-token-name";
|
||||||
|
}
|
||||||
|
if (context.objectPropertyKey) {
|
||||||
|
return "code-token-attribute";
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
styleId === "NormalTok" ||
|
styleId === "NormalTok" ||
|
||||||
styleId === "OtherTok" ||
|
styleId === "OtherTok" ||
|
||||||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
|
((styleId === "FunctionTok" || styleId === "DataTypeTok") &&
|
||||||
|
/^[\p{P}\p{S}\s]+$/u.test(text))
|
||||||
) {
|
) {
|
||||||
return styleId === "FunctionTok"
|
return styleId === "FunctionTok" || styleId === "DataTypeTok"
|
||||||
? "code-token-punctuation"
|
? "code-token-punctuation"
|
||||||
: "code-block-text";
|
: "code-block-text";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const drawing =
|
|||||||
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
"http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||||
const picture =
|
const picture =
|
||||||
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
"http://schemas.openxmlformats.org/drawingml/2006/picture";
|
||||||
|
const math =
|
||||||
|
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||||
|
|
||||||
function generatedFixture() {
|
function generatedFixture() {
|
||||||
const baseline = readReferenceDocxPackage(
|
const baseline = readReferenceDocxPackage(
|
||||||
@@ -38,6 +40,14 @@ function generatedFixture() {
|
|||||||
`<w:p><w:r><w:t>CONTAINER_END</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: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="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>` +
|
`<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"/>` +
|
`<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>` +
|
`<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>` +
|
||||||
@@ -183,6 +193,46 @@ const tokens: DocxThemeTokenSet = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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",
|
slot: "table-header",
|
||||||
source: "computed-css",
|
source: "computed-css",
|
||||||
@@ -292,7 +342,12 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
documentXml.match(
|
documentXml.match(
|
||||||
/<w:spacing[^>]*w:before="0"[^>]*w:after="0"[^>]*w:line="360"[^>]*w:lineRule="exact"/gu
|
/<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:fill="17324D"/gu)).toHaveLength(2);
|
||||||
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
|
expect(documentXml.match(/w:val="FFFFFF"/gu)).toHaveLength(2);
|
||||||
expect(
|
expect(
|
||||||
@@ -304,6 +359,12 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
|
expect(tableParagraph).toContain('<w:autoSpaceDE w:val="0"/>');
|
||||||
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
|
expect(tableParagraph).toContain('<w:autoSpaceDN w:val="0"/>');
|
||||||
expect(documentXml).toContain("<w:cantSplit/>");
|
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(
|
expect(documentXml).toContain(
|
||||||
'<w:tab w:val="right" w:pos="9806"/>'
|
'<w:tab w:val="right" w:pos="9806"/>'
|
||||||
);
|
);
|
||||||
@@ -389,6 +450,98 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 右侧内容内缩", () => {
|
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
|
||||||
const fixture = readGeneratedDocxPackage(generatedFixture());
|
const fixture = readGeneratedDocxPackage(generatedFixture());
|
||||||
const documentXml = decoder.decode(
|
const documentXml = decoder.decode(
|
||||||
@@ -508,6 +661,7 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
'<w:tblInd w:w="495" w:type="dxa"/>'
|
'<w:tblInd w:w="495" w:type="dxa"/>'
|
||||||
);
|
);
|
||||||
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
|
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="2675"/>');
|
||||||
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
|
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
|
||||||
expect(
|
expect(
|
||||||
@@ -525,6 +679,112 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
|
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("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
|
||||||
const result = finalizeGeneratedDocxStructure(
|
const result = finalizeGeneratedDocxStructure(
|
||||||
generatedFixture(),
|
generatedFixture(),
|
||||||
@@ -882,6 +1142,36 @@ describe("生成 DOCX 结构收口", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("按最终页面内容区重算右置百分比容器缩进", () => {
|
it("按最终页面内容区重算右置百分比容器缩进", () => {
|
||||||
const relativeTokens: DocxThemeTokenSet = {
|
const relativeTokens: DocxThemeTokenSet = {
|
||||||
...tokens,
|
...tokens,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ describe("Pandoc DOCX 转换器", () => {
|
|||||||
expect(arguments_).toContain("--data-dir");
|
expect(arguments_).toContain("--data-dir");
|
||||||
expect(arguments_).toContain("--resource-path");
|
expect(arguments_).toContain("--resource-path");
|
||||||
expect(argumentAfter(arguments_, "--from")).toBe(
|
expect(argumentAfter(arguments_, "--from")).toBe(
|
||||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html"
|
"commonmark_x-yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars+raw_html"
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||||
@@ -219,4 +219,29 @@ describe("Pandoc DOCX 转换器", () => {
|
|||||||
code: "DOCX_OUTPUT_INVALID"
|
code: "DOCX_OUTPUT_INVALID"
|
||||||
} satisfies Partial<PandocDocxConversionError>);
|
} 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 { describe, expect, it, vi } from "vitest";
|
||||||
import { zipSync } from "fflate";
|
import { zipSync } from "fflate";
|
||||||
import {
|
import {
|
||||||
@@ -118,7 +119,34 @@ describe("Pandoc 运行时探测", () => {
|
|||||||
executablePath: "pandoc.exe"
|
executablePath: "pandoc.exe"
|
||||||
});
|
});
|
||||||
expect(runner.mock.calls[0]?.[0]).toContain(
|
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")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -404,11 +404,12 @@ describe("动态 reference.docx", () => {
|
|||||||
const sourceCodeStyle = stylesXml.match(
|
const sourceCodeStyle = stylesXml.match(
|
||||||
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
/<w:style\b[^>]*w:styleId="SourceCode"[\s\S]*?<\/w:style>/u
|
||||||
)?.[0];
|
)?.[0];
|
||||||
|
expect(sourceCodeStyle).not.toContain("<w:shd");
|
||||||
expect(sourceCodeStyle).toContain(
|
expect(sourceCodeStyle).toContain(
|
||||||
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
|
'<w:left w:val="single" w:sz="6" w:space="7" w:color="E7EAED"/>'
|
||||||
);
|
);
|
||||||
expect(sourceCodeStyle).toMatch(
|
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(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
|
||||||
expect(stylesXml).toMatch(
|
expect(stylesXml).toMatch(
|
||||||
@@ -451,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("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
it("生成横向、自定义页边距和奇偶首页页眉页脚", () => {
|
||||||
const exportConfig: ExportConfig = {
|
const exportConfig: ExportConfig = {
|
||||||
...defaultExportConfig,
|
...defaultExportConfig,
|
||||||
@@ -506,6 +550,7 @@ describe("动态 reference.docx", () => {
|
|||||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||||
|
expect(settingsXml).toContain("<w:doNotExpandShiftReturn");
|
||||||
expect(stylesXml).toContain('<w:kern w:val="2"');
|
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||||
expect(headerXml).toContain("年度 <报告>");
|
expect(headerXml).toContain("年度 <报告>");
|
||||||
expect(headerXml).toContain("报告 & 计划.md");
|
expect(headerXml).toContain("报告 & 计划.md");
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ describe("DOCX 令牌样式映射", () => {
|
|||||||
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
||||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
||||||
.toBe("code-token-attribute");
|
.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"'))
|
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
|
||||||
.toBe("code-token-string");
|
.toBe("code-token-string");
|
||||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
||||||
@@ -97,6 +106,13 @@ describe("DOCX 令牌样式映射", () => {
|
|||||||
.toBe("code-token-title");
|
.toBe("code-token-title");
|
||||||
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
||||||
.toBe("code-block-text");
|
.toBe("code-block-text");
|
||||||
|
expect(
|
||||||
|
resolvePandocSyntaxStyleSlot(
|
||||||
|
"NormalTok",
|
||||||
|
" method",
|
||||||
|
{ objectPropertyKey: true }
|
||||||
|
)
|
||||||
|
).toBe("code-token-attribute");
|
||||||
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
||||||
.toBeUndefined();
|
.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export function readDocxComputedStyle(
|
|||||||
fontStyle: style.fontStyle,
|
fontStyle: style.fontStyle,
|
||||||
color: style.color,
|
color: style.color,
|
||||||
backgroundColor: style.backgroundColor,
|
backgroundColor: style.backgroundColor,
|
||||||
|
...(style.backgroundImage
|
||||||
|
? { backgroundImage: style.backgroundImage }
|
||||||
|
: {}),
|
||||||
lineHeight: style.lineHeight,
|
lineHeight: style.lineHeight,
|
||||||
letterSpacing: style.letterSpacing,
|
letterSpacing: style.letterSpacing,
|
||||||
textAlign: style.textAlign,
|
textAlign: style.textAlign,
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ export function parseCssColor(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseCssLinearGradientFallbackColor(
|
||||||
|
input: string
|
||||||
|
): string | undefined {
|
||||||
|
const value = input.trim();
|
||||||
|
if (!/^(?:repeating-)?linear-gradient\(/iu.test(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const colorValues = value.match(
|
||||||
|
/rgba?\([^)]*\)|#[\da-f]{3,8}/giu
|
||||||
|
) ?? [];
|
||||||
|
for (const colorValue of colorValues) {
|
||||||
|
const parsed = parseCssColor(colorValue);
|
||||||
|
if (parsed) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAlpha(value: string | undefined): number | undefined {
|
function parseAlpha(value: string | undefined): number | undefined {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
millimetersToPoints,
|
millimetersToPoints,
|
||||||
parseCssBorder,
|
parseCssBorder,
|
||||||
parseCssColor,
|
parseCssColor,
|
||||||
|
parseCssLinearGradientFallbackColor,
|
||||||
parseCssFontFamilies,
|
parseCssFontFamilies,
|
||||||
parseCssLengthToPt,
|
parseCssLengthToPt,
|
||||||
roundDocxValue
|
roundDocxValue
|
||||||
@@ -292,6 +293,20 @@ function normalizeComputedSlot(
|
|||||||
);
|
);
|
||||||
if (background) {
|
if (background) {
|
||||||
style.backgroundColor = background;
|
style.backgroundColor = background;
|
||||||
|
} else if (computed.backgroundImage) {
|
||||||
|
const gradientBackground = parseCssLinearGradientFallbackColor(
|
||||||
|
computed.backgroundImage
|
||||||
|
);
|
||||||
|
if (gradientBackground) {
|
||||||
|
style.backgroundColor = gradientBackground;
|
||||||
|
diagnostic(context, {
|
||||||
|
severity: "info",
|
||||||
|
code: "layout-approximated",
|
||||||
|
property: "background-image",
|
||||||
|
message: "CSS 线性渐变已使用首个非透明色近似为 Word 段落底纹"
|
||||||
|
});
|
||||||
|
context.approximate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const letterSpacing = computed.letterSpacing.toLowerCase() === "normal"
|
const letterSpacing = computed.letterSpacing.toLowerCase() === "normal"
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function createDocxStyleProbeMarkup(): string {
|
|||||||
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
|
<ul ${attribute("unordered-list")}><li ${attribute("list-item")}>无序列表</li></ul>
|
||||||
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
|
<ol ${attribute("ordered-list")}><li>有序列表</li></ol>
|
||||||
<blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}>引用内容</p></blockquote>
|
<blockquote ${attribute("block-quote")}><p ${attribute("block-quote-text")}>引用内容</p></blockquote>
|
||||||
<pre class="md-fences" ${attribute("code-block")}><code class="language-javascript" ${attribute("code-block-text")}><span class="hljs-keyword" ${attribute("code-token-keyword")}>const</span> <span class="hljs-attr" ${attribute("code-token-attribute")}>key</span> <span class="hljs-operator" ${attribute("code-token-operator")}>=</span> <span class="hljs-string" ${attribute("code-token-string")}>"value"</span>; <span class="hljs-literal" ${attribute("code-token-literal")}>true</span> <span class="hljs-title" ${attribute("code-token-title")}>title</span> <span class="hljs-number" ${attribute("code-token-number")}>1</span> <span class="hljs-comment" ${attribute("code-token-comment")}>// comment</span> <span class="hljs-built_in" ${attribute("code-token-built-in")}>Array</span> <span class="hljs-meta" ${attribute("code-token-meta")}>@meta</span> <span class="hljs-variable" ${attribute("code-token-variable")}>value</span> <span class="hljs-regexp" ${attribute("code-token-regexp")}>/x/</span> <span class="hljs-punctuation" ${attribute("code-token-punctuation")}>{}</span></code></pre>
|
<pre class="md-fences" ${attribute("code-block")}><code class="language-javascript" ${attribute("code-block-text")}><span class="hljs-keyword" ${attribute("code-token-keyword")}>const</span> <span class="hljs-attr" ${attribute("code-token-attribute")}>key</span> <span class="hljs-operator" ${attribute("code-token-operator")}>=</span> <span class="hljs-string" ${attribute("code-token-string")}>"value"</span>; <span class="hljs-literal" ${attribute("code-token-literal")}>true</span> <span class="hljs-title" ${attribute("code-token-title")}>title</span> <span class="hljs-number" ${attribute("code-token-number")}>1</span> <span class="hljs-comment" ${attribute("code-token-comment")}>// comment</span> <span class="hljs-built_in" ${attribute("code-token-built-in")}>Array</span> <span class="hljs-meta" ${attribute("code-token-meta")}>@meta</span> <span class="hljs-variable" ${attribute("code-token-variable")}>value</span> <span class="hljs-regexp" ${attribute("code-token-regexp")}>/x/</span> <span class="hljs-punctuation" ${attribute("code-token-punctuation")}>{}</span> <span class="hljs-name" ${attribute("code-token-name")}>section</span></code></pre>
|
||||||
<table ${attribute("table")}>
|
<table ${attribute("table")}>
|
||||||
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
|
<thead><tr><th ${attribute("table-header")}>表头</th></tr></thead>
|
||||||
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
|
<tbody><tr><td ${attribute("table-cell")}>单元格</td></tr></tbody>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const computedProperties = [
|
|||||||
"fontStyle",
|
"fontStyle",
|
||||||
"color",
|
"color",
|
||||||
"backgroundColor",
|
"backgroundColor",
|
||||||
|
"backgroundImage",
|
||||||
"lineHeight",
|
"lineHeight",
|
||||||
"letterSpacing",
|
"letterSpacing",
|
||||||
"textAlign",
|
"textAlign",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const DOCX_STYLE_SLOT_NAMES = [
|
|||||||
"code-token-operator",
|
"code-token-operator",
|
||||||
"code-token-regexp",
|
"code-token-regexp",
|
||||||
"code-token-punctuation",
|
"code-token-punctuation",
|
||||||
|
"code-token-name",
|
||||||
"table",
|
"table",
|
||||||
"table-header",
|
"table-header",
|
||||||
"table-cell",
|
"table-cell",
|
||||||
@@ -153,6 +154,7 @@ const kinds: Record<DocxStyleSlotName, DocxStyleSlotKind> = {
|
|||||||
"code-token-operator": "inline",
|
"code-token-operator": "inline",
|
||||||
"code-token-regexp": "inline",
|
"code-token-regexp": "inline",
|
||||||
"code-token-punctuation": "inline",
|
"code-token-punctuation": "inline",
|
||||||
|
"code-token-name": "inline",
|
||||||
table: "table",
|
table: "table",
|
||||||
"table-header": "table",
|
"table-header": "table",
|
||||||
"table-cell": "table",
|
"table-cell": "table",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const docxComputedStyleSchema = z.object({
|
|||||||
fontStyle: cssValueSchema,
|
fontStyle: cssValueSchema,
|
||||||
color: cssValueSchema,
|
color: cssValueSchema,
|
||||||
backgroundColor: cssValueSchema,
|
backgroundColor: cssValueSchema,
|
||||||
|
backgroundImage: cssValueSchema.optional(),
|
||||||
lineHeight: cssValueSchema,
|
lineHeight: cssValueSchema,
|
||||||
letterSpacing: cssValueSchema,
|
letterSpacing: cssValueSchema,
|
||||||
textAlign: cssValueSchema,
|
textAlign: cssValueSchema,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
millimetersToPoints,
|
millimetersToPoints,
|
||||||
parseCssBorder,
|
parseCssBorder,
|
||||||
parseCssColor,
|
parseCssColor,
|
||||||
|
parseCssLinearGradientFallbackColor,
|
||||||
parseCssFontFamilies,
|
parseCssFontFamilies,
|
||||||
parseCssLengthToPt,
|
parseCssLengthToPt,
|
||||||
resolveDocxThemeTokens,
|
resolveDocxThemeTokens,
|
||||||
@@ -106,6 +107,12 @@ describe("CSS 到 Word 基础值归一化", () => {
|
|||||||
expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined();
|
expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined();
|
||||||
expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080");
|
expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080");
|
||||||
expect(parseCssColor("#00000080")).toBe("#7f7f7f");
|
expect(parseCssColor("#00000080")).toBe("#7f7f7f");
|
||||||
|
expect(parseCssLinearGradientFallbackColor(
|
||||||
|
"linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))"
|
||||||
|
)).toBe("#eff6ff");
|
||||||
|
expect(parseCssLinearGradientFallbackColor(
|
||||||
|
"radial-gradient(rgb(239, 246, 255), transparent)"
|
||||||
|
)).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
parseCssFontFamilies(
|
parseCssFontFamilies(
|
||||||
'"Source Han Serif SC", "Microsoft YaHei", serif'
|
'"Source Han Serif SC", "Microsoft YaHei", serif'
|
||||||
@@ -185,6 +192,32 @@ describe("DOCX 主题令牌归一化", () => {
|
|||||||
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("将线性渐变首个实色近似为 Word 段落底纹", () => {
|
||||||
|
const tokens = resolveDocxThemeTokens({
|
||||||
|
snapshot: createSnapshot({
|
||||||
|
"heading-2": {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(90deg, rgb(239, 246, 255), rgba(0, 0, 0, 0))"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
config: {
|
||||||
|
mode: "auto",
|
||||||
|
basePreset: "general",
|
||||||
|
overrides: {},
|
||||||
|
legacyPreset: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findSlot(tokens, "heading-2").style.backgroundColor)
|
||||||
|
.toBe("#eff6ff");
|
||||||
|
expect(tokens.diagnostics).toContainEqual(expect.objectContaining({
|
||||||
|
slot: "heading-2",
|
||||||
|
code: "layout-approximated",
|
||||||
|
property: "background-image"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("按 border-box 文档的内容区宽度归一化全宽表格", () => {
|
it("按 border-box 文档的内容区宽度归一化全宽表格", () => {
|
||||||
const tokens = resolveDocxThemeTokens({
|
const tokens = resolveDocxThemeTokens({
|
||||||
snapshot: createSnapshot({
|
snapshot: createSnapshot({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const CHUNK_CLASS = "md-code-pagination-chunk";
|
|||||||
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
|
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
|
||||||
const LINE_GROUP_CLASS = "md-code-line-group";
|
const LINE_GROUP_CLASS = "md-code-line-group";
|
||||||
const LINE_CLASS = "md-code-line";
|
const LINE_CLASS = "md-code-line";
|
||||||
|
const LINE_INDENT_CLASS = "md-code-line-indent";
|
||||||
|
|
||||||
function splitNodeIntoLines(node: Node): Node[][] {
|
function splitNodeIntoLines(node: Node): Node[][] {
|
||||||
const documentRef = node.ownerDocument;
|
const documentRef = node.ownerDocument;
|
||||||
@@ -47,6 +48,56 @@ function lineHasContent(nodes: Node[]) {
|
|||||||
return nodes.some((node) => (node.textContent ?? "").length > 0);
|
return nodes.some((node) => (node.textContent ?? "").length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stabilizeLeadingWhitespace(
|
||||||
|
documentRef: Document,
|
||||||
|
nodes: Node[]
|
||||||
|
) {
|
||||||
|
let indent = "";
|
||||||
|
let index = 0;
|
||||||
|
for (; index < nodes.length; index += 1) {
|
||||||
|
const node = nodes[index];
|
||||||
|
if (!node || node.nodeType !== node.TEXT_NODE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = node.textContent ?? "";
|
||||||
|
const match = value.match(/^[\t ]+/u);
|
||||||
|
if (!match) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
indent += match[0];
|
||||||
|
const remainder = value.slice(match[0].length);
|
||||||
|
if (remainder) {
|
||||||
|
node.textContent = remainder;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!indent) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
const indentElement = documentRef.createElement("span");
|
||||||
|
indentElement.className = LINE_INDENT_CLASS;
|
||||||
|
let columns = 0;
|
||||||
|
for (const character of indent) {
|
||||||
|
columns = character === "\t"
|
||||||
|
? columns + (4 - columns % 4)
|
||||||
|
: columns + 1;
|
||||||
|
}
|
||||||
|
indentElement.dataset.codeIndentColumns = String(columns);
|
||||||
|
indentElement.style.width = `${columns}ch`;
|
||||||
|
indentElement.textContent = indent;
|
||||||
|
return [
|
||||||
|
indentElement,
|
||||||
|
...nodes.slice(index).filter(
|
||||||
|
(node) =>
|
||||||
|
node.nodeType !== node.TEXT_NODE ||
|
||||||
|
(node.textContent ?? "").length > 0
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function createLineGroups(
|
function createLineGroups(
|
||||||
documentRef: Document,
|
documentRef: Document,
|
||||||
lines: Node[][]
|
lines: Node[][]
|
||||||
@@ -68,7 +119,12 @@ function createLineGroups(
|
|||||||
) {
|
) {
|
||||||
const line = documentRef.createElement("span");
|
const line = documentRef.createElement("span");
|
||||||
line.className = LINE_CLASS;
|
line.className = LINE_CLASS;
|
||||||
line.append(...(lines[lineIndex] ?? []));
|
line.append(
|
||||||
|
...stabilizeLeadingWhitespace(
|
||||||
|
documentRef,
|
||||||
|
lines[lineIndex] ?? []
|
||||||
|
)
|
||||||
|
);
|
||||||
group.append(line);
|
group.append(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
DOCX_MEDIA_RASTER_SCALE,
|
DOCX_MEDIA_RASTER_SCALE,
|
||||||
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||||
|
MAXIMUM_DOCX_MEDIA_COUNT,
|
||||||
MAXIMUM_DOCX_MEDIA_PIXELS,
|
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
|
||||||
type DocxMediaCapturePlan,
|
type DocxMediaCapturePlan,
|
||||||
type DocxMediaCaptureTarget,
|
type DocxMediaCaptureTarget,
|
||||||
type DocxMediaAlignment,
|
type DocxMediaAlignment,
|
||||||
type DocxMediaKind,
|
type DocxMediaKind,
|
||||||
type DocxDocumentLayoutPlan,
|
type DocxDocumentLayoutPlan,
|
||||||
|
type DocxEmojiRunLayout,
|
||||||
type DocxInlineCodeLayout,
|
type DocxInlineCodeLayout,
|
||||||
type DocxListItemLayout,
|
type DocxListItemLayout,
|
||||||
type DocxTableLayout,
|
type DocxTableLayout,
|
||||||
@@ -15,12 +16,19 @@ import {
|
|||||||
type PagedDocumentPayload
|
type PagedDocumentPayload
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
|
import { PagedDocumentRuntime } from "./paged-document-runtime.js";
|
||||||
|
import { stabilizePagedTableColumns } from "./paged-table-handler.js";
|
||||||
|
|
||||||
export interface DocxMediaRenderDimensions {
|
export interface DocxMediaRenderDimensions {
|
||||||
contentWidthPx: number;
|
contentWidthPx: number;
|
||||||
contentHeightPx: number;
|
contentHeightPx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocxTableGeometry = Pick<
|
||||||
|
DocxTableLayout,
|
||||||
|
"ordinal" | "widthPercent" | "leftOffsetPercent" |
|
||||||
|
"columnWidthPercents"
|
||||||
|
>;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__mdToPdfRenderDocxMedia?: (
|
__mdToPdfRenderDocxMedia?: (
|
||||||
@@ -157,6 +165,23 @@ function average(values: readonly number[], fallback: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
|
function collectTableColumnWidths(table: HTMLTableElement, tableRect: DOMRect) {
|
||||||
|
const stabilizedColumns = Array.from(
|
||||||
|
table.querySelectorAll<HTMLTableColElement>(
|
||||||
|
":scope > colgroup[data-stabilized-columns=\"true\"] > col"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const stabilizedWidths = stabilizedColumns.map((column) =>
|
||||||
|
Number(column.dataset.stabilizedWidthPx)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
stabilizedWidths.length > 0 &&
|
||||||
|
stabilizedWidths.every((width) => Number.isFinite(width) && width > 0)
|
||||||
|
) {
|
||||||
|
// 这组轨道来自分页前的完整自动布局。后续为稳定 Paged.js 克隆
|
||||||
|
// 写入的 col/cell 辅助宽度可能反过来扰动源表二次测量,DOCX 必须
|
||||||
|
// 使用最初与 Chromium 分页基线一致的列轨,而不是辅助样式后的值。
|
||||||
|
return stabilizedWidths;
|
||||||
|
}
|
||||||
const rows = Array.from(table.rows);
|
const rows = Array.from(table.rows);
|
||||||
const columnCount = rows.reduce(
|
const columnCount = rows.reduce(
|
||||||
(maximum, row) =>
|
(maximum, row) =>
|
||||||
@@ -255,7 +280,7 @@ function cellAlignment(value: string): "left" | "center" | "right" | "justify" {
|
|||||||
|
|
||||||
const DOCX_TEXT_BLOCK_SELECTOR =
|
const DOCX_TEXT_BLOCK_SELECTOR =
|
||||||
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
|
"h1, h2, h3, h4, h5, h6, p, li, th, td, figcaption, " +
|
||||||
".doc-classification, .doc-issue-row, .doc-printing-row, " +
|
".md-alert-text, .doc-classification, .doc-issue-row, .doc-printing-row, " +
|
||||||
".doc-briefing-meta";
|
".doc-briefing-meta";
|
||||||
|
|
||||||
function textNodeCharacters(root: HTMLElement) {
|
function textNodeCharacters(root: HTMLElement) {
|
||||||
@@ -293,9 +318,19 @@ function characterRect(
|
|||||||
return rect.width > 0 && rect.height > 0 ? rect : undefined;
|
return rect.width > 0 && rect.height > 0 ? rect : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDistributedLastLine(
|
||||||
|
_element: HTMLElement,
|
||||||
|
computed: CSSStyleDeclaration,
|
||||||
|
_lastLine: readonly DOMRect[]
|
||||||
|
) {
|
||||||
|
const textAlignLast = computed.textAlignLast.trim().toLowerCase();
|
||||||
|
return textAlignLast === "justify" || textAlignLast === "distribute";
|
||||||
|
}
|
||||||
|
|
||||||
export function collectDocxTextBlockLayouts(
|
export function collectDocxTextBlockLayouts(
|
||||||
article: HTMLElement
|
article: HTMLElement
|
||||||
): DocxTextBlockLayout[] {
|
): DocxTextBlockLayout[] {
|
||||||
|
const articleRect = article.getBoundingClientRect();
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
|
article.querySelectorAll<HTMLElement>(DOCX_TEXT_BLOCK_SELECTOR)
|
||||||
).filter((element) => {
|
).filter((element) => {
|
||||||
@@ -350,39 +385,33 @@ export function collectDocxTextBlockLayouts(
|
|||||||
}
|
}
|
||||||
const text = output.join("").trim();
|
const text = output.join("").trim();
|
||||||
const computed = getComputedStyle(element);
|
const computed = getComputedStyle(element);
|
||||||
|
const alert = element.closest<HTMLElement>(".md-alert");
|
||||||
|
const alertRole = element.matches(".md-alert-text")
|
||||||
|
? "title" as const
|
||||||
|
: alert
|
||||||
|
? "body" as const
|
||||||
|
: undefined;
|
||||||
|
const alertRect = alert?.getBoundingClientRect();
|
||||||
|
const alertComputed = alert ? getComputedStyle(alert) : undefined;
|
||||||
|
const alertLeft = alertRect && alertComputed
|
||||||
|
? alertRect.left +
|
||||||
|
Number.parseFloat(alertComputed.borderLeftWidth || "0") +
|
||||||
|
Number.parseFloat(alertComputed.paddingLeft || "0")
|
||||||
|
: undefined;
|
||||||
|
const alertRight = alertRect && alertComputed
|
||||||
|
? alertRect.right -
|
||||||
|
Number.parseFloat(alertComputed.borderRightWidth || "0") -
|
||||||
|
Number.parseFloat(alertComputed.paddingRight || "0")
|
||||||
|
: undefined;
|
||||||
const letterSpacingPx = computed.letterSpacing === "normal"
|
const letterSpacingPx = computed.letterSpacing === "normal"
|
||||||
? 0
|
? 0
|
||||||
: Number.parseFloat(computed.letterSpacing);
|
: Number.parseFloat(computed.letterSpacing);
|
||||||
const elementRect = element.getBoundingClientRect();
|
|
||||||
const contentWidth = Math.max(
|
|
||||||
0,
|
|
||||||
elementRect.width -
|
|
||||||
Number.parseFloat(computed.paddingLeft || "0") -
|
|
||||||
Number.parseFloat(computed.paddingRight || "0") -
|
|
||||||
Number.parseFloat(computed.borderLeftWidth || "0") -
|
|
||||||
Number.parseFloat(computed.borderRightWidth || "0")
|
|
||||||
);
|
|
||||||
const lastLine = lineCharacterRects.at(-1) ?? [];
|
const lastLine = lineCharacterRects.at(-1) ?? [];
|
||||||
const sortedLastLine = [...lastLine].sort(
|
const distributed = hasDistributedLastLine(
|
||||||
(left, right) => left.left - right.left
|
element,
|
||||||
|
computed,
|
||||||
|
lastLine
|
||||||
);
|
);
|
||||||
const lastLineWidth = sortedLastLine.length > 0
|
|
||||||
? sortedLastLine.at(-1)!.right - sortedLastLine[0]!.left
|
|
||||||
: 0;
|
|
||||||
const maximumGap = sortedLastLine.slice(1).reduce(
|
|
||||||
(largest, rect, rectIndex) => Math.max(
|
|
||||||
largest,
|
|
||||||
rect.left - sortedLastLine[rectIndex]!.right
|
|
||||||
),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const fontSizePx = Number.parseFloat(computed.fontSize);
|
|
||||||
const distributed =
|
|
||||||
sortedLastLine.length > 1 &&
|
|
||||||
contentWidth > 0 &&
|
|
||||||
lastLineWidth / contentWidth >= 0.85 &&
|
|
||||||
Number.isFinite(fontSizePx) &&
|
|
||||||
maximumGap >= fontSizePx * 0.5;
|
|
||||||
const lineTops = lineCharacterRects
|
const lineTops = lineCharacterRects
|
||||||
.map((rects) => rects.length > 0
|
.map((rects) => rects.length > 0
|
||||||
? Math.min(...rects.map((rect) => rect.top))
|
? Math.min(...rects.map((rect) => rect.top))
|
||||||
@@ -401,6 +430,19 @@ export function collectDocxTextBlockLayouts(
|
|||||||
letterSpacingPt: Number.isFinite(letterSpacingPx)
|
letterSpacingPt: Number.isFinite(letterSpacingPx)
|
||||||
? letterSpacingPx * 0.75
|
? letterSpacingPx * 0.75
|
||||||
: 0,
|
: 0,
|
||||||
|
...(alertRole && alertLeft !== undefined && alertRight !== undefined
|
||||||
|
? {
|
||||||
|
alertRole,
|
||||||
|
fontSizePt: cssPixelsToPoints(computed.fontSize),
|
||||||
|
bold: computed.fontWeight === "bold" ||
|
||||||
|
Number.parseInt(computed.fontWeight, 10) >= 600,
|
||||||
|
italic: computed.fontStyle === "italic" ||
|
||||||
|
computed.fontStyle === "oblique",
|
||||||
|
color: cssColor(computed.color) ?? "#000000",
|
||||||
|
leftIndentPt: Math.max(0, (alertLeft - articleRect.left) * 0.75),
|
||||||
|
rightIndentPt: Math.max(0, (articleRect.right - alertRight) * 0.75)
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
alignment: distributed
|
alignment: distributed
|
||||||
? "distribute" as const
|
? "distribute" as const
|
||||||
: cellAlignment(computed.textAlign),
|
: cellAlignment(computed.textAlign),
|
||||||
@@ -447,6 +489,18 @@ export function collectDocxListItemLayouts(
|
|||||||
if (!rect) {
|
if (!rect) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const visibleRects = characters.flatMap((character) => {
|
||||||
|
const characterBounds = characterRect(range, character);
|
||||||
|
return characterBounds ? [characterBounds] : [];
|
||||||
|
});
|
||||||
|
const lastLineTop = visibleRects.length > 0
|
||||||
|
? Math.max(...visibleRects.map((bounds) => bounds.top))
|
||||||
|
: 0;
|
||||||
|
const lastLine = visibleRects.filter(
|
||||||
|
(bounds) => Math.abs(bounds.top - lastLineTop) <= 1
|
||||||
|
);
|
||||||
|
const computed = getComputedStyle(item);
|
||||||
|
const distributed = hasDistributedLastLine(item, computed, lastLine);
|
||||||
let listDepth = -1;
|
let listDepth = -1;
|
||||||
for (
|
for (
|
||||||
let ancestor: Element | null = item.parentElement;
|
let ancestor: Element | null = item.parentElement;
|
||||||
@@ -464,7 +518,8 @@ export function collectDocxListItemLayouts(
|
|||||||
textStartPt: Math.max(
|
textStartPt: Math.max(
|
||||||
0,
|
0,
|
||||||
(rect.left - articleLeft) * 0.75
|
(rect.left - articleLeft) * 0.75
|
||||||
)
|
),
|
||||||
|
...(distributed ? { alignment: "distribute" as const } : {})
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -475,6 +530,86 @@ function cssPixelsToPoints(value: string) {
|
|||||||
return Number.isFinite(pixels) ? pixels * 0.75 : 0;
|
return Number.isFinite(pixels) ? pixels * 0.75 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMOJI_ONLY_TEXT = /^(?:\p{Extended_Pictographic}|\p{Emoji_Modifier}|\u200d|\ufe0f|\s)+$/u;
|
||||||
|
|
||||||
|
function sampledEmojiColor(text: string, computed: CSSStyleDeclaration) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
context.font = computed.font;
|
||||||
|
const metrics = context.measureText(text);
|
||||||
|
const fontSize = Math.max(12, Number.parseFloat(computed.fontSize) || 16);
|
||||||
|
canvas.width = Math.max(1, Math.ceil(metrics.width + fontSize));
|
||||||
|
canvas.height = Math.max(1, Math.ceil(fontSize * 2));
|
||||||
|
const renderContext = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!renderContext) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
renderContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
renderContext.font = computed.font;
|
||||||
|
renderContext.textBaseline = "middle";
|
||||||
|
renderContext.fillStyle = computed.color;
|
||||||
|
renderContext.fillText(text, fontSize / 2, canvas.height / 2);
|
||||||
|
const pixels = renderContext.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height
|
||||||
|
).data;
|
||||||
|
const histogram = new Map<string, number>();
|
||||||
|
for (let index = 0; index < pixels.length; index += 4) {
|
||||||
|
const alpha = pixels[index + 3] ?? 0;
|
||||||
|
if (alpha < 96) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const red = pixels[index] ?? 0;
|
||||||
|
const green = pixels[index + 1] ?? 0;
|
||||||
|
const blue = pixels[index + 2] ?? 0;
|
||||||
|
if (Math.max(red, green, blue) - Math.min(red, green, blue) < 16) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = [red, green, blue]
|
||||||
|
.map((value) => Math.round(value / 8) * 8)
|
||||||
|
.join(",");
|
||||||
|
histogram.set(key, (histogram.get(key) ?? 0) + alpha);
|
||||||
|
}
|
||||||
|
const dominant = Array.from(histogram.entries()).sort(
|
||||||
|
([, left], [, right]) => right - left
|
||||||
|
)[0]?.[0];
|
||||||
|
if (!dominant) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const channels = dominant.split(",").map(Number);
|
||||||
|
if (channels.length !== 3 || channels.some((value) => !Number.isFinite(value))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return `#${channels
|
||||||
|
.map((value) => Math.min(255, value).toString(16).padStart(2, "0"))
|
||||||
|
.join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectDocxEmojiRunLayouts(
|
||||||
|
article: HTMLElement
|
||||||
|
): DocxEmojiRunLayout[] {
|
||||||
|
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
|
||||||
|
const layouts: DocxEmojiRunLayout[] = [];
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
const text = (node.textContent ?? "").normalize("NFC").trim();
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!text || !parent || !EMOJI_ONLY_TEXT.test(text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const color = sampledEmojiColor(text, getComputedStyle(parent));
|
||||||
|
if (!color) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
layouts.push({ ordinal: layouts.length + 1, text, color });
|
||||||
|
}
|
||||||
|
return layouts;
|
||||||
|
}
|
||||||
|
|
||||||
export function collectDocxInlineCodeLayouts(
|
export function collectDocxInlineCodeLayouts(
|
||||||
article: HTMLElement
|
article: HTMLElement
|
||||||
): DocxInlineCodeLayout[] {
|
): DocxInlineCodeLayout[] {
|
||||||
@@ -545,7 +680,7 @@ export function collectDocxDocumentLayoutPlan(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
ordinal: index + 1,
|
ordinal: index + 1,
|
||||||
widthPercent: Math.min(100, (width / contentWidth) * 100),
|
widthPercent: Math.min(300, (width / contentWidth) * 100),
|
||||||
leftOffsetPercent: Math.max(
|
leftOffsetPercent: Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
|
Math.min(100, ((rect.left - articleRect.left) / contentWidth) * 100)
|
||||||
@@ -570,11 +705,112 @@ export function collectDocxDocumentLayoutPlan(
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const emojiRuns = collectDocxEmojiRunLayouts(article);
|
||||||
return {
|
return {
|
||||||
tables,
|
tables,
|
||||||
textBlocks: collectDocxTextBlockLayouts(article),
|
textBlocks: collectDocxTextBlockLayouts(article),
|
||||||
listItems: collectDocxListItemLayouts(article),
|
listItems: collectDocxListItemLayouts(article),
|
||||||
inlineCodes: collectDocxInlineCodeLayouts(article)
|
inlineCodes: collectDocxInlineCodeLayouts(article),
|
||||||
|
...(emojiRuns.length > 0 ? { emojiRuns } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectPagedDocxTableGeometries(
|
||||||
|
root: ParentNode,
|
||||||
|
dimensions: DocxMediaRenderDimensions
|
||||||
|
): DocxTableGeometry[] {
|
||||||
|
const tablesByReference = new Map<string, HTMLTableElement[]>();
|
||||||
|
let anonymousTableIndex = 0;
|
||||||
|
for (const table of root.querySelectorAll<HTMLTableElement>(
|
||||||
|
".pagedjs_pages table"
|
||||||
|
)) {
|
||||||
|
const reference = table.dataset.ref ??
|
||||||
|
`__anonymous_table_${anonymousTableIndex += 1}`;
|
||||||
|
const tables = tablesByReference.get(reference) ?? [];
|
||||||
|
tables.push(table);
|
||||||
|
tablesByReference.set(reference, tables);
|
||||||
|
}
|
||||||
|
const geometries: DocxTableGeometry[] = [];
|
||||||
|
for (const tables of tablesByReference.values()) {
|
||||||
|
const table = tables.reduce((best, candidate) => {
|
||||||
|
const visibleArea = (element: HTMLTableElement) => {
|
||||||
|
const content = element.closest<HTMLElement>(
|
||||||
|
".pagedjs_area, #write"
|
||||||
|
);
|
||||||
|
const contentRect = content?.getBoundingClientRect();
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (!contentRect) {
|
||||||
|
return Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||||
|
}
|
||||||
|
const width = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(rect.right, contentRect.right) -
|
||||||
|
Math.max(rect.left, contentRect.left)
|
||||||
|
);
|
||||||
|
const height = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(rect.bottom, contentRect.bottom) -
|
||||||
|
Math.max(rect.top, contentRect.top)
|
||||||
|
);
|
||||||
|
return width * height;
|
||||||
|
};
|
||||||
|
return visibleArea(candidate) > visibleArea(best)
|
||||||
|
? candidate
|
||||||
|
: best;
|
||||||
|
});
|
||||||
|
const content = table.closest<HTMLElement>(
|
||||||
|
".pagedjs_area, #write"
|
||||||
|
);
|
||||||
|
const contentRect = content?.getBoundingClientRect();
|
||||||
|
const contentWidth = finitePositive(
|
||||||
|
contentRect?.width ?? 0,
|
||||||
|
dimensions.contentWidthPx
|
||||||
|
);
|
||||||
|
const contentLeft = contentRect?.left ?? 0;
|
||||||
|
const rect = table.getBoundingClientRect();
|
||||||
|
const width = finitePositive(rect.width, contentWidth);
|
||||||
|
const columnWidths = collectTableColumnWidths(table, rect);
|
||||||
|
const columnTotal = columnWidths.reduce(
|
||||||
|
(total, value) => total + value,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
geometries.push({
|
||||||
|
ordinal: geometries.length + 1,
|
||||||
|
widthPercent: Math.min(300, (width / contentWidth) * 100),
|
||||||
|
leftOffsetPercent: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, ((rect.left - contentLeft) / contentWidth) * 100)
|
||||||
|
),
|
||||||
|
columnWidthPercents: columnWidths.map(
|
||||||
|
(value) => (value / columnTotal) * 100
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return geometries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePagedTableGeometries(
|
||||||
|
layout: DocxDocumentLayoutPlan,
|
||||||
|
pagedGeometries: readonly DocxTableGeometry[]
|
||||||
|
): DocxDocumentLayoutPlan {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
tables: layout.tables.map((table, index) => {
|
||||||
|
const geometry = pagedGeometries[index];
|
||||||
|
if (
|
||||||
|
!geometry ||
|
||||||
|
geometry.columnWidthPercents.length !==
|
||||||
|
table.columnWidthPercents.length
|
||||||
|
) {
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...table,
|
||||||
|
widthPercent: geometry.widthPercent,
|
||||||
|
leftOffsetPercent: geometry.leftOffsetPercent,
|
||||||
|
columnWidthPercents: [...geometry.columnWidthPercents]
|
||||||
|
};
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,9 +831,9 @@ export function collectDocxMediaCaptureTargets(
|
|||||||
].join(",")
|
].join(",")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (candidates.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
if (candidates.length > MAXIMUM_DOCX_MEDIA_COUNT) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_RESOURCE_COUNT}`
|
`DOCX 媒体数量不能超过 ${MAXIMUM_DOCX_MEDIA_COUNT}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +894,20 @@ export async function renderDocxMediaCapturePlan(
|
|||||||
payload: PagedDocumentPayload,
|
payload: PagedDocumentPayload,
|
||||||
dimensions: DocxMediaRenderDimensions
|
dimensions: DocxMediaRenderDimensions
|
||||||
): Promise<DocxMediaCapturePlan> {
|
): Promise<DocxMediaCapturePlan> {
|
||||||
|
const pagedResult = await runtime.render(payload, {
|
||||||
|
target: "pdf",
|
||||||
|
// DOCX 只消费分页表格的宽度、偏移和列宽比例;完整行结构与样式随后
|
||||||
|
// 从连续 DOM 重新采集。Paged.js 偶发丢失边界行时允许几何降级,
|
||||||
|
// 正常 PDF 渲染仍保持严格的正文行完整性门禁。
|
||||||
|
allowIncompleteTableGeometry: true
|
||||||
|
});
|
||||||
|
if (!pagedResult) {
|
||||||
|
throw new Error("DOCX 分页布局渲染已取消");
|
||||||
|
}
|
||||||
|
const pagedTableGeometries = collectPagedDocxTableGeometries(
|
||||||
|
root,
|
||||||
|
dimensions
|
||||||
|
);
|
||||||
const renderResult = await runtime.renderContinuous(payload, {
|
const renderResult = await runtime.renderContinuous(payload, {
|
||||||
geometryCss: createGeometryCss(dimensions),
|
geometryCss: createGeometryCss(dimensions),
|
||||||
themeMedia: "print"
|
themeMedia: "print"
|
||||||
@@ -665,10 +915,24 @@ export async function renderDocxMediaCapturePlan(
|
|||||||
if (!renderResult) {
|
if (!renderResult) {
|
||||||
throw new Error("DOCX 媒体渲染已取消");
|
throw new Error("DOCX 媒体渲染已取消");
|
||||||
}
|
}
|
||||||
|
stabilizePagedTableColumns(root, dimensions.contentHeightPx);
|
||||||
|
const continuousLayout = collectDocxDocumentLayoutPlan(
|
||||||
|
root,
|
||||||
|
dimensions
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
targets: collectDocxMediaCaptureTargets(root, dimensions),
|
targets: collectDocxMediaCaptureTargets(root, dimensions),
|
||||||
echartsErrors: renderResult.echartsErrors,
|
echartsErrors: Array.from(new Set([
|
||||||
mermaidErrors: renderResult.mermaidErrors,
|
...pagedResult.echartsErrors,
|
||||||
documentLayout: collectDocxDocumentLayoutPlan(root, dimensions)
|
...renderResult.echartsErrors
|
||||||
|
])),
|
||||||
|
mermaidErrors: Array.from(new Set([
|
||||||
|
...pagedResult.mermaidErrors,
|
||||||
|
...renderResult.mermaidErrors
|
||||||
|
])),
|
||||||
|
documentLayout: mergePagedTableGeometries(
|
||||||
|
continuousLayout,
|
||||||
|
pagedTableGeometries
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ export * from "./paged-page-sequence.js";
|
|||||||
export * from "./semantic-cover-fit.js";
|
export * from "./semantic-cover-fit.js";
|
||||||
export * from "./paged-page-decorations.js";
|
export * from "./paged-page-decorations.js";
|
||||||
export * from "./paged-render-target.js";
|
export * from "./paged-render-target.js";
|
||||||
|
export * from "./paged-table-handler.js";
|
||||||
export * from "./pdf-document-links.js";
|
export * from "./pdf-document-links.js";
|
||||||
export * from "./preview-styles.js";
|
export * from "./preview-styles.js";
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ import { renderMermaidDefinitions } from "./mermaid-renderer.js";
|
|||||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js";
|
import { replaceMermaidSvgWithImages } from "./mermaid-static-image.js";
|
||||||
import type { MermaidOutputMode } from "./mermaid-static-image.js";
|
import type { MermaidOutputMode } from "./mermaid-static-image.js";
|
||||||
import { enablePrintMediaForPreview } from "./preview-styles.js";
|
import { enablePrintMediaForPreview } from "./preview-styles.js";
|
||||||
import "./paged-table-handler.js";
|
import {
|
||||||
|
forcePagedTableRowsToNextPage,
|
||||||
|
missingPagedTableRowIds,
|
||||||
|
stabilizePagedTableColumns
|
||||||
|
} from "./paged-table-handler.js";
|
||||||
import {
|
import {
|
||||||
buildPagedMediaCss,
|
buildPagedMediaCss,
|
||||||
continuousDocumentGeometryCss,
|
continuousDocumentGeometryCss,
|
||||||
@@ -61,6 +65,7 @@ export interface PagedDocumentRenderOptions {
|
|||||||
target: PagedRenderTarget;
|
target: PagedRenderTarget;
|
||||||
shouldContinue?: () => boolean;
|
shouldContinue?: () => boolean;
|
||||||
mermaidOutput?: MermaidOutputMode;
|
mermaidOutput?: MermaidOutputMode;
|
||||||
|
allowIncompleteTableGeometry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContinuousDocumentRenderOptions {
|
export interface ContinuousDocumentRenderOptions {
|
||||||
@@ -75,6 +80,33 @@ export interface PreviewEngineStyles {
|
|||||||
echartsCss: string;
|
echartsCss: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function protectFittableInlineCodeParagraphs(
|
||||||
|
root: ParentNode,
|
||||||
|
pageContentHeightPx: number
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(pageContentHeightPx) || pageContentHeightPx <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let protectedCount = 0;
|
||||||
|
for (const paragraph of root.querySelectorAll<HTMLElement>(
|
||||||
|
"#write > p.md-inline-code-paragraph"
|
||||||
|
)) {
|
||||||
|
const height = paragraph.getBoundingClientRect().height;
|
||||||
|
if (!(height > 0 && height <= pageContentHeightPx)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
paragraph.style.setProperty("break-inside", "avoid", "important");
|
||||||
|
paragraph.style.setProperty(
|
||||||
|
"page-break-inside",
|
||||||
|
"avoid",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
paragraph.dataset.mdtpInlineCodeKeepWhole = "true";
|
||||||
|
protectedCount += 1;
|
||||||
|
}
|
||||||
|
return protectedCount;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
|
export const DEFAULT_IMAGE_READY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
function waitForImage(
|
function waitForImage(
|
||||||
@@ -699,6 +731,7 @@ export class PagedDocumentRuntime {
|
|||||||
payload.features.includes("echarts") ||
|
payload.features.includes("echarts") ||
|
||||||
content.querySelector(".mermaid svg") ||
|
content.querySelector(".mermaid svg") ||
|
||||||
content.querySelector("img.md-document-image") ||
|
content.querySelector("img.md-document-image") ||
|
||||||
|
content.querySelector("table") ||
|
||||||
content.querySelector('[data-semantic-region="cover"]')
|
content.querySelector('[data-semantic-region="cover"]')
|
||||||
) {
|
) {
|
||||||
const measurement = mountMeasurementContainer(
|
const measurement = mountMeasurementContainer(
|
||||||
@@ -710,6 +743,14 @@ export class PagedDocumentRuntime {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await documentRef.fonts.ready;
|
await documentRef.fonts.ready;
|
||||||
|
protectFittableInlineCodeParagraphs(
|
||||||
|
measurement.host,
|
||||||
|
getPageContentDimensions(payload.exportConfig).height
|
||||||
|
);
|
||||||
|
stabilizePagedTableColumns(
|
||||||
|
measurement.host,
|
||||||
|
getPageContentDimensions(payload.exportConfig).height
|
||||||
|
);
|
||||||
constrainSemanticCoversToPage(
|
constrainSemanticCoversToPage(
|
||||||
measurement.host,
|
measurement.host,
|
||||||
getPageContentDimensions(payload.exportConfig).height
|
getPageContentDimensions(payload.exportConfig).height
|
||||||
@@ -816,6 +857,51 @@ export class PagedDocumentRuntime {
|
|||||||
stylesheets,
|
stylesheets,
|
||||||
this.root
|
this.root
|
||||||
);
|
);
|
||||||
|
const maximumTableRowRecoveryAttempts = 3;
|
||||||
|
for (
|
||||||
|
let attempt = 0;
|
||||||
|
attempt < maximumTableRowRecoveryAttempts;
|
||||||
|
attempt += 1
|
||||||
|
) {
|
||||||
|
const missingRowIds = missingPagedTableRowIds(
|
||||||
|
repaginationSource,
|
||||||
|
this.root
|
||||||
|
);
|
||||||
|
if (missingRowIds.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const recoveredCount = forcePagedTableRowsToNextPage(
|
||||||
|
repaginationSource,
|
||||||
|
missingRowIds
|
||||||
|
);
|
||||||
|
if (recoveredCount !== missingRowIds.length) {
|
||||||
|
throw new Error(
|
||||||
|
`分页表格行恢复标记不完整:缺失 ${missingRowIds.length} 行,` +
|
||||||
|
`仅定位 ${recoveredCount} 行`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
previewer.chunker.destroy();
|
||||||
|
previewer.polisher.destroy();
|
||||||
|
previewer = new Previewer();
|
||||||
|
this.activePreviewer = previewer;
|
||||||
|
flow = await previewer.preview(
|
||||||
|
repaginationSource.cloneNode(true) as DocumentFragment,
|
||||||
|
stylesheets,
|
||||||
|
this.root
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const unresolvedTableRowIds = missingPagedTableRowIds(
|
||||||
|
repaginationSource,
|
||||||
|
this.root
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
unresolvedTableRowIds.length > 0 &&
|
||||||
|
!options.allowIncompleteTableGeometry
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`分页后表格正文行缺失:${unresolvedTableRowIds.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
||||||
repaginationSource
|
repaginationSource
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,9 +127,21 @@ svg {
|
|||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#write table[data-stabilized-columns="true"] {
|
||||||
|
table-layout: fixed !important;
|
||||||
|
}
|
||||||
|
|
||||||
#write th,
|
#write th,
|
||||||
#write td {
|
#write td {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#write th code,
|
||||||
|
#write td code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid {
|
.mermaid {
|
||||||
@@ -421,6 +433,15 @@ ${buildFooterCss(config)}
|
|||||||
widows: 3;
|
widows: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#write > p.md-inline-code-paragraph {
|
||||||
|
orphans: 1;
|
||||||
|
widows: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#write li {
|
||||||
|
text-align-last: left;
|
||||||
|
}
|
||||||
|
|
||||||
#write h1,
|
#write h1,
|
||||||
#write h2,
|
#write h2,
|
||||||
#write h3,
|
#write h3,
|
||||||
@@ -489,6 +510,11 @@ ${buildFooterCss(config)}
|
|||||||
word-break: inherit;
|
word-break: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#write .md-code-line-indent {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
#write table[data-empty-split-table="true"] {
|
#write table[data-empty-split-table="true"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,352 @@ interface PagedChunker {
|
|||||||
source: ParentNode;
|
source: ParentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PagedHandlerContext {
|
function measuredColumnWidths(table: HTMLTableElement) {
|
||||||
chunker: PagedChunker;
|
const rows = Array.from(table.rows);
|
||||||
|
const columnCount = rows.reduce(
|
||||||
|
(maximum, row) => Math.max(
|
||||||
|
maximum,
|
||||||
|
Array.from(row.cells).reduce(
|
||||||
|
(total, cell) => total + Math.max(1, cell.colSpan),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const tableRect = table.getBoundingClientRect();
|
||||||
|
if (columnCount < 1 || tableRect.width <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const boundaries = Array.from(
|
||||||
|
{ length: columnCount + 1 },
|
||||||
|
() => [] as number[]
|
||||||
|
);
|
||||||
|
boundaries[0]!.push(0);
|
||||||
|
boundaries[columnCount]!.push(tableRect.width);
|
||||||
|
for (const row of rows) {
|
||||||
|
let column = 0;
|
||||||
|
for (const cell of Array.from(row.cells)) {
|
||||||
|
const span = Math.max(1, cell.colSpan);
|
||||||
|
const rect = cell.getBoundingClientRect();
|
||||||
|
boundaries[column]?.push(
|
||||||
|
Math.max(0, Math.min(tableRect.width, rect.left - tableRect.left))
|
||||||
|
);
|
||||||
|
column = Math.min(columnCount, column + span);
|
||||||
|
boundaries[column]?.push(
|
||||||
|
Math.max(0, Math.min(tableRect.width, rect.right - tableRect.left))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolved = boundaries.map((samples, index) =>
|
||||||
|
samples.length > 0
|
||||||
|
? samples.reduce((sum, value) => sum + value, 0) / samples.length
|
||||||
|
: tableRect.width * index / columnCount
|
||||||
|
);
|
||||||
|
resolved[0] = 0;
|
||||||
|
resolved[columnCount] = tableRect.width;
|
||||||
|
for (let index = 1; index < resolved.length; index += 1) {
|
||||||
|
resolved[index] = Math.max(resolved[index - 1]! + 0.01, resolved[index]!);
|
||||||
|
}
|
||||||
|
return resolved.slice(1).map(
|
||||||
|
(boundary, index) => boundary - resolved[index]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColumnWidthWeights(
|
||||||
|
columns: readonly HTMLTableColElement[],
|
||||||
|
weights: readonly number[]
|
||||||
|
) {
|
||||||
|
for (const [index, column] of columns.entries()) {
|
||||||
|
column.style.width = `${Math.max(0, weights[index] ?? 0)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCellWidthTracks(
|
||||||
|
table: HTMLTableElement,
|
||||||
|
widths: readonly number[]
|
||||||
|
) {
|
||||||
|
for (const row of Array.from(table.rows)) {
|
||||||
|
let columnIndex = 0;
|
||||||
|
for (const cell of Array.from(row.cells)) {
|
||||||
|
const span = Math.max(1, cell.colSpan);
|
||||||
|
const width = widths
|
||||||
|
.slice(columnIndex, columnIndex + span)
|
||||||
|
.reduce((sum, value) => sum + value, 0);
|
||||||
|
if (width > 0) {
|
||||||
|
cell.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
columnIndex += span;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stabilizePagedTableColumns(
|
||||||
|
root: ParentNode,
|
||||||
|
pageContentHeightPx?: number
|
||||||
|
) {
|
||||||
|
let stabilizedCount = 0;
|
||||||
|
for (const [tableIndex, table] of Array.from(
|
||||||
|
root.querySelectorAll<HTMLTableElement>("table")
|
||||||
|
).entries()) {
|
||||||
|
let bodyRowIndex = 0;
|
||||||
|
if (pageContentHeightPx && pageContentHeightPx > 0) {
|
||||||
|
for (const row of Array.from(table.rows)) {
|
||||||
|
const rowHeight = row.getBoundingClientRect().height;
|
||||||
|
const keepWhole = rowHeight > 0 && rowHeight <= pageContentHeightPx;
|
||||||
|
if (row.parentElement?.tagName === "TBODY") {
|
||||||
|
row.dataset.mdtpTableRowId ??=
|
||||||
|
`table-${tableIndex + 1}-row-${bodyRowIndex + 1}`;
|
||||||
|
row.dataset.mdtpTableRowKeepWhole = String(keepWhole);
|
||||||
|
bodyRowIndex += 1;
|
||||||
|
}
|
||||||
|
for (const cell of Array.from(row.cells)) {
|
||||||
|
cell.style.setProperty(
|
||||||
|
"break-inside",
|
||||||
|
keepWhole ? "avoid" : "auto",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
cell.style.setProperty(
|
||||||
|
"page-break-inside",
|
||||||
|
keepWhole ? "avoid" : "auto",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
table.dataset.stabilizedColumns === "true" ||
|
||||||
|
table.querySelector(":scope > colgroup")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const widths = measuredColumnWidths(table);
|
||||||
|
const total = widths.reduce((sum, value) => sum + value, 0);
|
||||||
|
if (widths.length === 0 || total <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const colgroup = table.ownerDocument.createElement("colgroup");
|
||||||
|
colgroup.dataset.stabilizedColumns = "true";
|
||||||
|
for (const width of widths) {
|
||||||
|
const column = table.ownerDocument.createElement("col");
|
||||||
|
column.dataset.stabilizedWidthPx = String(width);
|
||||||
|
colgroup.append(column);
|
||||||
|
}
|
||||||
|
table.insertBefore(colgroup, table.firstChild);
|
||||||
|
table.dataset.stabilizedColumns = "true";
|
||||||
|
table.style.tableLayout = "fixed";
|
||||||
|
const columns = Array.from(colgroup.children) as HTMLTableColElement[];
|
||||||
|
let weights = widths;
|
||||||
|
applyColumnWidthWeights(columns, weights);
|
||||||
|
for (let iteration = 0; iteration < 3; iteration += 1) {
|
||||||
|
const measured = measuredColumnWidths(table);
|
||||||
|
if (
|
||||||
|
measured.length !== widths.length ||
|
||||||
|
measured.some((width) => width <= 0)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
weights = weights.map(
|
||||||
|
(weight, index) => weight * widths[index]! / measured[index]!
|
||||||
|
);
|
||||||
|
applyColumnWidthWeights(columns, weights);
|
||||||
|
}
|
||||||
|
applyCellWidthTracks(table, widths);
|
||||||
|
stabilizedCount += 1;
|
||||||
|
}
|
||||||
|
return stabilizedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missingPagedTableRowIds(
|
||||||
|
source: ParentNode,
|
||||||
|
rendered: ParentNode
|
||||||
|
) {
|
||||||
|
const isVisiblePagedRow = (row: HTMLElement) => {
|
||||||
|
const pageArea = row.closest<HTMLElement>(".pagedjs_area");
|
||||||
|
if (!pageArea) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const areaRect = pageArea.getBoundingClientRect();
|
||||||
|
const intersectionWidth = Math.min(rowRect.right, areaRect.right) -
|
||||||
|
Math.max(rowRect.left, areaRect.left);
|
||||||
|
const intersectionHeight = Math.min(rowRect.bottom, areaRect.bottom) -
|
||||||
|
Math.max(rowRect.top, areaRect.top);
|
||||||
|
return intersectionWidth > 0.5 && intersectionHeight > 0.5;
|
||||||
|
};
|
||||||
|
const renderedIds = new Set(
|
||||||
|
Array.from(rendered.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-mdtp-table-row-id]"
|
||||||
|
)).filter(isVisiblePagedRow)
|
||||||
|
.map((row) => row.dataset.mdtpTableRowId)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
);
|
||||||
|
return Array.from(source.querySelectorAll<HTMLTableRowElement>(
|
||||||
|
'tbody > tr[data-mdtp-table-row-id][data-mdtp-table-row-keep-whole="true"]'
|
||||||
|
)).map((row) => row.dataset.mdtpTableRowId)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
.filter((id) => !renderedIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPagedTableBeforeRow(row: HTMLTableRowElement) {
|
||||||
|
const body = row.parentElement as HTMLTableSectionElement | null;
|
||||||
|
const table = row.closest<HTMLTableElement>("table");
|
||||||
|
if (
|
||||||
|
!body ||
|
||||||
|
body.tagName !== "TBODY" ||
|
||||||
|
!table ||
|
||||||
|
body.querySelector(":scope > tr") === row
|
||||||
|
) {
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuation = table.cloneNode(false) as HTMLTableElement;
|
||||||
|
continuation.dataset.mdtpTableRecoveryContinuation = "true";
|
||||||
|
for (const child of Array.from(table.children)) {
|
||||||
|
if (child.tagName === "COLGROUP" || child.tagName === "THEAD") {
|
||||||
|
continuation.append(child.cloneNode(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continuationBody = body.cloneNode(false) as HTMLTableSectionElement;
|
||||||
|
let movingRow: HTMLTableRowElement | null = row;
|
||||||
|
while (movingRow) {
|
||||||
|
const nextRow = movingRow.nextElementSibling as HTMLTableRowElement | null;
|
||||||
|
continuationBody.append(movingRow);
|
||||||
|
movingRow = nextRow;
|
||||||
|
}
|
||||||
|
continuation.append(continuationBody);
|
||||||
|
|
||||||
|
let followingSection = body.nextElementSibling;
|
||||||
|
while (followingSection) {
|
||||||
|
const nextSection = followingSection.nextElementSibling;
|
||||||
|
continuation.append(followingSection);
|
||||||
|
followingSection = nextSection;
|
||||||
|
}
|
||||||
|
table.after(continuation);
|
||||||
|
return continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forcePagedTableRowsToNextPage(
|
||||||
|
source: ParentNode,
|
||||||
|
rowIds: readonly string[]
|
||||||
|
) {
|
||||||
|
const requested = new Set(rowIds);
|
||||||
|
let count = 0;
|
||||||
|
for (const row of source.querySelectorAll<HTMLTableRowElement>(
|
||||||
|
"tbody > tr[data-mdtp-table-row-id]"
|
||||||
|
)) {
|
||||||
|
const id = row.dataset.mdtpTableRowId;
|
||||||
|
if (!id || !requested.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const table = splitPagedTableBeforeRow(row);
|
||||||
|
let recoveryTarget: HTMLElement = row;
|
||||||
|
if (table?.parentNode) {
|
||||||
|
const previous = table.previousElementSibling as HTMLElement | null;
|
||||||
|
const marker = previous?.dataset.mdtpTableRecoveryMarker === "true"
|
||||||
|
? previous
|
||||||
|
: table.ownerDocument.createElement("div");
|
||||||
|
if (marker !== previous) {
|
||||||
|
marker.dataset.mdtpTableRecoveryMarker = "true";
|
||||||
|
marker.setAttribute("aria-hidden", "true");
|
||||||
|
marker.textContent = "\u00a0";
|
||||||
|
marker.style.setProperty("height", "1px", "important");
|
||||||
|
marker.style.setProperty("line-height", "1px", "important");
|
||||||
|
marker.style.setProperty("margin", "0 0 -1px", "important");
|
||||||
|
marker.style.setProperty("padding", "0", "important");
|
||||||
|
marker.style.setProperty("border", "0", "important");
|
||||||
|
marker.style.setProperty("overflow", "hidden", "important");
|
||||||
|
marker.style.setProperty("opacity", "0", "important");
|
||||||
|
table.parentNode.insertBefore(marker, table);
|
||||||
|
}
|
||||||
|
recoveryTarget = marker;
|
||||||
|
table.dataset.mdtpTableRecovery = "true";
|
||||||
|
}
|
||||||
|
recoveryTarget.style.setProperty("break-before", "page", "important");
|
||||||
|
recoveryTarget.style.setProperty(
|
||||||
|
"page-break-before",
|
||||||
|
"always",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
row.dataset.mdtpTableRowRecovery = "true";
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restorePagedTableStructure(
|
||||||
|
sourceTable: HTMLTableElement,
|
||||||
|
renderedTable: HTMLTableElement
|
||||||
|
) {
|
||||||
|
restorePagedTableColumns(sourceTable, renderedTable);
|
||||||
|
|
||||||
|
if (!renderedTable.querySelector("thead")) {
|
||||||
|
const sourceHeader = sourceTable.querySelector("thead");
|
||||||
|
if (sourceHeader) {
|
||||||
|
const firstNonColumnGroup = Array.from(renderedTable.children).find(
|
||||||
|
(child) => child.tagName !== "COLGROUP"
|
||||||
|
) ?? null;
|
||||||
|
renderedTable.insertBefore(
|
||||||
|
sourceHeader.cloneNode(true),
|
||||||
|
firstNonColumnGroup
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restorePagedTableColumns(
|
||||||
|
sourceTable: HTMLTableElement,
|
||||||
|
renderedTable: HTMLTableElement
|
||||||
|
) {
|
||||||
|
const sourceGroups = Array.from(
|
||||||
|
sourceTable.querySelectorAll<HTMLTableColElement>(
|
||||||
|
":scope > colgroup"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (sourceGroups.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const renderedGroups = Array.from(
|
||||||
|
renderedTable.querySelectorAll<HTMLTableColElement>(
|
||||||
|
":scope > colgroup"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const matchingStructure =
|
||||||
|
renderedGroups.length === sourceGroups.length &&
|
||||||
|
renderedGroups.every(
|
||||||
|
(group, index) =>
|
||||||
|
group.children.length === sourceGroups[index]?.children.length
|
||||||
|
);
|
||||||
|
if (!matchingStructure) {
|
||||||
|
for (const group of renderedGroups) {
|
||||||
|
group.remove();
|
||||||
|
}
|
||||||
|
const firstChild = renderedTable.firstChild;
|
||||||
|
for (const group of sourceGroups) {
|
||||||
|
renderedTable.insertBefore(group.cloneNode(true), firstChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [groupIndex, sourceGroup] of sourceGroups.entries()) {
|
||||||
|
const renderedGroup = renderedGroups[groupIndex]!;
|
||||||
|
for (const attribute of Array.from(sourceGroup.attributes)) {
|
||||||
|
renderedGroup.setAttribute(attribute.name, attribute.value);
|
||||||
|
}
|
||||||
|
for (const [columnIndex, sourceColumn] of Array.from(
|
||||||
|
sourceGroup.children
|
||||||
|
).entries()) {
|
||||||
|
const renderedColumn = renderedGroup.children[
|
||||||
|
columnIndex
|
||||||
|
] as HTMLTableColElement;
|
||||||
|
for (const attribute of Array.from(sourceColumn.attributes)) {
|
||||||
|
renderedColumn.setAttribute(attribute.name, attribute.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceTable.dataset.stabilizedColumns === "true") {
|
||||||
|
renderedTable.dataset.stabilizedColumns = "true";
|
||||||
|
renderedTable.style.tableLayout = "fixed";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function elementAncestors(
|
function elementAncestors(
|
||||||
@@ -48,6 +392,21 @@ class RepeatTableHeadersHandler extends Handler {
|
|||||||
breakToken?: PagedBreakToken
|
breakToken?: PagedBreakToken
|
||||||
) {
|
) {
|
||||||
this.splitTableRefs = [];
|
this.splitTableRefs = [];
|
||||||
|
for (const renderedTable of pageElement.querySelectorAll<HTMLTableElement>(
|
||||||
|
"table[data-ref]"
|
||||||
|
)) {
|
||||||
|
const ref = renderedTable.getAttribute("data-ref");
|
||||||
|
if (!ref) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sourceTable =
|
||||||
|
this.chunker.source.querySelector<HTMLTableElement>(
|
||||||
|
`table[data-ref="${CSS.escape(ref)}"]`
|
||||||
|
);
|
||||||
|
if (sourceTable) {
|
||||||
|
restorePagedTableColumns(sourceTable, renderedTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
const element = resolveBreakTokenElement(breakToken?.node);
|
const element = resolveBreakTokenElement(breakToken?.node);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
@@ -81,6 +440,21 @@ class RepeatTableHeadersHandler extends Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
layout(rendered: HTMLElement) {
|
layout(rendered: HTMLElement) {
|
||||||
|
for (const renderedTable of rendered.querySelectorAll<HTMLTableElement>(
|
||||||
|
"table[data-ref]"
|
||||||
|
)) {
|
||||||
|
const ref = renderedTable.getAttribute("data-ref");
|
||||||
|
if (!ref) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sourceTable =
|
||||||
|
this.chunker.source.querySelector<HTMLTableElement>(
|
||||||
|
`table[data-ref="${CSS.escape(ref)}"]`
|
||||||
|
);
|
||||||
|
if (sourceTable) {
|
||||||
|
restorePagedTableColumns(sourceTable, renderedTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const ref of this.splitTableRefs) {
|
for (const ref of this.splitTableRefs) {
|
||||||
const renderedTable =
|
const renderedTable =
|
||||||
rendered.querySelector<HTMLTableElement>(
|
rendered.querySelector<HTMLTableElement>(
|
||||||
@@ -101,23 +475,7 @@ class RepeatTableHeadersHandler extends Handler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstChild = renderedTable.firstChild;
|
restorePagedTableStructure(sourceTable, renderedTable);
|
||||||
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
|
|
||||||
renderedTable.insertBefore(
|
|
||||||
colgroup.cloneNode(true),
|
|
||||||
firstChild
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderedTable.querySelector("thead")) {
|
|
||||||
const sourceHeader = sourceTable.querySelector("thead");
|
|
||||||
if (sourceHeader) {
|
|
||||||
renderedTable.insertBefore(
|
|
||||||
sourceHeader.cloneNode(true),
|
|
||||||
renderedTable.firstChild
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderedTable.setAttribute("data-repeated-header", "true");
|
renderedTable.setAttribute("data-repeated-header", "true");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ describe("代码块分页预处理", () => {
|
|||||||
).toBe("return");
|
).toBe("return");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("将高亮代码行首缩进封装为分页稳定节点", () => {
|
||||||
|
const root = createRoot(
|
||||||
|
'<span class="hljs-punctuation">{</span>\n ' +
|
||||||
|
'<span class="hljs-attr">"nested"</span>: true\n' +
|
||||||
|
'<span class="hljs-punctuation">}</span>\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
prepareCodeBlockPagination(root);
|
||||||
|
|
||||||
|
const lines = getLines(root);
|
||||||
|
const indent = lines[1]?.querySelector(
|
||||||
|
":scope > .md-code-line-indent"
|
||||||
|
);
|
||||||
|
expect(lines.map((line) => line.textContent)).toEqual([
|
||||||
|
"{",
|
||||||
|
' "nested": true',
|
||||||
|
"}"
|
||||||
|
]);
|
||||||
|
expect(indent?.textContent).toBe(" ");
|
||||||
|
expect((indent as HTMLElement | null)?.style.width).toBe("2ch");
|
||||||
|
expect((indent as HTMLElement | null)?.dataset.codeIndentColumns)
|
||||||
|
.toBe("2");
|
||||||
|
expect(indent?.nextElementSibling?.classList.contains("hljs-attr"))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("重复调用不会再次包装已经准备的代码块", () => {
|
it("重复调用不会再次包装已经准备的代码块", () => {
|
||||||
const root = createRoot("line-1\nline-2\n");
|
const root = createRoot("line-1\nline-2\n");
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
collectPagedDocxTableGeometries,
|
||||||
collectDocxDocumentLayoutPlan,
|
collectDocxDocumentLayoutPlan,
|
||||||
collectDocxInlineCodeLayouts,
|
collectDocxInlineCodeLayouts,
|
||||||
collectDocxListItemLayouts,
|
collectDocxListItemLayouts,
|
||||||
collectDocxTextBlockLayouts,
|
collectDocxTextBlockLayouts,
|
||||||
collectDocxMediaCaptureTargets,
|
collectDocxMediaCaptureTargets,
|
||||||
|
forcePagedTableRowsToNextPage,
|
||||||
|
mergePagedTableGeometries,
|
||||||
|
missingPagedTableRowIds,
|
||||||
|
protectFittableInlineCodeParagraphs,
|
||||||
removeInheritedPagedSplitJustification,
|
removeInheritedPagedSplitJustification,
|
||||||
renderDocxMediaCapturePlan
|
renderDocxMediaCapturePlan,
|
||||||
|
restorePagedTableColumns,
|
||||||
|
restorePagedTableStructure,
|
||||||
|
stabilizePagedTableColumns
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
describe("DOCX 媒体捕获计划", () => {
|
describe("DOCX 媒体捕获计划", () => {
|
||||||
@@ -18,6 +26,10 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("连续布局使用原始打印媒体主题 CSS", async () => {
|
it("连续布局使用原始打印媒体主题 CSS", async () => {
|
||||||
|
const render = vi.fn(async () => ({
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
}));
|
||||||
const renderContinuous = vi.fn(async () => ({
|
const renderContinuous = vi.fn(async () => ({
|
||||||
echartsErrors: [],
|
echartsErrors: [],
|
||||||
mermaidErrors: []
|
mermaidErrors: []
|
||||||
@@ -26,7 +38,7 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
root.innerHTML = '<article id="write"></article>';
|
root.innerHTML = '<article id="write"></article>';
|
||||||
|
|
||||||
await renderDocxMediaCapturePlan(
|
await renderDocxMediaCapturePlan(
|
||||||
{ renderContinuous } as never,
|
{ render, renderContinuous } as never,
|
||||||
root,
|
root,
|
||||||
{
|
{
|
||||||
articleHtml: '<article id="write"></article>',
|
articleHtml: '<article id="write"></article>',
|
||||||
@@ -57,6 +69,155 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({ themeMedia: "print" })
|
expect.objectContaining({ themeMedia: "print" })
|
||||||
);
|
);
|
||||||
|
expect(render).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
{
|
||||||
|
target: "pdf",
|
||||||
|
allowIncompleteTableGeometry: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("仅将能完整放入一页的顶层行内代码段落保持为整体", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<article id="write">
|
||||||
|
<p id="short" class="md-inline-code-paragraph">短段落</p>
|
||||||
|
<p id="long" class="md-inline-code-paragraph">超长段落</p>
|
||||||
|
<table><tbody><tr><td>
|
||||||
|
<p id="cell" class="md-inline-code-paragraph">单元格段落</p>
|
||||||
|
</td></tr></tbody></table>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
const short = document.querySelector<HTMLElement>("#short")!;
|
||||||
|
const long = document.querySelector<HTMLElement>("#long")!;
|
||||||
|
const cell = document.querySelector<HTMLElement>("#cell")!;
|
||||||
|
short.getBoundingClientRect = () => ({ height: 240 }) as DOMRect;
|
||||||
|
long.getBoundingClientRect = () => ({ height: 1200 }) as DOMRect;
|
||||||
|
cell.getBoundingClientRect = () => ({ height: 120 }) as DOMRect;
|
||||||
|
|
||||||
|
expect(protectFittableInlineCodeParagraphs(document, 900)).toBe(1);
|
||||||
|
expect(short.style.getPropertyValue("break-inside")).toBe("avoid");
|
||||||
|
expect(short.style.getPropertyPriority("break-inside")).toBe("important");
|
||||||
|
expect(short.dataset.mdtpInlineCodeKeepWhole).toBe("true");
|
||||||
|
expect(long.style.getPropertyValue("break-inside")).toBe("");
|
||||||
|
expect(cell.style.getPropertyValue("break-inside")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按逻辑表格去重采集分页后的真实列轨", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<main class="pagedjs_pages">
|
||||||
|
<section class="pagedjs_page">
|
||||||
|
<div class="pagedjs_area">
|
||||||
|
<table data-ref="table-a">
|
||||||
|
<colgroup data-stabilized-columns="true">
|
||||||
|
<col data-stabilized-width-px="120">
|
||||||
|
<col data-stabilized-width-px="280">
|
||||||
|
</colgroup>
|
||||||
|
<tbody><tr><td>A</td><td>B</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="pagedjs_page">
|
||||||
|
<div class="pagedjs_area">
|
||||||
|
<table data-ref="table-a">
|
||||||
|
<colgroup data-stabilized-columns="true">
|
||||||
|
<col data-stabilized-width-px="120">
|
||||||
|
<col data-stabilized-width-px="280">
|
||||||
|
</colgroup>
|
||||||
|
<tbody><tr><td>C</td><td>D</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
const areas = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(".pagedjs_area")
|
||||||
|
);
|
||||||
|
const tables = Array.from(
|
||||||
|
document.querySelectorAll<HTMLTableElement>("table")
|
||||||
|
);
|
||||||
|
areas.forEach((area) => {
|
||||||
|
area.getBoundingClientRect = () => ({
|
||||||
|
left: 100, right: 900, top: 0, bottom: 900,
|
||||||
|
width: 800, height: 900
|
||||||
|
}) as DOMRect;
|
||||||
|
});
|
||||||
|
tables.forEach((table, index) => {
|
||||||
|
table.getBoundingClientRect = () => ({
|
||||||
|
left: index === 0 ? 900 : 140,
|
||||||
|
right: index === 0 ? 1300 : 540,
|
||||||
|
top: 20, bottom: 220,
|
||||||
|
width: 400, height: 200
|
||||||
|
}) as DOMRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(collectPagedDocxTableGeometries(document, {
|
||||||
|
contentWidthPx: 800,
|
||||||
|
contentHeightPx: 900
|
||||||
|
})).toEqual([{
|
||||||
|
ordinal: 1,
|
||||||
|
widthPercent: 50,
|
||||||
|
leftOffsetPercent: 5,
|
||||||
|
columnWidthPercents: [30, 70]
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只覆盖列数一致的分页表格几何并保留连续布局行样式", () => {
|
||||||
|
const row = {
|
||||||
|
cells: [{
|
||||||
|
columnSpan: 1,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
color: "#000000",
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
alignment: "left" as const
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const layout = {
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
ordinal: 1,
|
||||||
|
widthPercent: 100,
|
||||||
|
leftOffsetPercent: 0,
|
||||||
|
columnWidthPercents: [40, 60],
|
||||||
|
rows: [row]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ordinal: 2,
|
||||||
|
widthPercent: 100,
|
||||||
|
leftOffsetPercent: 0,
|
||||||
|
columnWidthPercents: [100],
|
||||||
|
rows: [row]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
textBlocks: [],
|
||||||
|
listItems: [],
|
||||||
|
inlineCodes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = mergePagedTableGeometries(layout, [
|
||||||
|
{
|
||||||
|
ordinal: 1,
|
||||||
|
widthPercent: 90,
|
||||||
|
leftOffsetPercent: 5,
|
||||||
|
columnWidthPercents: [30, 70]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ordinal: 2,
|
||||||
|
widthPercent: 80,
|
||||||
|
leftOffsetPercent: 10,
|
||||||
|
columnWidthPercents: [20, 80]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(merged.tables[0]).toEqual({
|
||||||
|
...layout.tables[0],
|
||||||
|
widthPercent: 90,
|
||||||
|
leftOffsetPercent: 5,
|
||||||
|
columnWidthPercents: [30, 70]
|
||||||
|
});
|
||||||
|
expect(merged.tables[0]?.rows).toBe(layout.tables[0]?.rows);
|
||||||
|
expect(merged.tables[1]).toBe(layout.tables[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
|
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
|
||||||
@@ -334,6 +495,382 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("保留主题表格超出文档内容盒的实测宽度", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<article id="write"><table><tbody><tr><td>内容</td></tr></tbody></table></article>
|
||||||
|
`;
|
||||||
|
const article = document.querySelector<HTMLElement>("#write")!;
|
||||||
|
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||||
|
const cell = document.querySelector<HTMLTableCellElement>("td")!;
|
||||||
|
article.getBoundingClientRect = () => ({
|
||||||
|
left: 100,
|
||||||
|
right: 900,
|
||||||
|
top: 0,
|
||||||
|
bottom: 900,
|
||||||
|
width: 800,
|
||||||
|
height: 900,
|
||||||
|
}) as DOMRect;
|
||||||
|
table.getBoundingClientRect = () => ({
|
||||||
|
left: 100,
|
||||||
|
right: 980,
|
||||||
|
top: 20,
|
||||||
|
bottom: 120,
|
||||||
|
width: 880,
|
||||||
|
height: 100,
|
||||||
|
}) as DOMRect;
|
||||||
|
cell.getBoundingClientRect = table.getBoundingClientRect;
|
||||||
|
|
||||||
|
const layout = collectDocxDocumentLayoutPlan(document, {
|
||||||
|
contentWidthPx: 800,
|
||||||
|
contentHeightPx: 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.tables[0]?.widthPercent).toBeCloseTo(110, 8);
|
||||||
|
expect(layout.tables[0]?.columnWidthPercents).toEqual([100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分页前冻结整表实测列宽供拆分页片段复用", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<article id="write"><table><tbody><tr><td>A</td><td>B</td></tr></tbody></table></article>
|
||||||
|
`;
|
||||||
|
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||||
|
const cells = Array.from(table.rows[0]!.cells);
|
||||||
|
table.getBoundingClientRect = () => ({
|
||||||
|
left: 100,
|
||||||
|
right: 500,
|
||||||
|
top: 0,
|
||||||
|
bottom: 100,
|
||||||
|
width: 400,
|
||||||
|
height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
cells[0]!.getBoundingClientRect = () => ({
|
||||||
|
left: 100,
|
||||||
|
right: 220,
|
||||||
|
top: 0,
|
||||||
|
bottom: 100,
|
||||||
|
width: 120,
|
||||||
|
height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
cells[1]!.getBoundingClientRect = () => ({
|
||||||
|
left: 220,
|
||||||
|
right: 500,
|
||||||
|
top: 0,
|
||||||
|
bottom: 100,
|
||||||
|
width: 280,
|
||||||
|
height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
|
||||||
|
expect(stabilizePagedTableColumns(document)).toBe(1);
|
||||||
|
expect(table.dataset.stabilizedColumns).toBe("true");
|
||||||
|
expect(table.style.tableLayout).toBe("fixed");
|
||||||
|
expect(Array.from(table.querySelectorAll("col")).map(
|
||||||
|
(column) => column.style.width
|
||||||
|
)).toEqual(["120px", "280px"]);
|
||||||
|
expect(Array.from(table.querySelectorAll("col")).map(
|
||||||
|
(column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx
|
||||||
|
)).toEqual(["120", "280"]);
|
||||||
|
expect(Array.from(table.querySelectorAll("th, td")).map(
|
||||||
|
(cell) => (cell as HTMLElement).style.width
|
||||||
|
)).toEqual(["120px", "280px"]);
|
||||||
|
expect(stabilizePagedTableColumns(document)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOCX 布局采集保持分页前原始列轨而不受辅助样式二次测量影响", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<article id="write">
|
||||||
|
<table data-stabilized-columns="true">
|
||||||
|
<colgroup data-stabilized-columns="true">
|
||||||
|
<col data-stabilized-width-px="120" style="width: 140px">
|
||||||
|
<col data-stabilized-width-px="280" style="width: 260px">
|
||||||
|
</colgroup>
|
||||||
|
<tbody><tr><td>A</td><td>B</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
const article = document.querySelector<HTMLElement>("#write")!;
|
||||||
|
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||||
|
const cells = Array.from(table.rows[0]!.cells);
|
||||||
|
article.getBoundingClientRect = () => ({
|
||||||
|
left: 100, right: 500, top: 0, bottom: 100,
|
||||||
|
width: 400, height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
table.getBoundingClientRect = article.getBoundingClientRect;
|
||||||
|
cells[0]!.getBoundingClientRect = () => ({
|
||||||
|
left: 100, right: 240, top: 0, bottom: 100,
|
||||||
|
width: 140, height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
cells[1]!.getBoundingClientRect = () => ({
|
||||||
|
left: 240, right: 500, top: 0, bottom: 100,
|
||||||
|
width: 260, height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
|
||||||
|
const layout = collectDocxDocumentLayoutPlan(document, {
|
||||||
|
contentWidthPx: 400,
|
||||||
|
contentHeightPx: 900
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.tables[0]?.columnWidthPercents).toEqual([30, 70]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("冻结跨列单元格为对应列轨宽度之和", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<table><tbody>
|
||||||
|
<tr><td colspan="2">跨列</td></tr>
|
||||||
|
<tr><td>甲</td><td>乙</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
`;
|
||||||
|
const table = document.querySelector("table")!;
|
||||||
|
const cells = Array.from(table.querySelectorAll("td"));
|
||||||
|
table.getBoundingClientRect = () => ({
|
||||||
|
left: 100, right: 500, top: 0, bottom: 100,
|
||||||
|
width: 400, height: 100
|
||||||
|
}) as DOMRect;
|
||||||
|
cells[0]!.getBoundingClientRect = () => ({
|
||||||
|
left: 100, right: 500, top: 0, bottom: 50,
|
||||||
|
width: 400, height: 50
|
||||||
|
}) as DOMRect;
|
||||||
|
cells[1]!.getBoundingClientRect = () => ({
|
||||||
|
left: 100, right: 220, top: 50, bottom: 100,
|
||||||
|
width: 120, height: 50
|
||||||
|
}) as DOMRect;
|
||||||
|
cells[2]!.getBoundingClientRect = () => ({
|
||||||
|
left: 220, right: 500, top: 50, bottom: 100,
|
||||||
|
width: 280, height: 50
|
||||||
|
}) as DOMRect;
|
||||||
|
|
||||||
|
expect(stabilizePagedTableColumns(document)).toBe(1);
|
||||||
|
expect(cells.map((cell) => cell.style.width)).toEqual([
|
||||||
|
"400px", "120px", "280px"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分页片段已有冻结列定义时不重复插入 colgroup", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<table id="source">
|
||||||
|
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
|
||||||
|
<thead><tr><th>A</th><th>B</th></tr></thead>
|
||||||
|
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
<table id="rendered">
|
||||||
|
<colgroup data-stabilized-columns="true"><col style="width: 30%"><col style="width: 70%"></colgroup>
|
||||||
|
<tbody><tr><td>丙</td><td>丁</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
const source = document.querySelector<HTMLTableElement>("#source")!;
|
||||||
|
const rendered = document.querySelector<HTMLTableElement>("#rendered")!;
|
||||||
|
|
||||||
|
restorePagedTableStructure(source, rendered);
|
||||||
|
|
||||||
|
expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1);
|
||||||
|
expect(rendered.querySelectorAll(":scope > colgroup > col")).toHaveLength(2);
|
||||||
|
expect(rendered.querySelectorAll(":scope > thead")).toHaveLength(1);
|
||||||
|
expect(Array.from(rendered.children).map((child) => child.tagName)).toEqual([
|
||||||
|
"COLGROUP",
|
||||||
|
"THEAD",
|
||||||
|
"TBODY"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分页前仅对可容纳于单页的表格行单元格启用整行保护", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<table><tbody>
|
||||||
|
<tr id="normal"><td>普通行</td><td>内容</td></tr>
|
||||||
|
<tr id="tall"><td>超高行</td><td>内容</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
`;
|
||||||
|
const table = document.querySelector<HTMLTableElement>("table")!;
|
||||||
|
const normal = document.querySelector<HTMLTableRowElement>("#normal")!;
|
||||||
|
const tall = document.querySelector<HTMLTableRowElement>("#tall")!;
|
||||||
|
table.getBoundingClientRect = () => ({
|
||||||
|
width: 400, height: 1020, top: 0, right: 400,
|
||||||
|
bottom: 1020, left: 0
|
||||||
|
}) as DOMRect;
|
||||||
|
normal.getBoundingClientRect = () => ({
|
||||||
|
width: 400, height: 120, top: 0, right: 400,
|
||||||
|
bottom: 120, left: 0
|
||||||
|
}) as DOMRect;
|
||||||
|
tall.getBoundingClientRect = () => ({
|
||||||
|
width: 400, height: 900, top: 120, right: 400,
|
||||||
|
bottom: 1020, left: 0
|
||||||
|
}) as DOMRect;
|
||||||
|
Array.from(table.rows).flatMap((row) => Array.from(row.cells))
|
||||||
|
.forEach((cell, index) => {
|
||||||
|
cell.getBoundingClientRect = () => ({
|
||||||
|
width: 200,
|
||||||
|
height: index < 2 ? 120 : 900,
|
||||||
|
top: index < 2 ? 0 : 120,
|
||||||
|
right: index % 2 === 0 ? 200 : 400,
|
||||||
|
bottom: index < 2 ? 120 : 1020,
|
||||||
|
left: index % 2 === 0 ? 0 : 200
|
||||||
|
}) as DOMRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
stabilizePagedTableColumns(document, 800);
|
||||||
|
|
||||||
|
expect(Array.from(normal.cells).map(
|
||||||
|
(cell) => cell.style.getPropertyValue("break-inside")
|
||||||
|
)).toEqual(["avoid", "avoid"]);
|
||||||
|
expect(Array.from(tall.cells).map(
|
||||||
|
(cell) => cell.style.getPropertyValue("break-inside")
|
||||||
|
)).toEqual(["auto", "auto"]);
|
||||||
|
expect(Array.from(normal.cells).every(
|
||||||
|
(cell) => cell.style.getPropertyPriority("break-inside") === "important"
|
||||||
|
)).toBe(true);
|
||||||
|
expect(normal.dataset.mdtpTableRowId).toBe("table-1-row-1");
|
||||||
|
expect(normal.dataset.mdtpTableRowKeepWhole).toBe("true");
|
||||||
|
expect(tall.dataset.mdtpTableRowId).toBe("table-1-row-2");
|
||||||
|
expect(tall.dataset.mdtpTableRowKeepWhole).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("检测分页后缺失的可容纳正文行并施加恢复断点", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<main id="source"><table><tbody>
|
||||||
|
<tr data-mdtp-table-row-id="row-1" data-mdtp-table-row-keep-whole="true"><td>甲</td></tr>
|
||||||
|
<tr data-mdtp-table-row-id="row-2" data-mdtp-table-row-keep-whole="true"><td>乙</td></tr>
|
||||||
|
<tr data-mdtp-table-row-id="row-tall" data-mdtp-table-row-keep-whole="false"><td>超高</td></tr>
|
||||||
|
</tbody></table></main>
|
||||||
|
<main id="rendered"><table><tbody>
|
||||||
|
<tr data-mdtp-table-row-id="row-2"><td>乙</td></tr>
|
||||||
|
</tbody></table></main>
|
||||||
|
`;
|
||||||
|
const source = document.querySelector("#source")!;
|
||||||
|
const rendered = document.querySelector("#rendered")!;
|
||||||
|
|
||||||
|
expect(missingPagedTableRowIds(source, rendered)).toEqual(["row-1"]);
|
||||||
|
expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1);
|
||||||
|
const row = source.querySelector<HTMLTableRowElement>(
|
||||||
|
'[data-mdtp-table-row-id="row-1"]'
|
||||||
|
)!;
|
||||||
|
const table = row.closest("table")!;
|
||||||
|
const marker = table.previousElementSibling as HTMLElement;
|
||||||
|
expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true");
|
||||||
|
expect(marker.textContent).toBe("\u00a0");
|
||||||
|
expect(marker.style.getPropertyValue("height")).toBe("1px");
|
||||||
|
expect(marker.style.getPropertyValue("margin-bottom")).toBe("-1px");
|
||||||
|
expect(marker.style.getPropertyValue("opacity")).toBe("0");
|
||||||
|
expect(marker.style.getPropertyValue("break-before")).toBe("page");
|
||||||
|
expect(marker.style.getPropertyPriority("break-before")).toBe("important");
|
||||||
|
expect(table.dataset.mdtpTableRecovery).toBe("true");
|
||||||
|
expect(row.dataset.mdtpTableRowRecovery).toBe("true");
|
||||||
|
expect(forcePagedTableRowsToNextPage(source, ["row-1"])).toBe(1);
|
||||||
|
expect(source.querySelectorAll("[data-mdtp-table-recovery-marker]"))
|
||||||
|
.toHaveLength(1);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在非首行缺失时拆分续表并将恢复断点放到表格外", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<main id="source"><table id="source-table">
|
||||||
|
<colgroup><col><col></colgroup>
|
||||||
|
<thead><tr><th>甲</th><th>乙</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr data-mdtp-table-row-id="row-1"><td>一</td><td>壹</td></tr>
|
||||||
|
<tr data-mdtp-table-row-id="row-2"><td>二</td><td>贰</td></tr>
|
||||||
|
<tr data-mdtp-table-row-id="row-3"><td>三</td><td>叁</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot><tr><td>尾</td><td>末</td></tr></tfoot>
|
||||||
|
</table></main>
|
||||||
|
`;
|
||||||
|
const source = document.querySelector("#source")!;
|
||||||
|
|
||||||
|
expect(forcePagedTableRowsToNextPage(source, ["row-2"])).toBe(1);
|
||||||
|
|
||||||
|
const tables = source.querySelectorAll<HTMLTableElement>("table");
|
||||||
|
expect(tables).toHaveLength(2);
|
||||||
|
expect(Array.from(tables[0]!.querySelectorAll("tbody > tr")).map(
|
||||||
|
(row) => (row as HTMLElement).dataset.mdtpTableRowId
|
||||||
|
)).toEqual(["row-1"]);
|
||||||
|
expect(Array.from(tables[1]!.querySelectorAll("tbody > tr")).map(
|
||||||
|
(row) => (row as HTMLElement).dataset.mdtpTableRowId
|
||||||
|
)).toEqual(["row-2", "row-3"]);
|
||||||
|
expect(tables[1]!.dataset.mdtpTableRecoveryContinuation).toBe("true");
|
||||||
|
expect(tables[1]!.querySelector("colgroup")).not.toBeNull();
|
||||||
|
expect(tables[1]!.querySelector("thead")).not.toBeNull();
|
||||||
|
expect(tables[0]!.querySelector("tfoot")).toBeNull();
|
||||||
|
expect(tables[1]!.querySelector("tfoot")).not.toBeNull();
|
||||||
|
const marker = tables[1]!.previousElementSibling as HTMLElement;
|
||||||
|
expect(marker.dataset.mdtpTableRecoveryMarker).toBe("true");
|
||||||
|
expect(marker.style.getPropertyValue("break-before")).toBe("page");
|
||||||
|
expect(marker.style.getPropertyPriority("break-before")).toBe("important");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("仅将分页可视区域内真实可见的表格行视为已渲染", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<main id="source"><table><tbody>
|
||||||
|
<tr data-mdtp-table-row-id="row-clipped" data-mdtp-table-row-keep-whole="true"><td>裁切</td></tr>
|
||||||
|
<tr data-mdtp-table-row-id="row-visible" data-mdtp-table-row-keep-whole="true"><td>可见</td></tr>
|
||||||
|
</tbody></table></main>
|
||||||
|
<main id="rendered">
|
||||||
|
<section class="pagedjs_area" id="page-1">
|
||||||
|
<table><tbody>
|
||||||
|
<tr id="clipped" data-mdtp-table-row-id="row-clipped"><td>裁切</td></tr>
|
||||||
|
<tr id="visible" data-mdtp-table-row-id="row-visible"><td>可见</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="pagedjs_area" id="page-2">
|
||||||
|
<table><tbody>
|
||||||
|
<tr id="visible-duplicate" data-mdtp-table-row-id="row-visible"><td>可见副本</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
const rect = (
|
||||||
|
left: number,
|
||||||
|
top: number,
|
||||||
|
right: number,
|
||||||
|
bottom: number
|
||||||
|
) => ({
|
||||||
|
left, top, right, bottom,
|
||||||
|
width: right - left,
|
||||||
|
height: bottom - top,
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
toJSON: () => ({})
|
||||||
|
}) as DOMRect;
|
||||||
|
document.querySelector<HTMLElement>("#page-1")!.getBoundingClientRect =
|
||||||
|
() => rect(0, 0, 600, 800);
|
||||||
|
document.querySelector<HTMLElement>("#page-2")!.getBoundingClientRect =
|
||||||
|
() => rect(0, 900, 600, 1700);
|
||||||
|
document.querySelector<HTMLElement>("#clipped")!.getBoundingClientRect =
|
||||||
|
() => rect(0, 810, 600, 850);
|
||||||
|
document.querySelector<HTMLElement>("#visible")!.getBoundingClientRect =
|
||||||
|
() => rect(0, 780, 600, 820);
|
||||||
|
document.querySelector<HTMLElement>("#visible-duplicate")!
|
||||||
|
.getBoundingClientRect = () => rect(0, 920, 600, 960);
|
||||||
|
|
||||||
|
expect(missingPagedTableRowIds(
|
||||||
|
document.querySelector("#source")!,
|
||||||
|
document.querySelector("#rendered")!
|
||||||
|
)).toEqual(["row-clipped"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分页片段同步源表冻结列宽而不重复 colgroup", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<table id="source" data-stabilized-columns="true">
|
||||||
|
<colgroup data-stabilized-columns="true"><col data-stabilized-width-px="118" style="width: 120px"><col data-stabilized-width-px="282" style="width: 280px"></colgroup>
|
||||||
|
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
<table id="rendered">
|
||||||
|
<colgroup><col style="width: 100px"><col style="width: 300px"></colgroup>
|
||||||
|
<tbody><tr><td>甲</td><td>乙</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
const source = document.querySelector<HTMLTableElement>("#source")!;
|
||||||
|
const rendered = document.querySelector<HTMLTableElement>("#rendered")!;
|
||||||
|
|
||||||
|
restorePagedTableColumns(source, rendered);
|
||||||
|
|
||||||
|
expect(rendered.querySelectorAll(":scope > colgroup")).toHaveLength(1);
|
||||||
|
expect(Array.from(rendered.querySelectorAll("col")).map(
|
||||||
|
(column) => column.style.width
|
||||||
|
)).toEqual(["120px", "280px"]);
|
||||||
|
expect(Array.from(rendered.querySelectorAll("col")).map(
|
||||||
|
(column) => (column as HTMLTableColElement).dataset.stabilizedWidthPx
|
||||||
|
)).toEqual(["118", "282"]);
|
||||||
|
expect(rendered.dataset.stabilizedColumns).toBe("true");
|
||||||
|
expect(rendered.style.tableLayout).toBe("fixed");
|
||||||
|
});
|
||||||
|
|
||||||
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
|
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<article id="write" style="font-size: 20px">
|
<article id="write" style="font-size: 20px">
|
||||||
@@ -419,6 +956,42 @@ describe("DOCX 媒体捕获计划", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("不把普通两端对齐列表的自然末行误判为分散对齐", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<article id="write">
|
||||||
|
<ul><li style="text-align: justify; text-align-last: left">末行保持左对齐</li></ul>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
const article = document.querySelector<HTMLElement>("#write")!;
|
||||||
|
article.getBoundingClientRect = () => ({
|
||||||
|
left: 0,
|
||||||
|
right: 800,
|
||||||
|
top: 0,
|
||||||
|
bottom: 900,
|
||||||
|
width: 800,
|
||||||
|
height: 900
|
||||||
|
}) as DOMRect;
|
||||||
|
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
|
||||||
|
getBoundingClientRect?: () => DOMRect;
|
||||||
|
};
|
||||||
|
const original = rangePrototype.getBoundingClientRect;
|
||||||
|
rangePrototype.getBoundingClientRect = function (this: Range) {
|
||||||
|
return {
|
||||||
|
left: this.startOffset * 80,
|
||||||
|
right: this.startOffset * 80 + 10,
|
||||||
|
top: 20,
|
||||||
|
bottom: 32,
|
||||||
|
width: 10,
|
||||||
|
height: 12
|
||||||
|
} as DOMRect;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
expect(collectDocxListItemLayouts(article)[0]?.alignment).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
rangePrototype.getBoundingClientRect = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("采集正文文本块在 Chromium 中的实际行断点", () => {
|
it("采集正文文本块在 Chromium 中的实际行断点", () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<article id="write">
|
<article id="write">
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ const payload: PagedPreviewPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("分页预览协议", () => {
|
describe("分页预览协议", () => {
|
||||||
|
it("含行内代码的段落避免被 Paged.js 跨页拆分丢失片段", () => {
|
||||||
|
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||||
|
|
||||||
|
expect(css).toContain("#write > p.md-inline-code-paragraph");
|
||||||
|
expect(css).toContain("orphans: 1");
|
||||||
|
expect(css).toContain("widows: 1");
|
||||||
|
});
|
||||||
|
|
||||||
it("生成真实纸张尺寸和页边距 CSS", () => {
|
it("生成真实纸张尺寸和页边距 CSS", () => {
|
||||||
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||||
|
|
||||||
@@ -278,6 +286,12 @@ describe("分页预览协议", () => {
|
|||||||
expect(documentBaseCss).toContain("white-space: pre-wrap;");
|
expect(documentBaseCss).toContain("white-space: pre-wrap;");
|
||||||
expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
|
expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
|
||||||
expect(documentBaseCss).toContain("word-break: break-word;");
|
expect(documentBaseCss).toContain("word-break: break-word;");
|
||||||
|
expect(documentBaseCss).toMatch(
|
||||||
|
/#write th,[\s\S]*#write td \{[\s\S]*word-break: break-word;/u,
|
||||||
|
);
|
||||||
|
expect(documentBaseCss).toMatch(
|
||||||
|
/#write th code,[\s\S]*#write td code \{[\s\S]*white-space: pre-wrap;[\s\S]*overflow-wrap: anywhere;[\s\S]*word-break: break-word;/u,
|
||||||
|
);
|
||||||
|
|
||||||
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||||
expect(css).toContain("#write .md-code-line");
|
expect(css).toContain("#write .md-code-line");
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import hljs from "highlight.js";
|
|||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import type { Options as MarkdownItOptions } from "markdown-it";
|
import type { Options as MarkdownItOptions } from "markdown-it";
|
||||||
import type Renderer from "markdown-it/lib/renderer.mjs";
|
import type Renderer from "markdown-it/lib/renderer.mjs";
|
||||||
|
import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs";
|
||||||
import type Token from "markdown-it/lib/token.mjs";
|
import type Token from "markdown-it/lib/token.mjs";
|
||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
import { renderDocumentStructure } from "./render-document-structure.js";
|
import { renderDocumentStructure } from "./render-document-structure.js";
|
||||||
@@ -49,6 +50,7 @@ export interface RenderMarkdownOptions {
|
|||||||
export interface RenderedMarkdown
|
export interface RenderedMarkdown
|
||||||
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
|
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
|
||||||
rendererVersion: typeof RENDERER_VERSION;
|
rendererVersion: typeof RENDERER_VERSION;
|
||||||
|
markdownBody: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownOptions: MarkdownItOptions = {
|
const markdownOptions: MarkdownItOptions = {
|
||||||
@@ -93,6 +95,104 @@ const markdown = new MarkdownIt(markdownOptions)
|
|||||||
trust: false
|
trust: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tableBreakMarkupPattern = /^(?:<br>|<br\/>|<br \/>)/iu;
|
||||||
|
|
||||||
|
function tableBreakCandidateRule(
|
||||||
|
state: StateInline,
|
||||||
|
silent: boolean
|
||||||
|
): boolean {
|
||||||
|
const match = state.src.slice(state.pos).match(tableBreakMarkupPattern);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
const token = state.push("table_break_candidate", "", 0);
|
||||||
|
token.content = match[0];
|
||||||
|
}
|
||||||
|
state.pos += match[0].length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown.inline.ruler.before(
|
||||||
|
"html_inline",
|
||||||
|
"table_break_candidate",
|
||||||
|
tableBreakCandidateRule
|
||||||
|
);
|
||||||
|
markdown.core.ruler.after("inline", "scope_table_breaks", (state) => {
|
||||||
|
let tableCellDepth = 0;
|
||||||
|
for (const token of state.tokens) {
|
||||||
|
if (token.type === "td_open" || token.type === "th_open") {
|
||||||
|
tableCellDepth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.type === "td_close" || token.type === "th_close") {
|
||||||
|
tableCellDepth = Math.max(0, tableCellDepth - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.type !== "inline") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const child of token.children ?? []) {
|
||||||
|
if (child.type === "table_break_candidate" && tableCellDepth > 0) {
|
||||||
|
child.type = "table_break";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
markdown.renderer.rules.table_break_candidate = (tokens, index) =>
|
||||||
|
markdown.utils.escapeHtml(tokens[index]?.content ?? "");
|
||||||
|
markdown.renderer.rules.table_break = () => "<br>";
|
||||||
|
|
||||||
|
const markdownAlertTitles = new Map([
|
||||||
|
["note", "Note"],
|
||||||
|
["tip", "Tip"],
|
||||||
|
["important", "Important"],
|
||||||
|
["warning", "Warning"],
|
||||||
|
["caution", "Caution"]
|
||||||
|
]);
|
||||||
|
|
||||||
|
markdown.core.ruler.after("scope_table_breaks", "github_alerts", (state) => {
|
||||||
|
for (let index = 0; index < state.tokens.length - 2; index += 1) {
|
||||||
|
const blockquote = state.tokens[index];
|
||||||
|
const paragraph = state.tokens[index + 1];
|
||||||
|
const inline = state.tokens[index + 2];
|
||||||
|
if (
|
||||||
|
blockquote?.type !== "blockquote_open" ||
|
||||||
|
paragraph?.type !== "paragraph_open" ||
|
||||||
|
inline?.type !== "inline"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [marker, bodyBreak] = inline.children ?? [];
|
||||||
|
const match = marker?.type === "text"
|
||||||
|
? marker.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/iu)
|
||||||
|
: undefined;
|
||||||
|
if (!marker || !match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const alertType = match[1]?.toLowerCase();
|
||||||
|
const title = alertType ? markdownAlertTitles.get(alertType) : undefined;
|
||||||
|
if (!alertType || !title) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blockquote.attrJoin("class", `md-alert md-alert-${alertType}`);
|
||||||
|
marker.type = "markdown_alert_title";
|
||||||
|
marker.content = title;
|
||||||
|
marker.meta = { alertType };
|
||||||
|
if (bodyBreak?.type === "softbreak") {
|
||||||
|
bodyBreak.type = "markdown_alert_body_break";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
markdown.renderer.rules.markdown_alert_title = (tokens, index) => {
|
||||||
|
const token = tokens[index];
|
||||||
|
const alertType = typeof token?.meta?.alertType === "string"
|
||||||
|
? token.meta.alertType
|
||||||
|
: "note";
|
||||||
|
return `<span class="md-alert-text md-alert-text-${escapeAttribute(alertType)}">${markdown.utils.escapeHtml(token?.content ?? "")}</span>`;
|
||||||
|
};
|
||||||
|
markdown.renderer.rules.markdown_alert_body_break = () => "</p>\n<p>";
|
||||||
|
|
||||||
const defaultValidateLink = markdown.validateLink.bind(markdown);
|
const defaultValidateLink = markdown.validateLink.bind(markdown);
|
||||||
markdown.validateLink = (href) =>
|
markdown.validateLink = (href) =>
|
||||||
defaultValidateLink(href) ||
|
defaultValidateLink(href) ||
|
||||||
@@ -141,6 +241,13 @@ markdown.renderer.rules.paragraph_open = (
|
|||||||
if (standaloneImage) {
|
if (standaloneImage) {
|
||||||
return '<figure class="md-document-image-block">\n';
|
return '<figure class="md-document-image-block">\n';
|
||||||
}
|
}
|
||||||
|
const inlineToken = tokens[index + 1];
|
||||||
|
if (
|
||||||
|
inlineToken?.type === "inline" &&
|
||||||
|
(inlineToken.children ?? []).some((child) => child.type === "code_inline")
|
||||||
|
) {
|
||||||
|
tokens[index]?.attrJoin("class", "md-inline-code-paragraph");
|
||||||
|
}
|
||||||
return defaultParagraphOpenRenderer
|
return defaultParagraphOpenRenderer
|
||||||
? defaultParagraphOpenRenderer(
|
? defaultParagraphOpenRenderer(
|
||||||
tokens,
|
tokens,
|
||||||
@@ -367,6 +474,7 @@ export function renderMarkdown(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
rendererVersion: RENDERER_VERSION,
|
rendererVersion: RENDERER_VERSION,
|
||||||
|
markdownBody: parsed.content,
|
||||||
articleHtml,
|
articleHtml,
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
metadata,
|
metadata,
|
||||||
|
|||||||
@@ -6,6 +6,38 @@ import {
|
|||||||
} from "../src/render-markdown.js";
|
} from "../src/render-markdown.js";
|
||||||
|
|
||||||
describe("renderMarkdown", () => {
|
describe("renderMarkdown", () => {
|
||||||
|
it("将 GFM Alert 渲染为与 Pandoc 一致的独立标题引用块", () => {
|
||||||
|
const result = renderMarkdown(
|
||||||
|
"> [!CAUTION]\n> **关键约束**:必须执行。",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'<blockquote class="md-alert md-alert-caution">',
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'<p><span class="md-alert-text md-alert-text-caution">Caution</span></p>\n<p><strong>关键约束</strong>:必须执行。</p>',
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).not.toContain("[!CAUTION]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("为含行内代码的普通段落标记分页保护类", () => {
|
||||||
|
const result = renderMarkdown("段落前 `inline_code` 段落后");
|
||||||
|
|
||||||
|
expect(result.articleHtml).toContain(
|
||||||
|
'<p class="md-inline-code-paragraph">段落前 <code>inline_code</code> 段落后</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("向下游暴露已剥离文首 Front Matter 的正文 Markdown", () => {
|
||||||
|
const rendered = renderMarkdown(
|
||||||
|
"---\ntitle: 测试标题\n---\n# 正文\n\n---\n\n后续内容"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rendered.markdownBody).toBe(
|
||||||
|
"# 正文\n\n---\n\n后续内容"
|
||||||
|
);
|
||||||
|
expect(rendered.metadata.title).toBe("测试标题");
|
||||||
|
});
|
||||||
it("渲染常用 Markdown 扩展并识别能力", () => {
|
it("渲染常用 Markdown 扩展并识别能力", () => {
|
||||||
const result = renderMarkdown(`
|
const result = renderMarkdown(`
|
||||||
# 示例文档
|
# 示例文档
|
||||||
@@ -80,6 +112,39 @@ const answer = 42;
|
|||||||
expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>');
|
expect(result.bodyHtml).toContain('<td style="text-align:right">C</td>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("只在 GFM 表格单元格内解释无属性 br 换行", () => {
|
||||||
|
const result = renderMarkdown(`
|
||||||
|
| 场景 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标准 | 第一行<br>第二行<BR/>第三行<br />第四行 |
|
||||||
|
|
||||||
|
正文中的 <br>、<br/> 和 <br /> 保持文本。
|
||||||
|
|
||||||
|
| 转义与代码 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 转义 | \\<br> 与 <br> |
|
||||||
|
| 代码 | \`<br>\` |
|
||||||
|
| 属性 | <br class="unsafe"> |
|
||||||
|
|
||||||
|
\`\`\`html
|
||||||
|
<br>
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
"第一行<br />第二行<br />第三行<br />第四行"
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
"正文中的 <br>、<br/> 和 <br /> 保持文本"
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain("<br> 与 <br>");
|
||||||
|
expect(result.bodyHtml).toContain("<code><br></code>");
|
||||||
|
expect(result.bodyHtml).toContain("<br class=\"unsafe\">");
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'<pre class="md-fences" lang="html"><code class="language-html">'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("读取并规范化 Front Matter 元数据", () => {
|
it("读取并规范化 Front Matter 元数据", () => {
|
||||||
const result = renderMarkdown(`---
|
const result = renderMarkdown(`---
|
||||||
title: 项目报告
|
title: 项目报告
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|||||||
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
|
const bundlePath = join(repositoryRoot, "font-packs", "bundle.json");
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
const bundle = JSON.parse(await readFile(bundlePath, "utf8"));
|
||||||
const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
|
const outputRoot = join(repositoryRoot, "output", "font-packs", "root");
|
||||||
const appVersion = process.env.npm_package_version || "0.6.1";
|
const appVersion = process.env.npm_package_version || "0.6.4";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bundle.schemaVersion !== 1 ||
|
bundle.schemaVersion !== 1 ||
|
||||||
|
|||||||
@@ -71,6 +71,29 @@ function hasIndependentCover(manifest) {
|
|||||||
return profiles.includes("project-report") || profiles.includes("tender");
|
return profiles.includes("project-report") || profiles.includes("tender");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveCoverPageCount({
|
||||||
|
caseDefinition,
|
||||||
|
scope,
|
||||||
|
sourceMetadata
|
||||||
|
}) {
|
||||||
|
assert(caseDefinition && Number.isInteger(caseDefinition.coverPageCount),
|
||||||
|
"矩阵场景缺少有效封面页数");
|
||||||
|
if (scope !== "corpus") {
|
||||||
|
return caseDefinition.coverPageCount;
|
||||||
|
}
|
||||||
|
const profile = sourceMetadata?.document?.profile;
|
||||||
|
return profile === "project-report" || profile === "tender" ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCorpusTextColorEquivalent(metrics) {
|
||||||
|
return Boolean(
|
||||||
|
metrics &&
|
||||||
|
metrics.inkIou >= 0.98 &&
|
||||||
|
metrics.edgeIou >= 0.98 &&
|
||||||
|
metrics.meanAbsoluteError <= 8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function readBundledThemeMatrixDefinitions(themesDirectory) {
|
export function readBundledThemeMatrixDefinitions(themesDirectory) {
|
||||||
assert(
|
assert(
|
||||||
typeof themesDirectory === "string" && themesDirectory.length > 0,
|
typeof themesDirectory === "string" && themesDirectory.length > 0,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS,
|
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS,
|
||||||
DOCX_LAYOUT_MATRIX_ORIENTATIONS,
|
DOCX_LAYOUT_MATRIX_ORIENTATIONS,
|
||||||
getDocxFontGateFailures,
|
getDocxFontGateFailures,
|
||||||
|
isCorpusTextColorEquivalent,
|
||||||
readBundledThemeMatrixDefinitions,
|
readBundledThemeMatrixDefinitions,
|
||||||
|
resolveEffectiveCoverPageCount,
|
||||||
summarizeDocxLayoutVisualMatrix
|
summarizeDocxLayoutVisualMatrix
|
||||||
} from "./docx-layout-visual-matrix-cases.mjs";
|
} from "./docx-layout-visual-matrix-cases.mjs";
|
||||||
|
|
||||||
@@ -156,3 +158,40 @@ test("四套独立封面主题扩展为 40 个严格封面场景", () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("真实语料按文档语义而不是主题能力判定独立封面", () => {
|
||||||
|
const caseDefinition = { coverPageCount: 1 };
|
||||||
|
assert.equal(resolveEffectiveCoverPageCount({
|
||||||
|
caseDefinition,
|
||||||
|
scope: "full",
|
||||||
|
sourceMetadata: {}
|
||||||
|
}), 1);
|
||||||
|
assert.equal(resolveEffectiveCoverPageCount({
|
||||||
|
caseDefinition,
|
||||||
|
scope: "corpus",
|
||||||
|
sourceMetadata: {}
|
||||||
|
}), 0);
|
||||||
|
assert.equal(resolveEffectiveCoverPageCount({
|
||||||
|
caseDefinition,
|
||||||
|
scope: "corpus",
|
||||||
|
sourceMetadata: { document: { profile: "tender" } }
|
||||||
|
}), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("短文本仅在墨迹边缘等价且像素误差低时忽略抗锯齿色差", () => {
|
||||||
|
assert.equal(isCorpusTextColorEquivalent({
|
||||||
|
inkIou: 1,
|
||||||
|
edgeIou: 1,
|
||||||
|
meanAbsoluteError: 6.9
|
||||||
|
}), true);
|
||||||
|
assert.equal(isCorpusTextColorEquivalent({
|
||||||
|
inkIou: 1,
|
||||||
|
edgeIou: 0.999,
|
||||||
|
meanAbsoluteError: 23
|
||||||
|
}), false);
|
||||||
|
assert.equal(isCorpusTextColorEquivalent({
|
||||||
|
inkIou: 0.8,
|
||||||
|
edgeIou: 1,
|
||||||
|
meanAbsoluteError: 4
|
||||||
|
}), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const DOCX_REAL_WORLD_CORPUS = Object.freeze([
|
||||||
|
{
|
||||||
|
id: "changqing-tender-requirements",
|
||||||
|
label: "长庆油田智能健康技术服务招标技术要求",
|
||||||
|
markdownPath: "tmp/长庆油田智能健康技术服务招标技术要求.md",
|
||||||
|
requiredFeatures: ["inline-code", "table-math-alignment"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "health-data-schema",
|
||||||
|
label: "运动健康类和营养饮食数据结构升级",
|
||||||
|
markdownPath: "tmp/运动健康类和营养饮食数据数据结构升级.md",
|
||||||
|
requiredFeatures: ["json-code-indentation"],
|
||||||
|
codeIndentationProbe: {
|
||||||
|
rootLine: "{",
|
||||||
|
nestedLineIncludes: '"heart_rate_daily"',
|
||||||
|
minimumIndentPt: 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m4n-table-design",
|
||||||
|
label: "M4N 全量表设计模块化讲解",
|
||||||
|
markdownPath: "tmp/数据中台项目周报_2026_W30_附件_M4N全量表设计模块化讲解.md",
|
||||||
|
requiredFeatures: ["mermaid", "long-document"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function resolveDocxRealWorldCorpus(repositoryDirectory) {
|
||||||
|
return DOCX_REAL_WORLD_CORPUS.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
absoluteMarkdownPath: path.resolve(
|
||||||
|
repositoryDirectory,
|
||||||
|
entry.markdownPath
|
||||||
|
),
|
||||||
|
byteLength: fs.existsSync(path.resolve(repositoryDirectory, entry.markdownPath))
|
||||||
|
? fs.statSync(path.resolve(repositoryDirectory, entry.markdownPath)).size
|
||||||
|
: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortDocxRealWorldCorpusBySize(repositoryDirectory) {
|
||||||
|
return resolveDocxRealWorldCorpus(repositoryDirectory).sort(
|
||||||
|
(first, second) =>
|
||||||
|
first.byteLength - second.byteLength ||
|
||||||
|
first.id.localeCompare(second.id, "en")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDocxRealWorldCorpus(repositoryDirectory) {
|
||||||
|
const corpus = sortDocxRealWorldCorpusBySize(repositoryDirectory);
|
||||||
|
const failures = [];
|
||||||
|
for (const entry of corpus) {
|
||||||
|
if (!fs.existsSync(entry.absoluteMarkdownPath)) {
|
||||||
|
failures.push(`${entry.id} 缺少 Markdown:${entry.markdownPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const source = fs.readFileSync(entry.absoluteMarkdownPath, "utf8");
|
||||||
|
if (!source.trim()) {
|
||||||
|
failures.push(`${entry.id} Markdown 为空`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.requiredFeatures.includes("json-code-indentation") &&
|
||||||
|
!/```json\s*[\s\S]*?\n[\t ]+\S/gu.test(source)
|
||||||
|
) {
|
||||||
|
failures.push(`${entry.id} 缺少带缩进的 JSON 围栏探针`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.requiredFeatures.includes("inline-code") &&
|
||||||
|
!/(^|[^`])`[^`\n]+`([^`]|$)/mu.test(source)
|
||||||
|
) {
|
||||||
|
failures.push(`${entry.id} 缺少行内代码探针`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.requiredFeatures.includes("table-math-alignment") &&
|
||||||
|
!/^\|.*\$[^$]+\$.*\|/mu.test(source)
|
||||||
|
) {
|
||||||
|
failures.push(`${entry.id} 缺少表格公式对齐探针`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { corpus, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeDocxReleaseGateMatrix({
|
||||||
|
baselineCaseCount,
|
||||||
|
corpusCaseCounts
|
||||||
|
}) {
|
||||||
|
const corpusCaseCount = Object.values(corpusCaseCounts).reduce(
|
||||||
|
(sum, value) => sum + value,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
baselineCaseCount,
|
||||||
|
corpusDocumentCount: Object.keys(corpusCaseCounts).length,
|
||||||
|
corpusCaseCounts,
|
||||||
|
corpusCaseCount,
|
||||||
|
totalCaseCount: baselineCaseCount + corpusCaseCount
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DOCX_REAL_WORLD_CORPUS,
|
||||||
|
sortDocxRealWorldCorpusBySize,
|
||||||
|
summarizeDocxReleaseGateMatrix,
|
||||||
|
validateDocxRealWorldCorpus
|
||||||
|
} from "./docx-real-world-corpus.mjs";
|
||||||
|
|
||||||
|
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
|
||||||
|
|
||||||
|
test("冻结三份真实 Markdown 语料及必需特征", () => {
|
||||||
|
const validation = validateDocxRealWorldCorpus(repositoryDirectory);
|
||||||
|
assert.deepEqual(validation.failures, []);
|
||||||
|
assert.equal(validation.corpus.length, 3);
|
||||||
|
assert.equal(new Set(DOCX_REAL_WORLD_CORPUS.map((entry) => entry.id)).size, 3);
|
||||||
|
assert.ok(
|
||||||
|
DOCX_REAL_WORLD_CORPUS.some((entry) =>
|
||||||
|
entry.requiredFeatures.includes("json-code-indentation")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
DOCX_REAL_WORLD_CORPUS.some((entry) =>
|
||||||
|
entry.requiredFeatures.includes("table-math-alignment")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("真实 Markdown 按文件字节数稳定升序执行", () => {
|
||||||
|
const corpus = sortDocxRealWorldCorpusBySize(repositoryDirectory);
|
||||||
|
assert.deepEqual(
|
||||||
|
corpus.map((entry) => entry.byteLength),
|
||||||
|
corpus.map((entry) => entry.byteLength).toSorted((a, b) => a - b)
|
||||||
|
);
|
||||||
|
assert.ok(corpus.every((entry) => entry.byteLength > 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("发布门禁矩阵聚合为 140+420=560", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
summarizeDocxReleaseGateMatrix({
|
||||||
|
baselineCaseCount: 140,
|
||||||
|
corpusCaseCounts: Object.fromEntries(
|
||||||
|
DOCX_REAL_WORLD_CORPUS.map((entry) => [entry.id, 140])
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
baselineCaseCount: 140,
|
||||||
|
corpusDocumentCount: 3,
|
||||||
|
corpusCaseCounts: {
|
||||||
|
"changqing-tender-requirements": 140,
|
||||||
|
"health-data-schema": 140,
|
||||||
|
"m4n-table-design": 140
|
||||||
|
},
|
||||||
|
corpusCaseCount: 420,
|
||||||
|
totalCaseCount: 560
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDocxLayoutVisualMatrixCases,
|
||||||
|
readBundledThemeMatrixDefinitions
|
||||||
|
} from "./docx-layout-visual-matrix-cases.mjs";
|
||||||
|
import { validateDocxRealWorldCorpus } from "./docx-real-world-corpus.mjs";
|
||||||
|
|
||||||
|
export const DOCX_RELEASE_GATE_CASE_COUNT = 140;
|
||||||
|
export const DOCX_RELEASE_GATE_SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocxReleaseGateSuites(repositoryDirectory) {
|
||||||
|
const validation = validateDocxRealWorldCorpus(repositoryDirectory);
|
||||||
|
assert(
|
||||||
|
validation.failures.length === 0,
|
||||||
|
`真实语料无效:${validation.failures.join(";")}`
|
||||||
|
);
|
||||||
|
const themes = readBundledThemeMatrixDefinitions(
|
||||||
|
path.join(repositoryDirectory, "themes")
|
||||||
|
);
|
||||||
|
const cases = createDocxLayoutVisualMatrixCases(themes);
|
||||||
|
assert(
|
||||||
|
cases.length === DOCX_RELEASE_GATE_CASE_COUNT,
|
||||||
|
`单套矩阵应为 ${DOCX_RELEASE_GATE_CASE_COUNT},实际 ${cases.length}`
|
||||||
|
);
|
||||||
|
const caseIds = cases.map((entry) => entry.id);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "synthetic-baseline-140",
|
||||||
|
label: "标准合成基线 140",
|
||||||
|
kind: "synthetic",
|
||||||
|
order: 0,
|
||||||
|
outputDirectoryName: "synthetic-baseline-140",
|
||||||
|
expectedCaseCount: caseIds.length,
|
||||||
|
caseIds
|
||||||
|
},
|
||||||
|
...validation.corpus.map((entry, index) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: `${entry.label}(${entry.byteLength} bytes)`,
|
||||||
|
kind: "corpus",
|
||||||
|
order: index + 1,
|
||||||
|
outputDirectoryName:
|
||||||
|
`corpus-${String(index + 1).padStart(2, "0")}-${entry.byteLength}-${entry.id}`,
|
||||||
|
expectedCaseCount: caseIds.length,
|
||||||
|
caseIds,
|
||||||
|
markdownPath: entry.markdownPath,
|
||||||
|
absoluteMarkdownPath: entry.absoluteMarkdownPath,
|
||||||
|
markdownByteLength: entry.byteLength,
|
||||||
|
requiredFeatures: entry.requiredFeatures
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuiteAggregatePassed(aggregate, suite) {
|
||||||
|
return Boolean(
|
||||||
|
aggregate &&
|
||||||
|
aggregate.suite?.id === suite.id &&
|
||||||
|
aggregate.execution?.expectedCaseCount === suite.expectedCaseCount &&
|
||||||
|
aggregate.execution?.completedCaseCount === suite.expectedCaseCount &&
|
||||||
|
aggregate.execution?.passedCaseCount === suite.expectedCaseCount &&
|
||||||
|
aggregate.gatePassed === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDocxReleaseGateSuite({
|
||||||
|
suites,
|
||||||
|
requestedId,
|
||||||
|
aggregates,
|
||||||
|
allowOutOfOrder = false
|
||||||
|
}) {
|
||||||
|
assert(Array.isArray(suites) && suites.length === 4, "发布门禁必须包含四套独立矩阵");
|
||||||
|
const target = !requestedId || requestedId === "next"
|
||||||
|
? suites.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite))
|
||||||
|
: suites.find((suite) => suite.id === requestedId);
|
||||||
|
if (!target) {
|
||||||
|
if (!requestedId || requestedId === "next") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw new Error(`未知发布门禁套件:${requestedId}`);
|
||||||
|
}
|
||||||
|
if (!allowOutOfOrder || !requestedId || requestedId === "next") {
|
||||||
|
const unmetPrerequisite = suites
|
||||||
|
.slice(0, target.order)
|
||||||
|
.find((suite) => !isSuiteAggregatePassed(aggregates.get(suite.id), suite));
|
||||||
|
assert(
|
||||||
|
!unmetPrerequisite,
|
||||||
|
`必须先通过前置套件 ${unmetPrerequisite?.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFingerprintFiles(repositoryDirectory, suite) {
|
||||||
|
const roots = [
|
||||||
|
"themes",
|
||||||
|
"apps/server/scripts",
|
||||||
|
"apps/server/src",
|
||||||
|
"packages/application/src",
|
||||||
|
"packages/core/src",
|
||||||
|
"packages/document-visual-diff/src",
|
||||||
|
"packages/docx-engine/assets",
|
||||||
|
"packages/docx-engine/src",
|
||||||
|
"packages/preview-engine/src",
|
||||||
|
"packages/renderer/src"
|
||||||
|
];
|
||||||
|
const files = [
|
||||||
|
"package-lock.json",
|
||||||
|
"scripts/docx-layout-visual-matrix-cases.mjs",
|
||||||
|
"scripts/docx-real-world-corpus.mjs",
|
||||||
|
"scripts/docx-release-gate-suites.mjs",
|
||||||
|
"scripts/verify-docx-layout-visual-matrix.mjs",
|
||||||
|
"scripts/verify-docx-release-gate-suite.mjs"
|
||||||
|
];
|
||||||
|
const allowedExtension = /\.(?:css|js|json|lua|mjs|ts)$/iu;
|
||||||
|
const visit = (relativeDirectory) => {
|
||||||
|
const absoluteDirectory = path.join(repositoryDirectory, relativeDirectory);
|
||||||
|
if (!fs.existsSync(absoluteDirectory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(absoluteDirectory, { withFileTypes: true })) {
|
||||||
|
const relativePath = path.join(relativeDirectory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
visit(relativePath);
|
||||||
|
} else if (allowedExtension.test(entry.name)) {
|
||||||
|
files.push(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const root of roots) {
|
||||||
|
visit(root);
|
||||||
|
}
|
||||||
|
if (suite.markdownPath) {
|
||||||
|
files.push(suite.markdownPath);
|
||||||
|
}
|
||||||
|
return [...new Set(files)].sort((left, right) => left.localeCompare(right, "en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocxReleaseGateFingerprint(repositoryDirectory, suite) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(`schema:${DOCX_RELEASE_GATE_SCHEMA_VERSION}\n`);
|
||||||
|
hash.update(`suite:${suite.id}\n`);
|
||||||
|
for (const relativePath of collectFingerprintFiles(repositoryDirectory, suite)) {
|
||||||
|
const absolutePath = path.join(repositoryDirectory, relativePath);
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
hash.update(`${relativePath}:missing\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hash.update(`${relativePath}\0`);
|
||||||
|
hash.update(fs.readFileSync(absolutePath));
|
||||||
|
hash.update("\0");
|
||||||
|
}
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsonIfExists(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
||||||
|
|
||||||
|
function sleepSync(milliseconds) {
|
||||||
|
Atomics.wait(
|
||||||
|
new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
milliseconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJsonAtomic(filePath, value, options = {}) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
const rename = options.rename ?? fs.renameSync;
|
||||||
|
const sleep = options.sleep ?? sleepSync;
|
||||||
|
const retryDeadline = Date.now() + (options.retryWindowMs ?? 3_000);
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
rename(temporaryPath, filePath);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!WINDOWS_RENAME_RETRY_CODES.has(error?.code) ||
|
||||||
|
Date.now() >= retryDeadline
|
||||||
|
) {
|
||||||
|
fs.rmSync(temporaryPath, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
attempt += 1;
|
||||||
|
sleep(Math.min(100, 10 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeSuiteProgress({ suite, fingerprint, records }) {
|
||||||
|
const currentRecords = suite.caseIds
|
||||||
|
.map((caseId) => records[caseId])
|
||||||
|
.filter((record) => record?.fingerprint === fingerprint);
|
||||||
|
const passedCaseCount = currentRecords.filter((record) => record.status === "passed").length;
|
||||||
|
const gateFailedCaseCount = currentRecords.filter((record) => record.status === "gate-failed").length;
|
||||||
|
const errorCaseCount = currentRecords.filter((record) => record.status === "error").length;
|
||||||
|
const completedCaseCount = currentRecords.length;
|
||||||
|
return {
|
||||||
|
expectedCaseCount: suite.expectedCaseCount,
|
||||||
|
completedCaseCount,
|
||||||
|
pendingCaseCount: suite.expectedCaseCount - completedCaseCount,
|
||||||
|
passedCaseCount,
|
||||||
|
gateFailedCaseCount,
|
||||||
|
errorCaseCount,
|
||||||
|
complete: completedCaseCount === suite.expectedCaseCount,
|
||||||
|
gatePassed:
|
||||||
|
passedCaseCount === suite.expectedCaseCount &&
|
||||||
|
gateFailedCaseCount === 0 &&
|
||||||
|
errorCaseCount === 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDocxReleaseGateSuites,
|
||||||
|
isSuiteAggregatePassed,
|
||||||
|
resolveDocxReleaseGateSuite,
|
||||||
|
summarizeSuiteProgress,
|
||||||
|
writeJsonAtomic
|
||||||
|
} from "./docx-release-gate-suites.mjs";
|
||||||
|
|
||||||
|
const repositoryDirectory = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
".."
|
||||||
|
);
|
||||||
|
|
||||||
|
function passedAggregate(suite) {
|
||||||
|
return {
|
||||||
|
suite: { id: suite.id },
|
||||||
|
execution: {
|
||||||
|
expectedCaseCount: 140,
|
||||||
|
completedCaseCount: 140,
|
||||||
|
passedCaseCount: 140
|
||||||
|
},
|
||||||
|
gatePassed: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("发布门禁拆分为四套独立 140 且真实文档按字节升序", () => {
|
||||||
|
const suites = createDocxReleaseGateSuites(repositoryDirectory);
|
||||||
|
assert.equal(suites.length, 4);
|
||||||
|
assert.equal(suites[0].id, "synthetic-baseline-140");
|
||||||
|
assert.ok(suites.every((suite) => suite.expectedCaseCount === 140));
|
||||||
|
assert.deepEqual(
|
||||||
|
suites.slice(1).map((suite) => suite.markdownByteLength),
|
||||||
|
suites.slice(1).map((suite) => suite.markdownByteLength).toSorted((a, b) => a - b)
|
||||||
|
);
|
||||||
|
assert.ok(suites.slice(1).every((suite) => suite.outputDirectoryName.includes(String(suite.markdownByteLength))));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("next 严格遵守合成基线和真实文档大小顺序", () => {
|
||||||
|
const suites = createDocxReleaseGateSuites(repositoryDirectory);
|
||||||
|
const aggregates = new Map();
|
||||||
|
assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }).id, suites[0].id);
|
||||||
|
aggregates.set(suites[0].id, passedAggregate(suites[0]));
|
||||||
|
assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }).id, suites[1].id);
|
||||||
|
assert.throws(
|
||||||
|
() => resolveDocxReleaseGateSuite({ suites, requestedId: suites[2].id, aggregates }),
|
||||||
|
/必须先通过前置套件/u
|
||||||
|
);
|
||||||
|
for (const suite of suites) {
|
||||||
|
aggregates.set(suite.id, passedAggregate(suite));
|
||||||
|
}
|
||||||
|
assert.equal(resolveDocxReleaseGateSuite({ suites, requestedId: "next", aggregates }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("只有显式指定套件时才允许受控越过前置门禁", () => {
|
||||||
|
const suites = createDocxReleaseGateSuites(repositoryDirectory);
|
||||||
|
const aggregates = new Map();
|
||||||
|
assert.equal(
|
||||||
|
resolveDocxReleaseGateSuite({
|
||||||
|
suites,
|
||||||
|
requestedId: suites[3].id,
|
||||||
|
aggregates,
|
||||||
|
allowOutOfOrder: true
|
||||||
|
}).id,
|
||||||
|
suites[3].id
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveDocxReleaseGateSuite({
|
||||||
|
suites,
|
||||||
|
requestedId: "next",
|
||||||
|
aggregates,
|
||||||
|
allowOutOfOrder: true
|
||||||
|
}).id,
|
||||||
|
suites[0].id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("套件进度独立统计通过、门禁失败和基础设施错误", () => {
|
||||||
|
const suite = createDocxReleaseGateSuites(repositoryDirectory)[0];
|
||||||
|
const records = {
|
||||||
|
[suite.caseIds[0]]: { fingerprint: "same", status: "passed" },
|
||||||
|
[suite.caseIds[1]]: { fingerprint: "same", status: "gate-failed" },
|
||||||
|
[suite.caseIds[2]]: { fingerprint: "same", status: "error" },
|
||||||
|
[suite.caseIds[3]]: { fingerprint: "old", status: "passed" }
|
||||||
|
};
|
||||||
|
const progress = summarizeSuiteProgress({ suite, fingerprint: "same", records });
|
||||||
|
assert.deepEqual(progress, {
|
||||||
|
expectedCaseCount: 140,
|
||||||
|
completedCaseCount: 3,
|
||||||
|
pendingCaseCount: 137,
|
||||||
|
passedCaseCount: 1,
|
||||||
|
gateFailedCaseCount: 1,
|
||||||
|
errorCaseCount: 1,
|
||||||
|
complete: false,
|
||||||
|
gatePassed: false
|
||||||
|
});
|
||||||
|
assert.equal(isSuiteAggregatePassed({ suite: { id: suite.id }, execution: progress, gatePassed: false }, suite), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Windows 瞬时文件锁不会中断原子进度写入", () => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "mdtp-gate-atomic-"));
|
||||||
|
const filePath = path.join(directory, "progress.json");
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
writeJsonAtomic(filePath, { completed: 9 }, {
|
||||||
|
rename(source, destination) {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts < 3) {
|
||||||
|
const error = new Error("temporary Windows lock");
|
||||||
|
error.code = "EPERM";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fs.renameSync(source, destination);
|
||||||
|
},
|
||||||
|
sleep() {}
|
||||||
|
});
|
||||||
|
assert.equal(attempts, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(fs.readFileSync(filePath, "utf8")),
|
||||||
|
{ completed: 9 }
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
fs.readdirSync(directory).filter((name) => name.endsWith(".tmp")),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { DOMParser } from "@xmldom/xmldom";
|
import { DOMParser } from "@xmldom/xmldom";
|
||||||
import { unzipSync } from "fflate";
|
import { unzipSync } from "fflate";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createPdfDocumentSnapshot,
|
createPdfDocumentSnapshot,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
createWordPdfAdapter,
|
createWordPdfAdapter,
|
||||||
createWpsPdfAdapter,
|
createWpsPdfAdapter,
|
||||||
getBlockingVisualDiffIssues,
|
getBlockingVisualDiffIssues,
|
||||||
|
isCrossEngineRasterEquivalent,
|
||||||
|
normalizePdfEditableText,
|
||||||
renderPdfVisualDiffHtml,
|
renderPdfVisualDiffHtml,
|
||||||
serializePdfVisualDiffJson
|
serializePdfVisualDiffJson
|
||||||
} from "../packages/document-visual-diff/dist/index.js";
|
} from "../packages/document-visual-diff/dist/index.js";
|
||||||
@@ -21,9 +24,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
createDocxLayoutVisualMatrixCases,
|
createDocxLayoutVisualMatrixCases,
|
||||||
getDocxFontGateFailures,
|
getDocxFontGateFailures,
|
||||||
|
isCorpusTextColorEquivalent,
|
||||||
readBundledThemeMatrixDefinitions,
|
readBundledThemeMatrixDefinitions,
|
||||||
|
resolveEffectiveCoverPageCount,
|
||||||
summarizeDocxLayoutVisualMatrix
|
summarizeDocxLayoutVisualMatrix
|
||||||
} from "./docx-layout-visual-matrix-cases.mjs";
|
} from "./docx-layout-visual-matrix-cases.mjs";
|
||||||
|
import {
|
||||||
|
DOCX_REAL_WORLD_CORPUS
|
||||||
|
} from "./docx-real-world-corpus.mjs";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const WORD_NAMESPACE =
|
const WORD_NAMESPACE =
|
||||||
@@ -32,10 +40,10 @@ const MATH_NAMESPACE =
|
|||||||
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
"http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
|
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
|
||||||
const outputDirectory = path.join(
|
const outputDirectory = path.resolve(
|
||||||
repositoryDirectory,
|
repositoryDirectory,
|
||||||
"output",
|
process.env.MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR?.trim() ||
|
||||||
"docx-layout-visual-matrix"
|
path.join("output", "docx-layout-visual-matrix")
|
||||||
);
|
);
|
||||||
const matrixScript = path.join(
|
const matrixScript = path.join(
|
||||||
repositoryDirectory,
|
repositoryDirectory,
|
||||||
@@ -64,6 +72,23 @@ const selectedOrientation =
|
|||||||
process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
|
process.env.MD_TO_PDF_LAYOUT_VISUAL_ORIENTATION?.trim() || undefined;
|
||||||
const selectedMarginScenarioId =
|
const selectedMarginScenarioId =
|
||||||
process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined;
|
process.env.MD_TO_PDF_LAYOUT_VISUAL_MARGIN?.trim() || undefined;
|
||||||
|
const expectInlineCodeProbes =
|
||||||
|
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
|
||||||
|
const selectedCorpusId =
|
||||||
|
process.env.MD_TO_PDF_CORPUS_ID?.trim() || undefined;
|
||||||
|
const corpusDefinition = selectedCorpusId
|
||||||
|
? DOCX_REAL_WORLD_CORPUS.find((entry) => entry.id === selectedCorpusId)
|
||||||
|
: undefined;
|
||||||
|
const corpusSourceMetadata = selectedScope === "corpus" &&
|
||||||
|
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()
|
||||||
|
? matter(fs.readFileSync(
|
||||||
|
path.resolve(
|
||||||
|
repositoryDirectory,
|
||||||
|
process.env.MD_TO_PDF_R4_MARKDOWN_PATH.trim()
|
||||||
|
),
|
||||||
|
"utf8"
|
||||||
|
)).data
|
||||||
|
: undefined;
|
||||||
const scopedCases = selectedCaseId
|
const scopedCases = selectedCaseId
|
||||||
? cases.filter((entry) => entry.id === selectedCaseId)
|
? cases.filter((entry) => entry.id === selectedCaseId)
|
||||||
: selectedScope === "cover"
|
: selectedScope === "cover"
|
||||||
@@ -185,6 +210,43 @@ function paragraphBlockKind(paragraph, styleId) {
|
|||||||
return "paragraph";
|
return "paragraph";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function directWordChildren(node, localName) {
|
||||||
|
return Array.from(node?.childNodes ?? []).filter(
|
||||||
|
(child) =>
|
||||||
|
child.nodeType === 1 &&
|
||||||
|
child.localName === localName &&
|
||||||
|
child.namespaceURI === WORD_NAMESPACE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestWordAncestor(node, localName) {
|
||||||
|
let current = node?.parentNode;
|
||||||
|
while (current) {
|
||||||
|
if (
|
||||||
|
current.localName === localName &&
|
||||||
|
current.namespaceURI === WORD_NAMESPACE
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphTableCoordinates(paragraph, tableIndexes) {
|
||||||
|
const cell = nearestWordAncestor(paragraph, "tc");
|
||||||
|
const row = nearestWordAncestor(paragraph, "tr");
|
||||||
|
const table = nearestWordAncestor(paragraph, "tbl");
|
||||||
|
if (!cell || !row || !table) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tableGroupId: `table-${tableIndexes.get(table)}`,
|
||||||
|
tableRowIndex: directWordChildren(table, "tr").indexOf(row),
|
||||||
|
tableColumnIndex: directWordChildren(row, "tc").indexOf(cell)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function extractEditableContract(docxPath) {
|
function extractEditableContract(docxPath) {
|
||||||
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
|
const entries = unzipSync(Uint8Array.from(fs.readFileSync(docxPath)));
|
||||||
const documentXml = entries["word/document.xml"];
|
const documentXml = entries["word/document.xml"];
|
||||||
@@ -193,13 +255,83 @@ function extractEditableContract(docxPath) {
|
|||||||
decoder.decode(documentXml),
|
decoder.decode(documentXml),
|
||||||
"application/xml"
|
"application/xml"
|
||||||
);
|
);
|
||||||
|
const tableIndexes = new Map(
|
||||||
|
Array.from(document.getElementsByTagName("*")).filter(
|
||||||
|
(node) => node.localName === "tbl" && node.namespaceURI === WORD_NAMESPACE
|
||||||
|
).map((table, index) => [table, index])
|
||||||
|
);
|
||||||
let section = "cover";
|
let section = "cover";
|
||||||
let hasCoverSection = false;
|
let hasCoverSection = false;
|
||||||
const paragraphs = [];
|
const paragraphs = [];
|
||||||
|
let inlineCodeParagraphCount = 0;
|
||||||
|
let inlineCodeExactLineRuleCount = 0;
|
||||||
|
let pureMathTableParagraphCount = 0;
|
||||||
|
let stabilizedPureMathTableParagraphCount = 0;
|
||||||
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
|
for (const paragraph of Array.from(document.getElementsByTagName("*")).filter(
|
||||||
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
|
(node) => node.localName === "p" && node.namespaceURI === WORD_NAMESPACE
|
||||||
)) {
|
)) {
|
||||||
const descendants = Array.from(paragraph.getElementsByTagName("*"));
|
const descendants = Array.from(paragraph.getElementsByTagName("*"));
|
||||||
|
const inlineCode = descendants.some(
|
||||||
|
(node) =>
|
||||||
|
node.localName === "rStyle" &&
|
||||||
|
node.namespaceURI === WORD_NAMESPACE &&
|
||||||
|
(node.getAttribute("w:val") || node.getAttribute("val")) ===
|
||||||
|
"VerbatimChar"
|
||||||
|
);
|
||||||
|
if (inlineCode) {
|
||||||
|
inlineCodeParagraphCount += 1;
|
||||||
|
if (
|
||||||
|
descendants.some(
|
||||||
|
(node) =>
|
||||||
|
node.localName === "spacing" &&
|
||||||
|
node.namespaceURI === WORD_NAMESPACE &&
|
||||||
|
(node.getAttribute("w:lineRule") ||
|
||||||
|
node.getAttribute("lineRule")) === "exact"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
inlineCodeExactLineRuleCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasAncestor(paragraph, "tc")) {
|
||||||
|
const directContent = Array.from(paragraph.childNodes).filter(
|
||||||
|
(node) =>
|
||||||
|
node.nodeType === 1 &&
|
||||||
|
!(
|
||||||
|
node.localName === "pPr" &&
|
||||||
|
node.namespaceURI === WORD_NAMESPACE
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const directMath = directContent.filter(
|
||||||
|
(node) =>
|
||||||
|
node.localName === "oMath" &&
|
||||||
|
node.namespaceURI === MATH_NAMESPACE
|
||||||
|
);
|
||||||
|
const stabilizers = directContent.filter(
|
||||||
|
(node) =>
|
||||||
|
node.localName === "r" &&
|
||||||
|
node.namespaceURI === WORD_NAMESPACE &&
|
||||||
|
Array.from(node.getElementsByTagName("*")).some(
|
||||||
|
(descendant) =>
|
||||||
|
descendant.localName === "noProof" &&
|
||||||
|
descendant.namespaceURI === WORD_NAMESPACE
|
||||||
|
) &&
|
||||||
|
Array.from(node.getElementsByTagName("*")).some(
|
||||||
|
(descendant) =>
|
||||||
|
descendant.localName === "t" &&
|
||||||
|
descendant.namespaceURI === WORD_NAMESPACE &&
|
||||||
|
descendant.textContent === "\u200B"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
directMath.length > 0 &&
|
||||||
|
directContent.length === directMath.length + stabilizers.length
|
||||||
|
) {
|
||||||
|
pureMathTableParagraphCount += 1;
|
||||||
|
if (stabilizers.length > 0) {
|
||||||
|
stabilizedPureMathTableParagraphCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const hasSectionBreak = descendants.some(
|
const hasSectionBreak = descendants.some(
|
||||||
(node) =>
|
(node) =>
|
||||||
node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
|
node.localName === "sectPr" && node.namespaceURI === WORD_NAMESPACE
|
||||||
@@ -209,20 +341,53 @@ function extractEditableContract(docxPath) {
|
|||||||
section = "body";
|
section = "body";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const text = descendants.flatMap((node) =>
|
const hardBreakSegments = [""];
|
||||||
node.localName === "t" &&
|
const mathCharacterIndexes = [];
|
||||||
(node.namespaceURI === WORD_NAMESPACE ||
|
let normalizedCharacterOffset = 0;
|
||||||
node.namespaceURI === MATH_NAMESPACE)
|
for (const node of descendants) {
|
||||||
? [node.textContent ?? ""]
|
if (
|
||||||
: []
|
node.localName === "t" &&
|
||||||
).join("");
|
(node.namespaceURI === WORD_NAMESPACE ||
|
||||||
|
node.namespaceURI === MATH_NAMESPACE)
|
||||||
|
) {
|
||||||
|
const nodeText = (node.textContent ?? "").replace(
|
||||||
|
/[\u200B\u2060\uFEFF]/gu,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
hardBreakSegments[hardBreakSegments.length - 1] += nodeText;
|
||||||
|
const normalizedNodeLength = Array.from(
|
||||||
|
normalizePdfEditableText(nodeText)
|
||||||
|
).length;
|
||||||
|
if (node.namespaceURI === MATH_NAMESPACE) {
|
||||||
|
for (let index = 0; index < normalizedNodeLength; index += 1) {
|
||||||
|
mathCharacterIndexes.push(normalizedCharacterOffset + index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizedCharacterOffset += normalizedNodeLength;
|
||||||
|
} else if (
|
||||||
|
node.localName === "br" &&
|
||||||
|
node.namespaceURI === WORD_NAMESPACE
|
||||||
|
) {
|
||||||
|
hardBreakSegments.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = hardBreakSegments.join("");
|
||||||
if (!text || isInternalLayoutSpacerParagraph(descendants, text)) {
|
if (!text || isInternalLayoutSpacerParagraph(descendants, text)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const styleId = paragraphStyleId(paragraph);
|
const styleId = paragraphStyleId(paragraph);
|
||||||
|
const tableCoordinates = paragraphTableCoordinates(paragraph, tableIndexes);
|
||||||
paragraphs.push({
|
paragraphs.push({
|
||||||
index: paragraphs.length,
|
index: paragraphs.length,
|
||||||
text,
|
text,
|
||||||
|
...(mathCharacterIndexes.length > 0
|
||||||
|
? { mathCharacterIndexes }
|
||||||
|
: {}),
|
||||||
|
...(hardBreakSegments.length > 1
|
||||||
|
? { hardBreakSegments }
|
||||||
|
: {}),
|
||||||
|
...(inlineCode ? { hasInlineCode: true } : {}),
|
||||||
|
...(tableCoordinates ?? {}),
|
||||||
...(styleId ? { styleId } : {}),
|
...(styleId ? { styleId } : {}),
|
||||||
role: paragraphRole(styleId),
|
role: paragraphRole(styleId),
|
||||||
blockKind: paragraphBlockKind(paragraph, styleId),
|
blockKind: paragraphBlockKind(paragraph, styleId),
|
||||||
@@ -236,7 +401,13 @@ function extractEditableContract(docxPath) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
text: paragraphs.map((paragraph) => paragraph.text).join(""),
|
text: paragraphs.map((paragraph) => paragraph.text).join(""),
|
||||||
paragraphs
|
paragraphs,
|
||||||
|
translationInvariants: {
|
||||||
|
inlineCodeParagraphCount,
|
||||||
|
inlineCodeExactLineRuleCount,
|
||||||
|
pureMathTableParagraphCount,
|
||||||
|
stabilizedPureMathTableParagraphCount
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +509,82 @@ function inspectInlineCodeContinuity(snapshot, label) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inspectCodeIndentation(snapshot, label, probe) {
|
||||||
|
if (!probe) {
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
applicable: false,
|
||||||
|
passed: true,
|
||||||
|
failures: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const page of snapshot.pages) {
|
||||||
|
const nestedIndex = page.lines.findIndex((line) =>
|
||||||
|
line.text.includes(probe.nestedLineIncludes)
|
||||||
|
);
|
||||||
|
if (nestedIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nested = page.lines[nestedIndex];
|
||||||
|
const root = page.lines
|
||||||
|
.slice(0, nestedIndex)
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(line) =>
|
||||||
|
line.text.trim() === probe.rootLine &&
|
||||||
|
nested.baselineY - line.baselineY <= 30
|
||||||
|
);
|
||||||
|
if (!root) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const indentPt = nested.bounds.x - root.bounds.x;
|
||||||
|
const passed = indentPt >= probe.minimumIndentPt;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
applicable: true,
|
||||||
|
passed,
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
rootText: root.text,
|
||||||
|
nestedText: nested.text,
|
||||||
|
rootXPt: root.bounds.x,
|
||||||
|
nestedXPt: nested.bounds.x,
|
||||||
|
indentPt,
|
||||||
|
failures: passed
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
`${label}: CODE_INDENTATION_MISMATCH - JSON 嵌套行缩进 ${indentPt.toFixed(2)}pt,小于 ${probe.minimumIndentPt}pt`
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
applicable: true,
|
||||||
|
passed: false,
|
||||||
|
failures: [
|
||||||
|
`${label}: CODE_INDENTATION_PROBE_UNAVAILABLE - 未定位 JSON 根行与嵌套行`
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docxTranslationInvariantFailures(invariants) {
|
||||||
|
const failures = [];
|
||||||
|
if (invariants.inlineCodeExactLineRuleCount > 0) {
|
||||||
|
failures.push(
|
||||||
|
`DOCX: INLINE_CODE_EXACT_LINE_BOX - ${invariants.inlineCodeExactLineRuleCount} 个行内代码段落仍使用 exact 行盒`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
invariants.pureMathTableParagraphCount !==
|
||||||
|
invariants.stabilizedPureMathTableParagraphCount
|
||||||
|
) {
|
||||||
|
failures.push(
|
||||||
|
"DOCX: TABLE_MATH_ALIGNMENT_UNSTABLE - " +
|
||||||
|
`${invariants.pureMathTableParagraphCount - invariants.stabilizedPureMathTableParagraphCount} 个纯公式表格段落缺少对齐稳定结构`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPageSemantics(pageCount, coverPageCount, config) {
|
function buildPageSemantics(pageCount, coverPageCount, config) {
|
||||||
assert(
|
assert(
|
||||||
pageCount > coverPageCount,
|
pageCount > coverPageCount,
|
||||||
@@ -423,6 +670,108 @@ function reportCoverGateFailures(report, label) {
|
|||||||
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
|
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metricRatio(first, second) {
|
||||||
|
const maximum = Math.max(first, second);
|
||||||
|
return maximum > 0 ? Math.min(first, second) / maximum : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA = 55;
|
||||||
|
|
||||||
|
function reportCorpusGateFailures(
|
||||||
|
report,
|
||||||
|
label,
|
||||||
|
coverPageCount
|
||||||
|
) {
|
||||||
|
const failures = [];
|
||||||
|
if ((report.basic.candidateEditableSimilarity ?? 0) < 0.995) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: EDITABLE_CONTENT_SIMILARITY - Office 可编辑正文相似度 ` +
|
||||||
|
`${((report.basic.candidateEditableSimilarity ?? 0) * 100).toFixed(3)}% 低于 99.5%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const strictKinds = new Set([
|
||||||
|
"heading",
|
||||||
|
"title",
|
||||||
|
"caption",
|
||||||
|
"table-header",
|
||||||
|
"code-block"
|
||||||
|
]);
|
||||||
|
for (const comparison of report.basic.semanticBlockVisuals ?? []) {
|
||||||
|
const index = comparison.expectation.index + 1;
|
||||||
|
const visualUnavailable =
|
||||||
|
comparison.status === "unavailable" ||
|
||||||
|
comparison.lines.length === 0 ||
|
||||||
|
comparison.issues?.some(
|
||||||
|
(issue) => issue.code === "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
|
||||||
|
);
|
||||||
|
if (visualUnavailable) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: SEMANTIC_BLOCK_VISUAL_UNAVAILABLE - 第 ${index} 个元素块无法建立视觉观测`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const line of comparison.lines) {
|
||||||
|
const metrics = line.metrics;
|
||||||
|
if (!metrics) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const heightRatio = metricRatio(
|
||||||
|
metrics.baselineHeightPx,
|
||||||
|
metrics.candidateHeightPx
|
||||||
|
);
|
||||||
|
if (heightRatio < 0.78) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: ELEMENT_LINE_BOX_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行高度比例 ${heightRatio.toFixed(3)} 低于 0.78`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const widthRatio = metricRatio(
|
||||||
|
metrics.baselineWidthPx,
|
||||||
|
metrics.candidateWidthPx
|
||||||
|
);
|
||||||
|
if (comparison.lines.length === 1 && widthRatio < 0.85) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: ELEMENT_WIDTH_MISMATCH - 第 ${index} 个单行元素块宽度比例 ${widthRatio.toFixed(3)} 低于 0.85`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const minimumIou = strictKinds.has(
|
||||||
|
comparison.expectation.blockKind
|
||||||
|
) ? 0.45 : 0.3;
|
||||||
|
if (
|
||||||
|
comparison.lines.length === 1 &&
|
||||||
|
metrics.inkIou < minimumIou &&
|
||||||
|
metrics.edgeIou < minimumIou
|
||||||
|
) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: ELEMENT_RASTER_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行墨迹与边缘 IoU 均低于 ${minimumIou}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const textReflow = line.issues?.some(
|
||||||
|
(issue) => issue.details?.textReflow === true
|
||||||
|
);
|
||||||
|
const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
|
||||||
|
metrics,
|
||||||
|
textReflow,
|
||||||
|
comparison.expectation.blockKind,
|
||||||
|
comparison.expectation.hasInlineCode === true
|
||||||
|
);
|
||||||
|
if (!textReflow &&
|
||||||
|
!crossEngineRasterEquivalent &&
|
||||||
|
!isCorpusTextColorEquivalent(metrics) && (
|
||||||
|
metrics.backgroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA ||
|
||||||
|
metrics.foregroundColorDelta > MAXIMUM_CORPUS_ELEMENT_COLOR_DELTA
|
||||||
|
)) {
|
||||||
|
failures.push(
|
||||||
|
`${label}: ELEMENT_COLOR_MISMATCH - 第 ${index} 个元素块第 ${line.lineIndex + 1} 行颜色差异超限`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (coverPageCount > 0) {
|
||||||
|
failures.push(...reportCoverGateFailures(report, label));
|
||||||
|
}
|
||||||
|
return [...new Set(failures)];
|
||||||
|
}
|
||||||
|
|
||||||
function reportSummary(report) {
|
function reportSummary(report) {
|
||||||
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
|
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
|
||||||
(paragraph) => paragraph.issues
|
(paragraph) => paragraph.issues
|
||||||
@@ -599,9 +948,18 @@ assert(
|
|||||||
`视觉矩阵用例不存在:${selectedCaseId}`
|
`视觉矩阵用例不存在:${selectedCaseId}`
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
selectedScope === "full" || selectedScope === "cover",
|
["full", "cover", "corpus"].includes(selectedScope),
|
||||||
`不支持的视觉矩阵作用域:${selectedScope}`
|
`不支持的视觉矩阵作用域:${selectedScope}`
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
selectedScope !== "corpus" || corpusDefinition,
|
||||||
|
`真实语料门禁缺少有效 MD_TO_PDF_CORPUS_ID:${selectedCorpusId}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
selectedScope !== "corpus" ||
|
||||||
|
Boolean(process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim()),
|
||||||
|
"真实语料门禁必须显式提供 MD_TO_PDF_R4_MARKDOWN_PATH"
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
!selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
|
!selectedOrientation || ["portrait", "landscape"].includes(selectedOrientation),
|
||||||
`不支持的视觉矩阵方向:${selectedOrientation}`
|
`不支持的视觉矩阵方向:${selectedOrientation}`
|
||||||
@@ -632,6 +990,11 @@ for (const caseDefinition of selectedCases) {
|
|||||||
);
|
);
|
||||||
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
|
const docxPath = path.resolve(repositoryDirectory, result.docx.outputFile);
|
||||||
const editable = extractEditableContract(docxPath);
|
const editable = extractEditableContract(docxPath);
|
||||||
|
const effectiveCoverPageCount = resolveEffectiveCoverPageCount({
|
||||||
|
caseDefinition,
|
||||||
|
scope: selectedScope,
|
||||||
|
sourceMetadata: corpusSourceMetadata
|
||||||
|
});
|
||||||
const wordGeneration = await wordAdapter.generate({ docxPath });
|
const wordGeneration = await wordAdapter.generate({ docxPath });
|
||||||
const wpsGeneration = await wpsAdapter.generate({ docxPath });
|
const wpsGeneration = await wpsAdapter.generate({ docxPath });
|
||||||
const wordPdfPath = path.join(caseDirectory, "word.pdf");
|
const wordPdfPath = path.join(caseDirectory, "word.pdf");
|
||||||
@@ -668,17 +1031,17 @@ for (const caseDefinition of selectedCases) {
|
|||||||
});
|
});
|
||||||
const chromiumPageSemantics = buildPageSemantics(
|
const chromiumPageSemantics = buildPageSemantics(
|
||||||
chromium.pageCount,
|
chromium.pageCount,
|
||||||
caseDefinition.coverPageCount,
|
effectiveCoverPageCount,
|
||||||
result.exportConfig
|
result.exportConfig
|
||||||
);
|
);
|
||||||
const wordPageSemantics = buildPageSemantics(
|
const wordPageSemantics = buildPageSemantics(
|
||||||
word.pageCount,
|
word.pageCount,
|
||||||
caseDefinition.coverPageCount,
|
effectiveCoverPageCount,
|
||||||
result.exportConfig
|
result.exportConfig
|
||||||
);
|
);
|
||||||
const wpsPageSemantics = buildPageSemantics(
|
const wpsPageSemantics = buildPageSemantics(
|
||||||
wps.pageCount,
|
wps.pageCount,
|
||||||
caseDefinition.coverPageCount,
|
effectiveCoverPageCount,
|
||||||
result.exportConfig
|
result.exportConfig
|
||||||
);
|
);
|
||||||
const reportOptions = {
|
const reportOptions = {
|
||||||
@@ -715,6 +1078,23 @@ for (const caseDefinition of selectedCases) {
|
|||||||
word: inspectInlineCodeContinuity(word, "Microsoft Word"),
|
word: inspectInlineCodeContinuity(word, "Microsoft Word"),
|
||||||
wps: inspectInlineCodeContinuity(wps, "WPS Writer")
|
wps: inspectInlineCodeContinuity(wps, "WPS Writer")
|
||||||
};
|
};
|
||||||
|
const codeIndentation = {
|
||||||
|
chromium: inspectCodeIndentation(
|
||||||
|
chromium,
|
||||||
|
"Chromium",
|
||||||
|
corpusDefinition?.codeIndentationProbe
|
||||||
|
),
|
||||||
|
word: inspectCodeIndentation(
|
||||||
|
word,
|
||||||
|
"Microsoft Word",
|
||||||
|
corpusDefinition?.codeIndentationProbe
|
||||||
|
),
|
||||||
|
wps: inspectCodeIndentation(
|
||||||
|
wps,
|
||||||
|
"WPS Writer",
|
||||||
|
corpusDefinition?.codeIndentationProbe
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
const rasterDirectory = path.join(caseDirectory, "raster-pages");
|
const rasterDirectory = path.join(caseDirectory, "raster-pages");
|
||||||
fs.rmSync(rasterDirectory, { recursive: true, force: true });
|
fs.rmSync(rasterDirectory, { recursive: true, force: true });
|
||||||
@@ -742,9 +1122,13 @@ for (const caseDefinition of selectedCases) {
|
|||||||
renderedFonts: wps.fonts,
|
renderedFonts: wps.fonts,
|
||||||
engine: "wps"
|
engine: "wps"
|
||||||
}),
|
}),
|
||||||
...inlineCodeContinuity.chromium.failures,
|
...(expectInlineCodeProbes
|
||||||
...inlineCodeContinuity.word.failures,
|
? [
|
||||||
...inlineCodeContinuity.wps.failures,
|
...inlineCodeContinuity.chromium.failures,
|
||||||
|
...inlineCodeContinuity.word.failures,
|
||||||
|
...inlineCodeContinuity.wps.failures
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...reportGateFailures(wordReport, "Chromium/Word"),
|
...reportGateFailures(wordReport, "Chromium/Word"),
|
||||||
...reportGateFailures(wpsReport, "Chromium/WPS"),
|
...reportGateFailures(wpsReport, "Chromium/WPS"),
|
||||||
...reportGateFailures(officeReport, "Word/WPS")
|
...reportGateFailures(officeReport, "Word/WPS")
|
||||||
@@ -755,7 +1139,46 @@ for (const caseDefinition of selectedCases) {
|
|||||||
...reportCoverGateFailures(wpsReport, "Chromium/WPS"),
|
...reportCoverGateFailures(wpsReport, "Chromium/WPS"),
|
||||||
...reportCoverGateFailures(officeReport, "Word/WPS")
|
...reportCoverGateFailures(officeReport, "Word/WPS")
|
||||||
])]
|
])]
|
||||||
: allCurrentFailures;
|
: selectedScope === "corpus"
|
||||||
|
? [...new Set([
|
||||||
|
...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
|
||||||
|
...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
|
||||||
|
...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
|
||||||
|
...getDocxFontGateFailures({
|
||||||
|
theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId),
|
||||||
|
inspection: result.docx.inspection,
|
||||||
|
renderedFonts: word.fonts,
|
||||||
|
engine: "word"
|
||||||
|
}),
|
||||||
|
...getDocxFontGateFailures({
|
||||||
|
theme: themeDefinitions.find((theme) => theme.id === caseDefinition.themeId),
|
||||||
|
inspection: result.docx.inspection,
|
||||||
|
renderedFonts: wps.fonts,
|
||||||
|
engine: "wps"
|
||||||
|
}),
|
||||||
|
...docxTranslationInvariantFailures(
|
||||||
|
editable.translationInvariants
|
||||||
|
),
|
||||||
|
...codeIndentation.chromium.failures,
|
||||||
|
...codeIndentation.word.failures,
|
||||||
|
...codeIndentation.wps.failures,
|
||||||
|
...reportCorpusGateFailures(
|
||||||
|
wordReport,
|
||||||
|
"Chromium/Word",
|
||||||
|
effectiveCoverPageCount
|
||||||
|
),
|
||||||
|
...reportCorpusGateFailures(
|
||||||
|
wpsReport,
|
||||||
|
"Chromium/WPS",
|
||||||
|
effectiveCoverPageCount
|
||||||
|
),
|
||||||
|
...reportCorpusGateFailures(
|
||||||
|
officeReport,
|
||||||
|
"Word/WPS",
|
||||||
|
effectiveCoverPageCount
|
||||||
|
)
|
||||||
|
])]
|
||||||
|
: allCurrentFailures;
|
||||||
gateFailures.push(
|
gateFailures.push(
|
||||||
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
|
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
|
||||||
);
|
);
|
||||||
@@ -772,7 +1195,7 @@ for (const caseDefinition of selectedCases) {
|
|||||||
themeDefaultMargins: caseDefinition.themeDefaultMargins,
|
themeDefaultMargins: caseDefinition.themeDefaultMargins,
|
||||||
paper: caseDefinition.paper,
|
paper: caseDefinition.paper,
|
||||||
exportConfig: result.exportConfig,
|
exportConfig: result.exportConfig,
|
||||||
coverPageCount: caseDefinition.coverPageCount,
|
coverPageCount: effectiveCoverPageCount,
|
||||||
pages: {
|
pages: {
|
||||||
chromium: chromium.pageCount,
|
chromium: chromium.pageCount,
|
||||||
word: word.pageCount,
|
word: word.pageCount,
|
||||||
@@ -787,7 +1210,9 @@ for (const caseDefinition of selectedCases) {
|
|||||||
wps: wps.fonts
|
wps: wps.fonts
|
||||||
},
|
},
|
||||||
editableParagraphCount: editable.paragraphs.length,
|
editableParagraphCount: editable.paragraphs.length,
|
||||||
|
translationInvariants: editable.translationInvariants,
|
||||||
inlineCodeContinuity,
|
inlineCodeContinuity,
|
||||||
|
codeIndentation,
|
||||||
reports: {
|
reports: {
|
||||||
word: reportSummary(wordReport),
|
word: reportSummary(wordReport),
|
||||||
wps: reportSummary(wpsReport),
|
wps: reportSummary(wpsReport),
|
||||||
@@ -811,6 +1236,14 @@ const summary = {
|
|||||||
matrixDefinition,
|
matrixDefinition,
|
||||||
selectedCaseId,
|
selectedCaseId,
|
||||||
selectedScope,
|
selectedScope,
|
||||||
|
corpus: corpusDefinition
|
||||||
|
? {
|
||||||
|
id: corpusDefinition.id,
|
||||||
|
label: corpusDefinition.label,
|
||||||
|
markdownPath: corpusDefinition.markdownPath,
|
||||||
|
requiredFeatures: corpusDefinition.requiredFeatures
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
selectedOrientation,
|
selectedOrientation,
|
||||||
selectedMarginScenarioId,
|
selectedMarginScenarioId,
|
||||||
execution: {
|
execution: {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
process.stderr.write(
|
||||||
|
"verify-docx-release-gate-560 已改为兼容入口:每次只执行下一套独立 140,不再启动整批 560。\n"
|
||||||
|
);
|
||||||
|
await import("./verify-docx-release-gate-suite.mjs");
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDocxReleaseGateFingerprint,
|
||||||
|
createDocxReleaseGateSuites,
|
||||||
|
DOCX_RELEASE_GATE_SCHEMA_VERSION,
|
||||||
|
readJsonIfExists,
|
||||||
|
resolveDocxReleaseGateSuite,
|
||||||
|
summarizeSuiteProgress,
|
||||||
|
writeJsonAtomic
|
||||||
|
} from "./docx-release-gate-suites.mjs";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const repositoryDirectory = path.resolve(import.meta.dirname, "..");
|
||||||
|
const matrixScript = path.join(repositoryDirectory, "scripts", "verify-docx-layout-visual-matrix.mjs");
|
||||||
|
const outputRoot = path.resolve(
|
||||||
|
repositoryDirectory,
|
||||||
|
process.env.MD_TO_PDF_RELEASE_GATE_OUTPUT_DIR?.trim() ||
|
||||||
|
"output/docx-release-gate-v0.6.2"
|
||||||
|
);
|
||||||
|
const requestedSuiteId = process.env.MD_TO_PDF_RELEASE_GATE_SUITE?.trim() || "next";
|
||||||
|
const selectedCaseId = process.env.MD_TO_PDF_RELEASE_GATE_CASE?.trim() || undefined;
|
||||||
|
const allowOutOfOrder =
|
||||||
|
process.env.MD_TO_PDF_RELEASE_GATE_ALLOW_OUT_OF_ORDER?.trim() === "1";
|
||||||
|
const requestedCaseLimitText =
|
||||||
|
process.env.MD_TO_PDF_RELEASE_GATE_CASE_LIMIT?.trim() || undefined;
|
||||||
|
const requestedCaseLimit = requestedCaseLimitText
|
||||||
|
? Number.parseInt(requestedCaseLimitText, 10)
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
requestedCaseLimitText &&
|
||||||
|
(!Number.isInteger(requestedCaseLimit) || requestedCaseLimit <= 0)
|
||||||
|
) {
|
||||||
|
throw new Error("MD_TO_PDF_RELEASE_GATE_CASE_LIMIT 必须是正整数");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAggregates(suites) {
|
||||||
|
return new Map(suites.map((suite) => {
|
||||||
|
const aggregate = readJsonIfExists(
|
||||||
|
path.join(outputRoot, suite.outputDirectoryName, "aggregate.json")
|
||||||
|
);
|
||||||
|
const currentFingerprint = createDocxReleaseGateFingerprint(
|
||||||
|
repositoryDirectory,
|
||||||
|
suite
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
suite.id,
|
||||||
|
aggregate?.fingerprint === currentFingerprint ? aggregate : undefined
|
||||||
|
];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuiteHtml(aggregate) {
|
||||||
|
const rows = aggregate.cases.map((entry) =>
|
||||||
|
`<tr><td>${escapeHtml(entry.caseId)}</td><td class="${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</td><td>${escapeHtml(entry.finishedAt || "")}</td><td>${entry.summaryPath ? `<a href="${escapeHtml(entry.summaryPath)}">JSON</a>` : "-"}</td><td>${escapeHtml(entry.error || entry.gateFailures?.join(";") || "")}</td></tr>`
|
||||||
|
).join("");
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(aggregate.suite.label)}</title><style>body{font-family:Inter,"Microsoft YaHei",sans-serif;margin:32px;color:#172033}table{border-collapse:collapse;width:100%}th,td{border:1px solid #d0d5dd;padding:8px;text-align:left;vertical-align:top}.passed{background:#e6f7ec}.gate-failed,.error{background:#ffe4e4}</style></head><body><h1>${escapeHtml(aggregate.suite.label)}</h1><p>${aggregate.execution.completedCaseCount}/${aggregate.execution.expectedCaseCount};通过 ${aggregate.execution.passedCaseCount};门禁失败 ${aggregate.execution.gateFailedCaseCount};基础设施错误 ${aggregate.execution.errorCaseCount}。</p><table><thead><tr><th>场景</th><th>状态</th><th>完成时间</th><th>报告</th><th>问题</th></tr></thead><tbody>${rows}</tbody></table></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAggregate({ suite, fingerprint, records }) {
|
||||||
|
const execution = summarizeSuiteProgress({ suite, fingerprint, records });
|
||||||
|
return {
|
||||||
|
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
fingerprint,
|
||||||
|
suite: {
|
||||||
|
id: suite.id,
|
||||||
|
label: suite.label,
|
||||||
|
kind: suite.kind,
|
||||||
|
order: suite.order,
|
||||||
|
markdownPath: suite.markdownPath,
|
||||||
|
markdownByteLength: suite.markdownByteLength
|
||||||
|
},
|
||||||
|
execution,
|
||||||
|
gatePassed: execution.gatePassed,
|
||||||
|
cases: suite.caseIds.map((caseId) => records[caseId])
|
||||||
|
.filter((record) => record?.fingerprint === fingerprint)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSuiteReports({ suiteDirectory, suite, fingerprint, records }) {
|
||||||
|
const aggregate = buildAggregate({ suite, fingerprint, records });
|
||||||
|
writeJsonAtomic(path.join(suiteDirectory, "progress.json"), {
|
||||||
|
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
|
||||||
|
updatedAt: aggregate.generatedAt,
|
||||||
|
fingerprint,
|
||||||
|
records
|
||||||
|
});
|
||||||
|
writeJsonAtomic(path.join(suiteDirectory, "aggregate.json"), aggregate);
|
||||||
|
fs.writeFileSync(path.join(suiteDirectory, "aggregate.html"), renderSuiteHtml(aggregate), "utf8");
|
||||||
|
return aggregate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTopLevelIndex(suites) {
|
||||||
|
const entries = suites.map((suite) => ({
|
||||||
|
suite: {
|
||||||
|
id: suite.id,
|
||||||
|
label: suite.label,
|
||||||
|
kind: suite.kind,
|
||||||
|
order: suite.order,
|
||||||
|
outputDirectoryName: suite.outputDirectoryName,
|
||||||
|
markdownByteLength: suite.markdownByteLength
|
||||||
|
},
|
||||||
|
aggregate: readJsonIfExists(path.join(outputRoot, suite.outputDirectoryName, "aggregate.json"))
|
||||||
|
}));
|
||||||
|
const index = {
|
||||||
|
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
strategy: "four-independent-140-suites",
|
||||||
|
totalExpectedCaseCount: 560,
|
||||||
|
entries
|
||||||
|
};
|
||||||
|
writeJsonAtomic(path.join(outputRoot, "index.json"), index);
|
||||||
|
const rows = entries.map(({ suite, aggregate }) =>
|
||||||
|
`<tr><td>${suite.order + 1}</td><td>${escapeHtml(suite.label)}</td><td>${aggregate?.execution?.completedCaseCount ?? 0}/140</td><td class="${aggregate?.gatePassed ? "passed" : "pending"}">${aggregate?.gatePassed ? "通过" : aggregate ? "未通过/未完成" : "未开始"}</td><td><a href="${escapeHtml(suite.outputDirectoryName)}/aggregate.html">报告</a></td></tr>`
|
||||||
|
).join("");
|
||||||
|
fs.writeFileSync(path.join(outputRoot, "index.html"), `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>DOCX 4×140 发布门禁</title><style>body{font-family:Inter,"Microsoft YaHei",sans-serif;margin:32px;color:#172033}table{border-collapse:collapse;min-width:900px}th,td{border:1px solid #d0d5dd;padding:10px;text-align:left}.passed{background:#e6f7ec}.pending{background:#fff4cc}</style></head><body><h1>DOCX 4×140 独立发布门禁</h1><p>先通过标准合成基线,再按 Markdown 字节数从小到大逐套执行。</p><table><thead><tr><th>顺序</th><th>套件</th><th>进度</th><th>状态</th><th>独立报告</th></tr></thead><tbody>${rows}</tbody></table></body></html>`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChildSummary(stdout) {
|
||||||
|
const text = Buffer.isBuffer(stdout) ? stdout.toString("utf8") : String(stdout || "");
|
||||||
|
return JSON.parse(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(outputRoot, { recursive: true });
|
||||||
|
const suites = createDocxReleaseGateSuites(repositoryDirectory);
|
||||||
|
const suite = resolveDocxReleaseGateSuite({
|
||||||
|
suites,
|
||||||
|
requestedId: requestedSuiteId,
|
||||||
|
aggregates: readAggregates(suites),
|
||||||
|
allowOutOfOrder
|
||||||
|
});
|
||||||
|
if (!suite) {
|
||||||
|
writeTopLevelIndex(suites);
|
||||||
|
process.stdout.write("四套独立 140 门禁均已通过,无需重复执行。\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (selectedCaseId && !suite.caseIds.includes(selectedCaseId)) {
|
||||||
|
throw new Error(`套件 ${suite.id} 不包含场景 ${selectedCaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suiteDirectory = path.join(outputRoot, suite.outputDirectoryName);
|
||||||
|
const logsDirectory = path.join(suiteDirectory, "logs");
|
||||||
|
fs.mkdirSync(logsDirectory, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(suiteDirectory, "night-run.pid"),
|
||||||
|
`${process.pid}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const fingerprint = createDocxReleaseGateFingerprint(repositoryDirectory, suite);
|
||||||
|
const previousProgress = readJsonIfExists(path.join(suiteDirectory, "progress.json"));
|
||||||
|
const records = previousProgress?.records && typeof previousProgress.records === "object"
|
||||||
|
? previousProgress.records
|
||||||
|
: {};
|
||||||
|
const runStartedAt = new Date().toISOString();
|
||||||
|
const runPath = path.join(suiteDirectory, "run.json");
|
||||||
|
writeJsonAtomic(runPath, {
|
||||||
|
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
|
||||||
|
pid: process.pid,
|
||||||
|
processStartedAt: runStartedAt,
|
||||||
|
command: process.argv,
|
||||||
|
suiteId: suite.id,
|
||||||
|
suiteFingerprint: fingerprint,
|
||||||
|
allowOutOfOrder,
|
||||||
|
...(requestedCaseLimit ? { requestedCaseLimit } : {}),
|
||||||
|
status: "running"
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestedCaseIds = selectedCaseId
|
||||||
|
? [selectedCaseId]
|
||||||
|
: requestedCaseLimit
|
||||||
|
? suite.caseIds.slice(0, requestedCaseLimit)
|
||||||
|
: suite.caseIds;
|
||||||
|
for (const caseId of requestedCaseIds) {
|
||||||
|
if (records[caseId]?.fingerprint === fingerprint && records[caseId]?.status === "passed") {
|
||||||
|
process.stderr.write(`[DOCX gate ${suite.id}] ${caseId} 已通过,跳过\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.stderr.write(`[DOCX gate ${suite.id}] 执行 ${caseId}\n`);
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
MD_TO_PDF_LAYOUT_VISUAL_CASE: caseId,
|
||||||
|
MD_TO_PDF_LAYOUT_VISUAL_OUTPUT_DIR: suiteDirectory,
|
||||||
|
MD_TO_PDF_LAYOUT_VISUAL_SCOPE: suite.kind === "synthetic" ? "full" : "corpus",
|
||||||
|
MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES: suite.kind === "synthetic" ? "1" : "0"
|
||||||
|
};
|
||||||
|
if (suite.kind === "corpus") {
|
||||||
|
environment.MD_TO_PDF_CORPUS_ID = suite.id;
|
||||||
|
environment.MD_TO_PDF_R4_MARKDOWN_PATH = suite.markdownPath;
|
||||||
|
} else {
|
||||||
|
delete environment.MD_TO_PDF_CORPUS_ID;
|
||||||
|
delete environment.MD_TO_PDF_R4_MARKDOWN_PATH;
|
||||||
|
}
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
let record;
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync(process.execPath, [matrixScript], {
|
||||||
|
cwd: repositoryDirectory,
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 24 * 60 * 60 * 1000,
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
env: environment
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8");
|
||||||
|
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr, "utf8");
|
||||||
|
const summary = parseChildSummary(stdout);
|
||||||
|
record = {
|
||||||
|
caseId,
|
||||||
|
fingerprint,
|
||||||
|
status: summary.gatePassed ? "passed" : "gate-failed",
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
gateFailures: summary.gateFailures ?? [],
|
||||||
|
summaryPath: `summary-${caseId}.json`,
|
||||||
|
matrixPath: `matrix-${caseId}.html`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const stdout = Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : String(error?.stdout || "");
|
||||||
|
const stderr = Buffer.isBuffer(error?.stderr) ? error.stderr.toString("utf8") : String(error?.stderr || "");
|
||||||
|
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stdout.log`), stdout, "utf8");
|
||||||
|
fs.writeFileSync(path.join(logsDirectory, `${caseId}.stderr.log`), stderr || String(error?.stack || error), "utf8");
|
||||||
|
try {
|
||||||
|
const summary = parseChildSummary(stdout);
|
||||||
|
record = {
|
||||||
|
caseId,
|
||||||
|
fingerprint,
|
||||||
|
status: "gate-failed",
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
gateFailures: summary.gateFailures ?? [String(error?.message || error)],
|
||||||
|
summaryPath: `summary-${caseId}.json`,
|
||||||
|
matrixPath: `matrix-${caseId}.html`
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
record = {
|
||||||
|
caseId,
|
||||||
|
fingerprint,
|
||||||
|
status: "error",
|
||||||
|
startedAt,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
error: String(error?.message || error)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records[caseId] = record;
|
||||||
|
writeSuiteReports({ suiteDirectory, suite, fingerprint, records });
|
||||||
|
writeTopLevelIndex(suites);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = writeSuiteReports({ suiteDirectory, suite, fingerprint, records });
|
||||||
|
writeTopLevelIndex(suites);
|
||||||
|
writeJsonAtomic(runPath, {
|
||||||
|
schemaVersion: DOCX_RELEASE_GATE_SCHEMA_VERSION,
|
||||||
|
pid: process.pid,
|
||||||
|
processStartedAt: runStartedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
command: process.argv,
|
||||||
|
suiteId: suite.id,
|
||||||
|
suiteFingerprint: fingerprint,
|
||||||
|
status: aggregate.gatePassed ? "passed" : "completed-with-failures",
|
||||||
|
execution: aggregate.execution
|
||||||
|
});
|
||||||
|
process.stdout.write(`${JSON.stringify(aggregate, null, 2)}\n`);
|
||||||
|
if (!selectedCaseId && !aggregate.gatePassed) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user