45 Commits
Author SHA1 Message Date
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00
SkyJourney 01b06abc2c release: 发布字体直接内置版 v0.6.1
新增能力:14 套主题按纵横方向与五组页边距组成的 140 场景视觉矩阵全部通过,封面执行整页严格门禁,正文执行分页无关的语义块门禁。

问题修复:统一 CSS 到 WordprocessingML 的通用翻译,修复字体嵌入、中文标点字距、精确行距、打印媒体与行内代码连续性,不包含按主题 ID 的特调。

发行变化:Desktop 安装器与免安装 ZIP 直接内置 Serif、Sans、Mono 三套字体包;NSIS 移除字体选择和伴随安装逻辑,不再发布独立字体安装器或字体 ZIP。正式附件收敛为 Desktop 安装器、Desktop ZIP、Docker 离线镜像、Web 部署包和发行说明。

部署兼容:Docker 镜像 md-to-pdf:v0.6.1 使用完整无缓存路径构建,固定 Chromium、Pandoc 3.9.0.2 和三套内置字体包;支持覆盖安装 v0.6.0,Windows 安装器保持未签名的内部发布状态。

验证结果:116 个测试文件、616 项测试、全项目类型检查、生产构建和 git diff --check 全部通过;140/140 视觉矩阵阻断失败和诊断失败均为 0;源码服务、Docker Web API 与实际安装 Desktop 的 DOCX 字体嵌入链路均通过。
2026-08-04 12:25:02 +08:00
SkyJourney f93abf064f docs: 记录 v0.6.1 发布完成
补充发布提交与 annotated tag、8 项附件校验和、离线镜像重新导入及运行态验收结果。
2026-08-04 10:34:55 +08:00
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00
SkyJourney b275c671fc fix: 修复DOCX独立封面视觉门禁
统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
2026-08-03 18:15:21 +08:00
SkyJourney 9dee7e8605 fix: 统一十四套主题DOCX字体映射 2026-08-03 12:47:14 +08:00
SkyJourney 6a6972ae48 fix: 内置DOCX双中文字体包 2026-08-03 12:07:12 +08:00
SkyJourney a094dabf02 fix: 增强DOCX字体实际使用门禁 2026-08-03 11:32:06 +08:00
SkyJourney f0fff7f726 test: 扩展DOCX全主题视觉矩阵 2026-08-03 10:23:22 +08:00
SkyJourney 28c88faabb fix: 重建DOCX视觉一致性门禁 2026-08-03 10:09:43 +08:00
SkyJourney 842c3f1782 release: 发布墨呈 v0.6.0
新增能力:实现可编辑 DOCX 导出、通用主题样式翻译、Markdown 基础工具栏、统一导出菜单与导出进度提示;14 套主题支持纸张、页边距、字体、段落、代码、图片、图表、封面、页眉页脚和页码映射。

问题修复:修复精确预览放大后的右侧留白、保存与导出默认路径、Desktop DOCX 媒体捕获超时、品牌改名后的安装目录迁移,并统一主题展示名称。

兼容与部署:产品更名为墨呈/MorphDoc,保留 NSIS 安装身份;Desktop 内置 Pandoc 3.9.0.2,字体包支持安装向导可选与独立分发;Docker 内置 Chromium、Pandoc 和正式字体,支持离线部署。

发布规则:新增 release/v<version> 唯一产物目录、Docker 镜像 tar.gz、Web 部署 ZIP、manifest.json 和 SHA256SUMS.txt;正式字体迁入版本化仓库目录并由 Git LFS 管理。

验证结果:全项目测试、类型检查、生产构建、git diff --check、14 主题 DOCX 与视觉矩阵、Web API、Desktop 安装版、Word/WPS 可编辑性和字体门禁全部通过;离线镜像经 docker load 复验且容器保持 healthy。

发布产物:Desktop 安装器与 ZIP、字体安装器与 ZIP、Web 镜像压缩包、离线部署包、发行说明和校验清单已整理至本地 release/v0.6.0。Windows EXE 当前未进行 Authenticode 签名,仅按公司内部发布风险规则分发。
2026-08-02 15:54:27 +08:00
SkyJourney 3e89e3faff fix: 修复品牌改名后的安装目录迁移 2026-08-02 14:47:13 +08:00
SkyJourney 8f4d16a9b7 chore: 统一内置主题展示名称 2026-08-02 13:54:18 +08:00
SkyJourney 5c82b3df7f fix: 修复桌面 DOCX 媒体捕获超时 2026-08-02 13:50:25 +08:00
SkyJourney 14f470454c feat: 完善字体包发行与 Docker 内置字体 2026-08-02 11:33:27 +08:00
SkyJourney 3a8d2c1c2e feat: 内置 Desktop Pandoc 最小运行时 2026-08-02 10:44:40 +08:00
SkyJourney 9a2aa3c341 feat: 完成 v0.6.0 墨呈品牌迁移 2026-08-02 10:09:14 +08:00
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00
SkyJourney f9f5fccfc9 fix: 修复桌面字体转换依赖启动崩溃 2026-08-01 11:12:30 +08:00
SkyJourney 3081c02887 feat: 完成 Docker DOCX 与可选字体包部署链 2026-08-01 08:56:20 +08:00
SkyJourney 7f2c169405 feat: 优化桌面文件默认保存路径 2026-08-01 04:09:21 +08:00
SkyJourney dc026835b7 fix: 修复精确预览放大后右侧留白丢失 2026-07-31 23:42:04 +08:00
SkyJourney 6f62cfebd6 feat: 实现 Windows 可选字体包安装链 2026-07-31 22:29:23 +08:00
SkyJourney ae9fde3ac6 feat: 建立可复现字体包构建链 2026-07-31 21:52:36 +08:00
SkyJourney 9f96d51922 feat: 接入可选字体包同源渲染链 2026-07-31 21:15:45 +08:00
SkyJourney c76454be9d feat: 建立可选字体包协议与安全注册器 2026-07-31 20:36:22 +08:00
SkyJourney e5da699ea3 feat: 完成 DOCX 视觉门禁与字体兼容映射 2026-07-31 20:13:19 +08:00
SkyJourney 10bc62cee4 feat: 实现 DOCX 可编辑字体嵌入 2026-07-31 16:18:30 +08:00
SkyJourney f3847f3e4b feat: 完成 DOCX 通用结构映射与严格收口 2026-07-31 15:19:34 +08:00
SkyJourney 6e40374200 feat: 将主题令牌接入 DOCX 动态模板 2026-07-31 08:43:26 +08:00
SkyJourney 459a079f3b feat: 建立统一语义文档模型 2026-07-30 23:47:51 +08:00
SkyJourney 7f72258126 feat: 实现 DOCX 主题令牌归一化 2026-07-30 22:57:46 +08:00
SkyJourney d2acc9c5ce feat: 实现 DOCX 主题样式采集 2026-07-30 22:09:30 +08:00
SkyJourney 6086762b4f feat: 建立 DOCX 标准样式探针 2026-07-30 21:27:29 +08:00
SkyJourney 6fed666f44 feat: 建立 DOCX 主题映射引擎 2026-07-30 20:28:47 +08:00
SkyJourney e7c688df6e test: 完成 DOCX 全主题视觉验收 2026-07-30 19:04:12 +08:00
SkyJourney aff2830c28 test: 建立 DOCX 自动化验收门禁 2026-07-30 16:48:41 +08:00
SkyJourney f2dfc6ef72 feat: 实现 Web 与 Desktop DOCX 导出交互 2026-07-30 16:05:45 +08:00
SkyJourney bf43433d38 feat: 实现 Pandoc DOCX 转换服务 2026-07-30 15:05:50 +08:00
SkyJourney fa159d916f feat: 实现动态 DOCX 模板与样式映射 2026-07-30 14:10:43 +08:00
SkyJourney fe5d67fb6d feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获 2026-07-30 13:21:18 +08:00
SkyJourney 7831bc23f0 feat: 升级 CodeMirror 编辑器并新增 Markdown 工具栏 2026-07-30 12:39:35 +08:00
SkyJourney f7d4efeafc feat: 建立 DOCX 共享协议与准备服务 2026-07-30 11:04:18 +08:00
SkyJourney fb3ddada39 docs: 固化 Pandoc 分发与内部许可策略 2026-07-30 10:49:21 +08:00
SkyJourney 81d9099eae docs: 冻结 v0.6.0 架构与验收边界 2026-07-30 10:23:04 +08:00
355 changed files with 62168 additions and 729 deletions
+19
View File
@@ -0,0 +1,19 @@
.git
.github
.local
.vscode
apps/desktop
coverage
dist
node_modules
out
output/*
!output/font-packs/
output/font-packs/*
!output/font-packs/root/
!output/font-packs/root/**
playwright-report
test-results
tmp
*.log
*.tsbuildinfo
+6
View File
@@ -1 +1,7 @@
deploy/entrypoint.sh text eol=lf
# 正式字体资产保存在仓库固定目录,但二进制内容交由 Git LFS 管理。
font-packs/assets/**/*.ttf filter=lfs diff=lfs merge=lfs -text
font-packs/assets/**/*.otf filter=lfs diff=lfs merge=lfs -text
font-packs/assets/**/*.woff filter=lfs diff=lfs merge=lfs -text
font-packs/assets/**/*.woff2 filter=lfs diff=lfs merge=lfs -text
+4
View File
@@ -14,6 +14,10 @@ Thumbs.db
tmp/
playwright-report/
test-results/
# 构建、门禁与正式发版产物只保留在本机,不进入 Git。
output/
/apps/desktop/out/
/apps/desktop/.runtime/
/release/
*.tsbuildinfo
+107
View File
@@ -0,0 +1,107 @@
YIXIONG Tech.ltd 公司内部专属软件许可证
版本 1.02026 年
版权所有 © 2026 YIXIONG Tech.ltd。保留所有权利。
一、适用范围
本许可证适用于“墨呈(MorphDoc)”项目中由 YIXIONG Tech.ltd 拥有
著作权的源代码、原创主题、原创文档、构建脚本和其他原创材料(以下简称
“本软件”)。
本软件包含或调用的第三方程序、依赖、字体、主题和其他材料不受本许可证
重新许可,继续适用其各自的许可证、授权条件和使用边界。
二、授权对象
“内部授权用户”是指经 YIXIONG Tech.ltd 明确授权,并且属于以下范围的
个人或组织:
1. YIXIONG Tech.ltd 的员工;
2. YIXIONG Tech.ltd 直接或间接控制的组织;
3. 因内部项目需要而获得明确书面授权的承包商、供应商或合作人员。
授权在相关人员不再属于上述范围、授权被撤销或合作关系终止时结束,但
依法或书面约定需要继续履行的保密、知识产权和责任条款不因此失效。
三、允许的行为
在遵守本许可证、公司制度和适用法律的前提下,内部授权用户可以:
1. 为公司内部业务、测试、研发、评估和运维目的运行本软件;
2. 阅读、复制和修改本软件源代码;
3. 在公司控制或明确授权的设备、服务器、容器和内部网络中部署本软件;
4. 在内部授权用户之间分发源代码、Docker 镜像、安装包、压缩包和测试
产物;
5. 为内部使用目的制作和分发修改版本;
6. 使用本软件处理合法取得的 Markdown、图片、主题和其他文档资源。
四、禁止的行为
未经 YIXIONG Tech.ltd 事先明确书面许可,任何人不得:
1. 向公司外部或公开网络发布、上传、分发、提供下载或公开展示本软件的
源代码、二进制、镜像、安装包或实质性部分;
2. 出售、出租、转授权、托管经营或以本软件向公司外部提供商业服务;
3. 将本软件或修改版本置于开源许可证、公共领域声明或其他与本许可证
冲突的授权条件下;
4. 删除或隐瞒版权、许可证、来源、第三方声明或其他权利标识;
5. 超出第三方材料自身许可证或授权边界使用、复制或分发第三方材料;
6. 使用本软件侵犯他人知识产权、隐私、商业秘密或其他合法权益;
7. 规避访问控制、安全限制、资源隔离或审计措施。
五、修改与贡献
内部授权用户为公司项目提交的修改、补丁、文档和其他贡献,按照其劳动、
合作、保密、知识产权归属协议及公司制度处理。本许可证本身不改变已经
存在的职务成果、委托开发或合作开发权利归属。
未经 YIXIONG Tech.ltd 明确书面决定,内部修改或贡献不会使本软件自动
转为开源软件,也不会产生向公司外部公开源代码的义务。
六、用户文档与导出结果
用户输入的 Markdown、图片、数据和其他内容,其权利归原权利人所有。
使用本软件生成的 PDF、DOCX、图片或其他导出结果,不会仅因使用本软件而
自动受本许可证约束。
导出结果中嵌入的字体、图片、模板、主题或其他第三方材料,仍可能受其
各自许可证或授权条件约束,使用者应自行确保拥有相应权利。
七、第三方材料
本软件的第三方许可证与特殊分发边界记录在 `THIRD_PARTY_NOTICES.md`
以及相关资源目录的许可证文件中。若本许可证与第三方材料的有效许可证
发生冲突,对该第三方材料以其自身许可证为准。
八、未来公开许可
YIXIONG Tech.ltd 可以在未来自行决定以 Apache License 2.0、其他开源
许可证或其他授权方式发布本软件的某个版本。任何未来公开许可仅自明确
发布之日起适用于指定版本,不追溯授予当前内部版本,也不自动覆盖第三方
材料。
在公开许可正式发布前,本软件不是面向公众的开源软件。源代码可在公司
内部查看和修改,不等于授予公众使用、复制、修改或分发权。
九、免责声明
在适用法律允许的最大范围内,本软件按“现状”提供,不附带任何明示或
默示保证,包括但不限于适销性、特定用途适用性、无侵权、持续可用或数据
完整性保证。
十、责任限制
在适用法律允许的最大范围内,YIXIONG Tech.ltd 及贡献者不对因使用、
无法使用、修改、部署或分发本软件导致的任何间接、附带、特殊、惩罚性或
后果性损失承担责任。公司内部另有有效合同、制度或书面约定的,从其约定。
十一、终止
违反本许可证的授权自动终止。YIXIONG Tech.ltd 有权要求停止使用、删除
未经授权的副本,并依据适用法律、合同、保密协议和公司制度追究责任。
十二、其他
本许可证未明确授予的权利均由 YIXIONG Tech.ltd 保留。对本许可证的任何
例外、补充或变更必须由 YIXIONG Tech.ltd 以明确书面形式作出。
+10
View File
@@ -0,0 +1,10 @@
墨呈(MorphDoc
Copyright 2026 YIXIONG Tech.ltd
This product includes software developed by YIXIONG Tech.ltd and
contributors.
The original project code and documentation are currently licensed only for
authorized internal use under the proprietary LICENSE in this repository.
Third-party components and assets remain under their respective licenses.
See THIRD_PARTY_NOTICES.md.
+25 -10
View File
@@ -1,12 +1,25 @@
# Markdown PDF 导出器
# 墨呈
一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
> 一稿多式,即写即呈
当前版本:`v0.5.1`
面向结构化 Markdown 的主题化文档创作与发布工具,同时提供 Web 与
Windows 桌面端。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题
CSS 和分页运行时,DOCX 使用同源语义文档、主题令牌和可编辑 OOXML。
## v0.5.1 主要能力
当前版本:`v0.6.0`
## v0.6.0 核心更新
- 新增可编辑 DOCX 导出,使用固定 Pandoc、动态 Word 样式、原生分节、
页眉页脚、页码和高分辨率 PNG 媒体管线;
- 主题 CSS 通过通用语义槽位翻译为 Word 样式,支持 14 套内置主题、
公文结构、独立封面、代码、表格、图片、Mermaid 与 ECharts
- 新增 CodeMirror 6 Markdown 工具栏,支持标题、行内格式、引用、列表、
代码块、分隔线和尺寸表格;
- 产品品牌升级为“墨呈”,Windows 主程序和发行产物使用英文名
`MorphDoc`
## v0.5.1 主题与公文能力
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
以及 3 套正式文档主题和 3 套标书主题;
@@ -39,6 +52,7 @@
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
| DOCX 引擎 | 固定版本 Pandoc | 内置固定版本 Pandoc |
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
DesktopWeb 端可使用 Base64/Data URL 或受限公网图片。
@@ -125,9 +139,10 @@ npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,不能
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.1/`
正式安装包命名为 `md-to-pdf-0.5.1-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并
校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的
`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`
正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。
## 文档与媒体
@@ -147,7 +162,7 @@ ECharts 围栏语法、支持系列和安全边界详见
## 主题
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
3 套标书主题,默认使用 Typora Github。主题由版本化
3 套标书主题,默认使用 GitHub。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/`
+80
View File
@@ -0,0 +1,80 @@
# 第三方许可证与公开发布说明
根目录 `LICENSE` 是当前公司内部专属许可证,适用于本项目原创代码、原创
主题和原创文档,不会替换第三方组件、字体、主题或工具各自的许可证。
Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前仓库没有
通过 Apache License 2.0 向公司外部授予项目原创内容的使用或分发权。
本文件是发行审查入口,不替代各第三方项目附带的完整许可证。正式发行前
还应根据最终生产依赖生成完整的软件物料清单和许可证清单。
## Pandoc
`v0.6.0` 将未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。
- 项目:Pandoc
- 版权:John MacFarlane 及贡献者
- 许可证:GNU General Public Licenseversion 2 or later
- 交互方式:独立子进程、命令行参数和临时文档文件
- 项目地址:<https://pandoc.org/>
- 源码地址:<https://github.com/jgm/pandoc>
项目不链接、导入或修改 Pandoc 程序代码。发行包必须保留 Pandoc 的
`COPYING``COPYRIGHT` 和版本信息,并在相同版本的发行位置提供对应源码
获取方式。具体版本、校验值和分发规则见
[`docs/PANDOC_DISTRIBUTION.md`](docs/PANDOC_DISTRIBUTION.md)。
## Fandol 字体
`themes/_shared/official-fonts/` 包含 Fandol 中文字体的 WOFF2 转换版本。
Fandol 字体采用 GNU GPL,并附带字体例外条款。完整许可证、上游说明、
文件哈希和来源信息保存在该目录:
- [`LICENSE.txt`](themes/_shared/official-fonts/LICENSE.txt)
- [`README.md`](themes/_shared/official-fonts/README.md)
- [`UPSTREAM-README.txt`](themes/_shared/official-fonts/UPSTREAM-README.txt)
使用字体生成的 PDF、图片或 DOCX 不会仅因为使用该字体而自动受 GPL
约束。重新分发字体时必须保留上述许可证和来源文件。
## npm、Electron 与 Chromium 组件
项目直接或间接使用 MIT、Apache-2.0、BSD、ISC、EPL-2.0、MPL-2.0、
CC-BY-4.0 等许可证的软件包。`package-lock.json` 固定具体依赖版本;
Electron 和 Chromium 发行目录还包含其自身第三方许可证文件。
构建产物不得删除 Electron、Chromium 或其他依赖已经附带的许可证文件。
正式发布阶段应基于生产依赖和浏览器 Bundle 生成最终第三方清单,而不能
仅以根目录项目许可证代替。
## 仅限公司内部使用的 Typora 资源
以下目录来自公司内部安装的 Typora 1.14.7,目前没有确认可用于公开再
分发的完整授权:
```text
themes/typora-github/
themes/typora-pixyll/
themes/typora-whitey/
```
它们不受本项目内部专属许可证重新授权。即使其中部分样式可追溯到 MIT
上游,也不能据此推定 Typora 修改版本整体可以公开分发。
当前项目属于公司内部源码开放和内部部署阶段。根据现有内部使用决策,
公司内部的源码仓库、Web/Docker 部署、Desktop 安装包、ZIP 和测试产物
可以继续携带这三套主题,但不得向公司外部或公开网络发布、销售或授权。
接收内部发行物的人员也不能依据根目录项目许可证对这三套主题进行公司外
再分发。
在将项目发布到公开 GitHub 仓库前,必须完成以下方案之一:
1. 取得明确的公开分发授权;
2. 使用许可证明确的上游资源重新实现并完成来源审查;
3. 删除这些目录,并从准备公开的 Git 历史中清除其内容。
仅在后续提交中删除目录仍会使旧版本存在于 Git 历史。若不能取得授权,
公开发布应使用经过清理的历史或从合规快照创建新的公开仓库。上述门禁
只针对公司外部和公开网络发行,不阻止当前公司内部源码共享、部署和
安装包分发。
+27 -8
View File
@@ -43,26 +43,45 @@ npm run make -w @md-to-pdf/desktop
`package``make` 会先调用根级 `build:web-runtime`,完整重建
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
再按固定清单下载或复用 Pandoc `3.9.0.2` 缓存、校验官方 ZIP 的
SHA-256,并执行版本与最小 DOCX 冒烟,最后才调用 electron-builder。
不得绕过该链路直接调用 electron-builder。
Pandoc 构建缓存位于被 Git 忽略的 `apps/desktop/.runtime/`。发行目录只
包含 `pandoc.exe``COPYING.rtf``COPYRIGHT.txt` 和生成的审计清单,
不会携带官方 MSI、手册、下载缓存或用户级 Pandoc 配置。已准备运行时可用
以下命令离线复验:
```powershell
npm run verify:pandoc-runtime -w @md-to-pdf/desktop
```
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.5.1/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.5.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
`apps/desktop/out/v0.6.2/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.2-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
Serif、Sans、Mono 三套正式字体随 NSIS 安装包和便携 ZIP 直接进入
`resources/font-packs`,由应用私有加载,不写入 Windows 系统字体目录。
安装向导不再提供字体选择页,正式发行也不附带独立字体安装器或字体 ZIP。
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
发布,不提交进 Git 历史。
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
“运行”操作直接启动 `MorphDoc.exe`,不依赖快捷方式文件。
产品名称、窗口标题、安装包名称和开始菜单名称保持
Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
`md-to-pdf.exe`
界面、快捷方式与卸载项使用中文名“墨呈”,窗口标题
墨呈 · MorphDoc”,默认安装目录和 Windows 主程序分别为 `MorphDoc`
`MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保
v0.5.1 覆盖升级沿用原卸载身份;v0.6.0 使用独立的安装位置注册表键,
升级时由旧版卸载器清理 `md-to-pdf` 目录,再安装到 `MorphDoc` 目录,
避免产品改名后继续继承旧目录名。
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium
Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。
+41 -18
View File
@@ -1,4 +1,16 @@
!define MORPHDOC_DISPLAY_NAME "墨呈"
!define MORPHDOC_EXECUTABLE "MorphDoc.exe"
!define MORPHDOC_LEGACY_DISPLAY_NAME "Markdown PDF 导出器"
!define MORPHDOC_LEGACY_EXECUTABLE "md-to-pdf.exe"
!define MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY "Software\${APP_GUID}"
; 保持 APP_GUID 与卸载身份不变,但不再沿用旧产品名对应的安装目录。
; electron-builder 会从 INSTALL_REGISTRY_KEY 读取历史 InstallLocation;使用
; 独立键可让 v0.6.0 默认进入 MorphDoc 目录,同时仍由相同卸载身份识别旧版。
!define INSTALL_REGISTRY_KEY "Software\${APP_GUID}.MorphDoc"
!macro customInstall
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown" \
"" \
@@ -6,11 +18,11 @@
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
"" \
'"$INSTDIR\md-to-pdf.exe",0'
'"$INSTDIR\${MORPHDOC_EXECUTABLE}",0'
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
WriteRegStr SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
@@ -20,33 +32,39 @@
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\shell\open\command" \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
".md" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
".markdown" \
""
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
CreateDirectory "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
CreateShortCut \
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
"$INSTDIR\md-to-pdf.exe" \
"$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
"" \
"$INSTDIR\md-to-pdf.exe"
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
CreateShortCut \
"$DESKTOP\Markdown PDF 导出器.lnk" \
"$INSTDIR\md-to-pdf.exe" \
"$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
"" \
"$INSTDIR\md-to-pdf.exe"
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
!macro customUnInstall
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
DeleteRegValue SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
@@ -54,11 +72,16 @@
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegKey SHCTX \
"Software\Classes\Applications\md-to-pdf.exe"
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}"
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
DeleteRegKey SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown"
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
Delete "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk"
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
+29 -8
View File
@@ -1,14 +1,20 @@
const path = require("node:path");
const { version } = require("./package.json");
const brand = require("../../product.json");
const pandocRuntime = require(
"../../packages/docx-engine/src/pandoc-runtime.json"
);
const windowsIcon = path.resolve(
__dirname,
"../../logos/desktop/windows/app.ico"
);
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
"build/installer.nsh";
module.exports = {
appId: "com.md-to-pdf.desktop",
productName: "Markdown PDF 导出器",
appId: brand.appId,
productName: brand.englishName,
copyright: "Copyright © YIXIONG Tech.ltd",
asar: true,
electronLanguages: ["zh-CN", "en-US"],
@@ -35,6 +41,21 @@ module.exports = {
to: "samples",
filter: ["**/*"]
},
{
from: "../../output/font-packs/root",
to: "font-packs",
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,
to: "app.ico"
@@ -56,8 +77,8 @@ module.exports = {
}
],
icon: windowsIcon,
executableName: "md-to-pdf",
artifactName: `md-to-pdf-${version}-x86_64.\${ext}`
executableName: brand.executableName,
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`
},
nsis: {
oneClick: false,
@@ -66,12 +87,12 @@ module.exports = {
allowElevation: true,
createDesktopShortcut: false,
createStartMenuShortcut: false,
shortcutName: "Markdown PDF 导出器",
uninstallDisplayName: "Markdown PDF 导出器",
shortcutName: brand.displayName,
uninstallDisplayName: brand.displayName,
installerIcon: windowsIcon,
uninstallerIcon: windowsIcon,
installerLanguages: ["zh_CN"],
include: "build/installer.nsh",
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
include: nsisInclude,
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
}
};
+12 -6
View File
@@ -1,30 +1,36 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.5.1",
"version": "0.6.2",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
"productName": "MorphDoc",
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
"author": "YIXIONG Tech.ltd",
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload",
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
"build:main": "node scripts/build-main.mjs",
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --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",
"make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
"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"
},
"devDependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"electron-builder": "26.15.3",
"esbuild": "^0.25.0",
"fflate": "0.8.3",
"typescript": "^5.9.3",
"vitest": "^4.1.10",
"wait-on": "^9.0.1"
+34 -1
View File
@@ -1,4 +1,13 @@
import { build } from "esbuild";
import { copyFile, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const productBrand = JSON.parse(
await readFile(
fileURLToPath(new URL("../../../product.json", import.meta.url)),
"utf8"
)
);
await build({
entryPoints: ["src/main.ts"],
@@ -7,10 +16,34 @@ await build({
format: "esm",
external: ["electron"],
outfile: "dist/main.js",
define: {
__APP_ID__: JSON.stringify(productBrand.appId),
__APP_ENGLISH_NAME__: JSON.stringify(productBrand.englishName),
__APP_DISPLAY_NAME__: JSON.stringify(productBrand.displayName),
__APP_WINDOW_TITLE__: JSON.stringify(productBrand.windowTitle),
__APP_LEGACY_DISPLAY_NAME__: JSON.stringify(
productBrand.legacyDisplayName
)
},
banner: {
js: [
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
"const require = __mdToPdfCreateRequire(import.meta.url);"
'import { dirname as __mdToPdfDirname } from "node:path";',
'import { fileURLToPath as __mdToPdfFileURLToPath } from "node:url";',
"const require = __mdToPdfCreateRequire(import.meta.url);",
"const __dirname = __mdToPdfDirname(__mdToPdfFileURLToPath(import.meta.url));"
].join("\n")
}
});
await copyFile(
fileURLToPath(
new URL(
"../../../packages/docx-engine/assets/docx-media-filter.lua",
import.meta.url
)
),
fileURLToPath(
new URL("../dist/docx-media-filter.lua", import.meta.url)
)
);
@@ -0,0 +1,20 @@
export interface MinimalPandocArtifact {
executableRelativePath: string;
licenseFiles: string[];
}
export function sha256(content: Uint8Array): string;
export function extractRequiredFiles(
archive: Uint8Array,
artifact: MinimalPandocArtifact
): Map<string, Uint8Array>;
export function verifyPandocVersion(
executablePath: string,
expectedVersion: string
): void;
export function preparePandocRuntime(options?: {
checkOnly?: boolean;
}): Promise<string>;
@@ -0,0 +1,359 @@
import { createHash } from "node:crypto";
import {
access,
mkdir,
readFile,
rename,
rm,
writeFile
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { unzipSync } from "fflate";
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
const desktopRoot = path.join(projectRoot, "apps", "desktop");
const manifestPath = path.join(
projectRoot,
"packages",
"docx-engine",
"src",
"pandoc-runtime.json"
);
const maximumArchiveBytes = 64 * 1024 * 1024;
const maximumExtractedBytes = 256 * 1024 * 1024;
const downloadTimeoutMs = 120_000;
function sha256(content) {
return createHash("sha256").update(content).digest("hex").toUpperCase();
}
function normalizedBaseName(fileName) {
return fileName.replaceAll("\\", "/").split("/").filter(Boolean).at(-1);
}
async function fileExists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function readRuntimeManifest() {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const artifact = manifest.artifacts?.find(
(candidate) =>
candidate.platform === "win32" && candidate.architecture === "x64"
);
if (!artifact || artifact.archiveType !== "zip") {
throw new Error("Pandoc 清单缺少 Windows x64 ZIP 发行项");
}
const requiredNames = [
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
];
if (
requiredNames.some(
(fileName) =>
!fileName ||
!Number.isInteger(artifact.files?.[fileName]?.bytes) ||
!/^[A-F0-9]{64}$/u.test(artifact.files?.[fileName]?.sha256 ?? "")
)
) {
throw new Error("Pandoc 清单缺少最小运行时文件哈希或长度");
}
return { manifest, artifact };
}
async function downloadArchive(downloadUrl, destination) {
const response = await fetch(downloadUrl, {
redirect: "follow",
signal: AbortSignal.timeout(downloadTimeoutMs)
});
if (!response.ok) {
throw new Error(`Pandoc 下载失败:HTTP ${response.status}`);
}
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > maximumArchiveBytes
) {
throw new Error("Pandoc 归档超过允许的下载大小");
}
const content = new Uint8Array(await response.arrayBuffer());
if (content.byteLength > maximumArchiveBytes) {
throw new Error("Pandoc 归档超过允许的下载大小");
}
await writeFile(destination, content);
return content;
}
async function loadVerifiedArchive(artifact, archivePath) {
if (await fileExists(archivePath)) {
const cached = new Uint8Array(await readFile(archivePath));
if (sha256(cached) === artifact.sha256) {
return cached;
}
await rm(archivePath, { force: true });
}
const partialPath = `${archivePath}.${process.pid}.partial`;
await rm(partialPath, { force: true });
try {
const downloaded = await downloadArchive(
artifact.downloadUrl,
partialPath
);
const actualHash = sha256(downloaded);
if (actualHash !== artifact.sha256) {
throw new Error(
`Pandoc 归档 SHA-256 不匹配:期望 ${artifact.sha256},实际 ${actualHash}`
);
}
await rename(partialPath, archivePath);
return downloaded;
} finally {
await rm(partialPath, { force: true });
}
}
function extractRequiredFiles(archive, artifact) {
const requiredNames = new Set([
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
]);
let selectedBytes = 0;
const entries = unzipSync(archive, {
filter(file) {
const selected = requiredNames.has(normalizedBaseName(file.name));
if (selected) {
selectedBytes += file.originalSize;
if (selectedBytes > maximumExtractedBytes) {
throw new Error("Pandoc 最小运行时超过允许的解压大小");
}
}
return selected;
}
});
const files = new Map();
for (const [entryName, content] of Object.entries(entries)) {
const baseName = normalizedBaseName(entryName);
if (!baseName || !requiredNames.has(baseName)) {
continue;
}
if (files.has(baseName)) {
throw new Error(`Pandoc 归档包含重复文件:${baseName}`);
}
files.set(baseName, content);
}
for (const requiredName of requiredNames) {
if (!files.has(requiredName)) {
throw new Error(`Pandoc 归档缺少必须文件:${requiredName}`);
}
}
return files;
}
function verifyPandocVersion(executablePath, expectedVersion) {
const result = spawnSync(executablePath, ["--version"], {
encoding: "utf8",
maxBuffer: 64 * 1024,
windowsHide: true
});
if (result.error || result.status !== 0) {
throw new Error(`内置 Pandoc 无法执行:${result.error?.message ?? result.stderr}`);
}
const firstLine = result.stdout.split(/\r?\n/u)[0]?.trim();
if (firstLine !== `pandoc ${expectedVersion}`) {
throw new Error(
`内置 Pandoc 版本不匹配:期望 pandoc ${expectedVersion},实际 ${firstLine ?? "未知"}`
);
}
}
async function smokeTestPandoc(executablePath) {
const smokeRoot = path.join(
tmpdir(),
`morphdoc-pandoc-smoke-${process.pid}-${Date.now()}`
);
const dataDirectory = path.join(smokeRoot, "data");
const outputPath = path.join(smokeRoot, "smoke.docx");
await mkdir(dataDirectory, { recursive: true });
try {
const result = spawnSync(
executablePath,
[
"--from=gfm",
"--to=docx",
`--data-dir=${dataDirectory}`,
`--output=${outputPath}`
],
{
encoding: "utf8",
input: "# 墨呈\n\nPandoc 运行时冒烟测试。\n",
maxBuffer: 1024 * 1024,
windowsHide: true
}
);
if (result.error || result.status !== 0) {
throw new Error(`内置 Pandoc DOCX 冒烟失败:${result.error?.message ?? result.stderr}`);
}
const output = await readFile(outputPath);
if (output.byteLength < 1024 || output[0] !== 0x50 || output[1] !== 0x4b) {
throw new Error("内置 Pandoc 生成的 DOCX 不是有效 ZIP 包");
}
} finally {
await rm(smokeRoot, { recursive: true, force: true });
}
}
async function verifyPreparedRuntime(targetDirectory, manifest, artifact) {
const auditPath = path.join(targetDirectory, "runtime-manifest.json");
if (!(await fileExists(auditPath))) {
return false;
}
try {
const audit = JSON.parse(await readFile(auditPath, "utf8"));
if (
audit.version !== manifest.version ||
audit.archiveSha256 !== artifact.sha256 ||
audit.downloadUrl !== artifact.downloadUrl
) {
return false;
}
const expectedFiles = [
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
];
for (const fileName of expectedFiles) {
const content = await readFile(path.join(targetDirectory, fileName));
const expected = artifact.files[fileName];
if (
!expected ||
content.byteLength !== expected.bytes ||
sha256(content) !== expected.sha256 ||
audit.files?.[fileName]?.sha256 !== expected.sha256
) {
return false;
}
}
const executablePath = path.join(
targetDirectory,
normalizedBaseName(artifact.executableRelativePath)
);
verifyPandocVersion(executablePath, manifest.version);
await smokeTestPandoc(executablePath);
return true;
} catch {
return false;
}
}
async function preparePandocRuntime({ checkOnly = false } = {}) {
const { manifest, artifact } = await readRuntimeManifest();
const platformDirectory = "windows-x86_64";
const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc");
const targetDirectory = path.join(
runtimeRoot,
manifest.version,
platformDirectory
);
if (await verifyPreparedRuntime(targetDirectory, manifest, artifact)) {
console.log(`Pandoc ${manifest.version} 最小运行时已通过校验`);
return targetDirectory;
}
if (checkOnly) {
throw new Error("Pandoc 最小运行时缺失或未通过完整性校验");
}
const downloadDirectory = path.join(runtimeRoot, "downloads");
await mkdir(downloadDirectory, { recursive: true });
const archiveName = new URL(artifact.downloadUrl).pathname.split("/").at(-1);
if (!archiveName) {
throw new Error("Pandoc 下载地址缺少归档文件名");
}
const archivePath = path.join(downloadDirectory, archiveName);
const archive = await loadVerifiedArchive(artifact, archivePath);
const files = extractRequiredFiles(archive, artifact);
const temporaryDirectory = path.join(
runtimeRoot,
manifest.version,
`.${platformDirectory}-${process.pid}-${Date.now()}`
);
await rm(temporaryDirectory, { recursive: true, force: true });
await mkdir(temporaryDirectory, { recursive: true });
try {
const auditFiles = {};
for (const [fileName, content] of files) {
const expected = artifact.files[fileName];
const actualHash = sha256(content);
if (
!expected ||
content.byteLength !== expected.bytes ||
actualHash !== expected.sha256
) {
throw new Error(`Pandoc 文件完整性校验失败:${fileName}`);
}
await writeFile(path.join(temporaryDirectory, fileName), content);
auditFiles[fileName] = {
bytes: content.byteLength,
sha256: actualHash
};
}
const executablePath = path.join(
temporaryDirectory,
normalizedBaseName(artifact.executableRelativePath)
);
verifyPandocVersion(executablePath, manifest.version);
await smokeTestPandoc(executablePath);
await writeFile(
path.join(temporaryDirectory, "runtime-manifest.json"),
`${JSON.stringify(
{
version: manifest.version,
platform: "win32",
architecture: "x64",
license: manifest.license,
projectUrl: manifest.projectUrl,
sourceArchiveUrl: manifest.sourceArchiveUrl,
downloadUrl: artifact.downloadUrl,
archiveSha256: artifact.sha256,
files: auditFiles
},
null,
2
)}\n`,
"utf8"
);
await rm(targetDirectory, { recursive: true, force: true });
await rename(temporaryDirectory, targetDirectory);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
console.log(`Pandoc ${manifest.version} 最小运行时已准备:${targetDirectory}`);
return targetDirectory;
}
const isDirectInvocation =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).catch(
(error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
);
}
export {
extractRequiredFiles,
preparePandocRuntime,
sha256,
verifyPandocVersion
};
@@ -0,0 +1,8 @@
void import("./verify-docx-theme-styles.mjs").catch((error) => {
console.error(error);
process.exitCode = 1;
const { app } = require("electron");
if (app.isReady()) {
app.quit();
}
});
@@ -0,0 +1,270 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createApplicationService } from "@md-to-pdf/application";
import { defaultExportConfig } from "@md-to-pdf/core";
import {
DOCX_STYLE_SLOT_NAMES,
createDocxThemeStyleCaptureScript,
createDocxThemeStyleFingerprint,
normalizeDocxThemeMappingConfig,
parseDocxThemeStyleRuntimeCapture,
resolveDocxThemeTokens
} from "@md-to-pdf/docx-theme-engine";
import { app, BrowserWindow } from "electron";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-theme-styles"
);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const application = createApplicationService({
bundledRoot: path.join(repositoryDirectory, "themes"),
localRoot: path.join(repositoryDirectory, ".local", "themes")
});
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname === "/") {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8"
});
response.end("<!doctype html><html><body></body></html>");
return;
}
const match = url.pathname.match(
/^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
);
if (!match) {
response.writeHead(404);
response.end("Not found");
return;
}
const themeId = decodeURIComponent(match[1]);
const assetPath = match[2]
.split("/")
.map((segment) => decodeURIComponent(segment))
.join("/");
const asset = await application.getThemeAsset(
themeId,
assetPath
);
if (!asset) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"access-control-allow-origin": "*",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
});
response.end(asset.content);
} catch (error) {
response.writeHead(500);
response.end(
error instanceof Error ? error.message : "Unknown error"
);
}
});
await app.whenReady();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("无法获取 Electron DOCX 主题样式验收地址");
}
const baseUrl = `http://127.0.0.1:${address.port}/`;
const window = new BrowserWindow({
show: false,
focusable: false,
width: 800,
height: 600,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
try {
await window.loadURL(baseUrl);
window.webContents.debugger.attach("1.3");
await window.webContents.debugger.sendCommand(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
const { themes } = await application.listThemes();
const bundledThemes = themes
.filter((theme) => theme.source === "bundled")
.sort((first, second) =>
first.id.localeCompare(second.id, "en")
);
const snapshots = [];
const tokenSets = [];
for (const theme of bundledThemes) {
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
const prepared = await application.prepareDocxExport({
markdown: `# ${theme.name}`,
fileName: `${theme.id}.md`,
language: "zh-CN",
exportConfig: {
...defaultExportConfig,
themeId: theme.id
}
});
const themeCss = prepared.theme.css;
const manifest = prepared.theme.manifest;
const themeFingerprint =
await createDocxThemeStyleFingerprint(theme.id, themeCss);
const request = {
themeId: theme.id,
themeFingerprint,
themeCss,
baseUrl
};
const capture = await window.webContents.executeJavaScript(
createDocxThemeStyleCaptureScript(request),
true
);
const snapshot = parseDocxThemeStyleRuntimeCapture(
request,
capture
);
assert(
snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
`主题 ${theme.id} 的槽位数量不正确`
);
assert(
snapshot.slots.every((slot) => slot.matched),
`主题 ${theme.id} 存在未命中槽位`
);
snapshots.push(snapshot);
tokenSets.push(
resolveDocxThemeTokens({
snapshot,
config: normalizeDocxThemeMappingConfig(manifest)
})
);
}
assert(
snapshots.length === 14,
`内置主题数量应为 14,实际为 ${snapshots.length}`
);
const invalidDiagnostics = tokenSets.flatMap((tokens) =>
tokens.diagnostics.filter(
(diagnostic) =>
diagnostic.severity === "error" ||
diagnostic.code === "css-value-invalid"
)
);
assert(
invalidDiagnostics.length === 0,
`DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断`
);
const playwrightReport = JSON.parse(
fs.readFileSync(
path.join(outputDirectory, "snapshots.json"),
"utf8"
)
);
const playwrightTokensByTheme = new Map(
playwrightReport.tokenSets.map((tokens) => [
tokens.themeId,
tokens
])
);
const crossEngineMismatches = [];
for (const electronTokens of tokenSets) {
const playwrightTokens = playwrightTokensByTheme.get(
electronTokens.themeId
);
assert(
playwrightTokens,
`Playwright 报告缺少主题 ${electronTokens.themeId}`
);
for (const electronSlot of electronTokens.slots) {
const playwrightSlot = playwrightTokens.slots.find(
(slot) => slot.slot === electronSlot.slot
);
if (
JSON.stringify(playwrightSlot) !==
JSON.stringify(electronSlot)
) {
crossEngineMismatches.push(
`${electronTokens.themeId}:${electronSlot.slot}`
);
}
}
}
assert(
crossEngineMismatches.length === 0,
`Playwright/Electron 令牌不一致:${crossEngineMismatches.join("、")}`
);
fs.mkdirSync(outputDirectory, { recursive: true });
fs.writeFileSync(
path.join(outputDirectory, "electron-snapshots.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
electronVersion: process.versions.electron,
chromiumVersion: process.versions.chrome,
themeCount: snapshots.length,
slotCount: DOCX_STYLE_SLOT_NAMES.length,
crossEngineTokenMismatches:
crossEngineMismatches.length,
snapshots,
tokenSets
},
null,
2
)}\n`,
"utf8"
);
console.log(
JSON.stringify({
electronVersion: process.versions.electron,
chromiumVersion: process.versions.chrome,
themes: snapshots.length,
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
totalSlots:
snapshots.length * DOCX_STYLE_SLOT_NAMES.length,
tokenSets: tokenSets.length,
crossEngineTokenMismatches:
crossEngineMismatches.length,
diagnostics: tokenSets.reduce(
(count, tokens) => count + tokens.diagnostics.length,
0
)
})
);
} finally {
if (
!window.isDestroyed() &&
window.webContents.debugger.isAttached()
) {
window.webContents.debugger.detach();
}
if (!window.isDestroyed()) {
window.destroy();
}
await new Promise((resolve) => server.close(resolve));
app.quit();
}
+11 -2
View File
@@ -1,11 +1,16 @@
import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type {
DocxExportRequestInput,
PagedDocumentPayload
} from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GET_DOCX_CAPABILITY,
DESKTOP_EXPORT_DOCX,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
@@ -77,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
generatePdf: (payload: PagedDocumentPayload) =>
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
savePdf: (fileName: string, pdf: Uint8Array) =>
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf),
getDocxCapability: () =>
ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY),
exportDocx: (request: DocxExportRequestInput) =>
ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request)
});
+30
View File
@@ -52,3 +52,33 @@ export function parseThemeResourceUrl(requestUrl: string) {
assetPath: assetSegments.join("/")
};
}
export function parseFontPackResourceUrl(requestUrl: string) {
const url = new URL(requestUrl);
if (url.protocol !== "mdpdf:" || url.host !== "font-pack") {
return undefined;
}
let segments: string[];
try {
segments = url.pathname
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
} catch {
throw new Error("字体包资源地址无效");
}
const [packId, packVersion, faceId, kind, ...extra] = segments;
if (
!packId ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(packId) ||
!packVersion ||
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(packVersion) ||
!faceId ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(faceId) ||
kind !== "web" ||
extra.length > 0
) {
throw new Error("字体包资源地址无效");
}
return { packId, packVersion, faceId, kind } as const;
}
+5
View File
@@ -0,0 +1,5 @@
declare const __APP_ID__: string;
declare const __APP_ENGLISH_NAME__: string;
declare const __APP_DISPLAY_NAME__: string;
declare const __APP_WINDOW_TITLE__: string;
declare const __APP_LEGACY_DISPLAY_NAME__: string;
+4
View File
@@ -36,6 +36,10 @@ export const DESKTOP_GENERATE_PDF =
"md-to-pdf:desktop:generate-pdf";
export const DESKTOP_SAVE_PDF =
"md-to-pdf:desktop:save-pdf";
export const DESKTOP_GET_DOCX_CAPABILITY =
"md-to-pdf:desktop:get-docx-capability";
export const DESKTOP_EXPORT_DOCX =
"md-to-pdf:desktop:export-docx";
export const PDF_RUNTIME_READY =
"md-to-pdf:desktop-pdf:ready";
export const PDF_RUNTIME_RENDER =
@@ -16,14 +16,22 @@ import {
} from "node:fs/promises";
import path from "node:path";
import {
DocxExportService,
DocxThemeTokenService,
MAXIMUM_MARKDOWN_LENGTH,
readDocxExportRuntimeLimits,
type ApplicationService
} from "@md-to-pdf/application";
import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GENERATE_PDF,
DESKTOP_GET_DOCX_CAPABILITY,
DESKTOP_GET_THEME_CSS,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
@@ -36,10 +44,17 @@ import {
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SAVE_PDF,
DESKTOP_EXPORT_DOCX,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED
} from "./channels.js";
import {
toDesktopDocxExportFailure,
type DesktopDocxExportOutcome
} from "./docx-contract.js";
import { saveDesktopDocx } from "./desktop-docx-save.js";
import { ElectronDocxMediaEngine } from "./electron-docx-media-engine.js";
import {
parseMarkdownRenderRequest,
parseThemeId
@@ -60,6 +75,7 @@ import {
type WindowState
} from "./window-state.js";
import { getWindowCloseDecision } from "./window-close-policy.js";
import { SaveLocationStore } from "./save-location-state.js";
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
@@ -93,9 +109,13 @@ export interface DesktopApplicationControllerOptions {
renderUrl: string;
preloadPath: string;
pdfPreloadPath: string;
docxRenderUrl: string;
desktopResourcesPath: string;
iconPath: string;
themeDirectory: string;
windowStatePath: string;
saveLocationStatePath: string;
documentsDirectory: string;
}
function focusWindow(window: BrowserWindow) {
@@ -117,7 +137,10 @@ export class DesktopApplicationController {
readonly #iconPath: string;
readonly #themeDirectory: string;
readonly #windowStatePath: string;
readonly #saveLocationStore: SaveLocationStore;
readonly #pdfGenerator: ElectronPdfGenerator;
readonly #docxExportService: DocxExportService;
readonly #docxMediaEngine: ElectronDocxMediaEngine;
readonly #sessions = new Map<number, WindowSession>();
readonly #documentSessions = new Map<string, WindowSession>();
#primarySession: WindowSession | undefined;
@@ -134,19 +157,54 @@ export class DesktopApplicationController {
this.#iconPath = options.iconPath;
this.#themeDirectory = options.themeDirectory;
this.#windowStatePath = options.windowStatePath;
this.#saveLocationStore = new SaveLocationStore(
options.saveLocationStatePath,
options.documentsDirectory
);
this.#pdfGenerator = new ElectronPdfGenerator({
renderUrl: options.renderUrl,
preloadPath: options.pdfPreloadPath
});
const pandocRuntime = new PandocRuntime({
desktopResourcesPath: options.desktopResourcesPath
});
this.#docxMediaEngine = new ElectronDocxMediaEngine({
renderUrl: options.docxRenderUrl
});
this.#docxExportService = new DocxExportService({
application: this.#applicationService,
runtime: pandocRuntime,
converter: new PandocDocxConverter({
runtime: pandocRuntime,
luaFilterUrl: new URL(
"./docx-media-filter.lua",
import.meta.url
)
}),
themeTokens: new DocxThemeTokenService(
this.#docxMediaEngine
),
limits: readDocxExportRuntimeLimits()
});
}
async initialize() {
this.#savedWindowState = await loadWindowState(
this.#windowStatePath
);
const [savedWindowState] = await Promise.all([
loadWindowState(this.#windowStatePath),
this.#saveLocationStore.initialize()
]);
this.#savedWindowState = savedWindowState;
this.#registerIpcHandlers();
}
async #recordSuccessfulFile(filePath: string) {
await this.#saveLocationStore
.recordSuccessfulFile(filePath)
.catch((error: unknown) => {
console.warn("无法持久化最近保存目录", error);
});
}
async createWindow(filePath?: string) {
let pendingSnapshot: MarkdownFileSnapshot | undefined;
if (filePath) {
@@ -175,7 +233,7 @@ export class DesktopApplicationController {
: undefined;
const initialBounds = this.#resolveInitialBounds(isPrimary);
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
title: `${__APP_WINDOW_TITLE__} v${app.getVersion()}`,
icon: this.#iconPath,
...initialBounds,
minWidth: Math.min(
@@ -279,7 +337,11 @@ export class DesktopApplicationController {
}
async closeResources() {
await this.#pdfGenerator.close();
await Promise.all([
this.#pdfGenerator.close(),
this.#docxExportService.close()
]);
await this.#docxMediaEngine.close();
}
#resolveInitialBounds(isPrimary: boolean): Rectangle {
@@ -689,9 +751,10 @@ export class DesktopApplicationController {
? "另存为 Markdown"
: "保存 Markdown",
defaultPath:
forceSaveAs && session.currentFilePath
? session.currentFilePath
: path.join(app.getPath("documents"), suggestedName),
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
suggestedName
),
filters: [
{
name: "Markdown 文件",
@@ -744,6 +807,7 @@ export class DesktopApplicationController {
throw new Error("该 Markdown 已在另一个窗口中打开");
}
this.#commitSnapshot(session, snapshot);
await this.#recordSuccessfulFile(targetPath);
return { fileName: snapshot.document.fileName };
};
@@ -861,19 +925,115 @@ export class DesktopApplicationController {
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(session.window, {
title: "导出 PDF",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
suggestedName
),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
await this.#recordSuccessfulFile(selection.filePath);
return true;
}
);
ipcMain.handle(
DESKTOP_GET_DOCX_CAPABILITY,
async (event) => {
this.#getSession(event);
return this.#docxExportService.getCapability();
}
);
ipcMain.handle(
DESKTOP_EXPORT_DOCX,
async (
event,
unsafeRequest: unknown
): Promise<DesktopDocxExportOutcome> => {
const session = this.#getSession(event);
const abortController = new AbortController();
const abort = () =>
abortController.abort(
new Error("桌面应用窗口已关闭")
);
event.sender.once("destroyed", abort);
try {
const generated =
await this.#docxExportService.generate(
unsafeRequest,
{
mediaAdapter: this.#docxMediaEngine,
imageContext: session.documentRoot
? {
localRoot: session.documentRoot,
allowUnrestrictedLocalFiles: true
}
: {},
signal: abortController.signal
}
);
if (
session.window.isDestroyed() ||
event.sender.isDestroyed()
) {
return {
ok: false,
error: "DOCX_RENDER_TIMEOUT",
message: "DOCX 导出已取消",
retryable: false
};
}
const selection = await dialog.showSaveDialog(
session.window,
{
title: "导出 DOCX",
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
generated.fileName
),
filters: [
{
name: "Word 文档",
extensions: ["docx"]
}
]
}
);
if (selection.canceled || !selection.filePath) {
return {
ok: true,
saved: false,
fileName: generated.fileName,
diagnostics: generated.diagnostics,
timings: generated.timings
};
}
const saved = await saveDesktopDocx(
selection.filePath,
generated.docx
);
await this.#recordSuccessfulFile(saved.targetPath);
return {
ok: true,
saved: true,
fileName: saved.fileName,
diagnostics: generated.diagnostics,
timings: generated.timings
};
} catch (error) {
return toDesktopDocxExportFailure(error);
} finally {
event.sender.removeListener("destroyed", abort);
}
}
);
}
async #openDocumentLink(
+23
View File
@@ -0,0 +1,23 @@
import path from "node:path";
import { writeFile } from "node:fs/promises";
export function resolveDesktopDocxTargetPath(selectedPath: string) {
return selectedPath.toLowerCase().endsWith(".docx")
? selectedPath
: `${selectedPath}.docx`;
}
export async function saveDesktopDocx(
selectedPath: string,
content: Uint8Array
) {
if (!selectedPath || content.byteLength === 0) {
throw new Error("DOCX 保存参数无效");
}
const targetPath = resolveDesktopDocxTargetPath(selectedPath);
await writeFile(targetPath, content);
return {
targetPath,
fileName: path.basename(targetPath)
};
}
+83
View File
@@ -0,0 +1,83 @@
import {
ApplicationRequestError,
DocxExportServiceError
} from "@md-to-pdf/application";
import type {
DocxCapability,
DocxExportDiagnostics,
DocxExportErrorCode,
DocxGenerationTimings
} from "@md-to-pdf/core";
import { docxExportErrorCodeSchema } from "@md-to-pdf/core";
export interface DesktopDocxExportSuccess {
ok: true;
saved: boolean;
fileName: string;
diagnostics: DocxExportDiagnostics;
timings: DocxGenerationTimings;
}
export interface DesktopDocxExportFailure {
ok: false;
error: DocxExportErrorCode;
message: string;
retryable: boolean;
}
export type DesktopDocxExportOutcome =
| DesktopDocxExportSuccess
| DesktopDocxExportFailure;
export type DesktopDocxCapability = DocxCapability;
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
const cause = error.cause;
if (!(cause instanceof Error)) {
return undefined;
}
return /^ DOCX (?: \d+ms| \d+\/\d+ (?:image|mermaid|echarts)PNG \d+ms)$/u.test(
cause.message
)
? cause.message
: undefined;
}
export function toDesktopDocxExportFailure(
error: unknown
): DesktopDocxExportFailure {
if (error instanceof ApplicationRequestError) {
const parsedCode = docxExportErrorCodeSchema.safeParse(
error.code
);
if (!parsedCode.success) {
return {
ok: false,
error: "DOCX_GENERATION_FAILED",
message: "DOCX 导出失败",
retryable: false
};
}
return {
ok: false,
error: parsedCode.data,
message: error.message,
retryable: false
};
}
if (error instanceof DocxExportServiceError) {
const detail = readSafeDesktopMediaDetail(error);
return {
ok: false,
error: error.code,
message: detail ? `${error.message}${detail}` : error.message,
retryable: error.retryable
};
}
return {
ok: false,
error: "DOCX_GENERATION_FAILED",
message: "DOCX 导出失败",
retryable: false
};
}
@@ -0,0 +1,146 @@
import type { WebContents } from "electron";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
interface CaptureScreenshotResult {
data: string;
}
export interface ElectronDocxMediaCaptureTimeouts {
renderMs: number;
screenshotMs: number;
}
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
renderMs: 30_000,
screenshotMs: 20_000
};
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
signal?: AbortSignal
) {
return new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
callback();
};
const abort = () =>
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
const timeout = setTimeout(
() => finish(() => reject(new Error(message))),
timeoutMs
);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
operation.then(
(value) => finish(() => resolve(value)),
(error) => finish(() => reject(error))
);
});
}
function createRenderScript(request: DocxMediaCaptureRequest) {
const serialized = JSON.stringify(request);
return `(async () => {
const request = ${serialized};
const renderer = window.__mdToPdfRenderDocxMedia;
if (!renderer) {
throw new Error("DOCX 媒体渲染运行时不可用");
}
return renderer(request.payload, request.dimensions);
})()`;
}
export async function captureDocxMediaWithElectronWebContents(
webContents: WebContents,
request: DocxMediaCaptureRequest,
signal?: AbortSignal,
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
) {
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
webContents.debugger.attach("1.3");
}
try {
await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", {
media: "print"
});
const plan = (await withTimeout(
webContents.executeJavaScript(createRenderScript(request), true),
timeouts.renderMs,
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
signal
)) as DocxMediaCapturePlan;
signal?.throwIfAborted();
const captures = [];
for (const target of plan.targets) {
signal?.throwIfAborted();
const result = (await withTimeout(
webContents.debugger.sendCommand(
"Page.captureScreenshot",
{
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
}
),
timeouts.screenshotMs,
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind}PNG 捕获超过 ${timeouts.screenshotMs}ms`,
signal
)) as CaptureScreenshotResult;
captures.push({
id: target.id,
png: Buffer.from(result.data, "base64")
});
}
signal?.throwIfAborted();
return { plan, captures };
} finally {
if (webContents.debugger.isAttached()) {
await webContents.debugger
.sendCommand("Emulation.setEmulatedMedia", { media: "screen" })
.catch(() => undefined);
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
}
}
}
}
export class ElectronDocxMediaCaptureAdapter
implements DocxMediaCaptureAdapter
{
constructor(private readonly webContents: WebContents) {}
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
return captureDocxMediaWithElectronWebContents(
this.webContents,
request,
signal
);
}
}
@@ -0,0 +1,248 @@
import { BrowserWindow } from "electron";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type {
DocxThemeStyleCaptureAdapter,
DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
import { captureDocxThemeStyleWithElectronWebContents } from "./electron-docx-theme-style.js";
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
export const DESKTOP_DOCX_PARTITION =
"md-to-pdf-desktop-docx";
export interface ElectronDocxMediaEngineOptions {
renderUrl: string;
}
export async function withActiveDocxCaptureSurface<T>(
window: Pick<
BrowserWindow,
"showInactive" | "hide" | "isDestroyed"
>,
operation: () => Promise<T>,
settleMs = 50
) {
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, settleMs));
try {
return await operation();
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
}
export class ElectronDocxMediaEngine
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
{
readonly themeStyleBaseUrl: string;
private readonly renderUrl: string;
private windowPromise: Promise<BrowserWindow> | undefined;
private queue = Promise.resolve();
private closed = false;
constructor(options: ElectronDocxMediaEngineOptions) {
this.renderUrl = options.renderUrl;
this.themeStyleBaseUrl = this.renderUrl;
}
capture(
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
}
const operation = this.queue.then(() =>
this.captureNow(request, signal)
);
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
captureThemeStyle(
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
}
const operation = this.queue.then(() =>
this.captureThemeStyleNow(request, signal)
);
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
async close() {
this.closed = true;
await this.queue;
const windowPromise = this.windowPromise;
this.windowPromise = undefined;
if (!windowPromise) {
return;
}
try {
const window = await windowPromise;
if (!window.isDestroyed()) {
window.destroy();
}
} catch {
// 创建失败时没有需要关闭的窗口。
}
}
private async captureNow(
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
signal?.throwIfAborted();
const window = await this.getWindow();
signal?.throwIfAborted();
const abort = () => {
if (!window.isDestroyed()) {
window.destroy();
}
};
signal?.addEventListener("abort", abort, { once: true });
try {
return await withActiveDocxCaptureSurface(
window,
() =>
captureDocxMediaWithElectronWebContents(
window.webContents,
request,
signal
)
);
} finally {
signal?.removeEventListener("abort", abort);
}
}
private async captureThemeStyleNow(
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
) {
signal?.throwIfAborted();
if (
new URL(request.baseUrl).origin !==
new URL(this.renderUrl).origin
) {
throw new Error("桌面 DOCX 主题资源地址必须与渲染地址同源");
}
const window = await this.getWindow();
signal?.throwIfAborted();
const abort = () => {
if (!window.isDestroyed()) {
window.destroy();
}
};
signal?.addEventListener("abort", abort, { once: true });
try {
return await captureDocxThemeStyleWithElectronWebContents(
window.webContents,
request,
signal
);
} finally {
signal?.removeEventListener("abort", abort);
}
}
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
this.windowPromise = undefined;
throw error;
});
}
return this.windowPromise;
}
private async createWindow() {
const window = new BrowserWindow({
show: false,
focusable: false,
opacity: 0,
skipTaskbar: true,
x: -32_000,
y: -32_000,
width: 1280,
height: 900,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: DESKTOP_DOCX_PARTITION
}
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
const target = new URL(url);
const renderTarget = new URL(this.renderUrl);
if (
target.origin !== renderTarget.origin ||
target.pathname !== renderTarget.pathname
) {
event.preventDefault();
}
});
window.webContents.on("destroyed", () => {
this.windowPromise = undefined;
});
window.webContents.session.webRequest.onBeforeRequest(
{ urls: ["*://*/*"] },
(details, callback) => {
callback({
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
});
}
);
try {
await window.loadURL(this.renderUrl);
await window.webContents.executeJavaScript(`
new Promise((resolve, reject) => {
if (document.documentElement.dataset.runtimeReady === "true") {
resolve();
return;
}
const timeout = setTimeout(() => {
observer.disconnect();
reject(new Error("桌面 DOCX 媒体运行时启动超时"));
}, 10000);
const observer = new MutationObserver(() => {
if (document.documentElement.dataset.runtimeReady === "true") {
clearTimeout(timeout);
observer.disconnect();
resolve();
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-runtime-ready"]
});
})
`);
window.webContents.setZoomFactor(1);
return window;
} catch (error) {
window.destroy();
throw error;
}
}
}
@@ -0,0 +1,45 @@
import {
createDocxThemeStyleCaptureScript,
parseDocxThemeStyleRuntimeCapture,
type DocxThemeStyleCaptureRequest,
type DocxThemeStyleSnapshot
} from "@md-to-pdf/docx-theme-engine";
import type { WebContents } from "electron";
export async function captureDocxThemeStyleWithElectronWebContents(
webContents: WebContents,
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
): Promise<DocxThemeStyleSnapshot> {
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
webContents.debugger.attach("1.3");
}
try {
await webContents.debugger.sendCommand(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
const capture = await webContents.executeJavaScript(
createDocxThemeStyleCaptureScript(request),
true
);
signal?.throwIfAborted();
return parseDocxThemeStyleRuntimeCapture(request, capture);
} finally {
if (
!webContents.isDestroyed() &&
webContents.debugger.isAttached()
) {
await webContents.debugger
.sendCommand("Emulation.setEmulatedMedia", {
media: "screen"
})
.catch(() => undefined);
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
}
}
}
}
+124 -13
View File
@@ -6,25 +6,32 @@ import {
session
} from "electron";
import { access, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
type ApplicationService
} from "@md-to-pdf/application";
import { parseThemeResourceUrl } from "./application-contract.js";
import {
parseFontPackResourceUrl,
parseThemeResourceUrl
} from "./application-contract.js";
import {
DesktopApplicationController
} from "./desktop-application-controller.js";
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
import {
findMarkdownFileArgument,
isMarkdownFilePath
} from "./markdown-file.js";
import { migrateLegacyUserData } from "./user-data-migration.js";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const FONT_PACK_HOST = "font-pack";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
let applicationController: DesktopApplicationController | undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument(
@@ -32,6 +39,28 @@ let pendingExternalMarkdownPath = findMarkdownFileArgument(
process.cwd()
);
function configurePackagedUserDataDirectory() {
if (!app.isPackaged) {
return Promise.resolve();
}
const appDataDirectory = app.getPath("appData");
const targetDirectory = path.join(
appDataDirectory,
__APP_ENGLISH_NAME__
);
const targetAlreadyExists = existsSync(targetDirectory);
app.setPath("userData", targetDirectory);
if (targetAlreadyExists) {
return Promise.resolve();
}
return migrateLegacyUserData(
[path.join(appDataDirectory, __APP_LEGACY_DISPLAY_NAME__)],
targetDirectory
).then(() => undefined);
}
const userDataMigration = configurePackagedUserDataDirectory();
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
@@ -101,8 +130,32 @@ function getDesktopThemeDirectory() {
: path.resolve(currentDirectory, "../../..", ".local", "themes");
}
function getDesktopUserFontPackDirectory() {
if (!app.isPackaged) {
return path.resolve(
currentDirectory,
"../../..",
".local",
"font-packs"
);
}
const localApplicationData = process.env.LOCALAPPDATA?.trim();
return localApplicationData
? path.join(localApplicationData, "md-to-pdf", "font-packs")
: path.join(app.getPath("userData"), "font-packs");
}
function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../..");
const fontPackRoots = app.isPackaged
? [
path.join(process.resourcesPath, "font-packs"),
getDesktopUserFontPackDirectory()
]
: [
path.join(projectRoot, "output", "font-packs", "root"),
getDesktopUserFontPackDirectory()
];
return createApplicationService({
bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes")
@@ -110,6 +163,22 @@ function createDesktopApplicationService() {
localRoot: getDesktopThemeDirectory(),
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
fontPacks: {
roots: fontPackRoots,
appVersion: app.getVersion(),
createAssetUrl: (
packId,
version,
faceId,
kind,
sha256
) =>
`${APP_SCHEME}://${FONT_PACK_HOST}/${encodeURIComponent(
packId
)}/${encodeURIComponent(version)}/${encodeURIComponent(
faceId
)}/${kind}?v=${sha256}`
},
onWarning: (message) => console.warn(message)
});
}
@@ -154,6 +223,34 @@ async function registerApplicationProtocol(
return new Response("Not found", { status: 404 });
}
}
if (url.host === FONT_PACK_HOST) {
try {
const resource = parseFontPackResourceUrl(request.url);
if (!resource) {
return new Response("Not found", { status: 404 });
}
const asset = await applicationService.getFontPackAsset(
resource.packId,
resource.packVersion,
resource.faceId,
resource.kind
);
if (!asset) {
return new Response("Not found", { status: 404 });
}
return new Response(asset.content, {
headers: {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=31536000, immutable",
"content-type": asset.contentType,
etag: `\"${asset.sha256}\"`,
"x-content-type-options": "nosniff"
}
});
} catch {
return new Response("Not found", { status: 404 });
}
}
if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 });
}
@@ -182,6 +279,9 @@ async function registerApplicationProtocol(
protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_PDF_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_DOCX_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest)
]);
}
@@ -219,23 +319,24 @@ if (!hasSingleInstanceLock) {
return;
}
event.preventDefault();
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
app.on("will-quit", () => {
void applicationController?.closeResources();
void Promise.all([
applicationController.flushWindowState(),
applicationController.closeResources()
]).finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
app
.whenReady()
.then(async () => {
await userDataMigration;
Menu.setApplicationMenu(null);
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
app.setAppUserModelId(__APP_ID__);
}
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
@@ -260,12 +361,22 @@ if (!hasSingleInstanceLock) {
currentDirectory,
"pdf-preload.cjs"
),
docxRenderUrl: new URL(
"/preview-frame.html?target=continuous",
applicationUrl
).href,
desktopResourcesPath: process.resourcesPath,
iconPath: getApplicationIcon(),
themeDirectory,
windowStatePath: path.join(
app.getPath("userData"),
"window-state.json"
)
),
saveLocationStatePath: path.join(
app.getPath("userData"),
"save-location.json"
),
documentsDirectory: app.getPath("documents")
});
await applicationController.initialize();
+8 -1
View File
@@ -1,5 +1,6 @@
import {
exportConfigSchema,
semanticDocumentModelSchema,
themeFeatureSchema,
type PagedDocumentPayload,
type PagedDocumentRenderResult
@@ -66,6 +67,9 @@ export function parsePagedDocumentPayload(
),
language: readString(metadata, "language", 50)
},
semanticDocument: semanticDocumentModelSchema.parse(
value.semanticDocument
),
features: parsedFeatures,
themeCss: readString(
value,
@@ -117,7 +121,10 @@ export function isAllowedPdfRuntimeUrl(
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true;
}
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
if (
requested.protocol === "mdpdf:" &&
["theme", "font-pack"].includes(requested.host)
) {
return true;
}
return requested.origin === new URL(renderUrl).origin;
+164
View File
@@ -0,0 +1,164 @@
import {
mkdir,
readFile,
rename,
rm,
stat,
writeFile
} from "node:fs/promises";
import path from "node:path";
const SAVE_LOCATION_STATE_VERSION = 1;
export interface SaveLocationState {
version: 1;
directory: string;
}
export interface ResolveDefaultSavePathOptions {
sourceFilePath?: string;
lastSaveDirectory?: string;
documentsDirectory: string;
suggestedFileName: string;
}
export function parseSaveLocationState(
value: unknown
): SaveLocationState | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const state = value as Partial<SaveLocationState>;
if (
state.version !== SAVE_LOCATION_STATE_VERSION ||
typeof state.directory !== "string" ||
!path.isAbsolute(state.directory)
) {
return undefined;
}
return {
version: SAVE_LOCATION_STATE_VERSION,
directory: path.resolve(state.directory)
};
}
async function isAvailableDirectory(directory: string | undefined) {
if (!directory || !path.isAbsolute(directory)) {
return false;
}
try {
return (await stat(directory)).isDirectory();
} catch {
return false;
}
}
function assertSuggestedFileName(value: string) {
const fileName = path.basename(value.trim());
if (!fileName || fileName === ".") {
throw new Error("默认保存文件名无效");
}
return fileName;
}
export async function resolveDefaultSavePath(
options: ResolveDefaultSavePathOptions
) {
const fileName = assertSuggestedFileName(
options.suggestedFileName
);
const sourceDirectory = options.sourceFilePath
? path.dirname(path.resolve(options.sourceFilePath))
: undefined;
if (await isAvailableDirectory(sourceDirectory)) {
return path.join(sourceDirectory!, fileName);
}
if (await isAvailableDirectory(options.lastSaveDirectory)) {
return path.join(options.lastSaveDirectory!, fileName);
}
return path.join(
path.resolve(options.documentsDirectory),
fileName
);
}
export async function loadSaveLocationState(filePath: string) {
try {
return parseSaveLocationState(
JSON.parse(await readFile(filePath, "utf8")) as unknown
);
} catch {
return undefined;
}
}
export async function saveSaveLocationState(
filePath: string,
directory: string
) {
const state = parseSaveLocationState({
version: SAVE_LOCATION_STATE_VERSION,
directory
});
if (!state) {
throw new Error("最近保存目录无效");
}
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8"
);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
return state;
}
export class SaveLocationStore {
readonly #statePath: string;
readonly #documentsDirectory: string;
#lastSaveDirectory: string | undefined;
#pendingWrite = Promise.resolve();
constructor(statePath: string, documentsDirectory: string) {
this.#statePath = statePath;
this.#documentsDirectory = documentsDirectory;
}
async initialize() {
this.#lastSaveDirectory = (
await loadSaveLocationState(this.#statePath)
)?.directory;
}
resolveDefaultPath(
sourceFilePath: string | undefined,
suggestedFileName: string
) {
return resolveDefaultSavePath({
documentsDirectory: this.#documentsDirectory,
suggestedFileName,
...(sourceFilePath ? { sourceFilePath } : {}),
...(this.#lastSaveDirectory
? { lastSaveDirectory: this.#lastSaveDirectory }
: {})
});
}
async recordSuccessfulFile(filePath: string) {
const directory = path.dirname(path.resolve(filePath));
this.#lastSaveDirectory = directory;
const write = this.#pendingWrite
.catch(() => undefined)
.then(() =>
saveSaveLocationState(this.#statePath, directory)
);
this.#pendingWrite = write.then(() => undefined);
await write;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { cp, lstat, mkdir } from "node:fs/promises";
import path from "node:path";
const MIGRATED_ENTRIES = [
"window-state.json",
"save-location.json",
"themes",
"Local Storage",
"Session Storage",
"IndexedDB"
] as const;
async function readOrdinaryStatus(target: string) {
try {
const status = await lstat(target);
return status.isSymbolicLink() ? undefined : status;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw error;
}
}
export interface LegacyUserDataMigrationResult {
migrated: boolean;
sourceDirectory?: string;
copiedEntries: string[];
}
export async function migrateLegacyUserData(
legacyDirectories: readonly string[],
targetDirectory: string
): Promise<LegacyUserDataMigrationResult> {
if (await readOrdinaryStatus(targetDirectory)) {
return { migrated: false, copiedEntries: [] };
}
for (const legacyDirectory of legacyDirectories) {
if (path.resolve(legacyDirectory) === path.resolve(targetDirectory)) {
continue;
}
const legacyStatus = await readOrdinaryStatus(legacyDirectory);
if (!legacyStatus?.isDirectory()) {
continue;
}
await mkdir(targetDirectory, { recursive: true });
const copiedEntries: string[] = [];
for (const entry of MIGRATED_ENTRIES) {
const source = path.join(legacyDirectory, entry);
if (!(await readOrdinaryStatus(source))) {
continue;
}
await cp(source, path.join(targetDirectory, entry), {
recursive: true,
errorOnExist: true,
force: false
});
copiedEntries.push(entry);
}
return {
migrated: true,
sourceDirectory: legacyDirectory,
copiedEntries
};
}
return { migrated: false, copiedEntries: [] };
}
@@ -0,0 +1,57 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveDesktopDocxTargetPath,
saveDesktopDocx
} from "../src/desktop-docx-save.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
async function createTemporaryDirectory() {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-desktop-docx-save-")
);
temporaryDirectories.push(directory);
return directory;
}
describe("Desktop DOCX 原生保存", () => {
it("在用户未填写扩展名时补充 .docx 并原样落盘", async () => {
const directory = await createTemporaryDirectory();
const content = Uint8Array.of(80, 75, 3, 4, 1, 2, 3);
const selectedPath = path.join(directory, "桌面验收");
const saved = await saveDesktopDocx(selectedPath, content);
expect(saved).toEqual({
targetPath: `${selectedPath}.docx`,
fileName: "桌面验收.docx"
});
expect(await readFile(saved.targetPath)).toEqual(Buffer.from(content));
});
it("大小写不敏感地保留已有 DOCX 扩展名", () => {
expect(resolveDesktopDocxTargetPath("报告.DOCX")).toBe(
"报告.DOCX"
);
});
it("拒绝空路径或空内容", async () => {
await expect(
saveDesktopDocx("", Uint8Array.of(80, 75))
).rejects.toThrow("DOCX 保存参数无效");
await expect(
saveDesktopDocx("报告.docx", new Uint8Array())
).rejects.toThrow("DOCX 保存参数无效");
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
ApplicationRequestError,
DocxExportServiceError
} from "@md-to-pdf/application";
import { toDesktopDocxExportFailure } from "../src/docx-contract.js";
describe("Desktop DOCX IPC 契约", () => {
it("映射应用请求错误", () => {
expect(
toDesktopDocxExportFailure(
new ApplicationRequestError(
400,
"INVALID_EXPORT_CONFIG",
"导出配置无效"
)
)
).toEqual({
ok: false,
error: "INVALID_EXPORT_CONFIG",
message: "导出配置无效",
retryable: false
});
});
it("保留共享服务的重试语义", () => {
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
429,
"DOCX_QUEUE_FULL",
"DOCX 生成队列已满",
true
)
)
).toEqual({
ok: false,
error: "DOCX_QUEUE_FULL",
message: "DOCX 生成队列已满",
retryable: true
});
});
it("仅透传格式受控的桌面媒体超时详情", () => {
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
500,
"DOCX_GENERATION_FAILED",
"DOCX 导出失败",
false,
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
)
)
).toMatchObject({
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
});
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
500,
"DOCX_GENERATION_FAILED",
"DOCX 导出失败",
false,
{
cause: new Error(
"桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 20000ms"
)
}
)
)
).toMatchObject({
message:
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 20000ms"
});
});
it("保留 Front Matter 解析错误", () => {
expect(
toDesktopDocxExportFailure(
new ApplicationRequestError(
400,
"INVALID_FRONT_MATTER",
"无法解析 Markdown Front Matter"
)
)
).toEqual({
ok: false,
error: "INVALID_FRONT_MATTER",
message: "无法解析 Markdown Front Matter",
retryable: false
});
});
it("隐藏未知主进程错误细节", () => {
expect(
toDesktopDocxExportFailure(
new Error("C:\\secret\\pandoc.exe 启动失败")
)
).toEqual({
ok: false,
error: "DOCX_GENERATION_FAILED",
message: "DOCX 导出失败",
retryable: false
});
});
});
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from "vitest";
import type { WebContents } from "electron";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { captureDocxMediaWithElectronWebContents } from "../src/electron-docx-media-capture.js";
const plan: DocxMediaCapturePlan = {
targets: [
{
id: "docx-media-1",
kind: "image",
ordinal: 1,
kindOrdinal: 1,
altText: "图片",
alignment: "center",
displayWidthPx: 200,
displayHeightPx: 100,
captureX: 12,
captureY: 24,
captureWidthPx: 200,
captureHeightPx: 100,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Electron DOCX 媒体捕获", () => {
it("复用隐藏 Chromium 并在捕获后释放调试会话", async () => {
let attached = false;
const attach = vi.fn(() => {
attached = true;
});
const detach = vi.fn(() => {
attached = false;
});
const sendCommand = vi.fn(async () => ({
data: Buffer.from("png").toString("base64")
}));
const webContents = {
executeJavaScript: vi.fn(async () => plan),
debugger: {
isAttached: vi.fn(() => attached),
attach,
detach,
sendCommand
}
} as unknown as WebContents;
const result = await captureDocxMediaWithElectronWebContents(
webContents,
request
);
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
});
expect(attach).toHaveBeenCalledWith("1.3");
expect(sendCommand).toHaveBeenCalledWith(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
expect(sendCommand).toHaveBeenCalledWith(
"Emulation.setEmulatedMedia",
{ media: "screen" }
);
expect(sendCommand).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
format: "png",
clip: expect.objectContaining({ scale: 3.125 })
})
);
expect(detach).toHaveBeenCalledOnce();
});
it("媒体布局永久不返回时报告局部超时", async () => {
let attached = false;
const webContents = {
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
debugger: {
isAttached: vi.fn(() => attached),
attach: vi.fn(() => {
attached = true;
}),
detach: vi.fn(() => {
attached = false;
}),
sendCommand: vi.fn(async () => undefined)
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
});
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
let attached = false;
const detach = vi.fn(() => {
attached = false;
});
const webContents = {
executeJavaScript: vi.fn(async () => plan),
debugger: {
isAttached: vi.fn(() => attached),
attach: vi.fn(() => {
attached = true;
}),
detach,
sendCommand: vi.fn((method: string) =>
method === "Page.captureScreenshot"
? new Promise(() => undefined)
: Promise.resolve(undefined)
)
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow(
"桌面 DOCX 第 1/1 个媒体(imagePNG 捕获超过 10ms"
);
expect(detach).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
function createWindow() {
let destroyed = false;
return {
showInactive: vi.fn(),
hide: vi.fn(),
isDestroyed: vi.fn(() => destroyed),
destroy: () => {
destroyed = true;
}
};
}
describe("Electron DOCX 捕获合成表面", () => {
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
const window = createWindow();
const operation = vi.fn(async () => "captured");
await expect(
withActiveDocxCaptureSurface(window, operation, 0)
).resolves.toBe("captured");
expect(window.showInactive).toHaveBeenCalledOnce();
expect(operation).toHaveBeenCalledOnce();
expect(window.hide).toHaveBeenCalledOnce();
});
it("窗口在失败期间销毁时不再隐藏", async () => {
const window = createWindow();
await expect(
withActiveDocxCaptureSurface(
window,
async () => {
window.destroy();
throw new Error("capture failed");
},
0
)
).rejects.toThrow("capture failed");
expect(window.showInactive).toHaveBeenCalledOnce();
expect(window.hide).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
import {
DOCX_STYLE_SLOT_NAMES,
type DocxComputedStyle,
type DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { describe, expect, it, vi } from "vitest";
import { captureDocxThemeStyleWithElectronWebContents } from "../src/electron-docx-theme-style.js";
const computed: DocxComputedStyle = {
fontFamily: "Arial",
fontSize: "16px",
fontWeight: "400",
fontStyle: "normal",
color: "rgb(0, 0, 0)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineHeight: "normal",
letterSpacing: "normal",
textAlign: "start",
direction: "ltr",
textIndent: "0px",
textDecorationLine: "none",
marginTop: "0px",
marginRight: "0px",
marginBottom: "0px",
marginLeft: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
borderTop: "0px none rgb(0, 0, 0)",
borderRight: "0px none rgb(0, 0, 0)",
borderBottom: "0px none rgb(0, 0, 0)",
borderLeft: "0px none rgb(0, 0, 0)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
breakAfter: "auto",
breakInside: "auto",
display: "block",
flexDirection: "row",
alignItems: "normal",
justifyContent: "normal",
outline: "rgb(0, 0, 0) none 0px",
outlineOffset: "0px"
};
const request: DocxThemeStyleCaptureRequest = {
themeId: "external-clean",
themeFingerprint: "b".repeat(64),
themeCss: "#write { color: #000; }",
baseUrl: "mdpdf://app/"
};
describe("Electron DOCX 主题样式采集", () => {
it("通过 CDP 切换打印媒体并在结束后恢复", async () => {
let attached = false;
const sendCommand = vi.fn(async () => undefined);
const webContents = {
debugger: {
isAttached: () => attached,
attach: vi.fn(() => {
attached = true;
}),
detach: vi.fn(() => {
attached = false;
}),
sendCommand
},
executeJavaScript: vi.fn(async () => ({
rootFontSizePx: 16,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
matched: true,
computed
}))
})),
isDestroyed: () => false
};
const snapshot =
await captureDocxThemeStyleWithElectronWebContents(
webContents as never,
request
);
expect(sendCommand).toHaveBeenNthCalledWith(
1,
"Emulation.setEmulatedMedia",
{ media: "print" }
);
expect(sendCommand).toHaveBeenLastCalledWith(
"Emulation.setEmulatedMedia",
{ media: "screen" }
);
expect(webContents.debugger.detach).toHaveBeenCalledTimes(1);
expect(snapshot.slots).toHaveLength(
DOCX_STYLE_SLOT_NAMES.length
);
});
});
@@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { strToU8, zipSync } from "fflate";
import {
extractRequiredFiles,
sha256
} from "../scripts/prepare-pandoc-runtime.mjs";
const artifact = {
executableRelativePath: "pandoc.exe",
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"]
};
function archive(files: Record<string, string>) {
return zipSync(
Object.fromEntries(
Object.entries(files).map(([name, content]) => [
name,
strToU8(content)
])
)
);
}
describe("Desktop Pandoc 最小运行时构建", () => {
it("仅提取可执行文件和两份许可证文件", () => {
const files = extractRequiredFiles(
archive({
"pandoc-3.9.0.2/pandoc.exe": "executable",
"pandoc-3.9.0.2/COPYING.rtf": "license",
"pandoc-3.9.0.2/COPYRIGHT.txt": "copyright",
"pandoc-3.9.0.2/MANUAL.html": "manual"
}),
artifact
);
expect([...files.keys()].sort()).toEqual([
"COPYING.rtf",
"COPYRIGHT.txt",
"pandoc.exe"
]);
});
it("拒绝缺失许可证文件的归档", () => {
expect(() =>
extractRequiredFiles(
archive({
"pandoc.exe": "executable",
"COPYING.rtf": "license"
}),
artifact
)
).toThrow("COPYRIGHT.txt");
});
it("拒绝用不同目录伪造的重复文件", () => {
expect(() =>
extractRequiredFiles(
archive({
"official/pandoc.exe": "official",
"shadow/pandoc.exe": "shadow",
"official/COPYING.rtf": "license",
"official/COPYRIGHT.txt": "copyright"
}),
artifact
)
).toThrow("重复文件:pandoc.exe");
});
it("使用大写十六进制输出稳定 SHA-256", () => {
const content = strToU8("MorphDoc");
expect(sha256(content)).toBe(
createHash("sha256").update(content).digest("hex").toUpperCase()
);
});
});
+33
View File
@@ -10,6 +10,7 @@ import {
} from "../src/pdf-contract.js";
import {
parseMarkdownRenderRequest,
parseFontPackResourceUrl,
parseThemeId,
parseThemeResourceUrl
} from "../src/application-contract.js";
@@ -26,6 +27,14 @@ const payload = createPagedDocumentPayload({
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: ["mermaid"],
warnings: []
},
@@ -97,6 +106,12 @@ describe("桌面 PDF 网络边界", () => {
renderUrl
)
).toBe(true);
expect(
isAllowedPdfRuntimeUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
renderUrl
)
).toBe(true);
});
it("拒绝外部网络资源", () => {
@@ -135,4 +150,22 @@ describe("桌面应用服务 IPC 载荷", () => {
parseThemeResourceUrl("mdpdf://bundle/index.html")
).toBeUndefined();
});
it("只接受字体包 Web 资源地址", () => {
expect(
parseFontPackResourceUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif-regular/web?v=abc"
)
).toEqual({
packId: "official-cjk",
packVersion: "1.0.0",
faceId: "serif-regular",
kind: "web"
});
expect(() =>
parseFontPackResourceUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif-regular/docx"
)
).toThrow("字体包资源地址无效");
});
});
+190 -4
View File
@@ -3,14 +3,36 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
interface PackageManifest {
version?: string;
productName?: string;
scripts?: Record<string, string>;
}
interface ProductBrand {
appId: string;
englishName: string;
displayName: string;
windowTitle: string;
executableName: string;
artifactPrefix: string;
}
interface PandocRuntimeManifest {
version: string;
artifacts: Array<{
platform: string;
architecture: string;
sha256: string;
licenseFiles: string[];
files?: Record<string, { bytes: number; sha256: string }>;
}>;
}
const desktopRoot = fileURLToPath(new URL("../", import.meta.url));
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
function readManifest(path: string): PackageManifest {
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
function readManifest<T = PackageManifest>(path: string): T {
return JSON.parse(readFileSync(path, "utf8")) as T;
}
describe("桌面发行构建链", () => {
@@ -43,9 +65,64 @@ describe("桌面发行构建链", () => {
expect(desktopManifest.scripts?.make).toMatch(
/^npm run build:embedded-web && /
);
expect(rootManifest.scripts?.["desktop:package"]).toBe(
"npm run package -w @md-to-pdf/desktop"
expect(desktopManifest.scripts?.package).toContain(
"npm run prepare:pandoc"
);
expect(desktopManifest.scripts?.make).toContain(
"npm run prepare:pandoc"
);
expect(rootManifest.scripts?.["desktop:package"]).toBe(
"npm run build:font-pack && npm run package -w @md-to-pdf/desktop"
);
expect(rootManifest.scripts?.["desktop:make"]).toBe(
"npm run build:font-pack && node scripts/build-desktop-release.mjs"
);
expect(rootManifest.scripts?.["desktop:make:with-font-pack"]).toBeUndefined();
});
it("将固定 Pandoc 最小运行时作为 Desktop 独立资源打包", () => {
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
const runtimeManifest = readManifest<PandocRuntimeManifest>(
`${projectRoot}/packages/docx-engine/src/pandoc-runtime.json`
);
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const prepareScript = readFileSync(
`${desktopRoot}/scripts/prepare-pandoc-runtime.mjs`,
"utf8"
);
const windowsArtifact = runtimeManifest.artifacts.find(
(artifact) =>
artifact.platform === "win32" && artifact.architecture === "x64"
);
expect(runtimeManifest.version).toBe("3.9.0.2");
expect(windowsArtifact).toMatchObject({
sha256:
"C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A",
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"],
files: {
"pandoc.exe": {
bytes: 231056136,
sha256:
"E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA"
}
}
});
expect(desktopManifest.scripts?.["prepare:pandoc"]).toBe(
"node scripts/prepare-pandoc-runtime.mjs"
);
expect(builderConfig).toContain(
"`.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`"
);
expect(builderConfig).toContain('"pandoc.exe"');
expect(builderConfig).toContain('"COPYING.rtf"');
expect(builderConfig).toContain('"COPYRIGHT.txt"');
expect(prepareScript).toContain("sha256(downloaded)");
expect(prepareScript).toContain("smokeTestPandoc");
expect(prepareScript).toContain('process.argv.includes("--check")');
});
it("将内置主题示例作为独立资源复制到发行目录", () => {
@@ -58,6 +135,115 @@ describe("桌面发行构建链", () => {
expect(builderConfig).toContain('to: "samples"');
});
it("保持安装身份并从统一配置生成 MorphDoc 发行名称", () => {
const brand = readManifest<ProductBrand>(
`${projectRoot}/product.json`
);
const desktopManifest = readManifest(
`${desktopRoot}/package.json`
);
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
expect(brand).toMatchObject({
appId: "com.md-to-pdf.desktop",
englishName: "MorphDoc",
displayName: "墨呈",
windowTitle: "墨呈 · MorphDoc",
executableName: "MorphDoc",
artifactPrefix: "MorphDoc"
});
expect(desktopManifest).toMatchObject({
version: "0.6.2",
productName: "MorphDoc"
});
expect(builderConfig).toContain("appId: brand.appId");
expect(builderConfig).toContain("productName: brand.englishName");
expect(builderConfig).toContain("executableName: brand.executableName");
expect(builderConfig).toContain("shortcutName: brand.displayName");
expect(installerInclude).toContain(
'!define MORPHDOC_EXECUTABLE "MorphDoc.exe"'
);
expect(installerInclude).toContain(
'!define MORPHDOC_DISPLAY_NAME "墨呈"'
);
expect(installerInclude).toContain(
'!define INSTALL_REGISTRY_KEY "Software\\${APP_GUID}.MorphDoc"'
);
expect(installerInclude).toContain(
'DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"'
);
expect(installerInclude).toContain(
"Software\\Classes\\Applications\\${MORPHDOC_LEGACY_EXECUTABLE}"
);
});
it("在安装版与便携版中强制内置完整字体包集合", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const desktopMain = readFileSync(
`${desktopRoot}/src/main.ts`,
"utf8"
);
const bundledBuild = readFileSync(
`${projectRoot}/scripts/build-desktop-release.mjs`,
"utf8"
);
const installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
expect(builderConfig).toContain('../../output/font-packs/root');
expect(builderConfig).toContain('to: "font-packs"');
expect(desktopMain).toContain(
'path.join(process.resourcesPath, "font-packs")'
);
expect(desktopMain).toContain("getDesktopUserFontPackDirectory()");
expect(bundledBuild).not.toContain("createDesktopCompanionInclude");
expect(bundledBuild).toContain("已生成字体直接内置");
expect(installerInclude).not.toContain("FONT_PACK_COMPANION");
expect(installerInclude).not.toContain("FontPackCompanionPage");
expect(installerInclude).not.toContain("/WITHFONTPACK=");
});
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
const buildScript = readFileSync(
`${desktopRoot}/scripts/build-main.mjs`,
"utf8"
);
expect(buildScript).toContain(
"packages/docx-engine/assets/docx-media-filter.lua"
);
expect(buildScript).toContain("dist/docx-media-filter.lua");
});
it("为 ESM 主进程中的 CommonJS 字体转换依赖保留 __dirname", () => {
const buildScript = readFileSync(
`${desktopRoot}/scripts/build-main.mjs`,
"utf8"
);
expect(buildScript).toContain(
'dirname as __mdToPdfDirname'
);
expect(buildScript).toContain(
'fileURLToPath as __mdToPdfFileURLToPath'
);
expect(buildScript).toContain(
"const __dirname = __mdToPdfDirname(__mdToPdfFileURLToPath(import.meta.url));"
);
});
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
const dockerfile = readFileSync(
`${projectRoot}/deploy/Dockerfile`,
@@ -0,0 +1,153 @@
import {
mkdir,
mkdtemp,
readFile,
rm
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
SaveLocationStore,
loadSaveLocationState,
parseSaveLocationState,
resolveDefaultSavePath,
saveSaveLocationState
} from "../src/save-location-state.js";
let temporaryDirectory: string | undefined;
async function createTemporaryDirectory() {
temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "md-to-pdf-save-location-")
);
return temporaryDirectory;
}
afterEach(async () => {
if (temporaryDirectory) {
await rm(temporaryDirectory, { recursive: true, force: true });
temporaryDirectory = undefined;
}
});
describe("桌面默认保存位置", () => {
it("只接受版本匹配的绝对目录状态", () => {
expect(
parseSaveLocationState({
version: 1,
directory: path.resolve("C:/Documents")
})
).toEqual({
version: 1,
directory: path.resolve("C:/Documents")
});
expect(
parseSaveLocationState({ version: 2, directory: "C:/Documents" })
).toBeUndefined();
expect(
parseSaveLocationState({ version: 1, directory: "relative" })
).toBeUndefined();
});
it("优先使用有效的源文件目录", async () => {
const root = await createTemporaryDirectory();
const sourceDirectory = path.join(root, "source");
const historyDirectory = path.join(root, "history");
const documentsDirectory = path.join(root, "documents");
await Promise.all([
mkdir(sourceDirectory),
mkdir(historyDirectory),
mkdir(documentsDirectory)
]);
await expect(
resolveDefaultSavePath({
sourceFilePath: path.join(sourceDirectory, "source.md"),
lastSaveDirectory: historyDirectory,
documentsDirectory,
suggestedFileName: "report.pdf"
})
).resolves.toBe(path.join(sourceDirectory, "report.pdf"));
});
it("源目录不可用时使用最近成功目录", async () => {
const root = await createTemporaryDirectory();
const historyDirectory = path.join(root, "history");
const documentsDirectory = path.join(root, "documents");
await Promise.all([
mkdir(historyDirectory),
mkdir(documentsDirectory)
]);
await expect(
resolveDefaultSavePath({
sourceFilePath: path.join(root, "missing", "source.md"),
lastSaveDirectory: historyDirectory,
documentsDirectory,
suggestedFileName: "report.docx"
})
).resolves.toBe(path.join(historyDirectory, "report.docx"));
});
it("源目录和历史目录均不可用时回退到文档目录", async () => {
const root = await createTemporaryDirectory();
const documentsDirectory = path.join(root, "documents");
await mkdir(documentsDirectory);
await expect(
resolveDefaultSavePath({
lastSaveDirectory: path.join(root, "missing"),
documentsDirectory,
suggestedFileName: "draft.md"
})
).resolves.toBe(path.join(documentsDirectory, "draft.md"));
});
it("以原子 JSON 状态持久化并恢复最近目录", async () => {
const root = await createTemporaryDirectory();
const directory = path.join(root, "saved");
const statePath = path.join(root, "state", "save-location.json");
await mkdir(directory);
await saveSaveLocationState(statePath, directory);
await expect(loadSaveLocationState(statePath)).resolves.toEqual({
version: 1,
directory
});
expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({
version: 1,
directory
});
});
it("跨 Store 实例恢复最近成功文件目录", async () => {
const root = await createTemporaryDirectory();
const savedDirectory = path.join(root, "saved");
const documentsDirectory = path.join(root, "documents");
const statePath = path.join(root, "save-location.json");
await Promise.all([
mkdir(savedDirectory),
mkdir(documentsDirectory)
]);
const first = new SaveLocationStore(
statePath,
documentsDirectory
);
await first.initialize();
await first.recordSuccessfulFile(
path.join(savedDirectory, "first.pdf")
);
const restored = new SaveLocationStore(
statePath,
documentsDirectory
);
await restored.initialize();
await expect(
restored.resolveDefaultPath(undefined, "second.docx")
).resolves.toBe(path.join(savedDirectory, "second.docx"));
});
});
@@ -0,0 +1,84 @@
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { rm } from "node:fs/promises";
import { migrateLegacyUserData } from "../src/user-data-migration.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
async function createRoot() {
const root = await mkdtemp(path.join(tmpdir(), "morphdoc-migration-"));
temporaryDirectories.push(root);
return root;
}
describe("品牌升级用户数据迁移", () => {
it("首次启动时复制状态、主题和浏览器本地存储但保留旧目录", async () => {
const root = await createRoot();
const legacy = path.join(root, "Markdown PDF 导出器");
const target = path.join(root, "MorphDoc");
await mkdir(path.join(legacy, "themes", "custom"), {
recursive: true
});
await mkdir(path.join(legacy, "Local Storage"), {
recursive: true
});
await writeFile(
path.join(legacy, "window-state.json"),
'{"width":1280}',
"utf8"
);
await writeFile(
path.join(legacy, "themes", "custom", "theme.css"),
"#write{}",
"utf8"
);
const result = await migrateLegacyUserData([legacy], target);
expect(result).toMatchObject({
migrated: true,
sourceDirectory: legacy,
copiedEntries: ["window-state.json", "themes", "Local Storage"]
});
expect(
await readFile(path.join(target, "window-state.json"), "utf8")
).toBe('{"width":1280}');
expect(
await readFile(
path.join(target, "themes", "custom", "theme.css"),
"utf8"
)
).toBe("#write{}");
expect(
await readFile(path.join(legacy, "window-state.json"), "utf8")
).toBe('{"width":1280}');
});
it("目标目录已经存在时不覆盖新版本数据", async () => {
const root = await createRoot();
const legacy = path.join(root, "Markdown PDF 导出器");
const target = path.join(root, "MorphDoc");
await mkdir(legacy, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(path.join(legacy, "window-state.json"), "legacy", "utf8");
await writeFile(path.join(target, "window-state.json"), "current", "utf8");
expect(await migrateLegacyUserData([legacy], target)).toEqual({
migrated: false,
copiedEntries: []
});
expect(
await readFile(path.join(target, "window-state.json"), "utf8")
).toBe("current");
});
});
+7 -1
View File
@@ -8,12 +8,18 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-http": "node scripts/verify-docx-http.mjs",
"verify:docx-r4-matrix": "node scripts/verify-docx-r4-matrix.mjs",
"verify:docx-theme-styles": "node scripts/verify-docx-theme-styles.mjs"
},
"dependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fflate": "0.8.3",
"fastify": "^5.6.2",
"playwright": "1.62.0"
},
+301
View File
@@ -0,0 +1,301 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOCX_MIME_TYPE,
defaultExportConfig
} from "@md-to-pdf/core";
import { inspectDocxAcceptance } from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOTS } from "@md-to-pdf/docx-theme-engine";
import { buildApp } from "../dist/app.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-acceptance"
);
const temporaryPrefix = "md-to-pdf-docx-";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function temporaryDirectories() {
return new Set(
fs
.readdirSync(os.tmpdir(), { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() && entry.name.startsWith(temporaryPrefix)
)
.map((entry) => entry.name)
);
}
const markdown = `---
title: Server HTTP DOCX 验收
author: 墨呈研发组
---
# HTTP 导出保持可编辑
这是一段由真实 Fastify 路由和 Pandoc 生成的可编辑中文正文,包含
[验收链接](https://example.invalid/server-docx)。
1. 原生编号第一项
2. 原生编号第二项
| HTTP 门禁 | 预期 |
| --- | --- |
| MIME | DOCX |
| 内容 | 可编辑 |
脚注必须保留。[^server-footnote]
公式 $a^2+b^2=c^2$ 必须使用 OMML。
[^server-footnote]: Server HTTP 验收脚注。
`;
const exportConfig = {
...defaultExportConfig,
themeId: "typora-like",
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
marginMode: "custom",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
}
};
const computedStyle = {
fontFamily: '"Microsoft YaHei", sans-serif',
fontSize: "16px",
fontWeight: "400",
fontStyle: "normal",
color: "rgb(34, 34, 34)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineHeight: "24px",
letterSpacing: "normal",
textAlign: "start",
direction: "ltr",
textIndent: "0px",
textDecorationLine: "none",
marginTop: "0px",
marginRight: "0px",
marginBottom: "8px",
marginLeft: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
borderTop: "0px none rgb(34, 34, 34)",
borderRight: "0px none rgb(34, 34, 34)",
borderBottom: "0px none rgb(34, 34, 34)",
borderLeft: "0px none rgb(34, 34, 34)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
breakAfter: "auto",
breakInside: "auto",
display: "block",
flexDirection: "row",
alignItems: "normal",
justifyContent: "normal",
outline: "0px none rgb(34, 34, 34)",
outlineOffset: "0px"
};
const mediaAdapter = {
themeStyleBaseUrl:
"http://localhost:5173/preview-frame.html",
async captureThemeStyle(request) {
return {
schemaVersion: 1,
themeId: request.themeId,
themeFingerprint: request.themeFingerprint,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
rootFontSizePx: 16,
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
slot: name,
matched: true,
computed: computedStyle
}))
};
},
async capture() {
return {
plan: {
targets: [],
echartsErrors: [],
mermaidErrors: []
},
captures: []
};
},
async close() {}
};
const pdfGenerator = {
async generate() {
throw new Error("DOCX HTTP 验收不得调用 PDF");
},
async close() {}
};
const beforeTemporaryDirectories = temporaryDirectories();
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter: mediaAdapter
});
let response;
try {
const capabilityResponse = await app.inject({
method: "GET",
url: "/api/docx/capability"
});
assert(capabilityResponse.statusCode === 200, "DOCX capability 请求失败");
assert(
capabilityResponse.json().status === "available",
`DOCX capability 不可用:${capabilityResponse.body}`
);
response = await app.inject({
method: "POST",
url: "/api/docx",
payload: {
markdown,
fileName: "目录/Server HTTP 验收.md",
language: "zh-CN",
resources: [],
exportConfig
}
});
} finally {
await app.close();
}
assert(
response.statusCode === 200,
`DOCX HTTP 返回 ${response.statusCode}${response.body}`
);
assert(
response.headers["content-type"]?.includes(DOCX_MIME_TYPE),
`DOCX MIME 无效:${response.headers["content-type"]}`
);
assert(
response.headers["content-disposition"]?.includes(
"Server%20HTTP%20%E9%AA%8C%E6%94%B6.docx"
),
`DOCX 文件名无效:${response.headers["content-disposition"]}`
);
assert(
response.headers["cache-control"] === "no-store",
"DOCX HTTP 响应必须禁用缓存"
);
for (const timing of [
"runtime-probe",
"prepare",
"media",
"reference",
"pandoc",
"validation",
"total"
]) {
assert(
response.headers["server-timing"]?.includes(`${timing};dur=`),
`Server-Timing 缺少 ${timing}`
);
}
assert(
response.headers["x-docx-warning-count"] === "0",
"无媒体 HTTP 验收不应产生 DOCX 警告"
);
const report = inspectDocxAcceptance(response.rawPayload, {
id: "server-http",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
},
requiredText: [
"真实 Fastify 路由",
"原生编号第一项",
"HTTP 门禁"
],
requiredFootnoteText: ["Server HTTP 验收脚注"],
minimumParagraphs: 8,
minimumTextRuns: 12,
minimumTables: 1,
minimumNumberedParagraphs: 2,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 1,
requiredStyleIds: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
],
requirePageField: true
});
const afterTemporaryDirectories = temporaryDirectories();
const leakedTemporaryDirectories = [...afterTemporaryDirectories].filter(
(name) => !beforeTemporaryDirectories.has(name)
);
assert(
leakedTemporaryDirectories.length === 0,
`DOCX HTTP 遗留临时目录:${leakedTemporaryDirectories.join(", ")}`
);
fs.mkdirSync(outputDirectory, { recursive: true });
const outputPath = path.join(outputDirectory, "server-http.docx");
const reportPath = path.join(
outputDirectory,
"server-http-report.json"
);
fs.writeFileSync(outputPath, response.rawPayload);
const result = {
outputFile: path.relative(repositoryDirectory, outputPath),
bytes: response.rawPayload.byteLength,
headers: {
contentType: response.headers["content-type"],
contentDisposition: response.headers["content-disposition"],
cacheControl: response.headers["cache-control"],
serverTiming: response.headers["server-timing"],
warningCount: response.headers["x-docx-warning-count"],
echartsErrorCount: response.headers["x-echarts-error-count"],
mermaidErrorCount: response.headers["x-mermaid-error-count"]
},
leakedTemporaryDirectories,
acceptance: report
};
fs.writeFileSync(
reportPath,
`${JSON.stringify(result, null, 2)}\n`,
"utf8"
);
console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,923 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
import { unzipSync } from "fflate";
import matter from "gray-matter";
import { buildApp } from "../dist/app.js";
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
import { createPdfGenerator } from "../dist/pdf-engine.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const appVersion = JSON.parse(
fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8")
).version;
const themesDirectory = path.join(repositoryDirectory, "themes");
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
const outputDirectory = path.resolve(
process.env.MD_TO_PDF_R4_OUTPUT_DIR?.trim() ||
path.join(repositoryDirectory, "output", "docx-r4")
);
const pdfDirectory = path.join(outputDirectory, "chromium-pdf");
const docxDirectory = path.join(outputDirectory, "docx");
const standardReportPath = path.join(
repositoryDirectory,
"output",
"docx-theme-matrix",
"theme-matrix-report.json"
);
const fontPackRoot = path.resolve(
process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() ||
path.join(repositoryDirectory, "output", "font-packs", "root")
);
const decoder = new TextDecoder();
function writeArtifactWithBusyFallback(filePath, content) {
try {
fs.writeFileSync(filePath, content);
return filePath;
} catch (error) {
if (error?.code !== "EBUSY") {
throw error;
}
const extension = path.extname(filePath);
const fallbackPath = path.join(
path.dirname(filePath),
`${path.basename(filePath, extension)}-attempt-${randomUUID()}${extension}`
);
fs.writeFileSync(fallbackPath, content);
process.stderr.write(
`[DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件:${path.basename(fallbackPath)}\n`
);
return fallbackPath;
}
}
const inlineCodeContinuityProbes = [
{
id: "paragraph",
code: "mdtp_ic_p",
text: "段落前mdtp_ic_p段落后"
},
{
id: "list-item",
code: "mdtp_ic_l",
text: "列表前mdtp_ic_l列表后"
},
{
id: "table-cell",
code: "mdtp_ic_c",
text: "单元格前mdtp_ic_c单元格后"
}
];
const inlineCodeContinuityMarkdown = `
## 行内代码连续性门禁
段落前\`mdtp_ic_p\`段落后
- 列表前\`mdtp_ic_l\`列表后
| 门禁 | 内容 | 邻接单元格 |
| --- | --- | --- |
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
| 场景 | 内容 |
| --- | --- |
| 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
| 叁式 | 连甲<br><br>连乙 |
| 肆式 | <br>边界<br> |
| 伍式 | 转甲\\<br>转乙 &lt;br&gt; 转丙 |
| 陆式 | 属甲<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: "转甲&lt;br&gt;转乙 &lt;br&gt; 转丙",
hardBreakCount: 0
},
{
id: "attribute",
marker: "属甲",
expectedText: "属甲&lt;br class=\"unsafe\"&gt;属乙",
hardBreakCount: 0
},
{
id: "inline-code",
marker: "码甲",
expectedText: "码甲&lt;br&gt;码乙",
hardBreakCount: 0,
requiredCharacterStyle: "VerbatimChar"
},
{
id: "outside-table",
marker: "外一",
expectedText: "表格外保留 外一&lt;br&gt;外二 原文。",
hardBreakCount: 0
},
{
id: "fenced-code",
marker: "围一",
expectedText: "围一&lt;br&gt;围二",
hardBreakCount: 0,
requiredParagraphStyle: "SourceCode"
}
];
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"]
]);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function newestModifiedAt(targetPath) {
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
return stat.mtimeMs;
}
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
(newest, entry) =>
Math.max(
newest,
newestModifiedAt(path.join(targetPath, entry.name))
),
stat.mtimeMs
);
}
function assertFreshArtifact(label, sourcePaths, artifactPath) {
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
assert(
artifactModifiedAt + 1_500 >= newestSource,
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
);
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExportConfigOverride() {
const source = process.env.MD_TO_PDF_R4_EXPORT_CONFIG_JSON?.trim();
if (!source) {
return undefined;
}
const parsed = JSON.parse(source);
assert(
parsed && typeof parsed === "object" && !Array.isArray(parsed),
"R4 导出配置覆盖必须是 JSON 对象"
);
return parsed;
}
function mergeExportConfig(base, override) {
if (!override) {
return base;
}
return {
...base,
...override,
mermaid: { ...base.mermaid, ...override.mermaid },
paper: {
...base.paper,
...override.paper,
margins: { ...base.paper.margins, ...override.paper?.margins }
},
header: {
...base.header,
...override.header,
left: { ...base.header.left, ...override.header?.left },
center: { ...base.header.center, ...override.header?.center },
right: { ...base.header.right, ...override.header?.right }
},
footer: { ...base.footer, ...override.footer },
metadata: { ...base.metadata, ...override.metadata },
print: { ...base.print, ...override.print }
};
}
function readThemes() {
return fs
.readdirSync(themesDirectory, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(themesDirectory, entry.name, "theme.json"))
)
.map((entry) =>
themeManifestSchema.parse(
readJson(path.join(themesDirectory, entry.name, "theme.json"))
)
)
.sort((first, second) => first.id.localeCompare(second.id, "en"));
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配 R4 验收端口"))
: resolve(port)
);
});
});
}
function resolveWebFile(relativePath) {
const resolved = path.resolve(webDirectory, relativePath);
const relative = path.relative(webDirectory, resolved);
assert(
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative),
`Web 资源路径越界:${relativePath}`
);
return resolved;
}
function registerWebRuntime(app) {
app.get("/preview-frame.html", async (_request, reply) => {
const content = fs.readFileSync(resolveWebFile("preview-frame.html"));
return reply.type("text/html; charset=utf-8").send(content);
});
app.get("/assets/*", async (request, reply) => {
const relativePath = String(request.params["*"] ?? "");
const filePath = resolveWebFile(path.join("assets", relativePath));
const stat = fs.statSync(filePath);
assert(stat.isFile(), `Web 资源不是普通文件:${relativePath}`);
return reply
.type(
contentTypes.get(path.extname(filePath).toLowerCase()) ||
"application/octet-stream"
)
.send(fs.readFileSync(filePath));
});
}
function inspectDocx(content) {
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const fontTableXml = decoder.decode(entries["word/fontTable.xml"]);
const fontParts = Object.keys(entries).filter((name) =>
name.startsWith("word/fonts/")
);
const fontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
].map((match) => match[1]);
const embeddedFontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"[\s\S]*?<\/w:font>/gu)
].flatMap((match) => /<w:embed(?:Regular|Bold|Italic|BoldItalic)\b/u.test(match[0])
? [match[1]]
: []);
const paragraphs = [
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
].map((match) => match[0]);
const authorParagraphCount = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="Author"\s*\/>/u.test(paragraph)
).length;
const officialIssueRows = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="MdOfficialIssueRow"\s*\/>/u.test(
paragraph
)
);
const officialIssueRowsWithRightTab = officialIssueRows.filter(
(paragraph) =>
[...paragraph.matchAll(/<w:tab(?:\s[^>]*)?\s*\/>/gu)].some(
(match) =>
/\bw:val="right"/u.test(match[0]) &&
/\bw:pos="\d+"/u.test(match[0])
)
);
const officialIssueRowContentTabCount = officialIssueRows.reduce(
(count, paragraph) =>
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
0
);
const inlineCodeContinuity = inlineCodeContinuityProbes.map((probe) => {
const matchingParagraphs = paragraphs.filter((paragraph) =>
paragraph.includes(probe.code)
);
const paragraph = matchingParagraphs[0] ?? "";
const text = [...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)]
.map((match) => match[1])
.join("");
const codeRunPattern = new RegExp(
`<w:r(?:\\s|>)[\\s\\S]*?<w:rStyle\\s+w:val="VerbatimChar"\\s*\\/>[\\s\\S]*?<w:t(?:\\s[^>]*)?>${probe.code}<\\/w:t>[\\s\\S]*?<\\/w:r>`,
"u"
);
const hardBreakCount =
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
return {
id: probe.id,
code: probe.code,
expectedText: probe.text,
actualText: text,
paragraphCount: matchingParagraphs.length,
hardBreakCount,
characterStylePresent: codeRunPattern.test(paragraph),
passed:
matchingParagraphs.length === 1 &&
text === probe.text &&
hardBreakCount === 0 &&
codeRunPattern.test(paragraph)
};
});
const breakProbes = docxBreakParagraphProbes.map((probe) => {
const matchingParagraphs = paragraphs.map((paragraph) => ({
paragraph,
actualText: [
...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)
].map((match) => match[1]).join("")
})).filter(({ actualText }) => actualText === probe.expectedText);
const paragraph = matchingParagraphs[0]?.paragraph ?? "";
const actualText = matchingParagraphs[0]?.actualText ?? "";
const hardBreakCount =
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
const runs = paragraph.match(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu) ?? [];
const characterStylePresent = !probe.requiredCharacterStyle ||
runs.some((run) =>
run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) &&
run.includes("&lt;br&gt;")
);
const paragraphStylePresent = !probe.requiredParagraphStyle ||
new RegExp(
`<w:pStyle\\s+w:val="${probe.requiredParagraphStyle}"\\s*\\/>`,
"u"
).test(paragraph);
return {
id: probe.id,
marker: probe.marker,
expectedText: probe.expectedText,
actualText,
expectedHardBreakCount: probe.hardBreakCount,
hardBreakCount,
paragraphCount: matchingParagraphs.length,
characterStylePresent,
paragraphStylePresent,
passed:
matchingParagraphs.length === 1 &&
actualText === probe.expectedText &&
hardBreakCount === probe.hardBreakCount &&
characterStylePresent &&
paragraphStylePresent
};
});
const adjacentA = breakProbes.find((probe) => probe.id === "adjacent-a");
const adjacentB = breakProbes.find((probe) => probe.id === "adjacent-b");
const tableBreakSemantics = {
probes: breakProbes,
adjacentCellsIsolated:
adjacentA?.paragraphCount === 1 &&
adjacentB?.paragraphCount === 1 &&
adjacentA.actualText !== adjacentB.actualText,
passed:
breakProbes.every((probe) => probe.passed) &&
adjacentA?.paragraphCount === 1 &&
adjacentB?.paragraphCount === 1
};
return {
bytes: content.byteLength,
sha256: sha256(content),
partCount: Object.keys(entries).length,
sectionCount: documentXml.match(/<w:sectPr(?:\s|>)/gu)?.length ?? 0,
embeddedFontPartCount: fontParts.length,
fontNames: [...new Set(fontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
internalMarkerCount:
documentXml.match(/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu)?.length ?? 0,
authorParagraphCount,
officialIssueRowCount: officialIssueRows.length,
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
inlineCodeContinuity,
inlineCodeContinuityPassed: inlineCodeContinuity.every(
(probe) => probe.passed
),
tableBreakSemantics,
requiredStylesPresent: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
].every((styleId) => stylesXml.includes(`w:styleId="${styleId}"`))
};
}
async function requestArtifact(origin, route, payload, contentType) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const content = new Uint8Array(await response.arrayBuffer());
assert(
response.ok,
`${route} 返回 ${response.status}${decoder.decode(content)}`
);
assert(
response.headers.get("content-type")?.includes(contentType),
`${route} MIME 无效:${response.headers.get("content-type")}`
);
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(
"转甲&lt;br&gt;转乙 &lt;br&gt; 转丙"
)
},
{
id: "attribute-literal",
passed: cellFor("属甲").includes(
"属甲&lt;br class=\"unsafe\"&gt;属乙"
)
},
{
id: "inline-code-literal",
passed: cellFor("码甲").includes(
"码甲<code>&lt;br&gt;</code>码乙"
)
},
{
id: "outside-table-literal",
passed: fragmentFor("外一", "外二").includes(
"外一&lt;br&gt;外二"
)
},
{
id: "fenced-code-literal",
passed: (() => {
const fragment = fragmentFor("围一", "围二");
return fragment.includes("&lt;") && fragment.includes("br") &&
fragment.includes("&gt;") && !/<br(?:\s|\/?>)/iu.test(fragment);
})()
}
];
const adjacentA = cellFor("邻甲");
const adjacentB = cellFor("旁甲");
const adjacentCellsIsolated =
adjacentA.includes("邻甲<br />邻乙") &&
adjacentB.includes("旁甲<br />旁乙") &&
!adjacentA.includes("旁甲") &&
!adjacentB.includes("邻甲");
return {
probes,
adjacentCellsIsolated,
passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated
};
}
function responseDiagnostics(response, kind) {
return {
serverTiming: response.headers.get("server-timing"),
pageCount:
kind === "pdf"
? Number(response.headers.get("x-pdf-page-count"))
: undefined,
warningCount:
kind === "docx"
? Number(response.headers.get("x-docx-warning-count"))
: undefined,
echartsErrorCount: Number(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: Number(
response.headers.get("x-mermaid-error-count")
)
};
}
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
assertFreshArtifact(
"Preview Engine",
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
);
assertFreshArtifact(
"DOCX Engine",
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"docx-engine",
"dist",
"document-structure-transform.js"
)
);
assertFreshArtifact(
"Web Preview Runtime",
[
path.join(repositoryDirectory, "apps", "web", "src"),
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
],
path.join(webDirectory, "preview-frame.html")
);
assert(
fs.statSync(fontPackRoot).isDirectory(),
`缺少有效字体包根目录:${fontPackRoot}`
);
const allThemes = readThemes();
assert(
allThemes.length === 14,
`内置主题数量应为 14,实际为 ${allThemes.length}`
);
const selectedThemeId =
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
const externalMarkdownPath =
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() || undefined;
const appendInlineCodeProbes =
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
const exportConfigOverride = readExportConfigOverride();
const themes = selectedThemeId
? allThemes.filter((theme) => theme.id === selectedThemeId)
: allThemes;
assert(
themes.length > 0,
`R4 验收主题不存在:${selectedThemeId}`
);
const standardReport = readJson(standardReportPath);
assert(
standardReport.themeCount === allThemes.length,
"标准 DOCX 主题矩阵与 R4 主题数量不一致"
);
const standardByTheme = new Map(
standardReport.results.map((result) => [result.id, result])
);
fs.mkdirSync(pdfDirectory, { recursive: true });
fs.mkdirSync(docxDirectory, { recursive: true });
const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const captureDocxMedia = docxMediaAdapter.capture.bind(docxMediaAdapter);
let capturedDocxDocumentLayout;
docxMediaAdapter.capture = async (...args) => {
const output = await captureDocxMedia(...args);
capturedDocxDocumentLayout = structuredClone(
output.plan.documentLayout ?? { tables: [] }
);
return output;
};
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter,
fontPacks: {
roots: [fontPackRoot],
appVersion
}
});
registerWebRuntime(app);
const results = [];
try {
await app.listen({ port, host: "127.0.0.1" });
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
const capability = await capabilityResponse.json();
assert(
capabilityResponse.ok &&
capability.status === "available" &&
capability.detectedVersion === "3.9.0.2",
`DOCX capability 无效:${JSON.stringify(capability)}`
);
for (const theme of themes) {
capturedDocxDocumentLayout = undefined;
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
const samplePath = externalMarkdownPath
? path.resolve(repositoryDirectory, externalMarkdownPath)
: path.join(samplesDirectory, `${theme.id}.md`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const sourceMarkdown = fs.readFileSync(samplePath, "utf8");
const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`;
const sourceMetadata = matter(sourceMarkdown).data;
const exportConfig = mergeExportConfig({
...defaultExportConfig,
name: `${theme.name} R4 生产链验收`,
themeId: theme.id,
pageDecorationsMode: "theme",
header: structuredClone(
theme.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme.pageDefaults?.footer ?? defaultExportConfig.footer
),
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme",
margins: structuredClone(
theme.pageDefaults?.margins ??
defaultExportConfig.paper.margins
)
}
}, exportConfigOverride);
const payload = {
markdown,
fileName: path.basename(samplePath),
language: "zh-CN",
resources: [],
exportConfig
};
const rendered = await requestJson(origin, "/api/render", payload);
const htmlBreakSemantics = appendInlineCodeProbes
? inspectHtmlBreakSemantics(rendered.articleHtml)
: undefined;
if (appendInlineCodeProbes) {
assert(
htmlBreakSemantics.passed,
`主题 ${theme.id} 的 Chromium br 语义门禁失败:${JSON.stringify(htmlBreakSemantics)}`
);
}
const pdf = await requestArtifact(
origin,
"/api/pdf",
payload,
"application/pdf"
);
assert(
decoder.decode(pdf.content.subarray(0, 5)) === "%PDF-",
`主题 ${theme.id} 的 PDF 签名无效`
);
const docx = await requestArtifact(
origin,
"/api/docx",
payload,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
const inspection = inspectDocx(docx.content);
assert(!inspection.hasAltChunk, `主题 ${theme.id} 包含 altChunk`);
assert(
inspection.internalMarkerCount === 0,
`主题 ${theme.id} 残留内部结构标记`
);
assert(
inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式`
);
if (appendInlineCodeProbes) {
assert(
inspection.inlineCodeContinuityPassed,
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
);
assert(
inspection.tableBreakSemantics.passed,
`主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}`
);
}
const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert(
standard.inspection.structureCoverage === 1 &&
standard.inspection.titlePolicyPassed &&
standard.inspection.titleDeduplicationPassed,
`主题 ${theme.id} 的标准结构门禁未通过`
);
if (standard.inspection.profile) {
assert(
inspection.authorParagraphCount === 0,
`结构化主题 ${theme.id} 残留重复 Author 段落`
);
}
const expectsOfficialDualEndedRow = Boolean(
sourceMetadata.document?.profile === "official" &&
sourceMetadata.document.signatory
);
if (expectsOfficialDualEndedRow) {
assert(
inspection.officialIssueRowCount > 0 &&
inspection.officialIssueRowCount ===
inspection.officialIssueRowRightTabCount &&
inspection.officialIssueRowContentTabCount ===
inspection.officialIssueRowCount,
`主题 ${theme.id} 的公文双端行缺少右制表位或签发人分隔`
);
} else {
assert(
inspection.officialIssueRowRightTabCount === 0 &&
inspection.officialIssueRowContentTabCount === 0,
`主题 ${theme.id} 的单端公文行包含无意义制表位`
);
}
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
const preferredDocxPath = path.join(docxDirectory, `${theme.id}.docx`);
fs.writeFileSync(pdfPath, pdf.content);
const docxPath = writeArtifactWithBusyFallback(
preferredDocxPath,
docx.content
);
results.push({
id: theme.id,
name: theme.name,
category: theme.category,
compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath),
exportConfig,
htmlBreakSemantics,
pdf: {
outputFile: path.relative(repositoryDirectory, pdfPath),
bytes: pdf.content.byteLength,
sha256: sha256(pdf.content),
diagnostics: responseDiagnostics(pdf.response, "pdf")
},
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
expectsOfficialDualEndedRow
});
}
} finally {
await app.close();
}
const enhancedThemes = results.filter(
(result) => result.docx.inspection.optionalFontPackApplied
);
for (const result of results) {
assert(
result.pdf.diagnostics.pageCount > 0,
`主题 ${result.id} 的 PDF 页数无效`
);
assert(
result.pdf.diagnostics.echartsErrorCount === 0 &&
result.pdf.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 PDF 图表渲染失败`
);
assert(
result.docx.diagnostics.echartsErrorCount === 0 &&
result.docx.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 DOCX 图表渲染失败`
);
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
origin,
themeCount: themes.length,
totalThemeCount: allThemes.length,
selectedThemeId,
fontPackRoot,
standardReport: path.relative(repositoryDirectory, standardReportPath),
outputDirectory: path.relative(repositoryDirectory, outputDirectory),
enhancedThemeIds: enhancedThemes.map((result) => result.id),
results
};
const reportPath = path.join(outputDirectory, "r4-matrix-report.json");
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
@@ -0,0 +1,332 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createApplicationService } from "@md-to-pdf/application";
import {
DOCX_PANDOC_VERSION,
defaultExportConfig
} from "@md-to-pdf/core";
import {
DOCX_STYLE_SLOT_NAMES,
createDocxThemeStyleFingerprint,
normalizeDocxThemeMappingConfig,
resolveDocxThemeTokens
} from "@md-to-pdf/docx-theme-engine";
import {
PandocRuntime,
createDynamicReferenceDocx,
readReferenceDocxPackage,
resolveTokenFonts
} from "@md-to-pdf/docx-engine";
import { chromium } from "playwright";
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-theme-styles"
);
const referenceDirectory = path.join(
outputDirectory,
"references"
);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function styleBlock(stylesXml, styleId) {
const escaped = styleId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return stylesXml.match(
new RegExp(
`<w:style[^>]*w:styleId="${escaped}"[^>]*>[\\s\\S]*?<\\/w:style>`,
"u"
)
)?.[0];
}
function inspectReference(reference, tokens) {
const entries = readReferenceDocxPackage(reference.content).entries;
const stylesXml = new TextDecoder().decode(
entries.get("word/styles.xml")
);
const fontTableXml = new TextDecoder().decode(
entries.get("word/fontTable.xml")
);
const normal = styleBlock(stylesXml, "Normal");
const paragraph = tokens.slots.find(
(slot) => slot.slot === "paragraph"
)?.style;
assert(normal, `主题 ${tokens.themeId} 缺少 Normal 样式`);
const fonts = resolveTokenFonts(paragraph, {
latin: "Arial",
eastAsia: "SimSun"
});
if (paragraph?.fontCandidates.length) {
assert(
normal.includes(`w:ascii="${fonts.latin}"`),
`主题 ${tokens.themeId} 的 Latin 正文字体未写入模板`
);
assert(
normal.includes(`w:eastAsia="${fonts.eastAsia}"`),
`主题 ${tokens.themeId} 的东亚正文字体未写入模板`
);
assert(
fontTableXml.includes(`w:name="${fonts.eastAsia}"`),
`主题 ${tokens.themeId} 的东亚字体未写入字体表`
);
}
if (paragraph?.fontSizePt !== undefined) {
assert(
normal.includes(
`w:sz w:val="${Math.round(paragraph.fontSizePt * 2)}"`
),
`主题 ${tokens.themeId} 的正文字号未写入模板`
);
}
for (const styleId of [
"Heading1",
"SourceCode",
"Table",
"MdOfficialTitle",
"MdBriefingTitle",
"MdProjectReportTitle",
"MdTenderTitle"
]) {
assert(
stylesXml.includes(`w:styleId="${styleId}"`),
`主题 ${tokens.themeId} 缺少样式 ${styleId}`
);
}
return {
bytes: reference.content.byteLength,
templateFingerprint: reference.templateFingerprint,
cacheKey: reference.cacheKey,
stylePreset: reference.stylePreset,
partCount: reference.partCount,
paragraphFonts: fonts,
customStyleCount:
stylesXml.match(/w:styleId="Md[A-Za-z]+"/gu)?.length ?? 0
};
}
const application = createApplicationService({
bundledRoot: path.join(repositoryDirectory, "themes"),
localRoot: path.join(repositoryDirectory, ".local", "themes")
});
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname === "/") {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8"
});
response.end("<!doctype html><html><body></body></html>");
return;
}
const match = url.pathname.match(
/^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
);
if (!match) {
response.writeHead(404);
response.end("Not found");
return;
}
const themeId = decodeURIComponent(match[1]);
const assetPath = match[2]
.split("/")
.map((segment) => decodeURIComponent(segment))
.join("/");
const asset = await application.getThemeAsset(
themeId,
assetPath
);
if (!asset) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"access-control-allow-origin": "*",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
});
response.end(asset.content);
} catch (error) {
response.writeHead(500);
response.end(
error instanceof Error ? error.message : "Unknown error"
);
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("无法获取 DOCX 主题样式验收地址");
}
const baseUrl = `http://127.0.0.1:${address.port}/`;
const browser = await chromium.launch({ headless: true });
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
: {}
);
const capability = await runtime.probe();
if (capability.capability.status !== "available") {
throw new Error(capability.capability.message);
}
assert(
capability.capability.detectedVersion === DOCX_PANDOC_VERSION,
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
);
const baseline = await runtime.getDefaultReferenceDocx();
try {
const context = await browser.newContext({
locale: "zh-CN",
serviceWorkers: "block"
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
const { themes } = await application.listThemes();
const bundledThemes = themes
.filter((theme) => theme.source === "bundled")
.sort((first, second) =>
first.id.localeCompare(second.id, "en")
);
const snapshots = [];
const tokenSets = [];
const references = [];
for (const theme of bundledThemes) {
console.error(`[DOCX theme styles] capturing ${theme.id}`);
const prepared = await application.prepareDocxExport({
markdown: `# ${theme.name}`,
fileName: `${theme.id}.md`,
language: "zh-CN",
exportConfig: {
...defaultExportConfig,
themeId: theme.id
}
});
const themeCss = prepared.theme.css;
const manifest = prepared.theme.manifest;
const themeFingerprint =
await createDocxThemeStyleFingerprint(theme.id, themeCss);
const snapshot =
await captureDocxThemeStyleWithPlaywrightPage(page, {
themeId: theme.id,
themeFingerprint,
themeCss,
baseUrl
});
assert(
snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
`主题 ${theme.id} 的槽位数量不正确`
);
const missing = snapshot.slots.filter((slot) => !slot.matched);
assert(
missing.length === 0,
`主题 ${theme.id} 缺少槽位:${missing
.map((slot) => slot.slot)
.join("、")}`
);
snapshots.push(snapshot);
const tokens = resolveDocxThemeTokens({
snapshot,
config: normalizeDocxThemeMappingConfig(manifest)
});
tokenSets.push(tokens);
const reference = createDynamicReferenceDocx(baseline, {
exportConfig: prepared.request.exportConfig,
theme: manifest,
fileName: prepared.request.fileName,
metadata: prepared.document.metadata,
themeTokens: tokens
});
fs.mkdirSync(referenceDirectory, { recursive: true });
fs.writeFileSync(
path.join(referenceDirectory, `${theme.id}.docx`),
reference.content
);
references.push({
themeId: theme.id,
...inspectReference(reference, tokens)
});
}
assert(
snapshots.length === 14,
`内置主题数量应为 14,实际为 ${snapshots.length}`
);
const invalidDiagnostics = tokenSets.flatMap((tokens) =>
tokens.diagnostics.filter(
(diagnostic) =>
diagnostic.severity === "error" ||
diagnostic.code === "css-value-invalid"
)
);
assert(
invalidDiagnostics.length === 0,
`DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断`
);
const paragraphStyles = new Set(
snapshots.map(
(snapshot) =>
snapshot.slots.find((slot) => slot.slot === "paragraph")
?.computed?.fontFamily
)
);
assert(
paragraphStyles.size > 1,
"主题 CSS 未产生可区分的正文字体计算结果"
);
fs.mkdirSync(outputDirectory, { recursive: true });
fs.writeFileSync(
path.join(outputDirectory, "snapshots.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
chromiumVersion: browser.version(),
themeCount: snapshots.length,
slotCount: DOCX_STYLE_SLOT_NAMES.length,
snapshots,
tokenSets,
pandocVersion: capability.capability.detectedVersion,
references
},
null,
2
)}\n`,
"utf8"
);
console.log(
JSON.stringify({
chromiumVersion: browser.version(),
themes: snapshots.length,
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
totalSlots:
snapshots.length * DOCX_STYLE_SLOT_NAMES.length,
tokenSets: tokenSets.length,
diagnostics: tokenSets.reduce(
(count, tokens) => count + tokens.diagnostics.length,
0
),
pandocVersion: capability.capability.detectedVersion,
references: references.length
})
);
await context.close();
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
+260 -16
View File
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
import Fastify from "fastify";
import {
EXPORT_CONFIG_VERSION,
DOCX_MIME_TYPE,
createPagedDocumentPayload,
defaultExportConfig,
exportConfigSchema,
@@ -11,10 +12,23 @@ import {
} from "@md-to-pdf/core";
import {
ApplicationRequestError,
DocxExportService,
DocxExportServiceError,
DocxThemeTokenService,
createApplicationService,
type ApplicationService
readDocxExportRuntimeLimits,
type ApplicationService,
type ApplicationServiceOptions
} from "@md-to-pdf/application";
import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
import {
createDocxMediaEngine,
type ServerDocxMediaCaptureAdapter
} from "./docx-media-engine.js";
import {
createPdfGenerator,
PdfEngineClosedError,
@@ -68,7 +82,14 @@ export interface BuildAppOptions {
logger?: boolean;
pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean;
prewarmDocxRuntime?: boolean;
applicationService?: ApplicationService;
docxExportService?: Pick<
DocxExportService,
"getCapability" | "generate" | "close"
>;
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
}
function milliseconds(value: number) {
@@ -106,6 +127,31 @@ function createPdfServerTiming(
].join(", ");
}
function createDocxServerTiming(
timings: Awaited<
ReturnType<DocxExportService["generate"]>
>["timings"]
) {
return [
`queue;dur=${milliseconds(timings.queueMs)}`,
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
`prepare;dur=${milliseconds(timings.prepareMs)}`,
`theme-style;dur=${milliseconds(timings.themeStyleMs)}`,
`media;dur=${milliseconds(timings.mediaMs)}`,
`reference;dur=${milliseconds(timings.referenceMs)}`,
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
`validation;dur=${milliseconds(timings.validationMs)}`,
`total;dur=${milliseconds(timings.totalMs)}`
].join(", ");
}
export function createDocxContentDisposition(fileName: string) {
return (
`attachment; filename="document.docx"; ` +
`filename*=UTF-8''${encodeRfc5987(fileName)}`
);
}
export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: options.logger ?? true,
@@ -117,10 +163,29 @@ export function buildApp(options: BuildAppOptions = {}) {
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
onWarning: (message) => app.log.warn(message)
onWarning: (message) => app.log.warn(message),
...(options.fontPacks ? { fontPacks: options.fontPacks } : {})
});
const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator();
const pandocRuntime = options.docxExportService
? undefined
: new PandocRuntime();
const docxMediaAdapter =
options.docxMediaAdapter ?? createDocxMediaEngine();
const docxExportService =
options.docxExportService ??
new DocxExportService({
application: applicationService,
runtime: pandocRuntime!,
converter: new PandocDocxConverter({
runtime: pandocRuntime!
}),
themeTokens: new DocxThemeTokenService(
docxMediaAdapter
),
limits: readDocxExportRuntimeLimits()
});
if (options.prewarmPdfBrowser) {
app.addHook("onReady", async () => {
@@ -132,8 +197,27 @@ export function buildApp(options: BuildAppOptions = {}) {
});
}
if (options.prewarmDocxRuntime) {
app.addHook("onReady", async () => {
try {
const capability =
await docxExportService.getCapability();
if (capability.status !== "available") {
app.log.warn(
{ capability },
"DOCX runtime unavailable"
);
}
} catch (error) {
app.log.warn({ error }, "DOCX runtime probe failed");
}
});
}
app.addHook("onClose", async () => {
await pdfGenerator.close();
await docxExportService.close();
await docxMediaAdapter.close();
});
app.get("/api/health", async () => ({
@@ -143,19 +227,28 @@ export function buildApp(options: BuildAppOptions = {}) {
rendererVersion: RENDERER_VERSION
}));
app.get("/api/capabilities", async () => ({
defaultExportConfig,
supportedPaperFormats,
implemented: [
"project-skeleton",
"export-config",
"theme-manifest",
"markdown-render",
"html-preview",
"pdf-export"
],
planned: []
}));
app.get("/api/capabilities", async () => {
const docx = await docxExportService.getCapability();
return {
defaultExportConfig,
supportedPaperFormats,
docx,
implemented: [
"project-skeleton",
"export-config",
"theme-manifest",
"markdown-render",
"html-preview",
"pdf-export",
"docx-export"
],
planned: []
};
});
app.get("/api/docx/capability", async () =>
docxExportService.getCapability()
);
app.get("/api/themes", async () =>
applicationService.listThemes()
@@ -211,6 +304,44 @@ export function buildApp(options: BuildAppOptions = {}) {
}
);
app.get<{
Params: {
packId: string;
packVersion: string;
faceId: string;
};
}>(
"/api/font-packs/:packId/:packVersion/:faceId/web",
async (request, reply) => {
try {
const asset = await applicationService.getFontPackAsset(
request.params.packId,
request.params.packVersion,
request.params.faceId,
"web"
);
if (!asset) {
return reply.code(404).send({
error: "FONT_PACK_ASSET_NOT_FOUND",
message: "未找到指定字体包资源"
});
}
return reply
.header("content-type", asset.contentType)
.header("cache-control", "public, max-age=31536000, immutable")
.header("content-security-policy", "default-src 'none'; sandbox")
.header("x-content-type-options", "nosniff")
.header("etag", `\"${asset.sha256}\"`)
.send(asset.content);
} catch {
return reply.code(404).send({
error: "FONT_PACK_ASSET_NOT_FOUND",
message: "未找到指定字体包资源"
});
}
}
);
app.post<{ Body: RenderRequestBody }>(
"/api/render",
async (request, reply) => {
@@ -355,7 +486,16 @@ export function buildApp(options: BuildAppOptions = {}) {
});
}
request.log.error({ error }, "PDF generation failed");
request.log.error(
{
error,
errorMessage:
error instanceof Error ? error.message : String(error),
errorStack:
error instanceof Error ? error.stack : undefined
},
"PDF generation failed"
);
return reply.code(500).send({
error: "PDF_GENERATION_FAILED",
message: "PDF 生成失败"
@@ -364,5 +504,109 @@ export function buildApp(options: BuildAppOptions = {}) {
}
);
app.post<{ Body: unknown }>("/api/docx", async (request, reply) => {
const abortController = new AbortController();
const abortRequest = () =>
abortController.abort(new Error("HTTP 请求已中断"));
request.raw.once("aborted", abortRequest);
try {
const generated = await docxExportService.generate(
request.body ?? {},
{
mediaAdapter: docxMediaAdapter,
signal: abortController.signal
}
);
request.log.info(
{
fileName: generated.fileName,
bytes: generated.docx.byteLength,
diagnostics: generated.diagnostics,
timings: generated.timings
},
"DOCX generated"
);
return reply
.header("content-type", DOCX_MIME_TYPE)
.header(
"content-disposition",
createDocxContentDisposition(generated.fileName)
)
.header("cache-control", "no-store")
.header(
"server-timing",
createDocxServerTiming(generated.timings)
)
.header(
"x-docx-warning-count",
String(generated.diagnostics.warnings.length)
)
.header(
"x-echarts-error-count",
String(generated.diagnostics.echartsErrors.length)
)
.header(
"x-mermaid-error-count",
String(generated.diagnostics.mermaidErrors.length)
)
.send(
Buffer.from(
generated.docx.buffer,
generated.docx.byteOffset,
generated.docx.byteLength
)
);
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
error: error.code,
message: error.message,
retryable: false
});
}
if (error instanceof DocxExportServiceError) {
if (error.statusCode >= 500) {
request.log.error(
{
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"
);
}
if (error.retryable) {
reply.header("retry-after", "5");
}
return reply.code(error.statusCode).send({
error: error.code,
message: error.message,
retryable: error.retryable
});
}
request.log.error({ error }, "DOCX generation failed");
return reply.code(500).send({
error: "DOCX_GENERATION_FAILED",
message: "DOCX 生成失败",
retryable: false
});
} finally {
request.raw.removeListener("aborted", abortRequest);
}
});
return app;
}
+219
View File
@@ -0,0 +1,219 @@
import { chromium, type Browser, type BrowserContext } from "playwright";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type {
DocxThemeStyleCaptureAdapter,
DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
import { captureDocxThemeStyleWithPlaywrightPage } from "./playwright-docx-theme-style.js";
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
export interface ServerDocxMediaCaptureAdapter
extends DocxMediaCaptureAdapter,
DocxThemeStyleCaptureAdapter {
warmup?(): Promise<void>;
close(): Promise<void>;
}
export interface PlaywrightDocxMediaEngineOptions {
renderOrigin?: string;
launchBrowser?: () => Promise<Browser>;
}
function normalizeRenderOrigin(value: string) {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
}
return url.origin;
}
async function closeContext(context: BrowserContext) {
await context.close().catch(() => undefined);
}
export class PlaywrightDocxMediaEngine
implements ServerDocxMediaCaptureAdapter
{
readonly themeStyleBaseUrl: string;
private readonly renderOrigin: string;
private readonly renderUrl: string;
private readonly launchBrowser: () => Promise<Browser>;
private browserPromise: Promise<Browser> | undefined;
private closed = false;
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
this.renderOrigin = normalizeRenderOrigin(
options.renderOrigin ??
process.env.PDF_RENDER_ORIGIN ??
"http://localhost:5173"
);
this.renderUrl = new URL(
"/preview-frame.html?target=continuous",
this.renderOrigin
).href;
this.themeStyleBaseUrl = this.renderUrl;
this.launchBrowser =
options.launchBrowser ??
(() =>
chromium.launch({
headless: true
}));
}
async warmup() {
if (this.closed) {
throw new Error("DOCX 媒体服务已关闭");
}
await this.getBrowser();
}
async capture(
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
throw new Error("DOCX 媒体服务已关闭");
}
signal?.throwIfAborted();
const browser = await this.getBrowser();
signal?.throwIfAborted();
const context = await browser.newContext({
locale: request.payload.metadata.language || "zh-CN",
serviceWorkers: "block"
});
const abort = () => {
void closeContext(context);
};
signal?.addEventListener("abort", abort, { once: true });
try {
await context.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const page = await context.newPage();
await page.goto(this.renderUrl, {
waitUntil: "domcontentloaded"
});
await page.waitForFunction(
() =>
document.documentElement.dataset.runtimeReady === "true"
);
signal?.throwIfAborted();
return await captureDocxMediaWithPlaywrightPage(
page,
request,
signal
);
} finally {
signal?.removeEventListener("abort", abort);
await closeContext(context);
}
}
async captureThemeStyle(
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
throw new Error("DOCX 媒体服务已关闭");
}
signal?.throwIfAborted();
if (new URL(request.baseUrl).origin !== this.renderOrigin) {
throw new Error("DOCX 主题样式资源地址必须与渲染地址同源");
}
const browser = await this.getBrowser();
signal?.throwIfAborted();
const context = await browser.newContext({
locale: "zh-CN",
serviceWorkers: "block"
});
const abort = () => {
void closeContext(context);
};
signal?.addEventListener("abort", abort, { once: true });
try {
await context.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const page = await context.newPage();
await page.goto(this.renderUrl, {
waitUntil: "domcontentloaded"
});
return await captureDocxThemeStyleWithPlaywrightPage(
page,
request,
signal
);
} finally {
signal?.removeEventListener("abort", abort);
await closeContext(context);
}
}
async close() {
this.closed = true;
const browserPromise = this.browserPromise;
this.browserPromise = undefined;
if (!browserPromise) {
return;
}
try {
const browser = await browserPromise;
await browser.close();
} catch {
// 浏览器启动失败时没有可关闭的实例。
}
}
private getBrowser() {
if (!this.browserPromise) {
const browserPromise = this.launchBrowser();
this.browserPromise = browserPromise;
void browserPromise
.then((browser) => {
browser.on("disconnected", () => {
if (this.browserPromise === browserPromise) {
this.browserPromise = undefined;
}
});
})
.catch(() => {
if (this.browserPromise === browserPromise) {
this.browserPromise = undefined;
}
});
}
return this.browserPromise;
}
}
export function createDocxMediaEngine(
options: PlaywrightDocxMediaEngineOptions = {}
) {
return new PlaywrightDocxMediaEngine(options);
}
+11 -1
View File
@@ -2,8 +2,18 @@ import { buildApp } from "./app.js";
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
const host = process.env.HOST ?? "0.0.0.0";
const fontPackRoot = process.env.FONT_PACK_ROOT?.trim();
const app = buildApp({
prewarmPdfBrowser: true
prewarmPdfBrowser: true,
prewarmDocxRuntime: true,
...(fontPackRoot
? {
fontPacks: {
roots: [fontPackRoot],
appVersion: process.env.APP_VERSION ?? "0.6.2"
}
}
: {})
});
async function start() {
+1 -1
View File
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
timeoutMs: readIntegerEnvironment(
environment,
"PDF_TIMEOUT_MS",
60_000,
180_000,
1_000
)
};
@@ -0,0 +1,85 @@
import type { Page } from "playwright";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
interface CaptureScreenshotResult {
data: string;
}
async function renderCapturePlan(
page: Page,
request: DocxMediaCaptureRequest
) {
return page.evaluate(
async ({ payload, dimensions }): Promise<DocxMediaCapturePlan> => {
const renderer = (
window as typeof window & {
__mdToPdfRenderDocxMedia?: (
renderPayload: typeof payload,
renderDimensions: typeof dimensions
) => Promise<DocxMediaCapturePlan>;
}
).__mdToPdfRenderDocxMedia;
if (!renderer) {
throw new Error("DOCX 媒体渲染运行时不可用");
}
return renderer(payload, dimensions);
},
request
);
}
export async function captureDocxMediaWithPlaywrightPage(
page: Page,
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
signal?.throwIfAborted();
await page.emulateMedia({ media: "print" });
const plan = await renderCapturePlan(page, request);
signal?.throwIfAborted();
const session = await page.context().newCDPSession(page);
try {
const captures = [];
for (const target of plan.targets) {
signal?.throwIfAborted();
const result = (await session.send("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
})) as CaptureScreenshotResult;
captures.push({
id: target.id,
png: Buffer.from(result.data, "base64")
});
}
signal?.throwIfAborted();
return { plan, captures };
} finally {
await session.detach();
}
}
export class PlaywrightDocxMediaCaptureAdapter
implements DocxMediaCaptureAdapter
{
constructor(private readonly page: Page) {}
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
return captureDocxMediaWithPlaywrightPage(
this.page,
request,
signal
);
}
}
@@ -0,0 +1,21 @@
import {
createDocxThemeStyleCaptureScript,
parseDocxThemeStyleRuntimeCapture,
type DocxThemeStyleCaptureRequest,
type DocxThemeStyleSnapshot
} from "@md-to-pdf/docx-theme-engine";
import type { Page } from "playwright";
export async function captureDocxThemeStyleWithPlaywrightPage(
page: Page,
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
): Promise<DocxThemeStyleSnapshot> {
signal?.throwIfAborted();
await page.emulateMedia({ media: "print" });
const capture = await page.evaluate(
createDocxThemeStyleCaptureScript(request)
);
signal?.throwIfAborted();
return parseDocxThemeStyleRuntimeCapture(request, capture);
}
+93
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { buildApp } from "../src/app.js";
import {
createPdfContentDisposition,
@@ -39,6 +40,61 @@ function localThemeManifest(
};
}
function sha256(content: Uint8Array | string) {
return createHash("sha256").update(content).digest("hex");
}
async function createServerFontPack(root: string) {
const directory = join(root, "official-cjk", "1.0.0");
const web = Buffer.from([31, 32, 33, 34]);
const docx = Buffer.from([41, 42, 43, 44, 45]);
const license = "SIL Open Font License 1.1";
await mkdir(join(directory, "fonts"), { recursive: true });
await writeFile(join(directory, "fonts", "serif.woff2"), web);
await writeFile(join(directory, "fonts", "serif.ttf"), docx);
await writeFile(join(directory, "OFL.txt"), license, "utf8");
await writeFile(
join(directory, "font-pack.json"),
JSON.stringify({
manifestVersion: 1,
id: "official-cjk",
version: "1.0.0",
name: "政务中文字体包",
description: "Server 字体包资源测试",
license: "OFL-1.1",
licenseFile: "OFL.txt",
licenseSha256: sha256(license),
licenseBytes: Buffer.byteLength(license),
compatibility: {
minimumAppVersion: "0.6.0",
maximumAppVersionExclusive: "0.7.0"
},
faces: [
{
id: "serif-regular",
targets: ["FandolSong", "Mdpdf Fandol Song"],
weight: 400,
style: "normal",
web: {
path: "fonts/serif.woff2",
format: "woff2",
bytes: web.byteLength,
sha256: sha256(web)
},
docx: {
path: "fonts/serif.ttf",
format: "truetype",
bytes: docx.byteLength,
sha256: sha256(docx)
}
}
]
}),
"utf8"
);
return { web };
}
afterEach(async () => {
await app?.close();
if (temporaryDirectory) {
@@ -136,6 +192,43 @@ describe("预览 API", () => {
expect(response.body).toContain("#write");
});
it("为主题 CSS 提供经过注册器验证的字体包 Web 资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-font-pack-"));
const fontPack = await createServerFontPack(temporaryDirectory);
app = buildApp({
logger: false,
fontPacks: {
roots: [temporaryDirectory],
appVersion: "0.6.0"
}
});
const cssResponse = await app.inject({
method: "GET",
url: "/api/themes/gov-red-standard/css"
});
expect(cssResponse.statusCode).toBe(200);
expect(cssResponse.body).toContain("md-to-pdf-font-packs:official-cjk@1.0.0");
expect(cssResponse.body).toContain(
"/api/font-packs/official-cjk/1.0.0/serif-regular/web?v="
);
const assetResponse = await app.inject({
method: "GET",
url: `/api/font-packs/official-cjk/1.0.0/serif-regular/web?v=${sha256(fontPack.web)}`
});
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("font/woff2");
expect(assetResponse.headers["cache-control"]).toContain("immutable");
expect(assetResponse.rawPayload).toEqual(fontPack.web);
const missingResponse = await app.inject({
method: "GET",
url: "/api/font-packs/official-cjk/2.0.0/serif-regular/web"
});
expect(missingResponse.statusCode).toBe(404);
});
it("发现本地主题并安全提供相对字体资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const themeDirectory = join(temporaryDirectory, "local-test");
+101
View File
@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
interface PackageManifest {
scripts?: Record<string, string>;
}
const projectRoot = fileURLToPath(
new URL("../../../", import.meta.url)
);
function readProjectFile(relativePath: string) {
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
}
describe("Docker v0.6.2 部署契约", () => {
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
const workspaces = [
"semantic-document",
"docx-theme-engine",
"font-pack-registry",
"docx-engine"
];
for (const workspace of workspaces) {
expect(dockerfile).toContain(
`COPY packages/${workspace}/package.json packages/${workspace}/package.json`
);
expect(dockerfile).toContain(
`npm run build -w @md-to-pdf/${workspace}`
);
expect(dockerfile).toContain(
`/app/packages/${workspace}/dist ./packages/${workspace}/dist`
);
}
expect(dockerfile).toContain(
"/app/packages/docx-engine/assets ./packages/docx-engine/assets"
);
expect(dockerfile).toContain("COPY tsconfig.base.json product.json ./");
});
it("固定校验 Pandoc 二进制、许可证、版权和最终版本", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
expect(dockerfile).toContain("ARG PANDOC_VERSION=3.9.0.2");
expect(dockerfile).toContain(
"A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508"
);
expect(dockerfile).toContain(
"9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD"
);
expect(dockerfile).toContain(
"842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740"
);
expect(dockerfile).toContain(
"COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2"
);
expect(dockerfile).toContain(
"ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc"
);
expect(dockerfile).toContain(
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
);
});
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
const compose = readProjectFile("deploy/compose.yaml");
const dockerignore = readProjectFile(".dockerignore");
const dockerfileIgnore = readProjectFile(
"deploy/Dockerfile.dockerignore"
);
expect(compose).toContain(
"FONT_PACK_ROOT: /app/.local/font-packs"
);
expect(compose).not.toContain("MD_TO_PDF_FONT_PACK_DIR");
expect(dockerfile).toContain(
"COPY --chown=pwuser:pwuser output/font-packs/root /app/.local/font-packs"
);
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
expect(compose).toContain(
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}"'
);
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
expect(dockerignore).toContain("!output/font-packs/root/**");
expect(dockerfileIgnore).toBe(dockerignore);
});
it("提供显式真实 Docker 字体包验收命令", () => {
const manifest = JSON.parse(
readProjectFile("package.json")
) as PackageManifest;
expect(manifest.scripts?.["verify:docker-font-pack"]).toContain(
"deploy/verify-font-pack-compose.mjs"
);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FastifyInstance } from "fastify";
import {
ApplicationRequestError,
DocxExportServiceError,
type DocxMediaCaptureAdapter
} from "@md-to-pdf/application";
import {
DOCX_MIME_TYPE,
defaultExportConfig,
type DocxCapability,
type DocxExportResult
} from "@md-to-pdf/core";
import { buildApp } from "../src/app.js";
import type { PdfGenerator } from "../src/pdf-engine.js";
import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js";
let app: FastifyInstance | undefined;
afterEach(async () => {
await app?.close();
app = undefined;
});
const availableCapability: DocxCapability = {
format: "docx",
status: "available",
expectedVersion: "3.9.0.2",
detectedVersion: "3.9.0.2"
};
function generatedDocx(): DocxExportResult {
return {
docx: new Uint8Array([80, 75, 3, 4]),
fileName: "测试报告.docx",
diagnostics: {
warnings: ["字体可能被替换"],
echartsErrors: ["图表 1"],
mermaidErrors: []
},
timings: {
queueMs: 1,
probeMs: 2,
prepareMs: 3,
themeStyleMs: 4,
mediaMs: 5,
referenceMs: 6,
pandocMs: 7,
validationMs: 8,
totalMs: 36
}
};
}
function createDocxService(
overrides: {
capability?: DocxCapability;
generate?: (request: unknown) => Promise<DocxExportResult>;
} = {}
) {
return {
getCapability: vi
.fn()
.mockResolvedValue(
overrides.capability ?? availableCapability
),
generate: vi.fn(
overrides.generate ?? (async () => generatedDocx())
),
close: vi.fn(async () => undefined)
};
}
function createMediaAdapter() {
return {
themeStyleBaseUrl:
"http://localhost:5173/preview-frame.html",
capture: vi.fn<
DocxMediaCaptureAdapter["capture"]
>(async () => ({
plan: {
targets: [],
echartsErrors: [],
mermaidErrors: []
},
captures: []
})),
captureThemeStyle: vi.fn(async () => {
throw new Error("此测试使用注入的 DOCX 导出服务");
}),
close: vi.fn(async () => undefined)
} satisfies ServerDocxMediaCaptureAdapter;
}
function createPdfGenerator() {
return {
generate: vi.fn(),
close: vi.fn(async () => undefined)
} as unknown as PdfGenerator;
}
function createTestApp(
service = createDocxService(),
mediaAdapter = createMediaAdapter()
) {
app = buildApp({
logger: false,
pdfGenerator: createPdfGenerator(),
docxExportService: service,
docxMediaAdapter: mediaAdapter
});
return { app, service, mediaAdapter };
}
describe("DOCX HTTP API", () => {
it("在统一 capability 中报告 DOCX 运行时状态", async () => {
const { app: instance, service } = createTestApp();
const response = await instance.inject({
method: "GET",
url: "/api/capabilities"
});
expect(response.statusCode).toBe(200);
expect(response.json().docx).toEqual(availableCapability);
expect(response.json().implemented).toContain("docx-export");
expect(service.getCapability).toHaveBeenCalledTimes(1);
});
it("通过独立端点返回不可用 capability", async () => {
const capability: DocxCapability = {
format: "docx",
status: "not-found",
expectedVersion: "3.9.0.2",
message: "未找到 Pandoc 运行时"
};
const { app: instance } = createTestApp(
createDocxService({ capability })
);
const response = await instance.inject({
method: "GET",
url: "/api/docx/capability"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(capability);
});
it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => {
const { app: instance, service, mediaAdapter } =
createTestApp();
const payload = {
markdown: "# 测试报告",
fileName: "目录/测试报告.md",
language: "zh-CN",
resources: [],
exportConfig: defaultExportConfig
};
const response = await instance.inject({
method: "POST",
url: "/api/docx",
payload
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain(
DOCX_MIME_TYPE
);
expect(response.headers["content-disposition"]).toContain(
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx"
);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.headers["x-docx-warning-count"]).toBe("1");
expect(response.headers["x-echarts-error-count"]).toBe("1");
expect(response.headers["x-mermaid-error-count"]).toBe("0");
expect(response.headers["server-timing"]).toContain(
"runtime-probe;dur=2.0"
);
expect(response.headers["server-timing"]).toContain(
"pandoc;dur=7.0"
);
expect(response.rawPayload).toEqual(
Buffer.from([80, 75, 3, 4])
);
expect(service.generate).toHaveBeenCalledWith(payload, {
mediaAdapter,
signal: expect.any(AbortSignal)
});
});
it("映射应用请求错误", async () => {
const service = createDocxService({
generate: async () => {
throw new ApplicationRequestError(
400,
"INVALID_EXPORT_CONFIG",
"导出配置无效"
);
}
});
const { app: instance } = createTestApp(service);
const response = await instance.inject({
method: "POST",
url: "/api/docx",
payload: {}
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: "INVALID_EXPORT_CONFIG",
message: "导出配置无效",
retryable: false
});
});
it("映射可重试的队列错误", async () => {
const service = createDocxService({
generate: async () => {
throw new DocxExportServiceError(
429,
"DOCX_QUEUE_FULL",
"DOCX 生成队列已满",
true
);
}
});
const { app: instance } = createTestApp(service);
const response = await instance.inject({
method: "POST",
url: "/api/docx",
payload: {}
});
expect(response.statusCode).toBe(429);
expect(response.headers["retry-after"]).toBe("5");
expect(response.json()).toEqual({
error: "DOCX_QUEUE_FULL",
message: "DOCX 生成队列已满",
retryable: true
});
});
it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => {
const service = createDocxService();
const mediaAdapter = createMediaAdapter();
const { app: instance } = createTestApp(
service,
mediaAdapter
);
await instance.close();
expect(service.close).toHaveBeenCalledTimes(1);
expect(mediaAdapter.close).toHaveBeenCalledTimes(1);
app = undefined;
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import type { Browser } from "playwright";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { PlaywrightDocxMediaEngine } from "../src/docx-media-engine.js";
const emptyPlan: DocxMediaCapturePlan = {
targets: [],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Server DOCX 媒体浏览器", () => {
it("使用请求级上下文并阻止非同源网络访问", async () => {
const continueRoute = vi.fn(async () => undefined);
const abortRoute = vi.fn(async () => undefined);
const detach = vi.fn(async () => undefined);
const page = {
goto: vi.fn(async () => undefined),
emulateMedia: vi.fn(async () => undefined),
waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => emptyPlan),
context: () => context
};
const context = {
route: vi.fn(
async (
_pattern: string,
handler: (route: {
request(): { url(): string };
continue(): Promise<void>;
abort(reason: string): Promise<void>;
}) => Promise<void>
) => {
await handler({
request: () => ({
url: () => "http://renderer.test/assets/runtime.js"
}),
continue: continueRoute,
abort: abortRoute
});
await handler({
request: () => ({
url: () => "https://external.test/tracker.js"
}),
continue: continueRoute,
abort: abortRoute
});
}
),
newPage: vi.fn(async () => page),
newCDPSession: vi.fn(async () => ({
send: vi.fn(),
detach
})),
close: vi.fn(async () => undefined)
};
const browser = {
newContext: vi.fn(async () => context),
on: vi.fn(),
close: vi.fn(async () => undefined)
} as unknown as Browser;
const engine = new PlaywrightDocxMediaEngine({
renderOrigin: "http://renderer.test",
launchBrowser: vi.fn(async () => browser)
});
const result = await engine.capture(request);
expect(result.plan).toEqual(emptyPlan);
expect(browser.newContext).toHaveBeenCalledWith({
locale: "zh-CN",
serviceWorkers: "block"
});
expect(page.goto).toHaveBeenCalledWith(
"http://renderer.test/preview-frame.html?target=continuous",
{ waitUntil: "domcontentloaded" }
);
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
expect(continueRoute).toHaveBeenCalledTimes(1);
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
expect(detach).toHaveBeenCalledOnce();
expect(context.close).toHaveBeenCalledOnce();
await engine.close();
expect(browser.close).toHaveBeenCalledOnce();
});
it("关闭后拒绝预热和媒体捕获", async () => {
const engine = new PlaywrightDocxMediaEngine({
renderOrigin: "http://renderer.test",
launchBrowser: vi.fn()
});
await engine.close();
await expect(engine.warmup()).rejects.toThrow(
"DOCX 媒体服务已关闭"
);
await expect(engine.capture(request)).rejects.toThrow(
"DOCX 媒体服务已关闭"
);
});
});
+1 -1
View File
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
expect(readPdfEngineRuntimeLimits({})).toEqual({
concurrency: 2,
maxQueue: 8,
timeoutMs: 60_000
timeoutMs: 180_000
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { captureDocxMediaWithPlaywrightPage } from "../src/playwright-docx-media-capture.js";
const plan: DocxMediaCapturePlan = {
targets: [
{
id: "docx-media-1",
kind: "echarts",
ordinal: 1,
kindOrdinal: 1,
altText: "图表",
alignment: "center",
displayWidthPx: 320,
displayHeightPx: 180,
captureX: 10,
captureY: 20,
captureWidthPx: 320,
captureHeightPx: 180,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Playwright DOCX 媒体捕获", () => {
it("按运行时计划以目标倍率捕获 PNG", async () => {
const detach = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
data: Buffer.from("png").toString("base64")
}));
const page = {
emulateMedia: vi.fn(async () => undefined),
evaluate: vi.fn(async () => plan),
context: () => ({
newCDPSession: vi.fn(async () => ({ send, detach }))
})
} as unknown as Page;
const result = await captureDocxMediaWithPlaywrightPage(
page,
request
);
expect(result.plan).toBe(plan);
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
});
expect(send).toHaveBeenCalledWith("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: 10,
y: 20,
width: 320,
height: 180,
scale: 3.125
}
});
expect(detach).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,89 @@
import {
DOCX_STYLE_SLOT_NAMES,
type DocxComputedStyle,
type DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { describe, expect, it, vi } from "vitest";
import { captureDocxThemeStyleWithPlaywrightPage } from "../src/playwright-docx-theme-style.js";
const computed: DocxComputedStyle = {
fontFamily: "Arial",
fontSize: "16px",
fontWeight: "400",
fontStyle: "normal",
color: "rgb(0, 0, 0)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineHeight: "normal",
letterSpacing: "normal",
textAlign: "start",
direction: "ltr",
textIndent: "0px",
textDecorationLine: "none",
marginTop: "0px",
marginRight: "0px",
marginBottom: "0px",
marginLeft: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
borderTop: "0px none rgb(0, 0, 0)",
borderRight: "0px none rgb(0, 0, 0)",
borderBottom: "0px none rgb(0, 0, 0)",
borderLeft: "0px none rgb(0, 0, 0)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
breakAfter: "auto",
breakInside: "auto",
display: "block",
flexDirection: "row",
alignItems: "normal",
justifyContent: "normal",
outline: "rgb(0, 0, 0) none 0px",
outlineOffset: "0px"
};
const request: DocxThemeStyleCaptureRequest = {
themeId: "external-clean",
themeFingerprint: "a".repeat(64),
themeCss: "#write { color: #000; }",
baseUrl: "http://localhost:3001/"
};
describe("Playwright DOCX 主题样式采集", () => {
it("使用打印媒体并校验浏览器返回的快照", async () => {
const page = {
emulateMedia: vi.fn(async () => undefined),
evaluate: vi.fn(async () => ({
rootFontSizePx: 16,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
matched: true,
computed
}))
}))
};
const snapshot =
await captureDocxThemeStyleWithPlaywrightPage(
page as never,
request
);
expect(page.emulateMedia).toHaveBeenCalledWith({
media: "print"
});
expect(page.evaluate).toHaveBeenCalledTimes(1);
expect(snapshot.themeId).toBe("external-clean");
expect(snapshot.slots).toHaveLength(
DOCX_STYLE_SLOT_NAMES.length
);
});
});
+2 -2
View File
@@ -17,9 +17,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="可配置、可主题化的 Markdown PDF 导出工具"
content="面向结构化 Markdown 的主题化文档创作与发布工具"
/>
<title>Markdown PDF 导出器</title>
<title>墨呈 · MorphDoc</title>
</head>
<body>
<div id="root"></div>
+5
View File
@@ -10,9 +10,14 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0",
"@md-to-pdf/preview-engine": "0.1.0",
"@uiw/react-codemirror": "^4.25.11",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"pdfjs-dist": "6.1.200",
+242 -35
View File
@@ -9,10 +9,12 @@ import {
useRef,
useState
} from "react";
import type { EditorView } from "@codemirror/view";
import {
createPagedDocumentPayload,
getPaperDimensionsMm,
millimetersToCssPixels,
type DocxCapability,
type ExportConfig
} from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
@@ -42,6 +44,10 @@ import {
type PdfExportRequest
} from "./pdf-export";
import { APP_VERSION_LABEL } from "./app-version";
import {
APP_DISPLAY_NAME,
APP_SLOGAN
} from "./app-brand";
import { getSyncedScrollTop } from "./scroll-sync";
import {
loadPreviewZoom,
@@ -71,6 +77,26 @@ import {
applyThemeDefaults,
selectTheme
} from "./theme-margins";
import {
MarkdownEditor,
type MarkdownEditorHandle
} from "./MarkdownEditor";
import { MarkdownToolbar } from "./MarkdownToolbar";
import {
createDocxDiagnosticsMessage,
downloadDocx,
getDocxCapability,
requestDesktopDocxExport,
requestDocxExport
} from "./docx-export";
import {
ExportMenu,
type ExportCommand
} from "./ExportMenu";
import {
ExportToast,
type ExportToastState
} from "./ExportToast";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
@@ -107,6 +133,11 @@ const initialDocumentKind: DocumentKind = startsWithBlankMarkdown
export function App() {
const [markdown, setMarkdown] = useState(initialMarkdown);
const [editorDocumentVersion, setEditorDocumentVersion] =
useState(0);
const [editorView, setEditorView] = useState<EditorView | null>(
null
);
const [fileName, setFileName] = useState(initialFileName);
const [documentKind, setDocumentKind] =
useState<DocumentKind>(initialDocumentKind);
@@ -130,6 +161,12 @@ export function App() {
const [paginationError, setPaginationError] = useState("");
const [pdfError, setPdfError] = useState("");
const [exportingPdf, setExportingPdf] = useState(false);
const [exportingDocx, setExportingDocx] = useState(false);
const [docxCapability, setDocxCapability] =
useState<DocxCapability>();
const [docxCapabilityError, setDocxCapabilityError] = useState("");
const [exportToast, setExportToast] =
useState<ExportToastState>();
const [previewMode, setPreviewMode] =
useState<PreviewMode>("quick");
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
@@ -141,7 +178,7 @@ export function App() {
current: 0,
total: 0
});
const editorRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<MarkdownEditorHandle>(null);
const markdownFileInputRef = useRef<HTMLInputElement>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
@@ -157,6 +194,15 @@ export function App() {
const documentDirtyRef = useRef(documentDirty);
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
documentDirtyRef.current = documentDirty;
const dismissExportToast = useCallback(
() => setExportToast(undefined),
[]
);
function replaceMarkdownDocument(content: string) {
setMarkdown(content);
setEditorDocumentVersion((version) => version + 1);
}
useEffect(() => {
const bridge = window.mdToPdfDesktop;
@@ -191,7 +237,7 @@ export function App() {
return;
}
setAppError("");
setMarkdown(opened.markdown);
replaceMarkdownDocument(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
@@ -228,6 +274,31 @@ export function App() {
);
}, [documentDirty]);
useEffect(() => {
let active = true;
void getDocxCapability()
.then((capability) => {
if (!active) {
return;
}
setDocxCapability(capability);
setDocxCapabilityError("");
})
.catch((reason: unknown) => {
if (!active) {
return;
}
setDocxCapabilityError(
reason instanceof Error
? reason.message
: "无法检测 DOCX 导出能力"
);
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
@@ -658,9 +729,13 @@ export function App() {
function synchronizeScroll(origin: "editor" | "preview") {
const source =
origin === "editor" ? editorRef.current : getPreviewScroller();
origin === "editor"
? editorRef.current?.getScrollElement()
: getPreviewScroller();
const target =
origin === "editor" ? getPreviewScroller() : editorRef.current;
origin === "editor"
? getPreviewScroller()
: editorRef.current?.getScrollElement();
if (!source || !target) {
return;
}
@@ -866,7 +941,7 @@ export function App() {
try {
setAppError("");
const content = await file.text();
setMarkdown(content);
replaceMarkdownDocument(content);
setFileName(file.name);
setDocumentKind("file");
setSavedMarkdown(content);
@@ -885,7 +960,7 @@ export function App() {
if (!opened) {
return;
}
setMarkdown(opened.markdown);
replaceMarkdownDocument(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
@@ -923,7 +998,7 @@ export function App() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown("");
replaceMarkdownDocument("");
setFileName("");
setDocumentKind("new");
setSavedMarkdown("");
@@ -1034,7 +1109,7 @@ export function App() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown(sample.markdown);
replaceMarkdownDocument(sample.markdown);
setFileName(sample.fileName);
setDocumentKind("sample");
setSavedMarkdown(sample.markdown);
@@ -1078,7 +1153,7 @@ export function App() {
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown(tutorial.markdown);
replaceMarkdownDocument(tutorial.markdown);
setFileName(tutorial.fileName);
setDocumentKind("sample");
setSavedMarkdown(tutorial.markdown);
@@ -1206,9 +1281,15 @@ export function App() {
setExportingPdf(true);
setPdfError("");
setStatus(
download ? "正在生成 PDF…" : "正在生成精确预览…"
);
if (download) {
setExportToast({
kind: "progress",
title: "正在生成 PDF",
message: "正在渲染页面、图表和字体,请稍候"
});
} else {
setStatus("正在生成精确预览…");
}
const requestedKey = pdfCacheKey;
try {
const resolved = await getOrRequestPdfExport(
@@ -1219,7 +1300,15 @@ export function App() {
: undefined
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
if (download) {
setExportToast({
kind: "error",
title: "PDF 导出已停止",
message: "文档已更新,请重新导出"
});
} else {
setStatus("文档已更新,已丢弃过期 PDF");
}
return;
}
setPdfCache(resolved);
@@ -1227,7 +1316,7 @@ export function App() {
if (download) {
const saved = await downloadPdf(exported.blob, exported.fileName);
if (!saved) {
setStatus("已取消 PDF 保存");
setExportToast(undefined);
return;
}
}
@@ -1243,19 +1332,94 @@ export function App() {
exported.echartsErrorCount > 0
? `${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
: "";
setStatus(
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
);
const resultMessage =
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`;
if (download) {
setExportToast({
kind: "success",
title: `PDF 已导出${pageDescription}`,
message:
echartsDescription || mermaidDescription
? `${echartsDescription}${mermaidDescription}`.replace(
/^/u,
""
)
: "文件已保存"
});
} else {
setStatus(resultMessage);
}
} catch (reason) {
setPdfError(
reason instanceof Error ? reason.message : "PDF 导出失败"
);
setStatus(download ? "PDF 导出失败" : "精确预览失败");
const message =
reason instanceof Error ? reason.message : "PDF 导出失败";
if (download) {
setExportToast({
kind: "error",
title: "PDF 导出失败",
message
});
} else {
setPdfError(message);
setStatus("精确预览失败");
}
} finally {
setExportingPdf(false);
}
}
async function generateDocx() {
if (
exportingDocx ||
docxCapability?.status !== "available"
) {
return;
}
setExportingDocx(true);
setExportToast({
kind: "progress",
title: "正在生成 DOCX",
message: "正在转换样式并将图片和图表渲染为 PNG"
});
try {
const request = {
markdown,
fileName: effectiveFileName,
language: "zh-CN",
resources: noImageResources,
exportConfig
};
let diagnostics;
if (window.mdToPdfDesktop) {
const result = await requestDesktopDocxExport(request);
if (!result.saved) {
setExportToast(undefined);
return;
}
diagnostics = result;
} else {
const result = await requestDocxExport(request);
downloadDocx(result);
diagnostics = result;
}
setExportToast({
kind: "success",
title: "DOCX 已导出",
message: createDocxDiagnosticsMessage(diagnostics)
});
} catch (reason) {
setExportToast({
kind: "error",
title: "DOCX 导出失败",
message:
reason instanceof Error
? reason.message
: "DOCX 导出失败"
});
} finally {
setExportingDocx(false);
}
}
function handlePreviewModeChange(mode: PreviewMode) {
if (mode === previewMode) {
return;
@@ -1279,14 +1443,57 @@ export function App() {
updatePreviewCurrentPage();
}
const docxDisabledReason = !markdown.trim()
? "文档内容为空"
: docxCapabilityError
? docxCapabilityError
: !docxCapability
? "正在检测 DOCX 导出能力"
: docxCapability.status === "available"
? undefined
: docxCapability.message;
const exportBusy = exportingPdf || exportingDocx;
const exportCommands: ExportCommand[] = [
{
id: "pdf",
label: "PDF",
description: "固定版式,适合打印与归档",
disabled: exportBusy || !markdown.trim(),
disabledReason: !markdown.trim()
? "文档内容为空"
: exportBusy
? "已有导出任务正在进行"
: undefined,
busy: exportingPdf,
onSelect: () => generatePdf(true)
},
{
id: "docx",
label: "DOCX",
description: "保留样式,可在 Word 与 WPS 中编辑",
disabled: exportBusy || Boolean(docxDisabledReason),
disabledReason: exportBusy
? "已有导出任务正在进行"
: docxDisabledReason,
busy: exportingDocx,
onSelect: generateDocx
}
];
return (
<main className="workspace">
{exportToast ? (
<ExportToast
toast={exportToast}
onDismiss={dismissExportToast}
/>
) : null}
<header className="topbar">
<div className="topbar-primary">
<div className="topbar-brand">
<p className="eyebrow"></p>
<p className="eyebrow">{APP_SLOGAN}</p>
<div className="topbar-brand-title">
<h1>Markdown PDF </h1>
<h1>{APP_DISPLAY_NAME}</h1>
<span className="app-version">{APP_VERSION_LABEL}</span>
</div>
</div>
@@ -1486,13 +1693,7 @@ export function App() {
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
<button
type="button"
disabled={exportingPdf || !markdown.trim()}
onClick={() => void generatePdf(true)}
>
{exportingPdf ? "正在导出…" : "导出 PDF"}
</button>
<ExportMenu commands={exportCommands} />
</div>
</header>
@@ -1522,12 +1723,13 @@ export function App() {
</button>
</div>
<textarea
<MarkdownToolbar editorView={editorView} />
<MarkdownEditor
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
documentVersion={editorDocumentVersion}
onChange={setMarkdown}
onEditorReady={setEditorView}
onScroll={() => synchronizeScroll("editor")}
/>
</div>
@@ -1537,7 +1739,12 @@ export function App() {
className="source-panel-restore"
aria-controls="markdown-source-content"
aria-expanded="false"
onClick={() => setSourcePanelCollapsed(false)}
onClick={() => {
setSourcePanelCollapsed(false);
window.requestAnimationFrame(() =>
editorRef.current?.focus()
);
}}
>
</button>
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from "react";
export interface ExportCommand {
id: string;
label: string;
description: string;
disabled?: boolean;
disabledReason?: string | undefined;
busy?: boolean;
onSelect(): void | Promise<void>;
}
export interface ExportMenuProps {
commands: ExportCommand[];
}
export function ExportMenu({ commands }: ExportMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!menuRef.current?.contains(event.target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div className="export-menu more-menu" ref={menuRef}>
<button
type="button"
aria-controls="export-actions-menu"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((current) => !current)}
>
</button>
{open ? (
<div
id="export-actions-menu"
className="export-menu-popover more-menu-popover"
role="menu"
aria-label="导出格式"
>
{commands.map((command) => (
<button
type="button"
className="export-menu-command"
role="menuitem"
key={command.id}
disabled={command.disabled}
title={command.disabledReason}
onClick={() => {
setOpen(false);
void command.onSelect();
}}
>
<span>
<strong>
{command.busy
? `正在生成 ${command.label}`
: `导出 ${command.label}`}
</strong>
<small>
{command.disabledReason ?? command.description}
</small>
</span>
</button>
))}
</div>
) : null}
</div>
);
}
+14 -1
View File
@@ -731,6 +731,19 @@ export function ExportSettingsDrawer({
</div>
);
})}
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.header.showOnFirstPage}
disabled={!config.header.enabled}
onChange={(event) =>
updateHeader({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control">
<input
type="checkbox"
@@ -854,7 +867,7 @@ export function ExportSettingsDrawer({
})
}
/>
<span></span>
<span></span>
</label>
<label className="switch-control divider-control">
<input
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from "react";
export interface ExportToastState {
kind: "progress" | "success" | "error";
title: string;
message?: string;
}
export interface ExportToastProps {
toast: ExportToastState;
onDismiss(): void;
}
export function ExportToast({
toast,
onDismiss
}: ExportToastProps) {
useEffect(() => {
if (toast.kind !== "success") {
return;
}
const timeout = window.setTimeout(onDismiss, 3_500);
return () => window.clearTimeout(timeout);
}, [onDismiss, toast]);
return (
<aside
className={`export-toast is-${toast.kind}`}
role={toast.kind === "error" ? "alert" : "status"}
aria-live={toast.kind === "error" ? "assertive" : "polite"}
>
<div className="export-toast-content">
<strong>{toast.title}</strong>
{toast.message ? <span>{toast.message}</span> : null}
</div>
{toast.kind !== "progress" ? (
<button
type="button"
className="export-toast-close"
aria-label="关闭导出提示"
onClick={onDismiss}
>
×
</button>
) : null}
{toast.kind === "progress" ? (
<div className="export-toast-progress" aria-hidden="true">
<span />
</div>
) : null}
</aside>
);
}
+158
View File
@@ -0,0 +1,158 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef
} from "react";
import CodeMirror, {
type ReactCodeMirrorRef
} from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import {
EditorView,
keymap,
type KeyBinding
} from "@codemirror/view";
import {
executeMarkdownEditorCommand,
markdownEditorCommands
} from "./markdown-editor-commands";
export interface MarkdownEditorHandle {
focus: () => void;
getScrollElement: () => HTMLElement | null;
getView: () => EditorView | null;
}
interface MarkdownEditorProps {
value: string;
documentVersion: number;
onChange: (value: string) => void;
onEditorReady?: (view: EditorView | null) => void;
onScroll?: () => void;
}
const editorTheme = EditorView.theme({
"&": {
height: "100%",
backgroundColor: "#f6f7f5",
color: "#26312d",
fontSize: "0.86rem"
},
"&.cm-focused": {
outline: "none",
boxShadow: "inset 3px 0 #74a88f"
},
".cm-scroller": {
fontFamily:
'"Cascadia Code", "SFMono-Regular", Consolas, monospace',
lineHeight: "1.72",
tabSize: "2",
overflow: "auto"
},
".cm-content": {
minHeight: "100%",
padding: "24px"
},
".cm-line": {
padding: "0"
},
".cm-gutters": {
border: "0",
backgroundColor: "#eef1ef",
color: "#8a9690"
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: "rgb(116 168 143 / 9%)"
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "rgb(89 135 113 / 24%)"
}
});
export const MarkdownEditor = forwardRef<
MarkdownEditorHandle,
MarkdownEditorProps
>(function MarkdownEditor(
{
value,
documentVersion,
onChange,
onEditorReady,
onScroll
},
forwardedRef
) {
const codeMirrorRef = useRef<ReactCodeMirrorRef>(null);
const viewRef = useRef<EditorView | null>(null);
const shortcutBindings = useMemo(
() =>
markdownEditorCommands.flatMap<KeyBinding>((command) =>
command.shortcut
? [
{
key: command.shortcut,
run: (view) =>
executeMarkdownEditorCommand(command.id, { view })
}
]
: []
),
[]
);
const extensions = useMemo(
() => [markdown(), keymap.of(shortcutBindings)],
[shortcutBindings]
);
useImperativeHandle(
forwardedRef,
() => ({
focus: () => viewRef.current?.focus(),
getScrollElement: () => viewRef.current?.scrollDOM ?? null,
getView: () => viewRef.current
}),
[]
);
useEffect(
() => () => {
onEditorReady?.(null);
viewRef.current = null;
},
[documentVersion, onEditorReady]
);
return (
<div
className="markdown-editor"
onScrollCapture={onScroll}
>
<CodeMirror
key={documentVersion}
ref={codeMirrorRef}
value={value}
height="100%"
basicSetup={{
autocompletion: false,
bracketMatching: true,
closeBrackets: true,
foldGutter: false,
highlightActiveLine: true,
highlightActiveLineGutter: true,
lineNumbers: true,
searchKeymap: true
}}
extensions={extensions}
theme={editorTheme}
aria-label="Markdown 内容"
onChange={onChange}
onCreateEditor={(view) => {
viewRef.current = view;
onEditorReady?.(view);
}}
/>
</div>
);
});
+208
View File
@@ -0,0 +1,208 @@
import {
type KeyboardEvent,
useEffect,
useRef,
useState
} from "react";
import type { EditorView } from "@codemirror/view";
import { insertMarkdownTable } from "./markdown-editor-commands";
interface MarkdownTableToolProps {
editorView: EditorView | null;
}
const maximumColumns = 10;
const maximumRows = 10;
const minimumRows = 2;
interface TableSize {
columns: number;
rows: number;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
export function MarkdownTableTool({
editorView
}: MarkdownTableToolProps) {
const [open, setOpen] = useState(false);
const [size, setSize] = useState<TableSize>({
columns: 3,
rows: 3
});
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
function focusSize(nextSize: TableSize) {
window.requestAnimationFrame(() => {
rootRef.current
?.querySelector<HTMLButtonElement>(
`[data-table-size="${nextSize.columns}x${nextSize.rows}"]`
)
?.focus();
});
}
useEffect(() => {
if (!open) {
return;
}
focusSize(size);
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => {
if (!editorView) {
setOpen(false);
}
}, [editorView]);
function moveSelection(
event: KeyboardEvent<HTMLButtonElement>,
columnsDelta: number,
rowsDelta: number
) {
event.preventDefault();
const nextSize = {
columns: clamp(
size.columns + columnsDelta,
1,
maximumColumns
),
rows: clamp(
size.rows + rowsDelta,
minimumRows,
maximumRows
)
};
setSize(nextSize);
focusSize(nextSize);
}
function insertTable(selectedSize: TableSize) {
if (!editorView) {
return;
}
insertMarkdownTable(
editorView,
selectedSize.columns,
selectedSize.rows
);
setOpen(false);
}
return (
<div className="markdown-table-tool" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className="markdown-toolbar-button"
aria-label="插入表格"
aria-haspopup="dialog"
aria-expanded={open}
title="插入表格"
disabled={!editorView}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setOpen((current) => !current)}
>
</button>
{open ? (
<div
className="markdown-table-popover"
role="dialog"
aria-label="选择表格尺寸"
>
<strong>
{size.columns} × {size.rows}
</strong>
<span></span>
<div
className="markdown-table-grid"
role="grid"
aria-label="表格尺寸"
aria-rowcount={maximumRows - minimumRows + 1}
aria-colcount={maximumColumns}
>
{Array.from(
{
length:
maximumColumns *
(maximumRows - minimumRows + 1)
},
(_, index) => {
const columns = (index % maximumColumns) + 1;
const rows =
Math.floor(index / maximumColumns) + minimumRows;
const selected =
columns <= size.columns && rows <= size.rows;
const active =
columns === size.columns && rows === size.rows;
return (
<button
key={`${columns}x${rows}`}
type="button"
role="gridcell"
className={
selected
? "markdown-table-cell is-selected"
: "markdown-table-cell"
}
data-table-size={`${columns}x${rows}`}
aria-label={`插入 ${columns}× ${rows} 行表格`}
aria-selected={active}
tabIndex={active ? 0 : -1}
onPointerEnter={() =>
setSize({ columns, rows })
}
onFocus={() => setSize({ columns, rows })}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
moveSelection(event, -1, 0);
} else if (event.key === "ArrowRight") {
moveSelection(event, 1, 0);
} else if (event.key === "ArrowUp") {
moveSelection(event, 0, -1);
} else if (event.key === "ArrowDown") {
moveSelection(event, 0, 1);
} else if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
insertTable({ columns, rows });
}
}}
onClick={() => insertTable({ columns, rows })}
/>
);
}
)}
</div>
</div>
) : null}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { MouseEvent } from "react";
import type { EditorView } from "@codemirror/view";
import {
executeMarkdownEditorCommand,
getMarkdownEditorCommand
} from "./markdown-editor-commands";
import { MarkdownTableTool } from "./MarkdownTableTool";
interface MarkdownToolbarProps {
editorView: EditorView | null;
}
const toolbarGroups = [
["undo", "redo"],
["bold", "italic", "strikethrough", "inline-code"],
[
"code-block",
"quote",
"unordered-list",
"ordered-list",
"task-list"
],
["table", "horizontal-rule"]
] as const;
function preserveEditorSelection(event: MouseEvent<HTMLElement>) {
event.preventDefault();
}
export function MarkdownToolbar({
editorView
}: MarkdownToolbarProps) {
function execute(commandId: string) {
if (!editorView) {
return;
}
executeMarkdownEditorCommand(commandId, { view: editorView });
}
return (
<div
className="markdown-toolbar"
role="toolbar"
aria-label="Markdown 格式工具栏"
>
<label className="markdown-toolbar-heading">
<span className="sr-only"></span>
<select
aria-label="设置标题级别"
defaultValue=""
disabled={!editorView}
onChange={(event) => {
if (event.target.value) {
execute(event.target.value);
event.target.value = "";
}
}}
>
<option value=""></option>
{Array.from({ length: 6 }, (_, index) => (
<option
key={index + 1}
value={`heading-${index + 1}`}
>
H{index + 1}
</option>
))}
</select>
</label>
{toolbarGroups.map((group, groupIndex) => (
<div
className="markdown-toolbar-group"
key={group.join("-")}
aria-label={`工具组 ${groupIndex + 1}`}
>
{group.map((commandId) => {
if (commandId === "table") {
return (
<MarkdownTableTool
key={commandId}
editorView={editorView}
/>
);
}
const command = getMarkdownEditorCommand(commandId);
if (!command) {
return null;
}
const disabled =
!editorView ||
(command.isEnabled?.({ view: editorView }) === false);
return (
<button
key={command.id}
type="button"
className={`markdown-toolbar-button command-${command.id}`}
aria-label={command.label}
title={
command.shortcut
? `${command.label}${command.shortcut}`
: command.label
}
disabled={disabled}
onMouseDown={preserveEditorSelection}
onClick={() => execute(command.id)}
>
{command.toolbarLabel}
</button>
);
})}
</div>
))}
</div>
);
}
+14 -2
View File
@@ -19,13 +19,16 @@ import {
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import {
getPdfCanvasRenderScale,
getPdfPageCssDimensions
getPdfPageCssDimensions,
getPrecisePdfDocumentWidth
} from "./pdf-page-geometry";
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
import { PdfPageRenderQueue } from "./pdf-render-queue";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
const PRECISE_PDF_INLINE_GUTTER = 30;
type PdfJsAnnotationLinkService = Parameters<
AnnotationLayer["render"]
>[0]["linkService"];
@@ -455,8 +458,17 @@ export function PrecisePdfPreview({
return <div className="precise-pdf-status"></div>;
}
const documentWidth = getPrecisePdfDocumentWidth(
pages.map((page) => page.width),
zoomPercent / 100,
PRECISE_PDF_INLINE_GUTTER
);
return (
<div className="precise-pdf-document">
<div
className="precise-pdf-document"
style={{ width: `${documentWidth}px` }}
>
{pages.map((page) => (
<PrecisePdfPage
key={page.pageNumber}
+4
View File
@@ -0,0 +1,4 @@
export const APP_DISPLAY_NAME = __APP_DISPLAY_NAME__;
export const APP_WINDOW_TITLE = __APP_WINDOW_TITLE__;
export const APP_SLOGAN = __APP_SLOGAN__;
export const APP_DESCRIPTION = __APP_DESCRIPTION__;
+144
View File
@@ -0,0 +1,144 @@
import {
DOCX_MIME_TYPE,
type DocxCapability,
type DocxExportRequestInput
} from "@md-to-pdf/core";
import { parseContentDispositionFileName } from "./pdf-export";
export interface DocxExportResult {
blob: Blob;
fileName: string;
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}
export interface DesktopDocxExportResult {
saved: boolean;
fileName: string;
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}
function parseNumericHeader(value: string | null) {
if (value === null) {
return 0;
}
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}
async function readErrorMessage(response: Response) {
try {
const payload = (await response.json()) as {
message?: unknown;
};
if (typeof payload.message === "string") {
return payload.message;
}
} catch {
// 非 JSON 错误响应使用通用提示。
}
return "DOCX 导出失败";
}
export async function getDocxCapability(
fetcher: typeof fetch = fetch
): Promise<DocxCapability> {
const bridge = window.mdToPdfDesktop;
if (bridge) {
return bridge.getDocxCapability();
}
const response = await fetcher("/api/docx/capability");
if (!response.ok) {
throw new Error("无法检测 DOCX 导出能力");
}
return (await response.json()) as DocxCapability;
}
export async function requestDocxExport(
request: DocxExportRequestInput,
fetcher: typeof fetch = fetch
): Promise<DocxExportResult> {
const response = await fetcher("/api/docx", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
const contentType =
response.headers.get("content-type")?.split(";")[0]?.trim();
if (contentType !== DOCX_MIME_TYPE) {
throw new Error("DOCX 服务返回了无效文件类型");
}
return {
blob: await response.blob(),
fileName: parseContentDispositionFileName(
response.headers.get("content-disposition"),
"document.docx"
),
warningCount: parseNumericHeader(
response.headers.get("x-docx-warning-count")
),
echartsErrorCount: parseNumericHeader(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: parseNumericHeader(
response.headers.get("x-mermaid-error-count")
)
};
}
export async function requestDesktopDocxExport(
request: DocxExportRequestInput
): Promise<DesktopDocxExportResult> {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
throw new Error("桌面 DOCX 引擎不可用");
}
const outcome = await bridge.exportDocx(request);
if (!outcome.ok) {
throw new Error(outcome.message);
}
return {
saved: outcome.saved,
fileName: outcome.fileName,
warningCount: outcome.diagnostics.warnings.length,
echartsErrorCount: outcome.diagnostics.echartsErrors.length,
mermaidErrorCount: outcome.diagnostics.mermaidErrors.length
};
}
export function downloadDocx(result: DocxExportResult) {
const url = URL.createObjectURL(result.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = result.fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function createDocxDiagnosticsMessage(result: {
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}) {
const messages = [];
if (result.warningCount > 0) {
messages.push(`${result.warningCount} 条提示`);
}
if (result.echartsErrorCount > 0) {
messages.push(`${result.echartsErrorCount} 个 ECharts 图表失败`);
}
if (result.mermaidErrorCount > 0) {
messages.push(`${result.mermaidErrorCount} 个 Mermaid 图表失败`);
}
return messages.length > 0
? messages.join("")
: "纸张、样式和可编辑结构已写入文档";
}
@@ -0,0 +1,16 @@
export async function withHiddenWindowAnimationFrames<T>(
windowRef: Window,
operation: () => Promise<T>
) {
const requestAnimationFrame = windowRef.requestAnimationFrame;
windowRef.requestAnimationFrame = (callback) =>
windowRef.setTimeout(
() => callback(windowRef.performance.now()),
0
);
try {
return await operation();
} finally {
windowRef.requestAnimationFrame = requestAnimationFrame;
}
}
+4
View File
@@ -1,8 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { APP_WINDOW_TITLE } from "./app-brand";
import { APP_VERSION_LABEL } from "./app-version";
import "./styles.css";
document.title = `${APP_WINDOW_TITLE} ${APP_VERSION_LABEL}`;
const rootElement = document.getElementById("root");
if (!rootElement) {
+514
View File
@@ -0,0 +1,514 @@
import {
EditorSelection,
type EditorState,
type SelectionRange
} from "@codemirror/state";
import {
redo,
redoDepth,
undo,
undoDepth
} from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
export type MarkdownEditorCommandGroup =
| "history"
| "text"
| "block"
| "insert"
| "document";
export interface EditorCommandContext {
view: EditorView;
openPanel?: (panelId: string) => void;
}
export interface MarkdownEditorCommand {
id: string;
group: MarkdownEditorCommandGroup;
label: string;
toolbarLabel: string;
shortcut?: string;
isEnabled?: (context: EditorCommandContext) => boolean;
execute: (
context: EditorCommandContext
) => void | Promise<void>;
}
export interface MarkdownTextEdit {
insert: string;
selectionFrom: number;
selectionTo: number;
}
type SelectionEditor = (
selectedText: string,
range: SelectionRange,
state: EditorState
) => MarkdownTextEdit;
export function wrapMarkdownSelection(
selectedText: string,
prefix: string,
suffix = prefix,
placeholder = "文本"
): MarkdownTextEdit {
if (
selectedText.length >= prefix.length + suffix.length &&
selectedText.startsWith(prefix) &&
selectedText.endsWith(suffix)
) {
const insert = selectedText.slice(
prefix.length,
selectedText.length - suffix.length
);
return {
insert,
selectionFrom: 0,
selectionTo: insert.length
};
}
const content = selectedText || placeholder;
return {
insert: `${prefix}${content}${suffix}`,
selectionFrom: prefix.length,
selectionTo: prefix.length + content.length
};
}
function mapNonBlankLines(
text: string,
mapper: (line: string, index: number) => string
) {
let contentLineIndex = 0;
return text
.split("\n")
.map((line) => {
if (!line.trim()) {
return line;
}
const mapped = mapper(line, contentLineIndex);
contentLineIndex += 1;
return mapped;
})
.join("\n");
}
export type MarkdownLinePrefix =
| "quote"
| "unordered-list"
| "ordered-list"
| "task-list";
const linePrefixPatterns: Record<MarkdownLinePrefix, RegExp> = {
quote: /^>\s?/u,
"unordered-list": /^[-*+]\s+/u,
"ordered-list": /^\d+[.)]\s+/u,
"task-list": /^[-*+]\s+\[[ xX]\]\s+/u
};
export function toggleMarkdownLinePrefix(
text: string,
kind: MarkdownLinePrefix
) {
const pattern = linePrefixPatterns[kind];
const nonBlankLines = text
.split("\n")
.filter((line) => line.trim());
const shouldRemove =
nonBlankLines.length > 0 &&
nonBlankLines.every((line) => pattern.test(line));
return mapNonBlankLines(text, (line, index) => {
if (shouldRemove) {
return line.replace(pattern, "");
}
if (kind === "quote") {
return `> ${line.replace(linePrefixPatterns.quote, "")}`;
}
if (kind === "ordered-list") {
return `${index + 1}. ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
}
if (kind === "task-list") {
return `- [ ] ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
}
return `- ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
});
}
export function setMarkdownHeading(text: string, level: number) {
const prefix = level > 0 ? `${"#".repeat(level)} ` : "";
return mapNonBlankLines(
text,
(line) => `${prefix}${line.replace(/^#{1,6}\s+/u, "")}`
);
}
export function createMarkdownTable(
columnCount: number,
rowCount: number
): MarkdownTextEdit {
const columns = Math.trunc(columnCount);
const rows = Math.trunc(rowCount);
if (columns < 1 || columns > 10 || rows < 2 || rows > 10) {
throw new RangeError("表格尺寸必须为 110 列、210 行");
}
const headerCells = Array.from(
{ length: columns },
(_, index) => ` ${index + 1}`
);
const separatorCells = Array.from(
{ length: columns },
() => "---"
);
const contentRow = `| ${Array.from(
{ length: columns },
() => "内容"
).join(" | ")} |`;
const insert = [
`| ${headerCells.join(" | ")} |`,
`| ${separatorCells.join(" | ")} |`,
...Array.from({ length: rows - 1 }, () => contentRow)
].join("\n");
return {
insert,
selectionFrom: 2,
selectionTo: 5
};
}
function replaceSelections(
view: EditorView,
createEdit: SelectionEditor
) {
const transaction = view.state.changeByRange((range) => {
const selectedText = view.state.sliceDoc(range.from, range.to);
const edit = createEdit(selectedText, range, view.state);
return {
changes: {
from: range.from,
to: range.to,
insert: edit.insert
},
range: EditorSelection.range(
range.from + edit.selectionFrom,
range.from + edit.selectionTo
)
};
});
view.dispatch(transaction);
view.focus();
}
function editSelectedLines(
view: EditorView,
transform: (text: string) => string
) {
const transaction = view.state.changeByRange((range) => {
const startLine = view.state.doc.lineAt(range.from);
const endProbe =
range.to > range.from &&
view.state.doc.lineAt(range.to).from === range.to
? range.to - 1
: range.to;
const endLine = view.state.doc.lineAt(endProbe);
const selectedLines = view.state.sliceDoc(
startLine.from,
endLine.to
);
const insert = transform(selectedLines);
return {
changes: {
from: startLine.from,
to: endLine.to,
insert
},
range: EditorSelection.range(
startLine.from,
startLine.from + insert.length
)
};
});
view.dispatch(transaction);
view.focus();
}
function insertMarkdownBlock(
block: string,
range?: SelectionRange,
state?: EditorState
): MarkdownTextEdit {
const before =
range && state ? state.sliceDoc(0, range.from) : "";
const after = range && state ? state.sliceDoc(range.to) : "";
const leading =
!before || before.endsWith("\n\n")
? ""
: before.endsWith("\n")
? "\n"
: "\n\n";
const trailing =
!after || after.startsWith("\n\n")
? ""
: after.startsWith("\n")
? "\n"
: "\n\n";
const insert = `${leading}${block}${trailing}`;
return {
insert,
selectionFrom: leading.length,
selectionTo: leading.length + block.length
};
}
export function insertMarkdownTable(
view: EditorView,
columnCount: number,
rowCount: number
) {
replaceSelections(view, (_, range, state) => {
const table = createMarkdownTable(columnCount, rowCount);
const block = insertMarkdownBlock(
table.insert,
range,
state
);
return {
insert: block.insert,
selectionFrom: block.selectionFrom + table.selectionFrom,
selectionTo: block.selectionFrom + table.selectionTo
};
});
}
function createInlineCommand(
id: string,
label: string,
toolbarLabel: string,
prefix: string,
suffix = prefix,
placeholder = "文本",
shortcut?: string
): MarkdownEditorCommand {
return {
id,
group: "text",
label,
toolbarLabel,
...(shortcut ? { shortcut } : {}),
execute: ({ view }) =>
replaceSelections(view, (selectedText) =>
wrapMarkdownSelection(
selectedText,
prefix,
suffix,
placeholder
)
)
};
}
function createLineCommand(
id: string,
label: string,
toolbarLabel: string,
kind: MarkdownLinePrefix,
shortcut?: string
): MarkdownEditorCommand {
return {
id,
group: "block",
label,
toolbarLabel,
...(shortcut ? { shortcut } : {}),
execute: ({ view }) =>
editSelectedLines(view, (text) =>
toggleMarkdownLinePrefix(text, kind)
)
};
}
const historyCommands: MarkdownEditorCommand[] = [
{
id: "undo",
group: "history",
label: "撤销",
toolbarLabel: "↶",
shortcut: "Mod-z",
isEnabled: ({ view }) => undoDepth(view.state) > 0,
execute: ({ view }) => {
undo(view);
view.focus();
}
},
{
id: "redo",
group: "history",
label: "重做",
toolbarLabel: "↷",
shortcut: "Mod-Shift-z",
isEnabled: ({ view }) => redoDepth(view.state) > 0,
execute: ({ view }) => {
redo(view);
view.focus();
}
}
];
const textCommands: MarkdownEditorCommand[] = [
createInlineCommand(
"bold",
"加粗",
"B",
"**",
"**",
"粗体文本",
"Mod-b"
),
createInlineCommand(
"italic",
"斜体",
"I",
"*",
"*",
"斜体文本",
"Mod-i"
),
createInlineCommand(
"strikethrough",
"删除线",
"S",
"~~",
"~~",
"删除线文本",
"Mod-Shift-x"
),
createInlineCommand(
"inline-code",
"行内代码",
"</>",
"`",
"`",
"code"
)
];
const blockCommands: MarkdownEditorCommand[] = [
{
id: "code-block",
group: "block",
label: "代码块",
toolbarLabel: "代码块",
execute: ({ view }) =>
replaceSelections(view, (selectedText) =>
wrapMarkdownSelection(
selectedText,
"```\n",
"\n```",
"code"
)
)
},
createLineCommand("quote", "引用", "❞", "quote"),
createLineCommand(
"unordered-list",
"无序列表",
"• 列表",
"unordered-list",
"Mod-Shift-8"
),
createLineCommand(
"ordered-list",
"有序列表",
"1. 列表",
"ordered-list",
"Mod-Shift-7"
),
createLineCommand("task-list", "任务列表", "☑", "task-list")
];
const insertCommands: MarkdownEditorCommand[] = [
{
id: "table",
group: "insert",
label: "插入表格",
toolbarLabel: "表格",
execute: ({ view, openPanel }) => {
if (openPanel) {
openPanel("table");
return;
}
insertMarkdownTable(view, 3, 3);
}
},
{
id: "horizontal-rule",
group: "insert",
label: "插入分隔线",
toolbarLabel: "—",
execute: ({ view }) =>
replaceSelections(view, (_, range, state) =>
insertMarkdownBlock("---", range, state)
)
}
];
const headingCommands = Array.from(
{ length: 6 },
(_, index): MarkdownEditorCommand => {
const level = index + 1;
return {
id: `heading-${level}`,
group: "block",
label: `${level} `,
toolbarLabel: `H${level}`,
execute: ({ view }) =>
editSelectedLines(view, (text) =>
setMarkdownHeading(text, level)
)
};
}
);
export const markdownEditorCommands: readonly MarkdownEditorCommand[] =
[
...historyCommands,
...headingCommands,
...textCommands,
...blockCommands,
...insertCommands
];
const markdownEditorCommandMap = new Map(
markdownEditorCommands.map((command) => [command.id, command])
);
export function getMarkdownEditorCommand(commandId: string) {
return markdownEditorCommandMap.get(commandId);
}
export function executeMarkdownEditorCommand(
commandId: string,
context: EditorCommandContext
) {
const command = getMarkdownEditorCommand(commandId);
if (
!command ||
(command.isEnabled && !command.isEnabled(context))
) {
return false;
}
void command.execute(context);
return true;
}
+17 -8
View File
@@ -3,17 +3,19 @@ import {
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
SHARED_HIGHLIGHT_CSS,
renderDocxMediaCapturePlan,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "@md-to-pdf/preview-engine";
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import {
beginPreviewFrameRender,
completePreviewFrameRender
} from "./preview-frame-visibility";
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
@@ -27,7 +29,7 @@ const previewRoot = getPreviewRoot();
const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot, {
highlightCss,
highlightCss: SHARED_HIGHLIGHT_CSS,
katexCss,
echartsCss
});
@@ -137,16 +139,25 @@ window.__mdToPdfRender = async (
}
return result;
};
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
withHiddenWindowAnimationFrames(window, () =>
renderDocxMediaCapturePlan(
runtime,
previewRoot,
payload,
dimensions
)
);
document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
if (desktopPdfBridge) {
desktopPdfBridge.onRender(async (requestId, payload) => {
const requestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) =>
window.setTimeout(() => callback(performance.now()), 0);
try {
const result = await window.__mdToPdfRender?.(payload, "pdf");
const result = await withHiddenWindowAnimationFrames(
window,
async () => window.__mdToPdfRender?.(payload, "pdf")
);
if (!result) {
throw new Error("PDF 分页运行时不可用");
}
@@ -156,8 +167,6 @@ if (desktopPdfBridge) {
requestId,
reason instanceof Error ? reason.message : "PDF 分页失败"
);
} finally {
window.requestAnimationFrame = requestAnimationFrame;
}
});
desktopPdfBridge.ready();
+3 -2
View File
@@ -66,7 +66,8 @@ function parseNumericHeader(value: string | null) {
}
export function parseContentDispositionFileName(
contentDisposition: string | null
contentDisposition: string | null,
fallbackFileName = "document.pdf"
) {
const encoded = contentDisposition?.match(
/filename\*=UTF-8''([^;]+)/i
@@ -82,7 +83,7 @@ export function parseContentDispositionFileName(
return (
contentDisposition
?.match(/filename="([^"]+)"/i)?.[1]
?.trim() || "document.pdf"
?.trim() || fallbackFileName
);
}
+19
View File
@@ -31,3 +31,22 @@ export function getPdfCanvasRenderScale(
}
return (displayWidthCssPixels / widthPoints) * devicePixelRatio;
}
export function getPrecisePdfDocumentWidth(
pageWidthsPoints: readonly number[],
displayScale: number,
inlineGutter: number
) {
const maximumPageWidth = Math.max(0, ...pageWidthsPoints);
if (
maximumPageWidth <= 0 ||
displayScale <= 0 ||
inlineGutter < 0
) {
return 0;
}
return Math.ceil(
maximumPageWidth * PDF_PAGE_CSS_SCALE * displayScale +
inlineGutter * 2
);
}
+266 -13
View File
@@ -188,6 +188,45 @@ h1 {
background: #edf3f0;
}
.more-menu-popover button:disabled:hover {
background: transparent;
}
.export-menu-popover {
width: 286px;
}
.more-menu-popover .export-menu-command {
min-height: 58px;
padding: 9px 11px;
}
.export-menu-command > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-menu-command strong {
color: inherit;
font-size: 0.82rem;
}
.export-menu-command small {
overflow: hidden;
color: #74827b;
font-size: 0.68rem;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-menu-command:disabled small {
color: #929b97;
}
.more-menu-popover .menu-command {
display: flex;
align-items: center;
@@ -254,6 +293,100 @@ h1 {
letter-spacing: 0.06em;
}
.export-toast {
position: fixed;
z-index: 70;
top: 16px;
left: 50%;
display: flex;
width: min(440px, calc(100vw - 32px));
min-height: 62px;
padding: 13px 46px 13px 16px;
overflow: hidden;
border: 1px solid #bfd0c7;
border-radius: 12px;
background: rgb(255 255 255 / 97%);
box-shadow: 0 14px 38px rgb(38 58 50 / 24%);
transform: translateX(-50%);
}
.export-toast.is-success {
border-color: #9dc5b0;
}
.export-toast.is-error {
border-color: #dfaaa7;
background: rgb(255 250 249 / 98%);
}
.export-toast-content {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-toast-content strong {
color: #2d493c;
font-size: 0.84rem;
}
.export-toast.is-error .export-toast-content strong {
color: #943d39;
}
.export-toast-content span {
overflow: hidden;
color: #64736b;
font-size: 0.72rem;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-toast-close {
position: absolute;
top: 9px;
right: 9px;
width: 32px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: #718078;
font-size: 1.25rem;
line-height: 1;
}
.export-toast-progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
overflow: hidden;
background: #e2eae6;
}
.export-toast-progress span {
display: block;
width: 42%;
height: 100%;
border-radius: 999px;
background: #4d8b6e;
animation: export-progress 1.25s ease-in-out infinite;
}
@keyframes export-progress {
from {
transform: translateX(-110%);
}
to {
transform: translateX(340%);
}
}
.editor-layout {
display: grid;
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
@@ -551,24 +684,134 @@ h1 {
border: 0;
}
textarea {
.markdown-editor {
flex: 1;
width: 100%;
min-height: 0;
padding: 24px;
border: 0;
outline: 0;
resize: none;
background: #f6f7f5;
color: #26312d;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
line-height: 1.72;
tab-size: 2;
overflow: hidden;
}
textarea:focus {
box-shadow: inset 3px 0 #74a88f;
.markdown-editor > div,
.markdown-editor .cm-editor {
height: 100%;
}
.markdown-toolbar {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 6px 10px;
border-bottom: 1px solid #d9dedb;
background: #f8faf9;
}
.markdown-toolbar-group {
display: flex;
align-items: center;
gap: 3px;
padding-left: 6px;
border-left: 1px solid #d9dedb;
}
.markdown-toolbar-button {
min-width: 32px;
min-height: 30px;
padding: 0 7px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: #41584e;
font-size: 0.72rem;
white-space: nowrap;
}
.markdown-toolbar-button:hover:not(:disabled) {
border-color: #c3d1ca;
background: #e8efeb;
}
.markdown-toolbar-button.command-bold {
font-weight: 900;
}
.markdown-toolbar-button.command-italic {
font-style: italic;
}
.markdown-toolbar-button.command-strikethrough {
text-decoration: line-through;
}
.markdown-toolbar-heading select {
height: 30px;
padding: 0 24px 0 8px;
border: 1px solid #c9d4ce;
border-radius: 6px;
background: #fff;
color: #41584e;
font-size: 0.72rem;
}
.markdown-table-tool {
position: relative;
}
.markdown-table-popover {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: 0;
display: flex;
width: 250px;
padding: 12px;
border: 1px solid #bfcac4;
border-radius: 9px;
background: #fff;
box-shadow: 0 12px 30px rgb(40 58 49 / 18%);
flex-direction: column;
gap: 4px;
}
.markdown-table-popover strong {
color: #2f493e;
font-size: 0.8rem;
}
.markdown-table-popover > span {
color: #718078;
font-size: 0.68rem;
}
.markdown-table-grid {
display: grid;
margin-top: 6px;
grid-template-columns: repeat(10, 18px);
gap: 4px;
}
.markdown-table-cell {
width: 18px;
min-width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
border: 1px solid #cbd4cf;
border-radius: 3px;
background: #f4f7f5;
}
.markdown-table-cell.is-selected {
border-color: #65957d;
background: #a9cbb9;
}
.markdown-table-cell:focus-visible {
outline: 2px solid #356c55;
outline-offset: 1px;
}
.preview-panel {
@@ -1190,7 +1433,7 @@ textarea:focus {
min-height: 650px;
}
textarea {
.markdown-editor {
min-height: 560px;
}
@@ -1253,6 +1496,11 @@ textarea:focus {
.editor-layout {
transition: none;
}
.export-toast-progress span {
width: 100%;
animation: none;
}
}
@media (max-width: 520px) {
@@ -1271,6 +1519,11 @@ textarea:focus {
overflow-y: auto;
}
.export-menu-popover {
right: 0;
width: min(286px, calc(100vw - 24px));
}
.more-menu-submenu {
position: static;
width: auto;
+36
View File
@@ -1,6 +1,12 @@
/// <reference types="vite/client" />
import type {
DocxCapability,
DocxExportDiagnostics,
DocxExportErrorCode,
DocxExportRequestInput,
DocxGenerationTimings,
DocxMediaCapturePlan,
PagedDocumentPayload,
PagedDocumentRenderResult,
RenderedMarkdownDocument,
@@ -10,6 +16,10 @@ import type {
declare global {
const __APP_VERSION__: string;
const __APP_DISPLAY_NAME__: string;
const __APP_WINDOW_TITLE__: string;
const __APP_SLOGAN__: string;
const __APP_DESCRIPTION__: string;
interface DesktopPdfResult {
pdf: Uint8Array;
@@ -18,6 +28,21 @@ declare global {
mermaidErrors: string[];
}
type DesktopDocxExportOutcome =
| {
ok: true;
saved: boolean;
fileName: string;
diagnostics: DocxExportDiagnostics;
timings: DocxGenerationTimings;
}
| {
ok: false;
error: DocxExportErrorCode;
message: string;
retryable: boolean;
};
interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
@@ -76,6 +101,10 @@ declare global {
getThemeCss(themeId: string): Promise<string>;
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
getDocxCapability(): Promise<DocxCapability>;
exportDocx(
request: DocxExportRequestInput
): Promise<DesktopDocxExportOutcome>;
}
interface DesktopPdfRuntimeBridge {
@@ -96,5 +125,12 @@ declare global {
interface Window {
mdToPdfDesktop?: DesktopApplicationBridge;
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
__mdToPdfRenderDocxMedia?: (
payload: PagedDocumentPayload,
dimensions: {
contentWidthPx: number;
contentHeightPx: number;
}
) => Promise<DocxMediaCapturePlan>;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APP_SLOGAN,
APP_WINDOW_TITLE
} from "../src/app-brand";
const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("产品品牌", () => {
it("在主界面使用中文名和产品标语", () => {
expect(APP_DISPLAY_NAME).toBe("墨呈");
expect(APP_SLOGAN).toBe("一稿多式,即写即呈");
expect(APP_WINDOW_TITLE).toBe("墨呈 · MorphDoc");
expect(APP_DESCRIPTION).toBe(
"面向结构化 Markdown 的主题化文档创作与发布工具"
);
});
it("在静态入口中移除旧工具名称", () => {
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
const mainSource = readFileSync(`${webRoot}/src/main.tsx`, "utf8");
expect(indexHtml).toContain("<title>墨呈 · MorphDoc</title>");
expect(mainSource).toContain(
"document.title = `${APP_WINDOW_TITLE} ${APP_VERSION_LABEL}`"
);
expect(indexHtml).not.toContain("Markdown PDF 导出器");
expect(indexHtml).not.toContain("内网文档工具");
});
});
+2 -2
View File
@@ -10,8 +10,8 @@ const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.5.1");
expect(APP_VERSION_LABEL).toBe("v0.5.1");
expect(APP_VERSION).toBe("0.6.2");
expect(APP_VERSION_LABEL).toBe("v0.6.2");
});
it("允许浏览器加载同源 Web Manifest", () => {
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DOCX_MIME_TYPE,
defaultExportConfig,
type DocxCapability
} from "@md-to-pdf/core";
import {
createDocxDiagnosticsMessage,
getDocxCapability,
requestDesktopDocxExport,
requestDocxExport
} from "../src/docx-export";
afterEach(() => {
vi.unstubAllGlobals();
});
const availableCapability: DocxCapability = {
format: "docx",
status: "available",
expectedVersion: "3.9.0.2",
detectedVersion: "3.9.0.2"
};
const request = {
markdown: "# 测试",
fileName: "测试.md",
language: "zh-CN",
resources: [],
exportConfig: defaultExportConfig
};
describe("DOCX 导出客户端", () => {
it("在 Web 环境读取服务端 capability", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () =>
Response.json(availableCapability)
);
await expect(getDocxCapability(fetcher)).resolves.toEqual(
availableCapability
);
expect(fetcher).toHaveBeenCalledWith("/api/docx/capability");
});
it("在 Desktop 环境通过最小 IPC 读取 capability", async () => {
const getDesktopCapability = vi.fn(
async () => availableCapability
);
vi.stubGlobal("window", {
mdToPdfDesktop: {
getDocxCapability: getDesktopCapability
}
});
const fetcher = vi.fn();
await expect(getDocxCapability(fetcher)).resolves.toEqual(
availableCapability
);
expect(getDesktopCapability).toHaveBeenCalledOnce();
expect(fetcher).not.toHaveBeenCalled();
});
it("请求 DOCX 并读取文件名与诊断头", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () => {
return new Response(new Blob(["PK-test"]), {
status: 200,
headers: {
"content-type": DOCX_MIME_TYPE,
"content-disposition":
"attachment; filename=\"document.docx\"; " +
"filename*=UTF-8''%E6%B5%8B%E8%AF%95.docx",
"x-docx-warning-count": "2",
"x-echarts-error-count": "1",
"x-mermaid-error-count": "0"
}
});
});
const result = await requestDocxExport(request, fetcher);
expect(fetcher).toHaveBeenCalledWith(
"/api/docx",
expect.objectContaining({ method: "POST" })
);
expect(result.fileName).toBe("测试.docx");
expect(result.warningCount).toBe(2);
expect(result.echartsErrorCount).toBe(1);
expect(result.mermaidErrorCount).toBe(0);
expect(await result.blob.text()).toBe("PK-test");
});
it("拒绝服务端返回的非 DOCX 文件", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () =>
new Response("error page", {
status: 200,
headers: { "content-type": "text/html" }
})
);
await expect(
requestDocxExport(request, fetcher)
).rejects.toThrow("DOCX 服务返回了无效文件类型");
});
it("显示服务端结构化错误", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () =>
Response.json(
{ message: "Pandoc 版本不匹配" },
{ status: 503 }
)
);
await expect(
requestDocxExport(request, fetcher)
).rejects.toThrow("Pandoc 版本不匹配");
});
it("Desktop 保存取消保持为正常结果", async () => {
const exportDocx = vi.fn(async () => ({
ok: true as const,
saved: false,
fileName: "测试.docx",
diagnostics: {
warnings: [],
echartsErrors: [],
mermaidErrors: []
},
timings: {
queueMs: 0,
probeMs: 0,
prepareMs: 0,
themeStyleMs: 0,
mediaMs: 0,
referenceMs: 0,
pandocMs: 0,
validationMs: 0,
totalMs: 0
}
}));
vi.stubGlobal("window", {
mdToPdfDesktop: { exportDocx }
});
await expect(
requestDesktopDocxExport(request)
).resolves.toEqual({
saved: false,
fileName: "测试.docx",
warningCount: 0,
echartsErrorCount: 0,
mermaidErrorCount: 0
});
});
it("Desktop 失败结果转换为可展示错误", async () => {
vi.stubGlobal("window", {
mdToPdfDesktop: {
exportDocx: vi.fn(async () => ({
ok: false as const,
error: "DOCX_QUEUE_FULL" as const,
message: "DOCX 生成队列已满",
retryable: true
}))
}
});
await expect(
requestDesktopDocxExport(request)
).rejects.toThrow("DOCX 生成队列已满");
});
it("生成简洁的 DOCX 诊断摘要", () => {
expect(
createDocxDiagnosticsMessage({
warningCount: 0,
echartsErrorCount: 0,
mermaidErrorCount: 0
})
).toBe("纸张、样式和可编辑结构已写入文档");
expect(
createDocxDiagnosticsMessage({
warningCount: 2,
echartsErrorCount: 1,
mermaidErrorCount: 3
})
).toBe(
"2 条提示,1 个 ECharts 图表失败,3 个 Mermaid 图表失败"
);
});
});
+3 -1
View File
@@ -109,7 +109,8 @@ describe("导出设置缓存", () => {
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
fontFamily: undefined,
showOnFirstPage: undefined
},
footer: {
...defaultExportConfig.footer,
@@ -123,6 +124,7 @@ describe("导出设置缓存", () => {
expect(migrated.pageDecorationsMode).toBe("theme");
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
expect(migrated.header.showOnFirstPage).toBe(true);
expect(migrated.footer.showOnFirstPage).toBe(true);
});
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { withHiddenWindowAnimationFrames } from "../src/hidden-window-animation-frame";
describe("隐藏窗口动画帧调度", () => {
it("使用定时器驱动动画帧并在成功后恢复", async () => {
const original = window.requestAnimationFrame;
const callback = vi.fn();
await withHiddenWindowAnimationFrames(window, async () => {
expect(window.requestAnimationFrame).not.toBe(original);
await new Promise<void>((resolve) => {
window.requestAnimationFrame((timestamp) => {
callback(timestamp);
resolve();
});
});
});
expect(callback).toHaveBeenCalledOnce();
expect(window.requestAnimationFrame).toBe(original);
});
it("操作失败后仍恢复原动画帧函数", async () => {
const original = window.requestAnimationFrame;
await expect(
withHiddenWindowAnimationFrames(window, async () => {
throw new Error("render failed");
})
).rejects.toThrow("render failed");
expect(window.requestAnimationFrame).toBe(original);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
createMarkdownTable,
setMarkdownHeading,
toggleMarkdownLinePrefix,
wrapMarkdownSelection
} from "../src/markdown-editor-commands";
describe("Markdown 编辑器命令", () => {
it("包装、取消包装文本并保持内容选区", () => {
expect(wrapMarkdownSelection("正文", "**")).toEqual({
insert: "**正文**",
selectionFrom: 2,
selectionTo: 4
});
expect(wrapMarkdownSelection("**正文**", "**")).toEqual({
insert: "正文",
selectionFrom: 0,
selectionTo: 2
});
expect(
wrapMarkdownSelection("", "`", "`", "code")
).toEqual({
insert: "`code`",
selectionFrom: 1,
selectionTo: 5
});
});
it("切换引用和无序列表前缀", () => {
expect(toggleMarkdownLinePrefix("甲\n乙", "quote")).toBe(
"> 甲\n> 乙"
);
expect(
toggleMarkdownLinePrefix("> 甲\n> 乙", "quote")
).toBe("甲\n乙");
expect(
toggleMarkdownLinePrefix("1. 甲\n2. 乙", "unordered-list")
).toBe("- 甲\n- 乙");
});
it("为选中的多行生成连续有序列表并保留空行", () => {
expect(
toggleMarkdownLinePrefix(
"第一项\n\n第二项\n第三项",
"ordered-list"
)
).toBe("1. 第一项\n\n2. 第二项\n3. 第三项");
});
it("为选中的多行生成无序列表和任务列表", () => {
expect(
toggleMarkdownLinePrefix(
"第一项\n第二项\n第三项",
"unordered-list"
)
).toBe("- 第一项\n- 第二项\n- 第三项");
expect(
toggleMarkdownLinePrefix(
"第一项\n第二项\n第三项",
"task-list"
)
).toBe("- [ ] 第一项\n- [ ] 第二项\n- [ ] 第三项");
});
it("设置标题时替换现有标题级别", () => {
expect(setMarkdownHeading("## 标题\n正文", 3)).toBe(
"### 标题\n### 正文"
);
expect(setMarkdownHeading("### 标题", 1)).toBe("# 标题");
});
it("生成指定列数和总行数的 Markdown 表格", () => {
expect(createMarkdownTable(3, 4)).toEqual({
insert:
"| 列 1 | 列 2 | 列 3 |\n" +
"| --- | --- | --- |\n" +
"| 内容 | 内容 | 内容 |\n" +
"| 内容 | 内容 | 内容 |\n" +
"| 内容 | 内容 | 内容 |",
selectionFrom: 2,
selectionTo: 5
});
expect(() => createMarkdownTable(0, 2)).toThrow(RangeError);
expect(() => createMarkdownTable(3, 11)).toThrow(RangeError);
});
});
+23
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getPdfCanvasRenderScale,
getPdfPageCssDimensions,
getPrecisePdfDocumentWidth,
PDF_PAGE_CSS_SCALE
} from "../src/pdf-page-geometry";
@@ -31,4 +32,26 @@ describe("精确 PDF 页面尺寸", () => {
);
expect(getPdfCanvasRenderScale(0, 800, 2)).toBe(0);
});
it.each([
[50, 457],
[100, 854],
[120, 1013],
[400, 3235]
])("在 %i%% 缩放下把双侧固定留白计入滚动宽度", (zoom, expected) => {
expect(
getPrecisePdfDocumentWidth(
[595.275591],
zoom / 100,
30
)
).toBe(expected);
});
it("按混合纸张中最宽页面计算精确预览滚动宽度", () => {
expect(
getPrecisePdfDocumentWidth([595.275591, 792], 1.2, 30)
).toBe(1328);
expect(getPrecisePdfDocumentWidth([], 1.2, 30)).toBe(0);
});
});
+3 -1
View File
@@ -37,7 +37,8 @@ const officialTheme = createTheme("official", {
...formalTheme.pageDefaults,
header: {
...defaultExportConfig.header,
fontFamily: '"Mdpdf Fandol Song", SimSun, serif'
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
showOnFirstPage: false
},
footer: {
...defaultExportConfig.footer,
@@ -105,6 +106,7 @@ describe("主题推荐页边距", () => {
expect(selected.pageDecorationsMode).toBe("theme");
expect(selected.header.fontFamily).toContain("Fandol Song");
expect(selected.header.showOnFirstPage).toBe(false);
expect(selected.footer).toMatchObject({
alignment: "outer",
format: "official-page",
+16 -1
View File
@@ -9,6 +9,17 @@ const rootPackage = JSON.parse(
"utf8"
)
) as { version: string };
const productBrand = JSON.parse(
readFileSync(
fileURLToPath(new URL("../../product.json", import.meta.url)),
"utf8"
)
) as {
displayName: string;
windowTitle: string;
slogan: string;
description: string;
};
export default defineConfig({
plugins: [react()],
@@ -16,7 +27,11 @@ export default defineConfig({
new URL("../../logos/web", import.meta.url)
),
define: {
__APP_VERSION__: JSON.stringify(rootPackage.version)
__APP_VERSION__: JSON.stringify(rootPackage.version),
__APP_DISPLAY_NAME__: JSON.stringify(productBrand.displayName),
__APP_WINDOW_TITLE__: JSON.stringify(productBrand.windowTitle),
__APP_SLOGAN__: JSON.stringify(productBrand.slogan),
__APP_DESCRIPTION__: JSON.stringify(productBrand.description)
},
build: {
rollupOptions: {
+67 -2
View File
@@ -11,19 +11,27 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/semantic-document/package.json packages/semantic-document/package.json
COPY packages/docx-theme-engine/package.json packages/docx-theme-engine/package.json
COPY packages/font-pack-registry/package.json packages/font-pack-registry/package.json
COPY packages/docx-engine/package.json packages/docx-engine/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/preview-engine/package.json packages/preview-engine/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci
COPY tsconfig.base.json ./
COPY tsconfig.base.json product.json ./
COPY apps ./apps
COPY packages ./packages
COPY logos ./logos
COPY samples ./samples
RUN 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 \
@@ -39,12 +47,50 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/semantic-document/package.json packages/semantic-document/package.json
COPY packages/docx-theme-engine/package.json packages/docx-theme-engine/package.json
COPY packages/font-pack-registry/package.json packages/font-pack-registry/package.json
COPY packages/docx-engine/package.json packages/docx-engine/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/preview-engine/package.json packages/preview-engine/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
FROM node:22-bookworm-slim AS pandoc-runtime
ARG PANDOC_VERSION=3.9.0.2
ARG PANDOC_LINUX_AMD64_URL=https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz
ARG PANDOC_LINUX_AMD64_SHA256=A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508
ARG PANDOC_COPYING_URL=https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYING.md
ARG PANDOC_COPYING_SHA256=9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD
ARG PANDOC_COPYRIGHT_URL=https://raw.githubusercontent.com/jgm/pandoc/3.9.0.2/COPYRIGHT
ARG PANDOC_COPYRIGHT_SHA256=842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740
RUN test "$(dpkg --print-architecture)" = "amd64" \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/var/cache/pandoc \
set -eux; \
archive="/var/cache/pandoc/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz"; \
if [ ! -f "${archive}" ]; then \
curl --fail --location --retry 3 --silent --show-error \
--output "${archive}" "${PANDOC_LINUX_AMD64_URL}"; \
fi; \
echo "${PANDOC_LINUX_AMD64_SHA256} ${archive}" | sha256sum --check --strict; \
mkdir -p "/opt/pandoc/${PANDOC_VERSION}"; \
tar --extract --gzip --file "${archive}" \
--directory "/opt/pandoc/${PANDOC_VERSION}" --strip-components=1; \
curl --fail --location --retry 3 --silent --show-error \
--output "/opt/pandoc/${PANDOC_VERSION}/COPYING.md" "${PANDOC_COPYING_URL}"; \
echo "${PANDOC_COPYING_SHA256} /opt/pandoc/${PANDOC_VERSION}/COPYING.md" \
| sha256sum --check --strict; \
curl --fail --location --retry 3 --silent --show-error \
--output "/opt/pandoc/${PANDOC_VERSION}/COPYRIGHT" "${PANDOC_COPYRIGHT_URL}"; \
echo "${PANDOC_COPYRIGHT_SHA256} /opt/pandoc/${PANDOC_VERSION}/COPYRIGHT" \
| sha256sum --check --strict; \
test "$(/opt/pandoc/${PANDOC_VERSION}/bin/pandoc --version | head -n 1)" = "pandoc ${PANDOC_VERSION}"
FROM ${RUNTIME_BASE_IMAGE} AS runtime
USER root
@@ -63,12 +109,20 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
fi
COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2
RUN ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
&& test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"
WORKDIR /app
COPY --chown=pwuser:pwuser package.json package-lock.json ./
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
COPY --chown=pwuser:pwuser packages/semantic-document/package.json packages/semantic-document/package.json
COPY --chown=pwuser:pwuser packages/docx-theme-engine/package.json packages/docx-theme-engine/package.json
COPY --chown=pwuser:pwuser packages/font-pack-registry/package.json packages/font-pack-registry/package.json
COPY --chown=pwuser:pwuser packages/docx-engine/package.json packages/docx-engine/package.json
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
@@ -86,12 +140,18 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/semantic-document/dist ./packages/semantic-document/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/docx-theme-engine/dist ./packages/docx-theme-engine/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/font-pack-registry/dist ./packages/font-pack-registry/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/docx-engine/dist ./packages/docx-engine/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/docx-engine/assets ./packages/docx-engine/assets
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser --from=build /app/samples ./samples
COPY --chown=pwuser:pwuser themes ./themes
COPY --chown=pwuser:pwuser output/font-packs/root /app/.local/font-packs
COPY deploy/nginx.conf /etc/nginx/nginx.conf
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
@@ -102,7 +162,12 @@ ENV HOST=127.0.0.1 \
PDF_RENDER_ORIGIN=http://127.0.0.1:8080 \
PDF_CONCURRENCY=1 \
PDF_MAX_QUEUE=4 \
PDF_TIMEOUT_MS=120000
PDF_TIMEOUT_MS=120000 \
FONT_PACK_ROOT=/app/.local/font-packs
RUN test -f /app/.local/font-packs/mdtp-serif-sc/1.0.0/font-pack.json
RUN test -f /app/.local/font-packs/mdtp-sans-sc/1.0.0/font-pack.json
RUN test -f /app/.local/font-packs/mdtp-mono/1.0.0/font-pack.json
USER pwuser
+5 -1
View File
@@ -7,7 +7,11 @@ coverage
dist
node_modules
out
output
output/*
!output/font-packs/
output/font-packs/*
!output/font-packs/root/
!output/font-packs/root/**
playwright-report
test-results
tmp
+49 -6
View File
@@ -2,7 +2,8 @@
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
Playwright Chromium 生成 PDF
Playwright Chromium 生成 PDF,并使用固定 Pandoc `3.9.0.2` 生成
可编辑 DOCX。发行镜像固定内置受校验字体包,不依赖宿主机字体挂载。
## 启动
@@ -37,7 +38,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2'
docker compose -f deploy\compose.yaml build
```
@@ -47,13 +48,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build
```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.1 应用代码和生产
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.2 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
@@ -73,6 +74,45 @@ docker compose -f deploy\compose.yaml up -d
3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
## 内置字体包
v0.6.2 的发行镜像固定将受校验字体包内置到
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
字体包使用固定目录结构:
```text
<字体包根目录>/
├── mdtp-serif-sc/1.0.0/
├── mdtp-sans-sc/1.0.0/
└── mdtp-mono/1.0.0/
```
从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像:
```powershell
npm run build:font-pack
docker compose -f deploy\compose.yaml up -d
```
字体二进制通过 Git LFS 固定在仓库中。Docker 构建上下文只放行构建所需
源码与本次生成的 `output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
发布前执行真实内置字体门禁:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.2'
npm run verify:docker-font-pack
```
该命令要求本机 Docker 引擎已经启动,并且冻结字体源可供
`npm run build:font-pack` 生成。验收会重建指定镜像,核对镜像内清单哈希、
确认不存在字体运行时挂载,并检查健康状态、Pandoc capability、WOFF2
资源和 DOCX 字体部件;所有临时容器、网络和目录会在结束时清理。
## 运行参数
| 环境变量 | 默认值 | 说明 |
@@ -82,6 +122,7 @@ docker compose -f deploy\compose.yaml up -d
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `MD_TO_PDF_APP_VERSION` | `0.6.2` | 字体包兼容性判断使用的应用版本 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
@@ -91,4 +132,6 @@ docker compose -f deploy\compose.yaml up -d
容器为 Chromium 提供 1 GiB 共享内存,并以非 root `pwuser` 运行。
构建阶段使用项目锁定的 Playwright `1.62.0` 安装精确匹配的 Chromium
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。DOCX 运行时仅
支持 Linux amd64Pandoc 二进制、许可证和版权文件均在构建阶段执行
固定版本与 SHA-256 校验。
+2
View File
@@ -19,6 +19,8 @@ services:
PDF_CONCURRENCY: "${PDF_CONCURRENCY:-1}"
PDF_MAX_QUEUE: "${PDF_MAX_QUEUE:-4}"
PDF_TIMEOUT_MS: "${PDF_TIMEOUT_MS:-120000}"
APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}"
FONT_PACK_ROOT: /app/.local/font-packs
volumes:
- "${MD_TO_PDF_THEME_DIR:-../.local/themes}:/app/.local/themes:ro"
shm_size: "1gb"
+281
View File
@@ -0,0 +1,281 @@
import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig } from "@md-to-pdf/core";
import { strFromU8, unzipSync } from "fflate";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, "..");
const composeFile = path.join(scriptDirectory, "compose.yaml");
const image = process.env.MD_TO_PDF_IMAGE?.trim() || "md-to-pdf:local";
const fontPackRoot = path.resolve(
path.join(repositoryRoot, "output", "font-packs", "root")
);
const expectedFontPacks = [
{ id: "mdtp-serif-sc", version: "1.0.0" },
{ id: "mdtp-sans-sc", version: "1.0.0" },
{ id: "mdtp-mono", version: "1.0.0" }
];
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex").toLowerCase();
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: repositoryRoot,
encoding: "utf8",
stdio: options.capture ? "pipe" : "inherit",
env: options.env ?? process.env
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} 失败(退出码 ${result.status}${
options.capture ? `\n${result.stderr || result.stdout}` : ""
}`
);
}
return options.capture ? result.stdout.trim() : "";
}
async function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配端口"))
: resolve(port)
);
});
});
}
async function waitForHealth(origin) {
let lastError;
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(`${origin}/api/health`);
if (response.ok && (await response.json()).status === "ok") {
return;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(
`容器健康检查未就绪${lastError ? `${String(lastError)}` : ""}`
);
}
function assertNoFontPackMount(containerId) {
const inspection = JSON.parse(
run("docker", ["inspect", containerId], { capture: true })
)[0];
const mount = inspection.Mounts.find(
(item) => item.Destination === "/app/.local/font-packs"
);
assert(!mount, "Docker 字体包必须来自镜像层,不能依赖运行时挂载");
}
async function exportDocx(origin) {
const response = await fetch(`${origin}/api/docx`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
markdown:
"---\ntitle: Docker 内置字体包验收\n---\n\n# Docker 内置字体包验收\n\n这是一段包含行内代码 `const value = 1` 的可编辑中文正文,代码前后不得换行。",
fileName: "Docker 内置字体包验收.md",
language: "zh-CN",
resources: [],
exportConfig: {
...defaultExportConfig,
themeId: "gov-red-standard"
}
})
});
if (!response.ok) {
throw new Error(
`DOCX 导出失败(${response.status}):${await response.text()}`
);
}
return new Uint8Array(await response.arrayBuffer());
}
const fontPackStatus = await stat(fontPackRoot).catch(() => undefined);
assert(
fontPackStatus?.isDirectory(),
`未找到有效字体包根目录:${fontPackRoot}。请先运行 npm run build:font-pack。`
);
const localManifests = await Promise.all(
expectedFontPacks.map(async (pack) => ({
...pack,
content: await readFile(
path.join(fontPackRoot, pack.id, pack.version, "font-pack.json")
)
}))
);
const buildEnvironment = {
...process.env,
MD_TO_PDF_IMAGE: image
};
run(
"docker",
["compose", "--file", composeFile, "build", "app"],
{ env: buildEnvironment }
);
run("docker", ["image", "inspect", image], { capture: true });
const embeddedManifestHashes = {};
for (const manifest of localManifests) {
const embeddedHash = run(
"docker",
[
"run",
"--rm",
"--entrypoint",
"sha256sum",
image,
`/app/.local/font-packs/${manifest.id}/${manifest.version}/font-pack.json`
],
{ capture: true }
).split(/\s+/u)[0];
assert(
embeddedHash === sha256(manifest.content),
`镜像内字体包清单不一致:${manifest.id}@${manifest.version}`
);
embeddedManifestHashes[`${manifest.id}@${manifest.version}`] = embeddedHash;
}
const port = await freePort();
const projectName = `morphdoc-font-pack-${randomUUID().slice(0, 8)}`;
const temporaryRoot = await mkdtemp(
path.join(os.tmpdir(), "morphdoc-docker-font-pack-")
);
const themeRoot = path.join(temporaryRoot, "empty-themes");
await mkdir(themeRoot, { recursive: true });
const environment = {
...buildEnvironment,
MD_TO_PDF_PORT: String(port),
MD_TO_PDF_THEME_DIR: themeRoot,
MD_TO_PDF_APP_VERSION: "0.6.2"
};
const compose = [
"compose",
"--project-name",
projectName,
"--file",
composeFile
];
try {
run(
"docker",
[...compose, "up", "--detach", "--no-build", "--pull", "never"],
{ env: environment }
);
const containerId = run(
"docker",
[...compose, "ps", "--quiet", "app"],
{ capture: true, env: environment }
);
assert(containerId, "未创建 Docker 字体包验收容器");
assertNoFontPackMount(containerId);
const origin = `http://127.0.0.1:${port}`;
await waitForHealth(origin);
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
const capability = await capabilityResponse.json();
assert(
capabilityResponse.ok &&
capability.status === "available" &&
capability.detectedVersion === "3.9.0.2",
`DOCX capability 无效:${JSON.stringify(capability)}`
);
const cssResponse = await fetch(
`${origin}/api/themes/gov-red-standard/css`
);
const css = await cssResponse.text();
assert(cssResponse.ok, "主题 CSS 请求失败");
assert(css.includes("md-to-pdf-font-packs:"), "主题 CSS 缺少字体包标记");
for (const pack of expectedFontPacks) {
assert(
css.includes(`${pack.id}@${pack.version}`),
`Docker 内置字体包未进入主题 CSS:${pack.id}@${pack.version}`
);
}
const assetPath = css.match(
/(\/api\/font-packs\/[^"'()\s]+\/web\?v=[a-f0-9]+)/u
)?.[1];
assert(assetPath, "Docker 内置字体包 CSS 缺少受控 WOFF2 地址");
const assetResponse = await fetch(`${origin}${assetPath}`);
assert(assetResponse.ok, "Docker 内置字体包 WOFF2 请求失败");
assert(
assetResponse.headers.get("content-type")?.includes("font/woff2"),
"Docker 内置字体包 WOFF2 MIME 无效"
);
assert(
assetResponse.headers.get("cache-control")?.includes("immutable"),
"Docker 内置字体包 WOFF2 缓存策略无效"
);
const docx = await exportDocx(origin);
const entries = unzipSync(docx);
const fontParts = Object.keys(entries).filter((entry) =>
entry.startsWith("word/fonts/")
);
assert(fontParts.length >= 2, `DOCX 字体部件不足:${fontParts.length}`);
const fontTable = strFromU8(entries["word/fontTable.xml"]);
assert(fontTable.includes("MdTP Serif SC"), "字体包未进入 DOCX 字体表");
assert(fontTable.includes("MdTP Mono"), "等宽字体包未进入 DOCX 字体表");
const documentXml = strFromU8(entries["word/document.xml"]);
const inlineCodeParagraph = documentXml
.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/gu)
?.find(
(paragraph) =>
paragraph.includes("这是一段包含行内代码") &&
paragraph.includes("const value = 1") &&
paragraph.includes("代码前后不得换行")
);
assert(inlineCodeParagraph, "行内代码及前后正文未保持在同一 Word 段落");
process.stdout.write(
`${JSON.stringify(
{
image,
embeddedManifestSha256: embeddedManifestHashes,
runtimeFontPackMount: false,
fontPackAvailable: true,
pandocVersion: capability.detectedVersion,
docxFontParts: fontParts.length
},
null,
2
)}\n`
);
} finally {
run(
"docker",
[...compose, "down", "--remove-orphans", "--timeout", "5"],
{ env: environment }
);
await rm(temporaryRoot, { recursive: true, force: true });
}
+205
View File
@@ -0,0 +1,205 @@
# Pandoc 运行时与分发规范
状态:`v0.6.0` 阶段 2 冻结,Desktop 内置运行时已实现。
## 1. 固定版本
`v0.6.0` 固定使用 Pandoc `3.9.0.2`
选择依据:
- 这是阶段冻结时的官方稳定版本;
- 本地 DOCX 探查和 Word/WPS 往返实验使用同一版本;
- 官方 Windows 和 Linux 静态发行包均包含 Lua 支持;
- 默认 `reference.docx` 已嵌入可执行文件;
- Windows 便携程序和 Linux 静态程序均不需要 Python。
不得在构建时自动下载“latest”。升级 Pandoc 必须作为显式变更,重新执行
DOCX OOXML、Word/WPS 互操作和包体回归。
## 2. 官方发行文件
### 2.1 Windows Desktop
```text
文件:pandoc-3.9.0.2-windows-x86_64.zip
SHA-256C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A
压缩大小:41,250,425 字节
pandoc.exe231,056,136 字节
pandoc.exe SHA-256E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA
```
发行包来源:
<https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip>
Desktop 只将解压后的以下内容放入应用资源目录,不嵌入 MSI 安装器:
```text
resources/pandoc/3.9.0.2/windows-x86_64/
├── pandoc.exe
├── COPYING.rtf
└── COPYRIGHT.txt
```
`MANUAL.html` 可以放入发行文档或第三方说明,不是运行必需文件。
### 2.2 Docker amd64
```text
文件:pandoc-3.9.0.2-linux-amd64.tar.gz
SHA-256A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508
压缩大小:34,520,947 字节
pandoc161,618,224 字节
```
发行包来源:
<https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz>
该程序已在 `node:22-bookworm-slim` 中验证:
```text
pandoc 3.9.0.2
Features: +server +lua
not a dynamic executable
```
Docker 安装目标:
```text
/opt/pandoc/3.9.0.2/bin/pandoc
/usr/local/bin/pandoc -> /opt/pandoc/3.9.0.2/bin/pandoc
```
官方 Linux 二进制 tarball 实际只包含 `bin/pandoc`,不包含许可证和版权
文件。Docker 构建会从同一个不可变版本标签 `3.9.0.2` 取得以下文件,并
分别执行 SHA-256 校验后放入相同运行目录:
```text
COPYING.md
SHA-2569D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD
COPYRIGHT
SHA-256842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740
```
二进制归档、许可证和版权文件均在独立下载阶段获取与校验,最终镜像只
复制 `/opt/pandoc/3.9.0.2/`,不保留下载缓存、Curl 或下载阶段依赖。
`v0.6.0` 正式验证范围为 Linux amd64。官方同时提供 arm64 包,但在完成
Playwright、Pandoc 和 DOCX 全链路验证前,不宣称 arm64 发布支持。
## 3. 路径发现与版本校验
应用服务采用以下优先级:
1. 服务端或开发环境显式配置的 `DOCX_PANDOC_PATH`
2. Desktop 的 `process.resourcesPath` 固定资源路径;
3. Docker 的 `/opt/pandoc/3.9.0.2/bin/pandoc`
4. 开发环境 `PATH` 中的 `pandoc`
约束:
- Web 页面、Markdown 和导出请求不能指定可执行路径;
- Desktop 渲染进程不能直接启动或选择 Pandoc;
- 显式路径只允许由受信任的服务端环境或开发者配置;
- 调用必须使用 Node `spawn`/`execFile` 的参数数组,不经过 Shell
- 路径解析后执行 `pandoc --version`,首行必须精确匹配
`pandoc 3.9.0.2`
- 不接受不同补丁版本静默降级;
- 能力检测失败时 DOCX capability 返回不可用原因,PDF 能力继续可用。
正式实现中的能力状态至少区分:
```text
available
not-found
version-mismatch
not-executable
probe-timeout
```
## 4. 许可证与源码义务
Pandoc 采用 GNU GPL version 2 or later。项目将其作为未修改的独立程序,
通过命令行参数和临时文件通信,不链接或导入 Pandoc 代码。
每个包含 Pandoc 的二进制发行必须:
- 保留 Pandoc `COPYING``COPYRIGHT` 和版本信息;
- 在第三方声明中标明 Pandoc 名称、版权、许可证和项目地址;
- 提供与所分发二进制精确对应的源码获取方式;
- 将对应源码归档与应用二进制放在同一版本的发布位置;
- 保证离线或内部转交发布产物时,许可证说明和源码获取信息同时转交;
- 不对 Pandoc 施加禁止再分发、禁止逆向或额外保密限制。
项目发布资产计划至少包含:
```text
MorphDoc-0.6.1-x86_64-Setup.exe
MorphDoc-0.6.1-x86_64.zip
pandoc-3.9.0.2-source.tar.gz
THIRD_PARTY_NOTICES.md
```
Docker 镜像必须包含许可证、版权声明和源码归档位置说明,并通过 OCI
`org.opencontainers.image.source`、版本和文档标签指向项目发布位置。
若未来修改 Pandoc 源码或构建方式,必须重新审查 GPL 对完整对应源码和
构建脚本的要求。公开或商业发布前应由项目权利人完成最终法律审查;本
规范是工程合规约束,不构成法律意见。
## 5. 包体实测
基线为本机已有 `v0.5.1` 正式产物。以下数值使用官方压缩包和解压程序
实测,NSIS 与 ZIP 最终值仍以 `v0.6.0` 构建结果为准。
| 产物 | v0.5.1 基线 | v0.6.0 实测/估算 | 增量 |
| --- | ---: | ---: | ---: |
| Desktop 运行目录 | 333.98 MiB | 556.94 MiB | 222.96 MiB |
| NSIS 安装包 | 109.38 MiB | 133.78 MiB | 24.40 MiB |
| Desktop ZIP | 145.13 MiB | 183.38 MiB | 38.25 MiB |
| Docker 内容大小 | 615.91 MiB | 约 770.05 MiB | 154.13 MiB |
| Docker 压缩拉取层 | — | — | 约 32.92 MiB |
结论:
- Desktop 解压目录增长明显;实测安装包增加 24.40 MiB,ZIP 增加
38.25 MiB
- Docker 运行时内容增长约 154 MiB,镜像传输增量约 33 MiB
- 不采用首次使用时联网下载,以保持内网和离线 Desktop 能力;
- 不使用 UPX 或二次修改官方二进制,避免兼容性、杀毒误报和合规风险;
- 不为了减小包体引入 Python 或另一个文档转换运行时。
## 6. 构建与更新规则
后续实现应增加受版本控制的运行时清单,至少记录:
- Pandoc 版本;
- 平台和架构;
- 官方下载地址;
- SHA-256
- 解压后的相对可执行路径;
- 许可证和版权文件;
- 对应源码归档地址。
Desktop 清单位于
`packages/docx-engine/src/pandoc-runtime.json`;准备脚本位于
`apps/desktop/scripts/prepare-pandoc-runtime.mjs`。下载缓存和解压目录
位于被 Git 忽略的 `apps/desktop/.runtime/`,不会进入源码提交。
构建流程必须:
1. 根据清单下载或读取缓存;
2. 校验 SHA-256
3. 拒绝未知平台和架构;
4. 将运行所需文件复制到固定目录;
5. 生成或复制第三方声明;
6. 构建后执行 `pandoc --version`
7. 用最小 Markdown 生成 DOCX 冒烟文件;
8. 检查发行包中不存在用户级 Pandoc 配置或缓存。
运行时不得读取用户 Pandoc 数据目录。DOCX 转换应通过隔离环境变量或显式
参数使用项目控制的模板、Lua Filter 和临时资源目录,确保不同设备输出
可复现。
+650 -15
View File
@@ -1,6 +1,24 @@
# Markdown PDF 导出器进度
# 墨呈进度
最后更新:2026-07-29
最后更新:2026-08-26
## 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. 当前概况
@@ -61,6 +79,455 @@ DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
并修复红头标题居中、代码块自动换行、左侧内容留白和跨页分页。Windows
安装包、Docker Compose、本机安装与发布验证均已完成。
`v0.6.0` 已完成开发立项,核心范围冻结为 DOCX 导出与 Markdown
工具栏。DOCX 采用 Node.js 编排、固定版本 Pandoc、动态
`reference.docx` 和 Chromium/Electron 高分辨率 PNG 媒体管线,不引入
Python;左侧编辑器计划升级为 CodeMirror 6,并通过项目自有命令注册层
支持基础工具栏及后续 Front Matter、ECharts YAML 工具扩展。完整设计与
验收门禁见 [v0.6.0 设计文档](V0.6.0_DESIGN.md)。
`v0.6.1` 已冻结为 DOCX 视觉一致性和发布门禁修复版本。发布后复核确认
`v0.6.0` 的 14 主题矩阵主要验证 CSS 槽位、OOXML 结构和样式存在性,
六个视觉场景只覆盖五套主题;视觉报告中的像素、墨迹 IoU 和边缘 IoU
failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过。`v0.6.1`
要求四套独立封面执行 Chromium、Word、WPS 整页硬门禁,正文改为跨页
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
建立阻断验收。完整范围见 [v0.6.1 设计文档](V0.6.1_DESIGN.md)。
`v0.6.1` 最终视觉矩阵已完成:14 套内置主题 × 纵横两个方向 × 主题默认、
标准、紧凑、宽松、非对称装订五组页边距共 140 个场景全部通过,阻断失败
和诊断失败均为 0。修复集中在通用 CSS 到 OOXML 翻译、字体嵌入、中文
标点字距、精确行距、打印媒体和行内代码连续性,没有引入按主题 ID 分支。
同一 `red-briefing` 已通过源码服务、Docker Web API 和实际安装 Desktop
三条生产链,三份 DOCX 均约 24.17 MB、包含 5 个字体部件;安装版产物经
真实 Word/WPS 渲染均为 2 页 A4,封面和正文逐页复核通过。
`v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
39.34 MiB,解压目录增加约 220.35 MiB;Docker 压缩层预计增加约
32.92 MiB,运行内容增加约 154.13 MiB。项目当前采用公司内部专属许可,
未来公开 GitHub 时计划重新评估并切换到 Apache License 2.0。三套 Typora
复制主题允许继续用于公司内部源码共享、部署和安装包分发,但不受项目
许可证重新授权;公开 GitHub 前必须移除、替换或取得公开分发授权。
`v0.6.0` 发布收口阶段 A 已完成品牌与版本迁移:用户界面中文名为
“墨呈”,产品标语为“一稿多式,即写即呈”,窗口标题为
“墨呈 · MorphDoc v0.6.0”;Desktop 安装目录、可执行文件与发行产物统一
使用 `MorphDoc`。Electron `appId` 和 Markdown 文件关联 ProgID 保持不变,
并为旧版用户数据、快捷方式和应用注册项提供兼容迁移与清理。
`v0.6.0` 发布收口阶段 B 已完成 Desktop Pandoc 内置链路:发行构建根据
受版本控制的清单准备官方 Pandoc `3.9.0.2` Windows x64 ZIP,严格校验
SHA-256,只提取 `pandoc.exe``COPYING.rtf``COPYRIGHT.txt` 并生成
审计清单;打包前执行精确版本、默认模板和最小 DOCX 冒烟。生产环境优先
使用 `process.resourcesPath` 下的固定程序,无需用户安装 Pandoc,显式
开发配置和系统 PATH 降级仍保持兼容。
`v0.6.0` 发布收口阶段 C 已完成字体发行链收口。字体包构建器新增确定性
ZIP 附件,Windows 独立安装器和附件统一使用 `MorphDoc-FontPack` 名称;
Desktop 组合发行在同目录放置主安装器、字体安装器和手动 ZIP,主安装器
仅绑定字体安装器文件名与 SHA-256,检测成功时显示默认勾选项,本体及免
安装包均不包含字体包目录。Docker 构建上下文只放行受校验字体包根目录,
镜像固定内置到 `/app/.local/font-packs`,Compose 已取消字体运行时挂载。
真实 Docker 门禁确认镜像内清单哈希一致、无字体卷、Pandoc 为 3.9.0.2、
WOFF2 可用,生产 DOCX 含 5 个字体部件。当前镜像大小为 711,082,692
字节;Desktop 主安装器为 140,279,447 字节,独立字体安装器为
24,488,770 字节,字体 ZIP 为 29,186,293 字节。
`v0.6.0` 发布前 Desktop DOCX 媒体捕获超时修复已完成。共享图片等待
逻辑消除了 `decode()` 与加载事件之间的竞态,并增加有界超时;隐藏窗口
中的媒体渲染使用定时器驱动帧调度,Electron 捕获阶段临时激活合成表面,
同时为布局与单媒体截图提供可诊断的独立超时。Windows 目录版使用默认
10 页示例完成真实导出,12 个媒体全部生成 PNG,最终 DOCX 为 8,937,824
字节、包含 12 个媒体部件和 5 个字体部件;单元测试、类型检查、生产构建
`git diff --check` 均通过。
`v0.6.0` 发布前主题展示名已完成品牌中立化:四套基础主题分别显示为
GitHub、Clean、Pixyll 和 Whitey,默认主题仍为 GitHub。为确保覆盖升级
和历史配置兼容,`typora-github``typora-like``typora-pixyll`
`typora-whitey` 等内部主题 ID、目录名和配置键保持不变;来源说明、作者
字段及许可证审计信息也继续保留。
`v0.6.0` 发布收口阶段 D 已建立统一发版规则。正式分发文件只允许归集到
被 Git 忽略的 `release/v<version>/`;统一脚本从 Desktop、字体包和
版本化 Docker 镜像生成安装器、便携 ZIP、字体附件、离线镜像 tar.gz、
Web 部署 ZIP、发行说明、机器可读清单及 SHA-256 校验文件,先写入临时
目录并在全部成功后原子落盘,既有版本目录禁止覆盖。正式字体资产迁入
`font-packs/assets/<id>/<semver>/` 并由 Git LFS 管理,构建器继续严格校验
冻结字节数、哈希、内部字族名和嵌入权限。本次 `release/v0.6.0` 的 8 项
校验和全部通过,707,722,584 字节的离线镜像归档已由 `docker load` 重新
导入,镜像 ID 保持
`sha256:53bfbc9592542d4ffe9ec3017c401c6d49a28c4ee993c6c5fc00536b1d01978a`
运行容器健康状态为 `healthy`
`v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求
Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和
安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用
现有主题、图片和 Markdown 安全渲染能力,为后续 Server 与 Desktop DOCX
引擎返回同一种准备结果。本阶段未新增 HTTP 路由、IPC 或 Pandoc 调用。
`v0.6.0` 阶段 4 已将左侧原生文本框升级为 CodeMirror 6,并建立项目
自有 Markdown 命令注册层和响应式基础工具栏。当前支持撤销、重做、
H1~H6、加粗、斜体、删除线、行内代码、代码块、引用、三类列表、尺寸
表格和分隔线;链接、图片等功能化工具延后。现有实现保留受控 Markdown
状态、防抖预览、文件工作流、文档脏状态、折叠恢复焦点及编辑/预览滚动
同步。外部文档替换会重置历史,单次工具栏操作形成一个 CodeMirror
事务。
`v0.6.0` 阶段 5 已完成 DOCX 资源预处理管线:复用连续预览运行时等待
图片、Mermaid、ECharts 和字体,按纸张内容区生成稳定媒体捕获计划;
Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium
统一将所有媒体捕获为 PNG。目标为 300 DPI,并限制 4096px 单边、1600
万像素、8 MiB 单图和 30 MiB 总量。本阶段只提供共享服务和平台适配器,
尚未新增 DOCX HTTP 路由、应用 IPC、Pandoc 调用或导出按钮。
`v0.6.0` 阶段 6 已完成纯 Node.js 动态 `reference.docx` 引擎:14 套
内置主题均声明结构化 DOCX 样式预设;引擎基于 Pandoc 3.9.0.2 默认模板
映射纸张、方向、页边距、正文、标题、代码、引用、表格、图注、字体、
首页/奇偶页眉页脚和动态页码。生成后会校验全部 XML、包内关系、内容
类型、最终节与关键样式,并使用稳定缓存键区分基线、Pandoc 版本、主题、
配置和元数据。本机真实 Pandoc 验收覆盖公文 A4 和自定义横向两组模板,
输出仍由标准 Word 段落、文本 Run、表格和代码文本组成,可继续编辑。
`v0.6.0` 阶段 7 已完成 Pandoc 转换服务:运行时按受信任配置、Desktop
资源、Docker 固定目录和 PATH 顺序发现并精确校验 3.9.0.2;默认模板和
动态模板使用稳定指纹与有界缓存。静态 Lua Filter 在 Pandoc AST 层将
普通图片、Mermaid 和 ECharts 替换为高分辨率 PNG,正文、表格、代码和
公式继续由 Pandoc 生成可编辑结构。每次转换使用独立临时目录和隔离的
data 目录,完成后校验 OOXML 并清理。共享应用服务提供有界并发、排队、
总超时、取消、关闭、capability、错误码和完整分阶段耗时;尚未新增
HTTP 路由、Desktop IPC 或导出按钮。
`v0.6.0` 阶段 8 已完成 Web 与 Desktop DOCX 导出交互:Server 提供
DOCX capability 与导出 HTTP APIDesktop 通过最小权限 IPC 在主进程
完成生成和原生另存为,两端复用共享服务、固定 Pandoc、PNG 媒体管线和
错误协议。顶栏原“导出 PDF”按钮升级为可扩展“导出”菜单,当前包含 PDF
与 DOCX,后续可增加 HTML 等格式;导出进度、成功和失败使用独立顶部
Toast 与非确定进度条,不污染预览状态或 PDF 精确预览缓存。Desktop
退出会等待 PDF、DOCX、Pandoc 与隐藏媒体窗口完成清理,Lua Filter 作为
明确构建资源进入桌面主进程目录。
`v0.6.0` 阶段 9 已建立 DOCX 自动化验收门禁:综合样例覆盖可编辑正文、
标题、字符样式、列表、表格、代码、链接、脚注、OMML、普通图片、
Mermaid 和 EChartsOOXML 验收器检查纸张、方向、四边页边距、原生结构、
PNG 关系、替代文本、页眉页脚、页码、关键样式及 `altChunk` 禁用。
真实 Pandoc 3.9.0.2 矩阵生成技术文档 A4、公文 A4 和技术文档 Letter
横向三套固定产物;Server HTTP 验收覆盖 MIME、UTF-8 文件名、诊断头、
分阶段耗时和临时目录清理,Desktop 原生保存覆盖扩展名与字节完整性。
统一命令为 `npm run verify:docx-acceptance`,产物及 JSON 报告写入被 Git
忽略的 `output/docx-acceptance/`,供阶段 10 的 Word/WPS 双向互存使用。
`v0.6.0` 阶段 10 已完成第一轮 Word 与全主题视觉审计:页眉页脚从表格
模拟改为原生段落、制表位和页码字段,并增加禁止页眉页脚表格回退的
OOXML 校验。14 套内置主题均可由 Word 正常打开和编辑,纸张、页边距、
原生结构、页眉页脚和页码字段通过;使用 Word 原生排版引擎导出 14 份
PDF,并逐页检查 22 个页面。审计同时确认当前实现尚未达到完整主题映射
门禁:4 套独立封面均未生成,8 套结构化主题的 Front Matter 未完整进入
Word,6 套主题存在重复标题,所有表格仍为自动收缩宽度,暗标和经典黑白
主题存在错误的蓝色样式。完整结论见
[DOCX 全主题视觉与结构审计](V0.6.0_DOCX_THEME_AUDIT.md),统一复现命令
`npm run verify:docx-themes`
下一阶段已冻结为建立独立 `packages/docx-theme-engine`:通过标准语义
样式探针和 Chromium/Electron `getComputedStyle()` 将主题 CSS 转换为
DOCX 样式令牌,支持自动映射、清单覆盖和预设降级;Front Matter 结构
将通过与 HTML/PDF 共用的语义文档模型进入 Pandoc AST。转换逻辑不得按
主题 ID 分支,普通外部主题即使没有 `docxStyle` 也应获得可诊断的基础
DOCX 映射。
`v0.6.0` 阶段 11 的协议骨架已建立为独立
`packages/docx-theme-engine`:定义 56 个稳定语义样式槽位、浏览器计算
样式快照、Word 目标样式令牌、属性来源、映射置信度和诊断协议。
`docxStyle` 支持 `auto``auto-with-overrides``explicit` 三种模式,
普通外部主题缺少 DOCX 声明时默认进入自动映射;现有主题的 `preset`
扁平样式覆盖保持兼容。标准探针 DOM 已覆盖普通文档、公文、简报、项目
报告和标书,Profile 专属槽位避免同名类在不同结构上下文中互相污染;
平台无关采集器可以按稳定顺序读取全部 `getComputedStyle()` 并保留未
命中状态。Server Playwright 与 Desktop Electron 适配器已经复用现有
受限浏览器和隐藏窗口,通过临时同源 iframe 安全注入主题 CSS、等待字体、
采集样式并立即清理;映射引擎同时提供按主题 CSS SHA-256 指纹进行并发
合并和有界 LRU 缓存的快照服务,待下一阶段接入导出编排。真实矩阵对
14 套主题分别在 Playwright Chromium 151 和
Electron Chromium 150 中采集 56 个槽位,共检查两组各 784 个槽位;
两端逐槽位 `font-family``font-size``color` 差异为 0。统一复现
命令为 `npm run verify:docx-theme-styles`,结果写入被 Git 忽略的
`output/docx-theme-styles/`
阶段 11 的计算样式归一化已经完成:通用解析器支持浏览器长度到 Word
磅值、RGB/RGBA/十六进制颜色、带引号字体候选、粗斜体、上下划线、行距、
字符间距、段落缩进与间距、四边内边距和边框、百分比宽度、分页控制;
结构令牌额外保留封面最小高度、Flex 纵向对齐、CSS outline 四边框近似
以及 `display:none` 隐藏字段。旧版仅声明 `docxStyle.preset` 的内置主题
自动升级为 `auto-with-overrides`,因此主题 CSS 成为主来源,清单覆盖
优先,原预设只在槽位缺失时降级;真正的 `explicit` 模式仍完全跳过 CSS。
Playwright Chromium 151 与 Electron Chromium 150 分别对 14 套主题生成
14 组、每组 56 槽位的 Word 令牌,跨引擎令牌差异为 0,无无效 CSS 诊断。
当前 14 条有效诊断均为 Flex/Grid 结构近似、商务标书超粗装饰边收敛或
经典标书双层边框与轮廓偏移近似,将在下一阶段由语义文档结构层消费。当前尚未将令牌接入
`reference.docx` 或 Pandoc 转换链路。
`v0.6.0` 阶段 12A 已建立独立 `packages/semantic-document` 和版本化
统一语义文档协议。普通文档、公文、简报、项目报告与标书现在先从
Front Matter 和正文首个 H1 构建主题无关的语义树,再由 Renderer 投影为
现有 HTML 节点与类名,因此网页预览和 PDF 的 DOM 保持兼容;同一模型也
`RenderedMarkdownDocument` 进入 `PreparedDocxExport`。模型已表达
版头、落款、版记、封面、标题去重策略,以及封面隐藏页眉页脚、隐藏页码、
下一页分节和正文页码重启意图。当前尚未改变 Pandoc AST、动态
`reference.docx` 或最终 DOCX;下一步阶段 12B 将把已归一化的主题令牌
接入动态 Word 模板。
`v0.6.0` 阶段 12B 已将主题令牌接入真实 DOCX 生产链。应用层现在复用
Server Playwright 与 Desktop Electron 适配器,按主题 CSS SHA-256
指纹缓存计算样式快照,并与媒体捕获并行生成 56 槽位令牌;`explicit`
模式直接使用清单覆盖与预设降级,无需启动浏览器。完整令牌进入 Pandoc
转换输入和动态模板缓存键,主题变化不会复用旧模板;诊断进入导出警告,
并新增 `themeStyleMs` 耗时。DOCX 引擎通过固定槽位映射写入标准 Markdown
样式和 31 个 `Md*` 结构样式,同时覆盖字体、字号、颜色、段落、代码、
引用、表格、图注、链接、字体表与 Office 主题字体,全程不按主题 ID
分支。Playwright Chromium 151 与 Electron Chromium 150 对 14 套主题
各采集 784 个槽位,完整令牌差异为 0;固定 Pandoc 3.9.0.2 的默认模板
成功生成并验证 14 份动态 `reference.docx`,每份均含 31 个结构样式。
当前结构样式尚未绑定到 Pandoc AST,封面、版头、分节与标题去重留给
阶段 12C。
`v0.6.0` 阶段 12C 已完成通用 Pandoc 结构映射和最终 OOXML 收口。统一
语义文档模型先生成主题无关的结构计划,静态 Lua Filter 将公文版头、
文号、签发人、落款、版记、简报元数据及项目/标书封面注入可编辑 Word
段落,并绑定标准 Markdown 样式和 31 个 `Md*` 结构样式;转换过程不按
主题 ID 分支。最终 DOCX 后处理生成真实分节,支持封面无页眉页脚、正文
页码从 1 重启和 `SECTIONPAGES`,同时收口容器边框、背景、分页控制、
表格内容区全宽、固定网格和行不拆分。标题策略会抑制 YAML 与首个 H1
重复,所有内部结构标记在输出前强制清除。根级
`npm run verify:docx-themes` 现在强制先用 Playwright Chromium 151
重新采集 14 套主题各 56 个真实槽位,再由 Pandoc 3.9.0.2 生成最终矩阵;
结果为 11/11 表格全宽、8/8 结构化主题字段完整、4/4 独立封面分节合格、
重复标题失败 0、内部标记残留 0。下一步阶段 12D 继续使用真实 Word/WPS
做视觉、编辑和互存回归,自动结构通过不替代客户端验收。
阶段 12C-R1 已完成 WordprocessingML 严格性修复。OOXML 写入层现在按
模式顺序生成和规范化段落、文本、表格、单元格、分节及条件表格样式属性;
样式生成将 CSS `justify` 映射为合法的 `w:jc="both"`,表格宽度仅写入
正文表格而不写入表格样式,最终转换会移除 Pandoc 追加的重复样式 ID。
内置校验器会拒绝乱序属性、重复样式 ID、非法两端对齐枚举和表格样式
`w:tblW`。Microsoft Open XML SDK 2.20 审计确认 14 份最终 DOCX 的上述
错误全部归零;Microsoft Word 和 WPS 均以禁止修复的只读方式正常打开
14/14Word 成功导出 31 页 PDF。逐页检查未发现内容丢失、裁切、重叠或
空白正文。下一步按 R2 字体嵌入、R3 PDF 与 DOCX 视觉差异引擎、R4 全主题
深度回归的顺序推进。
阶段 12D-R2 已完成纯 Node.js DOCX 字体嵌入和代表主题验收。主题清单可
声明本地或内置共享的 OTF、TTF、WOFF、WOFF2 字形;引擎使用
`fontverter` 将 Web 字体转为 SFNT,解析字体内部名称、字重和 `fsType`
仅接受可安装或可编辑嵌入权限,再以稳定字体键生成 ODTTF 部件、字体表
关系和内容类型。最终校验会解混淆字体并重新检查 SFNT、权限、字体族、
关系、孤立部件和内容类型。WOFF2 WASM 解码已改为进程内串行调度,避免
并发非重入导致批量字体损坏。内置主题统一前置共享字体 CSS,网页、PDF
和 DOCX 使用同一字体资产,映射过程不按主题 ID 分支。
测试机未安装 Fandol 或 Open Sans。政企正式工作报告、政企红头标准文件
和 GitHub 分别成功嵌入 2、5、5 个字形面,Word 与 WPS 均能打开、
显示和编辑;Word → WPS → Word 双向编辑 3/3 通过。完整 14 主题矩阵、
全项目测试、类型检查和构建通过。视觉检查确认正式报告和 Github 在
Word/WPS 均为 2 页;红头主题在 Word 为 2 页、WPS 为 3 页,版记被移到
近空白末页,已冻结为 R3 视觉差异引擎的首个回归样例。WPS 默认另存还会
移除嵌入字体,启用字体嵌入后可保留但会扩大互存文件;项目不会因 WPS
写回器产生的 OOXML 顺序问题放宽自身严格门禁。完整记录见
[DOCX 字体嵌入与代表主题验收](V0.6.0_DOCX_FONT_EMBEDDING.md)。
阶段 12D-R3 已完成独立 `packages/document-visual-diff` 视觉差异引擎和
字体兼容收口。引擎通过可替换适配器调用 Word、WPS 或既有 PDF,统一
提取页面、文字与栅格快照,并以页数、纸张、正文相似度、平均像素误差、
变化像素率、墨迹 IoU 和边缘 IoU 生成 JSON/HTML 报告;所有 Office
自动化均使用独立临时输出并在结束后清理。DOCX 样式层将固定 CSS 行高
写为 Word 精确行距,修复 WPS 中红头版记被挤到第三页的问题;字体解析
同时规范可变字体字重,并从 SFNT `OS/2``head``post` 表通用提取
Panose、字符集、字体族、间距和 Unicode/代码页签名,写入
`fontTable.xml`。映射与严格校验均不按主题或字体名称分支,校验器会
拒绝字体表声明与实际嵌入字体不一致的文档。
使用原生静态 TrueType 中文字体资产的两字形面验证中,Word 与 WPS 均
正确使用嵌入字体显示红头版头、标题和页码,文档保持 2 页、可选择和
编辑;Word 导出 PDF 相对基线两页的正文相似度均为 1,第一页 MAE
为 1.986、墨迹/边缘 IoU 为 0.812/0.805,第二页 MAE 为 1.176、
墨迹/边缘 IoU 为 0.786/0.832,未发现裁切、重叠、缺字或异常页眉页脚。
静态字体单面约 14.85 MiB,不进入主安装包;后续如立项字体包,将采用
独立可选安装与运行时发现机制。WPS 默认保存会移除嵌入字体、Word 会
保留使用字形子集,这是客户端写回行为,不影响导出文件的首次打开和
编辑门禁。
阶段 12D-FP1 已建立独立 `packages/font-pack-registry`,冻结可选字体包
协议和安全发现边界。字体包使用
`root/<pack-id>/<semver>/font-pack.json` 固定布局,清单声明应用兼容
区间、许可文件、字体目标家族、别名、字重、样式以及同源 Web WOFF2 和
DOCX 静态 TrueType 资源。注册器按根目录顺序和最高兼容版本确定可用包,
使用完整 SemVer 比较,并对清单、普通文件、路径包含关系、符号链接、
单资源/总容量、许可文件和 SHA-256 执行严格校验;实际读取资源时会再次
验证大小与哈希,避免注册后文件被替换。候选解析按字体家族或别名、字重
和斜体精确匹配,支持调用方显式指定字体包优先级;根目录缺失、版本不
兼容、资源损坏、版本覆盖和字体面未提供均返回结构化诊断,缺少字体包
不会阻断后续安全降级。当前尚未接入主题、Preview/PDF、DOCX、Desktop
安装器或 DockerFP2 将让 Web WOFF2 与 DOCX TTF 进入同一主题字体解析
结果,并把字体包指纹纳入缓存和导出诊断。全仓 454 项测试、类型检查和
生产构建通过。
阶段 12D-FP2 已将可选字体包接入统一主题与 DOCX 生产链。应用层按主题
声明、字体家族或别名、字重和样式解析同一逻辑字体面:Web、Preview 与
PDF 在主题 CSS 末尾注入 WOFF2 `@font-face`DOCX 同时选用对应的静态
TrueType 字体,并保留字体包许可、版本和来源诊断;未命中的字体面继续
安全降级到主题原有字体。字体包版本、资源 SHA-256 和整体指纹已进入
主题令牌、样式快照、动态模板及导出缓存边界,替换资源不会错误复用旧
结果。Server 只暴露带 ETag 和不可变缓存头的 WOFF2 资源端点,通过
`FONT_PACK_ROOT` 配置字体包根目录;Desktop 使用受限的
`mdpdf://font-pack/.../web` 协议,并从应用数据目录发现可选字体包,均不
对渲染器暴露 DOCX TTF 原始资源。静态字体单面上限调整为 16 MiB、单次
DOCX 字体源总量调整为 48 MiB,与已验证的原生中文 TrueType 资产一致。
全仓 458 项测试、类型检查、生产构建和 14 套主题真实 Pandoc 矩阵通过;
临时字体包验证确认政企红头标准文件可从同一字体包解析两个 WOFF2/TTF
字形面,其余字体继续按主题资产降级,最终五个字体源均通过 SFNT 与嵌入
权限校验。字体二进制、独立安装包和 Docker 分发尚未进入仓库;下一步
FP3 只处理独立 NSIS 字体包、Docker 可选挂载及其安装/升级/卸载门禁。
阶段 12D-FP3-A 已建立独立 `packages/font-pack-builder` 和首个冻结字体包
配方 `mdtp-serif-sc@1.0.0`。主仓库只保存配方、OFL-1.1 许可证、上游提交
与派生说明,不保存约 41.37 MiB 字体二进制;发布人员从 Git 忽略的受信任
源目录提供 Regular 400、Bold 700 两组静态 TTF/WOFF2。构建器会校验
普通文件、路径包含关系、大小、SHA-256、WOFF2/TrueType 格式、字体内部
家族、字重、Web/DOCX 元数据一致性及文档嵌入权限,再原子组装版本化
`font-pack.json` 目录。相同版本内容一致时重复构建保持幂等,内容不同时
拒绝覆盖;字体包只匹配 `FandolSong``Mdpdf Fandol Song`,不会通过
`SimSun` 等通用别名错误替换仿宋、黑体、楷体或外部主题字体。
统一命令为 `npm run build:font-pack``npm run verify:font-pack`,默认
产物写入被 Git 忽略的 `output/font-packs/`。真实资产构建结果为
41,371,798 字节,两字形面均为 `MdTP Serif SC``fsType=0`、可安装嵌入;
应用层验收确认政企红头标准文件同时取得两组 WOFF2 和静态 TTF,其余三个
字体面继续使用主题安全降级。全仓 461 项测试、类型检查和生产构建通过。
本阶段未修改 NSIS 或 Docker;下一步 FP3-B 实现独立用户级字体包安装、
升级、卸载和主安装器同目录可选入口。
阶段 12D-FP3-B 已实现 Windows 独立字体包安装与 Desktop 同目录可选入口。
字体包构建器复用 electron-builder `26.15.3` 锁定并校验的 NSIS 3.0.4.1
不要求系统安装 NSIS,也不引入第二套 Electron。独立安装器以当前用户权限
将完整 `mdtp-serif-sc` ID 安装到
`%LOCALAPPDATA%/md-to-pdf/font-packs/`,不注册系统字体;安装使用暂存、
备份、原子提升和失败回滚,同版本重装幂等,独立卸载只清理该 ID。隔离
根目录验收中,首次静默安装、同版本重装和卸载退出码均为 0,注册器复核
的字体包指纹仍为
`67adac137fa74ca54b4ab07dd584ae87657a1aec413b7f125141c5351fa5b89d`
主安装器通过生成的 NSIS include 绑定同目录字体包安装器的精确文件名与
SHA-256;交互模式仅在文件存在且哈希正确时显示默认勾选项,并在执行前
再次复核哈希。静默主安装默认跳过,只有 `/WITHFONTPACK=1` 才安装;字体
包失败不会回滚主程序。统一组合命令为
`npm run desktop:make:with-font-pack`,普通 Desktop 构建不启用伴随逻辑。
真实组合产物中,主安装器为 115,328,133 字节,独立字体包安装器为
24,489,062 字节;桌面解包目录未包含字体二进制或字体包安装器。Windows
实机安装向导确认可选页位于安装目录页之后,说明文字完整且字体包默认
勾选;未执行主程序重装。全仓 465 项测试、类型检查、生产构建和组合发行
构建通过。下一步 FP3-C 实现 Docker 可选字体包挂载与部署门禁。
本阶段按版本策略未提前修改应用的 `0.5.1` 版本号;正式 `v0.6.0` 发布
提交升级版本后,字体包才满足其 `minimumAppVersion=0.6.0` 运行时门禁。
已修复 `v0.5.1` 遗留的精确预览放大后右侧留白丢失问题。精确 PDF 文档
容器现在按当前缩放下最宽页面加左右各 30px 显式计算自身宽度,页面较小
时仍由 `min-width: 100%` 居中,页面溢出时双侧留白则稳定进入横向
`scrollWidth`。真实 Chromium 在 100%、120% 和 400% 下滚动到最右端,
文档右侧留白分别约为 30.38px、30.05px 和 30.45px120% 与 400%
均准确到达各自最大横向滚动值,快速和连续预览结构未改变。
Desktop 已统一 Markdown 保存/另存为、PDF 导出和 DOCX 导出的默认路径
策略:有效源文件目录优先,其次使用最近一次成功写入文件的目录,最后
回退到 Electron 返回的用户“文档”目录。最近目录只在目标文件实际写入
成功后更新,普通保存已有 Markdown 也会更新;状态使用版本化 JSON、
临时文件和原子替换写入应用数据目录,因此可跨窗口和应用重启复用。源
目录或历史目录被删除、移动、变为普通文件或不可访问时会自动跳过,不
阻断保存对话框。状态持久化失败不会把已经成功写入的用户文件误报为保存
失败。该优化作为独立提交完成,不与 FP3-C Docker 部署改动混合。
`v0.5.1` Web 与 Desktop 旧渲染引擎曾在整篇代码围栏全部标记为 JSON 时
出现缩进视觉丢失;任一围栏改为其他语言后其余 JSON 又会恢复。使用原始
问题文档在当前 `v0.6.0` Chromium 快速预览中验证,首次完整渲染的 14 个
JSON 代码块均保留 2/4/6 级缩进,将首个围栏改为 `text` 后的增量渲染也
保持正常。该问题标记为随 `v0.6.0` 渲染引擎重构消失,不再追加生产修复。
阶段 12D-FP3-C 已完成 Docker v0.6.0 DOCX 运行链和可选字体包挂载。镜像
构建补齐统一语义文档、DOCX 主题映射、字体包注册器和 DOCX 引擎工作区,
并将 Lua Filter 作为明确运行资产复制;独立下载阶段对 Pandoc `3.9.0.2`
Linux amd64 二进制、GPL 许可证和版权文件分别执行固定 SHA-256 校验,
最终运行镜像只保留 `/opt/pandoc/3.9.0.2/`,不包含下载工具或可选字体包。
基于已验证 Chromium 运行层生成的验收镜像内容大小为 681,961,183 字节,
以非 root `pwuser` 运行,DOCX capability 精确识别 Pandoc `3.9.0.2`
Compose 通过 `MD_TO_PDF_FONT_PACK_DIR` 将宿主机字体包根目录只读挂载到
`/app/.local/font-packs`,并以 `MD_TO_PDF_APP_VERSION=0.6.0` 执行兼容性
判断。静态部署契约覆盖 DOCX workspace、Pandoc 版本与三组哈希、Lua
Filter、只读挂载以及 Docker 构建上下文排除。统一真实门禁为
`npm run verify:docker-font-pack`:空目录场景正常降级;有效
`mdtp-serif-sc@1.0.0` 提供不可变缓存 WOFF2,并进入真实 DOCX 字体表和
字体部件;篡改 WOFF2 后注册器拒绝整个字体包,主题 CSS 不再注入字体包,
但 DOCX 仍通过主题字体安全降级并成功生成。三组隔离 Compose 容器、网络
和临时目录均在验收后清理。全仓 94 个测试文件、480 项测试、类型检查、
生产构建和 `git diff --check` 均通过。下一步 R4 进入 14 套主题深度视觉
回归。
阶段 12D-R4-2 已建立可复现的全主题生产链矩阵。统一命令
`npm run verify:docx-r4-matrix` 会先重新生成 14 套主题的标准 DOCX 结构
基线并复验可选字体包,再构建同源 Web 预览运行时,通过真实 Fastify HTTP
接口和 Playwright Chromium 分别生成 14 份 PDF 与 14 份 DOCX。产物与
结构化报告写入被 Git 忽略的 `output/docx-r4/`,供后续 Word、WPS 和视觉
差异阶段复用。
本轮生产链共生成 28 页 Chromium PDF14/14 DOCX 均保留标准 Word
段落、标题、代码、表格和图注样式,不含 `altChunk` 或内部结构标记,
Front Matter、封面分节、标题去重及表格全宽继续通过标准矩阵门禁。
PDF 与 DOCX 的 Mermaid、ECharts 错误均为 0`gov-red-standard` 通过
Server 字体包注册链命中 `mdtp-serif-sc@1.0.0`,字体表包含
`MdTP Serif SC` 并实际嵌入 5 个字体部件。3 条诊断仍是已冻结的商务标书
超粗边框和经典标书双层边框结构近似。Server 44 项测试、类型检查、构建
`git diff --check` 通过。下一步 R4-3 使用 Microsoft Word 对 14 份
生产 DOCX 执行无修复打开、编辑、保存和 PDF 导出。
阶段 12D-R4 媒体专项已完成生产链、OOXML、Word/WPS 编辑互存和视觉
门禁。媒体捕获协议现在记录实际显示尺寸、内容区边界和左右/居中对齐,
Pandoc 媒体布局使用稳定绑定标记与整数 EMU;最终 DrawingML 强制
`wp:inline`、零环绕距离、锁定宽高比、无裁切/旋转/翻转,并校验 PNG
关系、替代文本、孤立部件和纸张内容区上限。普通图片、宽图、高图、
Mermaid 与 ECharts 共 5 个媒体在 Word/WPS 中均可编辑;自动化将首图
缩放至 90% 后原位保存并重开,Word 非目标图尺寸漂移为 0,WPS 最大仅
0.046 pt,且两端没有浮动图片或额外图片关系。
统一命令 `npm run verify:docx-media-visuals` 会通过真实 Server HTTP 生成
Chromium PDF 与 DOCX,再由 Word/WPS 导出 PDF、编辑互存并复导 PDF;
三份 PDF 使用独立 PDF.js worker 生成页面快照、叠加图和热力图,避免
多文档进程级状态污染。五色几何门禁检查物理尺寸、宽高比和水平中心;
高图允许 Chromium 媒体回填与 DOCX 限高策略产生尺寸差异,但仍强制比例、
对齐及 Word/WPS 物理尺寸一致。4 条图片题注在 Chromium、Word、WPS
三端均要求距页面中心不超过 8 pt`ImageCaption` 已固定为居中,同时
保留主题提供的字体、字号、颜色和行距。Chromium 为 4 页,Word/WPS 为
5 页,属于已允许的自然分页差异;三端可编辑文本完整。原始逐页像素报告
仍如实标记页数与正文流差异,媒体专项门禁按上述契约通过。产物位于
`output/docx-media-gate/`
阶段 12D-R4 布局视觉矩阵已完成。统一命令
`npm run verify:docx-layout-visual-matrix` 覆盖 A4/Letter、纵向/横向、
主题/自定义及非对称页边距、普通文档、公文和两类独立封面,通过真实
Chromium、Microsoft Word 与 WPS Writer 生成逐页原图、叠加图、热力图和
结构化报告。发布门禁明确以元素颗粒度为准:字体、字重、颜色、尺寸、
段落与代码内边距、标题和封面换行、表格边框与填充、图片尺寸和对齐、
页眉页脚及页码位置属于严格项;Word/WPS 与 Chromium 的正文分页位置、
页数和由排版引擎产生的自然行容纳差异仅作为诊断,不单独阻断。封面仍
必须保持独立单页并在正文前强制换页。
六组矩阵的 Word/WPS 可编辑文本均精确匹配,严格问题为 0。视觉审查发现
主题普遍将折叠表格边框声明在 `th/td` 而非 `table`:转换器此前误用旧
模板的浅色细边框。本轮已将单元格四边通用映射到 OOXML 外框和
`insideH/insideV`,保留表自身显式边框优先级;横向规章制度主题重新渲染
后,Chromium、Word、WPS 的网格颜色、线宽和表头填充一致。随后重跑
14 套主题生产矩阵,全部完成同源 PDF/DOCX、结构、字体包、封面分节、
标题去重、表格全宽、媒体和 OOXML 校验。
## 2. 已完成
### 2.1 项目骨架
@@ -503,8 +970,8 @@ DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
尺寸和最大化状态仍按约 2 秒防抖写入并在启动时修正到可见工作区。
- Core 新增跨端文档链接分类与 PDF 本地链接编码协议;精确 PDF 使用
`mdpdf.local.invalid` 暂存本地链接,PDF.js 注释层解码后交给平台。
- 内置主题名称统一为 Typora Github、Typora Pixyll、Typora whitey 和
Typora Clean,默认主题为 Typora Github;删除强制覆盖主题链接样式的
- 内置主题名称统一为 GitHub、Pixyll、Whitey 和 Clean,默认主题为
GitHub;删除强制覆盖主题链接样式的
`!important`,保留主题自身设计。
- markdown-it 普通围栏输出 `pre.md-fences` 和可选 `lang`Preview
Engine 仅重置内部语义 `code` 的重复行内盒模型,代码块与行内代码均
@@ -529,7 +996,7 @@ DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
- 红头标题使用居中网格布局,签发人不再挤压发文机关;正文、红头标题、
页眉页脚使用主题内置 Fandol 中文字体,降低跨机器显示差异。
- `samples/tutorials` 内置 ECharts 和公文主题教程,包含 YAML 示例、
字段说明、图表标题和图例渲染;公文教程保持 Typora Github 阅读主题。
字段说明、图表标题和图例渲染;公文教程保持 GitHub 阅读主题。
- `samples/themes` 为全部 14 套主题提供示例;Desktop 发行配置将教程、
示例和字体随应用复制,打开主题示例时自动切换相应主题。
- 移除顶部加号入口,将文件操作、教程与示例、主题管理整合为三个菜单
@@ -538,8 +1005,36 @@ DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
Web 端不显示或拦截“另存为”。
### 3.21 v0.6.0 DOCX 资源预处理
- `packages/core` 新增图片、Mermaid、ECharts 的媒体类型、捕获计划、
PNG 资源清单和跨端限制常量;
- `packages/preview-engine` 复用连续文档运行时建立 DOCX 媒体舞台,
等待图片、字体和图表完成后按 DOM 顺序生成捕获目标;
- 媒体按纸张方向、纸张尺寸、主题默认页边距和用户页边距限制显示宽高;
- 普通图片、SVG、Mermaid 和 ECharts 统一输出 PNG,默认 300 DPI
超大媒体会按 4096px 单边和 1600 万像素上限降低倍率;
- 捕获框使用覆盖元素边界的整数坐标,避免 Chromium 对小数坐标缩放时
裁切边缘或产生不可预测的位图尺寸;
- `packages/application` 校验捕获计划、媒体 ID、PNG 签名、IHDR 尺寸、
单图大小、总大小和计划匹配关系,并生成稳定文件名;
- Server 的 Playwright 与 Desktop 的 Electron 适配器均使用 Chromium
DevTools 截图协议,不引入 Sharp、Python 或其他原生图片依赖;
- 真实 Chromium 冒烟已将输入 SVG 图片、Mermaid 和 ECharts 依次输出为
753×378、809×222、2103×591 PNG,图表错误均为 0。
## 4. 已执行验证
2026-07-30 对 v0.6.0 阶段 5 执行:
- 完整工作区 61 个测试文件、308 项测试全部通过;
- Markdown ECharts、Core、Renderer、Application、Preview Engine、
Web、Server 和 Desktop 类型检查全部通过;
- Web Runtime、Server 和 Desktop 生产构建通过;
- 真实 Playwright Chromium 使用 SVG 图片、Mermaid 和 ECharts 完成
300 DPI PNG 捕获,媒体顺序、尺寸、格式和错误清单验证通过;
- `git diff --check` 通过,仅有 Git 对 Windows 工作区换行转换的提示。
2026-07-29 对 v0.5.1 完整工作区及最终代码块修复执行:
```text
@@ -619,7 +1114,7 @@ git diff --check
HTTP/HTTPS 与锚点,并能将 `.invalid` 地址解码为原始本地路径。
- Desktop 多窗口、同一文件单例、主窗口关闭后的状态接管、最大化防抖
写入和重启恢复通过;本机安装版已升级至 v0.5.0。
- 围栏代码块在 Typora Github 中显示完整外框且内部不再逐行出现行内
- 围栏代码块在 GitHub 中显示完整外框且内部不再逐行出现行内
代码方框;Web 与最终 Desktop 安装版均由用户确认,ECharts 围栏未受
普通代码块兼容样式影响。
- 正式 NSIS 为 `md-to-pdf-0.5.0-x86_64-Setup.exe`95,004,759
@@ -897,6 +1392,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `apps/web/src/PreviewPageControl.tsx``preview-page.ts` 负责当前页输入、
页码边界、滚动页识别和快速跳转。
- `apps/web/src/preview-zoom.ts``PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
- `apps/web/src/MarkdownEditor.tsx` 负责 CodeMirror 受控状态、文档历史
重建、快捷键、焦点和滚动容器适配;`MarkdownToolbar.tsx`
`markdown-editor-commands.ts` 负责可扩展命令注册和基础工具栏。
- `packages/markdown-echarts` 负责 ECharts YAML 协议、验证、安全过滤、
Markdown-it 插件、浏览器 SVG 渲染和分页样式。
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
@@ -908,6 +1406,22 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
`apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配。
- `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX
样式、发现并校验固定 Pandoc、生成动态 OOXML、执行静态 Lua 媒体替换、
请求级临时转换和最终结构校验。
- `packages/document-visual-diff` 负责 Word/WPS/PDF 页面快照、文本与
栅格差异、可配置视觉门限以及 JSON/HTML 验收报告;该包仅用于开发和
发布验收,不进入应用运行时。
- `packages/font-pack-registry` 负责可选字体包协议、版本兼容、安全发现、
资源哈希与容量门禁、确定性版本选择和逻辑字体面匹配;应用层已将同一
解析结果用于主题 CSS 的 WOFF2 和 DOCX 的静态 TrueType,发行资产与
安装逻辑留在 FP3。
- `packages/font-pack-builder` 负责从仓库内 Git LFS 冻结资源构建版本化字体包,
校验输入哈希、字体格式、内部元数据、嵌入权限、同版本不可变性并生成
可供注册器再次验证的构建报告;正式字体二进制进入 LFS,构建产物、门禁
报告和 `release/` 均不进入 Git。
- `packages/application/src/docx-export-service.ts` 负责 DOCX capability、
并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
组装、协议和生命周期。
@@ -934,18 +1448,139 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
## 6. 推荐接手顺序
### 阶段一:v0.5.x 稳定性与性能观测
### 阶段一:v0.6.0 分阶段开发
- 为增量分页补充超长文档性能基线,记录稳定前缀页数和后缀重算耗时
- 覆盖 Windows 100%、125% 和 150% 显示缩放的快速/精确分页差异;
- 扩展 Desktop 多窗口、文件外部变化和链接打开的真实端到端回归
- 持续检查连续预览中媒体宽高、单滚动容器和资源释放。
- 阶段 1:已冻结架构、能力边界和 Word/WPS 验收门禁
- 阶段 2:已冻结 Pandoc `3.9.0.2`、许可证、Docker 与 Desktop
分发方式
- 阶段 3:已实现 DOCX 共享模型和跨端导出协议;
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
- 阶段 8:已实现 Web/Desktop DOCX 导出交互;
- 阶段 9:已完成 DOCX 自动化结构、真实 Pandoc、Server HTTP 与
Desktop 保存验收;
- 阶段 10:已完成 Word 原生页眉页脚和第一轮全主题视觉审计;完整主题
映射门禁未通过;
- 阶段 11:已建立独立 `packages/docx-theme-engine`、标准语义槽位及
快照、令牌、来源、置信度、诊断协议、标准探针 DOM 和平台无关采集器;
Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过;
CSS 计算值到 Word 样式令牌的通用归一化、清单覆盖、预设降级和结构
近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌;
- 阶段 12A:已建立独立统一语义文档模型,Renderer 与 DOCX 准备结果
共用同一语义树,现有 HTML DOM 保持兼容;
- 阶段 12B:已将跨端主题令牌接入动态 `reference.docx`14 主题双引擎
令牌和真实 Pandoc 模板矩阵通过;
- 阶段 12C:已完成通用 Pandoc 结构映射、封面真实分节、标题去重、
表格内容区全宽、分页控制、OOXML 严格性和 14 主题真实 Word/WPS
无修复打开门禁;
- 阶段 12D-R2:已完成可分发字体嵌入和三套代表主题真实 Word/WPS
编辑互存;
- 阶段 12D-R3:已完成独立 PDF/DOCX 视觉差异引擎、精确行距、字体
字重规范化及通用 SFNT 到 Word 字体元数据映射,并通过 Word/WPS
原生视觉与可编辑性验收;
- 阶段 12D-FP1:已完成可选字体包协议、安全注册器、版本/根目录优先级、
资源双重校验和字体候选匹配;
- 阶段 12D-FP2:已完成 Preview/PDF WOFF2 与 DOCX 静态 TrueType 的
同源字体解析、跨端受限资源入口、缓存指纹、降级诊断和真实字体包验证;
- 阶段 12D-FP3-A:已完成独立字体包构建器、冻结配方、许可证与来源追踪、
同版本不可变门禁和真实资产构建;
- 阶段 12D-FP3-B:已完成独立用户级 NSIS 字体包、原子安装/升级/卸载、
主安装器同目录哈希校验和显式静默开关;
- 阶段 12D-FP3-C:已完成 Docker v0.6.0 DOCX/Pandoc 运行链和早期可选
字体挂载门禁;发布收口阶段 C 已进一步改为镜像内置字体并移除运行时卷;
- 阶段 12D-R4-2:已完成 14 套主题的标准结构基线、可选字体包、真实
Server HTTP、Chromium PDF 与生产 DOCX 矩阵;
- 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体
Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁;
- 阶段 12D-R4 布局视觉矩阵:曾记录为完成六组 Chromium/Word/WPS
元素级严格门禁;`v0.6.1` 发布后复核确认该矩阵只覆盖五套主题,且外层
过滤了像素、墨迹 IoU 和边缘 IoU failure,因此不构成有效严格门禁;
- 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、
文件关联兼容和旧版用户数据迁移;
- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载
哈希门禁、许可证保留、打包接入、离线复验和真实 DOCX 冒烟;
- 发布收口阶段 C:已完成确定性字体 ZIP、MorphDoc 字体安装器命名、
Desktop 同目录可选附件、Docker 镜像内置字体和真实 DOCX 发行门禁;
- 发布收口阶段 D:已完成统一 `release/v<version>` 产物目录、Docker 离线
镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS;
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
### 阶段二:导出配置扩展
### 阶段二:v0.6.1 DOCX 视觉一致性修复
- Front Matter 元数据覆盖;
- 浏览器本地配置预设;
- 配置 JSON 导入和导出。
- 阶段 A:已完成。修复所有 failure 的发布阻断聚合,补齐四套独立封面
场景,并禁止正文回流封面页;四套封面真实 Word/WPS 复验均已被现有
视觉差异正确阻断;
- 阶段 B:已完成。正文段落按内容顺序映射为与物理页号无关的视觉块,
保留实际行坐标和 PDF.js 字体分类诊断,逐行裁剪局部栅格并输出叠加图、
热力图;正文整页栅格降为诊断,封面仍为整页硬门禁。单元测试 47 项、
全项目类型检查和生产构建通过;真实 GitHub 紧凑 Letter 纵向场景观测
22 个正文块、输出 102 张失败局部热力图并正确阻断。PDF.js 泛化字体名
不作为字体硬门禁,真实字体解析留在阶段 D;
- 阶段 C:已完成。矩阵生成器从 14 套内置主题清单生成纵横两个方向 ×
主题默认、标准、紧凑、宽松、非对称装订五组页边距,共 140 个唯一
排版场景;主题默认链路保持 `marginMode: theme`,即使与显式数值相同
也不去重。机器报告记录定义、执行完整度以及主题、方向、边距分层汇总,
HTML 报告固定显示 14 行 × 10 列并链接三份引擎详细报告。5 项矩阵结构
测试通过;真实 `red-briefing--portrait--theme-default` 验证解析为主题
默认 25mm 四边距,14 个正文块中 Word 有 13 个失败、字体部件仍为 0,
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
- 阶段 D:已完成。D1/D2 将字体门禁扩展到 PDF 实际字体解析、DOCX
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
`mdtp-sans-sc@1.0.0``mdtp-mono@1.0.0`,与
`mdtp-serif-sc@1.0.0` 组成确定性三字体包。D3 重建四套独立封面整页
门禁,40 个封面方向/边距组合全部通过。D4 完成正文语义块视觉收口:
通用翻译层统一处理字体、中文标点字距、精确行距、段落、列表、表格、
引用、代码块和行内代码连续性;最终 140 个 Chromium、Word、WPS
真实场景全部执行并通过,阻断失败和诊断失败均为 0;
- 阶段 E:已完成。源码服务、正式 Docker Web API 和实际安装 Desktop
使用同一 `red-briefing` 复验,三份 DOCX 均约 24.17 MB、包含 5 个字体
部件和 40,731,432 字节嵌入字体;安装版产物经 Word/WPS 渲染均为 2 页
A4,逐页视觉通过;
- 阶段 F:已完成最终发行重构。Desktop 的 Serif、Sans、Mono 三套字体包
直接内置于安装器和免安装 ZIP,NSIS 不再显示字体选择页,正式发行不再
拆分字体安装器或字体 ZIP。正式安装包为 185,578,422 字节,免安装 ZIP
为 242,513,118 字节;安装版默认媒体示例真实导出 10,931,974 字节的
DOCX,包含 7 个字体部件和 12 个媒体部件。Docker 使用 `--pull`
`--no-cache` 且禁用运行层复用的完整路径正式构建,镜像内容 ID 为
`sha256:f81dbce5857a0a5496851e149936d1fd7da52b76c035187be1079fa1fd5a4717`
`release/v0.6.1` 只归集 Desktop 安装器与 ZIP、Docker 离线镜像、Web
部署包和发行说明五个主附件,并生成机器清单与包含六项的 SHA-256 校验
文件;发布引用由本轮 `release:` 提交和重新创建的 annotated `v0.6.1`
标签统一指向最终发行状态。
### 阶段三:v0.6.2 DOCX 真实文档门禁修复
- 发布门禁已由一次性 560 拆分为四套独立 140:标准合成基线优先,随后
`tmp` 三份真实 Markdown 的文件字节数升序逐套执行。每套独立保存
`progress.json``aggregate.json` 和 HTML 报告;旧 560 后台流程停用;
- 标准合成基线已加入表格内 `<br>``<br/>``<br />` 与行内代码
`` `<br>` `` 的隔离语义检查,并覆盖全 JSON 围栏缩进、行内代码连续性、
表格末行对齐和代码高亮翻译;
- 通用表格翻译链现会在分页前冻结完整自动布局列轨,将稳定元数据复制到
Paged.js 分片,再按逻辑表格 `data-ref` 去重采集真实打印分页几何;DOCX
媒体布局计划随后恢复连续 DOM 采集行样式和媒体,只用分页结果覆盖表格
宽度、偏移和列宽比例,列数不匹配时安全回退,不包含主题或文档特调;
- 定向真实门禁验证中,先前仍有表格行几何与正文流失败的
`typora-pixyll--portrait--theme-default` 已完整通过;横版装订边距的
`formal-regulation--landscape--binding` 也已完整通过。Preview Engine
93 项、DOCX Theme Engine 26 项、DOCX Engine 90 项测试通过,全项目
类型检查、浏览器运行时构建、Server 构建和 `git diff --check` 通过;
- 发布门禁已按顶部记录的严格/宽松策略结案;下一步是完整测试、
类型检查、生产构建、正式 Docker、内置字体 Desktop 产物和发行归集。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
后续阶段的功能实现。
### 阶段四:后续版本扩展
- Front Matter 可视化编辑工具;
- ECharts YAML 可视化配置工具;
- 更完整的自定义主题 DOCX 显式样式清单;
- 浏览器本地配置预设及配置 JSON 导入和导出。
## 7. 首版验收目标
+70
View File
@@ -0,0 +1,70 @@
# 发版规则
## 唯一产物目录
所有正式分发文件必须统一归集到仓库根目录下的:
```text
release/v<semver>/
```
`release/``output/``apps/desktop/out/``apps/desktop/.runtime/`
`tmp/` 以及门禁报告均是本地中间产物或发布产物,必须保持 Git 忽略。
大型二进制不得直接提交到普通 Git 历史。
正式目录只能由 `npm run release:stage` 生成。脚本先写入同一文件系统中的
`.stage-*` 临时目录,完成镜像封包、清单和校验和后再原子改名;目标版本
目录已经存在时必须拒绝覆盖。
## 标准目录内容
每个版本至少包含:
```text
release/v<version>/
├── MorphDoc-<version>-x86_64-Setup.exe
├── MorphDoc-<version>-x86_64.zip
├── MorphDoc-Web-<version>-image.tar.gz
├── MorphDoc-Web-<version>-deploy.zip
├── manifest.json
├── SHA256SUMS.txt
└── RELEASE-NOTES.md
```
Docker 镜像必须通过 `docker save` 导出并使用 gzip 压缩。Web 部署 ZIP 必须
包含 Compose、Nginx、环境变量示例和离线加载说明。`manifest.json` 记录
版本、Git 提交、镜像 ID、文件大小和 SHA-256;`SHA256SUMS.txt` 用于分发后
独立校验。
## 正式字体资产
正式字体固定存放于:
```text
font-packs/assets/<font-pack-id>/<semver>/
```
TTF、OTF、WOFF 和 WOFF2 必须由 Git LFS 跟踪。新增或升级字体前必须完成
许可证、来源、内部字族名、文档嵌入权限、字节数和 SHA-256 审查;任何内容
变化都必须提升字体包 SemVer。克隆后需要先执行 `git lfs pull`,构建器不会
下载或使用系统字体代替缺失资产。
## 发版步骤
1. 完成全量测试、类型检查、生产构建和视觉门禁。
2. 构建字体直接内置的 Desktop 安装包和便携 ZIP。
3. 构建并验证版本化 Docker 镜像。
4. 准备 `docs/releases/v<version>.md`
5. 执行:
```powershell
npm run release:stage -- --version <version> --image md-to-pdf:v<version>
```
6. 逐项核验 `SHA256SUMS.txt`,并从离线归档重新加载 Docker 镜像。
7. 创建内容完整的 `release:` 提交。
8. 重新生成 release 清单,确保 `gitCommit` 指向该发布提交。
9. 创建 annotated `v<version>` 标签,标签说明与发布提交正文一致。
安装器公开分发前应完成 Authenticode 签名;未签名产物只能按内部发布风险
规则分发。
+429
View File
@@ -0,0 +1,429 @@
# v0.6.0 DOCX 导出与 Markdown 工具栏设计
状态:阶段 1~4 已完成,DOCX 共享协议和 Markdown 编辑器基础能力已经
落地。
## 1. 版本目标
`v0.6.0` 包含两个核心功能:
1. 新增可由 Microsoft Word 和 WPS 正常打开、编辑、保存的 DOCX 导出;
2. 将左侧原生文本框升级为 CodeMirror 6,并增加可扩展的基础 Markdown
工具栏。
本版本不改变现有 Markdown 渲染、连续预览、分页预览和 PDF 导出链路。
DOCX 是与 PDF 并列的新输出适配器,不以 HTML 转 Word 的方式复用 PDF
打印结果。
```mermaid
flowchart LR
A["Markdown 与受限资源"] --> B["共享文档与资源服务"]
B --> C["现有 HTML 渲染"]
C --> D["连续与分页预览"]
C --> E["Chromium PDF"]
B --> F["DOCX 文档准备"]
F --> G["媒体 PNG 渲染"]
F --> H["动态 reference.docx"]
G --> I["Pandoc DOCX"]
H --> I
I --> J["Word 与 WPS 可编辑文档"]
```
## 2. 发布门禁
DOCX 功能必须同时满足以下两条门禁,否则不能作为 `v0.6.0` 正式能力
发布。
### 2.1 纸张与样式门禁
导出的 DOCX 必须保留当前导出配置和主题中有明确 Word 语义的属性:
- 纸张尺寸;
- 横向或纵向;
- 上、右、下、左页边距;
- 页眉、页脚和基础页码;
- 正文与标题的中西文字体、字号、颜色和字重;
- 段落对齐、行距、段前、段后和首行缩进;
- 标题 16
- 引用、列表、任务列表、表格和代码块;
- 图片、图片标题、Mermaid 和 ECharts
- 链接、脚注和可转换为 OMML 的数学公式。
同一输入在固定 Pandoc 版本和相同配置下应生成结构稳定的 OOXML。DOCX
与 PDF 使用不同的排版引擎,不要求两者逐页分页完全一致。
### 2.2 可编辑性门禁
导出的文档必须:
- 可由当前支持范围内的 Microsoft Word 和 WPS 无修复提示地打开;
- 正文、标题、列表、表格、链接、脚注和公式保持原生 OOXML 结构;
- 文本可选择、修改、复制和重新排版;
- 图片可移动、缩放、删除和替换;
- 在 Word 或 WPS 中编辑并保存后,可由另一应用再次打开;
- 不以整页截图、整篇图片或 HTML `altChunk` 作为正文实现。
Mermaid 和 ECharts 在 DOCX 中是高分辨率 PNG,不是 Office 原生图表对象。
它们可以作为图片编辑,但不能在 Word 或 WPS 中修改图表数据。
## 3. “完整样式映射”的定义
CSS 和 WordprocessingML 不是等价排版模型。“完整样式映射”在本项目中
定义为:对项目声明支持的文档语义和可转换样式建立确定性映射,而不是
承诺转换任意浏览器 CSS。
### 3.1 必须映射
| Markdown/主题语义 | DOCX 目标 |
| --- | --- |
| 正文 | `Normal` 或项目正文段落样式 |
| 标题 16 | Word 原生标题层级 |
| 强调、粗体、删除线、行内代码 | 字符级 OOXML 属性或字符样式 |
| 引用 | 独立段落样式 |
| 有序、无序和任务列表 | 原生编号定义与段落编号 |
| 围栏代码块 | 代码段落样式、底纹、边框和等宽字体 |
| 表格 | 原生 Word 表格、单元格和表格样式 |
| 图片与图注 | DrawingML 图片和图注段落样式 |
| 链接 | 原生超链接关系 |
| 脚注 | 原生脚注 |
| 数学公式 | Pandoc 可转换时使用 OMML |
| 分页控制 | Word 段落分页属性 |
### 3.2 尽力映射
- 简单边框、底纹、单元格内边距和表格表头样式;
- 标题的段前、段后、与下段同页;
- 图片最大宽度和图注对齐;
- 内置主题中能够转换为 Word 属性的装饰;
- 自定义主题中经安全解析后允许的字体、颜色和基础段落属性。
### 3.3 不承诺映射
- CSS 伪元素、滤镜、阴影、渐变、动画和复杂背景;
- 浏览器布局模型,如 Flex、Grid、绝对定位和复杂浮动;
- Paged.js 分页结果和 Chromium 子像素布局;
- 任意 CSS 选择器的层叠结果;
- JavaScript 生成的交互行为;
- PDF 专属打印缩放。
内置主题必须提供经过测试的 Word 样式映射。自定义主题采用受限的通用
映射;未来可以在主题清单中增加显式 DOCX 样式扩展,但不作为本阶段的
前置条件。
## 4. DOCX 技术路线
### 4.1 组件职责
```text
packages/core/
DOCX 请求、结果、能力和错误协议
packages/application/
文档准备、资源解析、媒体处理和导出用例
packages/docx-engine/(计划新增)
Pandoc 调用、动态 reference.docx、OOXML 与 DOCX 验证
apps/server/
HTTP 适配、Pandoc 运行时和并发控制
apps/desktop/
IPC 适配、内置 Pandoc 定位和原生另存为
apps/web/
DOCX 导出交互、状态和错误展示
```
`packages/docx-engine` 不依赖 Fastify、Electron 或浏览器 UI。Server 和
Desktop 只提供平台运行时、传输与保存能力。
阶段 3 已在 `packages/core/src/docx.ts` 建立以下跨端协议:
- DOCX 请求及 Zod Schema
- 源图片资源;
- 固定 Pandoc 版本和运行时 capability
- HTTP/IPC 共用错误码与错误响应;
- DOCX 结果、媒体诊断和分阶段耗时;
- MIME、扩展名和安全输出文件名。
`packages/application` 提供 `prepareDocxExport()`,统一完成请求校验、
主题查找、图片解析和 Markdown 安全渲染,并返回 DOCX 引擎需要的源请求、
渲染文档、主题清单及 CSS。该方法不调用 Pandoc。
### 4.2 转换流程
1. 校验 Markdown、主题和导出配置;
2. 在每个请求的独立临时目录准备 Markdown 和资源;
3. 将本地、远程和 Data URL 图片规范化为受控本地资源;
4. 使用现有 Chromium/Electron 图表运行时将 Mermaid、ECharts 输出为
高分辨率 PNG
5. 根据主题和导出配置生成动态 `reference.docx`
6. 调用固定版本 Pandoc 生成 DOCX
7. 检查 DOCX ZIP 和关键 OOXML 部件;
8. 返回 DOCX Buffer,并在 `finally` 中清理临时目录。
不引入 Python。Node.js 负责业务编排、OOXML 修改、资源处理、进程控制和
安全边界;Pandoc 仅作为受控的独立转换器。
### 4.3 Pandoc 分发
- 固定使用 Pandoc `3.9.0.2`,不在构建时跟随 latest
- 开发环境允许通过 `DOCX_PANDOC_PATH``PATH` 使用相同版本;
- Docker amd64 使用官方 Linux 静态发行包,安装到
`/opt/pandoc/3.9.0.2/`
- Windows Desktop 将官方 ZIP 中的 `pandoc.exe`、许可证和版权文件放入
`process.resourcesPath` 下的固定资源目录;
- 用户输入不得成为可执行路径或任意命令行参数;
- 启动时或导出前校验精确版本,并通过 capability 状态报告不可用原因;
- 发行时保留 GPL、版权声明和精确版本源码获取方式;
- Pandoc 继续作为独立子进程,不链接或导入其程序代码。
官方文件、SHA-256、路径优先级、包体实测和许可证义务见
[Pandoc 运行时与分发规范](PANDOC_DISTRIBUTION.md)。
### 4.4 动态 reference.docx
动态模板以固定 Pandoc 版本的默认 `reference.docx` 为基线,由 Node
修改必要 OOXML
- `word/document.xml` 的节属性;
- `word/styles.xml` 的段落与字符样式;
- 页眉页脚部件及其关系;
- 页码字段;
- 字体、主题色和兼容性设置;
- 文档元数据。
模板缓存键至少包含 Pandoc 版本、主题版本和影响 DOCX 的导出配置。缓存
不得跨越不兼容的主题或 Pandoc 版本。
### 4.5 媒体策略
- 普通图片、SVG、Mermaid 和 ECharts 在 DOCX 媒体准备阶段统一输出 PNG;
- SVG 不直接嵌入 DOCX,避免 Word 与 WPS 的兼容差异;
- 默认按 300 DPI,即 96 CSS DPI 的 3.125 倍捕获;
- 单边最长 4096px、单图最多 1600 万像素、单图最多 8 MiB、单次导出
媒体总量最多 30 MiB
- DOCX 中使用纸张内容区对应的物理尺寸,不使用 PNG 像素尺寸直接决定
文档显示尺寸;
- 保持纵横比,不得超出可用内容宽高;
- 图表渲染必须等待字体、图片和图表资源完成;
- 图表错误产生局部、可理解的导出错误,不输出损坏 DOCX。
## 5. Markdown 编辑器与工具栏
### 5.1 编辑器选型
左侧编辑器采用:
- `@uiw/react-codemirror` `4.25.11`
- `@codemirror/lang-markdown` `6.5.1`
- CodeMirror 6 的状态、选区、事务、历史和快捷键扩展。
不引入带独立 Markdown 预览的完整编辑器,避免复制现有安全渲染和统一
预览链路。CodeMirror 只管理 Markdown 源文本,不负责生成最终预览 HTML。
### 5.2 命令扩展层
工具栏使用项目自有命令接口,不绑定第三方 Markdown 编辑器的私有插件
协议。命令至少包含:
```ts
interface MarkdownEditorCommand {
id: string;
group: "history" | "text" | "block" | "insert" | "document";
label: string;
shortcut?: string;
isEnabled?: (context: EditorCommandContext) => boolean;
execute: (context: EditorCommandContext) => void | Promise<void>;
render?: (context: ToolbarRenderContext) => React.ReactNode;
}
```
该接口必须允许命令:
- 读取和修改选区;
- 执行单个可撤销事务;
- 恢复光标和焦点;
- 打开 React 弹窗或侧边面板;
- 执行异步操作;
- 根据编辑器或平台状态禁用;
- 注册快捷键。
当前实现在 `apps/web/src/markdown-editor-commands.ts` 维护项目自有命令
注册表和纯文本变换函数,`MarkdownEditor.tsx` 只负责 CodeMirror 适配,
`MarkdownToolbar.tsx` 只负责 React 工具栏呈现。后续 Front Matter 或
ECharts 工具可以增加命令与面板入口,不需要修改编辑器内核。
### 5.3 v0.6.0 基础命令
- 撤销、重做;
- 标题级别;
- 加粗、斜体、删除线;
- 行内代码、代码块;
- 引用;
- 有序列表、无序列表和任务列表;
- 通过尺寸网格快速插入 `110` 列、`210` 行的表格;
- 分隔线。
链接、图片、Front Matter 表单工具和 ECharts YAML 工具不在 `v0.6.0`
实现,但必须能够在后续版本中以命令和面板扩展接入,不再次更换编辑器。
选中多行后执行有序列表、无序列表或任务列表,应在一次事务中为每个非空
行生成对应 Markdown 标记。有序列表按非空行连续编号。表格尺寸中的行数
包含表头,不包含 Markdown 分隔语法行;例如 `3 × 4` 生成 3 列、1 行
表头和 3 行内容。
### 5.4 兼容性要求
替换原生 `textarea` 后必须保持:
- React 中的 Markdown 受控状态和防抖渲染;
- Web 文件选择和下载;
- Desktop 新建、打开、保存、另存为及文件关联;
- 外部文件变化检测;
- 多窗口和同文件单例;
- 编辑区折叠与恢复焦点;
- 编辑区和预览区滚动同步;
- Windows 与 macOS 常用快捷键语义;
- 中文输入法组合输入;
- 大文档编辑性能。
外部文件加载不得污染用户的撤销历史;工具栏的一次操作应形成一次清晰
的撤销步骤。
当前通过独立的文档版本标识重建 CodeMirror 状态:用户键入和工具栏操作
保留在当前撤销历史中;新建、打开、重载、教程及主题示例切换会开始新的
历史。编辑器适配层同时暴露真实滚动容器和聚焦能力,继续复用原有预览
滚动同步与折叠逻辑。
## 6. API、安全与运行约束
### 6.1 API 与 IPC
- Web 提供单独的 DOCX 导出接口;
- Desktop 提供最小权限 DOCX IPC
- 两端复用相同应用用例和 DOCX 引擎;
- 响应使用正确的 DOCX MIME 类型和安全 UTF-8 文件名;
- DOCX 状态不得污染 PDF 精确预览缓存。
### 6.2 安全
- 每次导出使用独立临时目录,并在所有退出路径清理;
- 继续阻止路径穿越、符号链接越界、内网资源访问和任意本地文件读取;
- 对输入大小、资源数量、图片像素、总资源大小、执行时间和并发设置上限;
- Pandoc 只接收程序构造的参数数组,不经过 Shell;
- 不允许 Markdown 注入 Pandoc 参数、Lua Filter 路径或可执行文件路径;
- Pandoc 子进程超时后必须终止,应用退出时不得遗留子进程;
- 输出必须通过 ZIP 和关键 OOXML 部件检查后才能返回。
### 6.3 隐私
DOCX 沿用当前无历史数据库原则。Markdown、图片、中间 PNG、动态模板和
最终 DOCX 只存在于当前请求或桌面保存流程中。
### 6.4 当前项目许可与内部发行
- 当前仓库采用公司内部专属许可证,包元数据标记为 `UNLICENSED`
- 公司内部可以共享源码,并分发 Web/Docker、Desktop、ZIP 和测试产物;
- 未经书面许可,不向公司外部或公开网络分发项目源码与二进制;
- 用户文档和导出结果不因使用本软件而自动受项目许可证约束;
- 三套 Typora 复制主题只允许随公司内部发行物分发;
- Apache License 2.0 是未来公开 GitHub 时的候选许可证,不在当前阶段
自动授予;
- 公开前必须重新审查第三方依赖、主题、字体、Pandoc 源码义务及 Git
历史。
根许可证和第三方边界分别见仓库根目录 `LICENSE`
`THIRD_PARTY_NOTICES.md`
## 7. 验收矩阵
### 7.1 文档内容
综合样例必须覆盖:
- 中英文正文和多级标题;
- 强调、删除线、行内代码和链接;
- 有序、无序、嵌套和任务列表;
- 引用、代码块、长表格和分页边界;
- 脚注与 KaTeX 数学公式;
- 本地图片、受限远程图片和图片标题;
- Mermaid
- 当前支持的全部 ECharts 系列;
- Front Matter 公文、报告和标书结构;
- 页眉、页脚、页码及多页文档。
### 7.2 配置组合
至少验证:
- A4 纵向;
- A4 横向;
- Letter 纵向;
- 两组不同的四边页边距;
- 两套字体和颜色差异明显的内置主题;
- 普通文档主题和正式公文主题。
### 7.3 应用互操作
每个发布候选至少完成:
1. Word 打开、编辑、保存,WPS 再打开;
2. WPS 打开、编辑、保存,Word 再打开;
3. 修改正文、标题、列表、表格和图片;
4. 确认无修复提示、内容丢失或整体图片化;
5. 对关键页面进行渲染截图和视觉检查。
自动检查至少验证:
- DOCX ZIP 完整;
- 页面和页边距属性;
- 样式定义;
- 图片关系;
- 页眉页脚和页码字段;
- 原生编号、表格、链接、脚注和 OMML;
- 不存在正文 `altChunk`
### 7.4 回归
- 现有 Web、Docker 和 Desktop PDF 导出不得回退;
- 连续预览、快速预览和精确预览不得回退;
- Mermaid、ECharts 和图片安全边界保持不变;
- 完整通过 `npm test``npm run typecheck``npm run build`
`git diff --check`
## 8. 分阶段提交约定
`v0.6.0` 按以下阶段推进:
1. 架构与验收边界;
2. Pandoc 分发、许可证与运行时;
3. DOCX 共享模型与导出协议;
4. CodeMirror 6 与基础工具栏;
5. DOCX 资源预处理;
6. 动态 `reference.docx`
7. Pandoc DOCX 转换服务;
8. Web 与 Desktop 导出交互;
9. DOCX 自动化结构、真实 Pandoc、Server HTTP 与 Desktop 保存验收;
10. Word/WPS 双向互存、视觉、体积、文档和正式发布。
每个阶段遵循:
1. 完成当前阶段实现与验证;
2. 汇报结果并等待用户验收;
3. 验收后创建一个独立提交;
4. 再确认是否进入下一阶段。
阶段提交不得混入无关修改,也不得通过重置或清理覆盖用户工作区内容。
## 9. 已知限制
- DOCX 与 PDF 不保证分页完全相同;
- 图表不是 Office 原生可编辑图表;
- 任意浏览器 CSS 无法无损映射为 WordprocessingML
- 自定义主题首版只转换受支持属性;
- 字体能否显示取决于目标系统安装字体或后续是否实现字体嵌入;
- Word 与 WPS 对复杂分页和字体替换可能存在细微差异。
这些限制不降低两条发布门禁:受支持的纸张与样式必须稳定映射,文档主体
必须保持 Word/WPS 可编辑。
+164
View File
@@ -0,0 +1,164 @@
# v0.6.0 DOCX 字体嵌入与代表主题验收
最后更新:2026-07-31
## 1. 目标与边界
阶段 12D-R2 解决 DOCX 在目标机器没有安装主题字体时发生字体回退的问题。
实现必须同时满足:
- 纯 Node.js 运行,不增加 Python 环境;
- 接受主题现有的 OTF、TTF、WOFF 和 WOFF2 资源;
- 只嵌入明确声明且许可证位允许编辑的字体;
- 使用 Word 原生嵌入字体部件,正文、标题、表格和代码仍可编辑;
- Word 与 WPS 均可打开、编辑和互存;
- 转换、关系、内容类型和字体部件继续通过严格 OOXML 校验;
- 外部主题可以通过清单接入,不依赖主题 ID 分支。
字体采用完整字形嵌入,不做子集化。这样用户在 Word 或 WPS 中输入原文
没有出现过的新字符时,仍可继续使用同一字体。代价是生成文件明显增大。
## 2. 通用实现
主题清单新增可选的 `docxFonts.faces`
```json
{
"family": "FandolFang",
"aliases": ["FangSong", "STFangsong"],
"source": "theme-shared:official-fonts/FandolFang-Regular.woff2",
"weight": 400,
"style": "normal",
"license": "GPL-3.0-or-later WITH Font-exception-2.0"
}
```
处理链路如下:
```mermaid
flowchart LR
A["主题字体清单"] --> B["受限资源读取"]
B --> C["WOFF/WOFF2 转 SFNT"]
C --> D["解析名称、字重与 fsType"]
D --> E["许可证和体积门禁"]
E --> F["ODTTF 混淆"]
F --> G["写入 fontTable 与关系"]
G --> H["重写 Word 字体引用"]
H --> I["严格 OOXML 校验"]
```
核心约束:
- 单主题最多 16 个字形面;
- 单个源字体最多 8 MiB,源字体总量最多 32 MiB
- 单个解码后 SFNT 最多 16 MiB,总量最多 48 MiB
- 接受 `fsType=0x0000` 的可安装嵌入和 `fsType=0x0008` 的可编辑嵌入;
- 拒绝受限、仅预览打印、位图嵌入及无效权限组合;
- 字体键和 ODTTF 部件名由内容指纹稳定生成;
- WOFF2 WASM 解码采用进程内串行调度,避免底层非重入导致批量损坏;
- 同一字体内容按 SHA-256 合并并使用有界缓存;
- 内置主题可引用 `_shared` 字体,本地主题不能越权引用内置共享资源;
- 最终校验器会解混淆字体,重新检查 SFNT、内部字体族、权限位、关系和
孤立部件。
`themes/_shared/official-fonts/fonts.css` 会统一前置到内置主题 CSS,使网页、
PDF 和 DOCX 使用同一字体资产。字体选择和别名替换按字体族工作,没有
主题 ID 判断。
## 3. 许可证边界
Fandol 字体:
- 使用仓库已有的 Fandol WOFF2
- 字体内 `fsType=0x0008`,允许可编辑嵌入;
- 许可证为 GPL-3.0-or-later with Font Exception
- 完整许可证已位于
`themes/_shared/official-fonts/LICENSE.txt`
Open Sans
- `typora-github` 当前包含的是 `v17` 历史字体文件;
- 该批二进制的字体元数据和历史发行信息对应 Apache License 2.0
- 历史版权声明和完整许可证随字体放在
`themes/typora-github/github/OPEN-SANS-LICENSE.txt`
- 当前上游 Open Sans 已迁移到 SIL Open Font License 1.1。未来替换字体
二进制时必须重新核对许可证、版权声明和嵌入权限,不能沿用本阶段清单。
字体清单中的 `license` 是审计字段,不替代随包许可证文件。
## 4. 代表主题结果
测试机未安装 FandolSong、FandolFang、FandolHei、FandolKai 或
Open Sans,能够真实检验嵌入字体而不是本机字体回退。
| 主题 | 嵌入字形面 | 原始 DOCX 大小 | Word 页数 | WPS 页数 |
| --- | ---: | ---: | ---: | ---: |
| 政企正式·工作报告 | 2 | 9,932,187 字节 | 2 | 2 |
| 政企红头·标准文件 | 5 | 23,920,588 字节 | 2 | 3 |
| GitHub | 5 | 4,680,418 字节 | 2 | 2 |
三份文件均满足:
- 内置校验器确认嵌入字体部件、关系、内容类型和字体键完整;
- Word 使用 `OpenNoRepairDialog` 正常打开并导出 PDF
- WPS 正常打开并导出 PDF
- 未发现方框、缺字或明显系统字体回退;
- Word 追加编辑标记后,WPS 可以读取并继续编辑;
- WPS 另存后,Word 仍可禁止修复打开;
- 最终文件同时保留 Word 和 WPS 两段编辑文本。
14 套主题完整矩阵也已通过。未声明 `docxFonts` 的其余 11 套主题继续产生
零嵌入字体,不会被共享 CSS 隐式扩大 DOCX。
## 5. 已知客户端差异
### 5.1 WPS 红头主题分页
同一份红头 DOCX 在 Word 中为 2 页,在 WPS 中为 3 页。WPS 将版记整体
移动到近空白第 3 页。字体显示正确,内容没有丢失,但尚未达到跨客户端
视觉一致门禁。
该问题进入阶段 12D-R3,由 PDF 与 DOCX 自动视觉差异引擎量化后处理。
当前不通过主题 ID 特判或缩小正文来掩盖差异,避免在没有 PDF 基线的情况
下破坏 Word 与 Chromium 的一致性。
### 5.2 WPS 互存策略
WPS 默认另存会移除嵌入字体,三份文件会缩小到约 20 KiB。设置文档属性
“嵌入 TrueType 字体”并禁用子集后可以保留字体,但 WPS 会重新生成字体
部件,互存文件增大到约 14~45 MiB。
WPS 还会重排 `styles.xml``uiPriority`,并可能删除未使用的
`numbering.xml`。Microsoft Open XML SDK 会报告 WPS 写回文件自身产生的
顺序问题,但 Word 仍能禁止修复打开。项目不会因此放宽对自身生成文件的
严格 OOXML 门禁。
## 6. 验证结果
- `npm run verify:docx-themes`14/14 主题通过;
- `npm test`:全项目通过;
- `npm run typecheck`:全项目通过;
- `npm run build`:全项目通过;
- `git diff --check`:通过;
- Word 原始文档:3/3 禁止修复打开并导出 PDF;
- WPS 原始文档:3/3 打开并导出 PDF;
- Word → WPS → Word 双向编辑:3/3 通过;
- Word/WPS 原始渲染:共 13 页逐页检查完成。
本地验收产物位于被 Git 忽略的:
```text
output/docx-theme-matrix/
```
## 7. 下一步
阶段 12D-R3 建立 PDF 与 DOCX 自动视觉差异引擎,优先量化:
- 页面数和分页边界;
- 文本块位置和尺寸;
- 字体、字号、颜色和字重;
- 封面、版头、版记、表格及代码块边界;
- Word、WPS 与 Chromium PDF 的差异。
红头主题的 WPS 版记溢出作为首个固定回归样例。
+347
View File
@@ -0,0 +1,347 @@
# v0.6.0 DOCX 全主题视觉与结构审计
审计日期:2026-07-30
> 本文第 3~8 节记录阶段 10 的首轮审计基线。阶段 12C 已于
> 2026-07-31 完成对应结构修复和自动化门禁,最新状态见第 9 节;真实
> Word/WPS 视觉、编辑和互存结论仍需阶段 12D 复验。
## 1. 审计目标
本轮按 v0.6.0 已冻结的两条 DOCX 发布门禁检查全部内置主题:
1. 导出的 DOCX 必须保留完整纸张设置与文本样式,包括字体、段落、标题、
列表、代码块、表格、图片、Mermaid、ECharts、封面和结构化公文元素。
2. 导出的 DOCX 必须能被 Microsoft Word 与 WPS 正常打开、编辑和互存。
项目实际包含 14 套内置主题,而不是此前口头统计的 13 套。本轮没有只做
OOXML 自动检查,还使用本机 Microsoft Word 原生排版引擎逐份导出 PDF,
并检查全部 22 个渲染页面。
## 2. 审计方法与证据
- 使用 Pandoc `3.9.0.2` 和当前动态 `reference.docx` 生成 14 份 DOCX。
- 使用每套主题自己的 `samples/themes/<theme-id>.md` 和导出配置。
- 检查 DOCX 包结构、纸张尺寸、四边页边距、样式、表格、图片关系、
页眉页脚、页码字段、分页符和 `altChunk`
- 使用 Microsoft Word 原生 COM 导出 14 份 PDF,共 22 页。
- 将 Word PDF 以 150 DPI 栅格化,逐页按原始分辨率检查。
- 从 Word PDF 提取实际使用字体,排查字体替换。
- 检查 Front Matter 结构字段是否真正进入可编辑 Word 正文。
可复现命令:
```powershell
npm run verify:docx-themes
```
本地审计产物位于:
```text
output/docx-theme-matrix/
├── *.docx
├── theme-matrix-report.json
├── word-pdf-isolated/
└── word-png/
```
`output/` 已被 Git 忽略,审计产物不会进入仓库。
## 3. 总体结论
当前实现通过了“原生可编辑 DOCX”基础能力,但没有通过“完整主题样式映射”
发布门禁。
| 检查项 | 结果 | 结论 |
| --- | ---: | --- |
| Word 可打开并原生排版 | 14/14 | 通过 |
| 正文、标题、列表、表格和代码为原生 OOXML | 14/14 | 通过 |
| 禁止 `altChunk` | 14/14 | 通过 |
| A4、方向和四边页边距符合主题配置 | 14/14 | 通过 |
| 页眉页脚和页码为 Word 原生区域/字段 | 14/14 | 通过 |
| 本机字体无异常替换 | 14/14 | 通过 |
| 独立封面完整生成 | 0/4 | 阻塞 |
| 结构化公文/简报 Front Matter 完整映射 | 0/4 | 阻塞 |
| 主题视觉完整映射 | 0/14 | 阻塞 |
| 全宽表格 | 0/14 个含表格主题 | 阻塞 |
| 无重复标题 | 8/14 | 需修复 |
因此,本轮结论不是 Node.js 或 Pandoc 路线不可行,而是当前
`reference.docx + 原始 Markdown 直送 Pandoc` 的实现层级不足。Pandoc
仍适合作为可编辑 OOXML 的底座,但必须增加项目自有的结构化 AST 转换、
分节和主题专属 Word 样式映射,不能期待 Pandoc 自动理解浏览器主题 CSS
与项目 Front Matter。
## 4. 关键阻塞问题
### 4.1 结构化 Front Matter 没有进入 Word 正文
浏览器预览通过 `render-document-structure.ts``document.profile` 转成
红头、文号、签发人、版记、封面等结构;DOCX 转换当前将原始 Markdown
直接交给 Pandoc,两条链路没有复用同一份结构化文档模型。
| 主题 | Profile | 结构字段覆盖率 | 主要缺失 |
| --- | --- | ---: | --- |
| enterprise-red-simple | official | 14% | 文号、签发人、日期、抄送、印发机关 |
| gov-red-letter | official | 17% | 文号、日期、抄送、印发机关 |
| gov-red-standard | official | 22% | 文号、紧急程度、签发人、日期、抄送、版记 |
| red-briefing | briefing | 17% | 期号、编发单位、日期、联系方式等 |
| formal-feasibility | project-report | 33% | 项目名、建设单位、版本、日期和独立封面 |
| tender-blind | tender | 20% | 正副本、项目名、编号、分册、投标人等封面字段 |
| tender-business-blue | tender | 14% | 正副本、项目名、编号、分册、代表、日期 |
| tender-classic | tender | 14% | 正副本、项目名、编号、分册、代表、日期 |
四套声明独立封面的主题均没有生成封面,也没有显式分页符或分节:
- `formal-feasibility`
- `tender-blind`
- `tender-business-blue`
- `tender-classic`
### 4.2 14 套主题实际收敛成少量通用 Word 预设
当前 DOCX 样式主要来自 `official``formal``tender``technical`
`general` 几个预设。多个网页视觉差异明显的主题,在 DOCX 中使用完全相同
或近似的正文、标题和表格样式。
- `formal-regulation``formal-report` 基本相同。
- `typora-github``typora-like` 基本相同。
- `typora-pixyll``typora-whitey` 基本相同。
- 三套红头公文正文样式基本相同,但没有各自红头版式结构。
- 三套标书共用蓝色系表格预设,破坏暗标和经典黑白主题语义。
这说明当前是“按主题选择一个通用 Word 模板”,尚不是“完整映射每套主题”。
### 4.3 标题重复
以下 6 套样例的 YAML `title` 与正文第一个 H1 相同。Pandoc 同时输出文档
标题块和 H1,Word 中出现重复标题:
- `formal-regulation`
- `formal-report`
- `typora-github`
- `typora-like`
- `typora-pixyll`
- `typora-whitey`
此外,文档标题当前基于 H1 再增加 4pt,实际约 26pt;在红头信函中产生
过大的标题和不自然换行,例如“函”被孤立到第二行。
### 4.4 表格没有继承网页主题的全宽布局
Pandoc 生成的表格宽度是:
```xml
<w:tblW w:type="auto" w:w="0"/>
```
Word 会按内容自动收缩,导致所有表格明显窄于正文内容区。网页主题中的
`width: 100%`、列宽和部分表头视觉没有被映射到 Word 表格网格。
### 4.5 分页质量不足
当前 14 份文档均没有显式分页符。Word 只能根据通用段落属性自动分页,
出现以下问题:
- `typora-like` 第 2 页只剩最后一行审核人信息,属于严重孤行页。
- `formal-feasibility``formal-regulation``formal-report`
`tender-blind` 的末页内容很少,下半页大面积空白。
- 代码块可以跨页,但没有专门的代码块续排或避免不佳断点策略。
- 封面与正文无法通过 Word 分节控制首页页眉页脚、起始页码和封面不计页码。
### 4.6 字体在本机正确,但没有嵌入
Word 实际导出的 PDF 中可见字体符合当前样式意图:
- 公文、正式报告和标书主要使用 `SimSun``SimHei`
`Times New Roman``Arial`
- 技术主题主要使用 `Microsoft YaHei``Arial``Consolas`
但 14 份 DOCX 均没有嵌入字体文件,也没有嵌入字体引用。这意味着:
- 在本机和安装了相同字体的公司电脑上表现稳定。
- 在缺少宋体、黑体、微软雅黑或对应英文字体的环境中,Word/WPS 会替换字体,
进而改变换行、页数和视觉。
- 默认嵌入字体会显著增大文件,并涉及字体再分发许可,不适合作为默认策略。
建议明确“受支持字体清单 + fallback”,并在 Desktop/Docker 安装或内置
所需可分发字体;除非业务明确要求,不默认嵌入字体到每份 DOCX。
## 5. 逐主题视觉结论
| 主题 | Word 页数 | 主要视觉与结构问题 | 门禁结论 |
| --- | ---: | --- | --- |
| enterprise-red-simple | 1 | 无企业红头、文号、签发和版记;呈现为通用 Word 文档;表格偏窄 | 阻塞 |
| formal-feasibility | 2 | 无独立封面;项目、建设单位、版本等封面字段缺失;末页留白大 | 阻塞 |
| formal-regulation | 2 | 标题重复;视觉为通用蓝灰报告;表格偏窄;末页留白大 | 阻塞 |
| formal-report | 2 | 标题重复;主题区分度弱;末页只有少量内容 | 阻塞 |
| gov-red-letter | 2 | 无红头、文号、落款和版记;超大标题换行不佳;第 2 页外侧公文页码正确 | 阻塞 |
| gov-red-standard | 2 | 无红头、文号、紧急程度、签发和版记;奇偶外侧公文页码正确 | 阻塞 |
| red-briefing | 1 | 无简报报头、期号、编发单位和联系信息;正文段落间视觉空隙偏大 | 阻塞 |
| tender-blind | 2 | 无封面;标题和表头为蓝色,违反技术暗标黑白要求;末页留白大 | 阻塞 |
| tender-business-blue | 1 | 无封面;蓝色体系基本协调,但仍是通用标书模板;表格偏窄 | 阻塞 |
| tender-classic | 1 | 无封面;标题过大;表头仍为浅蓝色,违反经典黑白语义 | 阻塞 |
| typora-github | 2 | 标题重复;代码样式可用;表格偏窄;审计占位图黑块不计入产品缺陷 | 需修复 |
| typora-like | 2 | 标题重复;正文、引用和代码基本可用;第 2 页仅一行,孤行严重 | 阻塞 |
| typora-pixyll | 1 | 标题重复;整体可用,但与 Whitey 的 Word 风格区分不足;表格偏窄 | 需修复 |
| typora-whitey | 1 | 标题重复;整体可用,但主题专属字体和细节映射不足;表格偏窄 | 需修复 |
`typora-github` 审计图中的大黑块来自矩阵脚本使用 1×1 像素占位 PNG 后按
计划尺寸拉伸,只用于验证图片关系和 Word 尺寸,不代表真实 Mermaid 导出。
真实 Mermaid/ECharts PNG 管线已经在独立验收样例中验证,正式全主题回归
仍应改用真实捕获媒体,避免视觉报告产生歧义。
## 6. 已确认正常的能力
- DOCX 是标准 OOXML,而不是 HTML 或 PDF 嵌套。
- 正文、标题、列表、表格、代码和链接可在 Word 中继续编辑。
- A4 尺寸、方向和主题声明的四边页边距正确。
- 页眉页脚位于 Word 原生页眉区/页脚区,不再以正文表格模拟。
- 页码使用 `PAGE`/`NUMPAGES` 字段。
- `gov-red-letter` 首页不显示页码,第 2 页显示外侧 `— 2 —`
- `gov-red-standard` 奇偶页外侧公文页码正确。
- 视觉上较淡的 `tender-classic``typora-whitey` 页码经 PDF 文本提取
确认存在,不是字段丢失。
- 所有 DOCX 均未发现 `altChunk`,可编辑性基础路线正确。
## 7. 建议修复顺序
### P0:建立统一的结构化 DOCX 文档模型
在进入 Pandoc 前,将 Markdown AST、Front Matter 和主题结构合成为统一
中间模型,再输出 Pandoc AST 或通过项目 Lua Filter 注入原生 Word 段落、
表格、分页符和分节信息。公文、简报、项目报告和标书封面不能依赖
`reference.docx` 自动生成。
### P0:实现封面与分节
- 封面使用原生段落和可编辑文字。
- 封面结束后插入 Word 分节符。
- 支持封面无页眉页脚、封面不计页码、正文从 1 开始。
- 公文首页、奇偶页脚继续使用 Word 原生节属性。
### P0:扩展主题 DOCX 清单
为主题增加可显式覆盖的 Word 语义样式,而不是只选择通用预设:
- 文档标题、作者和元数据块;
- 红头、文号、签发人、落款、抄送和版记;
- 封面项目名、分册、正副本和日期;
- H1~H6、正文、列表、引用、代码、图注;
- 表格宽度、列宽、表头、边框和底色;
- 段前段后、孤行控制、标题与下一段同页。
### P1:修复通用排版问题
- 对 YAML 标题与首个 H1 去重。
- 表格按内容区宽度生成 `pct=5000` 或明确网格宽度。
- 为标题设置 `keepNext`,为正文设置 widow/orphan 控制。
- 收敛过大的 26pt 文档标题,并按主题单独配置。
- 暗标和经典黑白主题禁止蓝色标题、表头和装饰。
### P1:完善字体可复现策略
- 固化每套主题的首选字体和 fallback。
- 在发布说明中列出 Word/WPS 目标环境所需字体。
- 仅使用许可证允许随应用分发的字体作为可选内置字体。
- 增加“字体不可用”诊断,不默认把字体嵌入每份 DOCX。
### P2:升级全主题回归门禁
- 使用真实 Mermaid、ECharts 和普通图片,不再使用纯色占位图做视觉验收。
- 自动检测标题重复、表格内容区占比、孤行页和封面分节。
- Word 与 WPS 各完成一次打开、编辑、保存、重开。
- 保存后再次校验页数、字体、页眉页脚、字段和原生结构。
## 8. 阶段判定
当前 DOCX 引擎可以认定为:
> Pandoc 路线与 Node.js 编排可行,原生可编辑基础链路成立;但完整主题
> 映射、结构化公文、封面、表格宽度和分页控制尚未达到 v0.6.0 发布门禁。
在完成 P0 项目前,不建议将阶段 10 标记为通过,也不建议创建 v0.6.0
正式发布提交或版本标签。
## 9. 阶段 12C 自动修复复验
阶段 12C 保留 Pandoc 作为可编辑 OOXML 底座,并增加项目自有的通用转换
层:统一语义文档模型生成结构计划,Lua Filter 注入可编辑段落与样式,
最终 OOXML 收口层写入真实分节、页码重启、表格宽度和分页属性。映射只
依赖语义角色与主题令牌,不包含主题 ID 分支,因此同一机制也可服务未来
外部主题。
2026-07-31 使用 Playwright Chromium 151 重新采集 14 套主题各 56 个
计算样式槽位,并由 Pandoc 3.9.0.2 重新生成全部 DOCX,自动结构结果如下:
| 检查项 | 阶段 12C 结果 | 结论 |
| --- | ---: | --- |
| 主题使用本轮真实 Chromium 令牌 | 14/14 | 通过 |
| 结构化主题字段完整映射 | 8/8 | 通过 |
| 项目报告与标书独立封面真实分节 | 4/4 | 通过 |
| 封面隐藏页眉页脚、正文页码从 1 重启 | 4/4 | 通过 |
| 封面文档使用 `SECTIONPAGES` | 4/4 | 通过 |
| 含表格主题的表格铺满内容区 | 11/11 | 通过 |
| YAML 标题与正文首个 H1 去重 | 14/14 | 通过 |
| 内部结构标记清理 | 14/14 | 通过 |
| 禁止 `altChunk`、保留原生可编辑结构 | 14/14 | 通过 |
统一复现命令仍为:
```powershell
npm run verify:docx-themes
```
该命令不会回退到合成令牌或陈旧快照。它先生成本轮 Chromium 令牌,再
执行 14 主题 Pandoc 矩阵和硬断言。阶段 10 的 P0/P1 结构阻塞因此已由
自动门禁关闭;“完整主题样式映射”的最终判定仍保留到阶段 12D,必须在
Microsoft Word 与 WPS 中完成视觉检查、编辑、保存、重开和双向互存后
才能通过。
## 10. 阶段 12C-R1 OOXML 严格性复验
2026-07-31 对阶段 12C 生成的 14 份 DOCX 做 Microsoft Word 原生打开
时,最初仅 7 份可直接打开,另外 7 份触发“发现无法读取的内容”且无法
恢复。WPS 可以容错打开,但会报告缺失字体,因此不能据此判定 OOXML
合格。
深度审计确认根因集中在生成样式与 OOXML 模式约束:
- Pandoc 消费 `custom-style` 后会追加同名降级样式,造成重复
`w:styleId`
- CSS `text-align: justify` 被直接写成非法的 `w:jc="justify"`,正确
WordprocessingML 枚举应为 `both`
- 表格条件样式中的 `w:rPr``w:tcPr` 顺序不符合模式;
- `w:tblW` 被写入表格样式层,和正文表格的直接宽度属性混在一起;
- 多类段落、文本、表格、单元格和分节属性缺少统一的有序写入门禁。
通用引擎已经增加有序插入、序列化规范化和严格校验,并在最终 DOCX
收口层保留前置主题样式、移除 Pandoc 同名降级样式。修复不按主题 ID
分支,也不改变正文表格的内容区全宽属性。
复验结果:
| 检查项 | 结果 | 结论 |
| --- | ---: | --- |
| 14 主题结构矩阵 | 14/14 | 通过 |
| Microsoft Open XML SDK 非兼容性错误 | 0 | 通过 |
| Microsoft Word 禁止修复打开 | 14/14 | 通过 |
| WPS 原生只读打开 | 14/14 | 通过 |
| Word 原生 PDF 导出 | 14/14,共 31 页 | 通过 |
| 31 页逐页结构检查 | 31/31 | 通过 |
Open XML SDK 2.20 仍会按 Office 2007 模式报告 Pandoc 表格上的 Office
2010 `tblLook` 六个兼容属性;这些属性在本轮 Word/WPS 实测中正常,不计
为生成错误。
逐页检查没有发现内容丢失、裁切、重叠或空白正文,但保留以下阶段 12D
视觉问题:
- 深色表头文字对比度不足;
- 部分短文档尾页留白过大;
- `gov-red-standard` 在 Word 与 WPS 中分别为 2 页和 3 页,说明字体与
客户端排版差异尚未收口;
- 当前主题矩阵的 Mermaid 仍使用黑色占位 PNG,不代表真实媒体效果。
因此 R1 只关闭 OOXML 合法性和 Word/WPS 无修复打开门禁。字体可移植性、
PDF 与 DOCX 自动视觉差异、真实媒体和双向编辑互存继续由 R2~R4 验收。
+169
View File
@@ -0,0 +1,169 @@
# v0.6.1 DOCX 视觉一致性与发布门禁修复设计
状态:范围已冻结,阶段 A、B 已完成,等待阶段 C。
## 1. 版本目标
`v0.6.1` 是针对 `v0.6.0` DOCX 导出质量的维护版本,不新增与 DOCX
无关的产品功能。本版本必须完成:
1. 修复视觉报告失败但总发布门禁仍通过的判定漏洞;
2. 对四套独立封面主题建立 Chromium、Word、WPS 整页严格门禁;
3. 对 14 套内置主题建立与正文分页位置无关的语义块局部视觉门禁;
4. 修复主题样式、封面布局和字体解析、嵌入、回退问题;
5. 对源码服务、Docker Web API 和 Desktop 安装版执行发布后复验。
本版本不要求 Chromium、Word 和 WPS 的正文总页数或分页边界完全一致,
但不允许以排版引擎不同为理由放过封面、字体或语义块样式退化。
```mermaid
flowchart LR
A["同一 Markdown、主题与配置"] --> B["生产 Chromium PDF"]
A --> C["生产 DOCX"]
C --> D["Word 原生渲染"]
C --> E["WPS 原生渲染"]
B --> F["封面整页严格比较"]
D --> F
E --> F
B --> G["正文语义块匹配"]
D --> G
E --> G
F --> H["发布硬门禁"]
G --> H
```
## 2. 问题基线
`v0.6.0` 复核确认存在以下门禁缺口:
- 视觉报告中的像素差异、墨迹 IoU、边缘 IoU 等 failure 级问题未进入
外层发布阻断集合;
- 六个布局视觉场景只覆盖五套主题,未覆盖全部 14 套主题;
- 四套独立封面只覆盖其中两套;
- “独立封面”主要检查 OOXML 分节、页眉页脚、页码重启和字段存在,
不能代表视觉一致;
- 14 主题样式门禁主要检查 CSS 槽位、令牌和 Word 样式 ID 存在,不能
代表最终 Word/WPS 呈现一致;
- 字体名称写入 `fontTable.xml`、转换无 warning,均不能证明字体已经解析、
嵌入并被 Office 实际使用;
- `red-briefing` 在门禁、Docker Web 和用户实际产物中均为零嵌入字体,
仍以零 warning 通过。
## 3. 验收模型
### 3.1 封面整页硬门禁
适用于 `formal-feasibility``tender-business-blue``tender-blind`
`tender-classic`
- 封面必须独占一个物理页;
- 所有封面字段只能出现在封面页,正文不得回流到封面页;
- 封面页不显示正文页眉、页脚和页码;
- 字体、字号、字重、颜色、对齐、位置、边框、背景和装饰线必须匹配;
- 整页像素、墨迹 IoU、边缘 IoU 任一 failure 都阻断发布;
- Word 和 WPS 都必须通过,不允许只以二者相互接近替代 Chromium 基线。
### 3.2 正文语义块门禁
正文按稳定内容顺序和语义角色匹配,不按物理页号配对。允许语义块移动到
另一页,但块自身必须保持视觉一致:
| 语义块 | 必须比较 |
| --- | --- |
| 标题、正文 | 字体、字号、字重、颜色、行高、缩进、段距、对齐 |
| 引用、提示框 | 背景、边框、内边距、文字样式 |
| 代码块 | 等宽字体、字号、背景、边框、内边距、换行 |
| 列表 | 编号或项目符号、缩进、悬挂距离、文字样式 |
| 表格 | 总宽度、列宽、边框、底纹、单元格内边距、表头与文字样式 |
| 图片与图表 | 尺寸、宽高比、清晰度、对齐和题注 |
正文整页栅格差异只作为诊断,不作为最终样式结论;没有完成语义块局部比较
的主题不得标记为视觉通过。
### 3.3 字体硬门禁
- 记录 Chromium、DOCX 字体表、嵌入字体部件、Word PDF 和 WPS PDF 的
实际字体;
- 需要跨环境稳定的字体必须来自许可证允许分发的字体包;
- 应嵌入字体但字体部件为零时直接失败;
- 字体包未应用、字体回退或 Office 未使用目标字体时必须产生阻断错误;
- 字体名称存在但没有字体二进制不得视为通过。
### 3.4 发布链路门禁
同一验收输入必须覆盖:
1. 源码测试服务;
2. 正式 Docker Web API
3. 正式 Desktop 安装版。
任一底层报告状态为 `failed` 时,总门禁必须失败。只有已经证明属于合法
正文自然分页流动的问题才能降级为 warning;封面、字体、内容、语义块
样式和媒体失败不得降级。
### 3.5 全量配置矩阵
发布门禁不是少量代表用例,而是固定执行 `14 × 2 × 5 = 140` 个排版
场景:
- 14 套内置主题;
- 纵向、横向两个纸张方向;
- 主题默认、标准、紧凑、宽松和非对称装订五组页边距。
“主题默认”必须使用 `marginMode: theme`,验证主题清单中的真实默认值;
即使解析后的数值与某组显式边距相同,也不得去重。其余四组使用显式
`custom` 配置,验证配置覆盖链路。每个场景生成 Chromium PDF、DOCX、
Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导出链和
渲染引擎维度,不允许用汇总绿灯掩盖单个失败。
场景 ID 固定为 `<theme-id>--<orientation>--<margin-scenario>`。总报告同时
输出机器可读 JSON 和 14 行 × 10 列 HTML 矩阵;每个单元格必须显示通过、
失败或未执行,并链接到 Chromium/Word、Chromium/WPS、Word/WPS 三份
详细报告。完整门禁必须执行 140 个场景后才允许标记 `complete: true`
单场景筛选只用于开发复现,不能冒充全矩阵结果。
## 4. 实施阶段
1. 阶段 A:修复 failure 聚合策略,补齐四套封面场景和封面正文隔离;
2. 阶段 B:建立跨页无关的语义块观察、匹配、局部栅格和样式比较协议;
3. 阶段 C:建立 14 主题、2 个方向、5 组页边距的 140 场景全量语义块
矩阵和版本化门限;
4. 阶段 D:逐主题修复封面、正文块样式和字体嵌入;
5. 阶段 E:完成源码、Docker Web、Desktop 安装版发布后复验;
6. 阶段 F:更新版本、发行说明、发布产物、发布提交和 annotated tag。
每个阶段必须先提交真实失败基线,再修复到通过。不得通过删除用例、提高
门限、过滤 failure 或把 failure 改名为 warning 取得绿灯。
## 5. v0.6.1 发布条件
- 14 套主题在纵向、横向和五组页边距下全部生成真实 Chromium PDF、
DOCX、Word PDF 和 WPS PDF
- 四套独立封面全部通过整页严格门禁;
- 14 套主题声明支持的语义块全部通过局部视觉门禁;
- 字体解析、嵌入和实际使用结果符合主题预期;
- 源码、Docker Web 和 Desktop 安装版结果一致;
- 单元测试、类型检查、生产构建和 `git diff --check` 通过;
- 发布报告中不存在被忽略的 failure;
- 完成真实 Word/WPS 逐页人工复核后才能发布。
## 6. 最终验收结果
2026-08-04 已完成全部发布条件:
- `14 × 2 × 5 = 140` 个场景全部实际执行,矩阵报告为
`complete: true``gatePassed: true`,阻断失败和诊断失败均为 0
- 四套独立封面共 40 个方向/边距组合全部通过 Chromium 对 Word/WPS
整页严格门禁;其余页面按跨页无关的语义块完成视觉比较;
- 通用 CSS 到 OOXML 翻译已覆盖字体、字距、精确行距、段落、列表、表格、
引用、代码和行内代码连续性,全程未增加按主题 ID 分支;
- Serif、Sans、Mono 三套可分发字体进入同一字体包解析链,140 个场景的
DOCX 均包含 5 个字体部件,Word/WPS 实际字体使用门禁通过;
- `red-briefing` 已分别从源码服务、正式 Docker Web API 和实际安装的
Desktop 导出;三份 DOCX 均约 24.17 MB、含 5 个字体部件,并通过真实
Word/WPS 渲染复核;
- 根级测试、类型检查、生产构建和空白检查通过;Docker 镜像、Desktop
安装器及免安装 ZIP 已生成。
最终矩阵报告位于 `output/docx-layout-visual-matrix/summary.json`,发行说明
见 [`docs/releases/v0.6.1.md`](releases/v0.6.1.md)。
+38
View File
@@ -0,0 +1,38 @@
# MorphDoc · 墨呈 v0.6.0
## 核心更新
- 新增可编辑 DOCX 导出,保留纸张、页边距、字体、段落、代码块、图片、
Mermaid、ECharts、封面、页眉页脚和页码配置。
- 建立通用主题到 DOCX 样式翻译引擎,14 套内置主题通过真实 Word/WPS
无修复打开、编辑和逐页视觉门禁。
- 新增可扩展 Markdown 基础工具栏,支持标题、强调、代码、引用、多行列表、
任务列表、分隔线及 M×N 表格。
- PDF 与 DOCX 统一收口到“导出”子菜单,并提供独立顶部进度提示。
## 发行与兼容性
- 产品更名为“墨呈”,英文名 MorphDoc;保留原 NSIS 安装身份并迁移到新的
默认安装目录、快捷方式和开始菜单目录。
- Desktop 内置固定 Pandoc 3.9.0.2 最小运行时;字体包可在安装向导中选择,
也提供独立安装器与 ZIP。
- Docker 镜像内置固定 Chromium、Pandoc 与正式字体包,支持完全离线部署。
- 正式字体资产转由仓库固定目录和 Git LFS 管理。
## 问题修复
- 修复精确预览放大后右侧缺少固定留白的问题。
- 修复保存、另存为和导出的默认路径优先级。
- 修复 Desktop DOCX 图片捕获可能超时的问题。
- 修复品牌改名后安装器仍继承旧安装目录的问题。
- 统一主题展示名称并移除 Typora 字样,默认主题保持 GitHub。
## 验证结论
- 全项目测试、类型检查、生产构建和空白检查通过。
- 14 套主题生产 PDF/DOCX 与六组纸张、方向、页边距视觉矩阵通过。
- Web API、Desktop ZIP、覆盖安装后的真实安装版均完成 PDF/DOCX 导出。
- Word/WPS 可编辑性、字体嵌入、封面独立分页和页眉页脚位置门禁通过。
当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布;公开
网络分发前建议增加代码签名。
+55
View File
@@ -0,0 +1,55 @@
# MorphDoc · 墨呈 v0.6.1
## 核心修复
- 重构通用 CSS 到 WordprocessingML 翻译链,统一处理字体族、东亚字体、
字号、行距、段距、缩进、边框、底纹、表格和代码样式,不包含按主题 ID
分支或单主题补丁。
- 新增可分发的 `mdtp-mono` 等宽字体,并将 Serif、Sans、Mono 三套字体包
同源用于 Chromium 预览/PDF 与 DOCX 静态嵌入;字体门禁同时检查字体表、
嵌入部件和 Word/WPS 的实际使用结果。
- 修复中文标点受 Word 自动字距调整造成的行宽漂移,并按浏览器实测值写入
Word 行距和网格参数。
- 修复 Markdown 行内代码在普通段落、列表和表格中被错误拆成独立段落的
问题;行内代码现在保持原段落连续排版,并进入视觉门禁。
- 统一 DOCX 媒体捕获的打印媒体环境,避免开发预览与正式 Web/Desktop
导出采用不同 CSS 分支。
## 视觉门禁
- 发布矩阵固定为 14 套内置主题 × 纵横两个方向 × 主题默认、标准、紧凑、
宽松、非对称装订五组页边距,共 140 个真实场景。
- 四套独立封面使用 Chromium 对 Word/WPS 的整页严格门禁;封面分页、
字体、位置、边框、背景、装饰和页码任一失败都会阻断发布。
- 正文按内容顺序和语义角色比较标题、段落、引用、列表、代码、表格及媒体,
允许流式分页造成物理页号变化,但不允许元素块视觉样式丢失。
- 外层聚合不再过滤像素、墨迹 IoU、边缘 IoU 或字体 failure;只有执行满
140 个场景且阻断失败为零时,报告才允许标记完整通过。
## 验证结论
- 全量矩阵 `140/140` 执行完成并通过,阻断失败和诊断失败均为 0。
- 同一 `red-briefing` 输入已通过源码服务、正式 Docker Web API 和实际
安装的 Desktop 三条生产链;三份 DOCX 均约 24.17 MB,包含 5 个字体
部件,嵌入字体字节数为 40,731,432。
- 已安装 Desktop 产物经真实 Word 和 WPS 渲染均为 2 页 A4,独立封面及
正文逐页复核通过。
- 正式安装包覆盖安装后,默认 10 页媒体示例从实际 Desktop 窗口导出
10,931,974 字节的 DOCX,包含 7 个字体部件和 12 个媒体部件;字体部件
解压后共 11,190,728 字节,确认安装版直接使用随程序内置的字体资源。
- Docker 镜像内置三套字体包与 Pandoc 3.9.0.2;修复复用运行层时 Pandoc
软链接已存在导致构建失败的问题,并通过根级 `.dockerignore` 固定精简
构建上下文。正式镜像已使用 `--pull --no-cache` 和禁用运行层复用的完整
路径重新构建并通过生产门禁。
- 全项目测试、类型检查、生产构建及 `git diff --check` 通过。
## 发行与兼容性
- Desktop 安装器和免安装 ZIP 版本均为 0.6.1;支持覆盖 v0.6.0 安装。
- Serif、Sans、Mono 三套字体直接内置于 Desktop 安装器和免安装 ZIP
不写入系统字体目录;安装过程不再显示字体选择,也不再发布独立字体附件。
- Docker 发布镜像为 `md-to-pdf:v0.6.1`,内容 ID 为
`sha256:f81dbce5857a0a5496851e149936d1fd7da52b76c035187be1079fa1fd5a4717`
- DOCX 保持原生可编辑段落、Run、列表和表格结构,没有以整页图片替代正文。
- 当前 Windows 安装器未进行 Authenticode 签名,适用于公司内部发布;
公开网络分发前建议增加代码签名。

Some files were not shown because too many files have changed in this diff Show More