Compare commits
36
Commits
v0.5.0
...
842c3f1782
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842c3f1782 | ||
|
|
3e89e3faff | ||
|
|
8f4d16a9b7 | ||
|
|
5c82b3df7f | ||
|
|
14f470454c | ||
|
|
3a8d2c1c2e | ||
|
|
9a2aa3c341 | ||
|
|
58f87cc19f | ||
|
|
f9f5fccfc9 | ||
|
|
3081c02887 | ||
|
|
7f2c169405 | ||
|
|
dc026835b7 | ||
|
|
6f62cfebd6 | ||
|
|
ae9fde3ac6 | ||
|
|
9f96d51922 | ||
|
|
c76454be9d | ||
|
|
e5da699ea3 | ||
|
|
10bc62cee4 | ||
|
|
f3847f3e4b | ||
|
|
6e40374200 | ||
|
|
459a079f3b | ||
|
|
7f72258126 | ||
|
|
d2acc9c5ce | ||
|
|
6086762b4f | ||
|
|
6fed666f44 | ||
|
|
e7c688df6e | ||
|
|
aff2830c28 | ||
|
|
f2dfc6ef72 | ||
|
|
bf43433d38 | ||
|
|
fa159d916f | ||
|
|
fe5d67fb6d | ||
|
|
7831bc23f0 | ||
|
|
f7d4efeafc | ||
|
|
fb3ddada39 | ||
|
|
81d9099eae | ||
|
|
83e6559c2c |
@@ -1 +1,7 @@
|
|||||||
deploy/entrypoint.sh text eol=lf
|
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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.conda_env
|
||||||
.local/
|
.local/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -13,6 +14,10 @@ Thumbs.db
|
|||||||
tmp/
|
tmp/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
|
||||||
|
# 构建、门禁与正式发版产物只保留在本机,不进入 Git。
|
||||||
output/
|
output/
|
||||||
/apps/desktop/out/
|
/apps/desktop/out/
|
||||||
|
/apps/desktop/.runtime/
|
||||||
|
/release/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
YIXIONG Tech.ltd 公司内部专属软件许可证
|
||||||
|
版本 1.0,2026 年
|
||||||
|
|
||||||
|
版权所有 © 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 以明确书面形式作出。
|
||||||
@@ -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.
|
||||||
@@ -1,30 +1,40 @@
|
|||||||
# Markdown PDF 导出器
|
# 墨呈
|
||||||
|
|
||||||
一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
|
> 一稿多式,即写即呈
|
||||||
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
|
|
||||||
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
|
|
||||||
|
|
||||||
当前版本:`v0.5.0`。
|
面向结构化 Markdown 的主题化文档创作与发布工具,同时提供 Web 与
|
||||||
|
Windows 桌面端。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题
|
||||||
|
CSS 和分页运行时,DOCX 使用同源语义文档、主题令牌和可编辑 OOXML。
|
||||||
|
|
||||||
## v0.5.0 主要能力
|
当前版本:`v0.6.0`。
|
||||||
|
|
||||||
- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速
|
## v0.6.0 核心更新
|
||||||
预览继续提供分页近似,精确预览和导出 PDF 保持权威分页;
|
|
||||||
- 分页引擎从 Web 应用抽取为 `@md-to-pdf/preview-engine`,Web、
|
- 新增可编辑 DOCX 导出,使用固定 Pandoc、动态 Word 样式、原生分节、
|
||||||
Playwright PDF 与 Electron PDF 直接复用同一运行时;
|
页眉页脚、页码和高分辨率 PNG 媒体管线;
|
||||||
- 快速预览支持按修改位置复用稳定前缀,只重新分页受影响的后缀;
|
- 主题 CSS 通过通用语义槽位翻译为 Word 样式,支持 14 套内置主题、
|
||||||
- Web 链接只处理当前文档锚点和 HTTP/HTTPS 外链;外链在新浏览器窗口
|
公文结构、独立封面、代码、表格、图片、Mermaid 与 ECharts;
|
||||||
打开,本地路径及不支持协议不执行导航;
|
- 新增 CodeMirror 6 Markdown 工具栏,支持标题、行内格式、引用、列表、
|
||||||
- Desktop 支持 HTTP/HTTPS、系统协议、本地相对或绝对路径和 `file:`
|
代码块、分隔线和尺寸表格;
|
||||||
URI;Markdown 链接可选择在当前窗口或新窗口打开;
|
- 产品品牌升级为“墨呈”,Windows 主程序和发行产物使用英文名
|
||||||
- Desktop 支持多窗口和同一文件单例。窗口聚焦时检查磁盘文件变化,并
|
`MorphDoc`。
|
||||||
提示是否重新加载;
|
|
||||||
- 四个内置主题统一命名为 Typora Github、Typora Pixyll、
|
## v0.5.1 主题与公文能力
|
||||||
Typora whitey 和 Typora Clean,默认使用 Typora Github;
|
|
||||||
- 围栏代码块输出 Typora 兼容的 `.md-fences` DOM,并避免内部
|
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
|
||||||
`<code>` 重复套用行内代码样式;
|
以及 3 套正式文档主题和 3 套标书主题;
|
||||||
- 桌面发行链会在每次打包前完整重建内嵌 Web 依赖,避免安装包携带陈旧
|
- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
|
||||||
前端资源。
|
16mm 默认页边距,选择正式主题时自动采用其规范参数;
|
||||||
|
- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
|
||||||
|
签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
|
||||||
|
- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
|
||||||
|
字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
|
||||||
|
同时保留 `8px` 左侧内容留白;
|
||||||
|
- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown;
|
||||||
|
Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
|
||||||
|
- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
|
||||||
|
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
|
||||||
|
Web 端隐藏不适用的“另存为”。
|
||||||
|
|
||||||
## Web 与桌面端
|
## Web 与桌面端
|
||||||
|
|
||||||
@@ -32,7 +42,7 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 新建 Markdown | 支持 | 支持 |
|
| 新建 Markdown | 支持 | 支持 |
|
||||||
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||||
| 保存 Markdown | 浏览器下载 | 原生另存为 |
|
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
|
||||||
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||||
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
||||||
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
||||||
@@ -42,6 +52,7 @@
|
|||||||
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||||
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||||
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||||
|
| DOCX 引擎 | 固定版本 Pandoc | 内置固定版本 Pandoc |
|
||||||
|
|
||||||
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
||||||
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
||||||
@@ -128,9 +139,10 @@ npm run desktop:package
|
|||||||
npm run make -w @md-to-pdf/desktop
|
npm run make -w @md-to-pdf/desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能
|
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并
|
||||||
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/`。
|
校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的
|
||||||
正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`。
|
||||||
|
正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
||||||
未配置代码签名。
|
未配置代码签名。
|
||||||
|
|
||||||
## 文档与媒体
|
## 文档与媒体
|
||||||
@@ -149,8 +161,8 @@ ECharts 围栏语法、支持系列和安全边界详见
|
|||||||
|
|
||||||
## 主题
|
## 主题
|
||||||
|
|
||||||
仓库内置 Typora Github、Typora Pixyll、Typora whitey 和
|
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||||
Typora Clean 四套主题,默认使用 Typora Github。主题由版本化
|
3 套标书主题,默认使用 GitHub。主题由版本化
|
||||||
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||||
|
|
||||||
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
||||||
|
|||||||
@@ -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 License,version 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
-11
@@ -14,10 +14,11 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图
|
|||||||
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
||||||
重新加载,避免阅读或导出磁盘旧版本。
|
重新加载,避免阅读或导出磁盘旧版本。
|
||||||
|
|
||||||
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
|
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
|
||||||
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
|
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
|
||||||
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
|
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
|
||||||
自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。
|
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
|
||||||
|
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -42,11 +43,22 @@ npm run make -w @md-to-pdf/desktop
|
|||||||
|
|
||||||
`package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建
|
`package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建
|
||||||
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web,
|
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 输出到
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以
|
`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以
|
||||||
`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
提示。
|
提示。
|
||||||
|
|
||||||
@@ -55,13 +67,17 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
|
|||||||
发布,不提交进 Git 历史。
|
发布,不提交进 Git 历史。
|
||||||
|
|
||||||
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
|
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
|
||||||
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
|
“运行”操作直接启动 `MorphDoc.exe`,不依赖快捷方式文件。
|
||||||
|
|
||||||
产品名称、窗口标题、安装包名称和开始菜单名称保持为
|
界面、快捷方式与卸载项使用中文名“墨呈”,窗口标题为
|
||||||
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
|
“墨呈 · MorphDoc”,默认安装目录和 Windows 主程序分别为 `MorphDoc`
|
||||||
`md-to-pdf.exe`。
|
和 `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` 下,生产环境通过
|
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||||
`mdpdf://bundle/` 自定义协议加载。
|
`mdpdf://bundle/` 自定义协议加载。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,80 @@
|
|||||||
|
!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"
|
||||||
|
|
||||||
|
!ifdef FONT_PACK_COMPANION_FILE
|
||||||
|
!ifndef BUILD_UNINSTALLER
|
||||||
|
!include "FileFunc.nsh"
|
||||||
|
!include "nsDialogs.nsh"
|
||||||
|
|
||||||
|
Var fontPackCompanionAvailable
|
||||||
|
Var fontPackCompanionCheckbox
|
||||||
|
Var fontPackCompanionExitCode
|
||||||
|
Var fontPackCompanionHash
|
||||||
|
Var fontPackCompanionPath
|
||||||
|
Var fontPackCompanionSelected
|
||||||
|
|
||||||
|
!macro customInit
|
||||||
|
StrCpy $fontPackCompanionAvailable "0"
|
||||||
|
StrCpy $fontPackCompanionSelected "0"
|
||||||
|
StrCpy $fontPackCompanionPath "$EXEDIR\${FONT_PACK_COMPANION_FILE}"
|
||||||
|
IfFileExists "$fontPackCompanionPath" 0 font_pack_init_done
|
||||||
|
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
|
||||||
|
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_init_done
|
||||||
|
StrCpy $fontPackCompanionAvailable "1"
|
||||||
|
IfSilent font_pack_silent font_pack_interactive
|
||||||
|
font_pack_interactive:
|
||||||
|
StrCpy $fontPackCompanionSelected "1"
|
||||||
|
Goto font_pack_init_done
|
||||||
|
font_pack_silent:
|
||||||
|
${GetParameters} $R0
|
||||||
|
${GetOptions} $R0 "/WITHFONTPACK=" $R1
|
||||||
|
StrCmp $R1 "1" 0 font_pack_init_done
|
||||||
|
StrCpy $fontPackCompanionSelected "1"
|
||||||
|
font_pack_init_done:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
Function FontPackCompanionPage
|
||||||
|
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_page_skip
|
||||||
|
nsDialogs::Create 1018
|
||||||
|
Pop $R0
|
||||||
|
StrCmp $R0 "error" 0 +2
|
||||||
|
Abort
|
||||||
|
${NSD_CreateLabel} 0 0 100% 28u "检测到经过完整性校验的墨呈可选字体包。安装后,政企公文主题可使用与 PDF 相同的原生中文字体资产。"
|
||||||
|
Pop $R0
|
||||||
|
${NSD_CreateCheckbox} 0 38u 100% 14u "安装 ${FONT_PACK_COMPANION_NAME}"
|
||||||
|
Pop $fontPackCompanionCheckbox
|
||||||
|
${NSD_Check} $fontPackCompanionCheckbox
|
||||||
|
nsDialogs::Show
|
||||||
|
Return
|
||||||
|
font_pack_page_skip:
|
||||||
|
Abort
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function FontPackCompanionPageLeave
|
||||||
|
${NSD_GetState} $fontPackCompanionCheckbox $R0
|
||||||
|
StrCmp $R0 ${BST_CHECKED} 0 font_pack_page_unchecked
|
||||||
|
StrCpy $fontPackCompanionSelected "1"
|
||||||
|
Return
|
||||||
|
font_pack_page_unchecked:
|
||||||
|
StrCpy $fontPackCompanionSelected "0"
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
!macro customPageAfterChangeDir
|
||||||
|
Page custom FontPackCompanionPage FontPackCompanionPageLeave
|
||||||
|
!macroend
|
||||||
|
!endif
|
||||||
|
!endif
|
||||||
|
|
||||||
!macro customInstall
|
!macro customInstall
|
||||||
|
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
|
||||||
WriteRegStr SHCTX \
|
WriteRegStr SHCTX \
|
||||||
"Software\Classes\MarkdownPdfExporter.Markdown" \
|
"Software\Classes\MarkdownPdfExporter.Markdown" \
|
||||||
"" \
|
"" \
|
||||||
@@ -6,11 +82,11 @@
|
|||||||
WriteRegStr SHCTX \
|
WriteRegStr SHCTX \
|
||||||
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
|
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
|
||||||
"" \
|
"" \
|
||||||
'"$INSTDIR\md-to-pdf.exe",0'
|
'"$INSTDIR\${MORPHDOC_EXECUTABLE}",0'
|
||||||
WriteRegStr SHCTX \
|
WriteRegStr SHCTX \
|
||||||
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
|
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
|
||||||
"" \
|
"" \
|
||||||
'"$INSTDIR\md-to-pdf.exe" "%1"'
|
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
|
||||||
WriteRegStr SHCTX \
|
WriteRegStr SHCTX \
|
||||||
"Software\Classes\.md\OpenWithProgids" \
|
"Software\Classes\.md\OpenWithProgids" \
|
||||||
"MarkdownPdfExporter.Markdown" \
|
"MarkdownPdfExporter.Markdown" \
|
||||||
@@ -20,33 +96,57 @@
|
|||||||
"MarkdownPdfExporter.Markdown" \
|
"MarkdownPdfExporter.Markdown" \
|
||||||
""
|
""
|
||||||
WriteRegStr SHCTX \
|
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 \
|
WriteRegStr SHCTX \
|
||||||
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
|
||||||
".md" \
|
".md" \
|
||||||
""
|
""
|
||||||
WriteRegStr SHCTX \
|
WriteRegStr SHCTX \
|
||||||
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
|
||||||
".markdown" \
|
".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 \
|
CreateShortCut \
|
||||||
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
|
"$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk" \
|
||||||
"$INSTDIR\md-to-pdf.exe" \
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
|
||||||
"" \
|
"" \
|
||||||
"$INSTDIR\md-to-pdf.exe"
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||||
CreateShortCut \
|
CreateShortCut \
|
||||||
"$DESKTOP\Markdown PDF 导出器.lnk" \
|
"$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk" \
|
||||||
"$INSTDIR\md-to-pdf.exe" \
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
|
||||||
"" \
|
"" \
|
||||||
"$INSTDIR\md-to-pdf.exe"
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||||
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
|
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||||
|
!ifdef FONT_PACK_COMPANION_FILE
|
||||||
|
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done
|
||||||
|
StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done
|
||||||
|
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
|
||||||
|
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed
|
||||||
|
DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}"
|
||||||
|
ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode
|
||||||
|
StrCmp $fontPackCompanionExitCode "0" font_pack_install_done
|
||||||
|
DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode"
|
||||||
|
IfSilent font_pack_install_done 0
|
||||||
|
MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。"
|
||||||
|
Goto font_pack_install_done
|
||||||
|
font_pack_integrity_failed:
|
||||||
|
DetailPrint "可选字体包完整性复核失败,已跳过安装。"
|
||||||
|
IfSilent font_pack_install_done 0
|
||||||
|
MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。"
|
||||||
|
font_pack_install_done:
|
||||||
|
!endif
|
||||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
!macro customUnInstall
|
!macro customUnInstall
|
||||||
|
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
|
||||||
DeleteRegValue SHCTX \
|
DeleteRegValue SHCTX \
|
||||||
"Software\Classes\.md\OpenWithProgids" \
|
"Software\Classes\.md\OpenWithProgids" \
|
||||||
"MarkdownPdfExporter.Markdown"
|
"MarkdownPdfExporter.Markdown"
|
||||||
@@ -54,11 +154,16 @@
|
|||||||
"Software\Classes\.markdown\OpenWithProgids" \
|
"Software\Classes\.markdown\OpenWithProgids" \
|
||||||
"MarkdownPdfExporter.Markdown"
|
"MarkdownPdfExporter.Markdown"
|
||||||
DeleteRegKey SHCTX \
|
DeleteRegKey SHCTX \
|
||||||
"Software\Classes\Applications\md-to-pdf.exe"
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}"
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
|
||||||
DeleteRegKey SHCTX \
|
DeleteRegKey SHCTX \
|
||||||
"Software\Classes\MarkdownPdfExporter.Markdown"
|
"Software\Classes\MarkdownPdfExporter.Markdown"
|
||||||
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
|
Delete "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk"
|
||||||
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
|
RMDir "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
|
||||||
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
|
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)'
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
!macroend
|
!macroend
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { version } = require("./package.json");
|
const { version } = require("./package.json");
|
||||||
|
const brand = require("../../product.json");
|
||||||
|
const pandocRuntime = require(
|
||||||
|
"../../packages/docx-engine/src/pandoc-runtime.json"
|
||||||
|
);
|
||||||
|
|
||||||
const windowsIcon = path.resolve(
|
const windowsIcon = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"../../logos/desktop/windows/app.ico"
|
"../../logos/desktop/windows/app.ico"
|
||||||
);
|
);
|
||||||
|
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
|
||||||
|
"build/installer.nsh";
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
appId: "com.md-to-pdf.desktop",
|
appId: brand.appId,
|
||||||
productName: "Markdown PDF 导出器",
|
productName: brand.englishName,
|
||||||
copyright: "Copyright © YIXIONG Tech.ltd",
|
copyright: "Copyright © YIXIONG Tech.ltd",
|
||||||
asar: true,
|
asar: true,
|
||||||
electronLanguages: ["zh-CN", "en-US"],
|
electronLanguages: ["zh-CN", "en-US"],
|
||||||
@@ -30,6 +36,21 @@ module.exports = {
|
|||||||
to: "themes",
|
to: "themes",
|
||||||
filter: ["**/*"]
|
filter: ["**/*"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
from: "../../samples",
|
||||||
|
to: "samples",
|
||||||
|
filter: ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||||
|
to: `pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||||
|
filter: [
|
||||||
|
"pandoc.exe",
|
||||||
|
"COPYING.rtf",
|
||||||
|
"COPYRIGHT.txt",
|
||||||
|
"runtime-manifest.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from: windowsIcon,
|
from: windowsIcon,
|
||||||
to: "app.ico"
|
to: "app.ico"
|
||||||
@@ -51,8 +72,8 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
icon: windowsIcon,
|
icon: windowsIcon,
|
||||||
executableName: "md-to-pdf",
|
executableName: brand.executableName,
|
||||||
artifactName: `md-to-pdf-${version}-x86_64.\${ext}`
|
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`
|
||||||
},
|
},
|
||||||
nsis: {
|
nsis: {
|
||||||
oneClick: false,
|
oneClick: false,
|
||||||
@@ -61,12 +82,12 @@ module.exports = {
|
|||||||
allowElevation: true,
|
allowElevation: true,
|
||||||
createDesktopShortcut: false,
|
createDesktopShortcut: false,
|
||||||
createStartMenuShortcut: false,
|
createStartMenuShortcut: false,
|
||||||
shortcutName: "Markdown PDF 导出器",
|
shortcutName: brand.displayName,
|
||||||
uninstallDisplayName: "Markdown PDF 导出器",
|
uninstallDisplayName: brand.displayName,
|
||||||
installerIcon: windowsIcon,
|
installerIcon: windowsIcon,
|
||||||
uninstallerIcon: windowsIcon,
|
uninstallerIcon: windowsIcon,
|
||||||
installerLanguages: ["zh_CN"],
|
installerLanguages: ["zh_CN"],
|
||||||
include: "build/installer.nsh",
|
include: nsisInclude,
|
||||||
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
|
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"productName": "Markdown PDF 导出器",
|
"productName": "MorphDoc",
|
||||||
"description": "Markdown PDF 导出器桌面端",
|
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||||
"author": "YIXIONG Tech.ltd",
|
"author": "YIXIONG Tech.ltd",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||||
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||||
|
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
|
||||||
|
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
|
||||||
"build:main": "node scripts/build-main.mjs",
|
"build:main": "node scripts/build-main.mjs",
|
||||||
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||||
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
"package": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||||
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
"make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||||
|
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"electron": "43.2.0",
|
"electron": "43.2.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10",
|
||||||
"wait-on": "^9.0.1"
|
"wait-on": "^9.0.1"
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { build } from "esbuild";
|
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({
|
await build({
|
||||||
entryPoints: ["src/main.ts"],
|
entryPoints: ["src/main.ts"],
|
||||||
@@ -7,10 +16,34 @@ await build({
|
|||||||
format: "esm",
|
format: "esm",
|
||||||
external: ["electron"],
|
external: ["electron"],
|
||||||
outfile: "dist/main.js",
|
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: {
|
banner: {
|
||||||
js: [
|
js: [
|
||||||
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
|
'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")
|
].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();
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
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 type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_CREATE_NEW_WINDOW,
|
||||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
DESKTOP_GET_THEME_CSS,
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
DESKTOP_GET_DOCX_CAPABILITY,
|
||||||
|
DESKTOP_EXPORT_DOCX,
|
||||||
DESKTOP_GENERATE_PDF,
|
DESKTOP_GENERATE_PDF,
|
||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
DESKTOP_MARKDOWN_OPENED,
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
@@ -13,9 +19,12 @@ import {
|
|||||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
DESKTOP_REFRESH_THEMES,
|
DESKTOP_REFRESH_THEMES,
|
||||||
DESKTOP_RENDER_MARKDOWN,
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_RESOLVE_WINDOW_CLOSE,
|
||||||
DESKTOP_SAVE_MARKDOWN,
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN_AS,
|
||||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
DESKTOP_START_NEW_MARKDOWN,
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||||
DESKTOP_SAVE_PDF
|
DESKTOP_SAVE_PDF
|
||||||
} from "./channels.js";
|
} from "./channels.js";
|
||||||
|
|
||||||
@@ -38,12 +47,30 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
|||||||
},
|
},
|
||||||
startNewMarkdown: () =>
|
startNewMarkdown: () =>
|
||||||
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
||||||
|
createNewWindow: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_CREATE_NEW_WINDOW),
|
||||||
|
resolveWindowClose: (close: boolean) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_RESOLVE_WINDOW_CLOSE, close),
|
||||||
|
onWindowCloseRequested: (listener: () => void) => {
|
||||||
|
const handleCloseRequested = () => listener();
|
||||||
|
ipcRenderer.on(
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||||
|
handleCloseRequested
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||||
|
handleCloseRequested
|
||||||
|
);
|
||||||
|
},
|
||||||
setDocumentDirty: (dirty: boolean) =>
|
setDocumentDirty: (dirty: boolean) =>
|
||||||
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
|
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
|
||||||
openDocumentLink: (href: string) =>
|
openDocumentLink: (href: string) =>
|
||||||
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
|
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
|
||||||
saveMarkdown: (fileName: string, markdown: string) =>
|
saveMarkdown: (fileName: string, markdown: string) =>
|
||||||
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
||||||
|
saveMarkdownAs: (fileName: string, markdown: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
|
||||||
openThemeDirectory: () =>
|
openThemeDirectory: () =>
|
||||||
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
|
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
|
||||||
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
|
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
|
||||||
@@ -55,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
|||||||
generatePdf: (payload: PagedDocumentPayload) =>
|
generatePdf: (payload: PagedDocumentPayload) =>
|
||||||
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
||||||
savePdf: (fileName: string, pdf: Uint8Array) =>
|
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)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,3 +52,33 @@ export function parseThemeResourceUrl(requestUrl: string) {
|
|||||||
assetPath: assetSegments.join("/")
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+5
@@ -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;
|
||||||
@@ -14,8 +14,16 @@ export const DESKTOP_OPEN_DOCUMENT_LINK =
|
|||||||
"md-to-pdf:desktop:open-document-link";
|
"md-to-pdf:desktop:open-document-link";
|
||||||
export const DESKTOP_START_NEW_MARKDOWN =
|
export const DESKTOP_START_NEW_MARKDOWN =
|
||||||
"md-to-pdf:desktop:start-new-markdown";
|
"md-to-pdf:desktop:start-new-markdown";
|
||||||
|
export const DESKTOP_CREATE_NEW_WINDOW =
|
||||||
|
"md-to-pdf:desktop:create-new-window";
|
||||||
|
export const DESKTOP_WINDOW_CLOSE_REQUESTED =
|
||||||
|
"md-to-pdf:desktop:window-close-requested";
|
||||||
|
export const DESKTOP_RESOLVE_WINDOW_CLOSE =
|
||||||
|
"md-to-pdf:desktop:resolve-window-close";
|
||||||
export const DESKTOP_SAVE_MARKDOWN =
|
export const DESKTOP_SAVE_MARKDOWN =
|
||||||
"md-to-pdf:desktop:save-markdown";
|
"md-to-pdf:desktop:save-markdown";
|
||||||
|
export const DESKTOP_SAVE_MARKDOWN_AS =
|
||||||
|
"md-to-pdf:desktop:save-markdown-as";
|
||||||
export const DESKTOP_OPEN_THEME_DIRECTORY =
|
export const DESKTOP_OPEN_THEME_DIRECTORY =
|
||||||
"md-to-pdf:desktop:open-theme-directory";
|
"md-to-pdf:desktop:open-theme-directory";
|
||||||
export const DESKTOP_REFRESH_THEMES =
|
export const DESKTOP_REFRESH_THEMES =
|
||||||
@@ -28,6 +36,10 @@ export const DESKTOP_GENERATE_PDF =
|
|||||||
"md-to-pdf:desktop:generate-pdf";
|
"md-to-pdf:desktop:generate-pdf";
|
||||||
export const DESKTOP_SAVE_PDF =
|
export const DESKTOP_SAVE_PDF =
|
||||||
"md-to-pdf: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 =
|
export const PDF_RUNTIME_READY =
|
||||||
"md-to-pdf:desktop-pdf:ready";
|
"md-to-pdf:desktop-pdf:ready";
|
||||||
export const PDF_RUNTIME_RENDER =
|
export const PDF_RUNTIME_RENDER =
|
||||||
|
|||||||
@@ -16,13 +16,22 @@ import {
|
|||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
DocxExportService,
|
||||||
|
DocxThemeTokenService,
|
||||||
MAXIMUM_MARKDOWN_LENGTH,
|
MAXIMUM_MARKDOWN_LENGTH,
|
||||||
|
readDocxExportRuntimeLimits,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
PandocDocxConverter,
|
||||||
|
PandocRuntime
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
import {
|
import {
|
||||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_CREATE_NEW_WINDOW,
|
||||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
DESKTOP_GENERATE_PDF,
|
DESKTOP_GENERATE_PDF,
|
||||||
|
DESKTOP_GET_DOCX_CAPABILITY,
|
||||||
DESKTOP_GET_THEME_CSS,
|
DESKTOP_GET_THEME_CSS,
|
||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
DESKTOP_MARKDOWN_OPENED,
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
@@ -31,11 +40,21 @@ import {
|
|||||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
DESKTOP_REFRESH_THEMES,
|
DESKTOP_REFRESH_THEMES,
|
||||||
DESKTOP_RENDER_MARKDOWN,
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_RESOLVE_WINDOW_CLOSE,
|
||||||
DESKTOP_SAVE_MARKDOWN,
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN_AS,
|
||||||
DESKTOP_SAVE_PDF,
|
DESKTOP_SAVE_PDF,
|
||||||
|
DESKTOP_EXPORT_DOCX,
|
||||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
DESKTOP_START_NEW_MARKDOWN
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED
|
||||||
} from "./channels.js";
|
} 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 {
|
import {
|
||||||
parseMarkdownRenderRequest,
|
parseMarkdownRenderRequest,
|
||||||
parseThemeId
|
parseThemeId
|
||||||
@@ -55,6 +74,8 @@ import {
|
|||||||
saveWindowState,
|
saveWindowState,
|
||||||
type WindowState
|
type WindowState
|
||||||
} from "./window-state.js";
|
} 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 DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||||
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||||
@@ -72,6 +93,7 @@ interface WindowSession {
|
|||||||
window: BrowserWindow;
|
window: BrowserWindow;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
allowClose: boolean;
|
allowClose: boolean;
|
||||||
|
closeRequestPending: boolean;
|
||||||
currentFilePath: string | undefined;
|
currentFilePath: string | undefined;
|
||||||
documentKey: string | undefined;
|
documentKey: string | undefined;
|
||||||
documentRoot: string | undefined;
|
documentRoot: string | undefined;
|
||||||
@@ -87,9 +109,13 @@ export interface DesktopApplicationControllerOptions {
|
|||||||
renderUrl: string;
|
renderUrl: string;
|
||||||
preloadPath: string;
|
preloadPath: string;
|
||||||
pdfPreloadPath: string;
|
pdfPreloadPath: string;
|
||||||
|
docxRenderUrl: string;
|
||||||
|
desktopResourcesPath: string;
|
||||||
iconPath: string;
|
iconPath: string;
|
||||||
themeDirectory: string;
|
themeDirectory: string;
|
||||||
windowStatePath: string;
|
windowStatePath: string;
|
||||||
|
saveLocationStatePath: string;
|
||||||
|
documentsDirectory: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusWindow(window: BrowserWindow) {
|
function focusWindow(window: BrowserWindow) {
|
||||||
@@ -111,7 +137,10 @@ export class DesktopApplicationController {
|
|||||||
readonly #iconPath: string;
|
readonly #iconPath: string;
|
||||||
readonly #themeDirectory: string;
|
readonly #themeDirectory: string;
|
||||||
readonly #windowStatePath: string;
|
readonly #windowStatePath: string;
|
||||||
|
readonly #saveLocationStore: SaveLocationStore;
|
||||||
readonly #pdfGenerator: ElectronPdfGenerator;
|
readonly #pdfGenerator: ElectronPdfGenerator;
|
||||||
|
readonly #docxExportService: DocxExportService;
|
||||||
|
readonly #docxMediaEngine: ElectronDocxMediaEngine;
|
||||||
readonly #sessions = new Map<number, WindowSession>();
|
readonly #sessions = new Map<number, WindowSession>();
|
||||||
readonly #documentSessions = new Map<string, WindowSession>();
|
readonly #documentSessions = new Map<string, WindowSession>();
|
||||||
#primarySession: WindowSession | undefined;
|
#primarySession: WindowSession | undefined;
|
||||||
@@ -128,19 +157,54 @@ export class DesktopApplicationController {
|
|||||||
this.#iconPath = options.iconPath;
|
this.#iconPath = options.iconPath;
|
||||||
this.#themeDirectory = options.themeDirectory;
|
this.#themeDirectory = options.themeDirectory;
|
||||||
this.#windowStatePath = options.windowStatePath;
|
this.#windowStatePath = options.windowStatePath;
|
||||||
|
this.#saveLocationStore = new SaveLocationStore(
|
||||||
|
options.saveLocationStatePath,
|
||||||
|
options.documentsDirectory
|
||||||
|
);
|
||||||
this.#pdfGenerator = new ElectronPdfGenerator({
|
this.#pdfGenerator = new ElectronPdfGenerator({
|
||||||
renderUrl: options.renderUrl,
|
renderUrl: options.renderUrl,
|
||||||
preloadPath: options.pdfPreloadPath
|
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() {
|
async initialize() {
|
||||||
this.#savedWindowState = await loadWindowState(
|
const [savedWindowState] = await Promise.all([
|
||||||
this.#windowStatePath
|
loadWindowState(this.#windowStatePath),
|
||||||
);
|
this.#saveLocationStore.initialize()
|
||||||
|
]);
|
||||||
|
this.#savedWindowState = savedWindowState;
|
||||||
this.#registerIpcHandlers();
|
this.#registerIpcHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #recordSuccessfulFile(filePath: string) {
|
||||||
|
await this.#saveLocationStore
|
||||||
|
.recordSuccessfulFile(filePath)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn("无法持久化最近保存目录", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createWindow(filePath?: string) {
|
async createWindow(filePath?: string) {
|
||||||
let pendingSnapshot: MarkdownFileSnapshot | undefined;
|
let pendingSnapshot: MarkdownFileSnapshot | undefined;
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
@@ -159,14 +223,17 @@ export class DesktopApplicationController {
|
|||||||
return this.#createWindow(pendingSnapshot);
|
return this.#createWindow(pendingSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) {
|
async #createWindow(
|
||||||
|
pendingSnapshot?: MarkdownFileSnapshot,
|
||||||
|
startBlank = false
|
||||||
|
) {
|
||||||
const isPrimary = !this.#primarySession;
|
const isPrimary = !this.#primarySession;
|
||||||
const restoredWindowState = isPrimary
|
const restoredWindowState = isPrimary
|
||||||
? this.#latestWindowState ?? this.#savedWindowState
|
? this.#latestWindowState ?? this.#savedWindowState
|
||||||
: undefined;
|
: undefined;
|
||||||
const initialBounds = this.#resolveInitialBounds(isPrimary);
|
const initialBounds = this.#resolveInitialBounds(isPrimary);
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
title: `${__APP_WINDOW_TITLE__} v${app.getVersion()}`,
|
||||||
icon: this.#iconPath,
|
icon: this.#iconPath,
|
||||||
...initialBounds,
|
...initialBounds,
|
||||||
minWidth: Math.min(
|
minWidth: Math.min(
|
||||||
@@ -190,6 +257,7 @@ export class DesktopApplicationController {
|
|||||||
window,
|
window,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
allowClose: false,
|
allowClose: false,
|
||||||
|
closeRequestPending: false,
|
||||||
currentFilePath: undefined,
|
currentFilePath: undefined,
|
||||||
documentKey: undefined,
|
documentKey: undefined,
|
||||||
documentRoot: undefined,
|
documentRoot: undefined,
|
||||||
@@ -219,7 +287,11 @@ export class DesktopApplicationController {
|
|||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
});
|
});
|
||||||
await window.loadURL(this.#applicationUrl);
|
const targetUrl = new URL(this.#applicationUrl);
|
||||||
|
if (startBlank) {
|
||||||
|
targetUrl.searchParams.set("new", "1");
|
||||||
|
}
|
||||||
|
await window.loadURL(targetUrl.href);
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +337,11 @@ export class DesktopApplicationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async closeResources() {
|
async closeResources() {
|
||||||
await this.#pdfGenerator.close();
|
await Promise.all([
|
||||||
|
this.#pdfGenerator.close(),
|
||||||
|
this.#docxExportService.close()
|
||||||
|
]);
|
||||||
|
await this.#docxMediaEngine.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
#resolveInitialBounds(isPrimary: boolean): Rectangle {
|
#resolveInitialBounds(isPrimary: boolean): Rectangle {
|
||||||
@@ -325,11 +401,17 @@ export class DesktopApplicationController {
|
|||||||
void this.#checkForExternalChange(session);
|
void this.#checkForExternalChange(session);
|
||||||
});
|
});
|
||||||
window.on("close", (event) => {
|
window.on("close", (event) => {
|
||||||
if (!session.dirty || session.allowClose) {
|
const decision = getWindowCloseDecision(session);
|
||||||
|
if (decision === "allow") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void this.#confirmDirtyWindowClose(session);
|
if (decision === "request") {
|
||||||
|
session.closeRequestPending = true;
|
||||||
|
session.window.webContents.send(
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
const wasPrimary = this.#primarySession === session;
|
const wasPrimary = this.#primarySession === session;
|
||||||
@@ -358,28 +440,6 @@ export class DesktopApplicationController {
|
|||||||
this.#captureWindowState();
|
this.#captureWindowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async #confirmDirtyWindowClose(session: WindowSession) {
|
|
||||||
if (session.window.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await dialog.showMessageBox(session.window, {
|
|
||||||
type: "warning",
|
|
||||||
title: "关闭未保存文档",
|
|
||||||
message: "当前文档有未保存修改,确定要关闭窗口吗?",
|
|
||||||
detail: "关闭后,尚未保存的修改将会丢失。",
|
|
||||||
buttons: ["取消", "仍然关闭"],
|
|
||||||
defaultId: 0,
|
|
||||||
cancelId: 0,
|
|
||||||
noLink: true
|
|
||||||
});
|
|
||||||
session.suppressFocusCheckUntil =
|
|
||||||
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
|
|
||||||
if (result.response === 1 && !session.window.isDestroyed()) {
|
|
||||||
session.allowClose = true;
|
|
||||||
session.window.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #checkForExternalChange(session: WindowSession) {
|
async #checkForExternalChange(session: WindowSession) {
|
||||||
if (
|
if (
|
||||||
session.focusCheckRunning ||
|
session.focusCheckRunning ||
|
||||||
@@ -630,6 +690,28 @@ export class DesktopApplicationController {
|
|||||||
this.#clearDocument(this.#getSession(event));
|
this.#clearDocument(this.#getSession(event));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_CREATE_NEW_WINDOW, async (event) => {
|
||||||
|
this.#getSession(event);
|
||||||
|
await this.#createWindow(undefined, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_RESOLVE_WINDOW_CLOSE,
|
||||||
|
async (event, unsafeClose: unknown) => {
|
||||||
|
if (typeof unsafeClose !== "boolean") {
|
||||||
|
throw new Error("窗口关闭决策无效");
|
||||||
|
}
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
session.closeRequestPending = false;
|
||||||
|
session.suppressFocusCheckUntil =
|
||||||
|
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
|
||||||
|
if (unsafeClose && !session.window.isDestroyed()) {
|
||||||
|
session.allowClose = true;
|
||||||
|
session.window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
DESKTOP_SET_DOCUMENT_DIRTY,
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
async (event, unsafeDirty: unknown) => {
|
async (event, unsafeDirty: unknown) => {
|
||||||
@@ -640,13 +722,12 @@ export class DesktopApplicationController {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
const saveMarkdown = async (
|
||||||
DESKTOP_SAVE_MARKDOWN,
|
event: IpcMainInvokeEvent,
|
||||||
async (
|
unsafeFileName: unknown,
|
||||||
event,
|
unsafeMarkdown: unknown,
|
||||||
unsafeFileName: unknown,
|
forceSaveAs: boolean
|
||||||
unsafeMarkdown: unknown
|
) => {
|
||||||
) => {
|
|
||||||
const session = this.#getSession(event);
|
const session = this.#getSession(event);
|
||||||
if (
|
if (
|
||||||
typeof unsafeFileName !== "string" ||
|
typeof unsafeFileName !== "string" ||
|
||||||
@@ -659,18 +740,21 @@ export class DesktopApplicationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPath = session.currentFilePath;
|
let targetPath = session.currentFilePath;
|
||||||
if (!targetPath) {
|
if (forceSaveAs || !targetPath) {
|
||||||
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
||||||
path.basename(unsafeFileName)
|
path.basename(unsafeFileName)
|
||||||
)
|
)
|
||||||
? path.basename(unsafeFileName)
|
? path.basename(unsafeFileName)
|
||||||
: `${path.basename(unsafeFileName)}.md`;
|
: `${path.basename(unsafeFileName)}.md`;
|
||||||
const selection = await dialog.showSaveDialog(session.window, {
|
const selection = await dialog.showSaveDialog(session.window, {
|
||||||
title: "保存 Markdown",
|
title: forceSaveAs
|
||||||
defaultPath: path.join(
|
? "另存为 Markdown"
|
||||||
app.getPath("documents"),
|
: "保存 Markdown",
|
||||||
suggestedName
|
defaultPath:
|
||||||
),
|
await this.#saveLocationStore.resolveDefaultPath(
|
||||||
|
session.currentFilePath,
|
||||||
|
suggestedName
|
||||||
|
),
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: "Markdown 文件",
|
name: "Markdown 文件",
|
||||||
@@ -723,8 +807,29 @@ export class DesktopApplicationController {
|
|||||||
throw new Error("该 Markdown 已在另一个窗口中打开");
|
throw new Error("该 Markdown 已在另一个窗口中打开");
|
||||||
}
|
}
|
||||||
this.#commitSnapshot(session, snapshot);
|
this.#commitSnapshot(session, snapshot);
|
||||||
|
await this.#recordSuccessfulFile(targetPath);
|
||||||
return { fileName: snapshot.document.fileName };
|
return { fileName: snapshot.document.fileName };
|
||||||
}
|
};
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
|
||||||
|
saveMarkdown(
|
||||||
|
event,
|
||||||
|
unsafeFileName,
|
||||||
|
unsafeMarkdown,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_MARKDOWN_AS,
|
||||||
|
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
|
||||||
|
saveMarkdown(
|
||||||
|
event,
|
||||||
|
unsafeFileName,
|
||||||
|
unsafeMarkdown,
|
||||||
|
true
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
|
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
|
||||||
@@ -820,19 +925,115 @@ export class DesktopApplicationController {
|
|||||||
: `${path.basename(unsafeFileName)}.pdf`;
|
: `${path.basename(unsafeFileName)}.pdf`;
|
||||||
const selection = await dialog.showSaveDialog(session.window, {
|
const selection = await dialog.showSaveDialog(session.window, {
|
||||||
title: "导出 PDF",
|
title: "导出 PDF",
|
||||||
defaultPath: path.join(
|
defaultPath:
|
||||||
app.getPath("documents"),
|
await this.#saveLocationStore.resolveDefaultPath(
|
||||||
suggestedName
|
session.currentFilePath,
|
||||||
),
|
suggestedName
|
||||||
|
),
|
||||||
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
|
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
|
||||||
});
|
});
|
||||||
if (selection.canceled || !selection.filePath) {
|
if (selection.canceled || !selection.filePath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await writeFile(selection.filePath, unsafePdf);
|
await writeFile(selection.filePath, unsafePdf);
|
||||||
|
await this.#recordSuccessfulFile(selection.filePath);
|
||||||
return true;
|
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(
|
async #openDocumentLink(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,138 @@
|
|||||||
|
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 plan = (await withTimeout(
|
||||||
|
webContents.executeJavaScript(createRenderScript(request), true),
|
||||||
|
timeouts.renderMs,
|
||||||
|
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||||
|
signal
|
||||||
|
)) as DocxMediaCapturePlan;
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const ownsDebugger = !webContents.debugger.isAttached();
|
||||||
|
if (ownsDebugger) {
|
||||||
|
webContents.debugger.attach("1.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 (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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-13
@@ -6,25 +6,32 @@ import {
|
|||||||
session
|
session
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import { access, mkdir } from "node:fs/promises";
|
import { access, mkdir } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
createApplicationService,
|
createApplicationService,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
import { parseThemeResourceUrl } from "./application-contract.js";
|
import {
|
||||||
|
parseFontPackResourceUrl,
|
||||||
|
parseThemeResourceUrl
|
||||||
|
} from "./application-contract.js";
|
||||||
import {
|
import {
|
||||||
DesktopApplicationController
|
DesktopApplicationController
|
||||||
} from "./desktop-application-controller.js";
|
} from "./desktop-application-controller.js";
|
||||||
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||||
|
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
findMarkdownFileArgument,
|
findMarkdownFileArgument,
|
||||||
isMarkdownFilePath
|
isMarkdownFilePath
|
||||||
} from "./markdown-file.js";
|
} from "./markdown-file.js";
|
||||||
|
import { migrateLegacyUserData } from "./user-data-migration.js";
|
||||||
|
|
||||||
const APP_SCHEME = "mdpdf";
|
const APP_SCHEME = "mdpdf";
|
||||||
const APP_HOST = "bundle";
|
const APP_HOST = "bundle";
|
||||||
const THEME_HOST = "theme";
|
const THEME_HOST = "theme";
|
||||||
|
const FONT_PACK_HOST = "font-pack";
|
||||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
let applicationController: DesktopApplicationController | undefined;
|
let applicationController: DesktopApplicationController | undefined;
|
||||||
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||||
@@ -32,6 +39,28 @@ let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
|||||||
process.cwd()
|
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([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
scheme: APP_SCHEME,
|
scheme: APP_SCHEME,
|
||||||
@@ -101,6 +130,21 @@ function getDesktopThemeDirectory() {
|
|||||||
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDesktopFontPackDirectory() {
|
||||||
|
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() {
|
function createDesktopApplicationService() {
|
||||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||||
return createApplicationService({
|
return createApplicationService({
|
||||||
@@ -110,6 +154,22 @@ function createDesktopApplicationService() {
|
|||||||
localRoot: getDesktopThemeDirectory(),
|
localRoot: getDesktopThemeDirectory(),
|
||||||
createAssetUrl: (themeId, assetPath) =>
|
createAssetUrl: (themeId, assetPath) =>
|
||||||
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
||||||
|
fontPacks: {
|
||||||
|
roots: [getDesktopFontPackDirectory()],
|
||||||
|
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)
|
onWarning: (message) => console.warn(message)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,6 +214,34 @@ async function registerApplicationProtocol(
|
|||||||
return new Response("Not found", { status: 404 });
|
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) {
|
if (url.host !== APP_HOST) {
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -182,6 +270,9 @@ async function registerApplicationProtocol(
|
|||||||
protocol.handle(APP_SCHEME, handleRequest),
|
protocol.handle(APP_SCHEME, handleRequest),
|
||||||
session
|
session
|
||||||
.fromPartition(DESKTOP_PDF_PARTITION)
|
.fromPartition(DESKTOP_PDF_PARTITION)
|
||||||
|
.protocol.handle(APP_SCHEME, handleRequest),
|
||||||
|
session
|
||||||
|
.fromPartition(DESKTOP_DOCX_PARTITION)
|
||||||
.protocol.handle(APP_SCHEME, handleRequest)
|
.protocol.handle(APP_SCHEME, handleRequest)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -219,23 +310,24 @@ if (!hasSingleInstanceLock) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void applicationController.flushWindowState().finally(() => {
|
void Promise.all([
|
||||||
windowStateReadyToQuit = true;
|
applicationController.flushWindowState(),
|
||||||
app.quit();
|
applicationController.closeResources()
|
||||||
setTimeout(() => {
|
]).finally(() => {
|
||||||
windowStateReadyToQuit = false;
|
windowStateReadyToQuit = true;
|
||||||
}, 1_000);
|
app.quit();
|
||||||
});
|
setTimeout(() => {
|
||||||
});
|
windowStateReadyToQuit = false;
|
||||||
app.on("will-quit", () => {
|
}, 1_000);
|
||||||
void applicationController?.closeResources();
|
});
|
||||||
});
|
});
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
await userDataMigration;
|
||||||
Menu.setApplicationMenu(null);
|
Menu.setApplicationMenu(null);
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
app.setAppUserModelId(__APP_ID__);
|
||||||
}
|
}
|
||||||
const themeDirectory = getDesktopThemeDirectory();
|
const themeDirectory = getDesktopThemeDirectory();
|
||||||
await mkdir(themeDirectory, { recursive: true });
|
await mkdir(themeDirectory, { recursive: true });
|
||||||
@@ -260,12 +352,22 @@ if (!hasSingleInstanceLock) {
|
|||||||
currentDirectory,
|
currentDirectory,
|
||||||
"pdf-preload.cjs"
|
"pdf-preload.cjs"
|
||||||
),
|
),
|
||||||
|
docxRenderUrl: new URL(
|
||||||
|
"/preview-frame.html?target=continuous",
|
||||||
|
applicationUrl
|
||||||
|
).href,
|
||||||
|
desktopResourcesPath: process.resourcesPath,
|
||||||
iconPath: getApplicationIcon(),
|
iconPath: getApplicationIcon(),
|
||||||
themeDirectory,
|
themeDirectory,
|
||||||
windowStatePath: path.join(
|
windowStatePath: path.join(
|
||||||
app.getPath("userData"),
|
app.getPath("userData"),
|
||||||
"window-state.json"
|
"window-state.json"
|
||||||
)
|
),
|
||||||
|
saveLocationStatePath: path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"save-location.json"
|
||||||
|
),
|
||||||
|
documentsDirectory: app.getPath("documents")
|
||||||
});
|
});
|
||||||
await applicationController.initialize();
|
await applicationController.initialize();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
exportConfigSchema,
|
exportConfigSchema,
|
||||||
|
semanticDocumentModelSchema,
|
||||||
themeFeatureSchema,
|
themeFeatureSchema,
|
||||||
type PagedDocumentPayload,
|
type PagedDocumentPayload,
|
||||||
type PagedDocumentRenderResult
|
type PagedDocumentRenderResult
|
||||||
@@ -66,6 +67,9 @@ export function parsePagedDocumentPayload(
|
|||||||
),
|
),
|
||||||
language: readString(metadata, "language", 50)
|
language: readString(metadata, "language", 50)
|
||||||
},
|
},
|
||||||
|
semanticDocument: semanticDocumentModelSchema.parse(
|
||||||
|
value.semanticDocument
|
||||||
|
),
|
||||||
features: parsedFeatures,
|
features: parsedFeatures,
|
||||||
themeCss: readString(
|
themeCss: readString(
|
||||||
value,
|
value,
|
||||||
@@ -117,7 +121,10 @@ export function isAllowedPdfRuntimeUrl(
|
|||||||
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
|
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
|
if (
|
||||||
|
requested.protocol === "mdpdf:" &&
|
||||||
|
["theme", "font-pack"].includes(requested.host)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return requested.origin === new URL(renderUrl).origin;
|
return requested.origin === new URL(renderUrl).origin;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
|
export interface WindowCloseState {
|
||||||
|
dirty: boolean;
|
||||||
|
allowClose: boolean;
|
||||||
|
closeRequestPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WindowCloseDecision = "allow" | "request" | "wait";
|
||||||
|
|
||||||
|
export function getWindowCloseDecision(
|
||||||
|
state: WindowCloseState
|
||||||
|
): WindowCloseDecision {
|
||||||
|
if (!state.dirty || state.allowClose) {
|
||||||
|
return "allow";
|
||||||
|
}
|
||||||
|
return state.closeRequestPending ? "wait" : "request";
|
||||||
|
}
|
||||||
@@ -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 保存参数无效");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
message:
|
||||||
|
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 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,155 @@
|
|||||||
|
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(
|
||||||
|
"Page.captureScreenshot",
|
||||||
|
expect.objectContaining({
|
||||||
|
format: "png",
|
||||||
|
clip: expect.objectContaining({ scale: 3.125 })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||||
|
const webContents = {
|
||||||
|
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||||
|
debugger: {
|
||||||
|
isAttached: vi.fn(() => false)
|
||||||
|
}
|
||||||
|
} 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(() => new Promise(() => undefined))
|
||||||
|
}
|
||||||
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents,
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
{ renderMs: 10, screenshotMs: 10 }
|
||||||
|
)
|
||||||
|
).rejects.toThrow(
|
||||||
|
"桌面 DOCX 第 1/1 个媒体(image)PNG 捕获超过 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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../src/pdf-contract.js";
|
} from "../src/pdf-contract.js";
|
||||||
import {
|
import {
|
||||||
parseMarkdownRenderRequest,
|
parseMarkdownRenderRequest,
|
||||||
|
parseFontPackResourceUrl,
|
||||||
parseThemeId,
|
parseThemeId,
|
||||||
parseThemeResourceUrl
|
parseThemeResourceUrl
|
||||||
} from "../src/application-contract.js";
|
} from "../src/application-contract.js";
|
||||||
@@ -26,6 +27,14 @@ const payload = createPagedDocumentPayload({
|
|||||||
keywords: [],
|
keywords: [],
|
||||||
language: "zh-CN"
|
language: "zh-CN"
|
||||||
},
|
},
|
||||||
|
semanticDocument: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
titlePolicy: {
|
||||||
|
metadataTitle: "suppress",
|
||||||
|
firstBodyHeading: "keep"
|
||||||
|
},
|
||||||
|
regions: []
|
||||||
|
},
|
||||||
features: ["mermaid"],
|
features: ["mermaid"],
|
||||||
warnings: []
|
warnings: []
|
||||||
},
|
},
|
||||||
@@ -97,6 +106,12 @@ describe("桌面 PDF 网络边界", () => {
|
|||||||
renderUrl
|
renderUrl
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAllowedPdfRuntimeUrl(
|
||||||
|
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
|
||||||
|
renderUrl
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("拒绝外部网络资源", () => {
|
it("拒绝外部网络资源", () => {
|
||||||
@@ -135,4 +150,22 @@ describe("桌面应用服务 IPC 载荷", () => {
|
|||||||
parseThemeResourceUrl("mdpdf://bundle/index.html")
|
parseThemeResourceUrl("mdpdf://bundle/index.html")
|
||||||
).toBeUndefined();
|
).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("字体包资源地址无效");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,36 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
interface PackageManifest {
|
interface PackageManifest {
|
||||||
|
version?: string;
|
||||||
|
productName?: string;
|
||||||
scripts?: Record<string, 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 desktopRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||||
|
|
||||||
function readManifest(path: string): PackageManifest {
|
function readManifest<T = PackageManifest>(path: string): T {
|
||||||
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
|
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("桌面发行构建链", () => {
|
describe("桌面发行构建链", () => {
|
||||||
@@ -43,8 +65,194 @@ describe("桌面发行构建链", () => {
|
|||||||
expect(desktopManifest.scripts?.make).toMatch(
|
expect(desktopManifest.scripts?.make).toMatch(
|
||||||
/^npm run build:embedded-web && /
|
/^npm run build:embedded-web && /
|
||||||
);
|
);
|
||||||
|
expect(desktopManifest.scripts?.package).toContain(
|
||||||
|
"npm run prepare:pandoc"
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.make).toContain(
|
||||||
|
"npm run prepare:pandoc"
|
||||||
|
);
|
||||||
expect(rootManifest.scripts?.["desktop:package"]).toBe(
|
expect(rootManifest.scripts?.["desktop:package"]).toBe(
|
||||||
"npm run package -w @md-to-pdf/desktop"
|
"npm run package -w @md-to-pdf/desktop"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("将内置主题示例作为独立资源复制到发行目录", () => {
|
||||||
|
const builderConfig = readFileSync(
|
||||||
|
`${desktopRoot}/electron-builder.config.cjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(builderConfig).toContain('from: "../../samples"');
|
||||||
|
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.0",
|
||||||
|
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 installerInclude = readFileSync(
|
||||||
|
`${desktopRoot}/build/installer.nsh`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const companionBuild = readFileSync(
|
||||||
|
`${projectRoot}/scripts/build-desktop-with-font-pack.mjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(builderConfig).toContain("MD_TO_PDF_NSIS_INCLUDE");
|
||||||
|
expect(installerInclude).toContain("FONT_PACK_COMPANION_SHA256");
|
||||||
|
expect(installerInclude).toContain("StdUtils.HashFile");
|
||||||
|
expect(installerInclude).toContain("/WITHFONTPACK=");
|
||||||
|
expect(companionBuild).toContain("createDesktopCompanionInclude");
|
||||||
|
expect(companionBuild).toContain("MD_TO_PDF_NSIS_INCLUDE");
|
||||||
|
expect(companionBuild).toContain(
|
||||||
|
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
|
||||||
|
);
|
||||||
|
expect(companionBuild).toContain(
|
||||||
|
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const samplesBuildCopyIndex = dockerfile.indexOf(
|
||||||
|
"COPY samples ./samples"
|
||||||
|
);
|
||||||
|
const webBuildIndex = dockerfile.indexOf(
|
||||||
|
"npm run build -w @md-to-pdf/web"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(samplesBuildCopyIndex).toBeGreaterThan(-1);
|
||||||
|
expect(webBuildIndex).toBeGreaterThan(samplesBuildCopyIndex);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"COPY --chown=pwuser:pwuser --from=build /app/samples ./samples"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getWindowCloseDecision } from "../src/window-close-policy.js";
|
||||||
|
|
||||||
|
describe("桌面窗口关闭策略", () => {
|
||||||
|
it("干净文档或已授权窗口可以直接关闭", () => {
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: false,
|
||||||
|
allowClose: false,
|
||||||
|
closeRequestPending: false
|
||||||
|
})
|
||||||
|
).toBe("allow");
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: true,
|
||||||
|
allowClose: true,
|
||||||
|
closeRequestPending: false
|
||||||
|
})
|
||||||
|
).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未保存文档只发出一次关闭确认请求", () => {
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: true,
|
||||||
|
allowClose: false,
|
||||||
|
closeRequestPending: false
|
||||||
|
})
|
||||||
|
).toBe("request");
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: true,
|
||||||
|
allowClose: false,
|
||||||
|
closeRequestPending: true
|
||||||
|
})
|
||||||
|
).toBe("wait");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,12 +8,18 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"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": {
|
"dependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@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",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"playwright": "1.62.0"
|
"playwright": "1.62.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,489 @@
|
|||||||
|
import { createHash } 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 { 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 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();
|
||||||
|
|
||||||
|
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 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 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
|
||||||
|
);
|
||||||
|
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")
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 主题矩阵报告");
|
||||||
|
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 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 app = buildApp({
|
||||||
|
logger: { level: "error" },
|
||||||
|
pdfGenerator,
|
||||||
|
docxMediaAdapter,
|
||||||
|
fontPacks: {
|
||||||
|
roots: [fontPackRoot],
|
||||||
|
appVersion: "0.6.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
|
||||||
|
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
|
||||||
|
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
|
||||||
|
const markdown = fs.readFileSync(samplePath, "utf8");
|
||||||
|
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: `${theme.id}.md`,
|
||||||
|
language: "zh-CN",
|
||||||
|
resources: [],
|
||||||
|
exportConfig
|
||||||
|
};
|
||||||
|
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 样式`
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
standard.metadata?.document?.profile === "official" &&
|
||||||
|
standard.metadata.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 docxPath = path.join(docxDirectory, `${theme.id}.docx`);
|
||||||
|
fs.writeFileSync(pdfPath, pdf.content);
|
||||||
|
fs.writeFileSync(docxPath, docx.content);
|
||||||
|
results.push({
|
||||||
|
id: theme.id,
|
||||||
|
name: theme.name,
|
||||||
|
category: theme.category,
|
||||||
|
compatibleProfiles: theme.compatibleProfiles,
|
||||||
|
sample: path.relative(repositoryDirectory, samplePath),
|
||||||
|
exportConfig,
|
||||||
|
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,
|
||||||
|
diagnostics: responseDiagnostics(docx.response, "docx")
|
||||||
|
},
|
||||||
|
standardInspection: standard.inspection,
|
||||||
|
expectsOfficialDualEndedRow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhancedThemes = results.filter(
|
||||||
|
(result) => result.docx.inspection.optionalFontPackApplied
|
||||||
|
);
|
||||||
|
if (themes.some((theme) => theme.id === "gov-red-standard")) {
|
||||||
|
assert(
|
||||||
|
enhancedThemes.some((result) => result.id === "gov-red-standard"),
|
||||||
|
"有效字体包未增强 gov-red-standard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const result of results) {
|
||||||
|
assert(
|
||||||
|
result.pdf.diagnostics.pageCount > 0,
|
||||||
|
`主题 ${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));
|
||||||
|
}
|
||||||
+234
-15
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_VERSION,
|
EXPORT_CONFIG_VERSION,
|
||||||
|
DOCX_MIME_TYPE,
|
||||||
createPagedDocumentPayload,
|
createPagedDocumentPayload,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
exportConfigSchema,
|
exportConfigSchema,
|
||||||
@@ -11,10 +12,23 @@ import {
|
|||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
ApplicationRequestError,
|
ApplicationRequestError,
|
||||||
|
DocxExportService,
|
||||||
|
DocxExportServiceError,
|
||||||
|
DocxThemeTokenService,
|
||||||
createApplicationService,
|
createApplicationService,
|
||||||
type ApplicationService
|
readDocxExportRuntimeLimits,
|
||||||
|
type ApplicationService,
|
||||||
|
type ApplicationServiceOptions
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
PandocDocxConverter,
|
||||||
|
PandocRuntime
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||||
|
import {
|
||||||
|
createDocxMediaEngine,
|
||||||
|
type ServerDocxMediaCaptureAdapter
|
||||||
|
} from "./docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
createPdfGenerator,
|
createPdfGenerator,
|
||||||
PdfEngineClosedError,
|
PdfEngineClosedError,
|
||||||
@@ -68,7 +82,14 @@ export interface BuildAppOptions {
|
|||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
pdfGenerator?: PdfGenerator;
|
pdfGenerator?: PdfGenerator;
|
||||||
prewarmPdfBrowser?: boolean;
|
prewarmPdfBrowser?: boolean;
|
||||||
|
prewarmDocxRuntime?: boolean;
|
||||||
applicationService?: ApplicationService;
|
applicationService?: ApplicationService;
|
||||||
|
docxExportService?: Pick<
|
||||||
|
DocxExportService,
|
||||||
|
"getCapability" | "generate" | "close"
|
||||||
|
>;
|
||||||
|
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
||||||
|
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function milliseconds(value: number) {
|
function milliseconds(value: number) {
|
||||||
@@ -106,6 +127,31 @@ function createPdfServerTiming(
|
|||||||
].join(", ");
|
].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 = {}) {
|
export function buildApp(options: BuildAppOptions = {}) {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: options.logger ?? true,
|
logger: options.logger ?? true,
|
||||||
@@ -117,10 +163,29 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
bundledRoot: resolve(projectRoot, "themes"),
|
bundledRoot: resolve(projectRoot, "themes"),
|
||||||
localRoot:
|
localRoot:
|
||||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
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 =
|
const pdfGenerator =
|
||||||
options.pdfGenerator ?? createPdfGenerator();
|
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) {
|
if (options.prewarmPdfBrowser) {
|
||||||
app.addHook("onReady", async () => {
|
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 () => {
|
app.addHook("onClose", async () => {
|
||||||
await pdfGenerator.close();
|
await pdfGenerator.close();
|
||||||
|
await docxExportService.close();
|
||||||
|
await docxMediaAdapter.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/health", async () => ({
|
app.get("/api/health", async () => ({
|
||||||
@@ -143,19 +227,28 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
rendererVersion: RENDERER_VERSION
|
rendererVersion: RENDERER_VERSION
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/capabilities", async () => ({
|
app.get("/api/capabilities", async () => {
|
||||||
defaultExportConfig,
|
const docx = await docxExportService.getCapability();
|
||||||
supportedPaperFormats,
|
return {
|
||||||
implemented: [
|
defaultExportConfig,
|
||||||
"project-skeleton",
|
supportedPaperFormats,
|
||||||
"export-config",
|
docx,
|
||||||
"theme-manifest",
|
implemented: [
|
||||||
"markdown-render",
|
"project-skeleton",
|
||||||
"html-preview",
|
"export-config",
|
||||||
"pdf-export"
|
"theme-manifest",
|
||||||
],
|
"markdown-render",
|
||||||
planned: []
|
"html-preview",
|
||||||
}));
|
"pdf-export",
|
||||||
|
"docx-export"
|
||||||
|
],
|
||||||
|
planned: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/docx/capability", async () =>
|
||||||
|
docxExportService.getCapability()
|
||||||
|
);
|
||||||
|
|
||||||
app.get("/api/themes", async () =>
|
app.get("/api/themes", async () =>
|
||||||
applicationService.listThemes()
|
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 }>(
|
app.post<{ Body: RenderRequestBody }>(
|
||||||
"/api/render",
|
"/api/render",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -364,5 +495,93 @@ 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 },
|
||||||
|
"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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,8 +2,18 @@ import { buildApp } from "./app.js";
|
|||||||
|
|
||||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||||
const host = process.env.HOST ?? "0.0.0.0";
|
const host = process.env.HOST ?? "0.0.0.0";
|
||||||
|
const fontPackRoot = process.env.FONT_PACK_ROOT?.trim();
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
prewarmPdfBrowser: true
|
prewarmPdfBrowser: true,
|
||||||
|
prewarmDocxRuntime: true,
|
||||||
|
...(fontPackRoot
|
||||||
|
? {
|
||||||
|
fontPacks: {
|
||||||
|
roots: [fontPackRoot],
|
||||||
|
appVersion: process.env.APP_VERSION ?? "0.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
});
|
});
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
createPdfContentDisposition,
|
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 () => {
|
afterEach(async () => {
|
||||||
await app?.close();
|
await app?.close();
|
||||||
if (temporaryDirectory) {
|
if (temporaryDirectory) {
|
||||||
@@ -136,6 +192,43 @@ describe("预览 API", () => {
|
|||||||
expect(response.body).toContain("#write");
|
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 () => {
|
it("发现本地主题并安全提供相对字体资源", async () => {
|
||||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||||
const themeDirectory = join(temporaryDirectory, "local-test");
|
const themeDirectory = join(temporaryDirectory, "local-test");
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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.0 部署契约", () => {
|
||||||
|
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(
|
||||||
|
'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(
|
||||||
|
"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.0}"'
|
||||||
|
);
|
||||||
|
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
|
||||||
|
expect(dockerignore).toContain("!output/font-packs/root/**");
|
||||||
|
});
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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),
|
||||||
|
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(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 媒体服务已关闭"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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 = {
|
||||||
|
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(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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-3
@@ -12,14 +12,14 @@
|
|||||||
<meta name="theme-color" content="#1f2937" />
|
<meta name="theme-color" content="#1f2937" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:;"
|
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:; manifest-src 'self';"
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="可配置、可主题化的 Markdown PDF 导出工具"
|
content="面向结构化 Markdown 的主题化文档创作与发布工具"
|
||||||
/>
|
/>
|
||||||
<title>Markdown PDF 导出器</title>
|
<title>墨呈 · MorphDoc</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,9 +10,14 @@
|
|||||||
"typecheck": "tsc -b --pretty false"
|
"typecheck": "tsc -b --pretty false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/core": "0.1.0",
|
||||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
"@md-to-pdf/preview-engine": "0.1.0",
|
"@md-to-pdf/preview-engine": "0.1.0",
|
||||||
|
"@uiw/react-codemirror": "^4.25.11",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"pdfjs-dist": "6.1.200",
|
"pdfjs-dist": "6.1.200",
|
||||||
|
|||||||
+725
-70
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type DocumentActionDialogProps =
|
||||||
|
| {
|
||||||
|
type: "new-location";
|
||||||
|
busy?: false;
|
||||||
|
onCurrentWindow: () => void;
|
||||||
|
onNewWindow: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "unsaved";
|
||||||
|
busy: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentActionDialog(
|
||||||
|
props: DocumentActionDialogProps
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !props.busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
|
const isNewLocation = props.type === "new-location";
|
||||||
|
return (
|
||||||
|
<div className="document-dialog-layer">
|
||||||
|
<div className="document-dialog-backdrop" />
|
||||||
|
<section
|
||||||
|
className="document-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="document-dialog-title"
|
||||||
|
aria-describedby="document-dialog-description"
|
||||||
|
>
|
||||||
|
<span className="panel-kicker">文档操作</span>
|
||||||
|
<h2 id="document-dialog-title">
|
||||||
|
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
|
||||||
|
</h2>
|
||||||
|
<p id="document-dialog-description">
|
||||||
|
{isNewLocation
|
||||||
|
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
|
||||||
|
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
|
||||||
|
</p>
|
||||||
|
<div className="document-dialog-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onCancel}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
{isNewLocation ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onNewWindow}
|
||||||
|
>
|
||||||
|
新窗口新建
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="is-primary"
|
||||||
|
autoFocus
|
||||||
|
onClick={props.onCurrentWindow}
|
||||||
|
>
|
||||||
|
当前窗口新建
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onDiscard}
|
||||||
|
>
|
||||||
|
不保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="is-primary"
|
||||||
|
disabled={props.busy}
|
||||||
|
autoFocus
|
||||||
|
onClick={props.onSave}
|
||||||
|
>
|
||||||
|
{props.busy ? "正在保存…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
lengthToMillimeters,
|
lengthToMillimeters,
|
||||||
paperFormatLabels,
|
paperFormatLabels,
|
||||||
@@ -12,9 +13,11 @@ import {
|
|||||||
type MermaidExportConfig
|
type MermaidExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ThemeSummary } from "./application-backend";
|
||||||
|
|
||||||
interface ExportSettingsDrawerProps {
|
interface ExportSettingsDrawerProps {
|
||||||
config: ExportConfig;
|
config: ExportConfig;
|
||||||
|
theme: ThemeSummary | undefined;
|
||||||
onChange: (config: ExportConfig) => void;
|
onChange: (config: ExportConfig) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@@ -24,6 +27,9 @@ interface LengthInputProps {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
maximum: number;
|
maximum: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
precision?: number;
|
||||||
|
step?: number;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
|||||||
"page-total": "1 / 10",
|
"page-total": "1 / 10",
|
||||||
"chinese-page-total": "第 1 页 / 共 10 页",
|
"chinese-page-total": "第 1 页 / 共 10 页",
|
||||||
"dash-page": "- 1 -",
|
"dash-page": "- 1 -",
|
||||||
|
"official-page": "— 1 —(标准公文)",
|
||||||
custom: "自定义模板"
|
custom: "自定义模板"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +105,9 @@ function LengthInput({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
maximum,
|
maximum,
|
||||||
|
disabled = false,
|
||||||
|
precision = 1,
|
||||||
|
step = 0.1,
|
||||||
onChange
|
onChange
|
||||||
}: LengthInputProps) {
|
}: LengthInputProps) {
|
||||||
const [draft, setDraft] = useState(
|
const [draft, setDraft] = useState(
|
||||||
@@ -115,7 +125,8 @@ function LengthInput({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = Math.min(Math.max(number, 0), maximum);
|
const normalized = Math.min(Math.max(number, 0), maximum);
|
||||||
const rounded = Math.round(normalized * 10) / 10;
|
const factor = 10 ** precision;
|
||||||
|
const rounded = Math.round(normalized * factor) / factor;
|
||||||
setDraft(String(rounded));
|
setDraft(String(rounded));
|
||||||
onChange(`${rounded}mm`);
|
onChange(`${rounded}mm`);
|
||||||
}
|
}
|
||||||
@@ -128,8 +139,9 @@ function LengthInput({
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={maximum}
|
max={maximum}
|
||||||
step="0.1"
|
step={step}
|
||||||
value={draft}
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -144,6 +156,57 @@ function LengthInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageDecorationFontInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const normalized = draft.trim();
|
||||||
|
if (
|
||||||
|
!normalized ||
|
||||||
|
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
|
||||||
|
) {
|
||||||
|
setDraft(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(normalized);
|
||||||
|
onChange(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="setting-field stacked">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={300}
|
||||||
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||||
|
onBlur={commit}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MermaidFontInput({
|
function MermaidFontInput({
|
||||||
value,
|
value,
|
||||||
onChange
|
onChange
|
||||||
@@ -208,6 +271,7 @@ function updateHeaderSlot(
|
|||||||
|
|
||||||
export function ExportSettingsDrawer({
|
export function ExportSettingsDrawer({
|
||||||
config,
|
config,
|
||||||
|
theme,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
onReset
|
onReset
|
||||||
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
|
|||||||
value: string
|
value: string
|
||||||
) {
|
) {
|
||||||
updatePaper({
|
updatePaper({
|
||||||
|
marginMode: "custom",
|
||||||
margins: {
|
margins: {
|
||||||
...margins,
|
...margins,
|
||||||
[side]: value
|
[side]: value
|
||||||
@@ -266,9 +331,22 @@ export function ExportSettingsDrawer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function followThemeMargins() {
|
||||||
|
updatePaper({
|
||||||
|
marginMode: "theme",
|
||||||
|
margins: theme?.pageDefaults?.margins ?? {
|
||||||
|
top: "16mm",
|
||||||
|
right: "16mm",
|
||||||
|
bottom: "16mm",
|
||||||
|
left: "16mm"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateHeader(header: Partial<HeaderConfig>) {
|
function updateHeader(header: Partial<HeaderConfig>) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
|
pageDecorationsMode: "custom",
|
||||||
header: {
|
header: {
|
||||||
...config.header,
|
...config.header,
|
||||||
...header
|
...header
|
||||||
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
|
|||||||
function updateFooter(footer: Partial<FooterConfig>) {
|
function updateFooter(footer: Partial<FooterConfig>) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
|
pageDecorationsMode: "custom",
|
||||||
footer: {
|
footer: {
|
||||||
...config.footer,
|
...config.footer,
|
||||||
...footer
|
...footer
|
||||||
@@ -286,6 +365,19 @@ export function ExportSettingsDrawer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function followThemePageDecorations() {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
pageDecorationsMode: "theme",
|
||||||
|
header: structuredClone(
|
||||||
|
theme?.pageDefaults?.header ?? defaultExportConfig.header
|
||||||
|
),
|
||||||
|
footer: structuredClone(
|
||||||
|
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
|
|||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h3>页边距</h3>
|
<h3>页边距</h3>
|
||||||
|
<fieldset className="segmented-control margin-mode-control">
|
||||||
|
<legend>页边距来源</legend>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="margin-mode"
|
||||||
|
checked={config.paper.marginMode === "theme"}
|
||||||
|
onChange={followThemeMargins}
|
||||||
|
/>
|
||||||
|
跟随主题
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="margin-mode"
|
||||||
|
checked={config.paper.marginMode === "custom"}
|
||||||
|
onChange={() => updatePaper({ marginMode: "custom" })}
|
||||||
|
/>
|
||||||
|
自定义
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p className="setting-hint margin-source-hint">
|
||||||
|
{config.paper.marginMode === "theme"
|
||||||
|
? theme?.pageDefaults
|
||||||
|
? `当前采用“${theme.name}”推荐值`
|
||||||
|
: "当前主题未设置推荐值,采用全局默认 16mm"
|
||||||
|
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
|
||||||
|
</p>
|
||||||
<div className="margin-grid">
|
<div className="margin-grid">
|
||||||
<LengthInput
|
<LengthInput
|
||||||
label="上"
|
label="上"
|
||||||
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
|
|||||||
onChange={(value) => updateMargin("left", value)}
|
onChange={(value) => updateMargin("left", value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{config.paper.marginMode === "custom" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action margin-reset-action"
|
||||||
|
onClick={followThemeMargins}
|
||||||
|
>
|
||||||
|
恢复主题推荐值
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>页眉与页码来源</h3>
|
||||||
|
<fieldset className="segmented-control margin-mode-control">
|
||||||
|
<legend>页面装饰来源</legend>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="page-decorations-mode"
|
||||||
|
checked={config.pageDecorationsMode === "theme"}
|
||||||
|
onChange={followThemePageDecorations}
|
||||||
|
/>
|
||||||
|
跟随主题
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="page-decorations-mode"
|
||||||
|
checked={config.pageDecorationsMode === "custom"}
|
||||||
|
onChange={() =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
pageDecorationsMode: "custom"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
自定义
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p className="setting-hint margin-source-hint">
|
||||||
|
{config.pageDecorationsMode === "theme"
|
||||||
|
? theme?.pageDefaults?.header ||
|
||||||
|
theme?.pageDefaults?.footer
|
||||||
|
? `当前采用“${theme.name}”推荐的页眉与页码`
|
||||||
|
: "当前主题未设置推荐值,采用全局默认配置"
|
||||||
|
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
|
||||||
|
</p>
|
||||||
|
{config.pageDecorationsMode === "custom" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action margin-reset-action"
|
||||||
|
onClick={followThemePageDecorations}
|
||||||
|
>
|
||||||
|
恢复主题推荐值
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="setting-section-title">
|
<div className="setting-section-title">
|
||||||
<h3>页眉</h3>
|
<h3>页眉</h3>
|
||||||
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
|
|||||||
<p className="setting-hint">
|
<p className="setting-hint">
|
||||||
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
||||||
</p>
|
</p>
|
||||||
|
<PageDecorationFontInput
|
||||||
|
label="页眉字体"
|
||||||
|
value={config.header.fontFamily}
|
||||||
|
disabled={!config.header.enabled}
|
||||||
|
onChange={(fontFamily) => updateHeader({ fontFamily })}
|
||||||
|
/>
|
||||||
|
<LengthInput
|
||||||
|
label="页眉字号"
|
||||||
|
value={config.header.fontSize}
|
||||||
|
maximum={20}
|
||||||
|
disabled={!config.header.enabled}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(fontSize) => updateHeader({ fontSize })}
|
||||||
|
/>
|
||||||
{(["left", "center", "right"] as const).map((slot) => {
|
{(["left", "center", "right"] as const).map((slot) => {
|
||||||
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
||||||
return (
|
return (
|
||||||
@@ -540,6 +731,19 @@ export function ExportSettingsDrawer({
|
|||||||
</div>
|
</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">
|
<label className="switch-control divider-control">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -567,6 +771,21 @@ export function ExportSettingsDrawer({
|
|||||||
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<PageDecorationFontInput
|
||||||
|
label="页码字体"
|
||||||
|
value={config.footer.fontFamily}
|
||||||
|
disabled={!config.footer.enabled}
|
||||||
|
onChange={(fontFamily) => updateFooter({ fontFamily })}
|
||||||
|
/>
|
||||||
|
<LengthInput
|
||||||
|
label="页码字号"
|
||||||
|
value={config.footer.fontSize}
|
||||||
|
maximum={20}
|
||||||
|
disabled={!config.footer.enabled}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(fontSize) => updateFooter({ fontSize })}
|
||||||
|
/>
|
||||||
<label className="setting-field">
|
<label className="setting-field">
|
||||||
<span>样式</span>
|
<span>样式</span>
|
||||||
<select
|
<select
|
||||||
@@ -616,6 +835,7 @@ export function ExportSettingsDrawer({
|
|||||||
<option value="left">左侧</option>
|
<option value="left">左侧</option>
|
||||||
<option value="center">居中</option>
|
<option value="center">居中</option>
|
||||||
<option value="right">右侧</option>
|
<option value="right">右侧</option>
|
||||||
|
<option value="outer">外侧(奇数页右、偶数页左)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="setting-field">
|
<label className="setting-field">
|
||||||
@@ -636,6 +856,19 @@ export function ExportSettingsDrawer({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="switch-control divider-control">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.footer.showOnFirstPage}
|
||||||
|
disabled={!config.footer.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateFooter({
|
||||||
|
showOnFirstPage: event.target.checked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>正文首页显示页码</span>
|
||||||
|
</label>
|
||||||
<label className="switch-control divider-control">
|
<label className="switch-control divider-control">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,16 @@ import {
|
|||||||
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
import {
|
import {
|
||||||
getPdfCanvasRenderScale,
|
getPdfCanvasRenderScale,
|
||||||
getPdfPageCssDimensions
|
getPdfPageCssDimensions,
|
||||||
|
getPrecisePdfDocumentWidth
|
||||||
} from "./pdf-page-geometry";
|
} from "./pdf-page-geometry";
|
||||||
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
|
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
|
||||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||||
|
|
||||||
|
const PRECISE_PDF_INLINE_GUTTER = 30;
|
||||||
|
|
||||||
type PdfJsAnnotationLinkService = Parameters<
|
type PdfJsAnnotationLinkService = Parameters<
|
||||||
AnnotationLayer["render"]
|
AnnotationLayer["render"]
|
||||||
>[0]["linkService"];
|
>[0]["linkService"];
|
||||||
@@ -455,8 +458,17 @@ export function PrecisePdfPreview({
|
|||||||
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const documentWidth = getPrecisePdfDocumentWidth(
|
||||||
|
pages.map((page) => page.width),
|
||||||
|
zoomPercent / 100,
|
||||||
|
PRECISE_PDF_INLINE_GUTTER
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="precise-pdf-document">
|
<div
|
||||||
|
className="precise-pdf-document"
|
||||||
|
style={{ width: `${documentWidth}px` }}
|
||||||
|
>
|
||||||
{pages.map((page) => (
|
{pages.map((page) => (
|
||||||
<PrecisePdfPage
|
<PrecisePdfPage
|
||||||
key={page.pageNumber}
|
key={page.pageNumber}
|
||||||
|
|||||||
@@ -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__;
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
import type {
|
||||||
|
DocumentProfileName,
|
||||||
|
FooterConfig,
|
||||||
|
HeaderConfig,
|
||||||
|
PageMargins,
|
||||||
|
RenderedMarkdownDocument,
|
||||||
|
ThemeCategory
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export interface ThemeSummary {
|
export interface ThemeSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
category: ThemeCategory;
|
||||||
|
compatibleProfiles: DocumentProfileName[];
|
||||||
|
pageDefaults?: {
|
||||||
|
margins: PageMargins;
|
||||||
|
header?: HeaderConfig;
|
||||||
|
footer?: FooterConfig;
|
||||||
|
};
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
source: "bundled" | "local";
|
source: "bundled" | "local";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const sampleModules = import.meta.glob<string>(
|
||||||
|
"../../../samples/themes/*.md",
|
||||||
|
{
|
||||||
|
eager: true,
|
||||||
|
query: "?raw",
|
||||||
|
import: "default"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BuiltinThemeSample {
|
||||||
|
themeId: string;
|
||||||
|
fileName: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(modulePath: string) {
|
||||||
|
return modulePath.split("/").at(-1) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
|
||||||
|
sampleModules
|
||||||
|
)
|
||||||
|
.map(([modulePath, markdown]) => {
|
||||||
|
const fileName = getFileName(modulePath);
|
||||||
|
return {
|
||||||
|
themeId: fileName.replace(/\.md$/i, ""),
|
||||||
|
fileName,
|
||||||
|
markdown
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((first, second) =>
|
||||||
|
first.themeId.localeCompare(second.themeId, "en")
|
||||||
|
);
|
||||||
|
|
||||||
|
const builtinThemeSampleMap = new Map(
|
||||||
|
builtinThemeSamples.map((sample) => [sample.themeId, sample])
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getBuiltinThemeSample(themeId: string) {
|
||||||
|
return builtinThemeSampleMap.get(themeId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const tutorialModules = import.meta.glob<string>(
|
||||||
|
"../../../samples/tutorials/*.md",
|
||||||
|
{
|
||||||
|
eager: true,
|
||||||
|
query: "?raw",
|
||||||
|
import: "default"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BuiltinTutorial {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileName: string;
|
||||||
|
themeId: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tutorialDefinitions = [
|
||||||
|
{
|
||||||
|
id: "echarts-tutorial",
|
||||||
|
name: "ECharts 图表教程",
|
||||||
|
themeId: "typora-github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "formal-document-tutorial",
|
||||||
|
name: "公文主题使用教程",
|
||||||
|
themeId: "typora-github"
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const tutorialMarkdownById = new Map(
|
||||||
|
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
|
||||||
|
const fileName = modulePath.split("/").at(-1) ?? "";
|
||||||
|
return [fileName.replace(/\.md$/i, ""), markdown];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const builtinTutorials: BuiltinTutorial[] =
|
||||||
|
tutorialDefinitions.map((definition) => {
|
||||||
|
const markdown = tutorialMarkdownById.get(definition.id);
|
||||||
|
if (!markdown) {
|
||||||
|
throw new Error(`缺少内置教程:${definition.id}.md`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
fileName: `${definition.id}.md`,
|
||||||
|
markdown
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type DocumentShortcut =
|
||||||
|
| "new"
|
||||||
|
| "save"
|
||||||
|
| "save-as"
|
||||||
|
| "close";
|
||||||
|
|
||||||
|
export interface DocumentShortcutEvent {
|
||||||
|
key: string;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
isComposing: boolean;
|
||||||
|
repeat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocumentShortcut(
|
||||||
|
event: DocumentShortcutEvent,
|
||||||
|
desktop: boolean
|
||||||
|
): DocumentShortcut | undefined {
|
||||||
|
if (
|
||||||
|
event.isComposing ||
|
||||||
|
event.repeat ||
|
||||||
|
event.altKey ||
|
||||||
|
(!event.ctrlKey && !event.metaKey)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (event.shiftKey) {
|
||||||
|
return desktop && key === "s" ? "save-as" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "s":
|
||||||
|
return "save";
|
||||||
|
case "n":
|
||||||
|
return desktop ? "new" : undefined;
|
||||||
|
case "w":
|
||||||
|
return desktop ? "close" : undefined;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(",")
|
||||||
|
: "纸张、样式和可编辑结构已写入文档";
|
||||||
|
}
|
||||||
+121
-14
@@ -4,8 +4,10 @@ import {
|
|||||||
type ExportConfig
|
type ExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
|
||||||
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
|
||||||
|
"md-to-pdf.export-config.v3";
|
||||||
|
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
|
||||||
"md-to-pdf.export-config.v2";
|
"md-to-pdf.export-config.v2";
|
||||||
|
|
||||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||||
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
|
|||||||
return structuredClone(defaultExportConfig);
|
return structuredClone(defaultExportConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickLegacyPageDecorations(value: unknown) {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
if ("left" in source) {
|
||||||
|
return {
|
||||||
|
enabled: source.enabled,
|
||||||
|
height: source.height,
|
||||||
|
showDivider: source.showDivider,
|
||||||
|
fontSize: source.fontSize,
|
||||||
|
color: source.color,
|
||||||
|
left: source.left,
|
||||||
|
center: source.center,
|
||||||
|
right: source.right
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: source.enabled,
|
||||||
|
height: source.height,
|
||||||
|
showDivider: source.showDivider,
|
||||||
|
fontSize: source.fontSize,
|
||||||
|
color: source.color,
|
||||||
|
alignment: source.alignment,
|
||||||
|
format: source.format,
|
||||||
|
template: source.template,
|
||||||
|
startFrom: source.startFrom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesLegacyPageDecorationDefaults(
|
||||||
|
header: unknown,
|
||||||
|
footer: unknown
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
JSON.stringify(pickLegacyPageDecorations(header)) ===
|
||||||
|
JSON.stringify(
|
||||||
|
pickLegacyPageDecorations(defaultExportConfig.header)
|
||||||
|
) &&
|
||||||
|
JSON.stringify(pickLegacyPageDecorations(footer)) ===
|
||||||
|
JSON.stringify(
|
||||||
|
pickLegacyPageDecorations(defaultExportConfig.footer)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateStoredExportConfig(value: unknown): unknown {
|
||||||
|
if (
|
||||||
|
typeof value !== "object" ||
|
||||||
|
value === null ||
|
||||||
|
!("version" in value) ||
|
||||||
|
!("paper" in value)
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.version !== 2 &&
|
||||||
|
value.version !== 3 &&
|
||||||
|
value.version !== defaultExportConfig.version
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
const hasPageDecorationsMode =
|
||||||
|
typeof source.pageDecorationsMode === "string";
|
||||||
|
|
||||||
|
const paper =
|
||||||
|
typeof value.paper === "object" && value.paper !== null
|
||||||
|
? value.paper
|
||||||
|
: {};
|
||||||
|
const margins =
|
||||||
|
"margins" in paper &&
|
||||||
|
typeof paper.margins === "object" &&
|
||||||
|
paper.margins !== null
|
||||||
|
? paper.margins
|
||||||
|
: {};
|
||||||
|
const usesLegacyDefaults =
|
||||||
|
JSON.stringify(margins) ===
|
||||||
|
JSON.stringify(defaultExportConfig.paper.margins);
|
||||||
|
const hasMarginMode =
|
||||||
|
typeof (paper as Record<string, unknown>).marginMode === "string";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
...(value.version === 2 || value.version === 3
|
||||||
|
? { version: defaultExportConfig.version }
|
||||||
|
: {}),
|
||||||
|
...(value.version === 2
|
||||||
|
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
|
||||||
|
: {}),
|
||||||
|
...(!hasPageDecorationsMode
|
||||||
|
? {
|
||||||
|
pageDecorationsMode: usesLegacyPageDecorationDefaults(
|
||||||
|
source.header,
|
||||||
|
source.footer
|
||||||
|
)
|
||||||
|
? "theme"
|
||||||
|
: "custom"
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
paper: {
|
||||||
|
...paper,
|
||||||
|
...(!hasMarginMode
|
||||||
|
? {
|
||||||
|
marginMode: usesLegacyDefaults ? "theme" : "custom"
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return cloneDefaultExportConfig();
|
return cloneDefaultExportConfig();
|
||||||
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const value: unknown = JSON.parse(rawValue);
|
const value: unknown = JSON.parse(rawValue);
|
||||||
const migratedValue =
|
const migratedValue = migrateStoredExportConfig(value);
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"version" in value &&
|
|
||||||
value.version === 2
|
|
||||||
? {
|
|
||||||
...value,
|
|
||||||
version: defaultExportConfig.version,
|
|
||||||
mermaid: structuredClone(defaultExportConfig.mermaid)
|
|
||||||
}
|
|
||||||
: value;
|
|
||||||
const parsed = exportConfigSchema.safeParse(migratedValue);
|
const parsed = exportConfigSchema.safeParse(migratedValue);
|
||||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -47,7 +152,9 @@ export function loadExportConfig(
|
|||||||
return parseStoredExportConfig(currentValue);
|
return parseStoredExportConfig(currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
|
const legacyValue =
|
||||||
|
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
|
||||||
|
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
|
||||||
const config = parseStoredExportConfig(legacyValue);
|
const config = parseStoredExportConfig(legacyValue);
|
||||||
if (legacyValue) {
|
if (legacyValue) {
|
||||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { APP_WINDOW_TITLE } from "./app-brand";
|
||||||
|
import { APP_VERSION_LABEL } from "./app-version";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
document.title = `${APP_WINDOW_TITLE} ${APP_VERSION_LABEL}`;
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|||||||
@@ -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("表格尺寸必须为 1~10 列、2~10 行");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
isPagedPreviewRenderRequest,
|
isPagedPreviewRenderRequest,
|
||||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
PagedDocumentRuntime,
|
PagedDocumentRuntime,
|
||||||
|
renderDocxMediaCapturePlan,
|
||||||
resolveMermaidOutputMode,
|
resolveMermaidOutputMode,
|
||||||
resolvePagedRenderTarget,
|
resolvePagedRenderTarget,
|
||||||
type PagedPreviewFrameMessage,
|
type PagedPreviewFrameMessage,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
beginPreviewFrameRender,
|
beginPreviewFrameRender,
|
||||||
completePreviewFrameRender
|
completePreviewFrameRender
|
||||||
} from "./preview-frame-visibility";
|
} from "./preview-frame-visibility";
|
||||||
|
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
|
||||||
|
|
||||||
function getPreviewRoot() {
|
function getPreviewRoot() {
|
||||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||||
@@ -137,16 +139,25 @@ window.__mdToPdfRender = async (
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
|
||||||
|
withHiddenWindowAnimationFrames(window, () =>
|
||||||
|
renderDocxMediaCapturePlan(
|
||||||
|
runtime,
|
||||||
|
previewRoot,
|
||||||
|
payload,
|
||||||
|
dimensions
|
||||||
|
)
|
||||||
|
);
|
||||||
document.documentElement.dataset.runtimeReady = "true";
|
document.documentElement.dataset.runtimeReady = "true";
|
||||||
|
|
||||||
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||||
if (desktopPdfBridge) {
|
if (desktopPdfBridge) {
|
||||||
desktopPdfBridge.onRender(async (requestId, payload) => {
|
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||||
const requestAnimationFrame = window.requestAnimationFrame;
|
|
||||||
window.requestAnimationFrame = (callback) =>
|
|
||||||
window.setTimeout(() => callback(performance.now()), 0);
|
|
||||||
try {
|
try {
|
||||||
const result = await window.__mdToPdfRender?.(payload, "pdf");
|
const result = await withHiddenWindowAnimationFrames(
|
||||||
|
window,
|
||||||
|
async () => window.__mdToPdfRender?.(payload, "pdf")
|
||||||
|
);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("PDF 分页运行时不可用");
|
throw new Error("PDF 分页运行时不可用");
|
||||||
}
|
}
|
||||||
@@ -156,8 +167,6 @@ if (desktopPdfBridge) {
|
|||||||
requestId,
|
requestId,
|
||||||
reason instanceof Error ? reason.message : "PDF 分页失败"
|
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
window.requestAnimationFrame = requestAnimationFrame;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
desktopPdfBridge.ready();
|
desktopPdfBridge.ready();
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ function parseNumericHeader(value: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseContentDispositionFileName(
|
export function parseContentDispositionFileName(
|
||||||
contentDisposition: string | null
|
contentDisposition: string | null,
|
||||||
|
fallbackFileName = "document.pdf"
|
||||||
) {
|
) {
|
||||||
const encoded = contentDisposition?.match(
|
const encoded = contentDisposition?.match(
|
||||||
/filename\*=UTF-8''([^;]+)/i
|
/filename\*=UTF-8''([^;]+)/i
|
||||||
@@ -82,7 +83,7 @@ export function parseContentDispositionFileName(
|
|||||||
return (
|
return (
|
||||||
contentDisposition
|
contentDisposition
|
||||||
?.match(/filename="([^"]+)"/i)?.[1]
|
?.match(/filename="([^"]+)"/i)?.[1]
|
||||||
?.trim() || "document.pdf"
|
?.trim() || fallbackFileName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,22 @@ export function getPdfCanvasRenderScale(
|
|||||||
}
|
}
|
||||||
return (displayWidthCssPixels / widthPoints) * devicePixelRatio;
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+425
-13
@@ -188,6 +188,205 @@ h1 {
|
|||||||
background: #edf3f0;
|
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;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-command kbd {
|
||||||
|
color: #78857e;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-menu-submenu-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 5px 7px;
|
||||||
|
background: #e0e6e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-menu-submenu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 31;
|
||||||
|
top: -7px;
|
||||||
|
right: calc(100% + 8px);
|
||||||
|
width: 250px;
|
||||||
|
max-height: min(70vh, 560px);
|
||||||
|
padding: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #c8d1cc;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-menu-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-menu-group + .sample-menu-group {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 5px;
|
||||||
|
border-top: 1px solid #e3e8e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-menu-group-label {
|
||||||
|
padding: 6px 11px 3px;
|
||||||
|
color: #718078;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
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 {
|
.editor-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||||
@@ -485,24 +684,134 @@ h1 {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.markdown-editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 24px;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
resize: none;
|
|
||||||
background: #f6f7f5;
|
background: #f6f7f5;
|
||||||
color: #26312d;
|
overflow: hidden;
|
||||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
line-height: 1.72;
|
|
||||||
tab-size: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea:focus {
|
.markdown-editor > div,
|
||||||
box-shadow: inset 3px 0 #74a88f;
|
.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 {
|
.preview-panel {
|
||||||
@@ -749,6 +1058,59 @@ textarea:focus {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-dialog-layer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
background: rgb(24 35 30 / 46%);
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: min(480px, 100%);
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #c9d2cd;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 70px rgb(20 34 28 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog h2 {
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
color: #25332d;
|
||||||
|
font-family: Georgia, "Songti SC", serif;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog p {
|
||||||
|
margin: 0;
|
||||||
|
color: #5d6a64;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog-actions .is-primary {
|
||||||
|
border-color: #326b54;
|
||||||
|
background: #326b54;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-backdrop {
|
.settings-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -927,6 +1289,19 @@ textarea:focus {
|
|||||||
gap: 8px 18px;
|
gap: 8px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.margin-mode-control {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-source-hint {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-reset-action {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.margin-grid .setting-field {
|
.margin-grid .setting-field {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1058,7 +1433,7 @@ textarea:focus {
|
|||||||
min-height: 650px;
|
min-height: 650px;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.markdown-editor {
|
||||||
min-height: 560px;
|
min-height: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,6 +1496,11 @@ textarea:focus {
|
|||||||
.editor-layout {
|
.editor-layout {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-toast-progress span {
|
||||||
|
width: 100%;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
@@ -1133,4 +1513,36 @@ textarea:focus {
|
|||||||
.settings-drawer {
|
.settings-drawer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more-menu-popover {
|
||||||
|
max-height: min(72vh, 620px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-menu-popover {
|
||||||
|
right: 0;
|
||||||
|
width: min(286px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-menu-submenu {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
max-height: 48vh;
|
||||||
|
margin: 4px 0 0 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog-layer {
|
||||||
|
align-items: end;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-dialog-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ThemeCategory } from "@md-to-pdf/core";
|
||||||
|
import type { ThemeSummary } from "./application-backend";
|
||||||
|
|
||||||
|
const themeCategoryOrder: readonly ThemeCategory[] = [
|
||||||
|
"general",
|
||||||
|
"red-letter",
|
||||||
|
"formal",
|
||||||
|
"tender"
|
||||||
|
];
|
||||||
|
|
||||||
|
const themeCategoryLabels: Record<ThemeCategory, string> = {
|
||||||
|
general: "通用主题",
|
||||||
|
"red-letter": "政企红头",
|
||||||
|
formal: "政企正式",
|
||||||
|
tender: "标书正式"
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ThemeGroup {
|
||||||
|
category: ThemeCategory;
|
||||||
|
label: string;
|
||||||
|
themes: ThemeSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupThemesByCategory(
|
||||||
|
themes: readonly ThemeSummary[]
|
||||||
|
): ThemeGroup[] {
|
||||||
|
return themeCategoryOrder
|
||||||
|
.map((category) => ({
|
||||||
|
category,
|
||||||
|
label: themeCategoryLabels[category],
|
||||||
|
themes: themes.filter((theme) => theme.category === category)
|
||||||
|
}))
|
||||||
|
.filter((group) => group.themes.length > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
|
resolvePageMargins,
|
||||||
|
type ExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import type { ThemeSummary } from "./application-backend";
|
||||||
|
|
||||||
|
function marginsEqual(
|
||||||
|
first: ExportConfig["paper"]["margins"],
|
||||||
|
second: ExportConfig["paper"]["margins"]
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
first.top === second.top &&
|
||||||
|
first.right === second.right &&
|
||||||
|
first.bottom === second.bottom &&
|
||||||
|
first.left === second.left
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemePageDecorations(
|
||||||
|
config: ExportConfig,
|
||||||
|
theme?: ThemeSummary
|
||||||
|
): ExportConfig {
|
||||||
|
if (config.pageDecorationsMode !== "theme") {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
theme?.pageDefaults?.header ?? defaultExportConfig.header;
|
||||||
|
const footer =
|
||||||
|
theme?.pageDefaults?.footer ?? defaultExportConfig.footer;
|
||||||
|
if (
|
||||||
|
JSON.stringify(config.header) === JSON.stringify(header) &&
|
||||||
|
JSON.stringify(config.footer) === JSON.stringify(footer)
|
||||||
|
) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
header: structuredClone(header),
|
||||||
|
footer: structuredClone(footer)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeMargins(
|
||||||
|
config: ExportConfig,
|
||||||
|
theme?: ThemeSummary
|
||||||
|
): ExportConfig {
|
||||||
|
if (config.paper.marginMode !== "theme") {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const margins = resolvePageMargins(
|
||||||
|
config.paper,
|
||||||
|
theme?.pageDefaults?.margins
|
||||||
|
);
|
||||||
|
if (marginsEqual(config.paper.margins, margins)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
paper: {
|
||||||
|
...config.paper,
|
||||||
|
margins
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeDefaults(
|
||||||
|
config: ExportConfig,
|
||||||
|
theme?: ThemeSummary
|
||||||
|
): ExportConfig {
|
||||||
|
return applyThemePageDecorations(
|
||||||
|
applyThemeMargins(config, theme),
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTheme(
|
||||||
|
config: ExportConfig,
|
||||||
|
theme: ThemeSummary
|
||||||
|
): ExportConfig {
|
||||||
|
return applyThemeDefaults(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
themeId: theme.id
|
||||||
|
},
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+48
-1
@@ -1,13 +1,25 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DocxCapability,
|
||||||
|
DocxExportDiagnostics,
|
||||||
|
DocxExportErrorCode,
|
||||||
|
DocxExportRequestInput,
|
||||||
|
DocxGenerationTimings,
|
||||||
|
DocxMediaCapturePlan,
|
||||||
PagedDocumentPayload,
|
PagedDocumentPayload,
|
||||||
PagedDocumentRenderResult,
|
PagedDocumentRenderResult,
|
||||||
RenderedMarkdownDocument
|
RenderedMarkdownDocument,
|
||||||
|
ThemeCategory,
|
||||||
|
DocumentProfileName
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const __APP_VERSION__: string;
|
const __APP_VERSION__: string;
|
||||||
|
const __APP_DISPLAY_NAME__: string;
|
||||||
|
const __APP_WINDOW_TITLE__: string;
|
||||||
|
const __APP_SLOGAN__: string;
|
||||||
|
const __APP_DESCRIPTION__: string;
|
||||||
|
|
||||||
interface DesktopPdfResult {
|
interface DesktopPdfResult {
|
||||||
pdf: Uint8Array;
|
pdf: Uint8Array;
|
||||||
@@ -16,6 +28,21 @@ declare global {
|
|||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopDocxExportOutcome =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
saved: boolean;
|
||||||
|
fileName: string;
|
||||||
|
diagnostics: DocxExportDiagnostics;
|
||||||
|
timings: DocxGenerationTimings;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: DocxExportErrorCode;
|
||||||
|
message: string;
|
||||||
|
retryable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface DesktopApplicationBridge {
|
interface DesktopApplicationBridge {
|
||||||
openMarkdown(): Promise<
|
openMarkdown(): Promise<
|
||||||
| {
|
| {
|
||||||
@@ -36,12 +63,19 @@ declare global {
|
|||||||
listener: (reason: "open" | "reload" | "replace") => void
|
listener: (reason: "open" | "reload" | "replace") => void
|
||||||
): () => void;
|
): () => void;
|
||||||
startNewMarkdown(): Promise<void>;
|
startNewMarkdown(): Promise<void>;
|
||||||
|
createNewWindow(): Promise<void>;
|
||||||
|
resolveWindowClose(close: boolean): Promise<void>;
|
||||||
|
onWindowCloseRequested(listener: () => void): () => void;
|
||||||
setDocumentDirty(dirty: boolean): Promise<void>;
|
setDocumentDirty(dirty: boolean): Promise<void>;
|
||||||
openDocumentLink(href: string): Promise<void>;
|
openDocumentLink(href: string): Promise<void>;
|
||||||
saveMarkdown(
|
saveMarkdown(
|
||||||
fileName: string,
|
fileName: string,
|
||||||
markdown: string
|
markdown: string
|
||||||
): Promise<{ fileName: string } | undefined>;
|
): Promise<{ fileName: string } | undefined>;
|
||||||
|
saveMarkdownAs(
|
||||||
|
fileName: string,
|
||||||
|
markdown: string
|
||||||
|
): Promise<{ fileName: string } | undefined>;
|
||||||
openThemeDirectory(): Promise<string>;
|
openThemeDirectory(): Promise<string>;
|
||||||
refreshThemes(): Promise<void>;
|
refreshThemes(): Promise<void>;
|
||||||
renderMarkdown(input: {
|
renderMarkdown(input: {
|
||||||
@@ -58,6 +92,8 @@ declare global {
|
|||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
category: ThemeCategory;
|
||||||
|
compatibleProfiles: DocumentProfileName[];
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
source: "bundled" | "local";
|
source: "bundled" | "local";
|
||||||
}>;
|
}>;
|
||||||
@@ -65,6 +101,10 @@ declare global {
|
|||||||
getThemeCss(themeId: string): Promise<string>;
|
getThemeCss(themeId: string): Promise<string>;
|
||||||
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
||||||
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
||||||
|
getDocxCapability(): Promise<DocxCapability>;
|
||||||
|
exportDocx(
|
||||||
|
request: DocxExportRequestInput
|
||||||
|
): Promise<DesktopDocxExportOutcome>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopPdfRuntimeBridge {
|
interface DesktopPdfRuntimeBridge {
|
||||||
@@ -85,5 +125,12 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
mdToPdfDesktop?: DesktopApplicationBridge;
|
mdToPdfDesktop?: DesktopApplicationBridge;
|
||||||
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
||||||
|
__mdToPdfRenderDocxMedia?: (
|
||||||
|
payload: PagedDocumentPayload,
|
||||||
|
dimensions: {
|
||||||
|
contentWidthPx: number;
|
||||||
|
contentHeightPx: number;
|
||||||
|
}
|
||||||
|
) => Promise<DocxMediaCapturePlan>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("内网文档工具");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
APP_VERSION,
|
APP_VERSION,
|
||||||
APP_VERSION_LABEL
|
APP_VERSION_LABEL
|
||||||
} from "../src/app-version";
|
} from "../src/app-version";
|
||||||
|
|
||||||
|
const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
|
||||||
describe("应用版本", () => {
|
describe("应用版本", () => {
|
||||||
it("从统一构建版本生成标题徽标", () => {
|
it("从统一构建版本生成标题徽标", () => {
|
||||||
expect(APP_VERSION).toBe("0.5.0");
|
expect(APP_VERSION).toBe("0.6.0");
|
||||||
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
expect(APP_VERSION_LABEL).toBe("v0.6.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("允许浏览器加载同源 Web Manifest", () => {
|
||||||
|
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
|
||||||
|
|
||||||
|
expect(indexHtml).toContain('rel="manifest"');
|
||||||
|
expect(indexHtml).toContain("manifest-src 'self'");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
builtinThemeSamples,
|
||||||
|
getBuiltinThemeSample
|
||||||
|
} from "../src/builtin-theme-samples";
|
||||||
|
|
||||||
|
const expectedThemeIds = [
|
||||||
|
"enterprise-red-simple",
|
||||||
|
"formal-feasibility",
|
||||||
|
"formal-regulation",
|
||||||
|
"formal-report",
|
||||||
|
"gov-red-letter",
|
||||||
|
"gov-red-standard",
|
||||||
|
"red-briefing",
|
||||||
|
"tender-blind",
|
||||||
|
"tender-business-blue",
|
||||||
|
"tender-classic",
|
||||||
|
"typora-github",
|
||||||
|
"typora-like",
|
||||||
|
"typora-pixyll",
|
||||||
|
"typora-whitey"
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("内置主题示例", () => {
|
||||||
|
it("为全部内置主题注册同名 Markdown 文件", () => {
|
||||||
|
expect(
|
||||||
|
builtinThemeSamples.map((sample) => sample.themeId).sort()
|
||||||
|
).toEqual(expectedThemeIds);
|
||||||
|
expect(
|
||||||
|
builtinThemeSamples.every(
|
||||||
|
(sample) => sample.fileName === `${sample.themeId}.md`
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("提供可直接编辑的非空 Markdown 内容", () => {
|
||||||
|
for (const sample of builtinThemeSamples) {
|
||||||
|
expect(sample.markdown.startsWith("---")).toBe(true);
|
||||||
|
expect(sample.markdown.length).toBeGreaterThan(300);
|
||||||
|
expect(getBuiltinThemeSample(sample.themeId)).toBe(sample);
|
||||||
|
}
|
||||||
|
expect(getBuiltinThemeSample("unknown-theme")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { builtinTutorials } from "../src/builtin-tutorials";
|
||||||
|
|
||||||
|
describe("内置教程", () => {
|
||||||
|
it("注册 ECharts 与公文主题教程", () => {
|
||||||
|
expect(
|
||||||
|
builtinTutorials.map(({ id, name, themeId, fileName }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
themeId,
|
||||||
|
fileName
|
||||||
|
}))
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "echarts-tutorial",
|
||||||
|
name: "ECharts 图表教程",
|
||||||
|
themeId: "typora-github",
|
||||||
|
fileName: "echarts-tutorial.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "formal-document-tutorial",
|
||||||
|
name: "公文主题使用教程",
|
||||||
|
themeId: "typora-github",
|
||||||
|
fileName: "formal-document-tutorial.md"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("教程内容完整且能够直接作为 Markdown 打开", () => {
|
||||||
|
for (const tutorial of builtinTutorials) {
|
||||||
|
expect(tutorial.markdown.startsWith("---")).toBe(true);
|
||||||
|
expect(tutorial.markdown.length).toBeGreaterThan(1_000);
|
||||||
|
}
|
||||||
|
expect(builtinTutorials[0].markdown).toContain("```echarts");
|
||||||
|
expect(builtinTutorials[1].markdown).toContain(
|
||||||
|
"document.profile: official"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getDocumentShortcut } from "../src/document-shortcuts";
|
||||||
|
|
||||||
|
function shortcutEvent(
|
||||||
|
key: string,
|
||||||
|
overrides: Partial<Parameters<typeof getDocumentShortcut>[0]> = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
ctrlKey: true,
|
||||||
|
metaKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
isComposing: false,
|
||||||
|
repeat: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("文档快捷键", () => {
|
||||||
|
it("在桌面端识别新建、保存、另存为和关闭", () => {
|
||||||
|
expect(getDocumentShortcut(shortcutEvent("n"), true)).toBe("new");
|
||||||
|
expect(getDocumentShortcut(shortcutEvent("S"), true)).toBe("save");
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(
|
||||||
|
shortcutEvent("S", { shiftKey: true }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toBe("save-as");
|
||||||
|
expect(getDocumentShortcut(shortcutEvent("w"), true)).toBe("close");
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(
|
||||||
|
shortcutEvent("s", { ctrlKey: false, metaKey: true }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toBe("save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Web 端只接管保存", () => {
|
||||||
|
expect(getDocumentShortcut(shortcutEvent("n"), false)).toBeUndefined();
|
||||||
|
expect(getDocumentShortcut(shortcutEvent("w"), false)).toBeUndefined();
|
||||||
|
expect(getDocumentShortcut(shortcutEvent("s"), false)).toBe("save");
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(
|
||||||
|
shortcutEvent("s", { shiftKey: true }),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略组合输入、长按和带附加修饰键的按键", () => {
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(
|
||||||
|
shortcutEvent("s", { isComposing: true }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(shortcutEvent("s", { repeat: true }), true)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(shortcutEvent("s", { altKey: true }), true)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(
|
||||||
|
shortcutEvent("n", { shiftKey: true }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getDocumentShortcut(
|
||||||
|
shortcutEvent("s", { shiftKey: true, altKey: true }),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 图表失败"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_STORAGE_KEY,
|
EXPORT_CONFIG_STORAGE_KEY,
|
||||||
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
|
||||||
|
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
|
||||||
loadExportConfig,
|
loadExportConfig,
|
||||||
parseStoredExportConfig,
|
parseStoredExportConfig,
|
||||||
saveExportConfig
|
saveExportConfig
|
||||||
@@ -10,14 +11,15 @@ import {
|
|||||||
|
|
||||||
function createMemoryStorage(
|
function createMemoryStorage(
|
||||||
initialValue: string | null = null,
|
initialValue: string | null = null,
|
||||||
legacyValue: string | null = null
|
legacyValue: string | null = null,
|
||||||
|
legacyKey = LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY
|
||||||
) {
|
) {
|
||||||
const values = new Map<string, string>();
|
const values = new Map<string, string>();
|
||||||
if (initialValue) {
|
if (initialValue) {
|
||||||
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||||
}
|
}
|
||||||
if (legacyValue) {
|
if (legacyValue) {
|
||||||
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
values.set(legacyKey, legacyValue);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
getItem(key: string) {
|
getItem(key: string) {
|
||||||
@@ -52,26 +54,108 @@ describe("导出设置缓存", () => {
|
|||||||
).toEqual(defaultExportConfig);
|
).toEqual(defaultExportConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
it("将 v2 缓存迁移为 v4 并让默认页边距跟随主题", () => {
|
||||||
const legacyConfig = {
|
const legacyConfig = {
|
||||||
...defaultExportConfig,
|
...defaultExportConfig,
|
||||||
version: 2,
|
version: 2,
|
||||||
mermaid: undefined,
|
mermaid: undefined,
|
||||||
paper: {
|
paper: {
|
||||||
...defaultExportConfig.paper,
|
...defaultExportConfig.paper,
|
||||||
|
marginMode: undefined,
|
||||||
format: "A5"
|
format: "A5"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const storage = createMemoryStorage(
|
const storage = createMemoryStorage(
|
||||||
null,
|
null,
|
||||||
JSON.stringify(legacyConfig)
|
JSON.stringify(legacyConfig),
|
||||||
|
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
|
||||||
);
|
);
|
||||||
|
|
||||||
const migrated = loadExportConfig(storage);
|
const migrated = loadExportConfig(storage);
|
||||||
expect(migrated.version).toBe(3);
|
expect(migrated.version).toBe(4);
|
||||||
expect(migrated.paper.format).toBe("A5");
|
expect(migrated.paper.format).toBe("A5");
|
||||||
|
expect(migrated.paper.marginMode).toBe("theme");
|
||||||
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
|
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
|
||||||
expect(storage.read()).toContain('"version":3');
|
expect(storage.read()).toContain('"version":4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将改过页边距的 v3 缓存迁移为自定义模式", () => {
|
||||||
|
const legacyConfig = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
version: 3,
|
||||||
|
paper: {
|
||||||
|
...defaultExportConfig.paper,
|
||||||
|
marginMode: undefined,
|
||||||
|
margins: {
|
||||||
|
top: "20mm",
|
||||||
|
right: "18mm",
|
||||||
|
bottom: "20mm",
|
||||||
|
left: "18mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const migrated = parseStoredExportConfig(
|
||||||
|
JSON.stringify(legacyConfig)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(migrated.version).toBe(4);
|
||||||
|
expect(migrated.paper.marginMode).toBe("custom");
|
||||||
|
expect(migrated.paper.margins).toEqual(legacyConfig.paper.margins);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将未改页眉页码的旧 v4 缓存迁移为跟随主题", () => {
|
||||||
|
const legacyConfig = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
pageDecorationsMode: undefined,
|
||||||
|
header: {
|
||||||
|
...defaultExportConfig.header,
|
||||||
|
fontFamily: undefined,
|
||||||
|
showOnFirstPage: undefined
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
...defaultExportConfig.footer,
|
||||||
|
fontFamily: undefined,
|
||||||
|
showOnFirstPage: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const migrated = parseStoredExportConfig(
|
||||||
|
JSON.stringify(legacyConfig)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(migrated.pageDecorationsMode).toBe("theme");
|
||||||
|
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
|
||||||
|
expect(migrated.header.showOnFirstPage).toBe(true);
|
||||||
|
expect(migrated.footer.showOnFirstPage).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保留旧 v4 缓存中用户修改过的页眉页码", () => {
|
||||||
|
const legacyConfig = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
pageDecorationsMode: undefined,
|
||||||
|
header: {
|
||||||
|
...defaultExportConfig.header,
|
||||||
|
enabled: true,
|
||||||
|
fontFamily: undefined,
|
||||||
|
center: {
|
||||||
|
enabled: true,
|
||||||
|
content: "${title}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
...defaultExportConfig.footer,
|
||||||
|
alignment: "right",
|
||||||
|
fontFamily: undefined,
|
||||||
|
showOnFirstPage: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const migrated = parseStoredExportConfig(
|
||||||
|
JSON.stringify(legacyConfig)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(migrated.pageDecorationsMode).toBe("custom");
|
||||||
|
expect(migrated.header.enabled).toBe(true);
|
||||||
|
expect(migrated.header.center.content).toBe("${title}");
|
||||||
|
expect(migrated.footer.alignment).toBe("right");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("保存并恢复经过校验的配置", () => {
|
it("保存并恢复经过校验的配置", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getPdfCanvasRenderScale,
|
getPdfCanvasRenderScale,
|
||||||
getPdfPageCssDimensions,
|
getPdfPageCssDimensions,
|
||||||
|
getPrecisePdfDocumentWidth,
|
||||||
PDF_PAGE_CSS_SCALE
|
PDF_PAGE_CSS_SCALE
|
||||||
} from "../src/pdf-page-geometry";
|
} from "../src/pdf-page-geometry";
|
||||||
|
|
||||||
@@ -31,4 +32,26 @@ describe("精确 PDF 页面尺寸", () => {
|
|||||||
);
|
);
|
||||||
expect(getPdfCanvasRenderScale(0, 800, 2)).toBe(0);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ThemeSummary } from "../src/application-backend";
|
||||||
|
import { groupThemesByCategory } from "../src/theme-groups";
|
||||||
|
|
||||||
|
function createTheme(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
category: ThemeSummary["category"]
|
||||||
|
): ThemeSummary {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "",
|
||||||
|
compatibleProfiles: [],
|
||||||
|
bundled: true,
|
||||||
|
source: "bundled"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("groupThemesByCategory", () => {
|
||||||
|
it("按稳定顺序输出非空主题分组", () => {
|
||||||
|
const groups = groupThemesByCategory([
|
||||||
|
createTheme("tender", "标书", "tender"),
|
||||||
|
createTheme("general", "通用", "general"),
|
||||||
|
createTheme("red", "红头", "red-letter")
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups.map(({ category, label }) => ({ category, label })))
|
||||||
|
.toEqual([
|
||||||
|
{ category: "general", label: "通用主题" },
|
||||||
|
{ category: "red-letter", label: "政企红头" },
|
||||||
|
{ category: "tender", label: "标书正式" }
|
||||||
|
]);
|
||||||
|
expect(groups[0]?.themes[0]?.id).toBe("general");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
|
import type { ThemeSummary } from "../src/application-backend";
|
||||||
|
import {
|
||||||
|
applyThemeDefaults,
|
||||||
|
applyThemeMargins,
|
||||||
|
selectTheme
|
||||||
|
} from "../src/theme-margins";
|
||||||
|
|
||||||
|
function createTheme(
|
||||||
|
id: string,
|
||||||
|
margins?: ThemeSummary["pageDefaults"]
|
||||||
|
): ThemeSummary {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "",
|
||||||
|
category: "formal",
|
||||||
|
compatibleProfiles: [],
|
||||||
|
pageDefaults: margins,
|
||||||
|
bundled: true,
|
||||||
|
source: "bundled"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formalTheme = createTheme("formal", {
|
||||||
|
margins: {
|
||||||
|
top: "37mm",
|
||||||
|
right: "26mm",
|
||||||
|
bottom: "35mm",
|
||||||
|
left: "28mm"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const officialTheme = createTheme("official", {
|
||||||
|
...formalTheme.pageDefaults,
|
||||||
|
header: {
|
||||||
|
...defaultExportConfig.header,
|
||||||
|
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||||
|
showOnFirstPage: false
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
...defaultExportConfig.footer,
|
||||||
|
height: "7mm",
|
||||||
|
fontSize: "4.94mm",
|
||||||
|
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||||
|
color: "#000000",
|
||||||
|
alignment: "outer",
|
||||||
|
format: "official-page",
|
||||||
|
showOnFirstPage: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("主题推荐页边距", () => {
|
||||||
|
it("跟随模式切换主题时同步推荐值", () => {
|
||||||
|
const selected = selectTheme(defaultExportConfig, formalTheme);
|
||||||
|
|
||||||
|
expect(selected.themeId).toBe("formal");
|
||||||
|
expect(selected.paper.marginMode).toBe("theme");
|
||||||
|
expect(selected.paper.margins).toEqual(
|
||||||
|
formalTheme.pageDefaults?.margins
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("主题未声明推荐值时恢复全局 16mm", () => {
|
||||||
|
const config = selectTheme(defaultExportConfig, formalTheme);
|
||||||
|
const selected = selectTheme(config, createTheme("general"));
|
||||||
|
|
||||||
|
expect(selected.paper.margins).toEqual({
|
||||||
|
top: "16mm",
|
||||||
|
right: "16mm",
|
||||||
|
bottom: "16mm",
|
||||||
|
left: "16mm"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("自定义模式切换主题时保留用户值", () => {
|
||||||
|
const config = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
paper: {
|
||||||
|
...defaultExportConfig.paper,
|
||||||
|
marginMode: "custom" as const,
|
||||||
|
margins: {
|
||||||
|
top: "20mm",
|
||||||
|
right: "18mm",
|
||||||
|
bottom: "20mm",
|
||||||
|
left: "18mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selected = selectTheme(config, formalTheme);
|
||||||
|
|
||||||
|
expect(selected.themeId).toBe("formal");
|
||||||
|
expect(selected.paper.margins).toEqual(config.paper.margins);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("主题清单加载后补齐当前主题推荐值", () => {
|
||||||
|
expect(
|
||||||
|
applyThemeMargins(defaultExportConfig, formalTheme).paper.margins
|
||||||
|
).toEqual(formalTheme.pageDefaults?.margins);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("跟随主题时同步推荐的页眉和页码", () => {
|
||||||
|
const selected = selectTheme(defaultExportConfig, officialTheme);
|
||||||
|
|
||||||
|
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",
|
||||||
|
showOnFirstPage: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("页面装饰自定义模式切换主题时保留用户配置", () => {
|
||||||
|
const config = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
pageDecorationsMode: "custom" as const,
|
||||||
|
footer: {
|
||||||
|
...defaultExportConfig.footer,
|
||||||
|
alignment: "left" as const,
|
||||||
|
format: "page" as const
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selected = selectTheme(config, officialTheme);
|
||||||
|
|
||||||
|
expect(selected.header).toEqual(config.header);
|
||||||
|
expect(selected.footer).toEqual(config.footer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("主题清单加载后补齐当前主题页面装饰", () => {
|
||||||
|
const applied = applyThemeDefaults(
|
||||||
|
defaultExportConfig,
|
||||||
|
officialTheme
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(applied.footer.format).toBe("official-page");
|
||||||
|
expect(applied.footer.alignment).toBe("outer");
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-1
@@ -9,6 +9,17 @@ const rootPackage = JSON.parse(
|
|||||||
"utf8"
|
"utf8"
|
||||||
)
|
)
|
||||||
) as { version: string };
|
) 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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -16,7 +27,11 @@ export default defineConfig({
|
|||||||
new URL("../../logos/web", import.meta.url)
|
new URL("../../logos/web", import.meta.url)
|
||||||
),
|
),
|
||||||
define: {
|
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: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
+67
-2
@@ -11,18 +11,27 @@ COPY package.json package-lock.json ./
|
|||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/core/package.json packages/core/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/application/package.json packages/application/package.json
|
||||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/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/preview-engine/package.json packages/preview-engine/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY tsconfig.base.json ./
|
COPY tsconfig.base.json product.json ./
|
||||||
COPY apps ./apps
|
COPY apps ./apps
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
COPY logos ./logos
|
COPY logos ./logos
|
||||||
|
COPY samples ./samples
|
||||||
RUN npm run build -w @md-to-pdf/markdown-echarts \
|
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/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/renderer \
|
||||||
&& npm run build -w @md-to-pdf/application \
|
&& npm run build -w @md-to-pdf/application \
|
||||||
&& npm run build -w @md-to-pdf/preview-engine \
|
&& npm run build -w @md-to-pdf/preview-engine \
|
||||||
@@ -38,12 +47,50 @@ COPY package.json package-lock.json ./
|
|||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/core/package.json packages/core/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/application/package.json packages/application/package.json
|
||||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/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/preview-engine/package.json packages/preview-engine/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
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
|
FROM ${RUNTIME_BASE_IMAGE} AS runtime
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
@@ -62,12 +109,20 @@ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
|||||||
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
|
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2
|
||||||
|
RUN ln -s /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc \
|
||||||
|
&& test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --chown=pwuser:pwuser package.json package-lock.json ./
|
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/server/package.json apps/server/package.json
|
||||||
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/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/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/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/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
|
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
|
||||||
@@ -85,11 +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/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/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/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/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/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/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/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 themes ./themes
|
||||||
|
COPY --chown=pwuser:pwuser output/font-packs/root /app/.local/font-packs
|
||||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
|
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
|
||||||
|
|
||||||
@@ -100,7 +162,10 @@ ENV HOST=127.0.0.1 \
|
|||||||
PDF_RENDER_ORIGIN=http://127.0.0.1:8080 \
|
PDF_RENDER_ORIGIN=http://127.0.0.1:8080 \
|
||||||
PDF_CONCURRENCY=1 \
|
PDF_CONCURRENCY=1 \
|
||||||
PDF_MAX_QUEUE=4 \
|
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
|
||||||
|
|
||||||
USER pwuser
|
USER pwuser
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ coverage
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
out
|
out
|
||||||
output
|
output/*
|
||||||
|
!output/font-packs/
|
||||||
|
output/font-packs/*
|
||||||
|
!output/font-packs/root/
|
||||||
|
!output/font-packs/root/**
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
tmp
|
tmp
|
||||||
|
|||||||
+52
-7
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
|
本目录提供单容器部署:Nginx 在 `8080` 端口提供前端,并将
|
||||||
`/api/` 代理到同容器内的 Fastify;Fastify 复用固定版本
|
`/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
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,13 +48,13 @@ docker compose -f deploy\compose.yaml build
|
|||||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.0 应用代码和生产
|
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.6.0 应用代码和生产
|
||||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||||
|
|
||||||
## 主题挂载
|
## 主题挂载
|
||||||
@@ -69,10 +70,51 @@ docker compose -f deploy\compose.yaml up -d
|
|||||||
目录下每套主题都应包含 `theme.json`。主题规范详见
|
目录下每套主题都应包含 `theme.json`。主题规范详见
|
||||||
[`docs/THEMES.md`](../docs/THEMES.md)。
|
[`docs/THEMES.md`](../docs/THEMES.md)。
|
||||||
|
|
||||||
当前内置 Typora Github、Typora Pixyll、Typora whitey 和
|
当前内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||||
Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,无需重复
|
3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
|
||||||
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
|
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
|
||||||
|
|
||||||
|
## 内置字体包
|
||||||
|
|
||||||
|
v0.6.0 的发行镜像固定将受校验字体包内置到
|
||||||
|
`/app/.local/font-packs`。Compose 不挂载宿主机字体目录,因此部署端
|
||||||
|
无需额外复制字体,Preview、PDF 和 DOCX 可以离线使用同一套原生资产。
|
||||||
|
|
||||||
|
字体包使用固定目录结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<字体包根目录>/
|
||||||
|
└── mdtp-serif-sc/
|
||||||
|
└── 1.0.0/
|
||||||
|
├── font-pack.json
|
||||||
|
├── LICENSE.txt
|
||||||
|
└── fonts/
|
||||||
|
```
|
||||||
|
|
||||||
|
从源码构建镜像前,先从仓库外冻结源生成字体包,再构建镜像:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build:font-pack
|
||||||
|
docker compose -f deploy\compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
字体二进制仍不提交到 Git。Docker 构建上下文只放行本次生成的
|
||||||
|
`output/font-packs/root`,镜像构建会在缺少清单时立即失败。运行时注册器
|
||||||
|
仍会复核许可证、文件类型、路径边界、容量和 SHA-256。更换字体包内容时
|
||||||
|
必须提升字体包 SemVer 并重新构建镜像,不能覆盖同一个版本。
|
||||||
|
|
||||||
|
发布前执行真实内置字体门禁:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.6.0'
|
||||||
|
npm run verify:docker-font-pack
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令要求本机 Docker 引擎已经启动,并且冻结字体源可供
|
||||||
|
`npm run build:font-pack` 生成。验收会重建指定镜像,核对镜像内清单哈希、
|
||||||
|
确认不存在字体运行时挂载,并检查健康状态、Pandoc capability、WOFF2
|
||||||
|
资源和 DOCX 字体部件;所有临时容器、网络和目录会在结束时清理。
|
||||||
|
|
||||||
## 运行参数
|
## 运行参数
|
||||||
|
|
||||||
| 环境变量 | 默认值 | 说明 |
|
| 环境变量 | 默认值 | 说明 |
|
||||||
@@ -82,6 +124,7 @@ Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,
|
|||||||
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
||||||
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
||||||
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
||||||
|
| `MD_TO_PDF_APP_VERSION` | `0.6.0` | 字体包兼容性判断使用的应用版本 |
|
||||||
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
||||||
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
| `PDF_MAX_QUEUE` | `4` | 等待队列上限 |
|
||||||
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
| `PDF_TIMEOUT_MS` | `120000` | 单次 PDF 生成超时 |
|
||||||
@@ -91,4 +134,6 @@ Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,
|
|||||||
|
|
||||||
容器为 Chromium 提供 1 GiB 共享内存,并以非 root `pwuser` 运行。
|
容器为 Chromium 提供 1 GiB 共享内存,并以非 root `pwuser` 运行。
|
||||||
构建阶段使用项目锁定的 Playwright `1.62.0` 安装精确匹配的 Chromium
|
构建阶段使用项目锁定的 Playwright `1.62.0` 安装精确匹配的 Chromium
|
||||||
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。
|
及系统依赖,不依赖版本可能滞后的预制浏览器镜像标签。DOCX 运行时仅
|
||||||
|
支持 Linux amd64,Pandoc 二进制、许可证和版权文件均在构建阶段执行
|
||||||
|
固定版本与 SHA-256 校验。
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user