37 Commits
Author SHA1 Message Date
SkyJourney 842c3f1782 release: 发布墨呈 v0.6.0
新增能力:实现可编辑 DOCX 导出、通用主题样式翻译、Markdown 基础工具栏、统一导出菜单与导出进度提示;14 套主题支持纸张、页边距、字体、段落、代码、图片、图表、封面、页眉页脚和页码映射。

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

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

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

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

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

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

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00
SkyJourney f9f5fccfc9 fix: 修复桌面字体转换依赖启动崩溃 2026-08-01 11:12:30 +08:00
SkyJourney 3081c02887 feat: 完成 Docker DOCX 与可选字体包部署链 2026-08-01 08:56:20 +08:00
SkyJourney 7f2c169405 feat: 优化桌面文件默认保存路径 2026-08-01 04:09:21 +08:00
SkyJourney dc026835b7 fix: 修复精确预览放大后右侧留白丢失 2026-07-31 23:42:04 +08:00
SkyJourney 6f62cfebd6 feat: 实现 Windows 可选字体包安装链 2026-07-31 22:29:23 +08:00
SkyJourney ae9fde3ac6 feat: 建立可复现字体包构建链 2026-07-31 21:52:36 +08:00
SkyJourney 9f96d51922 feat: 接入可选字体包同源渲染链 2026-07-31 21:15:45 +08:00
SkyJourney c76454be9d feat: 建立可选字体包协议与安全注册器 2026-07-31 20:36:22 +08:00
SkyJourney e5da699ea3 feat: 完成 DOCX 视觉门禁与字体兼容映射 2026-07-31 20:13:19 +08:00
SkyJourney 10bc62cee4 feat: 实现 DOCX 可编辑字体嵌入 2026-07-31 16:18:30 +08:00
SkyJourney f3847f3e4b feat: 完成 DOCX 通用结构映射与严格收口 2026-07-31 15:19:34 +08:00
SkyJourney 6e40374200 feat: 将主题令牌接入 DOCX 动态模板 2026-07-31 08:43:26 +08:00
SkyJourney 459a079f3b feat: 建立统一语义文档模型 2026-07-30 23:47:51 +08:00
SkyJourney 7f72258126 feat: 实现 DOCX 主题令牌归一化 2026-07-30 22:57:46 +08:00
SkyJourney d2acc9c5ce feat: 实现 DOCX 主题样式采集 2026-07-30 22:09:30 +08:00
SkyJourney 6086762b4f feat: 建立 DOCX 标准样式探针 2026-07-30 21:27:29 +08:00
SkyJourney 6fed666f44 feat: 建立 DOCX 主题映射引擎 2026-07-30 20:28:47 +08:00
SkyJourney e7c688df6e test: 完成 DOCX 全主题视觉验收 2026-07-30 19:04:12 +08:00
SkyJourney aff2830c28 test: 建立 DOCX 自动化验收门禁 2026-07-30 16:48:41 +08:00
SkyJourney f2dfc6ef72 feat: 实现 Web 与 Desktop DOCX 导出交互 2026-07-30 16:05:45 +08:00
SkyJourney bf43433d38 feat: 实现 Pandoc DOCX 转换服务 2026-07-30 15:05:50 +08:00
SkyJourney fa159d916f feat: 实现动态 DOCX 模板与样式映射 2026-07-30 14:10:43 +08:00
SkyJourney fe5d67fb6d feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获 2026-07-30 13:21:18 +08:00
SkyJourney 7831bc23f0 feat: 升级 CodeMirror 编辑器并新增 Markdown 工具栏 2026-07-30 12:39:35 +08:00
SkyJourney f7d4efeafc feat: 建立 DOCX 共享协议与准备服务 2026-07-30 11:04:18 +08:00
SkyJourney fb3ddada39 docs: 固化 Pandoc 分发与内部许可策略 2026-07-30 10:49:21 +08:00
SkyJourney 81d9099eae docs: 冻结 v0.6.0 架构与验收边界 2026-07-30 10:23:04 +08:00
SkyJourney 83e6559c2c release: 发布 v0.5.1
新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。

桌面工作流:重组更多菜单,增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径,主题示例自动切换主题,Desktop 发行包完整携带教程、示例与字体。

问题修复:修正红头标题居中与正式文档字体;围栏代码按正文宽度自动换行,长内容安全断行,至少两行即可在当前页分页,并统一保留 8px 左侧内容留白;Docker Web 镜像正确打包 samples。

兼容与部署:未声明主题推荐设置时继续使用 16mm 默认页边距;Web 隐藏不适用的另存为。正式镜像 yixiong/md-to-pdf:v0.5.1 内容 ID 为 sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276,Compose 健康运行。

验证结果:最终修复前 65 个测试文件、286 项测试通过,最终代码块与主题定向测试 27 项通过;全项目类型检查、生产构建和 git diff --check 通过。容器检出 14 套主题、17 份 Markdown,代码块回归 PDF 为 2 页且无越界。NSIS SHA-256 为 676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC,ZIP SHA-256 为 82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F。
2026-07-29 16:58:17 +08:00
SkyJourney 58087d0c7e release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。

完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。

统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。

发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。

验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
2026-07-28 18:01:22 +08:00
412 changed files with 59035 additions and 1649 deletions
+6
View File
@@ -1 +1,7 @@
deploy/entrypoint.sh text eol=lf
# 正式字体资产保存在仓库固定目录,但二进制内容交由 Git LFS 管理。
font-packs/assets/**/*.ttf filter=lfs diff=lfs merge=lfs -text
font-packs/assets/**/*.otf filter=lfs diff=lfs merge=lfs -text
font-packs/assets/**/*.woff filter=lfs diff=lfs merge=lfs -text
font-packs/assets/**/*.woff2 filter=lfs diff=lfs merge=lfs -text
+6
View File
@@ -3,15 +3,21 @@ dist/
coverage/
.env
.env.local
.conda_env
.local/
*.log
.DS_Store
Thumbs.db
.idea/
.vscode/
.serena/
tmp/
playwright-report/
test-results/
# 构建、门禁与正式发版产物只保留在本机,不进入 Git。
output/
/apps/desktop/out/
/apps/desktop/.runtime/
/release/
*.tsbuildinfo
+2
View File
@@ -80,6 +80,7 @@ flowchart LR
- 应用服务:`packages/application`
- 共享模型:`packages/core`
- Markdown 渲染:`packages/renderer`
- 连续预览与分页引擎:`packages/preview-engine`
- ECharts 协议与运行时:`packages/markdown-echarts`
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
- Markdownmarkdown-it 及 `@mdit/plugin-*` 插件。
@@ -102,6 +103,7 @@ packages/application/ 共享应用服务、主题注册和图片资源处理
packages/core/ 导出配置和主题清单等共享模型
packages/renderer/ Markdown 到安全 HTML 的渲染管线
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
themes/ 内置主题
deploy/ Docker、Compose、Nginx 和部署文档
docs/ 进度、架构和部署文档
+107
View File
@@ -0,0 +1,107 @@
YIXIONG Tech.ltd 公司内部专属软件许可证
版本 1.02026 年
版权所有 © 2026 YIXIONG Tech.ltd。保留所有权利。
一、适用范围
本许可证适用于“墨呈(MorphDoc)”项目中由 YIXIONG Tech.ltd 拥有
著作权的源代码、原创主题、原创文档、构建脚本和其他原创材料(以下简称
“本软件”)。
本软件包含或调用的第三方程序、依赖、字体、主题和其他材料不受本许可证
重新许可,继续适用其各自的许可证、授权条件和使用边界。
二、授权对象
“内部授权用户”是指经 YIXIONG Tech.ltd 明确授权,并且属于以下范围的
个人或组织:
1. YIXIONG Tech.ltd 的员工;
2. YIXIONG Tech.ltd 直接或间接控制的组织;
3. 因内部项目需要而获得明确书面授权的承包商、供应商或合作人员。
授权在相关人员不再属于上述范围、授权被撤销或合作关系终止时结束,但
依法或书面约定需要继续履行的保密、知识产权和责任条款不因此失效。
三、允许的行为
在遵守本许可证、公司制度和适用法律的前提下,内部授权用户可以:
1. 为公司内部业务、测试、研发、评估和运维目的运行本软件;
2. 阅读、复制和修改本软件源代码;
3. 在公司控制或明确授权的设备、服务器、容器和内部网络中部署本软件;
4. 在内部授权用户之间分发源代码、Docker 镜像、安装包、压缩包和测试
产物;
5. 为内部使用目的制作和分发修改版本;
6. 使用本软件处理合法取得的 Markdown、图片、主题和其他文档资源。
四、禁止的行为
未经 YIXIONG Tech.ltd 事先明确书面许可,任何人不得:
1. 向公司外部或公开网络发布、上传、分发、提供下载或公开展示本软件的
源代码、二进制、镜像、安装包或实质性部分;
2. 出售、出租、转授权、托管经营或以本软件向公司外部提供商业服务;
3. 将本软件或修改版本置于开源许可证、公共领域声明或其他与本许可证
冲突的授权条件下;
4. 删除或隐瞒版权、许可证、来源、第三方声明或其他权利标识;
5. 超出第三方材料自身许可证或授权边界使用、复制或分发第三方材料;
6. 使用本软件侵犯他人知识产权、隐私、商业秘密或其他合法权益;
7. 规避访问控制、安全限制、资源隔离或审计措施。
五、修改与贡献
内部授权用户为公司项目提交的修改、补丁、文档和其他贡献,按照其劳动、
合作、保密、知识产权归属协议及公司制度处理。本许可证本身不改变已经
存在的职务成果、委托开发或合作开发权利归属。
未经 YIXIONG Tech.ltd 明确书面决定,内部修改或贡献不会使本软件自动
转为开源软件,也不会产生向公司外部公开源代码的义务。
六、用户文档与导出结果
用户输入的 Markdown、图片、数据和其他内容,其权利归原权利人所有。
使用本软件生成的 PDF、DOCX、图片或其他导出结果,不会仅因使用本软件而
自动受本许可证约束。
导出结果中嵌入的字体、图片、模板、主题或其他第三方材料,仍可能受其
各自许可证或授权条件约束,使用者应自行确保拥有相应权利。
七、第三方材料
本软件的第三方许可证与特殊分发边界记录在 `THIRD_PARTY_NOTICES.md`
以及相关资源目录的许可证文件中。若本许可证与第三方材料的有效许可证
发生冲突,对该第三方材料以其自身许可证为准。
八、未来公开许可
YIXIONG Tech.ltd 可以在未来自行决定以 Apache License 2.0、其他开源
许可证或其他授权方式发布本软件的某个版本。任何未来公开许可仅自明确
发布之日起适用于指定版本,不追溯授予当前内部版本,也不自动覆盖第三方
材料。
在公开许可正式发布前,本软件不是面向公众的开源软件。源代码可在公司
内部查看和修改,不等于授予公众使用、复制、修改或分发权。
九、免责声明
在适用法律允许的最大范围内,本软件按“现状”提供,不附带任何明示或
默示保证,包括但不限于适销性、特定用途适用性、无侵权、持续可用或数据
完整性保证。
十、责任限制
在适用法律允许的最大范围内,YIXIONG Tech.ltd 及贡献者不对因使用、
无法使用、修改、部署或分发本软件导致的任何间接、附带、特殊、惩罚性或
后果性损失承担责任。公司内部另有有效合同、制度或书面约定的,从其约定。
十一、终止
违反本许可证的授权自动终止。YIXIONG Tech.ltd 有权要求停止使用、删除
未经授权的副本,并依据适用法律、合同、保密协议和公司制度追究责任。
十二、其他
本许可证未明确授予的权利均由 YIXIONG Tech.ltd 保留。对本许可证的任何
例外、补充或变更必须由 YIXIONG Tech.ltd 以明确书面形式作出。
+10
View File
@@ -0,0 +1,10 @@
墨呈(MorphDoc
Copyright 2026 YIXIONG Tech.ltd
This product includes software developed by YIXIONG Tech.ltd and
contributors.
The original project code and documentation are currently licensed only for
authorized internal use under the proprietary LICENSE in this repository.
Third-party components and assets remain under their respective licenses.
See THIRD_PARTY_NOTICES.md.
+63 -35
View File
@@ -1,26 +1,40 @@
# Markdown PDF 导出器
# 墨呈
一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
> 一稿多式,即写即呈
当前版本:`v0.4.5`
面向结构化 Markdown 的主题化文档创作与发布工具,同时提供 Web 与
Windows 桌面端。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题
CSS 和分页运行时,DOCX 使用同源语义文档、主题令牌和可编辑 OOXML。
## v0.4.5 主要能力
当前版本:`v0.6.0`
- 新建、打开和保存 Markdown;新文档可直接导出 PDF
- Windows 注册 `.md``.markdown` 文件关联,系统打开文件时自动加载;
- 桌面窗口位置、尺寸和最大化状态延迟持久化,并在多显示器变化后自动
修正到可见工作区
- “打开 Markdown”保留为独立主按钮,新建、保存和主题目录收纳在“更多”
菜单
- 桌面端提供可直接放置自定义主题的本地目录;
- 图片、Mermaid 和 ECharts 采用串行媒体分页:每个媒体块至多重排一次,
严格保持文档顺序;
- 媒体先按内容限宽计算基准高度。当上一页空白接近媒体所需空间时,允许
将媒体内容等比例缩小回填;图片标题或图表标题保持原字号且与媒体成块;
- 示例文档覆盖横图、竖图、Base64 图片、Mermaid、ECharts 以及不同尺寸
的分页边界。
## v0.6.0 核心更新
- 新增可编辑 DOCX 导出,使用固定 Pandoc、动态 Word 样式、原生分节、
页眉页脚、页码和高分辨率 PNG 媒体管线
- 主题 CSS 通过通用语义槽位翻译为 Word 样式,支持 14 套内置主题、
公文结构、独立封面、代码、表格、图片、Mermaid 与 ECharts
- 新增 CodeMirror 6 Markdown 工具栏,支持标题、行内格式、引用、列表、
代码块、分隔线和尺寸表格;
- 产品品牌升级为“墨呈”,Windows 主程序和发行产物使用英文名
`MorphDoc`
## v0.5.1 主题与公文能力
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
以及 3 套正式文档主题和 3 套标书主题;
- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
16mm 默认页边距,选择正式主题时自动采用其规范参数;
- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
同时保留 `8px` 左侧内容留白;
- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown
Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
Web 端隐藏不适用的“另存为”。
## Web 与桌面端
@@ -28,11 +42,17 @@
| --- | --- | --- |
| 新建 Markdown | 支持 | 支持 |
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
| 保存 Markdown | 浏览器下载 | 原生另存为 |
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
| DOCX 引擎 | 固定版本 Pandoc | 内置固定版本 Pandoc |
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
DesktopWeb 端可使用 Base64/Data URL 或受限公网图片。
@@ -44,16 +64,18 @@ flowchart LR
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
B --> C["统一 #write HTML"]
D["主题 CSS 与导出配置"] --> C
C --> E["共享分页运行时"]
E --> F["Web 快速预览"]
E --> G["Playwright Chromium"]
E --> H["Electron Chromium"]
G --> I["Web PDF"]
H --> J["Desktop PDF"]
C --> E["共享 Preview Engine"]
E --> F["连续预览"]
E --> G["快速分页预览"]
E --> H["Playwright Chromium"]
E --> I["Electron Chromium"]
H --> J["Web PDF"]
I --> K["Desktop PDF"]
```
快速预览适合编辑反馈;精确预览直接展示服务端生成的 PDF,是 Web 最终
分页的权威结果。桌面 PDF 复用相同分页载荷和运行时。
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
载荷和运行时。
## 项目目录
@@ -67,6 +89,7 @@ packages/
renderer/ Markdown 到安全 #write HTML 的渲染管线
application/ 共享渲染、主题注册和图片资源应用服务
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
themes/ 内置主题与主题清单
deploy/ Dockerfile、Compose、Nginx 和部署文档
docs/ 项目进度与主题开发说明
@@ -75,9 +98,10 @@ logos/ 应用图标源文件
.local/themes/ 本地开发和 Compose 自定义主题目录
```
包依赖保持单向:`core` 提供基础协议;`markdown-echarts` 提供独立图表
能力;`renderer` 消费两者生成安全 HTML`application` 组合渲染器、主题
和资源用例;三个 `apps/*` 只负责各自平台的界面、传输与 PDF 适配。
包依赖保持单向:`core` 提供基础协议和链接分类`markdown-echarts`
提供独立图表能力;`renderer` 消费两者生成安全 HTML`application`
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
各包的目录和用法:
@@ -85,6 +109,7 @@ logos/ 应用图标源文件
- [core](packages/core/README.md)
- [renderer](packages/renderer/README.md)
- [markdown-echarts](packages/markdown-echarts/README.md)
- [preview-engine](packages/preview-engine/README.md)
## 本地开发
@@ -114,8 +139,10 @@ npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
产物位于 `apps/desktop/out/v0.4.5/`。正式安装包命名为
`md-to-pdf-0.4.5-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并
校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的
`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`
正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。
## 文档与媒体
@@ -134,8 +161,9 @@ ECharts 围栏语法、支持系列和安全边界详见
## 主题
仓库内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题。主题
版本化 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题
3 套标书主题,默认使用 GitHub。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/`
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
+80
View File
@@ -0,0 +1,80 @@
# 第三方许可证与公开发布说明
根目录 `LICENSE` 是当前公司内部专属许可证,适用于本项目原创代码、原创
主题和原创文档,不会替换第三方组件、字体、主题或工具各自的许可证。
Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前仓库没有
通过 Apache License 2.0 向公司外部授予项目原创内容的使用或分发权。
本文件是发行审查入口,不替代各第三方项目附带的完整许可证。正式发行前
还应根据最终生产依赖生成完整的软件物料清单和许可证清单。
## Pandoc
`v0.6.0` 将未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。
- 项目:Pandoc
- 版权:John MacFarlane 及贡献者
- 许可证:GNU General Public Licenseversion 2 or later
- 交互方式:独立子进程、命令行参数和临时文档文件
- 项目地址:<https://pandoc.org/>
- 源码地址:<https://github.com/jgm/pandoc>
项目不链接、导入或修改 Pandoc 程序代码。发行包必须保留 Pandoc 的
`COPYING``COPYRIGHT` 和版本信息,并在相同版本的发行位置提供对应源码
获取方式。具体版本、校验值和分发规则见
[`docs/PANDOC_DISTRIBUTION.md`](docs/PANDOC_DISTRIBUTION.md)。
## Fandol 字体
`themes/_shared/official-fonts/` 包含 Fandol 中文字体的 WOFF2 转换版本。
Fandol 字体采用 GNU GPL,并附带字体例外条款。完整许可证、上游说明、
文件哈希和来源信息保存在该目录:
- [`LICENSE.txt`](themes/_shared/official-fonts/LICENSE.txt)
- [`README.md`](themes/_shared/official-fonts/README.md)
- [`UPSTREAM-README.txt`](themes/_shared/official-fonts/UPSTREAM-README.txt)
使用字体生成的 PDF、图片或 DOCX 不会仅因为使用该字体而自动受 GPL
约束。重新分发字体时必须保留上述许可证和来源文件。
## npm、Electron 与 Chromium 组件
项目直接或间接使用 MIT、Apache-2.0、BSD、ISC、EPL-2.0、MPL-2.0、
CC-BY-4.0 等许可证的软件包。`package-lock.json` 固定具体依赖版本;
Electron 和 Chromium 发行目录还包含其自身第三方许可证文件。
构建产物不得删除 Electron、Chromium 或其他依赖已经附带的许可证文件。
正式发布阶段应基于生产依赖和浏览器 Bundle 生成最终第三方清单,而不能
仅以根目录项目许可证代替。
## 仅限公司内部使用的 Typora 资源
以下目录来自公司内部安装的 Typora 1.14.7,目前没有确认可用于公开再
分发的完整授权:
```text
themes/typora-github/
themes/typora-pixyll/
themes/typora-whitey/
```
它们不受本项目内部专属许可证重新授权。即使其中部分样式可追溯到 MIT
上游,也不能据此推定 Typora 修改版本整体可以公开分发。
当前项目属于公司内部源码开放和内部部署阶段。根据现有内部使用决策,
公司内部的源码仓库、Web/Docker 部署、Desktop 安装包、ZIP 和测试产物
可以继续携带这三套主题,但不得向公司外部或公开网络发布、销售或授权。
接收内部发行物的人员也不能依据根目录项目许可证对这三套主题进行公司外
再分发。
在将项目发布到公开 GitHub 仓库前,必须完成以下方案之一:
1. 取得明确的公开分发授权;
2. 使用许可证明确的上游资源重新实现并完成来源审查;
3. 删除这些目录,并从准备公开的 Git 历史中清除其内容。
仅在后续提交中删除目录仍会使旧版本存在于 Git 历史。若不能取得授权,
公开发布应使用经过清理的历史或从合规快照创建新的公开仓库。上述门禁
只针对公司外部和公开网络发行,不阻止当前公司内部源码共享、部署和
安装包分发。
+45 -11
View File
@@ -8,12 +8,17 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
落在屏幕外。Windows 安装包会注册 `.md``.markdown` 文件关联,
双击文件或使用“打开方式”会复用已有应用窗口加载文档。
双击文件或使用“打开方式”会在对应文档窗口加载文件;同一文件只保留
一个窗口,其他文件可以同时使用多个窗口。
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
重新加载,避免阅读或导出磁盘旧版本。
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
## 开发
@@ -36,9 +41,24 @@ npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
`package``make` 会先调用根级 `build:web-runtime`,完整重建
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web
再按固定清单下载或复用 Pandoc `3.9.0.2` 缓存、校验官方 ZIP 的
SHA-256,并执行版本与最小 DOCX 冒烟,最后才调用 electron-builder。
不得绕过该链路直接调用 electron-builder。
Pandoc 构建缓存位于被 Git 忽略的 `apps/desktop/.runtime/`。发行目录只
包含 `pandoc.exe``COPYING.rtf``COPYRIGHT.txt` 和生成的审计清单,
不会携带官方 MSI、手册、下载缓存或用户级 Pandoc 配置。已准备运行时可用
以下命令离线复验:
```powershell
npm run verify:pandoc-runtime -w @md-to-pdf/desktop
```
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
`apps/desktop/out/v0.6.0/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
@@ -47,16 +67,30 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
发布,不提交进 Git 历史。
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
“运行”操作直接启动 `MorphDoc.exe`,不依赖快捷方式文件。
产品名称、窗口标题、安装包名称和开始菜单名称保持
Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
`md-to-pdf.exe`
界面、快捷方式与卸载项使用中文名“墨呈”,窗口标题
墨呈 · MorphDoc”,默认安装目录和 Windows 主程序分别为 `MorphDoc`
`MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保
v0.5.1 覆盖升级沿用原卸载身份;v0.6.0 使用独立的安装位置注册表键,
升级时由旧版卸载器清理 `md-to-pdf` 目录,再安装到 `MorphDoc` 目录,
避免产品改名后继续继承旧目录名。
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium
Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。
## 链接与窗口行为
- 当前文档 `#anchor` 在当前预览中定位;
- HTTP/HTTPS 链接交给系统默认浏览器;
- `mailto:` 等系统协议交给操作系统处理;
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
- Markdown 文件会提示在当前窗口或新窗口打开;
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
+123 -18
View File
@@ -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
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown" \
"" \
@@ -6,11 +82,11 @@
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
"" \
'"$INSTDIR\md-to-pdf.exe",0'
'"$INSTDIR\${MORPHDOC_EXECUTABLE}",0'
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
WriteRegStr SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
@@ -20,33 +96,57 @@
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\shell\open\command" \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
".md" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
".markdown" \
""
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
CreateDirectory "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
CreateShortCut \
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
"$INSTDIR\md-to-pdf.exe" \
"$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
"" \
"$INSTDIR\md-to-pdf.exe"
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
CreateShortCut \
"$DESKTOP\Markdown PDF 导出器.lnk" \
"$INSTDIR\md-to-pdf.exe" \
"$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
"" \
"$INSTDIR\md-to-pdf.exe"
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
!ifdef FONT_PACK_COMPANION_FILE
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done
StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed
DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}"
ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode
StrCmp $fontPackCompanionExitCode "0" font_pack_install_done
DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。"
Goto font_pack_install_done
font_pack_integrity_failed:
DetailPrint "可选字体包完整性复核失败,已跳过安装。"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。"
font_pack_install_done:
!endif
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
!macro customUnInstall
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
DeleteRegValue SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
@@ -54,11 +154,16 @@
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegKey SHCTX \
"Software\Classes\Applications\md-to-pdf.exe"
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}"
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
DeleteRegKey SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown"
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
Delete "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk"
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
+29 -8
View File
@@ -1,14 +1,20 @@
const path = require("node:path");
const { version } = require("./package.json");
const brand = require("../../product.json");
const pandocRuntime = require(
"../../packages/docx-engine/src/pandoc-runtime.json"
);
const windowsIcon = path.resolve(
__dirname,
"../../logos/desktop/windows/app.ico"
);
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
"build/installer.nsh";
module.exports = {
appId: "com.md-to-pdf.desktop",
productName: "Markdown PDF 导出器",
appId: brand.appId,
productName: brand.englishName,
copyright: "Copyright © YIXIONG Tech.ltd",
asar: true,
electronLanguages: ["zh-CN", "en-US"],
@@ -30,6 +36,21 @@ module.exports = {
to: "themes",
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,
to: "app.ico"
@@ -51,8 +72,8 @@ module.exports = {
}
],
icon: windowsIcon,
executableName: "md-to-pdf",
artifactName: `md-to-pdf-${version}-x86_64.\${ext}`
executableName: brand.executableName,
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`
},
nsis: {
oneClick: false,
@@ -61,12 +82,12 @@ module.exports = {
allowElevation: true,
createDesktopShortcut: false,
createStartMenuShortcut: false,
shortcutName: "Markdown PDF 导出器",
uninstallDisplayName: "Markdown PDF 导出器",
shortcutName: brand.displayName,
uninstallDisplayName: brand.displayName,
installerIcon: windowsIcon,
uninstallerIcon: windowsIcon,
installerLanguages: ["zh_CN"],
include: "build/installer.nsh",
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
include: nsisInclude,
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
}
};
+13 -6
View File
@@ -1,29 +1,36 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.4.5",
"version": "0.6.0",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
"productName": "MorphDoc",
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
"author": "YIXIONG Tech.ltd",
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload",
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
"build:main": "node scripts/build-main.mjs",
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
"package": "npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
"make": "npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
"package": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
"make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
},
"devDependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"electron-builder": "26.15.3",
"esbuild": "^0.25.0",
"fflate": "0.8.3",
"typescript": "^5.9.3",
"vitest": "^4.1.10",
"wait-on": "^9.0.1"
+34 -1
View File
@@ -1,4 +1,13 @@
import { build } from "esbuild";
import { copyFile, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const productBrand = JSON.parse(
await readFile(
fileURLToPath(new URL("../../../product.json", import.meta.url)),
"utf8"
)
);
await build({
entryPoints: ["src/main.ts"],
@@ -7,10 +16,34 @@ await build({
format: "esm",
external: ["electron"],
outfile: "dist/main.js",
define: {
__APP_ID__: JSON.stringify(productBrand.appId),
__APP_ENGLISH_NAME__: JSON.stringify(productBrand.englishName),
__APP_DISPLAY_NAME__: JSON.stringify(productBrand.displayName),
__APP_WINDOW_TITLE__: JSON.stringify(productBrand.windowTitle),
__APP_LEGACY_DISPLAY_NAME__: JSON.stringify(
productBrand.legacyDisplayName
)
},
banner: {
js: [
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
"const require = __mdToPdfCreateRequire(import.meta.url);"
'import { dirname as __mdToPdfDirname } from "node:path";',
'import { fileURLToPath as __mdToPdfFileURLToPath } from "node:url";',
"const require = __mdToPdfCreateRequire(import.meta.url);",
"const __dirname = __mdToPdfDirname(__mdToPdfFileURLToPath(import.meta.url));"
].join("\n")
}
});
await copyFile(
fileURLToPath(
new URL(
"../../../packages/docx-engine/assets/docx-media-filter.lua",
import.meta.url
)
),
fileURLToPath(
new URL("../dist/docx-media-filter.lua", import.meta.url)
)
);
@@ -0,0 +1,20 @@
export interface MinimalPandocArtifact {
executableRelativePath: string;
licenseFiles: string[];
}
export function sha256(content: Uint8Array): string;
export function extractRequiredFiles(
archive: Uint8Array,
artifact: MinimalPandocArtifact
): Map<string, Uint8Array>;
export function verifyPandocVersion(
executablePath: string,
expectedVersion: string
): void;
export function preparePandocRuntime(options?: {
checkOnly?: boolean;
}): Promise<string>;
@@ -0,0 +1,359 @@
import { createHash } from "node:crypto";
import {
access,
mkdir,
readFile,
rename,
rm,
writeFile
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { unzipSync } from "fflate";
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
const desktopRoot = path.join(projectRoot, "apps", "desktop");
const manifestPath = path.join(
projectRoot,
"packages",
"docx-engine",
"src",
"pandoc-runtime.json"
);
const maximumArchiveBytes = 64 * 1024 * 1024;
const maximumExtractedBytes = 256 * 1024 * 1024;
const downloadTimeoutMs = 120_000;
function sha256(content) {
return createHash("sha256").update(content).digest("hex").toUpperCase();
}
function normalizedBaseName(fileName) {
return fileName.replaceAll("\\", "/").split("/").filter(Boolean).at(-1);
}
async function fileExists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function readRuntimeManifest() {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const artifact = manifest.artifacts?.find(
(candidate) =>
candidate.platform === "win32" && candidate.architecture === "x64"
);
if (!artifact || artifact.archiveType !== "zip") {
throw new Error("Pandoc 清单缺少 Windows x64 ZIP 发行项");
}
const requiredNames = [
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
];
if (
requiredNames.some(
(fileName) =>
!fileName ||
!Number.isInteger(artifact.files?.[fileName]?.bytes) ||
!/^[A-F0-9]{64}$/u.test(artifact.files?.[fileName]?.sha256 ?? "")
)
) {
throw new Error("Pandoc 清单缺少最小运行时文件哈希或长度");
}
return { manifest, artifact };
}
async function downloadArchive(downloadUrl, destination) {
const response = await fetch(downloadUrl, {
redirect: "follow",
signal: AbortSignal.timeout(downloadTimeoutMs)
});
if (!response.ok) {
throw new Error(`Pandoc 下载失败:HTTP ${response.status}`);
}
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > maximumArchiveBytes
) {
throw new Error("Pandoc 归档超过允许的下载大小");
}
const content = new Uint8Array(await response.arrayBuffer());
if (content.byteLength > maximumArchiveBytes) {
throw new Error("Pandoc 归档超过允许的下载大小");
}
await writeFile(destination, content);
return content;
}
async function loadVerifiedArchive(artifact, archivePath) {
if (await fileExists(archivePath)) {
const cached = new Uint8Array(await readFile(archivePath));
if (sha256(cached) === artifact.sha256) {
return cached;
}
await rm(archivePath, { force: true });
}
const partialPath = `${archivePath}.${process.pid}.partial`;
await rm(partialPath, { force: true });
try {
const downloaded = await downloadArchive(
artifact.downloadUrl,
partialPath
);
const actualHash = sha256(downloaded);
if (actualHash !== artifact.sha256) {
throw new Error(
`Pandoc 归档 SHA-256 不匹配:期望 ${artifact.sha256},实际 ${actualHash}`
);
}
await rename(partialPath, archivePath);
return downloaded;
} finally {
await rm(partialPath, { force: true });
}
}
function extractRequiredFiles(archive, artifact) {
const requiredNames = new Set([
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
]);
let selectedBytes = 0;
const entries = unzipSync(archive, {
filter(file) {
const selected = requiredNames.has(normalizedBaseName(file.name));
if (selected) {
selectedBytes += file.originalSize;
if (selectedBytes > maximumExtractedBytes) {
throw new Error("Pandoc 最小运行时超过允许的解压大小");
}
}
return selected;
}
});
const files = new Map();
for (const [entryName, content] of Object.entries(entries)) {
const baseName = normalizedBaseName(entryName);
if (!baseName || !requiredNames.has(baseName)) {
continue;
}
if (files.has(baseName)) {
throw new Error(`Pandoc 归档包含重复文件:${baseName}`);
}
files.set(baseName, content);
}
for (const requiredName of requiredNames) {
if (!files.has(requiredName)) {
throw new Error(`Pandoc 归档缺少必须文件:${requiredName}`);
}
}
return files;
}
function verifyPandocVersion(executablePath, expectedVersion) {
const result = spawnSync(executablePath, ["--version"], {
encoding: "utf8",
maxBuffer: 64 * 1024,
windowsHide: true
});
if (result.error || result.status !== 0) {
throw new Error(`内置 Pandoc 无法执行:${result.error?.message ?? result.stderr}`);
}
const firstLine = result.stdout.split(/\r?\n/u)[0]?.trim();
if (firstLine !== `pandoc ${expectedVersion}`) {
throw new Error(
`内置 Pandoc 版本不匹配:期望 pandoc ${expectedVersion},实际 ${firstLine ?? "未知"}`
);
}
}
async function smokeTestPandoc(executablePath) {
const smokeRoot = path.join(
tmpdir(),
`morphdoc-pandoc-smoke-${process.pid}-${Date.now()}`
);
const dataDirectory = path.join(smokeRoot, "data");
const outputPath = path.join(smokeRoot, "smoke.docx");
await mkdir(dataDirectory, { recursive: true });
try {
const result = spawnSync(
executablePath,
[
"--from=gfm",
"--to=docx",
`--data-dir=${dataDirectory}`,
`--output=${outputPath}`
],
{
encoding: "utf8",
input: "# 墨呈\n\nPandoc 运行时冒烟测试。\n",
maxBuffer: 1024 * 1024,
windowsHide: true
}
);
if (result.error || result.status !== 0) {
throw new Error(`内置 Pandoc DOCX 冒烟失败:${result.error?.message ?? result.stderr}`);
}
const output = await readFile(outputPath);
if (output.byteLength < 1024 || output[0] !== 0x50 || output[1] !== 0x4b) {
throw new Error("内置 Pandoc 生成的 DOCX 不是有效 ZIP 包");
}
} finally {
await rm(smokeRoot, { recursive: true, force: true });
}
}
async function verifyPreparedRuntime(targetDirectory, manifest, artifact) {
const auditPath = path.join(targetDirectory, "runtime-manifest.json");
if (!(await fileExists(auditPath))) {
return false;
}
try {
const audit = JSON.parse(await readFile(auditPath, "utf8"));
if (
audit.version !== manifest.version ||
audit.archiveSha256 !== artifact.sha256 ||
audit.downloadUrl !== artifact.downloadUrl
) {
return false;
}
const expectedFiles = [
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
];
for (const fileName of expectedFiles) {
const content = await readFile(path.join(targetDirectory, fileName));
const expected = artifact.files[fileName];
if (
!expected ||
content.byteLength !== expected.bytes ||
sha256(content) !== expected.sha256 ||
audit.files?.[fileName]?.sha256 !== expected.sha256
) {
return false;
}
}
const executablePath = path.join(
targetDirectory,
normalizedBaseName(artifact.executableRelativePath)
);
verifyPandocVersion(executablePath, manifest.version);
await smokeTestPandoc(executablePath);
return true;
} catch {
return false;
}
}
async function preparePandocRuntime({ checkOnly = false } = {}) {
const { manifest, artifact } = await readRuntimeManifest();
const platformDirectory = "windows-x86_64";
const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc");
const targetDirectory = path.join(
runtimeRoot,
manifest.version,
platformDirectory
);
if (await verifyPreparedRuntime(targetDirectory, manifest, artifact)) {
console.log(`Pandoc ${manifest.version} 最小运行时已通过校验`);
return targetDirectory;
}
if (checkOnly) {
throw new Error("Pandoc 最小运行时缺失或未通过完整性校验");
}
const downloadDirectory = path.join(runtimeRoot, "downloads");
await mkdir(downloadDirectory, { recursive: true });
const archiveName = new URL(artifact.downloadUrl).pathname.split("/").at(-1);
if (!archiveName) {
throw new Error("Pandoc 下载地址缺少归档文件名");
}
const archivePath = path.join(downloadDirectory, archiveName);
const archive = await loadVerifiedArchive(artifact, archivePath);
const files = extractRequiredFiles(archive, artifact);
const temporaryDirectory = path.join(
runtimeRoot,
manifest.version,
`.${platformDirectory}-${process.pid}-${Date.now()}`
);
await rm(temporaryDirectory, { recursive: true, force: true });
await mkdir(temporaryDirectory, { recursive: true });
try {
const auditFiles = {};
for (const [fileName, content] of files) {
const expected = artifact.files[fileName];
const actualHash = sha256(content);
if (
!expected ||
content.byteLength !== expected.bytes ||
actualHash !== expected.sha256
) {
throw new Error(`Pandoc 文件完整性校验失败:${fileName}`);
}
await writeFile(path.join(temporaryDirectory, fileName), content);
auditFiles[fileName] = {
bytes: content.byteLength,
sha256: actualHash
};
}
const executablePath = path.join(
temporaryDirectory,
normalizedBaseName(artifact.executableRelativePath)
);
verifyPandocVersion(executablePath, manifest.version);
await smokeTestPandoc(executablePath);
await writeFile(
path.join(temporaryDirectory, "runtime-manifest.json"),
`${JSON.stringify(
{
version: manifest.version,
platform: "win32",
architecture: "x64",
license: manifest.license,
projectUrl: manifest.projectUrl,
sourceArchiveUrl: manifest.sourceArchiveUrl,
downloadUrl: artifact.downloadUrl,
archiveSha256: artifact.sha256,
files: auditFiles
},
null,
2
)}\n`,
"utf8"
);
await rm(targetDirectory, { recursive: true, force: true });
await rename(temporaryDirectory, targetDirectory);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
console.log(`Pandoc ${manifest.version} 最小运行时已准备:${targetDirectory}`);
return targetDirectory;
}
const isDirectInvocation =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).catch(
(error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
);
}
export {
extractRequiredFiles,
preparePandocRuntime,
sha256,
verifyPandocVersion
};
@@ -0,0 +1,8 @@
void import("./verify-docx-theme-styles.mjs").catch((error) => {
console.error(error);
process.exitCode = 1;
const { app } = require("electron");
if (app.isReady()) {
app.quit();
}
});
@@ -0,0 +1,270 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createApplicationService } from "@md-to-pdf/application";
import { defaultExportConfig } from "@md-to-pdf/core";
import {
DOCX_STYLE_SLOT_NAMES,
createDocxThemeStyleCaptureScript,
createDocxThemeStyleFingerprint,
normalizeDocxThemeMappingConfig,
parseDocxThemeStyleRuntimeCapture,
resolveDocxThemeTokens
} from "@md-to-pdf/docx-theme-engine";
import { app, BrowserWindow } from "electron";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-theme-styles"
);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const application = createApplicationService({
bundledRoot: path.join(repositoryDirectory, "themes"),
localRoot: path.join(repositoryDirectory, ".local", "themes")
});
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname === "/") {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8"
});
response.end("<!doctype html><html><body></body></html>");
return;
}
const match = url.pathname.match(
/^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
);
if (!match) {
response.writeHead(404);
response.end("Not found");
return;
}
const themeId = decodeURIComponent(match[1]);
const assetPath = match[2]
.split("/")
.map((segment) => decodeURIComponent(segment))
.join("/");
const asset = await application.getThemeAsset(
themeId,
assetPath
);
if (!asset) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"access-control-allow-origin": "*",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
});
response.end(asset.content);
} catch (error) {
response.writeHead(500);
response.end(
error instanceof Error ? error.message : "Unknown error"
);
}
});
await app.whenReady();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("无法获取 Electron DOCX 主题样式验收地址");
}
const baseUrl = `http://127.0.0.1:${address.port}/`;
const window = new BrowserWindow({
show: false,
focusable: false,
width: 800,
height: 600,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
});
try {
await window.loadURL(baseUrl);
window.webContents.debugger.attach("1.3");
await window.webContents.debugger.sendCommand(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
const { themes } = await application.listThemes();
const bundledThemes = themes
.filter((theme) => theme.source === "bundled")
.sort((first, second) =>
first.id.localeCompare(second.id, "en")
);
const snapshots = [];
const tokenSets = [];
for (const theme of bundledThemes) {
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
const prepared = await application.prepareDocxExport({
markdown: `# ${theme.name}`,
fileName: `${theme.id}.md`,
language: "zh-CN",
exportConfig: {
...defaultExportConfig,
themeId: theme.id
}
});
const themeCss = prepared.theme.css;
const manifest = prepared.theme.manifest;
const themeFingerprint =
await createDocxThemeStyleFingerprint(theme.id, themeCss);
const request = {
themeId: theme.id,
themeFingerprint,
themeCss,
baseUrl
};
const capture = await window.webContents.executeJavaScript(
createDocxThemeStyleCaptureScript(request),
true
);
const snapshot = parseDocxThemeStyleRuntimeCapture(
request,
capture
);
assert(
snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
`主题 ${theme.id} 的槽位数量不正确`
);
assert(
snapshot.slots.every((slot) => slot.matched),
`主题 ${theme.id} 存在未命中槽位`
);
snapshots.push(snapshot);
tokenSets.push(
resolveDocxThemeTokens({
snapshot,
config: normalizeDocxThemeMappingConfig(manifest)
})
);
}
assert(
snapshots.length === 14,
`内置主题数量应为 14,实际为 ${snapshots.length}`
);
const invalidDiagnostics = tokenSets.flatMap((tokens) =>
tokens.diagnostics.filter(
(diagnostic) =>
diagnostic.severity === "error" ||
diagnostic.code === "css-value-invalid"
)
);
assert(
invalidDiagnostics.length === 0,
`DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断`
);
const playwrightReport = JSON.parse(
fs.readFileSync(
path.join(outputDirectory, "snapshots.json"),
"utf8"
)
);
const playwrightTokensByTheme = new Map(
playwrightReport.tokenSets.map((tokens) => [
tokens.themeId,
tokens
])
);
const crossEngineMismatches = [];
for (const electronTokens of tokenSets) {
const playwrightTokens = playwrightTokensByTheme.get(
electronTokens.themeId
);
assert(
playwrightTokens,
`Playwright 报告缺少主题 ${electronTokens.themeId}`
);
for (const electronSlot of electronTokens.slots) {
const playwrightSlot = playwrightTokens.slots.find(
(slot) => slot.slot === electronSlot.slot
);
if (
JSON.stringify(playwrightSlot) !==
JSON.stringify(electronSlot)
) {
crossEngineMismatches.push(
`${electronTokens.themeId}:${electronSlot.slot}`
);
}
}
}
assert(
crossEngineMismatches.length === 0,
`Playwright/Electron 令牌不一致:${crossEngineMismatches.join("、")}`
);
fs.mkdirSync(outputDirectory, { recursive: true });
fs.writeFileSync(
path.join(outputDirectory, "electron-snapshots.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
electronVersion: process.versions.electron,
chromiumVersion: process.versions.chrome,
themeCount: snapshots.length,
slotCount: DOCX_STYLE_SLOT_NAMES.length,
crossEngineTokenMismatches:
crossEngineMismatches.length,
snapshots,
tokenSets
},
null,
2
)}\n`,
"utf8"
);
console.log(
JSON.stringify({
electronVersion: process.versions.electron,
chromiumVersion: process.versions.chrome,
themes: snapshots.length,
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
totalSlots:
snapshots.length * DOCX_STYLE_SLOT_NAMES.length,
tokenSets: tokenSets.length,
crossEngineTokenMismatches:
crossEngineMismatches.length,
diagnostics: tokenSets.reduce(
(count, tokens) => count + tokens.diagnostics.length,
0
)
})
);
} finally {
if (
!window.isDestroyed() &&
window.webContents.debugger.isAttached()
) {
window.webContents.debugger.detach();
}
if (!window.isDestroyed()) {
window.destroy();
}
await new Promise((resolve) => server.close(resolve));
app.quit();
}
+46 -4
View File
@@ -1,19 +1,30 @@
import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type {
DocxExportRequestInput,
PagedDocumentPayload
} from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GET_DOCX_CAPABILITY,
DESKTOP_EXPORT_DOCX,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_DOCUMENT_LINK,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED,
DESKTOP_SAVE_PDF
} from "./channels.js";
@@ -23,16 +34,43 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
discardPendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
onMarkdownOpened: (listener: () => void) => {
const handleOpened = () => listener();
onMarkdownOpened: (
listener: (reason: "open" | "reload" | "replace") => void
) => {
const handleOpened = (
_event: Electron.IpcRendererEvent,
reason: "open" | "reload" | "replace"
) => listener(reason);
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
return () =>
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
},
startNewMarkdown: () =>
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) =>
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
openDocumentLink: (href: string) =>
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
saveMarkdownAs: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
openThemeDirectory: () =>
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
@@ -44,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
generatePdf: (payload: PagedDocumentPayload) =>
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
savePdf: (fileName: string, pdf: Uint8Array) =>
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf),
getDocxCapability: () =>
ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY),
exportDocx: (request: DocxExportRequestInput) =>
ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request)
});
+30
View File
@@ -52,3 +52,33 @@ export function parseThemeResourceUrl(requestUrl: string) {
assetPath: assetSegments.join("/")
};
}
export function parseFontPackResourceUrl(requestUrl: string) {
const url = new URL(requestUrl);
if (url.protocol !== "mdpdf:" || url.host !== "font-pack") {
return undefined;
}
let segments: string[];
try {
segments = url.pathname
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
} catch {
throw new Error("字体包资源地址无效");
}
const [packId, packVersion, faceId, kind, ...extra] = segments;
if (
!packId ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(packId) ||
!packVersion ||
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(packVersion) ||
!faceId ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(faceId) ||
kind !== "web" ||
extra.length > 0
) {
throw new Error("字体包资源地址无效");
}
return { packId, packVersion, faceId, kind } as const;
}
+5
View File
@@ -0,0 +1,5 @@
declare const __APP_ID__: string;
declare const __APP_ENGLISH_NAME__: string;
declare const __APP_DISPLAY_NAME__: string;
declare const __APP_WINDOW_TITLE__: string;
declare const __APP_LEGACY_DISPLAY_NAME__: string;
+16
View File
@@ -8,10 +8,22 @@ export const DESKTOP_DISCARD_PENDING_MARKDOWN =
"md-to-pdf:desktop:discard-pending-markdown";
export const DESKTOP_MARKDOWN_OPENED =
"md-to-pdf:desktop:markdown-opened";
export const DESKTOP_SET_DOCUMENT_DIRTY =
"md-to-pdf:desktop:set-document-dirty";
export const DESKTOP_OPEN_DOCUMENT_LINK =
"md-to-pdf:desktop:open-document-link";
export const 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 =
"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 =
"md-to-pdf:desktop:open-theme-directory";
export const DESKTOP_REFRESH_THEMES =
@@ -24,6 +36,10 @@ export const DESKTOP_GENERATE_PDF =
"md-to-pdf:desktop:generate-pdf";
export const DESKTOP_SAVE_PDF =
"md-to-pdf:desktop:save-pdf";
export const DESKTOP_GET_DOCX_CAPABILITY =
"md-to-pdf:desktop:get-docx-capability";
export const DESKTOP_EXPORT_DOCX =
"md-to-pdf:desktop:export-docx";
export const PDF_RUNTIME_READY =
"md-to-pdf:desktop-pdf:ready";
export const PDF_RUNTIME_RENDER =
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { classifyDocumentLink } from "@md-to-pdf/core";
export type DesktopDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "external";
href: string;
}
| {
type: "local";
filePath: string;
}
| {
type: "ignore";
};
function decodeLocalPath(href: string) {
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
try {
return decodeURIComponent(pathOnly);
} catch {
throw new Error("本地链接的 URL 编码无效");
}
}
export function resolveDesktopDocumentLinkAction(
href: string,
markdownFilePath?: string
): DesktopDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return { type: "anchor", href: link.normalizedHref };
}
if (link.kind === "network" || link.kind === "protocol") {
return { type: "external", href: link.normalizedHref };
}
if (link.kind !== "local") {
return { type: "ignore" };
}
if (link.scheme === "file") {
try {
return {
type: "local",
filePath: path.normalize(fileURLToPath(new URL(link.href)))
};
} catch {
throw new Error("file: 链接格式无效");
}
}
const decoded = decodeLocalPath(link.href);
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
return { type: "local", filePath: path.normalize(decoded) };
}
if (!markdownFilePath) {
throw new Error("当前文档尚未保存,无法解析相对本地链接");
}
return {
type: "local",
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
};
}
+23
View File
@@ -0,0 +1,23 @@
import path from "node:path";
import { writeFile } from "node:fs/promises";
export function resolveDesktopDocxTargetPath(selectedPath: string) {
return selectedPath.toLowerCase().endsWith(".docx")
? selectedPath
: `${selectedPath}.docx`;
}
export async function saveDesktopDocx(
selectedPath: string,
content: Uint8Array
) {
if (!selectedPath || content.byteLength === 0) {
throw new Error("DOCX 保存参数无效");
}
const targetPath = resolveDesktopDocxTargetPath(selectedPath);
await writeFile(targetPath, content);
return {
targetPath,
fileName: path.basename(targetPath)
};
}
+83
View File
@@ -0,0 +1,83 @@
import {
ApplicationRequestError,
DocxExportServiceError
} from "@md-to-pdf/application";
import type {
DocxCapability,
DocxExportDiagnostics,
DocxExportErrorCode,
DocxGenerationTimings
} from "@md-to-pdf/core";
import { docxExportErrorCodeSchema } from "@md-to-pdf/core";
export interface DesktopDocxExportSuccess {
ok: true;
saved: boolean;
fileName: string;
diagnostics: DocxExportDiagnostics;
timings: DocxGenerationTimings;
}
export interface DesktopDocxExportFailure {
ok: false;
error: DocxExportErrorCode;
message: string;
retryable: boolean;
}
export type DesktopDocxExportOutcome =
| DesktopDocxExportSuccess
| DesktopDocxExportFailure;
export type DesktopDocxCapability = DocxCapability;
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
const cause = error.cause;
if (!(cause instanceof Error)) {
return undefined;
}
return /^ DOCX (?: \d+ms| \d+\/\d+ (?:image|mermaid|echarts)PNG \d+ms)$/u.test(
cause.message
)
? cause.message
: undefined;
}
export function toDesktopDocxExportFailure(
error: unknown
): DesktopDocxExportFailure {
if (error instanceof ApplicationRequestError) {
const parsedCode = docxExportErrorCodeSchema.safeParse(
error.code
);
if (!parsedCode.success) {
return {
ok: false,
error: "DOCX_GENERATION_FAILED",
message: "DOCX 导出失败",
retryable: false
};
}
return {
ok: false,
error: parsedCode.data,
message: error.message,
retryable: false
};
}
if (error instanceof DocxExportServiceError) {
const detail = readSafeDesktopMediaDetail(error);
return {
ok: false,
error: error.code,
message: detail ? `${error.message}${detail}` : error.message,
retryable: error.retryable
};
}
return {
ok: false,
error: "DOCX_GENERATION_FAILED",
message: "DOCX 导出失败",
retryable: false
};
}
@@ -0,0 +1,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();
}
}
}
}
+157 -453
View File
@@ -1,84 +1,66 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
net,
protocol,
screen,
session,
shell,
type IpcMainInvokeEvent
session
} from "electron";
import {
access,
mkdir,
writeFile
} from "node:fs/promises";
import { access, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
MAXIMUM_MARKDOWN_LENGTH,
type ApplicationService
} from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_PDF,
DESKTOP_START_NEW_MARKDOWN
} from "./channels.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseFontPackResourceUrl,
parseThemeResourceUrl
} from "./application-contract.js";
import {
DESKTOP_PDF_PARTITION,
ElectronPdfGenerator
} from "./electron-pdf-generator.js";
import { parsePagedDocumentPayload } from "./pdf-contract.js";
DesktopApplicationController
} from "./desktop-application-controller.js";
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
import {
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument,
type OpenedMarkdownDocument
isMarkdownFilePath
} from "./markdown-file.js";
import {
loadWindowState,
resolveWindowBounds,
saveWindowState,
type WindowState
} from "./window-state.js";
import { migrateLegacyUserData } from "./user-data-migration.js";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const FONT_PACK_HOST = "font-pack";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
let applicationController:
| {
window: BrowserWindow;
openMarkdownFile(filePath: string): Promise<void>;
flushWindowState(): Promise<void>;
}
| undefined;
let applicationController: DesktopApplicationController | undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument(
process.argv,
process.cwd()
);
function configurePackagedUserDataDirectory() {
if (!app.isPackaged) {
return Promise.resolve();
}
const appDataDirectory = app.getPath("appData");
const targetDirectory = path.join(
appDataDirectory,
__APP_ENGLISH_NAME__
);
const targetAlreadyExists = existsSync(targetDirectory);
app.setPath("userData", targetDirectory);
if (targetAlreadyExists) {
return Promise.resolve();
}
return migrateLegacyUserData(
[path.join(appDataDirectory, __APP_LEGACY_DISPLAY_NAME__)],
targetDirectory
).then(() => undefined);
}
const userDataMigration = configurePackagedUserDataDirectory();
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
@@ -130,29 +112,16 @@ function encodeThemeAssetPath(assetPath: string) {
.join("/");
}
function focusApplicationWindow() {
const window = applicationController?.window;
if (!window || window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
function dispatchExternalMarkdown(filePath: string) {
if (!applicationController) {
pendingExternalMarkdownPath = filePath;
return;
}
void applicationController.openMarkdownFile(filePath).catch(
void applicationController.openExternalMarkdown(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
focusApplicationWindow();
}
function getDesktopThemeDirectory() {
@@ -161,6 +130,21 @@ function getDesktopThemeDirectory() {
: 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() {
const projectRoot = path.resolve(currentDirectory, "../../..");
return createApplicationService({
@@ -170,6 +154,22 @@ function createDesktopApplicationService() {
localRoot: getDesktopThemeDirectory(),
createAssetUrl: (themeId, 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)
});
}
@@ -214,6 +214,34 @@ async function registerApplicationProtocol(
return new Response("Not found", { status: 404 });
}
}
if (url.host === FONT_PACK_HOST) {
try {
const resource = parseFontPackResourceUrl(request.url);
if (!resource) {
return new Response("Not found", { status: 404 });
}
const asset = await applicationService.getFontPackAsset(
resource.packId,
resource.packVersion,
resource.faceId,
resource.kind
);
if (!asset) {
return new Response("Not found", { status: 404 });
}
return new Response(asset.content, {
headers: {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=31536000, immutable",
"content-type": asset.contentType,
etag: `\"${asset.sha256}\"`,
"x-content-type-options": "nosniff"
}
});
} catch {
return new Response("Not found", { status: 404 });
}
}
if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 });
}
@@ -226,12 +254,10 @@ async function registerApplicationProtocol(
} catch {
return new Response("Bad request", { status: 400 });
}
const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 });
}
try {
await access(target);
} catch {
@@ -244,380 +270,13 @@ async function registerApplicationProtocol(
protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_PDF_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_DOCX_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest)
]);
}
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== allowedOrigin) {
event.preventDefault();
}
});
const applicationSession = window.webContents.session;
applicationSession.setPermissionCheckHandler(() => false);
applicationSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
}
async function createApplication(
applicationService: ApplicationService
) {
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
const renderUrl = new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href;
const windowStatePath = path.join(
app.getPath("userData"),
"window-state.json"
);
const savedWindowState = await loadWindowState(windowStatePath);
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
const initialBounds = resolveWindowBounds(
savedWindowState?.bounds,
screen.getAllDisplays().map((display) => display.workArea),
primaryWorkArea,
DEFAULT_WINDOW_SIZE,
MINIMUM_WINDOW_SIZE
);
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
...initialBounds,
minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width),
minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height),
autoHideMenuBar: true,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(currentDirectory, "app-preload.cjs")
}
});
let saveWindowStateTimer: NodeJS.Timeout | undefined;
let pendingWindowStateWrite = Promise.resolve();
let latestWindowState: WindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
const captureWindowState = () => {
if (window.isDestroyed()) {
return;
}
latestWindowState = {
version: 1,
bounds: window.getNormalBounds(),
maximized: window.isMaximized()
};
};
const persistWindowState = () => {
const state = structuredClone(latestWindowState);
pendingWindowStateWrite = pendingWindowStateWrite
.catch(() => undefined)
.then(() => saveWindowState(windowStatePath, state))
.catch((error: unknown) => {
console.warn("无法保存窗口状态", error);
});
return pendingWindowStateWrite;
};
const scheduleWindowStateSave = () => {
captureWindowState();
if (saveWindowStateTimer) {
clearTimeout(saveWindowStateTimer);
}
saveWindowStateTimer = setTimeout(() => {
saveWindowStateTimer = undefined;
void persistWindowState();
}, 2_000);
};
const flushWindowState = async () => {
if (saveWindowStateTimer) {
clearTimeout(saveWindowStateTimer);
saveWindowStateTimer = undefined;
}
captureWindowState();
await persistWindowState();
};
window.on("move", scheduleWindowStateSave);
window.on("resize", scheduleWindowStateSave);
window.on("maximize", scheduleWindowStateSave);
window.on("unmaximize", scheduleWindowStateSave);
lockDownWindow(window, new URL(applicationUrl).origin);
const pdfGenerator = new ElectronPdfGenerator({
renderUrl,
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
});
const validateSender = (event: IpcMainInvokeEvent) =>
event.sender === window.webContents &&
event.senderFrame !== null &&
new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).origin;
let documentRoot: string | undefined;
let currentMarkdownPath: string | undefined;
let pendingOpenedMarkdown:
| {
document: OpenedMarkdownDocument;
filePath: string;
}
| undefined;
const queueOpenedMarkdown = async (filePath: string) => {
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
pendingOpenedMarkdown = {
document: opened,
filePath
};
window.webContents.send(DESKTOP_MARKDOWN_OPENED);
};
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const selection = await dialog.showOpenDialog(window, {
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const opened = await readMarkdownDocument(
filePath,
MAXIMUM_MARKDOWN_LENGTH
);
documentRoot = path.dirname(filePath);
currentMarkdownPath = filePath;
return opened;
});
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const pending = pendingOpenedMarkdown;
pendingOpenedMarkdown = undefined;
if (!pending) {
return undefined;
}
currentMarkdownPath = pending.filePath;
documentRoot = path.dirname(pending.filePath);
return pending.document;
});
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
pendingOpenedMarkdown = undefined;
});
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
currentMarkdownPath = undefined;
documentRoot = undefined;
pendingOpenedMarkdown = undefined;
});
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN,
async (
event,
unsafeFileName: unknown,
unsafeMarkdown: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
typeof unsafeMarkdown !== "string" ||
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
) {
throw new Error("Markdown 保存参数无效");
}
let targetPath = currentMarkdownPath;
if (!targetPath) {
const suggestedName = /\.(?:md|markdown)$/iu.test(
path.basename(unsafeFileName)
)
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.md`;
const selection = await dialog.showSaveDialog(window, {
title: "保存 Markdown",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
]
});
if (selection.canceled || !selection.filePath) {
return undefined;
}
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
? selection.filePath
: `${selection.filePath}.md`;
}
await writeFile(targetPath, unsafeMarkdown, "utf8");
currentMarkdownPath = targetPath;
documentRoot = path.dirname(targetPath);
return { fileName: path.basename(targetPath) };
}
);
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const errorMessage = await shell.openPath(themeDirectory);
if (errorMessage) {
throw new Error(`无法打开自定义主题目录:${errorMessage}`);
}
return themeDirectory;
});
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
applicationService.invalidateThemes();
});
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest),
documentRoot ? { localRoot: documentRoot } : {}
);
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
return applicationService.listThemes();
});
ipcMain.handle(
DESKTOP_GET_THEME_CSS,
async (event, unsafeThemeId: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const css = await applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 请求");
}
const payload = parsePagedDocumentPayload(unsafePayload);
const result = await pdfGenerator.generate(payload);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 保存请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
!(unsafePdf instanceof Uint8Array) ||
unsafePdf.byteLength === 0 ||
unsafePdf.byteLength > 200 * 1024 * 1024
) {
throw new Error("PDF 保存参数无效");
}
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(window, {
title: "导出 PDF",
defaultPath: path.join(app.getPath("documents"), suggestedName),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
return true;
}
);
window.once("ready-to-show", () => {
if (savedWindowState?.maximized) {
window.maximize();
}
window.show();
});
window.on("close", () => {
void flushWindowState();
});
window.on("closed", () => {
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN);
ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN);
ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN);
ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN);
ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY);
ipcMain.removeHandler(DESKTOP_REFRESH_THEMES);
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
void pdfGenerator.close();
});
await window.loadURL(applicationUrl);
return {
window,
openMarkdownFile: queueOpenedMarkdown,
flushWindowState
};
}
const hasSingleInstanceLock = app.requestSingleInstanceLock();
let windowStateReadyToQuit = false;
@@ -631,8 +290,8 @@ if (!hasSingleInstanceLock) {
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else {
focusApplicationWindow();
} else if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("open-file", (event, filePath) => {
@@ -641,35 +300,80 @@ if (!hasSingleInstanceLock) {
dispatchExternalMarkdown(filePath);
}
});
app.on("activate", () => {
if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("before-quit", (event) => {
if (windowStateReadyToQuit || !applicationController) {
return;
}
event.preventDefault();
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
});
void Promise.all([
applicationController.flushWindowState(),
applicationController.closeResources()
]).finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
app
.whenReady()
.then(async () => {
await userDataMigration;
Menu.setApplicationMenu(null);
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
app.setAppUserModelId(__APP_ID__);
}
await mkdir(getDesktopThemeDirectory(), { recursive: true });
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
applicationController =
await createApplication(applicationService);
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
applicationController = new DesktopApplicationController({
applicationService,
applicationUrl,
renderUrl: new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href,
preloadPath: path.join(
currentDirectory,
"app-preload.cjs"
),
pdfPreloadPath: path.join(
currentDirectory,
"pdf-preload.cjs"
),
docxRenderUrl: new URL(
"/preview-frame.html?target=continuous",
applicationUrl
).href,
desktopResourcesPath: process.resourcesPath,
iconPath: getApplicationIcon(),
themeDirectory,
windowStatePath: path.join(
app.getPath("userData"),
"window-state.json"
),
saveLocationStatePath: path.join(
app.getPath("userData"),
"save-location.json"
),
documentsDirectory: app.getPath("documents")
});
await applicationController.initialize();
const startupMarkdownPath = pendingExternalMarkdownPath;
pendingExternalMarkdownPath = undefined;
if (startupMarkdownPath) {
await applicationController.openMarkdownFile(
startupMarkdownPath
);
}
await applicationController.createWindow(startupMarkdownPath);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
+37 -1
View File
@@ -1,4 +1,5 @@
import { readFile, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
const markdownExtensions = new Set([".md", ".markdown"]);
@@ -8,6 +9,24 @@ export interface OpenedMarkdownDocument {
fileName: string;
}
export interface MarkdownFileSnapshot {
document: OpenedMarkdownDocument;
filePath: string;
documentKey: string;
contentHash: string;
}
export function createMarkdownContentHash(markdown: string) {
return createHash("sha256").update(markdown, "utf8").digest("hex");
}
export function createMarkdownDocumentKey(filePath: string) {
const normalized = path.normalize(filePath);
return process.platform === "win32"
? normalized.toLocaleLowerCase("en-US")
: normalized;
}
export function isMarkdownFilePath(filePath: string) {
return markdownExtensions.has(path.extname(filePath).toLowerCase());
}
@@ -58,3 +77,20 @@ export async function readMarkdownDocument(
fileName: path.basename(filePath)
};
}
export async function readMarkdownFileSnapshot(
filePath: string,
maximumLength: number
): Promise<MarkdownFileSnapshot> {
const canonicalPath = await realpath(filePath);
const document = await readMarkdownDocument(
canonicalPath,
maximumLength
);
return {
document,
filePath: canonicalPath,
documentKey: createMarkdownDocumentKey(canonicalPath),
contentHash: createMarkdownContentHash(document.markdown)
};
}
+8 -1
View File
@@ -1,5 +1,6 @@
import {
exportConfigSchema,
semanticDocumentModelSchema,
themeFeatureSchema,
type PagedDocumentPayload,
type PagedDocumentRenderResult
@@ -66,6 +67,9 @@ export function parsePagedDocumentPayload(
),
language: readString(metadata, "language", 50)
},
semanticDocument: semanticDocumentModelSchema.parse(
value.semanticDocument
),
features: parsedFeatures,
themeCss: readString(
value,
@@ -117,7 +121,10 @@ export function isAllowedPdfRuntimeUrl(
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true;
}
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
if (
requested.protocol === "mdpdf:" &&
["theme", "font-pack"].includes(requested.host)
) {
return true;
}
return requested.origin === new URL(renderUrl).origin;
+164
View File
@@ -0,0 +1,164 @@
import {
mkdir,
readFile,
rename,
rm,
stat,
writeFile
} from "node:fs/promises";
import path from "node:path";
const SAVE_LOCATION_STATE_VERSION = 1;
export interface SaveLocationState {
version: 1;
directory: string;
}
export interface ResolveDefaultSavePathOptions {
sourceFilePath?: string;
lastSaveDirectory?: string;
documentsDirectory: string;
suggestedFileName: string;
}
export function parseSaveLocationState(
value: unknown
): SaveLocationState | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const state = value as Partial<SaveLocationState>;
if (
state.version !== SAVE_LOCATION_STATE_VERSION ||
typeof state.directory !== "string" ||
!path.isAbsolute(state.directory)
) {
return undefined;
}
return {
version: SAVE_LOCATION_STATE_VERSION,
directory: path.resolve(state.directory)
};
}
async function isAvailableDirectory(directory: string | undefined) {
if (!directory || !path.isAbsolute(directory)) {
return false;
}
try {
return (await stat(directory)).isDirectory();
} catch {
return false;
}
}
function assertSuggestedFileName(value: string) {
const fileName = path.basename(value.trim());
if (!fileName || fileName === ".") {
throw new Error("默认保存文件名无效");
}
return fileName;
}
export async function resolveDefaultSavePath(
options: ResolveDefaultSavePathOptions
) {
const fileName = assertSuggestedFileName(
options.suggestedFileName
);
const sourceDirectory = options.sourceFilePath
? path.dirname(path.resolve(options.sourceFilePath))
: undefined;
if (await isAvailableDirectory(sourceDirectory)) {
return path.join(sourceDirectory!, fileName);
}
if (await isAvailableDirectory(options.lastSaveDirectory)) {
return path.join(options.lastSaveDirectory!, fileName);
}
return path.join(
path.resolve(options.documentsDirectory),
fileName
);
}
export async function loadSaveLocationState(filePath: string) {
try {
return parseSaveLocationState(
JSON.parse(await readFile(filePath, "utf8")) as unknown
);
} catch {
return undefined;
}
}
export async function saveSaveLocationState(
filePath: string,
directory: string
) {
const state = parseSaveLocationState({
version: SAVE_LOCATION_STATE_VERSION,
directory
});
if (!state) {
throw new Error("最近保存目录无效");
}
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8"
);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
return state;
}
export class SaveLocationStore {
readonly #statePath: string;
readonly #documentsDirectory: string;
#lastSaveDirectory: string | undefined;
#pendingWrite = Promise.resolve();
constructor(statePath: string, documentsDirectory: string) {
this.#statePath = statePath;
this.#documentsDirectory = documentsDirectory;
}
async initialize() {
this.#lastSaveDirectory = (
await loadSaveLocationState(this.#statePath)
)?.directory;
}
resolveDefaultPath(
sourceFilePath: string | undefined,
suggestedFileName: string
) {
return resolveDefaultSavePath({
documentsDirectory: this.#documentsDirectory,
suggestedFileName,
...(sourceFilePath ? { sourceFilePath } : {}),
...(this.#lastSaveDirectory
? { lastSaveDirectory: this.#lastSaveDirectory }
: {})
});
}
async recordSuccessfulFile(filePath: string) {
const directory = path.dirname(path.resolve(filePath));
this.#lastSaveDirectory = directory;
const write = this.#pendingWrite
.catch(() => undefined)
.then(() =>
saveSaveLocationState(this.#statePath, directory)
);
this.#pendingWrite = write.then(() => undefined);
await write;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { cp, lstat, mkdir } from "node:fs/promises";
import path from "node:path";
const MIGRATED_ENTRIES = [
"window-state.json",
"save-location.json",
"themes",
"Local Storage",
"Session Storage",
"IndexedDB"
] as const;
async function readOrdinaryStatus(target: string) {
try {
const status = await lstat(target);
return status.isSymbolicLink() ? undefined : status;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
throw error;
}
}
export interface LegacyUserDataMigrationResult {
migrated: boolean;
sourceDirectory?: string;
copiedEntries: string[];
}
export async function migrateLegacyUserData(
legacyDirectories: readonly string[],
targetDirectory: string
): Promise<LegacyUserDataMigrationResult> {
if (await readOrdinaryStatus(targetDirectory)) {
return { migrated: false, copiedEntries: [] };
}
for (const legacyDirectory of legacyDirectories) {
if (path.resolve(legacyDirectory) === path.resolve(targetDirectory)) {
continue;
}
const legacyStatus = await readOrdinaryStatus(legacyDirectory);
if (!legacyStatus?.isDirectory()) {
continue;
}
await mkdir(targetDirectory, { recursive: true });
const copiedEntries: string[] = [];
for (const entry of MIGRATED_ENTRIES) {
const source = path.join(legacyDirectory, entry);
if (!(await readOrdinaryStatus(source))) {
continue;
}
await cp(source, path.join(targetDirectory, entry), {
recursive: true,
errorOnExist: true,
force: false
});
copiedEntries.push(entry);
}
return {
migrated: true,
sourceDirectory: legacyDirectory,
copiedEntries
};
}
return { migrated: false, copiedEntries: [] };
}
+16
View File
@@ -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,88 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
describe("桌面文档链接动作", () => {
const markdownPath = path.resolve("D:/文档/章节/当前.md");
it("保留锚点并把网络和自定义协议交给系统", () => {
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
type: "anchor",
href: "#标题"
});
expect(
resolveDesktopDocumentLinkAction("//example.com/path")
).toEqual({
type: "external",
href: "https://example.com/path"
});
expect(
resolveDesktopDocumentLinkAction("mailto:user@example.com")
).toEqual({
type: "external",
href: "mailto:user@example.com"
});
expect(
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
).toEqual({
type: "external",
href: "obsidian://open?vault=docs"
});
});
it("允许相对路径越过 Markdown 目录", () => {
expect(
resolveDesktopDocumentLinkAction(
"../../共享/说明.pdf#page=2",
markdownPath
)
).toEqual({
type: "local",
filePath: path.resolve(
path.dirname(markdownPath),
"../../共享/说明.pdf"
)
});
});
it("解析绝对路径、file URI 和 URL 编码", () => {
const absolutePath = path.resolve("C:/资料/项目说明.md");
expect(
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
).toEqual({
type: "local",
filePath: path.normalize(absolutePath)
});
expect(
resolveDesktopDocumentLinkAction(
pathToFileURL(absolutePath).href,
markdownPath
)
).toEqual({
type: "local",
filePath: path.normalize(absolutePath)
});
expect(
resolveDesktopDocumentLinkAction(
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
markdownPath
)
).toEqual({
type: "local",
filePath: path.resolve(
path.dirname(markdownPath),
"../共享/说明.pdf"
)
});
});
it("拒绝危险链接和无文件基准的相对路径", () => {
expect(
resolveDesktopDocumentLinkAction("javascript:alert(1)")
).toEqual({ type: "ignore" });
expect(() =>
resolveDesktopDocumentLinkAction("../说明.pdf")
).toThrow("尚未保存");
});
});
@@ -0,0 +1,57 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveDesktopDocxTargetPath,
saveDesktopDocx
} from "../src/desktop-docx-save.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
async function createTemporaryDirectory() {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-desktop-docx-save-")
);
temporaryDirectories.push(directory);
return directory;
}
describe("Desktop DOCX 原生保存", () => {
it("在用户未填写扩展名时补充 .docx 并原样落盘", async () => {
const directory = await createTemporaryDirectory();
const content = Uint8Array.of(80, 75, 3, 4, 1, 2, 3);
const selectedPath = path.join(directory, "桌面验收");
const saved = await saveDesktopDocx(selectedPath, content);
expect(saved).toEqual({
targetPath: `${selectedPath}.docx`,
fileName: "桌面验收.docx"
});
expect(await readFile(saved.targetPath)).toEqual(Buffer.from(content));
});
it("大小写不敏感地保留已有 DOCX 扩展名", () => {
expect(resolveDesktopDocxTargetPath("报告.DOCX")).toBe(
"报告.DOCX"
);
});
it("拒绝空路径或空内容", async () => {
await expect(
saveDesktopDocx("", Uint8Array.of(80, 75))
).rejects.toThrow("DOCX 保存参数无效");
await expect(
saveDesktopDocx("报告.docx", new Uint8Array())
).rejects.toThrow("DOCX 保存参数无效");
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
ApplicationRequestError,
DocxExportServiceError
} from "@md-to-pdf/application";
import { toDesktopDocxExportFailure } from "../src/docx-contract.js";
describe("Desktop DOCX IPC 契约", () => {
it("映射应用请求错误", () => {
expect(
toDesktopDocxExportFailure(
new ApplicationRequestError(
400,
"INVALID_EXPORT_CONFIG",
"导出配置无效"
)
)
).toEqual({
ok: false,
error: "INVALID_EXPORT_CONFIG",
message: "导出配置无效",
retryable: false
});
});
it("保留共享服务的重试语义", () => {
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
429,
"DOCX_QUEUE_FULL",
"DOCX 生成队列已满",
true
)
)
).toEqual({
ok: false,
error: "DOCX_QUEUE_FULL",
message: "DOCX 生成队列已满",
retryable: true
});
});
it("仅透传格式受控的桌面媒体超时详情", () => {
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
500,
"DOCX_GENERATION_FAILED",
"DOCX 导出失败",
false,
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
)
)
).toMatchObject({
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
});
expect(
toDesktopDocxExportFailure(
new DocxExportServiceError(
500,
"DOCX_GENERATION_FAILED",
"DOCX 导出失败",
false,
{
cause: new Error(
"桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 20000ms"
)
}
)
)
).toMatchObject({
message:
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 20000ms"
});
});
it("保留 Front Matter 解析错误", () => {
expect(
toDesktopDocxExportFailure(
new ApplicationRequestError(
400,
"INVALID_FRONT_MATTER",
"无法解析 Markdown Front Matter"
)
)
).toEqual({
ok: false,
error: "INVALID_FRONT_MATTER",
message: "无法解析 Markdown Front Matter",
retryable: false
});
});
it("隐藏未知主进程错误细节", () => {
expect(
toDesktopDocxExportFailure(
new Error("C:\\secret\\pandoc.exe 启动失败")
)
).toEqual({
ok: false,
error: "DOCX_GENERATION_FAILED",
message: "DOCX 导出失败",
retryable: false
});
});
});
@@ -0,0 +1,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 个媒体(imagePNG 捕获超过 10ms"
);
expect(detach).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
function createWindow() {
let destroyed = false;
return {
showInactive: vi.fn(),
hide: vi.fn(),
isDestroyed: vi.fn(() => destroyed),
destroy: () => {
destroyed = true;
}
};
}
describe("Electron DOCX 捕获合成表面", () => {
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
const window = createWindow();
const operation = vi.fn(async () => "captured");
await expect(
withActiveDocxCaptureSurface(window, operation, 0)
).resolves.toBe("captured");
expect(window.showInactive).toHaveBeenCalledOnce();
expect(operation).toHaveBeenCalledOnce();
expect(window.hide).toHaveBeenCalledOnce();
});
it("窗口在失败期间销毁时不再隐藏", async () => {
const window = createWindow();
await expect(
withActiveDocxCaptureSurface(
window,
async () => {
window.destroy();
throw new Error("capture failed");
},
0
)
).rejects.toThrow("capture failed");
expect(window.showInactive).toHaveBeenCalledOnce();
expect(window.hide).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
import {
DOCX_STYLE_SLOT_NAMES,
type DocxComputedStyle,
type DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { describe, expect, it, vi } from "vitest";
import { captureDocxThemeStyleWithElectronWebContents } from "../src/electron-docx-theme-style.js";
const computed: DocxComputedStyle = {
fontFamily: "Arial",
fontSize: "16px",
fontWeight: "400",
fontStyle: "normal",
color: "rgb(0, 0, 0)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineHeight: "normal",
letterSpacing: "normal",
textAlign: "start",
direction: "ltr",
textIndent: "0px",
textDecorationLine: "none",
marginTop: "0px",
marginRight: "0px",
marginBottom: "0px",
marginLeft: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
borderTop: "0px none rgb(0, 0, 0)",
borderRight: "0px none rgb(0, 0, 0)",
borderBottom: "0px none rgb(0, 0, 0)",
borderLeft: "0px none rgb(0, 0, 0)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
breakAfter: "auto",
breakInside: "auto",
display: "block",
flexDirection: "row",
alignItems: "normal",
justifyContent: "normal",
outline: "rgb(0, 0, 0) none 0px",
outlineOffset: "0px"
};
const request: DocxThemeStyleCaptureRequest = {
themeId: "external-clean",
themeFingerprint: "b".repeat(64),
themeCss: "#write { color: #000; }",
baseUrl: "mdpdf://app/"
};
describe("Electron DOCX 主题样式采集", () => {
it("通过 CDP 切换打印媒体并在结束后恢复", async () => {
let attached = false;
const sendCommand = vi.fn(async () => undefined);
const webContents = {
debugger: {
isAttached: () => attached,
attach: vi.fn(() => {
attached = true;
}),
detach: vi.fn(() => {
attached = false;
}),
sendCommand
},
executeJavaScript: vi.fn(async () => ({
rootFontSizePx: 16,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
matched: true,
computed
}))
})),
isDestroyed: () => false
};
const snapshot =
await captureDocxThemeStyleWithElectronWebContents(
webContents as never,
request
);
expect(sendCommand).toHaveBeenNthCalledWith(
1,
"Emulation.setEmulatedMedia",
{ media: "print" }
);
expect(sendCommand).toHaveBeenLastCalledWith(
"Emulation.setEmulatedMedia",
{ media: "screen" }
);
expect(webContents.debugger.detach).toHaveBeenCalledTimes(1);
expect(snapshot.slots).toHaveLength(
DOCX_STYLE_SLOT_NAMES.length
);
});
});
+33 -1
View File
@@ -3,9 +3,12 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
createMarkdownContentHash,
createMarkdownDocumentKey,
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument
readMarkdownDocument,
readMarkdownFileSnapshot
} from "../src/markdown-file.js";
describe("桌面 Markdown 文件参数", () => {
@@ -72,4 +75,33 @@ describe("桌面 Markdown 文件参数", () => {
readMarkdownDocument("C:\\docs\\说明.txt", 100)
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
});
it("使用真实路径和内容哈希创建文件快照", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
);
const filePath = path.join(directory, "快照.md");
try {
await writeFile(filePath, "# 第一版", "utf8");
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
expect(snapshot.document).toEqual({
markdown: "# 第一版",
fileName: "快照.md"
});
expect(snapshot.filePath).toBe(filePath);
expect(snapshot.documentKey).toBe(
createMarkdownDocumentKey(filePath)
);
expect(snapshot.contentHash).toBe(
createMarkdownContentHash("# 第一版")
);
await writeFile(filePath, "# 第二版", "utf8");
const changed = await readMarkdownFileSnapshot(filePath, 100);
expect(changed.contentHash).not.toBe(snapshot.contentHash);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { strToU8, zipSync } from "fflate";
import {
extractRequiredFiles,
sha256
} from "../scripts/prepare-pandoc-runtime.mjs";
const artifact = {
executableRelativePath: "pandoc.exe",
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"]
};
function archive(files: Record<string, string>) {
return zipSync(
Object.fromEntries(
Object.entries(files).map(([name, content]) => [
name,
strToU8(content)
])
)
);
}
describe("Desktop Pandoc 最小运行时构建", () => {
it("仅提取可执行文件和两份许可证文件", () => {
const files = extractRequiredFiles(
archive({
"pandoc-3.9.0.2/pandoc.exe": "executable",
"pandoc-3.9.0.2/COPYING.rtf": "license",
"pandoc-3.9.0.2/COPYRIGHT.txt": "copyright",
"pandoc-3.9.0.2/MANUAL.html": "manual"
}),
artifact
);
expect([...files.keys()].sort()).toEqual([
"COPYING.rtf",
"COPYRIGHT.txt",
"pandoc.exe"
]);
});
it("拒绝缺失许可证文件的归档", () => {
expect(() =>
extractRequiredFiles(
archive({
"pandoc.exe": "executable",
"COPYING.rtf": "license"
}),
artifact
)
).toThrow("COPYRIGHT.txt");
});
it("拒绝用不同目录伪造的重复文件", () => {
expect(() =>
extractRequiredFiles(
archive({
"official/pandoc.exe": "official",
"shadow/pandoc.exe": "shadow",
"official/COPYING.rtf": "license",
"official/COPYRIGHT.txt": "copyright"
}),
artifact
)
).toThrow("重复文件:pandoc.exe");
});
it("使用大写十六进制输出稳定 SHA-256", () => {
const content = strToU8("MorphDoc");
expect(sha256(content)).toBe(
createHash("sha256").update(content).digest("hex").toUpperCase()
);
});
});
+33
View File
@@ -10,6 +10,7 @@ import {
} from "../src/pdf-contract.js";
import {
parseMarkdownRenderRequest,
parseFontPackResourceUrl,
parseThemeId,
parseThemeResourceUrl
} from "../src/application-contract.js";
@@ -26,6 +27,14 @@ const payload = createPagedDocumentPayload({
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: ["mermaid"],
warnings: []
},
@@ -97,6 +106,12 @@ describe("桌面 PDF 网络边界", () => {
renderUrl
)
).toBe(true);
expect(
isAllowedPdfRuntimeUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
renderUrl
)
).toBe(true);
});
it("拒绝外部网络资源", () => {
@@ -135,4 +150,22 @@ describe("桌面应用服务 IPC 载荷", () => {
parseThemeResourceUrl("mdpdf://bundle/index.html")
).toBeUndefined();
});
it("只接受字体包 Web 资源地址", () => {
expect(
parseFontPackResourceUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif-regular/web?v=abc"
)
).toEqual({
packId: "official-cjk",
packVersion: "1.0.0",
faceId: "serif-regular",
kind: "web"
});
expect(() =>
parseFontPackResourceUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif-regular/docx"
)
).toThrow("字体包资源地址无效");
});
});
+258
View File
@@ -0,0 +1,258 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
interface PackageManifest {
version?: string;
productName?: string;
scripts?: Record<string, string>;
}
interface ProductBrand {
appId: string;
englishName: string;
displayName: string;
windowTitle: string;
executableName: string;
artifactPrefix: string;
}
interface PandocRuntimeManifest {
version: string;
artifacts: Array<{
platform: string;
architecture: string;
sha256: string;
licenseFiles: string[];
files?: Record<string, { bytes: number; sha256: string }>;
}>;
}
const desktopRoot = fileURLToPath(new URL("../", import.meta.url));
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
function readManifest<T = PackageManifest>(path: string): T {
return JSON.parse(readFileSync(path, "utf8")) as T;
}
describe("桌面发行构建链", () => {
it("打包前按依赖顺序完整重建内嵌 Web", () => {
const rootManifest = readManifest(`${projectRoot}/package.json`);
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
const webBuild = rootManifest.scripts?.["build:web-runtime"] ?? "";
const expectedWorkspaces = [
"@md-to-pdf/markdown-echarts",
"@md-to-pdf/core",
"@md-to-pdf/renderer",
"@md-to-pdf/application",
"@md-to-pdf/preview-engine",
"@md-to-pdf/web"
];
let previousIndex = -1;
for (const workspace of expectedWorkspaces) {
const workspaceIndex = webBuild.indexOf(workspace);
expect(workspaceIndex).toBeGreaterThan(previousIndex);
previousIndex = workspaceIndex;
}
expect(desktopManifest.scripts?.["build:embedded-web"]).toBe(
"npm --prefix ../.. run build:web-runtime"
);
expect(desktopManifest.scripts?.package).toMatch(
/^npm run build:embedded-web && /
);
expect(desktopManifest.scripts?.make).toMatch(
/^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(
"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");
});
});
+7 -1
View File
@@ -8,12 +8,18 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-http": "node scripts/verify-docx-http.mjs",
"verify:docx-r4-matrix": "node scripts/verify-docx-r4-matrix.mjs",
"verify:docx-theme-styles": "node scripts/verify-docx-theme-styles.mjs"
},
"dependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fflate": "0.8.3",
"fastify": "^5.6.2",
"playwright": "1.62.0"
},
+301
View File
@@ -0,0 +1,301 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOCX_MIME_TYPE,
defaultExportConfig
} from "@md-to-pdf/core";
import { inspectDocxAcceptance } from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOTS } from "@md-to-pdf/docx-theme-engine";
import { buildApp } from "../dist/app.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-acceptance"
);
const temporaryPrefix = "md-to-pdf-docx-";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function temporaryDirectories() {
return new Set(
fs
.readdirSync(os.tmpdir(), { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() && entry.name.startsWith(temporaryPrefix)
)
.map((entry) => entry.name)
);
}
const markdown = `---
title: Server HTTP DOCX 验收
author: 墨呈研发组
---
# HTTP 导出保持可编辑
这是一段由真实 Fastify 路由和 Pandoc 生成的可编辑中文正文,包含
[验收链接](https://example.invalid/server-docx)。
1. 原生编号第一项
2. 原生编号第二项
| HTTP 门禁 | 预期 |
| --- | --- |
| MIME | DOCX |
| 内容 | 可编辑 |
脚注必须保留。[^server-footnote]
公式 $a^2+b^2=c^2$ 必须使用 OMML。
[^server-footnote]: Server HTTP 验收脚注。
`;
const exportConfig = {
...defaultExportConfig,
themeId: "typora-like",
pageDecorationsMode: "custom",
paper: {
...defaultExportConfig.paper,
marginMode: "custom",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
}
};
const computedStyle = {
fontFamily: '"Microsoft YaHei", sans-serif',
fontSize: "16px",
fontWeight: "400",
fontStyle: "normal",
color: "rgb(34, 34, 34)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineHeight: "24px",
letterSpacing: "normal",
textAlign: "start",
direction: "ltr",
textIndent: "0px",
textDecorationLine: "none",
marginTop: "0px",
marginRight: "0px",
marginBottom: "8px",
marginLeft: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
borderTop: "0px none rgb(34, 34, 34)",
borderRight: "0px none rgb(34, 34, 34)",
borderBottom: "0px none rgb(34, 34, 34)",
borderLeft: "0px none rgb(34, 34, 34)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
breakAfter: "auto",
breakInside: "auto",
display: "block",
flexDirection: "row",
alignItems: "normal",
justifyContent: "normal",
outline: "0px none rgb(34, 34, 34)",
outlineOffset: "0px"
};
const mediaAdapter = {
themeStyleBaseUrl:
"http://localhost:5173/preview-frame.html",
async captureThemeStyle(request) {
return {
schemaVersion: 1,
themeId: request.themeId,
themeFingerprint: request.themeFingerprint,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
rootFontSizePx: 16,
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
slot: name,
matched: true,
computed: computedStyle
}))
};
},
async capture() {
return {
plan: {
targets: [],
echartsErrors: [],
mermaidErrors: []
},
captures: []
};
},
async close() {}
};
const pdfGenerator = {
async generate() {
throw new Error("DOCX HTTP 验收不得调用 PDF");
},
async close() {}
};
const beforeTemporaryDirectories = temporaryDirectories();
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter: mediaAdapter
});
let response;
try {
const capabilityResponse = await app.inject({
method: "GET",
url: "/api/docx/capability"
});
assert(capabilityResponse.statusCode === 200, "DOCX capability 请求失败");
assert(
capabilityResponse.json().status === "available",
`DOCX capability 不可用:${capabilityResponse.body}`
);
response = await app.inject({
method: "POST",
url: "/api/docx",
payload: {
markdown,
fileName: "目录/Server HTTP 验收.md",
language: "zh-CN",
resources: [],
exportConfig
}
});
} finally {
await app.close();
}
assert(
response.statusCode === 200,
`DOCX HTTP 返回 ${response.statusCode}${response.body}`
);
assert(
response.headers["content-type"]?.includes(DOCX_MIME_TYPE),
`DOCX MIME 无效:${response.headers["content-type"]}`
);
assert(
response.headers["content-disposition"]?.includes(
"Server%20HTTP%20%E9%AA%8C%E6%94%B6.docx"
),
`DOCX 文件名无效:${response.headers["content-disposition"]}`
);
assert(
response.headers["cache-control"] === "no-store",
"DOCX HTTP 响应必须禁用缓存"
);
for (const timing of [
"runtime-probe",
"prepare",
"media",
"reference",
"pandoc",
"validation",
"total"
]) {
assert(
response.headers["server-timing"]?.includes(`${timing};dur=`),
`Server-Timing 缺少 ${timing}`
);
}
assert(
response.headers["x-docx-warning-count"] === "0",
"无媒体 HTTP 验收不应产生 DOCX 警告"
);
const report = inspectDocxAcceptance(response.rawPayload, {
id: "server-http",
page: {
widthTwips: 11906,
heightTwips: 16838,
orientation: "portrait",
marginsTwips: {
top: 907,
right: 907,
bottom: 907,
left: 907
}
},
requiredText: [
"真实 Fastify 路由",
"原生编号第一项",
"HTTP 门禁"
],
requiredFootnoteText: ["Server HTTP 验收脚注"],
minimumParagraphs: 8,
minimumTextRuns: 12,
minimumTables: 1,
minimumNumberedParagraphs: 2,
minimumHyperlinks: 1,
minimumFootnoteReferences: 1,
minimumMathObjects: 1,
requiredStyleIds: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
],
requirePageField: true
});
const afterTemporaryDirectories = temporaryDirectories();
const leakedTemporaryDirectories = [...afterTemporaryDirectories].filter(
(name) => !beforeTemporaryDirectories.has(name)
);
assert(
leakedTemporaryDirectories.length === 0,
`DOCX HTTP 遗留临时目录:${leakedTemporaryDirectories.join(", ")}`
);
fs.mkdirSync(outputDirectory, { recursive: true });
const outputPath = path.join(outputDirectory, "server-http.docx");
const reportPath = path.join(
outputDirectory,
"server-http-report.json"
);
fs.writeFileSync(outputPath, response.rawPayload);
const result = {
outputFile: path.relative(repositoryDirectory, outputPath),
bytes: response.rawPayload.byteLength,
headers: {
contentType: response.headers["content-type"],
contentDisposition: response.headers["content-disposition"],
cacheControl: response.headers["cache-control"],
serverTiming: response.headers["server-timing"],
warningCount: response.headers["x-docx-warning-count"],
echartsErrorCount: response.headers["x-echarts-error-count"],
mermaidErrorCount: response.headers["x-mermaid-error-count"]
},
leakedTemporaryDirectories,
acceptance: report
};
fs.writeFileSync(
reportPath,
`${JSON.stringify(result, null, 2)}\n`,
"utf8"
);
console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,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
View File
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
import Fastify from "fastify";
import {
EXPORT_CONFIG_VERSION,
DOCX_MIME_TYPE,
createPagedDocumentPayload,
defaultExportConfig,
exportConfigSchema,
@@ -11,10 +12,23 @@ import {
} from "@md-to-pdf/core";
import {
ApplicationRequestError,
DocxExportService,
DocxExportServiceError,
DocxThemeTokenService,
createApplicationService,
type ApplicationService
readDocxExportRuntimeLimits,
type ApplicationService,
type ApplicationServiceOptions
} from "@md-to-pdf/application";
import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
import {
createDocxMediaEngine,
type ServerDocxMediaCaptureAdapter
} from "./docx-media-engine.js";
import {
createPdfGenerator,
PdfEngineClosedError,
@@ -68,7 +82,14 @@ export interface BuildAppOptions {
logger?: boolean;
pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean;
prewarmDocxRuntime?: boolean;
applicationService?: ApplicationService;
docxExportService?: Pick<
DocxExportService,
"getCapability" | "generate" | "close"
>;
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
}
function milliseconds(value: number) {
@@ -106,6 +127,31 @@ function createPdfServerTiming(
].join(", ");
}
function createDocxServerTiming(
timings: Awaited<
ReturnType<DocxExportService["generate"]>
>["timings"]
) {
return [
`queue;dur=${milliseconds(timings.queueMs)}`,
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
`prepare;dur=${milliseconds(timings.prepareMs)}`,
`theme-style;dur=${milliseconds(timings.themeStyleMs)}`,
`media;dur=${milliseconds(timings.mediaMs)}`,
`reference;dur=${milliseconds(timings.referenceMs)}`,
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
`validation;dur=${milliseconds(timings.validationMs)}`,
`total;dur=${milliseconds(timings.totalMs)}`
].join(", ");
}
export function createDocxContentDisposition(fileName: string) {
return (
`attachment; filename="document.docx"; ` +
`filename*=UTF-8''${encodeRfc5987(fileName)}`
);
}
export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: options.logger ?? true,
@@ -117,10 +163,29 @@ export function buildApp(options: BuildAppOptions = {}) {
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
onWarning: (message) => app.log.warn(message)
onWarning: (message) => app.log.warn(message),
...(options.fontPacks ? { fontPacks: options.fontPacks } : {})
});
const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator();
const pandocRuntime = options.docxExportService
? undefined
: new PandocRuntime();
const docxMediaAdapter =
options.docxMediaAdapter ?? createDocxMediaEngine();
const docxExportService =
options.docxExportService ??
new DocxExportService({
application: applicationService,
runtime: pandocRuntime!,
converter: new PandocDocxConverter({
runtime: pandocRuntime!
}),
themeTokens: new DocxThemeTokenService(
docxMediaAdapter
),
limits: readDocxExportRuntimeLimits()
});
if (options.prewarmPdfBrowser) {
app.addHook("onReady", async () => {
@@ -132,8 +197,27 @@ export function buildApp(options: BuildAppOptions = {}) {
});
}
if (options.prewarmDocxRuntime) {
app.addHook("onReady", async () => {
try {
const capability =
await docxExportService.getCapability();
if (capability.status !== "available") {
app.log.warn(
{ capability },
"DOCX runtime unavailable"
);
}
} catch (error) {
app.log.warn({ error }, "DOCX runtime probe failed");
}
});
}
app.addHook("onClose", async () => {
await pdfGenerator.close();
await docxExportService.close();
await docxMediaAdapter.close();
});
app.get("/api/health", async () => ({
@@ -143,19 +227,28 @@ export function buildApp(options: BuildAppOptions = {}) {
rendererVersion: RENDERER_VERSION
}));
app.get("/api/capabilities", async () => ({
defaultExportConfig,
supportedPaperFormats,
implemented: [
"project-skeleton",
"export-config",
"theme-manifest",
"markdown-render",
"html-preview",
"pdf-export"
],
planned: []
}));
app.get("/api/capabilities", async () => {
const docx = await docxExportService.getCapability();
return {
defaultExportConfig,
supportedPaperFormats,
docx,
implemented: [
"project-skeleton",
"export-config",
"theme-manifest",
"markdown-render",
"html-preview",
"pdf-export",
"docx-export"
],
planned: []
};
});
app.get("/api/docx/capability", async () =>
docxExportService.getCapability()
);
app.get("/api/themes", async () =>
applicationService.listThemes()
@@ -211,6 +304,44 @@ export function buildApp(options: BuildAppOptions = {}) {
}
);
app.get<{
Params: {
packId: string;
packVersion: string;
faceId: string;
};
}>(
"/api/font-packs/:packId/:packVersion/:faceId/web",
async (request, reply) => {
try {
const asset = await applicationService.getFontPackAsset(
request.params.packId,
request.params.packVersion,
request.params.faceId,
"web"
);
if (!asset) {
return reply.code(404).send({
error: "FONT_PACK_ASSET_NOT_FOUND",
message: "未找到指定字体包资源"
});
}
return reply
.header("content-type", asset.contentType)
.header("cache-control", "public, max-age=31536000, immutable")
.header("content-security-policy", "default-src 'none'; sandbox")
.header("x-content-type-options", "nosniff")
.header("etag", `\"${asset.sha256}\"`)
.send(asset.content);
} catch {
return reply.code(404).send({
error: "FONT_PACK_ASSET_NOT_FOUND",
message: "未找到指定字体包资源"
});
}
}
);
app.post<{ Body: RenderRequestBody }>(
"/api/render",
async (request, reply) => {
@@ -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;
}
+219
View File
@@ -0,0 +1,219 @@
import { chromium, type Browser, type BrowserContext } from "playwright";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type {
DocxThemeStyleCaptureAdapter,
DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
import { captureDocxThemeStyleWithPlaywrightPage } from "./playwright-docx-theme-style.js";
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
export interface ServerDocxMediaCaptureAdapter
extends DocxMediaCaptureAdapter,
DocxThemeStyleCaptureAdapter {
warmup?(): Promise<void>;
close(): Promise<void>;
}
export interface PlaywrightDocxMediaEngineOptions {
renderOrigin?: string;
launchBrowser?: () => Promise<Browser>;
}
function normalizeRenderOrigin(value: string) {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
}
return url.origin;
}
async function closeContext(context: BrowserContext) {
await context.close().catch(() => undefined);
}
export class PlaywrightDocxMediaEngine
implements ServerDocxMediaCaptureAdapter
{
readonly themeStyleBaseUrl: string;
private readonly renderOrigin: string;
private readonly renderUrl: string;
private readonly launchBrowser: () => Promise<Browser>;
private browserPromise: Promise<Browser> | undefined;
private closed = false;
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
this.renderOrigin = normalizeRenderOrigin(
options.renderOrigin ??
process.env.PDF_RENDER_ORIGIN ??
"http://localhost:5173"
);
this.renderUrl = new URL(
"/preview-frame.html?target=continuous",
this.renderOrigin
).href;
this.themeStyleBaseUrl = this.renderUrl;
this.launchBrowser =
options.launchBrowser ??
(() =>
chromium.launch({
headless: true
}));
}
async warmup() {
if (this.closed) {
throw new Error("DOCX 媒体服务已关闭");
}
await this.getBrowser();
}
async capture(
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
throw new Error("DOCX 媒体服务已关闭");
}
signal?.throwIfAborted();
const browser = await this.getBrowser();
signal?.throwIfAborted();
const context = await browser.newContext({
locale: request.payload.metadata.language || "zh-CN",
serviceWorkers: "block"
});
const abort = () => {
void closeContext(context);
};
signal?.addEventListener("abort", abort, { once: true });
try {
await context.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const page = await context.newPage();
await page.goto(this.renderUrl, {
waitUntil: "domcontentloaded"
});
await page.waitForFunction(
() =>
document.documentElement.dataset.runtimeReady === "true"
);
signal?.throwIfAborted();
return await captureDocxMediaWithPlaywrightPage(
page,
request,
signal
);
} finally {
signal?.removeEventListener("abort", abort);
await closeContext(context);
}
}
async captureThemeStyle(
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
throw new Error("DOCX 媒体服务已关闭");
}
signal?.throwIfAborted();
if (new URL(request.baseUrl).origin !== this.renderOrigin) {
throw new Error("DOCX 主题样式资源地址必须与渲染地址同源");
}
const browser = await this.getBrowser();
signal?.throwIfAborted();
const context = await browser.newContext({
locale: "zh-CN",
serviceWorkers: "block"
});
const abort = () => {
void closeContext(context);
};
signal?.addEventListener("abort", abort, { once: true });
try {
await context.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const page = await context.newPage();
await page.goto(this.renderUrl, {
waitUntil: "domcontentloaded"
});
return await captureDocxThemeStyleWithPlaywrightPage(
page,
request,
signal
);
} finally {
signal?.removeEventListener("abort", abort);
await closeContext(context);
}
}
async close() {
this.closed = true;
const browserPromise = this.browserPromise;
this.browserPromise = undefined;
if (!browserPromise) {
return;
}
try {
const browser = await browserPromise;
await browser.close();
} catch {
// 浏览器启动失败时没有可关闭的实例。
}
}
private getBrowser() {
if (!this.browserPromise) {
const browserPromise = this.launchBrowser();
this.browserPromise = browserPromise;
void browserPromise
.then((browser) => {
browser.on("disconnected", () => {
if (this.browserPromise === browserPromise) {
this.browserPromise = undefined;
}
});
})
.catch(() => {
if (this.browserPromise === browserPromise) {
this.browserPromise = undefined;
}
});
}
return this.browserPromise;
}
}
export function createDocxMediaEngine(
options: PlaywrightDocxMediaEngineOptions = {}
) {
return new PlaywrightDocxMediaEngine(options);
}
+11 -1
View File
@@ -2,8 +2,18 @@ import { buildApp } from "./app.js";
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
const host = process.env.HOST ?? "0.0.0.0";
const fontPackRoot = process.env.FONT_PACK_ROOT?.trim();
const app = buildApp({
prewarmPdfBrowser: true
prewarmPdfBrowser: true,
prewarmDocxRuntime: true,
...(fontPackRoot
? {
fontPacks: {
roots: [fontPackRoot],
appVersion: process.env.APP_VERSION ?? "0.6.0"
}
}
: {})
});
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);
}
+93
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { buildApp } from "../src/app.js";
import {
createPdfContentDisposition,
@@ -39,6 +40,61 @@ function localThemeManifest(
};
}
function sha256(content: Uint8Array | string) {
return createHash("sha256").update(content).digest("hex");
}
async function createServerFontPack(root: string) {
const directory = join(root, "official-cjk", "1.0.0");
const web = Buffer.from([31, 32, 33, 34]);
const docx = Buffer.from([41, 42, 43, 44, 45]);
const license = "SIL Open Font License 1.1";
await mkdir(join(directory, "fonts"), { recursive: true });
await writeFile(join(directory, "fonts", "serif.woff2"), web);
await writeFile(join(directory, "fonts", "serif.ttf"), docx);
await writeFile(join(directory, "OFL.txt"), license, "utf8");
await writeFile(
join(directory, "font-pack.json"),
JSON.stringify({
manifestVersion: 1,
id: "official-cjk",
version: "1.0.0",
name: "政务中文字体包",
description: "Server 字体包资源测试",
license: "OFL-1.1",
licenseFile: "OFL.txt",
licenseSha256: sha256(license),
licenseBytes: Buffer.byteLength(license),
compatibility: {
minimumAppVersion: "0.6.0",
maximumAppVersionExclusive: "0.7.0"
},
faces: [
{
id: "serif-regular",
targets: ["FandolSong", "Mdpdf Fandol Song"],
weight: 400,
style: "normal",
web: {
path: "fonts/serif.woff2",
format: "woff2",
bytes: web.byteLength,
sha256: sha256(web)
},
docx: {
path: "fonts/serif.ttf",
format: "truetype",
bytes: docx.byteLength,
sha256: sha256(docx)
}
}
]
}),
"utf8"
);
return { web };
}
afterEach(async () => {
await app?.close();
if (temporaryDirectory) {
@@ -136,6 +192,43 @@ describe("预览 API", () => {
expect(response.body).toContain("#write");
});
it("为主题 CSS 提供经过注册器验证的字体包 Web 资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-font-pack-"));
const fontPack = await createServerFontPack(temporaryDirectory);
app = buildApp({
logger: false,
fontPacks: {
roots: [temporaryDirectory],
appVersion: "0.6.0"
}
});
const cssResponse = await app.inject({
method: "GET",
url: "/api/themes/gov-red-standard/css"
});
expect(cssResponse.statusCode).toBe(200);
expect(cssResponse.body).toContain("md-to-pdf-font-packs:official-cjk@1.0.0");
expect(cssResponse.body).toContain(
"/api/font-packs/official-cjk/1.0.0/serif-regular/web?v="
);
const assetResponse = await app.inject({
method: "GET",
url: `/api/font-packs/official-cjk/1.0.0/serif-regular/web?v=${sha256(fontPack.web)}`
});
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("font/woff2");
expect(assetResponse.headers["cache-control"]).toContain("immutable");
expect(assetResponse.rawPayload).toEqual(fontPack.web);
const missingResponse = await app.inject({
method: "GET",
url: "/api/font-packs/official-cjk/2.0.0/serif-regular/web"
});
expect(missingResponse.statusCode).toBe(404);
});
it("发现本地主题并安全提供相对字体资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const themeDirectory = join(temporaryDirectory, "local-test");
@@ -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"
);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FastifyInstance } from "fastify";
import {
ApplicationRequestError,
DocxExportServiceError,
type DocxMediaCaptureAdapter
} from "@md-to-pdf/application";
import {
DOCX_MIME_TYPE,
defaultExportConfig,
type DocxCapability,
type DocxExportResult
} from "@md-to-pdf/core";
import { buildApp } from "../src/app.js";
import type { PdfGenerator } from "../src/pdf-engine.js";
import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js";
let app: FastifyInstance | undefined;
afterEach(async () => {
await app?.close();
app = undefined;
});
const availableCapability: DocxCapability = {
format: "docx",
status: "available",
expectedVersion: "3.9.0.2",
detectedVersion: "3.9.0.2"
};
function generatedDocx(): DocxExportResult {
return {
docx: new Uint8Array([80, 75, 3, 4]),
fileName: "测试报告.docx",
diagnostics: {
warnings: ["字体可能被替换"],
echartsErrors: ["图表 1"],
mermaidErrors: []
},
timings: {
queueMs: 1,
probeMs: 2,
prepareMs: 3,
themeStyleMs: 4,
mediaMs: 5,
referenceMs: 6,
pandocMs: 7,
validationMs: 8,
totalMs: 36
}
};
}
function createDocxService(
overrides: {
capability?: DocxCapability;
generate?: (request: unknown) => Promise<DocxExportResult>;
} = {}
) {
return {
getCapability: vi
.fn()
.mockResolvedValue(
overrides.capability ?? availableCapability
),
generate: vi.fn(
overrides.generate ?? (async () => generatedDocx())
),
close: vi.fn(async () => undefined)
};
}
function createMediaAdapter() {
return {
themeStyleBaseUrl:
"http://localhost:5173/preview-frame.html",
capture: vi.fn<
DocxMediaCaptureAdapter["capture"]
>(async () => ({
plan: {
targets: [],
echartsErrors: [],
mermaidErrors: []
},
captures: []
})),
captureThemeStyle: vi.fn(async () => {
throw new Error("此测试使用注入的 DOCX 导出服务");
}),
close: vi.fn(async () => undefined)
} satisfies ServerDocxMediaCaptureAdapter;
}
function createPdfGenerator() {
return {
generate: vi.fn(),
close: vi.fn(async () => undefined)
} as unknown as PdfGenerator;
}
function createTestApp(
service = createDocxService(),
mediaAdapter = createMediaAdapter()
) {
app = buildApp({
logger: false,
pdfGenerator: createPdfGenerator(),
docxExportService: service,
docxMediaAdapter: mediaAdapter
});
return { app, service, mediaAdapter };
}
describe("DOCX HTTP API", () => {
it("在统一 capability 中报告 DOCX 运行时状态", async () => {
const { app: instance, service } = createTestApp();
const response = await instance.inject({
method: "GET",
url: "/api/capabilities"
});
expect(response.statusCode).toBe(200);
expect(response.json().docx).toEqual(availableCapability);
expect(response.json().implemented).toContain("docx-export");
expect(service.getCapability).toHaveBeenCalledTimes(1);
});
it("通过独立端点返回不可用 capability", async () => {
const capability: DocxCapability = {
format: "docx",
status: "not-found",
expectedVersion: "3.9.0.2",
message: "未找到 Pandoc 运行时"
};
const { app: instance } = createTestApp(
createDocxService({ capability })
);
const response = await instance.inject({
method: "GET",
url: "/api/docx/capability"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(capability);
});
it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => {
const { app: instance, service, mediaAdapter } =
createTestApp();
const payload = {
markdown: "# 测试报告",
fileName: "目录/测试报告.md",
language: "zh-CN",
resources: [],
exportConfig: defaultExportConfig
};
const response = await instance.inject({
method: "POST",
url: "/api/docx",
payload
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain(
DOCX_MIME_TYPE
);
expect(response.headers["content-disposition"]).toContain(
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx"
);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.headers["x-docx-warning-count"]).toBe("1");
expect(response.headers["x-echarts-error-count"]).toBe("1");
expect(response.headers["x-mermaid-error-count"]).toBe("0");
expect(response.headers["server-timing"]).toContain(
"runtime-probe;dur=2.0"
);
expect(response.headers["server-timing"]).toContain(
"pandoc;dur=7.0"
);
expect(response.rawPayload).toEqual(
Buffer.from([80, 75, 3, 4])
);
expect(service.generate).toHaveBeenCalledWith(payload, {
mediaAdapter,
signal: expect.any(AbortSignal)
});
});
it("映射应用请求错误", async () => {
const service = createDocxService({
generate: async () => {
throw new ApplicationRequestError(
400,
"INVALID_EXPORT_CONFIG",
"导出配置无效"
);
}
});
const { app: instance } = createTestApp(service);
const response = await instance.inject({
method: "POST",
url: "/api/docx",
payload: {}
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: "INVALID_EXPORT_CONFIG",
message: "导出配置无效",
retryable: false
});
});
it("映射可重试的队列错误", async () => {
const service = createDocxService({
generate: async () => {
throw new DocxExportServiceError(
429,
"DOCX_QUEUE_FULL",
"DOCX 生成队列已满",
true
);
}
});
const { app: instance } = createTestApp(service);
const response = await instance.inject({
method: "POST",
url: "/api/docx",
payload: {}
});
expect(response.statusCode).toBe(429);
expect(response.headers["retry-after"]).toBe("5");
expect(response.json()).toEqual({
error: "DOCX_QUEUE_FULL",
message: "DOCX 生成队列已满",
retryable: true
});
});
it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => {
const service = createDocxService();
const mediaAdapter = createMediaAdapter();
const { app: instance } = createTestApp(
service,
mediaAdapter
);
await instance.close();
expect(service.close).toHaveBeenCalledTimes(1);
expect(mediaAdapter.close).toHaveBeenCalledTimes(1);
app = undefined;
});
});
+131
View File
@@ -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
View File
@@ -12,14 +12,14 @@
<meta name="theme-color" content="#1f2937" />
<meta
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="description"
content="可配置、可主题化的 Markdown PDF 导出工具"
content="面向结构化 Markdown 的主题化文档创作与发布工具"
/>
<title>Markdown PDF 导出器</title>
<title>墨呈 · MorphDoc</title>
</head>
<body>
<div id="root"></div>
+6 -3
View File
@@ -10,13 +10,16 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2",
"@md-to-pdf/preview-engine": "0.1.0",
"@uiw/react-codemirror": "^4.25.11",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"mermaid": "11.16.0",
"pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200",
"react": "^19.2.0",
"react-dom": "^19.2.0"
+879 -86
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -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>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from "react";
export interface ExportCommand {
id: string;
label: string;
description: string;
disabled?: boolean;
disabledReason?: string | undefined;
busy?: boolean;
onSelect(): void | Promise<void>;
}
export interface ExportMenuProps {
commands: ExportCommand[];
}
export function ExportMenu({ commands }: ExportMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!menuRef.current?.contains(event.target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div className="export-menu more-menu" ref={menuRef}>
<button
type="button"
aria-controls="export-actions-menu"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((current) => !current)}
>
</button>
{open ? (
<div
id="export-actions-menu"
className="export-menu-popover more-menu-popover"
role="menu"
aria-label="导出格式"
>
{commands.map((command) => (
<button
type="button"
className="export-menu-command"
role="menuitem"
key={command.id}
disabled={command.disabled}
title={command.disabledReason}
onClick={() => {
setOpen(false);
void command.onSelect();
}}
>
<span>
<strong>
{command.busy
? `正在生成 ${command.label}`
: `导出 ${command.label}`}
</strong>
<small>
{command.disabledReason ?? command.description}
</small>
</span>
</button>
))}
</div>
) : null}
</div>
);
}
+235 -2
View File
@@ -1,4 +1,5 @@
import {
defaultExportConfig,
getPaperDimensionsMm,
lengthToMillimeters,
paperFormatLabels,
@@ -12,9 +13,11 @@ import {
type MermaidExportConfig
} from "@md-to-pdf/core";
import { useEffect, useState } from "react";
import type { ThemeSummary } from "./application-backend";
interface ExportSettingsDrawerProps {
config: ExportConfig;
theme: ThemeSummary | undefined;
onChange: (config: ExportConfig) => void;
onClose: () => void;
onReset: () => void;
@@ -24,6 +27,9 @@ interface LengthInputProps {
label: string;
value: string;
maximum: number;
disabled?: boolean;
precision?: number;
step?: number;
onChange: (value: string) => void;
}
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
"page-total": "1 / 10",
"chinese-page-total": "第 1 页 / 共 10 页",
"dash-page": "- 1 -",
"official-page": "— 1 —(标准公文)",
custom: "自定义模板"
};
@@ -98,6 +105,9 @@ function LengthInput({
label,
value,
maximum,
disabled = false,
precision = 1,
step = 0.1,
onChange
}: LengthInputProps) {
const [draft, setDraft] = useState(
@@ -115,7 +125,8 @@ function LengthInput({
return;
}
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));
onChange(`${rounded}mm`);
}
@@ -128,8 +139,9 @@ function LengthInput({
type="number"
min="0"
max={maximum}
step="0.1"
step={step}
value={draft}
disabled={disabled}
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
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({
value,
onChange
@@ -208,6 +271,7 @@ function updateHeaderSlot(
export function ExportSettingsDrawer({
config,
theme,
onChange,
onClose,
onReset
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
value: string
) {
updatePaper({
marginMode: "custom",
margins: {
...margins,
[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>) {
onChange({
...config,
pageDecorationsMode: "custom",
header: {
...config.header,
...header
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
function updateFooter(footer: Partial<FooterConfig>) {
onChange({
...config,
pageDecorationsMode: "custom",
footer: {
...config.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>) {
onChange({
...config,
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
<section className="settings-section">
<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">
<LengthInput
label="上"
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
onChange={(value) => updateMargin("left", value)}
/>
</div>
{config.paper.marginMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemeMargins}
>
</button>
) : null}
</section>
<section className="settings-section">
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
</p>
</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">
<div className="setting-section-title">
<h3></h3>
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
<p className="setting-hint">
{"${title}"}{"${author}"} {"${filename}"}
</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) => {
const labels = { left: "左侧", center: "中间", right: "右侧" };
return (
@@ -540,6 +731,19 @@ export function ExportSettingsDrawer({
</div>
);
})}
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.header.showOnFirstPage}
disabled={!config.header.enabled}
onChange={(event) =>
updateHeader({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control">
<input
type="checkbox"
@@ -567,6 +771,21 @@ export function ExportSettingsDrawer({
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
</label>
</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">
<span></span>
<select
@@ -616,6 +835,7 @@ export function ExportSettingsDrawer({
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
<option value="outer"></option>
</select>
</label>
<label className="setting-field">
@@ -636,6 +856,19 @@ export function ExportSettingsDrawer({
}
/>
</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">
<input
type="checkbox"
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from "react";
export interface ExportToastState {
kind: "progress" | "success" | "error";
title: string;
message?: string;
}
export interface ExportToastProps {
toast: ExportToastState;
onDismiss(): void;
}
export function ExportToast({
toast,
onDismiss
}: ExportToastProps) {
useEffect(() => {
if (toast.kind !== "success") {
return;
}
const timeout = window.setTimeout(onDismiss, 3_500);
return () => window.clearTimeout(timeout);
}, [onDismiss, toast]);
return (
<aside
className={`export-toast is-${toast.kind}`}
role={toast.kind === "error" ? "alert" : "status"}
aria-live={toast.kind === "error" ? "assertive" : "polite"}
>
<div className="export-toast-content">
<strong>{toast.title}</strong>
{toast.message ? <span>{toast.message}</span> : null}
</div>
{toast.kind !== "progress" ? (
<button
type="button"
className="export-toast-close"
aria-label="关闭导出提示"
onClick={onDismiss}
>
×
</button>
) : null}
{toast.kind === "progress" ? (
<div className="export-toast-progress" aria-hidden="true">
<span />
</div>
) : null}
</aside>
);
}
+158
View File
@@ -0,0 +1,158 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef
} from "react";
import CodeMirror, {
type ReactCodeMirrorRef
} from "@uiw/react-codemirror";
import { markdown } from "@codemirror/lang-markdown";
import {
EditorView,
keymap,
type KeyBinding
} from "@codemirror/view";
import {
executeMarkdownEditorCommand,
markdownEditorCommands
} from "./markdown-editor-commands";
export interface MarkdownEditorHandle {
focus: () => void;
getScrollElement: () => HTMLElement | null;
getView: () => EditorView | null;
}
interface MarkdownEditorProps {
value: string;
documentVersion: number;
onChange: (value: string) => void;
onEditorReady?: (view: EditorView | null) => void;
onScroll?: () => void;
}
const editorTheme = EditorView.theme({
"&": {
height: "100%",
backgroundColor: "#f6f7f5",
color: "#26312d",
fontSize: "0.86rem"
},
"&.cm-focused": {
outline: "none",
boxShadow: "inset 3px 0 #74a88f"
},
".cm-scroller": {
fontFamily:
'"Cascadia Code", "SFMono-Regular", Consolas, monospace',
lineHeight: "1.72",
tabSize: "2",
overflow: "auto"
},
".cm-content": {
minHeight: "100%",
padding: "24px"
},
".cm-line": {
padding: "0"
},
".cm-gutters": {
border: "0",
backgroundColor: "#eef1ef",
color: "#8a9690"
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: "rgb(116 168 143 / 9%)"
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "rgb(89 135 113 / 24%)"
}
});
export const MarkdownEditor = forwardRef<
MarkdownEditorHandle,
MarkdownEditorProps
>(function MarkdownEditor(
{
value,
documentVersion,
onChange,
onEditorReady,
onScroll
},
forwardedRef
) {
const codeMirrorRef = useRef<ReactCodeMirrorRef>(null);
const viewRef = useRef<EditorView | null>(null);
const shortcutBindings = useMemo(
() =>
markdownEditorCommands.flatMap<KeyBinding>((command) =>
command.shortcut
? [
{
key: command.shortcut,
run: (view) =>
executeMarkdownEditorCommand(command.id, { view })
}
]
: []
),
[]
);
const extensions = useMemo(
() => [markdown(), keymap.of(shortcutBindings)],
[shortcutBindings]
);
useImperativeHandle(
forwardedRef,
() => ({
focus: () => viewRef.current?.focus(),
getScrollElement: () => viewRef.current?.scrollDOM ?? null,
getView: () => viewRef.current
}),
[]
);
useEffect(
() => () => {
onEditorReady?.(null);
viewRef.current = null;
},
[documentVersion, onEditorReady]
);
return (
<div
className="markdown-editor"
onScrollCapture={onScroll}
>
<CodeMirror
key={documentVersion}
ref={codeMirrorRef}
value={value}
height="100%"
basicSetup={{
autocompletion: false,
bracketMatching: true,
closeBrackets: true,
foldGutter: false,
highlightActiveLine: true,
highlightActiveLineGutter: true,
lineNumbers: true,
searchKeymap: true
}}
extensions={extensions}
theme={editorTheme}
aria-label="Markdown 内容"
onChange={onChange}
onCreateEditor={(view) => {
viewRef.current = view;
onEditorReady?.(view);
}}
/>
</div>
);
});
+208
View File
@@ -0,0 +1,208 @@
import {
type KeyboardEvent,
useEffect,
useRef,
useState
} from "react";
import type { EditorView } from "@codemirror/view";
import { insertMarkdownTable } from "./markdown-editor-commands";
interface MarkdownTableToolProps {
editorView: EditorView | null;
}
const maximumColumns = 10;
const maximumRows = 10;
const minimumRows = 2;
interface TableSize {
columns: number;
rows: number;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
export function MarkdownTableTool({
editorView
}: MarkdownTableToolProps) {
const [open, setOpen] = useState(false);
const [size, setSize] = useState<TableSize>({
columns: 3,
rows: 3
});
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
function focusSize(nextSize: TableSize) {
window.requestAnimationFrame(() => {
rootRef.current
?.querySelector<HTMLButtonElement>(
`[data-table-size="${nextSize.columns}x${nextSize.rows}"]`
)
?.focus();
});
}
useEffect(() => {
if (!open) {
return;
}
focusSize(size);
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => {
if (!editorView) {
setOpen(false);
}
}, [editorView]);
function moveSelection(
event: KeyboardEvent<HTMLButtonElement>,
columnsDelta: number,
rowsDelta: number
) {
event.preventDefault();
const nextSize = {
columns: clamp(
size.columns + columnsDelta,
1,
maximumColumns
),
rows: clamp(
size.rows + rowsDelta,
minimumRows,
maximumRows
)
};
setSize(nextSize);
focusSize(nextSize);
}
function insertTable(selectedSize: TableSize) {
if (!editorView) {
return;
}
insertMarkdownTable(
editorView,
selectedSize.columns,
selectedSize.rows
);
setOpen(false);
}
return (
<div className="markdown-table-tool" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className="markdown-toolbar-button"
aria-label="插入表格"
aria-haspopup="dialog"
aria-expanded={open}
title="插入表格"
disabled={!editorView}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setOpen((current) => !current)}
>
</button>
{open ? (
<div
className="markdown-table-popover"
role="dialog"
aria-label="选择表格尺寸"
>
<strong>
{size.columns} × {size.rows}
</strong>
<span></span>
<div
className="markdown-table-grid"
role="grid"
aria-label="表格尺寸"
aria-rowcount={maximumRows - minimumRows + 1}
aria-colcount={maximumColumns}
>
{Array.from(
{
length:
maximumColumns *
(maximumRows - minimumRows + 1)
},
(_, index) => {
const columns = (index % maximumColumns) + 1;
const rows =
Math.floor(index / maximumColumns) + minimumRows;
const selected =
columns <= size.columns && rows <= size.rows;
const active =
columns === size.columns && rows === size.rows;
return (
<button
key={`${columns}x${rows}`}
type="button"
role="gridcell"
className={
selected
? "markdown-table-cell is-selected"
: "markdown-table-cell"
}
data-table-size={`${columns}x${rows}`}
aria-label={`插入 ${columns}× ${rows} 行表格`}
aria-selected={active}
tabIndex={active ? 0 : -1}
onPointerEnter={() =>
setSize({ columns, rows })
}
onFocus={() => setSize({ columns, rows })}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
moveSelection(event, -1, 0);
} else if (event.key === "ArrowRight") {
moveSelection(event, 1, 0);
} else if (event.key === "ArrowUp") {
moveSelection(event, 0, -1);
} else if (event.key === "ArrowDown") {
moveSelection(event, 0, 1);
} else if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
insertTable({ columns, rows });
}
}}
onClick={() => insertTable({ columns, rows })}
/>
);
}
)}
</div>
</div>
) : null}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { MouseEvent } from "react";
import type { EditorView } from "@codemirror/view";
import {
executeMarkdownEditorCommand,
getMarkdownEditorCommand
} from "./markdown-editor-commands";
import { MarkdownTableTool } from "./MarkdownTableTool";
interface MarkdownToolbarProps {
editorView: EditorView | null;
}
const toolbarGroups = [
["undo", "redo"],
["bold", "italic", "strikethrough", "inline-code"],
[
"code-block",
"quote",
"unordered-list",
"ordered-list",
"task-list"
],
["table", "horizontal-rule"]
] as const;
function preserveEditorSelection(event: MouseEvent<HTMLElement>) {
event.preventDefault();
}
export function MarkdownToolbar({
editorView
}: MarkdownToolbarProps) {
function execute(commandId: string) {
if (!editorView) {
return;
}
executeMarkdownEditorCommand(commandId, { view: editorView });
}
return (
<div
className="markdown-toolbar"
role="toolbar"
aria-label="Markdown 格式工具栏"
>
<label className="markdown-toolbar-heading">
<span className="sr-only"></span>
<select
aria-label="设置标题级别"
defaultValue=""
disabled={!editorView}
onChange={(event) => {
if (event.target.value) {
execute(event.target.value);
event.target.value = "";
}
}}
>
<option value=""></option>
{Array.from({ length: 6 }, (_, index) => (
<option
key={index + 1}
value={`heading-${index + 1}`}
>
H{index + 1}
</option>
))}
</select>
</label>
{toolbarGroups.map((group, groupIndex) => (
<div
className="markdown-toolbar-group"
key={group.join("-")}
aria-label={`工具组 ${groupIndex + 1}`}
>
{group.map((commandId) => {
if (commandId === "table") {
return (
<MarkdownTableTool
key={commandId}
editorView={editorView}
/>
);
}
const command = getMarkdownEditorCommand(commandId);
if (!command) {
return null;
}
const disabled =
!editorView ||
(command.isEnabled?.({ view: editorView }) === false);
return (
<button
key={command.id}
type="button"
className={`markdown-toolbar-button command-${command.id}`}
aria-label={command.label}
title={
command.shortcut
? `${command.label}${command.shortcut}`
: command.label
}
disabled={disabled}
onMouseDown={preserveEditorSelection}
onClick={() => execute(command.id)}
>
{command.toolbarLabel}
</button>
);
})}
</div>
))}
</div>
);
}
+94 -4
View File
@@ -7,6 +7,7 @@ import {
useState
} from "react";
import {
AnnotationLayer,
getDocument,
GlobalWorkerOptions,
RenderingCancelledException,
@@ -18,12 +19,20 @@ import {
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import {
getPdfCanvasRenderScale,
getPdfPageCssDimensions
getPdfPageCssDimensions,
getPrecisePdfDocumentWidth
} from "./pdf-page-geometry";
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
import { PdfPageRenderQueue } from "./pdf-render-queue";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
const PRECISE_PDF_INLINE_GUTTER = 30;
type PdfJsAnnotationLinkService = Parameters<
AnnotationLayer["render"]
>[0]["linkService"];
interface PdfPageDescriptor {
pageNumber: number;
width: number;
@@ -33,12 +42,16 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps {
blob: Blob;
zoomPercent: number;
onExternalLink?: (href: string) => void;
onNavigateToPage?: (pageNumber: number) => void;
onPageCount?: (pageCount: number) => void;
}
interface PrecisePdfPageProps {
document: PDFDocumentProxy;
descriptor: PdfPageDescriptor;
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
renderQueue: PdfPageRenderQueue;
zoomPercent: number;
}
@@ -92,12 +105,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({
document,
descriptor,
onExternalLink,
onNavigateToPage,
renderQueue,
zoomPercent
}: PrecisePdfPageProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const textLayerRef = useRef<HTMLDivElement>(null);
const annotationLayerRef = useRef<HTMLDivElement>(null);
const [displayWidth, setDisplayWidth] = useState(0);
const [renderError, setRenderError] = useState("");
const [renderState, setRenderState] = useState<
@@ -158,11 +174,13 @@ function PrecisePdfPage({
useEffect(() => {
const canvas = canvasRef.current;
const textContainer = textLayerRef.current;
const annotationContainer = annotationLayerRef.current;
if (
!nearViewport ||
displayWidth <= 0 ||
!canvas ||
!textContainer
!textContainer ||
!annotationContainer
) {
return;
}
@@ -171,6 +189,7 @@ function PrecisePdfPage({
let page: PDFPageProxy | undefined;
let renderTask: RenderTask | undefined;
let textLayer: TextLayer | undefined;
let annotationLayer: AnnotationLayer | undefined;
setRenderError("");
setRenderState("queued");
@@ -212,6 +231,7 @@ function PrecisePdfPage({
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
textContainer.replaceChildren();
annotationContainer.replaceChildren();
renderTask = page.render({
canvas,
@@ -229,7 +249,43 @@ function PrecisePdfPage({
textContentSource: page.streamTextContent(),
viewport: displayViewport
});
await textLayer.render();
const annotations = await page.getAnnotations({
intent: "display"
});
const linkService = createPdfAnnotationLinkService(
document,
descriptor.pageNumber,
{
onExternalLink,
onNavigateToPage
}
);
annotationLayer = new AnnotationLayer({
div: annotationContainer,
accessibilityManager: undefined,
annotationCanvasMap: undefined,
annotationEditorUIManager: undefined,
page,
viewport: displayViewport,
structTreeLayer: undefined,
commentManager: undefined,
linkService,
annotationStorage: document.annotationStorage
});
await Promise.all([
textLayer.render(),
annotationLayer.render({
viewport: displayViewport,
div: annotationContainer,
annotations,
page,
linkService:
linkService as unknown as PdfJsAnnotationLinkService,
annotationStorage: document.annotationStorage,
renderForms: false,
enableScripting: false
})
]);
} catch (reason: unknown) {
if (
disposed ||
@@ -251,10 +307,12 @@ function PrecisePdfPage({
cancelQueuedRender();
renderTask?.cancel();
textLayer?.cancel();
annotationLayer?.destroy();
page?.cleanup();
canvas.width = 0;
canvas.height = 0;
textContainer.replaceChildren();
annotationContainer.replaceChildren();
setRenderState("idle");
};
}, [
@@ -264,6 +322,8 @@ function PrecisePdfPage({
displayWidth,
document,
nearViewport,
onExternalLink,
onNavigateToPage,
renderQueue
]);
@@ -282,6 +342,10 @@ function PrecisePdfPage({
aria-hidden="true"
/>
<div ref={textLayerRef} className="textLayer" />
<div
ref={annotationLayerRef}
className="annotationLayer"
/>
{renderState !== "rendered" ? (
<span className="precise-pdf-page-placeholder">
{renderState === "rendering"
@@ -299,6 +363,8 @@ function PrecisePdfPage({
export function PrecisePdfPreview({
blob,
zoomPercent,
onExternalLink,
onNavigateToPage,
onPageCount
}: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -308,6 +374,19 @@ export function PrecisePdfPreview({
() => new PdfPageRenderQueue(1),
[document]
);
const externalLinkRef = useRef(onExternalLink);
const navigateToPageRef = useRef(onNavigateToPage);
externalLinkRef.current = onExternalLink;
navigateToPageRef.current = onNavigateToPage;
const handleExternalLink = useCallback((href: string) => {
externalLinkRef.current?.(href);
}, []);
const handleNavigateToPage = useCallback(
(pageNumber: number) => {
navigateToPageRef.current?.(pageNumber);
},
[]
);
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
@@ -379,13 +458,24 @@ export function PrecisePdfPreview({
return <div className="precise-pdf-status"></div>;
}
const documentWidth = getPrecisePdfDocumentWidth(
pages.map((page) => page.width),
zoomPercent / 100,
PRECISE_PDF_INLINE_GUTTER
);
return (
<div className="precise-pdf-document">
<div
className="precise-pdf-document"
style={{ width: `${documentWidth}px` }}
>
{pages.map((page) => (
<PrecisePdfPage
key={page.pageNumber}
document={document}
descriptor={page}
onExternalLink={handleExternalLink}
onNavigateToPage={handleNavigateToPage}
renderQueue={renderQueue}
zoomPercent={zoomPercent}
/>
+4
View File
@@ -0,0 +1,4 @@
export const APP_DISPLAY_NAME = __APP_DISPLAY_NAME__;
export const APP_WINDOW_TITLE = __APP_WINDOW_TITLE__;
export const APP_SLOGAN = __APP_SLOGAN__;
export const APP_DESCRIPTION = __APP_DESCRIPTION__;
+15 -1
View File
@@ -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 {
id: string;
name: string;
version: string;
description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
pageDefaults?: {
margins: PageMargins;
header?: HeaderConfig;
footer?: FooterConfig;
};
bundled: boolean;
source: "bundled" | "local";
}
+41
View File
@@ -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);
}
+49
View File
@@ -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
};
});
+39
View File
@@ -0,0 +1,39 @@
import { classifyDocumentLink } from "@md-to-pdf/core";
export type WebDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "open";
href: string;
}
| {
type: "ignore";
};
export function resolveWebDocumentLinkAction(
href: string
): WebDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return {
type: "anchor",
href: link.normalizedHref
};
}
if (
link.kind === "network" ||
(link.kind === "protocol" &&
(link.scheme === "mailto" || link.scheme === "tel"))
) {
return {
type: "open",
href: link.normalizedHref
};
}
return { type: "ignore" };
}
+45
View File
@@ -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;
}
}
+144
View File
@@ -0,0 +1,144 @@
import {
DOCX_MIME_TYPE,
type DocxCapability,
type DocxExportRequestInput
} from "@md-to-pdf/core";
import { parseContentDispositionFileName } from "./pdf-export";
export interface DocxExportResult {
blob: Blob;
fileName: string;
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}
export interface DesktopDocxExportResult {
saved: boolean;
fileName: string;
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}
function parseNumericHeader(value: string | null) {
if (value === null) {
return 0;
}
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}
async function readErrorMessage(response: Response) {
try {
const payload = (await response.json()) as {
message?: unknown;
};
if (typeof payload.message === "string") {
return payload.message;
}
} catch {
// 非 JSON 错误响应使用通用提示。
}
return "DOCX 导出失败";
}
export async function getDocxCapability(
fetcher: typeof fetch = fetch
): Promise<DocxCapability> {
const bridge = window.mdToPdfDesktop;
if (bridge) {
return bridge.getDocxCapability();
}
const response = await fetcher("/api/docx/capability");
if (!response.ok) {
throw new Error("无法检测 DOCX 导出能力");
}
return (await response.json()) as DocxCapability;
}
export async function requestDocxExport(
request: DocxExportRequestInput,
fetcher: typeof fetch = fetch
): Promise<DocxExportResult> {
const response = await fetcher("/api/docx", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
const contentType =
response.headers.get("content-type")?.split(";")[0]?.trim();
if (contentType !== DOCX_MIME_TYPE) {
throw new Error("DOCX 服务返回了无效文件类型");
}
return {
blob: await response.blob(),
fileName: parseContentDispositionFileName(
response.headers.get("content-disposition"),
"document.docx"
),
warningCount: parseNumericHeader(
response.headers.get("x-docx-warning-count")
),
echartsErrorCount: parseNumericHeader(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: parseNumericHeader(
response.headers.get("x-mermaid-error-count")
)
};
}
export async function requestDesktopDocxExport(
request: DocxExportRequestInput
): Promise<DesktopDocxExportResult> {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
throw new Error("桌面 DOCX 引擎不可用");
}
const outcome = await bridge.exportDocx(request);
if (!outcome.ok) {
throw new Error(outcome.message);
}
return {
saved: outcome.saved,
fileName: outcome.fileName,
warningCount: outcome.diagnostics.warnings.length,
echartsErrorCount: outcome.diagnostics.echartsErrors.length,
mermaidErrorCount: outcome.diagnostics.mermaidErrors.length
};
}
export function downloadDocx(result: DocxExportResult) {
const url = URL.createObjectURL(result.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = result.fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function createDocxDiagnosticsMessage(result: {
warningCount: number;
echartsErrorCount: number;
mermaidErrorCount: number;
}) {
const messages = [];
if (result.warningCount > 0) {
messages.push(`${result.warningCount} 条提示`);
}
if (result.echartsErrorCount > 0) {
messages.push(`${result.echartsErrorCount} 个 ECharts 图表失败`);
}
if (result.mermaidErrorCount > 0) {
messages.push(`${result.mermaidErrorCount} 个 Mermaid 图表失败`);
}
return messages.length > 0
? messages.join("")
: "纸张、样式和可编辑结构已写入文档";
}
+121 -14
View File
@@ -4,8 +4,10 @@ import {
type ExportConfig
} from "@md-to-pdf/core";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
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";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
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 {
if (!rawValue) {
return cloneDefaultExportConfig();
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
try {
const value: unknown = JSON.parse(rawValue);
const migratedValue =
typeof value === "object" &&
value !== null &&
"version" in value &&
value.version === 2
? {
...value,
version: defaultExportConfig.version,
mermaid: structuredClone(defaultExportConfig.mermaid)
}
: value;
const migratedValue = migrateStoredExportConfig(value);
const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch {
@@ -47,7 +152,9 @@ export function loadExportConfig(
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);
if (legacyValue) {
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;
}
}
+4
View File
@@ -1,8 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { APP_WINDOW_TITLE } from "./app-brand";
import { APP_VERSION_LABEL } from "./app-version";
import "./styles.css";
document.title = `${APP_WINDOW_TITLE} ${APP_VERSION_LABEL}`;
const rootElement = document.getElementById("root");
if (!rootElement) {
+514
View File
@@ -0,0 +1,514 @@
import {
EditorSelection,
type EditorState,
type SelectionRange
} from "@codemirror/state";
import {
redo,
redoDepth,
undo,
undoDepth
} from "@codemirror/commands";
import type { EditorView } from "@codemirror/view";
export type MarkdownEditorCommandGroup =
| "history"
| "text"
| "block"
| "insert"
| "document";
export interface EditorCommandContext {
view: EditorView;
openPanel?: (panelId: string) => void;
}
export interface MarkdownEditorCommand {
id: string;
group: MarkdownEditorCommandGroup;
label: string;
toolbarLabel: string;
shortcut?: string;
isEnabled?: (context: EditorCommandContext) => boolean;
execute: (
context: EditorCommandContext
) => void | Promise<void>;
}
export interface MarkdownTextEdit {
insert: string;
selectionFrom: number;
selectionTo: number;
}
type SelectionEditor = (
selectedText: string,
range: SelectionRange,
state: EditorState
) => MarkdownTextEdit;
export function wrapMarkdownSelection(
selectedText: string,
prefix: string,
suffix = prefix,
placeholder = "文本"
): MarkdownTextEdit {
if (
selectedText.length >= prefix.length + suffix.length &&
selectedText.startsWith(prefix) &&
selectedText.endsWith(suffix)
) {
const insert = selectedText.slice(
prefix.length,
selectedText.length - suffix.length
);
return {
insert,
selectionFrom: 0,
selectionTo: insert.length
};
}
const content = selectedText || placeholder;
return {
insert: `${prefix}${content}${suffix}`,
selectionFrom: prefix.length,
selectionTo: prefix.length + content.length
};
}
function mapNonBlankLines(
text: string,
mapper: (line: string, index: number) => string
) {
let contentLineIndex = 0;
return text
.split("\n")
.map((line) => {
if (!line.trim()) {
return line;
}
const mapped = mapper(line, contentLineIndex);
contentLineIndex += 1;
return mapped;
})
.join("\n");
}
export type MarkdownLinePrefix =
| "quote"
| "unordered-list"
| "ordered-list"
| "task-list";
const linePrefixPatterns: Record<MarkdownLinePrefix, RegExp> = {
quote: /^>\s?/u,
"unordered-list": /^[-*+]\s+/u,
"ordered-list": /^\d+[.)]\s+/u,
"task-list": /^[-*+]\s+\[[ xX]\]\s+/u
};
export function toggleMarkdownLinePrefix(
text: string,
kind: MarkdownLinePrefix
) {
const pattern = linePrefixPatterns[kind];
const nonBlankLines = text
.split("\n")
.filter((line) => line.trim());
const shouldRemove =
nonBlankLines.length > 0 &&
nonBlankLines.every((line) => pattern.test(line));
return mapNonBlankLines(text, (line, index) => {
if (shouldRemove) {
return line.replace(pattern, "");
}
if (kind === "quote") {
return `> ${line.replace(linePrefixPatterns.quote, "")}`;
}
if (kind === "ordered-list") {
return `${index + 1}. ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
}
if (kind === "task-list") {
return `- [ ] ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
}
return `- ${line.replace(
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
""
)}`;
});
}
export function setMarkdownHeading(text: string, level: number) {
const prefix = level > 0 ? `${"#".repeat(level)} ` : "";
return mapNonBlankLines(
text,
(line) => `${prefix}${line.replace(/^#{1,6}\s+/u, "")}`
);
}
export function createMarkdownTable(
columnCount: number,
rowCount: number
): MarkdownTextEdit {
const columns = Math.trunc(columnCount);
const rows = Math.trunc(rowCount);
if (columns < 1 || columns > 10 || rows < 2 || rows > 10) {
throw new RangeError("表格尺寸必须为 110 列、210 行");
}
const headerCells = Array.from(
{ length: columns },
(_, index) => ` ${index + 1}`
);
const separatorCells = Array.from(
{ length: columns },
() => "---"
);
const contentRow = `| ${Array.from(
{ length: columns },
() => "内容"
).join(" | ")} |`;
const insert = [
`| ${headerCells.join(" | ")} |`,
`| ${separatorCells.join(" | ")} |`,
...Array.from({ length: rows - 1 }, () => contentRow)
].join("\n");
return {
insert,
selectionFrom: 2,
selectionTo: 5
};
}
function replaceSelections(
view: EditorView,
createEdit: SelectionEditor
) {
const transaction = view.state.changeByRange((range) => {
const selectedText = view.state.sliceDoc(range.from, range.to);
const edit = createEdit(selectedText, range, view.state);
return {
changes: {
from: range.from,
to: range.to,
insert: edit.insert
},
range: EditorSelection.range(
range.from + edit.selectionFrom,
range.from + edit.selectionTo
)
};
});
view.dispatch(transaction);
view.focus();
}
function editSelectedLines(
view: EditorView,
transform: (text: string) => string
) {
const transaction = view.state.changeByRange((range) => {
const startLine = view.state.doc.lineAt(range.from);
const endProbe =
range.to > range.from &&
view.state.doc.lineAt(range.to).from === range.to
? range.to - 1
: range.to;
const endLine = view.state.doc.lineAt(endProbe);
const selectedLines = view.state.sliceDoc(
startLine.from,
endLine.to
);
const insert = transform(selectedLines);
return {
changes: {
from: startLine.from,
to: endLine.to,
insert
},
range: EditorSelection.range(
startLine.from,
startLine.from + insert.length
)
};
});
view.dispatch(transaction);
view.focus();
}
function insertMarkdownBlock(
block: string,
range?: SelectionRange,
state?: EditorState
): MarkdownTextEdit {
const before =
range && state ? state.sliceDoc(0, range.from) : "";
const after = range && state ? state.sliceDoc(range.to) : "";
const leading =
!before || before.endsWith("\n\n")
? ""
: before.endsWith("\n")
? "\n"
: "\n\n";
const trailing =
!after || after.startsWith("\n\n")
? ""
: after.startsWith("\n")
? "\n"
: "\n\n";
const insert = `${leading}${block}${trailing}`;
return {
insert,
selectionFrom: leading.length,
selectionTo: leading.length + block.length
};
}
export function insertMarkdownTable(
view: EditorView,
columnCount: number,
rowCount: number
) {
replaceSelections(view, (_, range, state) => {
const table = createMarkdownTable(columnCount, rowCount);
const block = insertMarkdownBlock(
table.insert,
range,
state
);
return {
insert: block.insert,
selectionFrom: block.selectionFrom + table.selectionFrom,
selectionTo: block.selectionFrom + table.selectionTo
};
});
}
function createInlineCommand(
id: string,
label: string,
toolbarLabel: string,
prefix: string,
suffix = prefix,
placeholder = "文本",
shortcut?: string
): MarkdownEditorCommand {
return {
id,
group: "text",
label,
toolbarLabel,
...(shortcut ? { shortcut } : {}),
execute: ({ view }) =>
replaceSelections(view, (selectedText) =>
wrapMarkdownSelection(
selectedText,
prefix,
suffix,
placeholder
)
)
};
}
function createLineCommand(
id: string,
label: string,
toolbarLabel: string,
kind: MarkdownLinePrefix,
shortcut?: string
): MarkdownEditorCommand {
return {
id,
group: "block",
label,
toolbarLabel,
...(shortcut ? { shortcut } : {}),
execute: ({ view }) =>
editSelectedLines(view, (text) =>
toggleMarkdownLinePrefix(text, kind)
)
};
}
const historyCommands: MarkdownEditorCommand[] = [
{
id: "undo",
group: "history",
label: "撤销",
toolbarLabel: "↶",
shortcut: "Mod-z",
isEnabled: ({ view }) => undoDepth(view.state) > 0,
execute: ({ view }) => {
undo(view);
view.focus();
}
},
{
id: "redo",
group: "history",
label: "重做",
toolbarLabel: "↷",
shortcut: "Mod-Shift-z",
isEnabled: ({ view }) => redoDepth(view.state) > 0,
execute: ({ view }) => {
redo(view);
view.focus();
}
}
];
const textCommands: MarkdownEditorCommand[] = [
createInlineCommand(
"bold",
"加粗",
"B",
"**",
"**",
"粗体文本",
"Mod-b"
),
createInlineCommand(
"italic",
"斜体",
"I",
"*",
"*",
"斜体文本",
"Mod-i"
),
createInlineCommand(
"strikethrough",
"删除线",
"S",
"~~",
"~~",
"删除线文本",
"Mod-Shift-x"
),
createInlineCommand(
"inline-code",
"行内代码",
"</>",
"`",
"`",
"code"
)
];
const blockCommands: MarkdownEditorCommand[] = [
{
id: "code-block",
group: "block",
label: "代码块",
toolbarLabel: "代码块",
execute: ({ view }) =>
replaceSelections(view, (selectedText) =>
wrapMarkdownSelection(
selectedText,
"```\n",
"\n```",
"code"
)
)
},
createLineCommand("quote", "引用", "❞", "quote"),
createLineCommand(
"unordered-list",
"无序列表",
"• 列表",
"unordered-list",
"Mod-Shift-8"
),
createLineCommand(
"ordered-list",
"有序列表",
"1. 列表",
"ordered-list",
"Mod-Shift-7"
),
createLineCommand("task-list", "任务列表", "☑", "task-list")
];
const insertCommands: MarkdownEditorCommand[] = [
{
id: "table",
group: "insert",
label: "插入表格",
toolbarLabel: "表格",
execute: ({ view, openPanel }) => {
if (openPanel) {
openPanel("table");
return;
}
insertMarkdownTable(view, 3, 3);
}
},
{
id: "horizontal-rule",
group: "insert",
label: "插入分隔线",
toolbarLabel: "—",
execute: ({ view }) =>
replaceSelections(view, (_, range, state) =>
insertMarkdownBlock("---", range, state)
)
}
];
const headingCommands = Array.from(
{ length: 6 },
(_, index): MarkdownEditorCommand => {
const level = index + 1;
return {
id: `heading-${level}`,
group: "block",
label: `${level} `,
toolbarLabel: `H${level}`,
execute: ({ view }) =>
editSelectedLines(view, (text) =>
setMarkdownHeading(text, level)
)
};
}
);
export const markdownEditorCommands: readonly MarkdownEditorCommand[] =
[
...historyCommands,
...headingCommands,
...textCommands,
...blockCommands,
...insertCommands
];
const markdownEditorCommandMap = new Map(
markdownEditorCommands.map((command) => [command.id, command])
);
export function getMarkdownEditorCommand(commandId: string) {
return markdownEditorCommandMap.get(commandId);
}
export function executeMarkdownEditorCommand(
commandId: string,
context: EditorCommandContext
) {
const command = getMarkdownEditorCommand(commandId);
if (
!command ||
(command.isEnabled && !command.isEnabled(context))
) {
return false;
}
void command.execute(context);
return true;
}
-537
View File
@@ -1,537 +0,0 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
import {
fitDocumentImagesToPage,
prepareDocumentImageBlocks
} from "./document-image-fit";
import {
applyMediaBackfillCandidates,
findMediaBackfillCandidates,
getMediaBackfillIdsInDocumentOrder,
prepareMediaBackfillBlocks
} from "./media-page-backfill";
import { createMermaidSiteConfig } from "./mermaid-config";
import {
fitOversizedMermaidToPage,
getPageContentDimensions
} from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
import type { MermaidOutputMode } from "./mermaid-static-image";
import { enablePrintMediaForPreview } from "./preview-styles";
import "./paged-table-handler";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
type PagedPreviewPayload
} from "./paged-preview";
import type { PagedRenderTarget } from "./paged-render-target";
export type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
export interface PagedDocumentRenderOptions {
target: PagedRenderTarget;
shouldContinue?: () => boolean;
mermaidOutput?: MermaidOutputMode;
}
function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
return Promise.all(
images.map(async (image) => {
if (image.complete) {
return;
}
try {
await image.decode();
} catch {
await new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
}
})
);
}
function stylesheet(
documentRef: Document,
css: string,
name: string
) {
return {
[`${documentRef.location.href}#${name}`]: css
};
}
function mountMeasurementContainer(
documentRef: Document,
content: DocumentFragment,
payload: PagedPreviewPayload,
target: PagedRenderTarget
) {
const pageContent = getPageContentDimensions(payload.exportConfig);
const style = documentRef.createElement("style");
const themeCss =
target === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss;
style.dataset.pagedMeasurementStyle = "true";
style.textContent = [
documentBaseCss,
highlightCss,
katexCss,
echartsCss,
themeCss,
documentGeometryCss
].join("\n");
const host = documentRef.createElement("div");
host.dataset.pagedMeasurementHost = "true";
host.style.cssText = [
"position: fixed",
"left: -100000px",
"top: 0",
`width: ${pageContent.width}px`,
"height: auto",
"opacity: 0",
"pointer-events: none",
"contain: layout style paint"
].join(";");
documentRef.head.append(style);
host.append(content);
documentRef.body.append(host);
return {
host,
restore() {
content.append(...Array.from(host.childNodes));
host.remove();
style.remove();
}
};
}
function applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
function removeTrailingPdfPageBreak(container: ParentNode) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
pages
.at(-1)
?.style.setProperty("break-after", "auto", "important");
}
export class PagedDocumentRuntime {
private activePreviewer: Previewer | undefined;
private mermaidRenderSequence = 0;
private mermaidPromise:
| Promise<(typeof import("mermaid"))["default"]>
| undefined;
private echartsPromise:
| Promise<
typeof import("@md-to-pdf/markdown-echarts/browser")
>
| undefined;
constructor(private readonly root: HTMLElement) {}
destroy() {
if (!this.activePreviewer) {
return;
}
this.activePreviewer.chunker.destroy();
this.activePreviewer.polisher.destroy();
this.activePreviewer = undefined;
}
private loadMermaid() {
if (!this.mermaidPromise) {
this.mermaidPromise = Promise.all([
import("mermaid"),
import("@mermaid-js/layout-elk")
]).then(
([{ default: mermaid }, { default: elkLayouts }]) => {
mermaid.registerLayoutLoaders(elkLayouts);
return mermaid;
}
);
}
return this.mermaidPromise;
}
private loadECharts() {
if (!this.echartsPromise) {
this.echartsPromise = import(
"@md-to-pdf/markdown-echarts/browser"
);
}
return this.echartsPromise;
}
private async renderECharts(
container: ParentNode,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("echarts")) {
return [];
}
const { renderEChartsBlocks } = await this.loadECharts();
const outcomes = await renderEChartsBlocks(container, {
outputMode: "inline-svg",
concurrency: 2,
timeoutMs: 5_000
});
return outcomes.flatMap((outcome, index) =>
outcome.success
? []
: [
`图表 ${index + 1}${
outcome.error?.message ?? "未知错误"
}`
]
);
}
private async replaceEChartsSvgWithImages(container: ParentNode) {
const { freezeEChartsSvg } = await this.loadECharts();
const figures = Array.from(
container.querySelectorAll<HTMLElement>(
".md-echarts:not(.md-echarts-error)"
)
);
for (const figure of figures) {
const host = figure.querySelector<HTMLElement>(
".md-echarts-host"
);
const svg = host?.querySelector<SVGSVGElement>("svg");
if (!host || !svg) {
continue;
}
const label =
host.getAttribute("aria-label") || "ECharts 图表";
host.replaceChildren(
freezeEChartsSvg(host, svg, "svg-image", label)
);
figure.dataset.echartsRendered = "svg-image";
}
await waitForImages(container);
}
private async renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await this.loadMermaid();
mermaid.initialize(
createMermaidSiteConfig(payload.exportConfig.mermaid)
);
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
this.mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-document-${this.mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error
? outcome.error.message
: "未知错误";
node.classList.add("mermaid-error");
node.textContent =
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
return errors;
}
async render(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
if (!shouldContinue()) {
return undefined;
}
const setupStartedAt = performance.now();
const documentRef = this.root.ownerDocument;
this.destroy();
this.root.replaceChildren();
documentRef.documentElement.dataset.renderTarget = options.target;
documentRef.documentElement.lang =
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 分页文档";
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
const content = template.content;
const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now();
const mermaidErrors = await this.renderMermaid(content, payload);
const mermaidMs = performance.now() - mermaidStartedAt;
let echartsErrors: string[] = [];
let echartsMs = 0;
let echartsFitMs = 0;
let mermaidFitMs = 0;
let mermaidConversionMs = 0;
if (
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image")
) {
const measurement = mountMeasurementContainer(
documentRef,
content,
payload,
options.target
);
try {
await documentRef.fonts.ready;
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
measurement.host,
payload
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host);
prepareDocumentImageBlocks(measurement.host);
prepareMediaBackfillBlocks(measurement.host);
fitDocumentImagesToPage(
measurement.host,
payload.exportConfig,
{ prepareBlocks: false }
);
const echartsFitStartedAt = performance.now();
fitOversizedEChartsToPage(
measurement.host,
payload.exportConfig
);
await this.replaceEChartsSvgWithImages(measurement.host);
echartsFitMs =
performance.now() - echartsFitStartedAt;
const mermaidFitStartedAt = performance.now();
fitOversizedMermaidToPage(
measurement.host,
payload.exportConfig
);
mermaidFitMs = performance.now() - mermaidFitStartedAt;
const mermaidConversionStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") {
replaceMermaidSvgWithImages(measurement.host);
await waitForImages(measurement.host);
}
mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
} finally {
measurement.restore();
}
}
if (!shouldContinue()) {
return undefined;
}
const resourceWaitStartedAt = performance.now();
await documentRef.fonts.ready;
await waitForImages(content);
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
const paginationStartedAt = performance.now();
const repaginationSource = content.cloneNode(
true
) as DocumentFragment;
const stylesheets = [
stylesheet(documentRef, documentBaseCss, "document-base"),
stylesheet(documentRef, highlightCss, "highlight"),
stylesheet(documentRef, katexCss, "katex"),
stylesheet(documentRef, echartsCss, "echarts"),
stylesheet(documentRef, payload.themeCss, "theme"),
stylesheet(
documentRef,
documentGeometryCss,
"document-geometry"
),
stylesheet(
documentRef,
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
];
let previewer = new Previewer();
this.activePreviewer = previewer;
let flow = await previewer.preview(
content,
stylesheets,
this.root
);
const mediaIds = getMediaBackfillIdsInDocumentOrder(
repaginationSource
);
let mediaCandidates = new Map(
findMediaBackfillCandidates(this.root).map((candidate) => [
candidate.id,
candidate
])
);
for (const mediaId of mediaIds) {
if (!shouldContinue()) {
break;
}
const candidate = mediaCandidates.get(mediaId);
if (
!candidate ||
applyMediaBackfillCandidates(repaginationSource, [
candidate
]) === 0
) {
continue;
}
previewer.chunker.destroy();
previewer.polisher.destroy();
previewer = new Previewer();
this.activePreviewer = previewer;
flow = await previewer.preview(
repaginationSource.cloneNode(true) as DocumentFragment,
stylesheets,
this.root
);
mediaCandidates = new Map(
findMediaBackfillCandidates(this.root).map(
(nextCandidate) => [
nextCandidate.id,
nextCandidate
]
)
);
}
const paginationMs = performance.now() - paginationStartedAt;
if (!shouldContinue()) {
return undefined;
}
const finalizeStartedAt = performance.now();
applyPageNumbers(this.root, payload, flow.total);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
}
const finalizeMs = performance.now() - finalizeStartedAt;
return {
pageCount: flow.total,
echartsErrors,
mermaidErrors,
timings: {
setupMs,
echartsMs,
echartsFitMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
resourceWaitMs,
paginationMs,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
}
}
declare global {
interface Window {
__mdToPdfRender?: (
payload: PagedPreviewPayload,
target?: PagedRenderTarget
) => Promise<PagedDocumentRenderResult>;
}
}
+82 -21
View File
@@ -1,16 +1,21 @@
import { PagedDocumentRuntime } from "./paged-document-runtime";
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import {
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
renderDocxMediaCapturePlan,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
import { resolvePagedRenderTarget } from "./paged-render-target";
import { resolveMermaidOutputMode } from "./mermaid-static-image";
} from "@md-to-pdf/preview-engine";
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import {
beginPreviewFrameRender,
completePreviewFrameRender
} from "./preview-frame-visibility";
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
@@ -23,7 +28,11 @@ function getPreviewRoot() {
const previewRoot = getPreviewRoot();
const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot);
const runtime = new PagedDocumentRuntime(previewRoot, {
highlightCss,
katexCss,
echartsCss
});
document.documentElement.dataset.renderTarget = renderTarget;
let latestRequestId = 0;
@@ -33,9 +42,47 @@ function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin);
}
function measurePreviewContentHeight() {
const rootRect = previewRoot.getBoundingClientRect();
const documentRect =
document.documentElement.getBoundingClientRect();
return Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight,
rootRect.bottom - documentRect.top
)
);
}
document.addEventListener(
"click",
(event) => {
const target =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href]")
: undefined;
const href = target?.getAttribute("href")?.trim();
if (!href) {
return;
}
event.preventDefault();
event.stopPropagation();
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "link",
href
});
},
{ capture: true }
);
async function renderPagedPreview(
requestId: number,
payload: PagedPreviewPayload
payload: PagedPreviewPayload,
layout: "paged" | "continuous"
) {
if (requestId !== latestRequestId) {
return;
@@ -45,15 +92,21 @@ async function renderPagedPreview(
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering",
requestId
requestId,
layout
});
try {
const result = await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
const result =
layout === "continuous"
? await runtime.renderContinuous(payload, {
shouldContinue: () => requestId === latestRequestId
})
: await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
@@ -62,7 +115,8 @@ async function renderPagedPreview(
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
contentHeight: Math.ceil(document.documentElement.scrollHeight),
layout,
contentHeight: measurePreviewContentHeight(),
...result
});
} finally {
@@ -85,16 +139,25 @@ window.__mdToPdfRender = async (
}
return result;
};
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
withHiddenWindowAnimationFrames(window, () =>
renderDocxMediaCapturePlan(
runtime,
previewRoot,
payload,
dimensions
)
);
document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
if (desktopPdfBridge) {
desktopPdfBridge.onRender(async (requestId, payload) => {
const requestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) =>
window.setTimeout(() => callback(performance.now()), 0);
try {
const result = await window.__mdToPdfRender?.(payload, "pdf");
const result = await withHiddenWindowAnimationFrames(
window,
async () => window.__mdToPdfRender?.(payload, "pdf")
);
if (!result) {
throw new Error("PDF 分页运行时不可用");
}
@@ -104,8 +167,6 @@ if (desktopPdfBridge) {
requestId,
reason instanceof Error ? reason.message : "PDF 分页失败"
);
} finally {
window.requestAnimationFrame = requestAnimationFrame;
}
});
desktopPdfBridge.ready();
@@ -121,12 +182,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
}
latestRequestId = Math.max(latestRequestId, event.data.requestId);
const { requestId, payload } = event.data;
const { requestId, layout, payload } = event.data;
renderQueue = renderQueue
.catch(() => undefined)
.then(async () => {
try {
await renderPagedPreview(requestId, payload);
await renderPagedPreview(requestId, payload, layout);
} catch (reason: unknown) {
if (requestId !== latestRequestId) {
return;
-41
View File
@@ -1,41 +0,0 @@
declare module "pagedjs" {
export interface PagedFlow {
total: number;
pages: HTMLElement[];
performance: number;
size: {
width: { value: number; unit: string };
height: { value: number; unit: string };
format?: string;
orientation?: string;
};
}
export type PagedStylesheet = string | Record<string, string>;
export class Previewer {
chunker: {
destroy(): void;
};
polisher: {
destroy(): void;
};
preview(
content?: HTMLElement | DocumentFragment | string,
stylesheets?: PagedStylesheet[],
renderTo?: HTMLElement | string
): Promise<PagedFlow>;
}
export class Handler {
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
);
}
export function registerHandlers(
...handlers: Array<typeof Handler>
): void;
}
+152
View File
@@ -0,0 +1,152 @@
import type { PDFDocumentProxy } from "pdfjs-dist";
import { decodeLocalDocumentLinkFromPdf } from "@md-to-pdf/core";
export type PdfDestination = string | unknown[];
export interface PdfAnnotationLinkCallbacks {
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
}
export interface PdfAnnotationLinkService {
externalLinkEnabled: boolean;
addLinkAttributes(
link: HTMLAnchorElement,
url: string,
newWindow?: boolean
): void;
getDestinationHash(destination: PdfDestination): string;
getAnchorUrl(anchor: string): string;
goToDestination(destination: PdfDestination): Promise<void>;
executeNamedAction(action: string): void;
executeSetOCGState(action: object): Promise<void>;
getAttachmentContent(id: string): Promise<undefined>;
}
export async function resolvePdfDestinationPage(
document: Pick<
PDFDocumentProxy,
"getDestination" | "getPageIndex" | "numPages"
>,
destination: PdfDestination
) {
const explicitDestination =
typeof destination === "string"
? await document.getDestination(destination)
: destination;
if (
!Array.isArray(explicitDestination) ||
explicitDestination.length === 0
) {
return undefined;
}
const pageReference = explicitDestination[0];
let pageNumber: number;
if (
typeof pageReference === "number" &&
Number.isInteger(pageReference)
) {
pageNumber = pageReference + 1;
} else if (
pageReference &&
typeof pageReference === "object"
) {
pageNumber =
(await document.getPageIndex(
pageReference as Parameters<
PDFDocumentProxy["getPageIndex"]
>[0]
)) + 1;
} else {
return undefined;
}
return pageNumber >= 1 && pageNumber <= document.numPages
? pageNumber
: undefined;
}
export function resolvePdfNamedActionPage(
action: string,
currentPage: number,
pageCount: number
) {
switch (action) {
case "FirstPage":
return 1;
case "LastPage":
return pageCount;
case "NextPage":
case "PageDown":
return Math.min(pageCount, currentPage + 1);
case "PrevPage":
case "PageUp":
return Math.max(1, currentPage - 1);
default:
return undefined;
}
}
export function createPdfAnnotationLinkService(
document: Pick<
PDFDocumentProxy,
"getDestination" | "getPageIndex" | "numPages"
>,
currentPage: number,
callbacks: PdfAnnotationLinkCallbacks
): PdfAnnotationLinkService {
return {
externalLinkEnabled: true,
addLinkAttributes(link, url) {
const resolvedUrl =
decodeLocalDocumentLinkFromPdf(url) ?? url;
link.href = "#";
link.rel = "noopener noreferrer";
link.title ||= resolvedUrl;
link.dataset.pdfExternalHref = resolvedUrl;
link.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
callbacks.onExternalLink(resolvedUrl);
return false;
};
},
getDestinationHash() {
return "#pdf-destination";
},
getAnchorUrl() {
return "#pdf-action";
},
async goToDestination(destination) {
try {
const pageNumber = await resolvePdfDestinationPage(
document,
destination
);
if (pageNumber !== undefined) {
callbacks.onNavigateToPage(pageNumber);
}
} catch {
// 损坏或无法解析的 PDF 目标保持无行为。
}
},
executeNamedAction(action) {
const pageNumber = resolvePdfNamedActionPage(
action,
currentPage,
document.numPages
);
if (pageNumber !== undefined) {
callbacks.onNavigateToPage(pageNumber);
}
},
async executeSetOCGState() {
// 精确预览不改变 PDF 可选内容组状态。
},
async getAttachmentContent() {
// 精确预览不下载或执行 PDF 内嵌附件。
return undefined;
}
};
}
+3 -2
View File
@@ -66,7 +66,8 @@ function parseNumericHeader(value: string | null) {
}
export function parseContentDispositionFileName(
contentDisposition: string | null
contentDisposition: string | null,
fallbackFileName = "document.pdf"
) {
const encoded = contentDisposition?.match(
/filename\*=UTF-8''([^;]+)/i
@@ -82,7 +83,7 @@ export function parseContentDispositionFileName(
return (
contentDisposition
?.match(/filename="([^"]+)"/i)?.[1]
?.trim() || "document.pdf"
?.trim() || fallbackFileName
);
}
+19
View File
@@ -31,3 +31,22 @@ export function getPdfCanvasRenderScale(
}
return (displayWidthCssPixels / widthPoints) * devicePixelRatio;
}
export function getPrecisePdfDocumentWidth(
pageWidthsPoints: readonly number[],
displayScale: number,
inlineGutter: number
) {
const maximumPageWidth = Math.max(0, ...pageWidthsPoints);
if (
maximumPageWidth <= 0 ||
displayScale <= 0 ||
inlineGutter < 0
) {
return 0;
}
return Math.ceil(
maximumPageWidth * PDF_PAGE_CSS_SCALE * displayScale +
inlineGutter * 2
);
}
+466 -14
View File
@@ -188,6 +188,205 @@ h1 {
background: #edf3f0;
}
.more-menu-popover button:disabled:hover {
background: transparent;
}
.export-menu-popover {
width: 286px;
}
.more-menu-popover .export-menu-command {
min-height: 58px;
padding: 9px 11px;
}
.export-menu-command > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-menu-command strong {
color: inherit;
font-size: 0.82rem;
}
.export-menu-command small {
overflow: hidden;
color: #74827b;
font-size: 0.68rem;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-menu-command:disabled small {
color: #929b97;
}
.more-menu-popover .menu-command {
display: flex;
align-items: center;
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 {
display: grid;
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
@@ -485,24 +684,134 @@ h1 {
border: 0;
}
textarea {
.markdown-editor {
flex: 1;
width: 100%;
min-height: 0;
padding: 24px;
border: 0;
outline: 0;
resize: none;
background: #f6f7f5;
color: #26312d;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
line-height: 1.72;
tab-size: 2;
overflow: hidden;
}
textarea:focus {
box-shadow: inset 3px 0 #74a88f;
.markdown-editor > div,
.markdown-editor .cm-editor {
height: 100%;
}
.markdown-toolbar {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 6px 10px;
border-bottom: 1px solid #d9dedb;
background: #f8faf9;
}
.markdown-toolbar-group {
display: flex;
align-items: center;
gap: 3px;
padding-left: 6px;
border-left: 1px solid #d9dedb;
}
.markdown-toolbar-button {
min-width: 32px;
min-height: 30px;
padding: 0 7px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: #41584e;
font-size: 0.72rem;
white-space: nowrap;
}
.markdown-toolbar-button:hover:not(:disabled) {
border-color: #c3d1ca;
background: #e8efeb;
}
.markdown-toolbar-button.command-bold {
font-weight: 900;
}
.markdown-toolbar-button.command-italic {
font-style: italic;
}
.markdown-toolbar-button.command-strikethrough {
text-decoration: line-through;
}
.markdown-toolbar-heading select {
height: 30px;
padding: 0 24px 0 8px;
border: 1px solid #c9d4ce;
border-radius: 6px;
background: #fff;
color: #41584e;
font-size: 0.72rem;
}
.markdown-table-tool {
position: relative;
}
.markdown-table-popover {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: 0;
display: flex;
width: 250px;
padding: 12px;
border: 1px solid #bfcac4;
border-radius: 9px;
background: #fff;
box-shadow: 0 12px 30px rgb(40 58 49 / 18%);
flex-direction: column;
gap: 4px;
}
.markdown-table-popover strong {
color: #2f493e;
font-size: 0.8rem;
}
.markdown-table-popover > span {
color: #718078;
font-size: 0.68rem;
}
.markdown-table-grid {
display: grid;
margin-top: 6px;
grid-template-columns: repeat(10, 18px);
gap: 4px;
}
.markdown-table-cell {
width: 18px;
min-width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
border: 1px solid #cbd4cf;
border-radius: 3px;
background: #f4f7f5;
}
.markdown-table-cell.is-selected {
border-color: #65957d;
background: #a9cbb9;
}
.markdown-table-cell:focus-visible {
outline: 2px solid #356c55;
outline-offset: 1px;
}
.preview-panel {
@@ -583,7 +892,8 @@ textarea:focus {
}
.precise-pdf-page canvas,
.precise-pdf-page .textLayer {
.precise-pdf-page .textLayer,
.precise-pdf-page .annotationLayer {
position: absolute;
inset: 0;
}
@@ -617,6 +927,45 @@ textarea:focus {
--min-font-size-inv: calc(1 / var(--min-font-size));
}
.precise-pdf-page .annotationLayer {
z-index: 2;
overflow: hidden;
color-scheme: only light;
pointer-events: none;
transform-origin: 0 0;
}
.precise-pdf-page .annotationLayer section {
position: absolute;
box-sizing: border-box;
text-align: initial;
pointer-events: auto;
user-select: none;
transform-origin: 0 0;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a {
position: absolute;
inset: 0;
cursor: pointer;
outline: none;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:hover,
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:focus-visible {
background: rgb(31 111 83 / 14%);
outline: 1px solid rgb(31 111 83 / 45%);
}
.precise-pdf-page .textLayer :is(span, br) {
position: absolute;
color: transparent;
@@ -709,6 +1058,59 @@ textarea:focus {
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 {
position: absolute;
width: 100%;
@@ -887,6 +1289,19 @@ textarea:focus {
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 {
align-items: flex-start;
flex-direction: column;
@@ -1018,7 +1433,7 @@ textarea:focus {
min-height: 650px;
}
textarea {
.markdown-editor {
min-height: 560px;
}
@@ -1081,6 +1496,11 @@ textarea:focus {
.editor-layout {
transition: none;
}
.export-toast-progress span {
width: 100%;
animation: none;
}
}
@media (max-width: 520px) {
@@ -1093,4 +1513,36 @@ textarea:focus {
.settings-drawer {
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;
}
}
+34
View File
@@ -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);
}
+92
View File
@@ -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
);
}
+53 -2
View File
@@ -1,13 +1,25 @@
/// <reference types="vite/client" />
import type {
DocxCapability,
DocxExportDiagnostics,
DocxExportErrorCode,
DocxExportRequestInput,
DocxGenerationTimings,
DocxMediaCapturePlan,
PagedDocumentPayload,
PagedDocumentRenderResult,
RenderedMarkdownDocument
RenderedMarkdownDocument,
ThemeCategory,
DocumentProfileName
} from "@md-to-pdf/core";
declare global {
const __APP_VERSION__: string;
const __APP_DISPLAY_NAME__: string;
const __APP_WINDOW_TITLE__: string;
const __APP_SLOGAN__: string;
const __APP_DESCRIPTION__: string;
interface DesktopPdfResult {
pdf: Uint8Array;
@@ -16,6 +28,21 @@ declare global {
mermaidErrors: string[];
}
type DesktopDocxExportOutcome =
| {
ok: true;
saved: boolean;
fileName: string;
diagnostics: DocxExportDiagnostics;
timings: DocxGenerationTimings;
}
| {
ok: false;
error: DocxExportErrorCode;
message: string;
retryable: boolean;
};
interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
@@ -32,12 +59,23 @@ declare global {
| undefined
>;
discardPendingMarkdown(): Promise<void>;
onMarkdownOpened(listener: () => void): () => void;
onMarkdownOpened(
listener: (reason: "open" | "reload" | "replace") => void
): () => void;
startNewMarkdown(): Promise<void>;
createNewWindow(): Promise<void>;
resolveWindowClose(close: boolean): Promise<void>;
onWindowCloseRequested(listener: () => void): () => void;
setDocumentDirty(dirty: boolean): Promise<void>;
openDocumentLink(href: string): Promise<void>;
saveMarkdown(
fileName: string,
markdown: string
): Promise<{ fileName: string } | undefined>;
saveMarkdownAs(
fileName: string,
markdown: string
): Promise<{ fileName: string } | undefined>;
openThemeDirectory(): Promise<string>;
refreshThemes(): Promise<void>;
renderMarkdown(input: {
@@ -54,6 +92,8 @@ declare global {
name: string;
version: string;
description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
bundled: boolean;
source: "bundled" | "local";
}>;
@@ -61,6 +101,10 @@ declare global {
getThemeCss(themeId: string): Promise<string>;
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
getDocxCapability(): Promise<DocxCapability>;
exportDocx(
request: DocxExportRequestInput
): Promise<DesktopDocxExportOutcome>;
}
interface DesktopPdfRuntimeBridge {
@@ -81,5 +125,12 @@ declare global {
interface Window {
mdToPdfDesktop?: DesktopApplicationBridge;
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
__mdToPdfRenderDocxMedia?: (
payload: PagedDocumentPayload,
dimensions: {
contentWidthPx: number;
contentHeightPx: number;
}
) => Promise<DocxMediaCapturePlan>;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APP_SLOGAN,
APP_WINDOW_TITLE
} from "../src/app-brand";
const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("产品品牌", () => {
it("在主界面使用中文名和产品标语", () => {
expect(APP_DISPLAY_NAME).toBe("墨呈");
expect(APP_SLOGAN).toBe("一稿多式,即写即呈");
expect(APP_WINDOW_TITLE).toBe("墨呈 · MorphDoc");
expect(APP_DESCRIPTION).toBe(
"面向结构化 Markdown 的主题化文档创作与发布工具"
);
});
it("在静态入口中移除旧工具名称", () => {
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
const mainSource = readFileSync(`${webRoot}/src/main.tsx`, "utf8");
expect(indexHtml).toContain("<title>墨呈 · MorphDoc</title>");
expect(mainSource).toContain(
"document.title = `${APP_WINDOW_TITLE} ${APP_VERSION_LABEL}`"
);
expect(indexHtml).not.toContain("Markdown PDF 导出器");
expect(indexHtml).not.toContain("内网文档工具");
});
});
+13 -2
View File
@@ -1,12 +1,23 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
APP_VERSION,
APP_VERSION_LABEL
} from "../src/app-version";
const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.4.5");
expect(APP_VERSION_LABEL).toBe("v0.4.5");
expect(APP_VERSION).toBe("0.6.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();
});
});
+39
View File
@@ -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"
);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolveWebDocumentLinkAction } from "../src/document-link";
describe("Web 文档链接行为", () => {
it("保留文档锚点", () => {
expect(resolveWebDocumentLinkAction("#section")).toEqual({
type: "anchor",
href: "#section"
});
});
it("在外部窗口打开 HTTP 链接和浏览器协议", () => {
expect(
resolveWebDocumentLinkAction("//example.com/path")
).toEqual({
type: "open",
href: "https://example.com/path"
});
expect(
resolveWebDocumentLinkAction("mailto:a@example.com")
).toEqual({
type: "open",
href: "mailto:a@example.com"
});
expect(resolveWebDocumentLinkAction("tel:+8612345")).toEqual({
type: "open",
href: "tel:+8612345"
});
});
it("忽略本地、未知和危险地址", () => {
for (const href of [
"../docs/a.md",
"C:/docs/a.md",
"file:///C:/docs/a.md",
"obsidian://open?vault=x",
"javascript:alert(1)"
]) {
expect(resolveWebDocumentLinkAction(href)).toEqual({
type: "ignore"
});
}
});
});
+77
View File
@@ -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();
});
});
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DOCX_MIME_TYPE,
defaultExportConfig,
type DocxCapability
} from "@md-to-pdf/core";
import {
createDocxDiagnosticsMessage,
getDocxCapability,
requestDesktopDocxExport,
requestDocxExport
} from "../src/docx-export";
afterEach(() => {
vi.unstubAllGlobals();
});
const availableCapability: DocxCapability = {
format: "docx",
status: "available",
expectedVersion: "3.9.0.2",
detectedVersion: "3.9.0.2"
};
const request = {
markdown: "# 测试",
fileName: "测试.md",
language: "zh-CN",
resources: [],
exportConfig: defaultExportConfig
};
describe("DOCX 导出客户端", () => {
it("在 Web 环境读取服务端 capability", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () =>
Response.json(availableCapability)
);
await expect(getDocxCapability(fetcher)).resolves.toEqual(
availableCapability
);
expect(fetcher).toHaveBeenCalledWith("/api/docx/capability");
});
it("在 Desktop 环境通过最小 IPC 读取 capability", async () => {
const getDesktopCapability = vi.fn(
async () => availableCapability
);
vi.stubGlobal("window", {
mdToPdfDesktop: {
getDocxCapability: getDesktopCapability
}
});
const fetcher = vi.fn();
await expect(getDocxCapability(fetcher)).resolves.toEqual(
availableCapability
);
expect(getDesktopCapability).toHaveBeenCalledOnce();
expect(fetcher).not.toHaveBeenCalled();
});
it("请求 DOCX 并读取文件名与诊断头", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () => {
return new Response(new Blob(["PK-test"]), {
status: 200,
headers: {
"content-type": DOCX_MIME_TYPE,
"content-disposition":
"attachment; filename=\"document.docx\"; " +
"filename*=UTF-8''%E6%B5%8B%E8%AF%95.docx",
"x-docx-warning-count": "2",
"x-echarts-error-count": "1",
"x-mermaid-error-count": "0"
}
});
});
const result = await requestDocxExport(request, fetcher);
expect(fetcher).toHaveBeenCalledWith(
"/api/docx",
expect.objectContaining({ method: "POST" })
);
expect(result.fileName).toBe("测试.docx");
expect(result.warningCount).toBe(2);
expect(result.echartsErrorCount).toBe(1);
expect(result.mermaidErrorCount).toBe(0);
expect(await result.blob.text()).toBe("PK-test");
});
it("拒绝服务端返回的非 DOCX 文件", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () =>
new Response("error page", {
status: 200,
headers: { "content-type": "text/html" }
})
);
await expect(
requestDocxExport(request, fetcher)
).rejects.toThrow("DOCX 服务返回了无效文件类型");
});
it("显示服务端结构化错误", async () => {
vi.stubGlobal("window", {});
const fetcher = vi.fn(async () =>
Response.json(
{ message: "Pandoc 版本不匹配" },
{ status: 503 }
)
);
await expect(
requestDocxExport(request, fetcher)
).rejects.toThrow("Pandoc 版本不匹配");
});
it("Desktop 保存取消保持为正常结果", async () => {
const exportDocx = vi.fn(async () => ({
ok: true as const,
saved: false,
fileName: "测试.docx",
diagnostics: {
warnings: [],
echartsErrors: [],
mermaidErrors: []
},
timings: {
queueMs: 0,
probeMs: 0,
prepareMs: 0,
themeStyleMs: 0,
mediaMs: 0,
referenceMs: 0,
pandocMs: 0,
validationMs: 0,
totalMs: 0
}
}));
vi.stubGlobal("window", {
mdToPdfDesktop: { exportDocx }
});
await expect(
requestDesktopDocxExport(request)
).resolves.toEqual({
saved: false,
fileName: "测试.docx",
warningCount: 0,
echartsErrorCount: 0,
mermaidErrorCount: 0
});
});
it("Desktop 失败结果转换为可展示错误", async () => {
vi.stubGlobal("window", {
mdToPdfDesktop: {
exportDocx: vi.fn(async () => ({
ok: false as const,
error: "DOCX_QUEUE_FULL" as const,
message: "DOCX 生成队列已满",
retryable: true
}))
}
});
await expect(
requestDesktopDocxExport(request)
).rejects.toThrow("DOCX 生成队列已满");
});
it("生成简洁的 DOCX 诊断摘要", () => {
expect(
createDocxDiagnosticsMessage({
warningCount: 0,
echartsErrorCount: 0,
mermaidErrorCount: 0
})
).toBe("纸张、样式和可编辑结构已写入文档");
expect(
createDocxDiagnosticsMessage({
warningCount: 2,
echartsErrorCount: 1,
mermaidErrorCount: 3
})
).toBe(
"2 条提示,1 个 ECharts 图表失败,3 个 Mermaid 图表失败"
);
});
});

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