Compare commits
44
Commits
v0.3.1
...
842c3f1782
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842c3f1782 | ||
|
|
3e89e3faff | ||
|
|
8f4d16a9b7 | ||
|
|
5c82b3df7f | ||
|
|
14f470454c | ||
|
|
3a8d2c1c2e | ||
|
|
9a2aa3c341 | ||
|
|
58f87cc19f | ||
|
|
f9f5fccfc9 | ||
|
|
3081c02887 | ||
|
|
7f2c169405 | ||
|
|
dc026835b7 | ||
|
|
6f62cfebd6 | ||
|
|
ae9fde3ac6 | ||
|
|
9f96d51922 | ||
|
|
c76454be9d | ||
|
|
e5da699ea3 | ||
|
|
10bc62cee4 | ||
|
|
f3847f3e4b | ||
|
|
6e40374200 | ||
|
|
459a079f3b | ||
|
|
7f72258126 | ||
|
|
d2acc9c5ce | ||
|
|
6086762b4f | ||
|
|
6fed666f44 | ||
|
|
e7c688df6e | ||
|
|
aff2830c28 | ||
|
|
f2dfc6ef72 | ||
|
|
bf43433d38 | ||
|
|
fa159d916f | ||
|
|
fe5d67fb6d | ||
|
|
7831bc23f0 | ||
|
|
f7d4efeafc | ||
|
|
fb3ddada39 | ||
|
|
81d9099eae | ||
|
|
83e6559c2c | ||
|
|
58087d0c7e | ||
|
|
925b0d1485 | ||
|
|
ac54ce46be | ||
|
|
06407650e0 | ||
|
|
8d91d08809 | ||
|
|
32a37293d0 | ||
|
|
06b30d084e | ||
|
|
4d60741f5a |
@@ -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
|
||||
|
||||
@@ -3,14 +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
|
||||
|
||||
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
||||
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
|
||||
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
|
||||
- 支持主题清单与 CSS 主题扩展;
|
||||
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
||||
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX、Mermaid 和 ECharts;
|
||||
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
|
||||
- 使用 Docker Compose 在内网部署;
|
||||
- 文档只在当前请求中临时处理,不建立文档历史数据库。
|
||||
|
||||
@@ -75,15 +76,19 @@ flowchart LR
|
||||
|
||||
- 前端:React、TypeScript、Vite。
|
||||
- 后端:Node.js、TypeScript、Fastify。
|
||||
- 桌面端:Electron、electron-builder、NSIS。
|
||||
- 应用服务:`packages/application`。
|
||||
- 共享模型:`packages/core`。
|
||||
- Markdown 渲染:`packages/renderer`。
|
||||
- PDF 引擎:计划使用 Playwright Chromium。
|
||||
- 连续预览与分页引擎:`packages/preview-engine`。
|
||||
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
||||
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
||||
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
||||
- 元数据:gray-matter。
|
||||
- 安全过滤:sanitize-html。
|
||||
- 代码高亮:highlight.js。
|
||||
- 数学公式:KaTeX。
|
||||
- 图表:Mermaid。
|
||||
- 图表:Mermaid、ECharts。
|
||||
- 校验:Zod。
|
||||
- 测试:Vitest。
|
||||
- 部署:Docker、Docker Compose。
|
||||
@@ -92,12 +97,15 @@ flowchart LR
|
||||
|
||||
```text
|
||||
apps/web/ 前端编辑、配置与预览界面
|
||||
apps/server/ HTTP API、主题读取及未来 PDF 服务
|
||||
apps/server/ HTTP API、主题读取和 Playwright PDF 服务
|
||||
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
|
||||
packages/application/ 共享应用服务、主题注册和图片资源处理
|
||||
packages/core/ 导出配置和主题清单等共享模型
|
||||
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
||||
themes/ 内置主题及未来可安装主题
|
||||
docker/ 容器化配置
|
||||
tests/ 跨包和端到端测试
|
||||
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
||||
packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||
themes/ 内置主题
|
||||
deploy/ Docker、Compose、Nginx 和部署文档
|
||||
docs/ 进度、架构和部署文档
|
||||
```
|
||||
|
||||
@@ -186,6 +194,11 @@ chore: 初始化项目骨架
|
||||
```
|
||||
|
||||
- 不修改或重写已有提交,除非用户明确要求。
|
||||
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
|
||||
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
|
||||
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
|
||||
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
|
||||
记录本版本更新,不得使用只有版本号的标签说明。
|
||||
|
||||
## 9. 当前交接入口
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
YIXIONG Tech.ltd 公司内部专属软件许可证
|
||||
版本 1.0,2026 年
|
||||
|
||||
版权所有 © 2026 YIXIONG Tech.ltd。保留所有权利。
|
||||
|
||||
一、适用范围
|
||||
|
||||
本许可证适用于“墨呈(MorphDoc)”项目中由 YIXIONG Tech.ltd 拥有
|
||||
著作权的源代码、原创主题、原创文档、构建脚本和其他原创材料(以下简称
|
||||
“本软件”)。
|
||||
|
||||
本软件包含或调用的第三方程序、依赖、字体、主题和其他材料不受本许可证
|
||||
重新许可,继续适用其各自的许可证、授权条件和使用边界。
|
||||
|
||||
二、授权对象
|
||||
|
||||
“内部授权用户”是指经 YIXIONG Tech.ltd 明确授权,并且属于以下范围的
|
||||
个人或组织:
|
||||
|
||||
1. YIXIONG Tech.ltd 的员工;
|
||||
2. YIXIONG Tech.ltd 直接或间接控制的组织;
|
||||
3. 因内部项目需要而获得明确书面授权的承包商、供应商或合作人员。
|
||||
|
||||
授权在相关人员不再属于上述范围、授权被撤销或合作关系终止时结束,但
|
||||
依法或书面约定需要继续履行的保密、知识产权和责任条款不因此失效。
|
||||
|
||||
三、允许的行为
|
||||
|
||||
在遵守本许可证、公司制度和适用法律的前提下,内部授权用户可以:
|
||||
|
||||
1. 为公司内部业务、测试、研发、评估和运维目的运行本软件;
|
||||
2. 阅读、复制和修改本软件源代码;
|
||||
3. 在公司控制或明确授权的设备、服务器、容器和内部网络中部署本软件;
|
||||
4. 在内部授权用户之间分发源代码、Docker 镜像、安装包、压缩包和测试
|
||||
产物;
|
||||
5. 为内部使用目的制作和分发修改版本;
|
||||
6. 使用本软件处理合法取得的 Markdown、图片、主题和其他文档资源。
|
||||
|
||||
四、禁止的行为
|
||||
|
||||
未经 YIXIONG Tech.ltd 事先明确书面许可,任何人不得:
|
||||
|
||||
1. 向公司外部或公开网络发布、上传、分发、提供下载或公开展示本软件的
|
||||
源代码、二进制、镜像、安装包或实质性部分;
|
||||
2. 出售、出租、转授权、托管经营或以本软件向公司外部提供商业服务;
|
||||
3. 将本软件或修改版本置于开源许可证、公共领域声明或其他与本许可证
|
||||
冲突的授权条件下;
|
||||
4. 删除或隐瞒版权、许可证、来源、第三方声明或其他权利标识;
|
||||
5. 超出第三方材料自身许可证或授权边界使用、复制或分发第三方材料;
|
||||
6. 使用本软件侵犯他人知识产权、隐私、商业秘密或其他合法权益;
|
||||
7. 规避访问控制、安全限制、资源隔离或审计措施。
|
||||
|
||||
五、修改与贡献
|
||||
|
||||
内部授权用户为公司项目提交的修改、补丁、文档和其他贡献,按照其劳动、
|
||||
合作、保密、知识产权归属协议及公司制度处理。本许可证本身不改变已经
|
||||
存在的职务成果、委托开发或合作开发权利归属。
|
||||
|
||||
未经 YIXIONG Tech.ltd 明确书面决定,内部修改或贡献不会使本软件自动
|
||||
转为开源软件,也不会产生向公司外部公开源代码的义务。
|
||||
|
||||
六、用户文档与导出结果
|
||||
|
||||
用户输入的 Markdown、图片、数据和其他内容,其权利归原权利人所有。
|
||||
使用本软件生成的 PDF、DOCX、图片或其他导出结果,不会仅因使用本软件而
|
||||
自动受本许可证约束。
|
||||
|
||||
导出结果中嵌入的字体、图片、模板、主题或其他第三方材料,仍可能受其
|
||||
各自许可证或授权条件约束,使用者应自行确保拥有相应权利。
|
||||
|
||||
七、第三方材料
|
||||
|
||||
本软件的第三方许可证与特殊分发边界记录在 `THIRD_PARTY_NOTICES.md`
|
||||
以及相关资源目录的许可证文件中。若本许可证与第三方材料的有效许可证
|
||||
发生冲突,对该第三方材料以其自身许可证为准。
|
||||
|
||||
八、未来公开许可
|
||||
|
||||
YIXIONG Tech.ltd 可以在未来自行决定以 Apache License 2.0、其他开源
|
||||
许可证或其他授权方式发布本软件的某个版本。任何未来公开许可仅自明确
|
||||
发布之日起适用于指定版本,不追溯授予当前内部版本,也不自动覆盖第三方
|
||||
材料。
|
||||
|
||||
在公开许可正式发布前,本软件不是面向公众的开源软件。源代码可在公司
|
||||
内部查看和修改,不等于授予公众使用、复制、修改或分发权。
|
||||
|
||||
九、免责声明
|
||||
|
||||
在适用法律允许的最大范围内,本软件按“现状”提供,不附带任何明示或
|
||||
默示保证,包括但不限于适销性、特定用途适用性、无侵权、持续可用或数据
|
||||
完整性保证。
|
||||
|
||||
十、责任限制
|
||||
|
||||
在适用法律允许的最大范围内,YIXIONG Tech.ltd 及贡献者不对因使用、
|
||||
无法使用、修改、部署或分发本软件导致的任何间接、附带、特殊、惩罚性或
|
||||
后果性损失承担责任。公司内部另有有效合同、制度或书面约定的,从其约定。
|
||||
|
||||
十一、终止
|
||||
|
||||
违反本许可证的授权自动终止。YIXIONG Tech.ltd 有权要求停止使用、删除
|
||||
未经授权的副本,并依据适用法律、合同、保密协议和公司制度追究责任。
|
||||
|
||||
十二、其他
|
||||
|
||||
本许可证未明确授予的权利均由 YIXIONG Tech.ltd 保留。对本许可证的任何
|
||||
例外、补充或变更必须由 YIXIONG Tech.ltd 以明确书面形式作出。
|
||||
@@ -0,0 +1,10 @@
|
||||
墨呈(MorphDoc)
|
||||
Copyright 2026 YIXIONG Tech.ltd
|
||||
|
||||
This product includes software developed by YIXIONG Tech.ltd and
|
||||
contributors.
|
||||
|
||||
The original project code and documentation are currently licensed only for
|
||||
authorized internal use under the proprietary LICENSE in this repository.
|
||||
Third-party components and assets remain under their respective licenses.
|
||||
See THIRD_PARTY_NOTICES.md.
|
||||
@@ -1,25 +1,115 @@
|
||||
# Markdown PDF 导出器
|
||||
# 墨呈
|
||||
|
||||
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。
|
||||
> 一稿多式,即写即呈
|
||||
|
||||
## 当前状态
|
||||
面向结构化 Markdown 的主题化文档创作与发布工具,同时提供 Web 与
|
||||
Windows 桌面端。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题
|
||||
CSS 和分页运行时,DOCX 使用同源语义文档、主题令牌和可编辑 OOXML。
|
||||
|
||||
已经实现:
|
||||
当前版本:`v0.6.0`。
|
||||
|
||||
- React Markdown 编辑与本地文件读取;
|
||||
- Fastify 预览接口;
|
||||
- 共享导出配置模型;
|
||||
- 动态主题清单、CSS 和静态资源接口;
|
||||
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
||||
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
||||
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
|
||||
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
|
||||
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
|
||||
- 快速预览与精确预览共用的 50%~400% 显示缩放;
|
||||
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
|
||||
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
|
||||
## 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 与桌面端
|
||||
|
||||
| 能力 | Web / Docker | Windows Desktop |
|
||||
| --- | --- | --- |
|
||||
| 新建 Markdown | 支持 | 支持 |
|
||||
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
|
||||
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
||||
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
||||
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
|
||||
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
|
||||
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
|
||||
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||
| DOCX 引擎 | 固定版本 Pandoc | 内置固定版本 Pandoc |
|
||||
|
||||
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
||||
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
||||
|
||||
## 渲染架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
||||
B --> C["统一 #write HTML"]
|
||||
D["主题 CSS 与导出配置"] --> C
|
||||
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 复用相同分页
|
||||
载荷和运行时。
|
||||
|
||||
## 项目目录
|
||||
|
||||
```text
|
||||
apps/
|
||||
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
|
||||
server/ Fastify API、Playwright PDF 与容器运行入口
|
||||
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
|
||||
packages/
|
||||
core/ 跨端文档、导出配置和主题协议
|
||||
renderer/ Markdown 到安全 #write HTML 的渲染管线
|
||||
application/ 共享渲染、主题注册和图片资源应用服务
|
||||
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
|
||||
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||
themes/ 内置主题与主题清单
|
||||
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
||||
docs/ 项目进度与主题开发说明
|
||||
scripts/ 主题导入等维护脚本
|
||||
logos/ 应用图标源文件
|
||||
.local/themes/ 本地开发和 Compose 自定义主题目录
|
||||
```
|
||||
|
||||
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
|
||||
提供独立图表能力;`renderer` 消费两者生成安全 HTML;`application`
|
||||
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
|
||||
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
|
||||
|
||||
各包的目录和用法:
|
||||
|
||||
- [application](packages/application/README.md)
|
||||
- [core](packages/core/README.md)
|
||||
- [renderer](packages/renderer/README.md)
|
||||
- [markdown-echarts](packages/markdown-echarts/README.md)
|
||||
- [preview-engine](packages/preview-engine/README.md)
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -32,110 +122,70 @@ npm run dev
|
||||
|
||||
默认地址:
|
||||
|
||||
- 前端:http://localhost:5173
|
||||
- 后端:http://localhost:3001
|
||||
- Web:http://localhost:5173
|
||||
- API:http://localhost:3001
|
||||
- 健康检查:http://localhost:3001/api/health
|
||||
|
||||
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
|
||||
桌面端开发:
|
||||
|
||||
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
|
||||
|
||||
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
|
||||
|
||||
## Mermaid 配置与样式
|
||||
|
||||
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
|
||||
|
||||
- 布局:Dagre、ELK;
|
||||
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
|
||||
- 外观:Classic、Hand-drawn、Neo;
|
||||
- 字体:任意本地 CSS 字体族。
|
||||
|
||||
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
layout: elk
|
||||
theme: base
|
||||
look: handDrawn
|
||||
fontFamily: Microsoft YaHei
|
||||
themeVariables:
|
||||
primaryColor: "#dbeafe"
|
||||
primaryBorderColor: "#2563eb"
|
||||
flowchart:
|
||||
wrappingWidth: 360
|
||||
---
|
||||
flowchart TB
|
||||
classDef important fill:#fee2e2,stroke:#dc2626
|
||||
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
|
||||
style A fill:#dcfce7,stroke:#16a34a
|
||||
class B important
|
||||
```powershell
|
||||
npm run desktop:dev
|
||||
```
|
||||
````
|
||||
|
||||
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
|
||||
桌面端目录包和 Windows 安装包:
|
||||
|
||||
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
|
||||
|
||||
### Mermaid 长标签
|
||||
|
||||
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
|
||||
|
||||
需要自动换行时可以使用 Mermaid Markdown String:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
|
||||
```powershell
|
||||
npm run desktop:package
|
||||
npm run make -w @md-to-pdf/desktop
|
||||
```
|
||||
````
|
||||
|
||||
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`:
|
||||
桌面 `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;当前发行包
|
||||
未配置代码签名。
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["较长节点"] --> B["普通节点"]
|
||||
classDef compact font-size:12px;
|
||||
class A compact;
|
||||
```
|
||||
````
|
||||
## 文档与媒体
|
||||
|
||||
## 本地导入 Typora 默认主题
|
||||
Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、
|
||||
KaTeX、Mermaid 和安全的 ECharts YAML 围栏。
|
||||
|
||||
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录:
|
||||
独立一行的 `` 会生成带标题的图片块。分页引擎将图片、
|
||||
Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行
|
||||
计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时
|
||||
只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免
|
||||
跨页、重复重排和后续媒体顺序变化。
|
||||
|
||||
ECharts 围栏语法、支持系列和安全边界详见
|
||||
[markdown-echarts 包文档](packages/markdown-echarts/README.md)。
|
||||
|
||||
## 主题
|
||||
|
||||
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||
3 套标书主题,默认使用 GitHub。主题由版本化
|
||||
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||
|
||||
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
||||
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
|
||||
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
|
||||
|
||||
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
|
||||
刷新本地开发副本:
|
||||
|
||||
```powershell
|
||||
npm run theme:import-typora
|
||||
```
|
||||
|
||||
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。
|
||||
|
||||
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
|
||||
|
||||
```powershell
|
||||
npm run theme:import-typora -- --replace
|
||||
```
|
||||
|
||||
旧副本会移动到 `.local\theme-backups`。
|
||||
|
||||
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
|
||||
|
||||
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
|
||||
|
||||
## Docker Desktop 部署
|
||||
## Docker Compose 部署
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy\compose.yaml up --build -d
|
||||
```
|
||||
|
||||
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
|
||||
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
|
||||
[AIO 容器部署说明](deploy/README.md)。
|
||||
默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
|
||||
配置详见 [AIO 容器部署说明](deploy/README.md)。
|
||||
|
||||
## 测试与构建
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
@@ -144,7 +194,18 @@ npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 隐私原则
|
||||
涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、
|
||||
Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端
|
||||
验证,并检查 PDF 页面渲染结果。
|
||||
|
||||
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
||||
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
|
||||
## 安全与隐私
|
||||
|
||||
- 不建立 Markdown、图片或 PDF 历史数据库;
|
||||
- 文档和资源只在当前请求或当前桌面会话中处理;
|
||||
- Markdown 原始 HTML 不执行,输出经过白名单过滤;
|
||||
- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML;
|
||||
- 本地资源阻止路径穿越和符号链接越界;
|
||||
- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时;
|
||||
- PDF 页面阻止未授权外部网络访问。
|
||||
|
||||
当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 第三方许可证与公开发布说明
|
||||
|
||||
根目录 `LICENSE` 是当前公司内部专属许可证,适用于本项目原创代码、原创
|
||||
主题和原创文档,不会替换第三方组件、字体、主题或工具各自的许可证。
|
||||
|
||||
Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前仓库没有
|
||||
通过 Apache License 2.0 向公司外部授予项目原创内容的使用或分发权。
|
||||
|
||||
本文件是发行审查入口,不替代各第三方项目附带的完整许可证。正式发行前
|
||||
还应根据最终生产依赖生成完整的软件物料清单和许可证清单。
|
||||
|
||||
## Pandoc
|
||||
|
||||
`v0.6.0` 将未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。
|
||||
|
||||
- 项目:Pandoc
|
||||
- 版权:John MacFarlane 及贡献者
|
||||
- 许可证:GNU General Public License,version 2 or later
|
||||
- 交互方式:独立子进程、命令行参数和临时文档文件
|
||||
- 项目地址:<https://pandoc.org/>
|
||||
- 源码地址:<https://github.com/jgm/pandoc>
|
||||
|
||||
项目不链接、导入或修改 Pandoc 程序代码。发行包必须保留 Pandoc 的
|
||||
`COPYING`、`COPYRIGHT` 和版本信息,并在相同版本的发行位置提供对应源码
|
||||
获取方式。具体版本、校验值和分发规则见
|
||||
[`docs/PANDOC_DISTRIBUTION.md`](docs/PANDOC_DISTRIBUTION.md)。
|
||||
|
||||
## Fandol 字体
|
||||
|
||||
`themes/_shared/official-fonts/` 包含 Fandol 中文字体的 WOFF2 转换版本。
|
||||
Fandol 字体采用 GNU GPL,并附带字体例外条款。完整许可证、上游说明、
|
||||
文件哈希和来源信息保存在该目录:
|
||||
|
||||
- [`LICENSE.txt`](themes/_shared/official-fonts/LICENSE.txt)
|
||||
- [`README.md`](themes/_shared/official-fonts/README.md)
|
||||
- [`UPSTREAM-README.txt`](themes/_shared/official-fonts/UPSTREAM-README.txt)
|
||||
|
||||
使用字体生成的 PDF、图片或 DOCX 不会仅因为使用该字体而自动受 GPL
|
||||
约束。重新分发字体时必须保留上述许可证和来源文件。
|
||||
|
||||
## npm、Electron 与 Chromium 组件
|
||||
|
||||
项目直接或间接使用 MIT、Apache-2.0、BSD、ISC、EPL-2.0、MPL-2.0、
|
||||
CC-BY-4.0 等许可证的软件包。`package-lock.json` 固定具体依赖版本;
|
||||
Electron 和 Chromium 发行目录还包含其自身第三方许可证文件。
|
||||
|
||||
构建产物不得删除 Electron、Chromium 或其他依赖已经附带的许可证文件。
|
||||
正式发布阶段应基于生产依赖和浏览器 Bundle 生成最终第三方清单,而不能
|
||||
仅以根目录项目许可证代替。
|
||||
|
||||
## 仅限公司内部使用的 Typora 资源
|
||||
|
||||
以下目录来自公司内部安装的 Typora 1.14.7,目前没有确认可用于公开再
|
||||
分发的完整授权:
|
||||
|
||||
```text
|
||||
themes/typora-github/
|
||||
themes/typora-pixyll/
|
||||
themes/typora-whitey/
|
||||
```
|
||||
|
||||
它们不受本项目内部专属许可证重新授权。即使其中部分样式可追溯到 MIT
|
||||
上游,也不能据此推定 Typora 修改版本整体可以公开分发。
|
||||
|
||||
当前项目属于公司内部源码开放和内部部署阶段。根据现有内部使用决策,
|
||||
公司内部的源码仓库、Web/Docker 部署、Desktop 安装包、ZIP 和测试产物
|
||||
可以继续携带这三套主题,但不得向公司外部或公开网络发布、销售或授权。
|
||||
接收内部发行物的人员也不能依据根目录项目许可证对这三套主题进行公司外
|
||||
再分发。
|
||||
|
||||
在将项目发布到公开 GitHub 仓库前,必须完成以下方案之一:
|
||||
|
||||
1. 取得明确的公开分发授权;
|
||||
2. 使用许可证明确的上游资源重新实现并完成来源审查;
|
||||
3. 删除这些目录,并从准备公开的 Git 历史中清除其内容。
|
||||
|
||||
仅在后续提交中删除目录仍会使旧版本存在于 Git 历史。若不能取得授权,
|
||||
公开发布应使用经过清理的历史或从合规快照创建新的公开仓库。上述门禁
|
||||
只针对公司外部和公开网络发行,不阻止当前公司内部源码共享、部署和
|
||||
安装包分发。
|
||||
@@ -0,0 +1,107 @@
|
||||
# Desktop 桌面端
|
||||
|
||||
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
|
||||
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
|
||||
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
|
||||
安装包。
|
||||
|
||||
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
|
||||
一个窗口,其他文件可以同时使用多个窗口。
|
||||
|
||||
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
||||
重新加载,避免阅读或导出磁盘旧版本。
|
||||
|
||||
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
|
||||
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
|
||||
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
|
||||
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
|
||||
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
|
||||
|
||||
## 开发
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
桌面端不启动 Fastify,也不占用本地 HTTP 端口。React 界面通过受限 IPC
|
||||
调用 `packages/application` 中的 Markdown 渲染和主题服务,PDF 由
|
||||
Electron 自带 Chromium 生成。Web 与 Docker 继续通过 Fastify HTTP
|
||||
适配器使用同一套应用服务。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
```powershell
|
||||
npm run build -w @md-to-pdf/desktop
|
||||
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.6.0/`。该目录被 Git 忽略。公司内部分发以
|
||||
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
|
||||
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
|
||||
发布,不提交进 Git 历史。
|
||||
|
||||
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
|
||||
“运行”操作直接启动 `MorphDoc.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;
|
||||
Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。
|
||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||
`mdpdf://bundle/` 自定义协议加载。
|
||||
|
||||
## 链接与窗口行为
|
||||
|
||||
- 当前文档 `#anchor` 在当前预览中定位;
|
||||
- HTTP/HTTPS 链接交给系统默认浏览器;
|
||||
- `mailto:` 等系统协议交给操作系统处理;
|
||||
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
|
||||
- Markdown 文件会提示在当前窗口或新窗口打开;
|
||||
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
|
||||
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
|
||||
|
||||
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||
- 启用 Context Isolation 和 Chromium Sandbox;
|
||||
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
|
||||
的窄接口;
|
||||
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
|
||||
链接越界;
|
||||
- 主进程校验 IPC 发送者和分页载荷;
|
||||
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
|
||||
- 应用页面使用自定义协议,不使用 `file://`。
|
||||
@@ -0,0 +1,169 @@
|
||||
!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" \
|
||||
"" \
|
||||
"Markdown 文档"
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
|
||||
"" \
|
||||
'"$INSTDIR\${MORPHDOC_EXECUTABLE}",0'
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
|
||||
"" \
|
||||
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\.md\OpenWithProgids" \
|
||||
"MarkdownPdfExporter.Markdown" \
|
||||
""
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\.markdown\OpenWithProgids" \
|
||||
"MarkdownPdfExporter.Markdown" \
|
||||
""
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\shell\open\command" \
|
||||
"" \
|
||||
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
|
||||
".md" \
|
||||
""
|
||||
WriteRegStr SHCTX \
|
||||
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
|
||||
".markdown" \
|
||||
""
|
||||
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\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk" \
|
||||
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
|
||||
"" \
|
||||
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||
CreateShortCut \
|
||||
"$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk" \
|
||||
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
|
||||
"" \
|
||||
"$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"
|
||||
DeleteRegValue SHCTX \
|
||||
"Software\Classes\.markdown\OpenWithProgids" \
|
||||
"MarkdownPdfExporter.Markdown"
|
||||
DeleteRegKey SHCTX \
|
||||
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}"
|
||||
DeleteRegKey SHCTX \
|
||||
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
|
||||
DeleteRegKey SHCTX \
|
||||
"Software\Classes\MarkdownPdfExporter.Markdown"
|
||||
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
|
||||
@@ -0,0 +1,93 @@
|
||||
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: brand.appId,
|
||||
productName: brand.englishName,
|
||||
copyright: "Copyright © YIXIONG Tech.ltd",
|
||||
asar: true,
|
||||
electronLanguages: ["zh-CN", "en-US"],
|
||||
directories: {
|
||||
output: `out/v${version}`
|
||||
},
|
||||
files: [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "../web/dist",
|
||||
to: "dist",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: "../../themes",
|
||||
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"
|
||||
},
|
||||
{
|
||||
from: "../../logos/desktop/macos/app.icns",
|
||||
to: "app.icns"
|
||||
}
|
||||
],
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: "nsis",
|
||||
arch: ["x64"]
|
||||
},
|
||||
{
|
||||
target: "zip",
|
||||
arch: ["x64"]
|
||||
}
|
||||
],
|
||||
icon: windowsIcon,
|
||||
executableName: brand.executableName,
|
||||
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
perMachine: false,
|
||||
allowElevation: true,
|
||||
createDesktopShortcut: false,
|
||||
createStartMenuShortcut: false,
|
||||
shortcutName: brand.displayName,
|
||||
uninstallDisplayName: brand.displayName,
|
||||
installerIcon: windowsIcon,
|
||||
uninstallerIcon: windowsIcon,
|
||||
installerLanguages: ["zh_CN"],
|
||||
include: nsisInclude,
|
||||
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"productName": "MorphDoc",
|
||||
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||
"author": "YIXIONG Tech.ltd",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
|
||||
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
|
||||
"build:main": "node scripts/build-main.mjs",
|
||||
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||
"package": "npm run build:embedded-web && npm run 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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
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";',
|
||||
'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();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
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";
|
||||
|
||||
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
|
||||
consumePendingMarkdown: () =>
|
||||
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
|
||||
discardPendingMarkdown: () =>
|
||||
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
|
||||
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),
|
||||
renderMarkdown: (input: MarkdownRenderRequest) =>
|
||||
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
|
||||
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
|
||||
getThemeCss: (themeId: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_GET_THEME_CSS, themeId),
|
||||
generatePdf: (payload: PagedDocumentPayload) =>
|
||||
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
||||
savePdf: (fileName: string, pdf: Uint8Array) =>
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf),
|
||||
getDocxCapability: () =>
|
||||
ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY),
|
||||
exportDocx: (request: DocxExportRequestInput) =>
|
||||
ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request)
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function parseMarkdownRenderRequest(
|
||||
value: unknown
|
||||
): MarkdownRenderRequest {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
markdown: value.markdown,
|
||||
language: value.language,
|
||||
resources: value.resources
|
||||
};
|
||||
}
|
||||
|
||||
export function parseThemeId(value: unknown) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(value)
|
||||
) {
|
||||
throw new Error("主题 ID 无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseThemeResourceUrl(requestUrl: string) {
|
||||
const url = new URL(requestUrl);
|
||||
if (url.protocol !== "mdpdf:" || url.host !== "theme") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let segments: string[];
|
||||
try {
|
||||
segments = url.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
} catch {
|
||||
throw new Error("主题资源地址无效");
|
||||
}
|
||||
const [unsafeThemeId, ...assetSegments] = segments;
|
||||
if (!unsafeThemeId || assetSegments.length === 0) {
|
||||
throw new Error("主题资源地址无效");
|
||||
}
|
||||
|
||||
return {
|
||||
themeId: parseThemeId(unsafeThemeId),
|
||||
assetPath: assetSegments.join("/")
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFontPackResourceUrl(requestUrl: string) {
|
||||
const url = new URL(requestUrl);
|
||||
if (url.protocol !== "mdpdf:" || url.host !== "font-pack") {
|
||||
return undefined;
|
||||
}
|
||||
let segments: string[];
|
||||
try {
|
||||
segments = url.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
} catch {
|
||||
throw new Error("字体包资源地址无效");
|
||||
}
|
||||
const [packId, packVersion, faceId, kind, ...extra] = segments;
|
||||
if (
|
||||
!packId ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(packId) ||
|
||||
!packVersion ||
|
||||
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(packVersion) ||
|
||||
!faceId ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(faceId) ||
|
||||
kind !== "web" ||
|
||||
extra.length > 0
|
||||
) {
|
||||
throw new Error("字体包资源地址无效");
|
||||
}
|
||||
return { packId, packVersion, faceId, kind } as const;
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare const __APP_ID__: string;
|
||||
declare const __APP_ENGLISH_NAME__: string;
|
||||
declare const __APP_DISPLAY_NAME__: string;
|
||||
declare const __APP_WINDOW_TITLE__: string;
|
||||
declare const __APP_LEGACY_DISPLAY_NAME__: string;
|
||||
@@ -0,0 +1,50 @@
|
||||
export const DESKTOP_RENDER_MARKDOWN =
|
||||
"md-to-pdf:desktop:render-markdown";
|
||||
export const DESKTOP_OPEN_MARKDOWN =
|
||||
"md-to-pdf:desktop:open-markdown";
|
||||
export const DESKTOP_CONSUME_PENDING_MARKDOWN =
|
||||
"md-to-pdf:desktop:consume-pending-markdown";
|
||||
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 =
|
||||
"md-to-pdf:desktop:refresh-themes";
|
||||
export const DESKTOP_LIST_THEMES =
|
||||
"md-to-pdf:desktop:list-themes";
|
||||
export const DESKTOP_GET_THEME_CSS =
|
||||
"md-to-pdf:desktop:get-theme-css";
|
||||
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 =
|
||||
"md-to-pdf:desktop-pdf:render";
|
||||
export const PDF_RUNTIME_COMPLETE =
|
||||
"md-to-pdf:desktop-pdf:complete";
|
||||
export const PDF_RUNTIME_FAIL =
|
||||
"md-to-pdf:desktop-pdf:fail";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
export function resolveDesktopDocxTargetPath(selectedPath: string) {
|
||||
return selectedPath.toLowerCase().endsWith(".docx")
|
||||
? selectedPath
|
||||
: `${selectedPath}.docx`;
|
||||
}
|
||||
|
||||
export async function saveDesktopDocx(
|
||||
selectedPath: string,
|
||||
content: Uint8Array
|
||||
) {
|
||||
if (!selectedPath || content.byteLength === 0) {
|
||||
throw new Error("DOCX 保存参数无效");
|
||||
}
|
||||
const targetPath = resolveDesktopDocxTargetPath(selectedPath);
|
||||
await writeFile(targetPath, content);
|
||||
return {
|
||||
targetPath,
|
||||
fileName: path.basename(targetPath)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError
|
||||
} from "@md-to-pdf/application";
|
||||
import type {
|
||||
DocxCapability,
|
||||
DocxExportDiagnostics,
|
||||
DocxExportErrorCode,
|
||||
DocxGenerationTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { docxExportErrorCodeSchema } from "@md-to-pdf/core";
|
||||
|
||||
export interface DesktopDocxExportSuccess {
|
||||
ok: true;
|
||||
saved: boolean;
|
||||
fileName: string;
|
||||
diagnostics: DocxExportDiagnostics;
|
||||
timings: DocxGenerationTimings;
|
||||
}
|
||||
|
||||
export interface DesktopDocxExportFailure {
|
||||
ok: false;
|
||||
error: DocxExportErrorCode;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export type DesktopDocxExportOutcome =
|
||||
| DesktopDocxExportSuccess
|
||||
| DesktopDocxExportFailure;
|
||||
|
||||
export type DesktopDocxCapability = DocxCapability;
|
||||
|
||||
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
|
||||
const cause = error.cause;
|
||||
if (!(cause instanceof Error)) {
|
||||
return undefined;
|
||||
}
|
||||
return /^桌面 DOCX (?:媒体布局超过 \d+ms|第 \d+\/\d+ 个媒体((?:image|mermaid|echarts))PNG 捕获超过 \d+ms)$/u.test(
|
||||
cause.message
|
||||
)
|
||||
? cause.message
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toDesktopDocxExportFailure(
|
||||
error: unknown
|
||||
): DesktopDocxExportFailure {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
const parsedCode = docxExportErrorCodeSchema.safeParse(
|
||||
error.code
|
||||
);
|
||||
if (!parsedCode.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 导出失败",
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: parsedCode.data,
|
||||
message: error.message,
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
const detail = readSafeDesktopMediaDetail(error);
|
||||
return {
|
||||
ok: false,
|
||||
error: error.code,
|
||||
message: detail ? `${error.message}:${detail}` : error.message,
|
||||
retryable: error.retryable
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 导出失败",
|
||||
retryable: false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { WebContents } from "electron";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
|
||||
|
||||
interface CaptureScreenshotResult {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface ElectronDocxMediaCaptureTimeouts {
|
||||
renderMs: number;
|
||||
screenshotMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
|
||||
renderMs: 30_000,
|
||||
screenshotMs: 20_000
|
||||
};
|
||||
|
||||
function withTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
callback();
|
||||
};
|
||||
const abort = () =>
|
||||
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
|
||||
const timeout = setTimeout(
|
||||
() => finish(() => reject(new Error(message))),
|
||||
timeoutMs
|
||||
);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
operation.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createRenderScript(request: DocxMediaCaptureRequest) {
|
||||
const serialized = JSON.stringify(request);
|
||||
return `(async () => {
|
||||
const request = ${serialized};
|
||||
const renderer = window.__mdToPdfRenderDocxMedia;
|
||||
if (!renderer) {
|
||||
throw new Error("DOCX 媒体渲染运行时不可用");
|
||||
}
|
||||
return renderer(request.payload, request.dimensions);
|
||||
})()`;
|
||||
}
|
||||
|
||||
export async function captureDocxMediaWithElectronWebContents(
|
||||
webContents: WebContents,
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal,
|
||||
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const plan = (await withTimeout(
|
||||
webContents.executeJavaScript(createRenderScript(request), true),
|
||||
timeouts.renderMs,
|
||||
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||
signal
|
||||
)) as DocxMediaCapturePlan;
|
||||
signal?.throwIfAborted();
|
||||
const ownsDebugger = !webContents.debugger.isAttached();
|
||||
if (ownsDebugger) {
|
||||
webContents.debugger.attach("1.3");
|
||||
}
|
||||
|
||||
try {
|
||||
const captures = [];
|
||||
for (const target of plan.targets) {
|
||||
signal?.throwIfAborted();
|
||||
const result = (await withTimeout(
|
||||
webContents.debugger.sendCommand(
|
||||
"Page.captureScreenshot",
|
||||
{
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: {
|
||||
x: target.captureX,
|
||||
y: target.captureY,
|
||||
width: target.captureWidthPx,
|
||||
height: target.captureHeightPx,
|
||||
scale: target.rasterScale
|
||||
}
|
||||
}
|
||||
),
|
||||
timeouts.screenshotMs,
|
||||
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind})PNG 捕获超过 ${timeouts.screenshotMs}ms`,
|
||||
signal
|
||||
)) as CaptureScreenshotResult;
|
||||
captures.push({
|
||||
id: target.id,
|
||||
png: Buffer.from(result.data, "base64")
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { plan, captures };
|
||||
} finally {
|
||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||
webContents.debugger.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronDocxMediaCaptureAdapter
|
||||
implements DocxMediaCaptureAdapter
|
||||
{
|
||||
constructor(private readonly webContents: WebContents) {}
|
||||
|
||||
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||
return captureDocxMediaWithElectronWebContents(
|
||||
this.webContents,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import type {
|
||||
DocxThemeStyleCaptureAdapter,
|
||||
DocxThemeStyleCaptureRequest
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
|
||||
import { captureDocxThemeStyleWithElectronWebContents } from "./electron-docx-theme-style.js";
|
||||
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
|
||||
|
||||
export const DESKTOP_DOCX_PARTITION =
|
||||
"md-to-pdf-desktop-docx";
|
||||
|
||||
export interface ElectronDocxMediaEngineOptions {
|
||||
renderUrl: string;
|
||||
}
|
||||
|
||||
export async function withActiveDocxCaptureSurface<T>(
|
||||
window: Pick<
|
||||
BrowserWindow,
|
||||
"showInactive" | "hide" | "isDestroyed"
|
||||
>,
|
||||
operation: () => Promise<T>,
|
||||
settleMs = 50
|
||||
) {
|
||||
window.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronDocxMediaEngine
|
||||
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||
{
|
||||
readonly themeStyleBaseUrl: string;
|
||||
private readonly renderUrl: string;
|
||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||
private queue = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: ElectronDocxMediaEngineOptions) {
|
||||
this.renderUrl = options.renderUrl;
|
||||
this.themeStyleBaseUrl = this.renderUrl;
|
||||
}
|
||||
|
||||
capture(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
|
||||
}
|
||||
const operation = this.queue.then(() =>
|
||||
this.captureNow(request, signal)
|
||||
);
|
||||
this.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
captureThemeStyle(
|
||||
request: DocxThemeStyleCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
|
||||
}
|
||||
const operation = this.queue.then(() =>
|
||||
this.captureThemeStyleNow(request, signal)
|
||||
);
|
||||
this.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
await this.queue;
|
||||
const windowPromise = this.windowPromise;
|
||||
this.windowPromise = undefined;
|
||||
if (!windowPromise) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const window = await windowPromise;
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
} catch {
|
||||
// 创建失败时没有需要关闭的窗口。
|
||||
}
|
||||
}
|
||||
|
||||
private async captureNow(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const window = await this.getWindow();
|
||||
signal?.throwIfAborted();
|
||||
const abort = () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
return await withActiveDocxCaptureSurface(
|
||||
window,
|
||||
() =>
|
||||
captureDocxMediaWithElectronWebContents(
|
||||
window.webContents,
|
||||
request,
|
||||
signal
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
private async captureThemeStyleNow(
|
||||
request: DocxThemeStyleCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
new URL(request.baseUrl).origin !==
|
||||
new URL(this.renderUrl).origin
|
||||
) {
|
||||
throw new Error("桌面 DOCX 主题资源地址必须与渲染地址同源");
|
||||
}
|
||||
const window = await this.getWindow();
|
||||
signal?.throwIfAborted();
|
||||
const abort = () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
return await captureDocxThemeStyleWithElectronWebContents(
|
||||
window.webContents,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
private getWindow() {
|
||||
if (!this.windowPromise) {
|
||||
this.windowPromise = this.createWindow().catch((error) => {
|
||||
this.windowPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this.windowPromise;
|
||||
}
|
||||
|
||||
private async createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
focusable: false,
|
||||
opacity: 0,
|
||||
skipTaskbar: true,
|
||||
x: -32_000,
|
||||
y: -32_000,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: DESKTOP_DOCX_PARTITION
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
const target = new URL(url);
|
||||
const renderTarget = new URL(this.renderUrl);
|
||||
if (
|
||||
target.origin !== renderTarget.origin ||
|
||||
target.pathname !== renderTarget.pathname
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
window.webContents.on("destroyed", () => {
|
||||
this.windowPromise = undefined;
|
||||
});
|
||||
window.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ["*://*/*"] },
|
||||
(details, callback) => {
|
||||
callback({
|
||||
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await window.loadURL(this.renderUrl);
|
||||
await window.webContents.executeJavaScript(`
|
||||
new Promise((resolve, reject) => {
|
||||
if (document.documentElement.dataset.runtimeReady === "true") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
reject(new Error("桌面 DOCX 媒体运行时启动超时"));
|
||||
}, 10000);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.documentElement.dataset.runtimeReady === "true") {
|
||||
clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-runtime-ready"]
|
||||
});
|
||||
})
|
||||
`);
|
||||
window.webContents.setZoomFactor(1);
|
||||
return window;
|
||||
} catch (error) {
|
||||
window.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
createDocxThemeStyleCaptureScript,
|
||||
parseDocxThemeStyleRuntimeCapture,
|
||||
type DocxThemeStyleCaptureRequest,
|
||||
type DocxThemeStyleSnapshot
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
export async function captureDocxThemeStyleWithElectronWebContents(
|
||||
webContents: WebContents,
|
||||
request: DocxThemeStyleCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<DocxThemeStyleSnapshot> {
|
||||
signal?.throwIfAborted();
|
||||
const ownsDebugger = !webContents.debugger.isAttached();
|
||||
if (ownsDebugger) {
|
||||
webContents.debugger.attach("1.3");
|
||||
}
|
||||
try {
|
||||
await webContents.debugger.sendCommand(
|
||||
"Emulation.setEmulatedMedia",
|
||||
{ media: "print" }
|
||||
);
|
||||
const capture = await webContents.executeJavaScript(
|
||||
createDocxThemeStyleCaptureScript(request),
|
||||
true
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return parseDocxThemeStyleRuntimeCapture(request, capture);
|
||||
} finally {
|
||||
if (
|
||||
!webContents.isDestroyed() &&
|
||||
webContents.debugger.isAttached()
|
||||
) {
|
||||
await webContents.debugger
|
||||
.sendCommand("Emulation.setEmulatedMedia", {
|
||||
media: "screen"
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||
webContents.debugger.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
type IpcMainEvent
|
||||
} from "electron";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
PDF_RUNTIME_FAIL,
|
||||
PDF_RUNTIME_READY,
|
||||
PDF_RUNTIME_RENDER
|
||||
} from "./channels.js";
|
||||
import {
|
||||
createElectronPdfPrintOptions,
|
||||
isAllowedPdfRuntimeUrl
|
||||
} from "./pdf-contract.js";
|
||||
|
||||
export const DESKTOP_PDF_PARTITION =
|
||||
"md-to-pdf-desktop-pdf";
|
||||
|
||||
export interface ElectronPdfGenerationResult {
|
||||
pdf: Buffer;
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
timings: {
|
||||
documentRenderMs: number;
|
||||
pdfPrintMs: number;
|
||||
totalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronPdfGeneratorOptions {
|
||||
renderUrl: string;
|
||||
preloadPath: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingRender {
|
||||
requestId: number;
|
||||
resolve: (result: PagedDocumentRenderResult) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
|
||||
async function waitWithTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
) {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(message)),
|
||||
timeoutMs
|
||||
);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronPdfGenerator {
|
||||
private readonly renderUrl: string;
|
||||
private readonly preloadPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||
private renderWindow: BrowserWindow | undefined;
|
||||
private pendingRender: PendingRender | undefined;
|
||||
private nextRequestId = 1;
|
||||
private queue = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: ElectronPdfGeneratorOptions) {
|
||||
this.renderUrl = options.renderUrl;
|
||||
this.preloadPath = options.preloadPath;
|
||||
this.timeoutMs = options.timeoutMs ?? 60_000;
|
||||
|
||||
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
|
||||
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||
}
|
||||
|
||||
generate(payload: PagedDocumentPayload) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||
}
|
||||
|
||||
const operation = this.queue.then(() => this.generateNow(payload));
|
||||
this.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
ipcMain.removeListener(
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
this.handleRenderComplete
|
||||
);
|
||||
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||
this.pendingRender = undefined;
|
||||
|
||||
const windowPromise = this.windowPromise;
|
||||
this.windowPromise = undefined;
|
||||
this.renderWindow = undefined;
|
||||
if (!windowPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const window = await windowPromise;
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
} catch {
|
||||
// 创建失败时没有需要关闭的窗口。
|
||||
}
|
||||
}
|
||||
|
||||
private async generateNow(payload: PagedDocumentPayload) {
|
||||
const startedAt = performance.now();
|
||||
const window = await this.getWindow();
|
||||
const documentRenderStartedAt = performance.now();
|
||||
const renderResult = await this.requestRender(window, payload);
|
||||
const documentRenderMs =
|
||||
performance.now() - documentRenderStartedAt;
|
||||
await window.webContents.executeJavaScript(
|
||||
"new Promise((resolve) => setTimeout(resolve, 0))"
|
||||
);
|
||||
|
||||
const pdfPrintStartedAt = performance.now();
|
||||
window.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
let pdf: Buffer;
|
||||
try {
|
||||
pdf = await waitWithTimeout(
|
||||
window.webContents.printToPDF(
|
||||
createElectronPdfPrintOptions(payload, renderResult)
|
||||
),
|
||||
this.timeoutMs,
|
||||
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
|
||||
);
|
||||
} catch (error) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
|
||||
|
||||
return {
|
||||
pdf,
|
||||
pageCount: renderResult.pageCount,
|
||||
echartsErrors: renderResult.echartsErrors,
|
||||
mermaidErrors: renderResult.mermaidErrors,
|
||||
timings: {
|
||||
documentRenderMs,
|
||||
pdfPrintMs,
|
||||
totalMs: performance.now() - startedAt
|
||||
}
|
||||
} satisfies ElectronPdfGenerationResult;
|
||||
}
|
||||
|
||||
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_PDF_PARTITION,
|
||||
preload: this.preloadPath
|
||||
}
|
||||
});
|
||||
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", () => {
|
||||
if (this.windowPromise) {
|
||||
this.windowPromise = undefined;
|
||||
}
|
||||
this.renderWindow = undefined;
|
||||
this.pendingRender?.reject(
|
||||
new Error("桌面 PDF 渲染窗口已关闭")
|
||||
);
|
||||
this.pendingRender = undefined;
|
||||
});
|
||||
window.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ["*://*/*"] },
|
||||
(details, callback) => {
|
||||
callback({
|
||||
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("桌面 PDF 分页运行时启动超时"));
|
||||
}, this.timeoutMs);
|
||||
const handleReady = (event: IpcMainEvent) => {
|
||||
if (event.sender !== window.webContents) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
|
||||
};
|
||||
ipcMain.on(PDF_RUNTIME_READY, handleReady);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([window.loadURL(this.renderUrl), ready]);
|
||||
window.webContents.setZoomFactor(1);
|
||||
this.renderWindow = window;
|
||||
return window;
|
||||
} catch (error) {
|
||||
window.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private requestRender(
|
||||
window: BrowserWindow,
|
||||
payload: PagedDocumentPayload
|
||||
) {
|
||||
if (this.pendingRender) {
|
||||
throw new Error("桌面 PDF 渲染器状态异常");
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId++;
|
||||
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingRender?.requestId === requestId) {
|
||||
this.pendingRender = undefined;
|
||||
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}, this.timeoutMs);
|
||||
|
||||
this.pendingRender = {
|
||||
requestId,
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly handleRenderComplete = (
|
||||
event: IpcMainEvent,
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
) => {
|
||||
const pending = this.pendingRender;
|
||||
if (
|
||||
!pending ||
|
||||
pending.requestId !== requestId ||
|
||||
event.sender !== this.renderWindow?.webContents
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingRender = undefined;
|
||||
pending.resolve(result);
|
||||
};
|
||||
|
||||
private readonly handleRenderFailure = (
|
||||
event: IpcMainEvent,
|
||||
requestId: number,
|
||||
message: string
|
||||
) => {
|
||||
const pending = this.pendingRender;
|
||||
if (
|
||||
!pending ||
|
||||
pending.requestId !== requestId ||
|
||||
event.sender !== this.renderWindow?.webContents
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingRender = undefined;
|
||||
pending.reject(
|
||||
new Error(
|
||||
typeof message === "string" ? message : "桌面 PDF 分页失败"
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
app,
|
||||
Menu,
|
||||
net,
|
||||
protocol,
|
||||
session
|
||||
} from "electron";
|
||||
import { access, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
createApplicationService,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
parseFontPackResourceUrl,
|
||||
parseThemeResourceUrl
|
||||
} from "./application-contract.js";
|
||||
import {
|
||||
DesktopApplicationController
|
||||
} from "./desktop-application-controller.js";
|
||||
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
|
||||
import {
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath
|
||||
} from "./markdown-file.js";
|
||||
import { migrateLegacyUserData } from "./user-data-migration.js";
|
||||
|
||||
const APP_SCHEME = "mdpdf";
|
||||
const APP_HOST = "bundle";
|
||||
const THEME_HOST = "theme";
|
||||
const FONT_PACK_HOST = "font-pack";
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
let applicationController: DesktopApplicationController | undefined;
|
||||
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||
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,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
codeCache: true,
|
||||
corsEnabled: true
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
function getCommandLineValue(name: string) {
|
||||
const prefix = `--${name}=`;
|
||||
return process.argv
|
||||
.find((argument) => argument.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
}
|
||||
|
||||
function getWebRoot() {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "dist")
|
||||
: path.resolve(currentDirectory, "../../web/dist");
|
||||
}
|
||||
|
||||
function getApplicationIcon() {
|
||||
if (app.isPackaged) {
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
process.platform === "darwin" ? "app.icns" : "app.ico"
|
||||
);
|
||||
}
|
||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||
return path.join(
|
||||
projectRoot,
|
||||
"logos",
|
||||
"desktop",
|
||||
process.platform === "darwin" ? "macos" : "windows",
|
||||
process.platform === "darwin" ? "app.icns" : "app.ico"
|
||||
);
|
||||
}
|
||||
|
||||
function encodeThemeAssetPath(assetPath: string) {
|
||||
return assetPath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function dispatchExternalMarkdown(filePath: string) {
|
||||
if (!applicationController) {
|
||||
pendingExternalMarkdownPath = filePath;
|
||||
return;
|
||||
}
|
||||
void applicationController.openExternalMarkdown(filePath).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getDesktopThemeDirectory() {
|
||||
return app.isPackaged
|
||||
? path.join(app.getPath("userData"), "themes")
|
||||
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||
}
|
||||
|
||||
function getDesktopFontPackDirectory() {
|
||||
if (!app.isPackaged) {
|
||||
return path.resolve(
|
||||
currentDirectory,
|
||||
"../../..",
|
||||
".local",
|
||||
"font-packs"
|
||||
);
|
||||
}
|
||||
const localApplicationData = process.env.LOCALAPPDATA?.trim();
|
||||
return localApplicationData
|
||||
? path.join(localApplicationData, "md-to-pdf", "font-packs")
|
||||
: path.join(app.getPath("userData"), "font-packs");
|
||||
}
|
||||
|
||||
function createDesktopApplicationService() {
|
||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||
return createApplicationService({
|
||||
bundledRoot: app.isPackaged
|
||||
? path.join(process.resourcesPath, "themes")
|
||||
: path.join(projectRoot, "themes"),
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
function isContainedPath(root: string, target: string) {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
async function registerApplicationProtocol(
|
||||
applicationService: ApplicationService
|
||||
) {
|
||||
const webRoot = getWebRoot();
|
||||
const handleRequest = async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.host === THEME_HOST) {
|
||||
try {
|
||||
const resource = parseThemeResourceUrl(request.url);
|
||||
if (!resource) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
const asset = await applicationService.getThemeAsset(
|
||||
resource.themeId,
|
||||
resource.assetPath
|
||||
);
|
||||
if (!asset) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return new Response(asset.content, {
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=300",
|
||||
"content-type": asset.contentType,
|
||||
"x-content-type-options": "nosniff"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
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 });
|
||||
}
|
||||
|
||||
let relativePath: string;
|
||||
try {
|
||||
relativePath =
|
||||
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
|
||||
"index.html";
|
||||
} 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 {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return net.fetch(pathToFileURL(target).href);
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
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)
|
||||
]);
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
let windowStateReadyToQuit = false;
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", (_event, arguments_, workingDirectory) => {
|
||||
const filePath = findMarkdownFileArgument(
|
||||
arguments_,
|
||||
workingDirectory
|
||||
);
|
||||
if (filePath) {
|
||||
dispatchExternalMarkdown(filePath);
|
||||
} else if (!applicationController?.focusMostRecentWindow()) {
|
||||
void applicationController?.createWindow();
|
||||
}
|
||||
});
|
||||
app.on("open-file", (event, filePath) => {
|
||||
event.preventDefault();
|
||||
if (isMarkdownFilePath(filePath)) {
|
||||
dispatchExternalMarkdown(filePath);
|
||||
}
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (!applicationController?.focusMostRecentWindow()) {
|
||||
void applicationController?.createWindow();
|
||||
}
|
||||
});
|
||||
app.on("before-quit", (event) => {
|
||||
if (windowStateReadyToQuit || !applicationController) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
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(__APP_ID__);
|
||||
}
|
||||
const themeDirectory = getDesktopThemeDirectory();
|
||||
await mkdir(themeDirectory, { recursive: true });
|
||||
const applicationService = createDesktopApplicationService();
|
||||
await registerApplicationProtocol(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;
|
||||
await applicationController.createWindow(startupMarkdownPath);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("桌面应用启动失败", error);
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||
|
||||
export interface OpenedMarkdownDocument {
|
||||
markdown: string;
|
||||
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());
|
||||
}
|
||||
|
||||
export function findMarkdownFileArgument(
|
||||
arguments_: string[],
|
||||
workingDirectory: string
|
||||
) {
|
||||
for (const argument of arguments_) {
|
||||
if (!argument || argument.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.isAbsolute(argument)
|
||||
? path.normalize(argument)
|
||||
: path.resolve(workingDirectory, argument);
|
||||
if (isMarkdownFilePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readMarkdownDocument(
|
||||
filePath: string,
|
||||
maximumLength: number
|
||||
): Promise<OpenedMarkdownDocument> {
|
||||
if (!isMarkdownFilePath(filePath)) {
|
||||
throw new Error("只允许打开 .md 或 .markdown 文件");
|
||||
}
|
||||
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
|
||||
throw new Error("Markdown 长度限制无效");
|
||||
}
|
||||
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) {
|
||||
throw new Error("Markdown 路径不是文件");
|
||||
}
|
||||
if (fileStats.size > maximumLength * 4) {
|
||||
throw new Error("Markdown 文件过大");
|
||||
}
|
||||
|
||||
const markdown = await readFile(filePath, "utf8");
|
||||
if (markdown.length > maximumLength) {
|
||||
throw new Error("Markdown 文件过大");
|
||||
}
|
||||
return {
|
||||
markdown,
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
exportConfigSchema,
|
||||
semanticDocumentModelSchema,
|
||||
themeFeatureSchema,
|
||||
type PagedDocumentPayload,
|
||||
type PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
|
||||
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
maximum: number
|
||||
) {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.length > maximum) {
|
||||
throw new Error(`${key} 无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown, key: string, maximum: number) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > maximum ||
|
||||
value.some((item) => typeof item !== "string")
|
||||
) {
|
||||
throw new Error(`${key} 无效`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
export function parsePagedDocumentPayload(
|
||||
value: unknown
|
||||
): PagedDocumentPayload {
|
||||
if (!isRecord(value) || !isRecord(value.metadata)) {
|
||||
throw new Error("PDF 分页载荷无效");
|
||||
}
|
||||
|
||||
const metadata = value.metadata;
|
||||
const features = readStringArray(value.features, "features", 20);
|
||||
const parsedFeatures = features.map((feature) =>
|
||||
themeFeatureSchema.parse(feature)
|
||||
);
|
||||
|
||||
return {
|
||||
articleHtml: readString(
|
||||
value,
|
||||
"articleHtml",
|
||||
MAX_ARTICLE_HTML_LENGTH
|
||||
),
|
||||
fileName: readString(value, "fileName", 500),
|
||||
metadata: {
|
||||
title: readString(metadata, "title", 500),
|
||||
author: readString(metadata, "author", 500),
|
||||
subject: readString(metadata, "subject", 1_000),
|
||||
keywords: readStringArray(
|
||||
metadata.keywords,
|
||||
"metadata.keywords",
|
||||
100
|
||||
),
|
||||
language: readString(metadata, "language", 50)
|
||||
},
|
||||
semanticDocument: semanticDocumentModelSchema.parse(
|
||||
value.semanticDocument
|
||||
),
|
||||
features: parsedFeatures,
|
||||
themeCss: readString(
|
||||
value,
|
||||
"themeCss",
|
||||
MAX_THEME_CSS_LENGTH
|
||||
),
|
||||
exportConfig: exportConfigSchema.parse(value.exportConfig)
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronPdfPrintOptions {
|
||||
displayHeaderFooter: false;
|
||||
margins: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
pageRanges: string;
|
||||
preferCSSPageSize: true;
|
||||
printBackground: boolean;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function createElectronPdfPrintOptions(
|
||||
payload: PagedDocumentPayload,
|
||||
renderResult: PagedDocumentRenderResult
|
||||
): ElectronPdfPrintOptions {
|
||||
return {
|
||||
displayHeaderFooter: false,
|
||||
margins: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
|
||||
preferCSSPageSize: true,
|
||||
printBackground: payload.exportConfig.print.background,
|
||||
scale: payload.exportConfig.print.scale
|
||||
};
|
||||
}
|
||||
|
||||
export function isAllowedPdfRuntimeUrl(
|
||||
requestUrl: string,
|
||||
renderUrl: string
|
||||
) {
|
||||
const requested = new URL(requestUrl);
|
||||
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
requested.protocol === "mdpdf:" &&
|
||||
["theme", "font-pack"].includes(requested.host)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return requested.origin === new URL(renderUrl).origin;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
contextBridge,
|
||||
ipcRenderer,
|
||||
type IpcRendererEvent
|
||||
} from "electron";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
PDF_RUNTIME_FAIL,
|
||||
PDF_RUNTIME_READY,
|
||||
PDF_RUNTIME_RENDER
|
||||
} from "./channels.js";
|
||||
|
||||
type RenderListener = (
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => Promise<void> | void;
|
||||
|
||||
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
|
||||
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
|
||||
onRender: (listener: RenderListener) => {
|
||||
const handler = (
|
||||
_event: IpcRendererEvent,
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => {
|
||||
void listener(requestId, payload);
|
||||
};
|
||||
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
|
||||
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
|
||||
},
|
||||
complete: (
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
|
||||
fail: (requestId: number, message: string) =>
|
||||
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const SAVE_LOCATION_STATE_VERSION = 1;
|
||||
|
||||
export interface SaveLocationState {
|
||||
version: 1;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ResolveDefaultSavePathOptions {
|
||||
sourceFilePath?: string;
|
||||
lastSaveDirectory?: string;
|
||||
documentsDirectory: string;
|
||||
suggestedFileName: string;
|
||||
}
|
||||
|
||||
export function parseSaveLocationState(
|
||||
value: unknown
|
||||
): SaveLocationState | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const state = value as Partial<SaveLocationState>;
|
||||
if (
|
||||
state.version !== SAVE_LOCATION_STATE_VERSION ||
|
||||
typeof state.directory !== "string" ||
|
||||
!path.isAbsolute(state.directory)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: SAVE_LOCATION_STATE_VERSION,
|
||||
directory: path.resolve(state.directory)
|
||||
};
|
||||
}
|
||||
|
||||
async function isAvailableDirectory(directory: string | undefined) {
|
||||
if (!directory || !path.isAbsolute(directory)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return (await stat(directory)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuggestedFileName(value: string) {
|
||||
const fileName = path.basename(value.trim());
|
||||
if (!fileName || fileName === ".") {
|
||||
throw new Error("默认保存文件名无效");
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
export async function resolveDefaultSavePath(
|
||||
options: ResolveDefaultSavePathOptions
|
||||
) {
|
||||
const fileName = assertSuggestedFileName(
|
||||
options.suggestedFileName
|
||||
);
|
||||
const sourceDirectory = options.sourceFilePath
|
||||
? path.dirname(path.resolve(options.sourceFilePath))
|
||||
: undefined;
|
||||
if (await isAvailableDirectory(sourceDirectory)) {
|
||||
return path.join(sourceDirectory!, fileName);
|
||||
}
|
||||
if (await isAvailableDirectory(options.lastSaveDirectory)) {
|
||||
return path.join(options.lastSaveDirectory!, fileName);
|
||||
}
|
||||
return path.join(
|
||||
path.resolve(options.documentsDirectory),
|
||||
fileName
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSaveLocationState(filePath: string) {
|
||||
try {
|
||||
return parseSaveLocationState(
|
||||
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSaveLocationState(
|
||||
filePath: string,
|
||||
directory: string
|
||||
) {
|
||||
const state = parseSaveLocationState({
|
||||
version: SAVE_LOCATION_STATE_VERSION,
|
||||
directory
|
||||
});
|
||||
if (!state) {
|
||||
throw new Error("最近保存目录无效");
|
||||
}
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
await rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export class SaveLocationStore {
|
||||
readonly #statePath: string;
|
||||
readonly #documentsDirectory: string;
|
||||
#lastSaveDirectory: string | undefined;
|
||||
#pendingWrite = Promise.resolve();
|
||||
|
||||
constructor(statePath: string, documentsDirectory: string) {
|
||||
this.#statePath = statePath;
|
||||
this.#documentsDirectory = documentsDirectory;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.#lastSaveDirectory = (
|
||||
await loadSaveLocationState(this.#statePath)
|
||||
)?.directory;
|
||||
}
|
||||
|
||||
resolveDefaultPath(
|
||||
sourceFilePath: string | undefined,
|
||||
suggestedFileName: string
|
||||
) {
|
||||
return resolveDefaultSavePath({
|
||||
documentsDirectory: this.#documentsDirectory,
|
||||
suggestedFileName,
|
||||
...(sourceFilePath ? { sourceFilePath } : {}),
|
||||
...(this.#lastSaveDirectory
|
||||
? { lastSaveDirectory: this.#lastSaveDirectory }
|
||||
: {})
|
||||
});
|
||||
}
|
||||
|
||||
async recordSuccessfulFile(filePath: string) {
|
||||
const directory = path.dirname(path.resolve(filePath));
|
||||
this.#lastSaveDirectory = directory;
|
||||
const write = this.#pendingWrite
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
saveSaveLocationState(this.#statePath, directory)
|
||||
);
|
||||
this.#pendingWrite = write.then(() => undefined);
|
||||
await write;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cp, lstat, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const MIGRATED_ENTRIES = [
|
||||
"window-state.json",
|
||||
"save-location.json",
|
||||
"themes",
|
||||
"Local Storage",
|
||||
"Session Storage",
|
||||
"IndexedDB"
|
||||
] as const;
|
||||
|
||||
async function readOrdinaryStatus(target: string) {
|
||||
try {
|
||||
const status = await lstat(target);
|
||||
return status.isSymbolicLink() ? undefined : status;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegacyUserDataMigrationResult {
|
||||
migrated: boolean;
|
||||
sourceDirectory?: string;
|
||||
copiedEntries: string[];
|
||||
}
|
||||
|
||||
export async function migrateLegacyUserData(
|
||||
legacyDirectories: readonly string[],
|
||||
targetDirectory: string
|
||||
): Promise<LegacyUserDataMigrationResult> {
|
||||
if (await readOrdinaryStatus(targetDirectory)) {
|
||||
return { migrated: false, copiedEntries: [] };
|
||||
}
|
||||
|
||||
for (const legacyDirectory of legacyDirectories) {
|
||||
if (path.resolve(legacyDirectory) === path.resolve(targetDirectory)) {
|
||||
continue;
|
||||
}
|
||||
const legacyStatus = await readOrdinaryStatus(legacyDirectory);
|
||||
if (!legacyStatus?.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(targetDirectory, { recursive: true });
|
||||
const copiedEntries: string[] = [];
|
||||
for (const entry of MIGRATED_ENTRIES) {
|
||||
const source = path.join(legacyDirectory, entry);
|
||||
if (!(await readOrdinaryStatus(source))) {
|
||||
continue;
|
||||
}
|
||||
await cp(source, path.join(targetDirectory, entry), {
|
||||
recursive: true,
|
||||
errorOnExist: true,
|
||||
force: false
|
||||
});
|
||||
copiedEntries.push(entry);
|
||||
}
|
||||
return {
|
||||
migrated: true,
|
||||
sourceDirectory: legacyDirectory,
|
||||
copiedEntries
|
||||
};
|
||||
}
|
||||
|
||||
return { migrated: false, copiedEntries: [] };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface WindowCloseState {
|
||||
dirty: boolean;
|
||||
allowClose: boolean;
|
||||
closeRequestPending: boolean;
|
||||
}
|
||||
|
||||
export type WindowCloseDecision = "allow" | "request" | "wait";
|
||||
|
||||
export function getWindowCloseDecision(
|
||||
state: WindowCloseState
|
||||
): WindowCloseDecision {
|
||||
if (!state.dirty || state.allowClose) {
|
||||
return "allow";
|
||||
}
|
||||
return state.closeRequestPending ? "wait" : "request";
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WindowState {
|
||||
version: 1;
|
||||
bounds: WindowBounds;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
export interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const WINDOW_STATE_VERSION = 1;
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function parseWindowBounds(value: unknown): WindowBounds | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const bounds = value as Partial<WindowBounds>;
|
||||
if (
|
||||
!isFiniteNumber(bounds.x) ||
|
||||
!isFiniteNumber(bounds.y) ||
|
||||
!isFiniteNumber(bounds.width) ||
|
||||
!isFiniteNumber(bounds.height) ||
|
||||
bounds.width <= 0 ||
|
||||
bounds.height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.round(bounds.width),
|
||||
height: Math.round(bounds.height)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWindowState(value: unknown): WindowState | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const state = value as Partial<WindowState>;
|
||||
const bounds = parseWindowBounds(state.bounds);
|
||||
if (
|
||||
state.version !== WINDOW_STATE_VERSION ||
|
||||
!bounds ||
|
||||
typeof state.maximized !== "boolean"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: WINDOW_STATE_VERSION,
|
||||
bounds,
|
||||
maximized: state.maximized
|
||||
};
|
||||
}
|
||||
|
||||
function intersectionArea(left: WindowBounds, right: WindowBounds) {
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(left.x + left.width, right.x + right.width) -
|
||||
Math.max(left.x, right.x)
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(left.y + left.height, right.y + right.height) -
|
||||
Math.max(left.y, right.y)
|
||||
);
|
||||
return width * height;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
|
||||
const width = Math.min(
|
||||
Math.max(1, Math.round(size.width)),
|
||||
workArea.width
|
||||
);
|
||||
const height = Math.min(
|
||||
Math.max(1, Math.round(size.height)),
|
||||
workArea.height
|
||||
);
|
||||
return {
|
||||
x: Math.round(workArea.x + (workArea.width - width) / 2),
|
||||
y: Math.round(workArea.y + (workArea.height - height) / 2),
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWindowBounds(
|
||||
savedBounds: WindowBounds | undefined,
|
||||
workAreas: WindowBounds[],
|
||||
primaryWorkArea: WindowBounds,
|
||||
defaultSize: WindowSize,
|
||||
minimumSize: WindowSize
|
||||
): WindowBounds {
|
||||
if (!savedBounds) {
|
||||
return centerBounds(defaultSize, primaryWorkArea);
|
||||
}
|
||||
|
||||
let selectedArea = primaryWorkArea;
|
||||
let selectedIntersection = 0;
|
||||
for (const workArea of workAreas) {
|
||||
const area = intersectionArea(savedBounds, workArea);
|
||||
if (area > selectedIntersection) {
|
||||
selectedArea = workArea;
|
||||
selectedIntersection = area;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIntersection === 0) {
|
||||
return centerBounds(
|
||||
{
|
||||
width: Math.min(
|
||||
Math.max(savedBounds.width, minimumSize.width),
|
||||
primaryWorkArea.width
|
||||
),
|
||||
height: Math.min(
|
||||
Math.max(savedBounds.height, minimumSize.height),
|
||||
primaryWorkArea.height
|
||||
)
|
||||
},
|
||||
primaryWorkArea
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.min(
|
||||
Math.max(savedBounds.width, minimumSize.width),
|
||||
selectedArea.width
|
||||
);
|
||||
const height = Math.min(
|
||||
Math.max(savedBounds.height, minimumSize.height),
|
||||
selectedArea.height
|
||||
);
|
||||
return {
|
||||
x: clamp(
|
||||
savedBounds.x,
|
||||
selectedArea.x,
|
||||
selectedArea.x + selectedArea.width - width
|
||||
),
|
||||
y: clamp(
|
||||
savedBounds.y,
|
||||
selectedArea.y,
|
||||
selectedArea.y + selectedArea.height - height
|
||||
),
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWindowState(filePath: string) {
|
||||
try {
|
||||
return parseWindowState(
|
||||
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWindowState(
|
||||
filePath: string,
|
||||
state: WindowState
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 保存参数无效");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError
|
||||
} from "@md-to-pdf/application";
|
||||
import { toDesktopDocxExportFailure } from "../src/docx-contract.js";
|
||||
|
||||
describe("Desktop DOCX IPC 契约", () => {
|
||||
it("映射应用请求错误", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_EXPORT_CONFIG",
|
||||
"导出配置无效"
|
||||
)
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "INVALID_EXPORT_CONFIG",
|
||||
message: "导出配置无效",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("保留共享服务的重试语义", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new DocxExportServiceError(
|
||||
429,
|
||||
"DOCX_QUEUE_FULL",
|
||||
"DOCX 生成队列已满",
|
||||
true
|
||||
)
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "DOCX_QUEUE_FULL",
|
||||
message: "DOCX 生成队列已满",
|
||||
retryable: true
|
||||
});
|
||||
});
|
||||
|
||||
it("仅透传格式受控的桌面媒体超时详情", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new DocxExportServiceError(
|
||||
500,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出失败",
|
||||
false,
|
||||
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
|
||||
});
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new DocxExportServiceError(
|
||||
500,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出失败",
|
||||
false,
|
||||
{
|
||||
cause: new Error(
|
||||
"桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
message:
|
||||
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||
});
|
||||
});
|
||||
|
||||
it("保留 Front Matter 解析错误", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_FRONT_MATTER",
|
||||
"无法解析 Markdown Front Matter"
|
||||
)
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "INVALID_FRONT_MATTER",
|
||||
message: "无法解析 Markdown Front Matter",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("隐藏未知主进程错误细节", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new Error("C:\\secret\\pandoc.exe 启动失败")
|
||||
)
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "DOCX_GENERATION_FAILED",
|
||||
message: "DOCX 导出失败",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebContents } from "electron";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
type DocxMediaCapturePlan
|
||||
} from "@md-to-pdf/core";
|
||||
import { captureDocxMediaWithElectronWebContents } from "../src/electron-docx-media-capture.js";
|
||||
|
||||
const plan: DocxMediaCapturePlan = {
|
||||
targets: [
|
||||
{
|
||||
id: "docx-media-1",
|
||||
kind: "image",
|
||||
ordinal: 1,
|
||||
kindOrdinal: 1,
|
||||
altText: "图片",
|
||||
alignment: "center",
|
||||
displayWidthPx: 200,
|
||||
displayHeightPx: 100,
|
||||
captureX: 12,
|
||||
captureY: 24,
|
||||
captureWidthPx: 200,
|
||||
captureHeightPx: 100,
|
||||
rasterScale: 3.125
|
||||
}
|
||||
],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
};
|
||||
|
||||
const request = {
|
||||
payload: createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"></article>',
|
||||
bodyHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "emit",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "test.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
}),
|
||||
dimensions: {
|
||||
contentWidthPx: 600,
|
||||
contentHeightPx: 900
|
||||
}
|
||||
};
|
||||
|
||||
describe("Electron DOCX 媒体捕获", () => {
|
||||
it("复用隐藏 Chromium 并在捕获后释放调试会话", async () => {
|
||||
let attached = false;
|
||||
const attach = vi.fn(() => {
|
||||
attached = true;
|
||||
});
|
||||
const detach = vi.fn(() => {
|
||||
attached = false;
|
||||
});
|
||||
const sendCommand = vi.fn(async () => ({
|
||||
data: Buffer.from("png").toString("base64")
|
||||
}));
|
||||
const webContents = {
|
||||
executeJavaScript: vi.fn(async () => plan),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => attached),
|
||||
attach,
|
||||
detach,
|
||||
sendCommand
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
const result = await captureDocxMediaWithElectronWebContents(
|
||||
webContents,
|
||||
request
|
||||
);
|
||||
|
||||
expect(result.captures[0]).toEqual({
|
||||
id: "docx-media-1",
|
||||
png: Buffer.from("png")
|
||||
});
|
||||
expect(attach).toHaveBeenCalledWith("1.3");
|
||||
expect(sendCommand).toHaveBeenCalledWith(
|
||||
"Page.captureScreenshot",
|
||||
expect.objectContaining({
|
||||
format: "png",
|
||||
clip: expect.objectContaining({ scale: 3.125 })
|
||||
})
|
||||
);
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||
const webContents = {
|
||||
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => false)
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
await expect(
|
||||
captureDocxMediaWithElectronWebContents(
|
||||
webContents,
|
||||
request,
|
||||
undefined,
|
||||
{ renderMs: 10, screenshotMs: 10 }
|
||||
)
|
||||
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
|
||||
});
|
||||
|
||||
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
|
||||
let attached = false;
|
||||
const detach = vi.fn(() => {
|
||||
attached = false;
|
||||
});
|
||||
const webContents = {
|
||||
executeJavaScript: vi.fn(async () => plan),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => attached),
|
||||
attach: vi.fn(() => {
|
||||
attached = true;
|
||||
}),
|
||||
detach,
|
||||
sendCommand: vi.fn(() => new Promise(() => undefined))
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
await expect(
|
||||
captureDocxMediaWithElectronWebContents(
|
||||
webContents,
|
||||
request,
|
||||
undefined,
|
||||
{ renderMs: 10, screenshotMs: 10 }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
"桌面 DOCX 第 1/1 个媒体(image)PNG 捕获超过 10ms"
|
||||
);
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
|
||||
|
||||
function createWindow() {
|
||||
let destroyed = false;
|
||||
return {
|
||||
showInactive: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("Electron DOCX 捕获合成表面", () => {
|
||||
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
|
||||
const window = createWindow();
|
||||
const operation = vi.fn(async () => "captured");
|
||||
|
||||
await expect(
|
||||
withActiveDocxCaptureSurface(window, operation, 0)
|
||||
).resolves.toBe("captured");
|
||||
|
||||
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||
expect(operation).toHaveBeenCalledOnce();
|
||||
expect(window.hide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("窗口在失败期间销毁时不再隐藏", async () => {
|
||||
const window = createWindow();
|
||||
|
||||
await expect(
|
||||
withActiveDocxCaptureSurface(
|
||||
window,
|
||||
async () => {
|
||||
window.destroy();
|
||||
throw new Error("capture failed");
|
||||
},
|
||||
0
|
||||
)
|
||||
).rejects.toThrow("capture failed");
|
||||
|
||||
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||
expect(window.hide).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
DOCX_STYLE_SLOT_NAMES,
|
||||
type DocxComputedStyle,
|
||||
type DocxThemeStyleCaptureRequest
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { captureDocxThemeStyleWithElectronWebContents } from "../src/electron-docx-theme-style.js";
|
||||
|
||||
const computed: DocxComputedStyle = {
|
||||
fontFamily: "Arial",
|
||||
fontSize: "16px",
|
||||
fontWeight: "400",
|
||||
fontStyle: "normal",
|
||||
color: "rgb(0, 0, 0)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
lineHeight: "normal",
|
||||
letterSpacing: "normal",
|
||||
textAlign: "start",
|
||||
direction: "ltr",
|
||||
textIndent: "0px",
|
||||
textDecorationLine: "none",
|
||||
marginTop: "0px",
|
||||
marginRight: "0px",
|
||||
marginBottom: "0px",
|
||||
marginLeft: "0px",
|
||||
paddingTop: "0px",
|
||||
paddingRight: "0px",
|
||||
paddingBottom: "0px",
|
||||
paddingLeft: "0px",
|
||||
borderTop: "0px none rgb(0, 0, 0)",
|
||||
borderRight: "0px none rgb(0, 0, 0)",
|
||||
borderBottom: "0px none rgb(0, 0, 0)",
|
||||
borderLeft: "0px none rgb(0, 0, 0)",
|
||||
width: "640px",
|
||||
maxWidth: "none",
|
||||
minWidth: "0px",
|
||||
minHeight: "0px",
|
||||
height: "24px",
|
||||
breakBefore: "auto",
|
||||
breakAfter: "auto",
|
||||
breakInside: "auto",
|
||||
display: "block",
|
||||
flexDirection: "row",
|
||||
alignItems: "normal",
|
||||
justifyContent: "normal",
|
||||
outline: "rgb(0, 0, 0) none 0px",
|
||||
outlineOffset: "0px"
|
||||
};
|
||||
|
||||
const request: DocxThemeStyleCaptureRequest = {
|
||||
themeId: "external-clean",
|
||||
themeFingerprint: "b".repeat(64),
|
||||
themeCss: "#write { color: #000; }",
|
||||
baseUrl: "mdpdf://app/"
|
||||
};
|
||||
|
||||
describe("Electron DOCX 主题样式采集", () => {
|
||||
it("通过 CDP 切换打印媒体并在结束后恢复", async () => {
|
||||
let attached = false;
|
||||
const sendCommand = vi.fn(async () => undefined);
|
||||
const webContents = {
|
||||
debugger: {
|
||||
isAttached: () => attached,
|
||||
attach: vi.fn(() => {
|
||||
attached = true;
|
||||
}),
|
||||
detach: vi.fn(() => {
|
||||
attached = false;
|
||||
}),
|
||||
sendCommand
|
||||
},
|
||||
executeJavaScript: vi.fn(async () => ({
|
||||
rootFontSizePx: 16,
|
||||
viewport: {
|
||||
widthPx: 794,
|
||||
heightPx: 1123,
|
||||
deviceScaleFactor: 1
|
||||
},
|
||||
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
|
||||
slot,
|
||||
matched: true,
|
||||
computed
|
||||
}))
|
||||
})),
|
||||
isDestroyed: () => false
|
||||
};
|
||||
const snapshot =
|
||||
await captureDocxThemeStyleWithElectronWebContents(
|
||||
webContents as never,
|
||||
request
|
||||
);
|
||||
expect(sendCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"Emulation.setEmulatedMedia",
|
||||
{ media: "print" }
|
||||
);
|
||||
expect(sendCommand).toHaveBeenLastCalledWith(
|
||||
"Emulation.setEmulatedMedia",
|
||||
{ media: "screen" }
|
||||
);
|
||||
expect(webContents.debugger.detach).toHaveBeenCalledTimes(1);
|
||||
expect(snapshot.slots).toHaveLength(
|
||||
DOCX_STYLE_SLOT_NAMES.length
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMarkdownContentHash,
|
||||
createMarkdownDocumentKey,
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath,
|
||||
readMarkdownDocument,
|
||||
readMarkdownFileSnapshot
|
||||
} from "../src/markdown-file.js";
|
||||
|
||||
describe("桌面 Markdown 文件参数", () => {
|
||||
it("识别两种 Markdown 扩展名且忽略大小写", () => {
|
||||
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
|
||||
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
|
||||
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("从启动参数中提取绝对 Markdown 路径", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
[
|
||||
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
|
||||
"--original-process-start-time=1",
|
||||
"D:\\文档\\项目周报.md"
|
||||
],
|
||||
"C:\\Windows"
|
||||
)
|
||||
).toBe(path.normalize("D:\\文档\\项目周报.md"));
|
||||
});
|
||||
|
||||
it("根据第二实例的工作目录解析相对路径", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
["md-to-pdf.exe", "docs\\readme.markdown"],
|
||||
"D:\\项目"
|
||||
)
|
||||
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
|
||||
});
|
||||
|
||||
it("忽略非 Markdown 文件及 Chromium 参数", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
["md-to-pdf.exe", "--inspect", "notes.txt"],
|
||||
"C:\\项目"
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("统一读取 Markdown 并限制解码后的字符数", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-markdown-")
|
||||
);
|
||||
const filePath = path.join(directory, "示例.md");
|
||||
try {
|
||||
await writeFile(filePath, "# 示例", "utf8");
|
||||
await expect(
|
||||
readMarkdownDocument(filePath, 20)
|
||||
).resolves.toEqual({
|
||||
markdown: "# 示例",
|
||||
fileName: "示例.md"
|
||||
});
|
||||
await expect(
|
||||
readMarkdownDocument(filePath, 2)
|
||||
).rejects.toThrow("Markdown 文件过大");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("拒绝非 Markdown 扩展名", async () => {
|
||||
await expect(
|
||||
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()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createElectronPdfPrintOptions,
|
||||
isAllowedPdfRuntimeUrl,
|
||||
parsePagedDocumentPayload
|
||||
} from "../src/pdf-contract.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
parseFontPackResourceUrl,
|
||||
parseThemeId,
|
||||
parseThemeResourceUrl
|
||||
} from "../src/application-contract.js";
|
||||
|
||||
const payload = createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||
bodyHtml: "<h1>测试</h1>",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
},
|
||||
features: ["mermaid"],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "测试.md",
|
||||
themeCss: "#write { color: black; }",
|
||||
exportConfig: defaultExportConfig
|
||||
});
|
||||
|
||||
describe("桌面 PDF 载荷", () => {
|
||||
it("校验并保留合法的共享分页载荷", () => {
|
||||
expect(parsePagedDocumentPayload(payload)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("拒绝无效功能标识", () => {
|
||||
expect(() =>
|
||||
parsePagedDocumentPayload({
|
||||
...payload,
|
||||
features: ["remote-script"]
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("映射为与 Playwright 对齐的 Electron 打印参数", () => {
|
||||
expect(
|
||||
createElectronPdfPrintOptions(payload, {
|
||||
pageCount: 3,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
timings: {
|
||||
setupMs: 0,
|
||||
echartsMs: 0,
|
||||
echartsFitMs: 0,
|
||||
mermaidMs: 0,
|
||||
mermaidFitMs: 0,
|
||||
mermaidConversionMs: 0,
|
||||
resourceWaitMs: 0,
|
||||
paginationMs: 0,
|
||||
finalizeMs: 0,
|
||||
totalMs: 0
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayHeaderFooter: false,
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
pageRanges: "1-3",
|
||||
preferCSSPageSize: true,
|
||||
printBackground: true,
|
||||
scale: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面 PDF 网络边界", () => {
|
||||
const renderUrl =
|
||||
"http://localhost:5173/preview-frame.html?target=pdf";
|
||||
|
||||
it("允许同源和内嵌资源", () => {
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"http://localhost:5173/assets/runtime.js",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl))
|
||||
.toBe(true);
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"mdpdf://theme/typora-like/font.woff2",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝外部网络资源", () => {
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面应用服务 IPC 载荷", () => {
|
||||
it("只提取 Markdown 渲染所需字段", () => {
|
||||
expect(
|
||||
parseMarkdownRenderRequest({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN",
|
||||
ignored: true
|
||||
})
|
||||
).toEqual({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN"
|
||||
});
|
||||
});
|
||||
|
||||
it("校验主题 ID 与主题资源地址", () => {
|
||||
expect(parseThemeId("typora-like")).toBe("typora-like");
|
||||
expect(
|
||||
parseThemeResourceUrl(
|
||||
"mdpdf://theme/typora-like/fonts/test.woff2"
|
||||
)
|
||||
).toEqual({
|
||||
themeId: "typora-like",
|
||||
assetPath: "fonts/test.woff2"
|
||||
});
|
||||
expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效");
|
||||
expect(
|
||||
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("字体包资源地址无效");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadWindowState,
|
||||
parseWindowState,
|
||||
resolveWindowBounds,
|
||||
saveWindowState
|
||||
} from "../src/window-state.js";
|
||||
|
||||
const primaryWorkArea = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1040
|
||||
};
|
||||
const defaultSize = { width: 1440, height: 960 };
|
||||
const minimumSize = { width: 1024, height: 720 };
|
||||
|
||||
describe("桌面窗口状态", () => {
|
||||
it("校验并规范化持久化状态", () => {
|
||||
expect(
|
||||
parseWindowState({
|
||||
version: 1,
|
||||
bounds: {
|
||||
x: 120.4,
|
||||
y: 80.6,
|
||||
width: 1280.2,
|
||||
height: 800.8
|
||||
},
|
||||
maximized: true
|
||||
})
|
||||
).toEqual({
|
||||
version: 1,
|
||||
bounds: {
|
||||
x: 120,
|
||||
y: 81,
|
||||
width: 1280,
|
||||
height: 801
|
||||
},
|
||||
maximized: true
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝损坏或不受支持的状态", () => {
|
||||
expect(parseWindowState(undefined)).toBeUndefined();
|
||||
expect(
|
||||
parseWindowState({
|
||||
version: 2,
|
||||
bounds: primaryWorkArea,
|
||||
maximized: false
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseWindowState({
|
||||
version: 1,
|
||||
bounds: { x: 0, y: 0, width: -1, height: 720 },
|
||||
maximized: false
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("首次启动时在主显示器居中", () => {
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
undefined,
|
||||
[primaryWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 240,
|
||||
y: 40,
|
||||
width: 1440,
|
||||
height: 960
|
||||
});
|
||||
});
|
||||
|
||||
it("将部分溢出的窗口完整收回原显示器", () => {
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
{ x: 1700, y: 900, width: 1280, height: 800 },
|
||||
[primaryWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 640,
|
||||
y: 240,
|
||||
width: 1280,
|
||||
height: 800
|
||||
});
|
||||
});
|
||||
|
||||
it("显示器移除后在主显示器居中恢复", () => {
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
{ x: 3000, y: 120, width: 1200, height: 780 },
|
||||
[primaryWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 360,
|
||||
y: 130,
|
||||
width: 1200,
|
||||
height: 780
|
||||
});
|
||||
});
|
||||
|
||||
it("窗口大于工作区时缩小到可完整展示", () => {
|
||||
const compactWorkArea = {
|
||||
x: 1920,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 680
|
||||
};
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
{ x: 2000, y: -100, width: 1800, height: 1000 },
|
||||
[primaryWorkArea, compactWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 1920,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 680
|
||||
});
|
||||
});
|
||||
|
||||
it("通过临时文件原子保存并可重新载入", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-window-")
|
||||
);
|
||||
const filePath = path.join(directory, "window-state.json");
|
||||
const state = {
|
||||
version: 1 as const,
|
||||
bounds: { x: 10, y: 20, width: 1200, height: 800 },
|
||||
maximized: false
|
||||
};
|
||||
try {
|
||||
await saveWindowState(filePath, state);
|
||||
await expect(loadWindowState(filePath)).resolves.toEqual(state);
|
||||
const updatedState = {
|
||||
...state,
|
||||
bounds: { x: 30, y: 40, width: 1280, height: 900 },
|
||||
maximized: true
|
||||
};
|
||||
await saveWindowState(filePath, updatedState);
|
||||
await expect(loadWindowState(filePath)).resolves.toEqual(
|
||||
updatedState
|
||||
);
|
||||
expect(await readdir(directory)).toEqual(["window-state.json"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
@@ -8,11 +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"
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+278
-135
@@ -4,16 +4,31 @@ import { performance } from "node:perf_hooks";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
EXPORT_CONFIG_VERSION,
|
||||
DOCX_MIME_TYPE,
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
exportConfigSchema,
|
||||
supportedPaperFormats
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
MarkdownDocumentParseError,
|
||||
RENDERER_VERSION,
|
||||
renderMarkdown
|
||||
} from "@md-to-pdf/renderer";
|
||||
ApplicationRequestError,
|
||||
DocxExportService,
|
||||
DocxExportServiceError,
|
||||
DocxThemeTokenService,
|
||||
createApplicationService,
|
||||
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,
|
||||
@@ -21,11 +36,11 @@ import {
|
||||
PdfRenderTimeoutError,
|
||||
type PdfGenerator
|
||||
} from "./pdf-engine.js";
|
||||
import { createThemeRegistry } from "./theme-registry.js";
|
||||
|
||||
interface RenderRequestBody {
|
||||
markdown?: unknown;
|
||||
language?: unknown;
|
||||
resources?: unknown;
|
||||
}
|
||||
|
||||
interface PdfRequestBody extends RenderRequestBody {
|
||||
@@ -34,66 +49,6 @@ interface PdfRequestBody extends RenderRequestBody {
|
||||
}
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const maximumMarkdownLength = 1_500_000;
|
||||
|
||||
function validateMarkdownRequest(
|
||||
body: RenderRequestBody | undefined
|
||||
):
|
||||
| { markdown: string; language?: string }
|
||||
| { statusCode: 400 | 413; error: string; message: string } {
|
||||
const { markdown, language } = body ?? {};
|
||||
|
||||
if (typeof markdown !== "string") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: "INVALID_MARKDOWN",
|
||||
message: "markdown 必须是字符串"
|
||||
};
|
||||
}
|
||||
if (markdown.length > maximumMarkdownLength) {
|
||||
return {
|
||||
statusCode: 413,
|
||||
error: "MARKDOWN_TOO_LARGE",
|
||||
message: "Markdown 内容不能超过 1.5 MB"
|
||||
};
|
||||
}
|
||||
if (language !== undefined && typeof language !== "string") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: "INVALID_LANGUAGE",
|
||||
message: "language 必须是字符串"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
markdown,
|
||||
...(typeof language === "string" ? { language } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdownRequest(
|
||||
markdown: string,
|
||||
language: string | undefined
|
||||
) {
|
||||
try {
|
||||
return {
|
||||
success: true as const,
|
||||
document: renderMarkdown(markdown, {
|
||||
...(language ? { language } : {})
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownDocumentParseError) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: error.code,
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRfc5987(value: string) {
|
||||
return encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
@@ -127,6 +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) {
|
||||
@@ -164,19 +127,65 @@ 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,
|
||||
bodyLimit: 2 * 1024 * 1024
|
||||
});
|
||||
const themes = createThemeRegistry({
|
||||
bundledRoot: resolve(projectRoot, "themes"),
|
||||
localRoot:
|
||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||
onWarning: (message) => app.log.warn(message)
|
||||
bodyLimit: 24 * 1024 * 1024
|
||||
});
|
||||
const applicationService =
|
||||
options.applicationService ??
|
||||
createApplicationService({
|
||||
bundledRoot: resolve(projectRoot, "themes"),
|
||||
localRoot:
|
||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||
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 () => {
|
||||
@@ -188,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 () => ({
|
||||
@@ -199,37 +227,39 @@ 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/themes", async () => ({
|
||||
themes: (await themes.list()).map(({ manifest, source }) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
bundled: manifest.bundled,
|
||||
source
|
||||
}))
|
||||
}));
|
||||
app.get("/api/docx/capability", async () =>
|
||||
docxExportService.getCapability()
|
||||
);
|
||||
|
||||
app.get("/api/themes", async () =>
|
||||
applicationService.listThemes()
|
||||
);
|
||||
|
||||
app.get<{ Params: { themeId: string } }>(
|
||||
"/api/themes/:themeId/css",
|
||||
async (request, reply) => {
|
||||
const { themeId } = request.params;
|
||||
|
||||
const css = await themes.getCss(themeId);
|
||||
const css = await applicationService.getThemeCss(themeId);
|
||||
if (css === undefined) {
|
||||
return reply.code(404).send({
|
||||
error: "THEME_NOT_FOUND",
|
||||
@@ -248,7 +278,7 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
"/api/themes/:themeId/assets/*",
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const asset = await themes.getAsset(
|
||||
const asset = await applicationService.getThemeAsset(
|
||||
request.params.themeId,
|
||||
request.params["*"]
|
||||
);
|
||||
@@ -274,28 +304,58 @@ 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) => {
|
||||
const validated = validateMarkdownRequest(request.body);
|
||||
if ("statusCode" in validated) {
|
||||
return reply.code(validated.statusCode).send({
|
||||
error: validated.error,
|
||||
message: validated.message
|
||||
});
|
||||
try {
|
||||
return await applicationService.render(request.body ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rendered = renderMarkdownRequest(
|
||||
validated.markdown,
|
||||
validated.language
|
||||
);
|
||||
if (!rendered.success) {
|
||||
return reply.code(400).send({
|
||||
error: rendered.error,
|
||||
message: rendered.message
|
||||
});
|
||||
}
|
||||
return rendered.document;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -303,14 +363,6 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
"/api/pdf",
|
||||
async (request, reply) => {
|
||||
const requestStartedAt = performance.now();
|
||||
const validated = validateMarkdownRequest(request.body);
|
||||
if ("statusCode" in validated) {
|
||||
return reply.code(validated.statusCode).send({
|
||||
error: validated.error,
|
||||
message: validated.message
|
||||
});
|
||||
}
|
||||
|
||||
const parsedConfig = exportConfigSchema.safeParse(
|
||||
request.body?.exportConfig
|
||||
);
|
||||
@@ -333,7 +385,9 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
|
||||
const themeStartedAt = performance.now();
|
||||
const themeCss = await themes.getCss(parsedConfig.data.themeId);
|
||||
const themeCss = await applicationService.getThemeCss(
|
||||
parsedConfig.data.themeId
|
||||
);
|
||||
const themeMs = performance.now() - themeStartedAt;
|
||||
if (themeCss === undefined) {
|
||||
return reply.code(404).send({
|
||||
@@ -343,18 +397,19 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
|
||||
const markdownStartedAt = performance.now();
|
||||
const renderedResult = renderMarkdownRequest(
|
||||
validated.markdown,
|
||||
validated.language
|
||||
);
|
||||
const markdownMs = performance.now() - markdownStartedAt;
|
||||
if (!renderedResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: renderedResult.error,
|
||||
message: renderedResult.message
|
||||
});
|
||||
let rendered;
|
||||
try {
|
||||
rendered = await applicationService.render(request.body ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const rendered = renderedResult.document;
|
||||
const markdownMs = performance.now() - markdownStartedAt;
|
||||
|
||||
try {
|
||||
const generated = await pdfGenerator.generate(
|
||||
@@ -440,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { chromium, type Browser, type BrowserContext } from "playwright";
|
||||
import type {
|
||||
DocxMediaCaptureAdapter,
|
||||
DocxMediaCaptureRequest
|
||||
} from "@md-to-pdf/application";
|
||||
import type {
|
||||
DocxThemeStyleCaptureAdapter,
|
||||
DocxThemeStyleCaptureRequest
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
|
||||
import { captureDocxThemeStyleWithPlaywrightPage } from "./playwright-docx-theme-style.js";
|
||||
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
||||
|
||||
export interface ServerDocxMediaCaptureAdapter
|
||||
extends DocxMediaCaptureAdapter,
|
||||
DocxThemeStyleCaptureAdapter {
|
||||
warmup?(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlaywrightDocxMediaEngineOptions {
|
||||
renderOrigin?: string;
|
||||
launchBrowser?: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
function normalizeRenderOrigin(value: string) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
async function closeContext(context: BrowserContext) {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export class PlaywrightDocxMediaEngine
|
||||
implements ServerDocxMediaCaptureAdapter
|
||||
{
|
||||
readonly themeStyleBaseUrl: string;
|
||||
private readonly renderOrigin: string;
|
||||
private readonly renderUrl: string;
|
||||
private readonly launchBrowser: () => Promise<Browser>;
|
||||
private browserPromise: Promise<Browser> | undefined;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
|
||||
this.renderOrigin = normalizeRenderOrigin(
|
||||
options.renderOrigin ??
|
||||
process.env.PDF_RENDER_ORIGIN ??
|
||||
"http://localhost:5173"
|
||||
);
|
||||
this.renderUrl = new URL(
|
||||
"/preview-frame.html?target=continuous",
|
||||
this.renderOrigin
|
||||
).href;
|
||||
this.themeStyleBaseUrl = this.renderUrl;
|
||||
this.launchBrowser =
|
||||
options.launchBrowser ??
|
||||
(() =>
|
||||
chromium.launch({
|
||||
headless: true
|
||||
}));
|
||||
}
|
||||
|
||||
async warmup() {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
await this.getBrowser();
|
||||
}
|
||||
|
||||
async capture(
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const browser = await this.getBrowser();
|
||||
signal?.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
locale: request.payload.metadata.language || "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
const abort = () => {
|
||||
void closeContext(context);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
await context.route("**/*", async (route) => {
|
||||
if (
|
||||
isAllowedPdfRequestUrl(
|
||||
route.request().url(),
|
||||
this.renderOrigin
|
||||
)
|
||||
) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.abort("blockedbyclient");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(this.renderUrl, {
|
||||
waitUntil: "domcontentloaded"
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.documentElement.dataset.runtimeReady === "true"
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return await captureDocxMediaWithPlaywrightPage(
|
||||
page,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await closeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async captureThemeStyle(
|
||||
request: DocxThemeStyleCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (this.closed) {
|
||||
throw new Error("DOCX 媒体服务已关闭");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (new URL(request.baseUrl).origin !== this.renderOrigin) {
|
||||
throw new Error("DOCX 主题样式资源地址必须与渲染地址同源");
|
||||
}
|
||||
|
||||
const browser = await this.getBrowser();
|
||||
signal?.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
const abort = () => {
|
||||
void closeContext(context);
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
await context.route("**/*", async (route) => {
|
||||
if (
|
||||
isAllowedPdfRequestUrl(
|
||||
route.request().url(),
|
||||
this.renderOrigin
|
||||
)
|
||||
) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.abort("blockedbyclient");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(this.renderUrl, {
|
||||
waitUntil: "domcontentloaded"
|
||||
});
|
||||
return await captureDocxThemeStyleWithPlaywrightPage(
|
||||
page,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
await closeContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
const browserPromise = this.browserPromise;
|
||||
this.browserPromise = undefined;
|
||||
if (!browserPromise) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const browser = await browserPromise;
|
||||
await browser.close();
|
||||
} catch {
|
||||
// 浏览器启动失败时没有可关闭的实例。
|
||||
}
|
||||
}
|
||||
|
||||
private getBrowser() {
|
||||
if (!this.browserPromise) {
|
||||
const browserPromise = this.launchBrowser();
|
||||
this.browserPromise = browserPromise;
|
||||
void browserPromise
|
||||
.then((browser) => {
|
||||
browser.on("disconnected", () => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.browserPromise === browserPromise) {
|
||||
this.browserPromise = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.browserPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDocxMediaEngine(
|
||||
options: PlaywrightDocxMediaEngineOptions = {}
|
||||
) {
|
||||
return new PlaywrightDocxMediaEngine(options);
|
||||
}
|
||||
@@ -2,8 +2,18 @@ import { buildApp } from "./app.js";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||
const 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);
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
import {
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
stat
|
||||
} from "node:fs/promises";
|
||||
import {
|
||||
extname,
|
||||
isAbsolute,
|
||||
posix,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from "node:path";
|
||||
import {
|
||||
themeManifestSchema,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface ThemeRecord {
|
||||
manifest: ThemeManifest;
|
||||
directory: string;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
export interface ThemeRegistryOptions {
|
||||
bundledRoot: string;
|
||||
localRoot: string;
|
||||
onWarning?: (message: string) => void;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const assetContentTypes: Record<string, string> = {
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".ttf": "font/ttf",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2"
|
||||
};
|
||||
|
||||
const cssImportPattern =
|
||||
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
|
||||
const maximumCssImportDepth = 8;
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideDirectory(directory: string, path: string) {
|
||||
const pathFromDirectory = relative(directory, path);
|
||||
return (
|
||||
pathFromDirectory === "" ||
|
||||
(!pathFromDirectory.startsWith(`..${sep}`) &&
|
||||
pathFromDirectory !== ".." &&
|
||||
!isAbsolute(pathFromDirectory))
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveThemeFile(directory: string, relativePath: string) {
|
||||
if (
|
||||
isAbsolute(relativePath) ||
|
||||
relativePath.includes("\0") ||
|
||||
relativePath.split(/[\\/]/).includes("..")
|
||||
) {
|
||||
throw new Error("主题资源路径不安全");
|
||||
}
|
||||
|
||||
const realDirectory = await realpath(directory);
|
||||
const candidate = resolve(realDirectory, relativePath);
|
||||
if (!isInsideDirectory(realDirectory, candidate)) {
|
||||
throw new Error("主题资源路径越界");
|
||||
}
|
||||
|
||||
const realCandidate = await realpath(candidate);
|
||||
if (!isInsideDirectory(realDirectory, realCandidate)) {
|
||||
throw new Error("主题资源符号链接越界");
|
||||
}
|
||||
|
||||
const fileStat = await stat(realCandidate);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error("主题资源不是文件");
|
||||
}
|
||||
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
async function readThemeRoot(
|
||||
root: string,
|
||||
source: ThemeRecord["source"],
|
||||
onWarning?: (message: string) => void
|
||||
): Promise<ThemeRecord[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error) && source === "local") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records: ThemeRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const directory = resolve(root, entry.name);
|
||||
try {
|
||||
const manifestPath = await resolveThemeFile(directory, "theme.json");
|
||||
const manifest = themeManifestSchema.parse(
|
||||
JSON.parse(await readFile(manifestPath, "utf8"))
|
||||
);
|
||||
|
||||
if (manifest.id !== entry.name) {
|
||||
throw new Error("主题目录名必须与主题 ID 一致");
|
||||
}
|
||||
if (manifest.bundled !== (source === "bundled")) {
|
||||
throw new Error("主题 bundled 标记与来源不一致");
|
||||
}
|
||||
|
||||
if (manifest.base) {
|
||||
await resolveThemeFile(directory, manifest.base);
|
||||
}
|
||||
await resolveThemeFile(directory, manifest.entry);
|
||||
if (manifest.print) {
|
||||
await resolveThemeFile(directory, manifest.print);
|
||||
}
|
||||
|
||||
records.push({
|
||||
manifest,
|
||||
directory,
|
||||
source
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (source === "local") {
|
||||
onWarning?.(`已忽略无效本地主题 ${entry.name}:${message}`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`主题 ${entry.name} 无效:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function encodeAssetPath(path: string) {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function prepareCssSegment(
|
||||
css: string,
|
||||
themeId: string,
|
||||
cssDirectory: string
|
||||
) {
|
||||
return css.replace(
|
||||
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
||||
(match, _quote: string, rawValue: string) => {
|
||||
const value = rawValue.trim();
|
||||
if (value.startsWith("data:") || value.startsWith("#")) {
|
||||
return match;
|
||||
}
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
||||
) {
|
||||
throw new Error(`主题包含不允许的外部资源:${value}`);
|
||||
}
|
||||
|
||||
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
if (normalized.split("/").includes("..")) {
|
||||
throw new Error(`主题资源路径不安全:${value}`);
|
||||
}
|
||||
|
||||
const assetPath = posix.normalize(posix.join(cssDirectory, normalized));
|
||||
if (
|
||||
assetPath === ".." ||
|
||||
assetPath.startsWith("../") ||
|
||||
assetPath.startsWith("/")
|
||||
) {
|
||||
throw new Error(`主题资源路径越界:${value}`);
|
||||
}
|
||||
|
||||
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}")`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readImportedPath(match: RegExpMatchArray) {
|
||||
return match.slice(1).find((value): value is string => value !== undefined);
|
||||
}
|
||||
|
||||
function normalizeCssPath(relativePath: string) {
|
||||
const normalized = relativePath.replaceAll("\\", "/");
|
||||
if (
|
||||
normalized.startsWith("/") ||
|
||||
normalized.split("/").includes("..") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(normalized)
|
||||
) {
|
||||
throw new Error(`主题 CSS 路径不安全:${relativePath}`);
|
||||
}
|
||||
|
||||
return posix.normalize(normalized.replace(/^\.\//, ""));
|
||||
}
|
||||
|
||||
async function loadThemeCssFile(
|
||||
theme: ThemeRecord,
|
||||
relativePath: string,
|
||||
importStack: string[] = []
|
||||
): Promise<string> {
|
||||
const normalizedPath = normalizeCssPath(relativePath);
|
||||
if (importStack.includes(normalizedPath)) {
|
||||
throw new Error(
|
||||
`主题 CSS 存在循环引用:${[...importStack, normalizedPath].join(" -> ")}`
|
||||
);
|
||||
}
|
||||
if (importStack.length >= maximumCssImportDepth) {
|
||||
throw new Error(`主题 CSS 导入层级超过 ${maximumCssImportDepth} 层`);
|
||||
}
|
||||
|
||||
const cssPath = await resolveThemeFile(theme.directory, normalizedPath);
|
||||
const css = (await readFile(cssPath, "utf8")).replace(
|
||||
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
|
||||
""
|
||||
);
|
||||
const cssDirectory = posix.dirname(normalizedPath);
|
||||
const matches = [...css.matchAll(cssImportPattern)];
|
||||
let result = "";
|
||||
let previousEnd = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
const matchStart = match.index ?? 0;
|
||||
result += prepareCssSegment(
|
||||
css.slice(previousEnd, matchStart),
|
||||
theme.manifest.id,
|
||||
cssDirectory
|
||||
);
|
||||
|
||||
const importedPath = readImportedPath(match);
|
||||
if (!importedPath) {
|
||||
throw new Error(`无法解析主题 CSS 导入:${match[0]}`);
|
||||
}
|
||||
const normalizedImport = normalizeCssPath(importedPath);
|
||||
const importPath = posix.normalize(posix.join(cssDirectory, normalizedImport));
|
||||
result += await loadThemeCssFile(theme, importPath, [
|
||||
...importStack,
|
||||
normalizedPath
|
||||
]);
|
||||
previousEnd = matchStart + match[0].length;
|
||||
}
|
||||
|
||||
const remainder = css.slice(previousEnd);
|
||||
result += prepareCssSegment(
|
||||
remainder,
|
||||
theme.manifest.id,
|
||||
cssDirectory
|
||||
);
|
||||
if (/@import\b/i.test(result)) {
|
||||
throw new Error("主题包含不支持的 CSS @import 语法");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
|
||||
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
||||
throw new Error("主题缓存有效期必须是非负有限数值");
|
||||
}
|
||||
let cachedThemes:
|
||||
| {
|
||||
expiresAt: number;
|
||||
promise: Promise<ThemeRecord[]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
async function scanThemes() {
|
||||
const [bundledThemes, localThemes] = await Promise.all([
|
||||
readThemeRoot(options.bundledRoot, "bundled"),
|
||||
readThemeRoot(options.localRoot, "local", options.onWarning)
|
||||
]);
|
||||
|
||||
const themes = [...bundledThemes, ...localThemes];
|
||||
const themeIds = new Set<string>();
|
||||
for (const theme of themes) {
|
||||
if (themeIds.has(theme.manifest.id)) {
|
||||
throw new Error(`主题 ID 重复:${theme.manifest.id}`);
|
||||
}
|
||||
themeIds.add(theme.manifest.id);
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
function list() {
|
||||
const now = Date.now();
|
||||
if (cachedThemes && now < cachedThemes.expiresAt) {
|
||||
return cachedThemes.promise;
|
||||
}
|
||||
|
||||
const promise = scanThemes();
|
||||
cachedThemes = {
|
||||
expiresAt: now + cacheTtlMs,
|
||||
promise
|
||||
};
|
||||
void promise.catch(() => {
|
||||
if (cachedThemes?.promise === promise) {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
|
||||
async function get(themeId: string) {
|
||||
return (await list()).find((theme) => theme.manifest.id === themeId);
|
||||
}
|
||||
|
||||
async function getCss(themeId: string) {
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cssFiles = [
|
||||
...(theme.manifest.base ? [theme.manifest.base] : []),
|
||||
theme.manifest.entry,
|
||||
...(theme.manifest.print
|
||||
? [theme.manifest.print]
|
||||
: [])
|
||||
];
|
||||
return (
|
||||
await Promise.all(
|
||||
cssFiles.map((path) => loadThemeCssFile(theme, path))
|
||||
)
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
async function getAsset(themeId: string, assetPath: string) {
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const extension = extname(assetPath).toLowerCase();
|
||||
const contentType = assetContentTypes[extension];
|
||||
if (!contentType) {
|
||||
throw new Error("不支持的主题资源类型");
|
||||
}
|
||||
|
||||
const path = await resolveThemeFile(theme.directory, assetPath);
|
||||
return {
|
||||
contentType,
|
||||
content: await readFile(path)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
getAsset,
|
||||
getCss,
|
||||
invalidate,
|
||||
list
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,13 @@ 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,
|
||||
createPdfFileName
|
||||
} from "../src/app.js";
|
||||
import { createThemeRegistry } from "../src/theme-registry.js";
|
||||
import { createThemeRegistry } from "@md-to-pdf/application";
|
||||
import {
|
||||
PdfEngineOverloadedError,
|
||||
type PdfGenerator
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
DocxExportServiceError,
|
||||
type DocxMediaCaptureAdapter
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
defaultExportConfig,
|
||||
type DocxCapability,
|
||||
type DocxExportResult
|
||||
} from "@md-to-pdf/core";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import type { PdfGenerator } from "../src/pdf-engine.js";
|
||||
import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js";
|
||||
|
||||
let app: FastifyInstance | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
});
|
||||
|
||||
const availableCapability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "available",
|
||||
expectedVersion: "3.9.0.2",
|
||||
detectedVersion: "3.9.0.2"
|
||||
};
|
||||
|
||||
function generatedDocx(): DocxExportResult {
|
||||
return {
|
||||
docx: new Uint8Array([80, 75, 3, 4]),
|
||||
fileName: "测试报告.docx",
|
||||
diagnostics: {
|
||||
warnings: ["字体可能被替换"],
|
||||
echartsErrors: ["图表 1"],
|
||||
mermaidErrors: []
|
||||
},
|
||||
timings: {
|
||||
queueMs: 1,
|
||||
probeMs: 2,
|
||||
prepareMs: 3,
|
||||
themeStyleMs: 4,
|
||||
mediaMs: 5,
|
||||
referenceMs: 6,
|
||||
pandocMs: 7,
|
||||
validationMs: 8,
|
||||
totalMs: 36
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDocxService(
|
||||
overrides: {
|
||||
capability?: DocxCapability;
|
||||
generate?: (request: unknown) => Promise<DocxExportResult>;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
getCapability: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
overrides.capability ?? availableCapability
|
||||
),
|
||||
generate: vi.fn(
|
||||
overrides.generate ?? (async () => generatedDocx())
|
||||
),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaAdapter() {
|
||||
return {
|
||||
themeStyleBaseUrl:
|
||||
"http://localhost:5173/preview-frame.html",
|
||||
capture: vi.fn<
|
||||
DocxMediaCaptureAdapter["capture"]
|
||||
>(async () => ({
|
||||
plan: {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
},
|
||||
captures: []
|
||||
})),
|
||||
captureThemeStyle: vi.fn(async () => {
|
||||
throw new Error("此测试使用注入的 DOCX 导出服务");
|
||||
}),
|
||||
close: vi.fn(async () => undefined)
|
||||
} satisfies ServerDocxMediaCaptureAdapter;
|
||||
}
|
||||
|
||||
function createPdfGenerator() {
|
||||
return {
|
||||
generate: vi.fn(),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as PdfGenerator;
|
||||
}
|
||||
|
||||
function createTestApp(
|
||||
service = createDocxService(),
|
||||
mediaAdapter = createMediaAdapter()
|
||||
) {
|
||||
app = buildApp({
|
||||
logger: false,
|
||||
pdfGenerator: createPdfGenerator(),
|
||||
docxExportService: service,
|
||||
docxMediaAdapter: mediaAdapter
|
||||
});
|
||||
return { app, service, mediaAdapter };
|
||||
}
|
||||
|
||||
describe("DOCX HTTP API", () => {
|
||||
it("在统一 capability 中报告 DOCX 运行时状态", async () => {
|
||||
const { app: instance, service } = createTestApp();
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "GET",
|
||||
url: "/api/capabilities"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().docx).toEqual(availableCapability);
|
||||
expect(response.json().implemented).toContain("docx-export");
|
||||
expect(service.getCapability).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("通过独立端点返回不可用 capability", async () => {
|
||||
const capability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "not-found",
|
||||
expectedVersion: "3.9.0.2",
|
||||
message: "未找到 Pandoc 运行时"
|
||||
};
|
||||
const { app: instance } = createTestApp(
|
||||
createDocxService({ capability })
|
||||
);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "GET",
|
||||
url: "/api/docx/capability"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(capability);
|
||||
});
|
||||
|
||||
it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => {
|
||||
const { app: instance, service, mediaAdapter } =
|
||||
createTestApp();
|
||||
const payload = {
|
||||
markdown: "# 测试报告",
|
||||
fileName: "目录/测试报告.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain(
|
||||
DOCX_MIME_TYPE
|
||||
);
|
||||
expect(response.headers["content-disposition"]).toContain(
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx"
|
||||
);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.headers["x-docx-warning-count"]).toBe("1");
|
||||
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||
expect(response.headers["x-mermaid-error-count"]).toBe("0");
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"runtime-probe;dur=2.0"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"pandoc;dur=7.0"
|
||||
);
|
||||
expect(response.rawPayload).toEqual(
|
||||
Buffer.from([80, 75, 3, 4])
|
||||
);
|
||||
expect(service.generate).toHaveBeenCalledWith(payload, {
|
||||
mediaAdapter,
|
||||
signal: expect.any(AbortSignal)
|
||||
});
|
||||
});
|
||||
|
||||
it("映射应用请求错误", async () => {
|
||||
const service = createDocxService({
|
||||
generate: async () => {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
"INVALID_EXPORT_CONFIG",
|
||||
"导出配置无效"
|
||||
);
|
||||
}
|
||||
});
|
||||
const { app: instance } = createTestApp(service);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({
|
||||
error: "INVALID_EXPORT_CONFIG",
|
||||
message: "导出配置无效",
|
||||
retryable: false
|
||||
});
|
||||
});
|
||||
|
||||
it("映射可重试的队列错误", async () => {
|
||||
const service = createDocxService({
|
||||
generate: async () => {
|
||||
throw new DocxExportServiceError(
|
||||
429,
|
||||
"DOCX_QUEUE_FULL",
|
||||
"DOCX 生成队列已满",
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
const { app: instance } = createTestApp(service);
|
||||
|
||||
const response = await instance.inject({
|
||||
method: "POST",
|
||||
url: "/api/docx",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(429);
|
||||
expect(response.headers["retry-after"]).toBe("5");
|
||||
expect(response.json()).toEqual({
|
||||
error: "DOCX_QUEUE_FULL",
|
||||
message: "DOCX 生成队列已满",
|
||||
retryable: true
|
||||
});
|
||||
});
|
||||
|
||||
it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => {
|
||||
const service = createDocxService();
|
||||
const mediaAdapter = createMediaAdapter();
|
||||
const { app: instance } = createTestApp(
|
||||
service,
|
||||
mediaAdapter
|
||||
);
|
||||
|
||||
await instance.close();
|
||||
|
||||
expect(service.close).toHaveBeenCalledTimes(1);
|
||||
expect(mediaAdapter.close).toHaveBeenCalledTimes(1);
|
||||
app = undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Browser } from "playwright";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
type DocxMediaCapturePlan
|
||||
} from "@md-to-pdf/core";
|
||||
import { PlaywrightDocxMediaEngine } from "../src/docx-media-engine.js";
|
||||
|
||||
const emptyPlan: DocxMediaCapturePlan = {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
};
|
||||
|
||||
const request = {
|
||||
payload: createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"></article>',
|
||||
bodyHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "test.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
}),
|
||||
dimensions: {
|
||||
contentWidthPx: 600,
|
||||
contentHeightPx: 900
|
||||
}
|
||||
};
|
||||
|
||||
describe("Server DOCX 媒体浏览器", () => {
|
||||
it("使用请求级上下文并阻止非同源网络访问", async () => {
|
||||
const continueRoute = vi.fn(async () => undefined);
|
||||
const abortRoute = vi.fn(async () => undefined);
|
||||
const detach = vi.fn(async () => undefined);
|
||||
const page = {
|
||||
goto: vi.fn(async () => undefined),
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => emptyPlan),
|
||||
context: () => context
|
||||
};
|
||||
const context = {
|
||||
route: vi.fn(
|
||||
async (
|
||||
_pattern: string,
|
||||
handler: (route: {
|
||||
request(): { url(): string };
|
||||
continue(): Promise<void>;
|
||||
abort(reason: string): Promise<void>;
|
||||
}) => Promise<void>
|
||||
) => {
|
||||
await handler({
|
||||
request: () => ({
|
||||
url: () => "http://renderer.test/assets/runtime.js"
|
||||
}),
|
||||
continue: continueRoute,
|
||||
abort: abortRoute
|
||||
});
|
||||
await handler({
|
||||
request: () => ({
|
||||
url: () => "https://external.test/tracker.js"
|
||||
}),
|
||||
continue: continueRoute,
|
||||
abort: abortRoute
|
||||
});
|
||||
}
|
||||
),
|
||||
newPage: vi.fn(async () => page),
|
||||
newCDPSession: vi.fn(async () => ({
|
||||
send: vi.fn(),
|
||||
detach
|
||||
})),
|
||||
close: vi.fn(async () => undefined)
|
||||
};
|
||||
const browser = {
|
||||
newContext: vi.fn(async () => context),
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as Browser;
|
||||
const engine = new PlaywrightDocxMediaEngine({
|
||||
renderOrigin: "http://renderer.test",
|
||||
launchBrowser: vi.fn(async () => browser)
|
||||
});
|
||||
|
||||
const result = await engine.capture(request);
|
||||
|
||||
expect(result.plan).toEqual(emptyPlan);
|
||||
expect(browser.newContext).toHaveBeenCalledWith({
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block"
|
||||
});
|
||||
expect(page.goto).toHaveBeenCalledWith(
|
||||
"http://renderer.test/preview-frame.html?target=continuous",
|
||||
{ waitUntil: "domcontentloaded" }
|
||||
);
|
||||
expect(continueRoute).toHaveBeenCalledTimes(1);
|
||||
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
expect(context.close).toHaveBeenCalledOnce();
|
||||
|
||||
await engine.close();
|
||||
expect(browser.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("关闭后拒绝预热和媒体捕获", async () => {
|
||||
const engine = new PlaywrightDocxMediaEngine({
|
||||
renderOrigin: "http://renderer.test",
|
||||
launchBrowser: vi.fn()
|
||||
});
|
||||
|
||||
await engine.close();
|
||||
|
||||
await expect(engine.warmup()).rejects.toThrow(
|
||||
"DOCX 媒体服务已关闭"
|
||||
);
|
||||
await expect(engine.capture(request)).rejects.toThrow(
|
||||
"DOCX 媒体服务已关闭"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Page } from "playwright";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
type DocxMediaCapturePlan
|
||||
} from "@md-to-pdf/core";
|
||||
import { captureDocxMediaWithPlaywrightPage } from "../src/playwright-docx-media-capture.js";
|
||||
|
||||
const plan: DocxMediaCapturePlan = {
|
||||
targets: [
|
||||
{
|
||||
id: "docx-media-1",
|
||||
kind: "echarts",
|
||||
ordinal: 1,
|
||||
kindOrdinal: 1,
|
||||
altText: "图表",
|
||||
alignment: "center",
|
||||
displayWidthPx: 320,
|
||||
displayHeightPx: 180,
|
||||
captureX: 10,
|
||||
captureY: 20,
|
||||
captureWidthPx: 320,
|
||||
captureHeightPx: 180,
|
||||
rasterScale: 3.125
|
||||
}
|
||||
],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
};
|
||||
|
||||
const request = {
|
||||
payload: createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"></article>',
|
||||
bodyHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "test.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
}),
|
||||
dimensions: {
|
||||
contentWidthPx: 600,
|
||||
contentHeightPx: 900
|
||||
}
|
||||
};
|
||||
|
||||
describe("Playwright DOCX 媒体捕获", () => {
|
||||
it("按运行时计划以目标倍率捕获 PNG", async () => {
|
||||
const detach = vi.fn(async () => undefined);
|
||||
const send = vi.fn(async () => ({
|
||||
data: Buffer.from("png").toString("base64")
|
||||
}));
|
||||
const page = {
|
||||
evaluate: vi.fn(async () => plan),
|
||||
context: () => ({
|
||||
newCDPSession: vi.fn(async () => ({ send, detach }))
|
||||
})
|
||||
} as unknown as Page;
|
||||
|
||||
const result = await captureDocxMediaWithPlaywrightPage(
|
||||
page,
|
||||
request
|
||||
);
|
||||
|
||||
expect(result.plan).toBe(plan);
|
||||
expect(result.captures[0]).toEqual({
|
||||
id: "docx-media-1",
|
||||
png: Buffer.from("png")
|
||||
});
|
||||
expect(send).toHaveBeenCalledWith("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 180,
|
||||
scale: 3.125
|
||||
}
|
||||
});
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
DOCX_STYLE_SLOT_NAMES,
|
||||
type DocxComputedStyle,
|
||||
type DocxThemeStyleCaptureRequest
|
||||
} from "@md-to-pdf/docx-theme-engine";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { captureDocxThemeStyleWithPlaywrightPage } from "../src/playwright-docx-theme-style.js";
|
||||
|
||||
const computed: DocxComputedStyle = {
|
||||
fontFamily: "Arial",
|
||||
fontSize: "16px",
|
||||
fontWeight: "400",
|
||||
fontStyle: "normal",
|
||||
color: "rgb(0, 0, 0)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
lineHeight: "normal",
|
||||
letterSpacing: "normal",
|
||||
textAlign: "start",
|
||||
direction: "ltr",
|
||||
textIndent: "0px",
|
||||
textDecorationLine: "none",
|
||||
marginTop: "0px",
|
||||
marginRight: "0px",
|
||||
marginBottom: "0px",
|
||||
marginLeft: "0px",
|
||||
paddingTop: "0px",
|
||||
paddingRight: "0px",
|
||||
paddingBottom: "0px",
|
||||
paddingLeft: "0px",
|
||||
borderTop: "0px none rgb(0, 0, 0)",
|
||||
borderRight: "0px none rgb(0, 0, 0)",
|
||||
borderBottom: "0px none rgb(0, 0, 0)",
|
||||
borderLeft: "0px none rgb(0, 0, 0)",
|
||||
width: "640px",
|
||||
maxWidth: "none",
|
||||
minWidth: "0px",
|
||||
minHeight: "0px",
|
||||
height: "24px",
|
||||
breakBefore: "auto",
|
||||
breakAfter: "auto",
|
||||
breakInside: "auto",
|
||||
display: "block",
|
||||
flexDirection: "row",
|
||||
alignItems: "normal",
|
||||
justifyContent: "normal",
|
||||
outline: "rgb(0, 0, 0) none 0px",
|
||||
outlineOffset: "0px"
|
||||
};
|
||||
|
||||
const request: DocxThemeStyleCaptureRequest = {
|
||||
themeId: "external-clean",
|
||||
themeFingerprint: "a".repeat(64),
|
||||
themeCss: "#write { color: #000; }",
|
||||
baseUrl: "http://localhost:3001/"
|
||||
};
|
||||
|
||||
describe("Playwright DOCX 主题样式采集", () => {
|
||||
it("使用打印媒体并校验浏览器返回的快照", async () => {
|
||||
const page = {
|
||||
emulateMedia: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => ({
|
||||
rootFontSizePx: 16,
|
||||
viewport: {
|
||||
widthPx: 794,
|
||||
heightPx: 1123,
|
||||
deviceScaleFactor: 1
|
||||
},
|
||||
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
|
||||
slot,
|
||||
matched: true,
|
||||
computed
|
||||
}))
|
||||
}))
|
||||
};
|
||||
const snapshot =
|
||||
await captureDocxThemeStyleWithPlaywrightPage(
|
||||
page as never,
|
||||
request
|
||||
);
|
||||
expect(page.emulateMedia).toHaveBeenCalledWith({
|
||||
media: "print"
|
||||
});
|
||||
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
||||
expect(snapshot.themeId).toBe("external-clean");
|
||||
expect(snapshot.slots).toHaveLength(
|
||||
DOCX_STYLE_SLOT_NAMES.length
|
||||
);
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -2,12 +2,24 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/apple-touch-icon.png"
|
||||
sizes="180x180"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<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:; 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: mdpdf:; connect-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
|
||||
+1185
-361
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
type DocumentActionDialogProps =
|
||||
| {
|
||||
type: "new-location";
|
||||
busy?: false;
|
||||
onCurrentWindow: () => void;
|
||||
onNewWindow: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
| {
|
||||
type: "unsaved";
|
||||
busy: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function DocumentActionDialog(
|
||||
props: DocumentActionDialogProps
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !props.busy) {
|
||||
event.preventDefault();
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [props]);
|
||||
|
||||
const isNewLocation = props.type === "new-location";
|
||||
return (
|
||||
<div className="document-dialog-layer">
|
||||
<div className="document-dialog-backdrop" />
|
||||
<section
|
||||
className="document-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="document-dialog-title"
|
||||
aria-describedby="document-dialog-description"
|
||||
>
|
||||
<span className="panel-kicker">文档操作</span>
|
||||
<h2 id="document-dialog-title">
|
||||
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
|
||||
</h2>
|
||||
<p id="document-dialog-description">
|
||||
{isNewLocation
|
||||
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
|
||||
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
|
||||
</p>
|
||||
<div className="document-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{isNewLocation ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewWindow}
|
||||
>
|
||||
新窗口新建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-primary"
|
||||
autoFocus
|
||||
onClick={props.onCurrentWindow}
|
||||
>
|
||||
当前窗口新建
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onDiscard}
|
||||
>
|
||||
不保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-primary"
|
||||
disabled={props.busy}
|
||||
autoFocus
|
||||
onClick={props.onSave}
|
||||
>
|
||||
{props.busy ? "正在保存…" : "保存"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ExportCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | undefined;
|
||||
busy?: boolean;
|
||||
onSelect(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ExportMenuProps {
|
||||
commands: ExportCommand[];
|
||||
}
|
||||
|
||||
export function ExportMenu({ commands }: ExportMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!menuRef.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="export-menu more-menu" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="export-actions-menu"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
导出 ▾
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id="export-actions-menu"
|
||||
className="export-menu-popover more-menu-popover"
|
||||
role="menu"
|
||||
aria-label="导出格式"
|
||||
>
|
||||
{commands.map((command) => (
|
||||
<button
|
||||
type="button"
|
||||
className="export-menu-command"
|
||||
role="menuitem"
|
||||
key={command.id}
|
||||
disabled={command.disabled}
|
||||
title={command.disabledReason}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void command.onSelect();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>
|
||||
{command.busy
|
||||
? `正在生成 ${command.label}…`
|
||||
: `导出 ${command.label}`}
|
||||
</strong>
|
||||
<small>
|
||||
{command.disabledReason ?? command.description}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface ExportToastState {
|
||||
kind: "progress" | "success" | "error";
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ExportToastProps {
|
||||
toast: ExportToastState;
|
||||
onDismiss(): void;
|
||||
}
|
||||
|
||||
export function ExportToast({
|
||||
toast,
|
||||
onDismiss
|
||||
}: ExportToastProps) {
|
||||
useEffect(() => {
|
||||
if (toast.kind !== "success") {
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(onDismiss, 3_500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [onDismiss, toast]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`export-toast is-${toast.kind}`}
|
||||
role={toast.kind === "error" ? "alert" : "status"}
|
||||
aria-live={toast.kind === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
<div className="export-toast-content">
|
||||
<strong>{toast.title}</strong>
|
||||
{toast.message ? <span>{toast.message}</span> : null}
|
||||
</div>
|
||||
{toast.kind !== "progress" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="export-toast-close"
|
||||
aria-label="关闭导出提示"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
{toast.kind === "progress" ? (
|
||||
<div className="export-toast-progress" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef
|
||||
} from "react";
|
||||
import CodeMirror, {
|
||||
type ReactCodeMirrorRef
|
||||
} from "@uiw/react-codemirror";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
type KeyBinding
|
||||
} from "@codemirror/view";
|
||||
import {
|
||||
executeMarkdownEditorCommand,
|
||||
markdownEditorCommands
|
||||
} from "./markdown-editor-commands";
|
||||
|
||||
export interface MarkdownEditorHandle {
|
||||
focus: () => void;
|
||||
getScrollElement: () => HTMLElement | null;
|
||||
getView: () => EditorView | null;
|
||||
}
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
documentVersion: number;
|
||||
onChange: (value: string) => void;
|
||||
onEditorReady?: (view: EditorView | null) => void;
|
||||
onScroll?: () => void;
|
||||
}
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: "#f6f7f5",
|
||||
color: "#26312d",
|
||||
fontSize: "0.86rem"
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
boxShadow: "inset 3px 0 #74a88f"
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily:
|
||||
'"Cascadia Code", "SFMono-Regular", Consolas, monospace',
|
||||
lineHeight: "1.72",
|
||||
tabSize: "2",
|
||||
overflow: "auto"
|
||||
},
|
||||
".cm-content": {
|
||||
minHeight: "100%",
|
||||
padding: "24px"
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0"
|
||||
},
|
||||
".cm-gutters": {
|
||||
border: "0",
|
||||
backgroundColor: "#eef1ef",
|
||||
color: "#8a9690"
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "rgb(116 168 143 / 9%)"
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "rgb(89 135 113 / 24%)"
|
||||
}
|
||||
});
|
||||
|
||||
export const MarkdownEditor = forwardRef<
|
||||
MarkdownEditorHandle,
|
||||
MarkdownEditorProps
|
||||
>(function MarkdownEditor(
|
||||
{
|
||||
value,
|
||||
documentVersion,
|
||||
onChange,
|
||||
onEditorReady,
|
||||
onScroll
|
||||
},
|
||||
forwardedRef
|
||||
) {
|
||||
const codeMirrorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const shortcutBindings = useMemo(
|
||||
() =>
|
||||
markdownEditorCommands.flatMap<KeyBinding>((command) =>
|
||||
command.shortcut
|
||||
? [
|
||||
{
|
||||
key: command.shortcut,
|
||||
run: (view) =>
|
||||
executeMarkdownEditorCommand(command.id, { view })
|
||||
}
|
||||
]
|
||||
: []
|
||||
),
|
||||
[]
|
||||
);
|
||||
const extensions = useMemo(
|
||||
() => [markdown(), keymap.of(shortcutBindings)],
|
||||
[shortcutBindings]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
focus: () => viewRef.current?.focus(),
|
||||
getScrollElement: () => viewRef.current?.scrollDOM ?? null,
|
||||
getView: () => viewRef.current
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onEditorReady?.(null);
|
||||
viewRef.current = null;
|
||||
},
|
||||
[documentVersion, onEditorReady]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-editor"
|
||||
onScrollCapture={onScroll}
|
||||
>
|
||||
<CodeMirror
|
||||
key={documentVersion}
|
||||
ref={codeMirrorRef}
|
||||
value={value}
|
||||
height="100%"
|
||||
basicSetup={{
|
||||
autocompletion: false,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
lineNumbers: true,
|
||||
searchKeymap: true
|
||||
}}
|
||||
extensions={extensions}
|
||||
theme={editorTheme}
|
||||
aria-label="Markdown 内容"
|
||||
onChange={onChange}
|
||||
onCreateEditor={(view) => {
|
||||
viewRef.current = view;
|
||||
onEditorReady?.(view);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { insertMarkdownTable } from "./markdown-editor-commands";
|
||||
|
||||
interface MarkdownTableToolProps {
|
||||
editorView: EditorView | null;
|
||||
}
|
||||
|
||||
const maximumColumns = 10;
|
||||
const maximumRows = 10;
|
||||
const minimumRows = 2;
|
||||
|
||||
interface TableSize {
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
export function MarkdownTableTool({
|
||||
editorView
|
||||
}: MarkdownTableToolProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [size, setSize] = useState<TableSize>({
|
||||
columns: 3,
|
||||
rows: 3
|
||||
});
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
function focusSize(nextSize: TableSize) {
|
||||
window.requestAnimationFrame(() => {
|
||||
rootRef.current
|
||||
?.querySelector<HTMLButtonElement>(
|
||||
`[data-table-size="${nextSize.columns}x${nextSize.rows}"]`
|
||||
)
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
focusSize(size);
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!rootRef.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorView) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [editorView]);
|
||||
|
||||
function moveSelection(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
columnsDelta: number,
|
||||
rowsDelta: number
|
||||
) {
|
||||
event.preventDefault();
|
||||
const nextSize = {
|
||||
columns: clamp(
|
||||
size.columns + columnsDelta,
|
||||
1,
|
||||
maximumColumns
|
||||
),
|
||||
rows: clamp(
|
||||
size.rows + rowsDelta,
|
||||
minimumRows,
|
||||
maximumRows
|
||||
)
|
||||
};
|
||||
setSize(nextSize);
|
||||
focusSize(nextSize);
|
||||
}
|
||||
|
||||
function insertTable(selectedSize: TableSize) {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
insertMarkdownTable(
|
||||
editorView,
|
||||
selectedSize.columns,
|
||||
selectedSize.rows
|
||||
);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="markdown-table-tool" ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="markdown-toolbar-button"
|
||||
aria-label="插入表格"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title="插入表格"
|
||||
disabled={!editorView}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
表格
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="markdown-table-popover"
|
||||
role="dialog"
|
||||
aria-label="选择表格尺寸"
|
||||
>
|
||||
<strong>
|
||||
{size.columns} 列 × {size.rows} 行
|
||||
</strong>
|
||||
<span>首行为表头</span>
|
||||
<div
|
||||
className="markdown-table-grid"
|
||||
role="grid"
|
||||
aria-label="表格尺寸"
|
||||
aria-rowcount={maximumRows - minimumRows + 1}
|
||||
aria-colcount={maximumColumns}
|
||||
>
|
||||
{Array.from(
|
||||
{
|
||||
length:
|
||||
maximumColumns *
|
||||
(maximumRows - minimumRows + 1)
|
||||
},
|
||||
(_, index) => {
|
||||
const columns = (index % maximumColumns) + 1;
|
||||
const rows =
|
||||
Math.floor(index / maximumColumns) + minimumRows;
|
||||
const selected =
|
||||
columns <= size.columns && rows <= size.rows;
|
||||
const active =
|
||||
columns === size.columns && rows === size.rows;
|
||||
return (
|
||||
<button
|
||||
key={`${columns}x${rows}`}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={
|
||||
selected
|
||||
? "markdown-table-cell is-selected"
|
||||
: "markdown-table-cell"
|
||||
}
|
||||
data-table-size={`${columns}x${rows}`}
|
||||
aria-label={`插入 ${columns} 列 × ${rows} 行表格`}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onPointerEnter={() =>
|
||||
setSize({ columns, rows })
|
||||
}
|
||||
onFocus={() => setSize({ columns, rows })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
moveSelection(event, -1, 0);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
moveSelection(event, 1, 0);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
moveSelection(event, 0, -1);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
moveSelection(event, 0, 1);
|
||||
} else if (
|
||||
event.key === "Enter" ||
|
||||
event.key === " "
|
||||
) {
|
||||
event.preventDefault();
|
||||
insertTable({ columns, rows });
|
||||
}
|
||||
}}
|
||||
onClick={() => insertTable({ columns, rows })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
executeMarkdownEditorCommand,
|
||||
getMarkdownEditorCommand
|
||||
} from "./markdown-editor-commands";
|
||||
import { MarkdownTableTool } from "./MarkdownTableTool";
|
||||
|
||||
interface MarkdownToolbarProps {
|
||||
editorView: EditorView | null;
|
||||
}
|
||||
|
||||
const toolbarGroups = [
|
||||
["undo", "redo"],
|
||||
["bold", "italic", "strikethrough", "inline-code"],
|
||||
[
|
||||
"code-block",
|
||||
"quote",
|
||||
"unordered-list",
|
||||
"ordered-list",
|
||||
"task-list"
|
||||
],
|
||||
["table", "horizontal-rule"]
|
||||
] as const;
|
||||
|
||||
function preserveEditorSelection(event: MouseEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
export function MarkdownToolbar({
|
||||
editorView
|
||||
}: MarkdownToolbarProps) {
|
||||
function execute(commandId: string) {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
executeMarkdownEditorCommand(commandId, { view: editorView });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="Markdown 格式工具栏"
|
||||
>
|
||||
<label className="markdown-toolbar-heading">
|
||||
<span className="sr-only">标题级别</span>
|
||||
<select
|
||||
aria-label="设置标题级别"
|
||||
defaultValue=""
|
||||
disabled={!editorView}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) {
|
||||
execute(event.target.value);
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">标题</option>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<option
|
||||
key={index + 1}
|
||||
value={`heading-${index + 1}`}
|
||||
>
|
||||
H{index + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{toolbarGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
className="markdown-toolbar-group"
|
||||
key={group.join("-")}
|
||||
aria-label={`工具组 ${groupIndex + 1}`}
|
||||
>
|
||||
{group.map((commandId) => {
|
||||
if (commandId === "table") {
|
||||
return (
|
||||
<MarkdownTableTool
|
||||
key={commandId}
|
||||
editorView={editorView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const command = getMarkdownEditorCommand(commandId);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
const disabled =
|
||||
!editorView ||
|
||||
(command.isEnabled?.({ view: editorView }) === false);
|
||||
return (
|
||||
<button
|
||||
key={command.id}
|
||||
type="button"
|
||||
className={`markdown-toolbar-button command-${command.id}`}
|
||||
aria-label={command.label}
|
||||
title={
|
||||
command.shortcut
|
||||
? `${command.label}(${command.shortcut})`
|
||||
: command.label
|
||||
}
|
||||
disabled={disabled}
|
||||
onMouseDown={preserveEditorSelection}
|
||||
onClick={() => execute(command.id)}
|
||||
>
|
||||
{command.toolbarLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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__;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const APP_VERSION = __APP_VERSION__;
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
@@ -0,0 +1,116 @@
|
||||
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";
|
||||
}
|
||||
|
||||
export interface MarkdownRenderInput {
|
||||
markdown: string;
|
||||
language: string;
|
||||
resources?: MarkdownImageResource[];
|
||||
}
|
||||
|
||||
export interface MarkdownImageResource {
|
||||
path: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
async function readErrorMessage(
|
||||
response: Response,
|
||||
fallback: string
|
||||
) {
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: unknown };
|
||||
return typeof payload.message === "string"
|
||||
? payload.message
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdownDocument(
|
||||
input: MarkdownRenderInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.renderMarkdown(input);
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
...(signal ? { signal } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, "渲染失败"));
|
||||
}
|
||||
return response.json() as Promise<RenderedMarkdownDocument>;
|
||||
}
|
||||
|
||||
export async function listThemes(signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.listThemes();
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/themes", {
|
||||
...(signal ? { signal } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
}
|
||||
|
||||
export async function getThemeCss(
|
||||
themeId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/themes/${encodeURIComponent(themeId)}/css`,
|
||||
{ ...(signal ? { signal } : {}) }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const sampleModules = import.meta.glob<string>(
|
||||
"../../../samples/themes/*.md",
|
||||
{
|
||||
eager: true,
|
||||
query: "?raw",
|
||||
import: "default"
|
||||
}
|
||||
);
|
||||
|
||||
export interface BuiltinThemeSample {
|
||||
themeId: string;
|
||||
fileName: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function getFileName(modulePath: string) {
|
||||
return modulePath.split("/").at(-1) ?? "";
|
||||
}
|
||||
|
||||
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
|
||||
sampleModules
|
||||
)
|
||||
.map(([modulePath, markdown]) => {
|
||||
const fileName = getFileName(modulePath);
|
||||
return {
|
||||
themeId: fileName.replace(/\.md$/i, ""),
|
||||
fileName,
|
||||
markdown
|
||||
};
|
||||
})
|
||||
.sort((first, second) =>
|
||||
first.themeId.localeCompare(second.themeId, "en")
|
||||
);
|
||||
|
||||
const builtinThemeSampleMap = new Map(
|
||||
builtinThemeSamples.map((sample) => [sample.themeId, sample])
|
||||
);
|
||||
|
||||
export function getBuiltinThemeSample(themeId: string) {
|
||||
return builtinThemeSampleMap.get(themeId);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const tutorialModules = import.meta.glob<string>(
|
||||
"../../../samples/tutorials/*.md",
|
||||
{
|
||||
eager: true,
|
||||
query: "?raw",
|
||||
import: "default"
|
||||
}
|
||||
);
|
||||
|
||||
export interface BuiltinTutorial {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
themeId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
const tutorialDefinitions = [
|
||||
{
|
||||
id: "echarts-tutorial",
|
||||
name: "ECharts 图表教程",
|
||||
themeId: "typora-github"
|
||||
},
|
||||
{
|
||||
id: "formal-document-tutorial",
|
||||
name: "公文主题使用教程",
|
||||
themeId: "typora-github"
|
||||
}
|
||||
] as const;
|
||||
|
||||
const tutorialMarkdownById = new Map(
|
||||
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
|
||||
const fileName = modulePath.split("/").at(-1) ?? "";
|
||||
return [fileName.replace(/\.md$/i, ""), markdown];
|
||||
})
|
||||
);
|
||||
|
||||
export const builtinTutorials: BuiltinTutorial[] =
|
||||
tutorialDefinitions.map((definition) => {
|
||||
const markdown = tutorialMarkdownById.get(definition.id);
|
||||
if (!markdown) {
|
||||
throw new Error(`缺少内置教程:${definition.id}.md`);
|
||||
}
|
||||
return {
|
||||
...definition,
|
||||
fileName: `${definition.id}.md`,
|
||||
markdown
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
const fallbackBaseName = "未命名文档";
|
||||
const windowsReservedName =
|
||||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
|
||||
|
||||
function stripMarkdownFormatting(value: string) {
|
||||
return value
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
|
||||
.replace(/<\/?[A-Za-z][^>]*>/gu, " ")
|
||||
.replace(/[`*_~]/gu, "")
|
||||
.replace(/^\s{0,3}(?:>\s*|[-+*]\s+|\d+[.)]\s+)/u, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function legalizeMarkdownBaseName(value: string) {
|
||||
const normalized = stripMarkdownFormatting(value)
|
||||
.normalize("NFKC")
|
||||
.replace(/[^\p{L}\p{N}\s_-]+/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.replace(/[ .]+$/u, "");
|
||||
const shortened = Array.from(normalized).slice(0, 100).join("").trim();
|
||||
if (!shortened) {
|
||||
return fallbackBaseName;
|
||||
}
|
||||
return windowsReservedName.test(shortened)
|
||||
? `文档 ${shortened}`
|
||||
: shortened;
|
||||
}
|
||||
|
||||
export function deriveMarkdownBaseName(markdown: string) {
|
||||
const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u);
|
||||
let inFrontMatter = lines[0]?.trim() === "---";
|
||||
let inFence = false;
|
||||
let fenceMarker = "";
|
||||
let fenceLength = 0;
|
||||
let firstBodyLine = "";
|
||||
|
||||
for (let index = inFrontMatter ? 1 : 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
const trimmed = line.trim();
|
||||
if (inFrontMatter) {
|
||||
if (trimmed === "---" || trimmed === "...") {
|
||||
inFrontMatter = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
const closingFence = trimmed.match(/^(`+|~+)\s*$/u)?.[1];
|
||||
if (
|
||||
closingFence?.[0] === fenceMarker &&
|
||||
closingFence.length >= fenceLength
|
||||
) {
|
||||
inFence = false;
|
||||
fenceMarker = "";
|
||||
fenceLength = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fence = trimmed.match(/^(`{3,}|~{3,})/u)?.[1];
|
||||
if (fence) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceMarker = fence[0] ?? "";
|
||||
fenceLength = fence.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inFence || !trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u)?.[1];
|
||||
if (heading) {
|
||||
return legalizeMarkdownBaseName(heading);
|
||||
}
|
||||
if (!firstBodyLine) {
|
||||
firstBodyLine = line;
|
||||
}
|
||||
}
|
||||
|
||||
return legalizeMarkdownBaseName(firstBodyLine);
|
||||
}
|
||||
|
||||
export function createMarkdownFileName(markdown: string) {
|
||||
return `${deriveMarkdownBaseName(markdown)}.md`;
|
||||
}
|
||||
|
||||
export function ensureMarkdownFileName(fileName: string, markdown: string) {
|
||||
const trimmed = fileName.trim();
|
||||
if (!trimmed) {
|
||||
return createMarkdownFileName(markdown);
|
||||
}
|
||||
return /\.(?:md|markdown)$/iu.test(trimmed)
|
||||
? trimmed
|
||||
: `${trimmed}.md`;
|
||||
}
|
||||
|
||||
export function downloadMarkdown(markdown: string, fileName: string) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([markdown], { type: "text/markdown;charset=utf-8" })
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type DocumentShortcut =
|
||||
| "new"
|
||||
| "save"
|
||||
| "save-as"
|
||||
| "close";
|
||||
|
||||
export interface DocumentShortcutEvent {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
isComposing: boolean;
|
||||
repeat: boolean;
|
||||
}
|
||||
|
||||
export function getDocumentShortcut(
|
||||
event: DocumentShortcutEvent,
|
||||
desktop: boolean
|
||||
): DocumentShortcut | undefined {
|
||||
if (
|
||||
event.isComposing ||
|
||||
event.repeat ||
|
||||
event.altKey ||
|
||||
(!event.ctrlKey && !event.metaKey)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.shiftKey) {
|
||||
return desktop && key === "s" ? "save-as" : undefined;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "s":
|
||||
return "save";
|
||||
case "n":
|
||||
return desktop ? "new" : undefined;
|
||||
case "w":
|
||||
return desktop ? "close" : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
DOCX_MIME_TYPE,
|
||||
type DocxCapability,
|
||||
type DocxExportRequestInput
|
||||
} from "@md-to-pdf/core";
|
||||
import { parseContentDispositionFileName } from "./pdf-export";
|
||||
|
||||
export interface DocxExportResult {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
warningCount: number;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
export interface DesktopDocxExportResult {
|
||||
saved: boolean;
|
||||
fileName: string;
|
||||
warningCount: number;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
function parseNumericHeader(value: string | null) {
|
||||
if (value === null) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as {
|
||||
message?: unknown;
|
||||
};
|
||||
if (typeof payload.message === "string") {
|
||||
return payload.message;
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 错误响应使用通用提示。
|
||||
}
|
||||
return "DOCX 导出失败";
|
||||
}
|
||||
|
||||
export async function getDocxCapability(
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<DocxCapability> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (bridge) {
|
||||
return bridge.getDocxCapability();
|
||||
}
|
||||
const response = await fetcher("/api/docx/capability");
|
||||
if (!response.ok) {
|
||||
throw new Error("无法检测 DOCX 导出能力");
|
||||
}
|
||||
return (await response.json()) as DocxCapability;
|
||||
}
|
||||
|
||||
export async function requestDocxExport(
|
||||
request: DocxExportRequestInput,
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<DocxExportResult> {
|
||||
const response = await fetcher("/api/docx", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
const contentType =
|
||||
response.headers.get("content-type")?.split(";")[0]?.trim();
|
||||
if (contentType !== DOCX_MIME_TYPE) {
|
||||
throw new Error("DOCX 服务返回了无效文件类型");
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: parseContentDispositionFileName(
|
||||
response.headers.get("content-disposition"),
|
||||
"document.docx"
|
||||
),
|
||||
warningCount: parseNumericHeader(
|
||||
response.headers.get("x-docx-warning-count")
|
||||
),
|
||||
echartsErrorCount: parseNumericHeader(
|
||||
response.headers.get("x-echarts-error-count")
|
||||
),
|
||||
mermaidErrorCount: parseNumericHeader(
|
||||
response.headers.get("x-mermaid-error-count")
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestDesktopDocxExport(
|
||||
request: DocxExportRequestInput
|
||||
): Promise<DesktopDocxExportResult> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
throw new Error("桌面 DOCX 引擎不可用");
|
||||
}
|
||||
const outcome = await bridge.exportDocx(request);
|
||||
if (!outcome.ok) {
|
||||
throw new Error(outcome.message);
|
||||
}
|
||||
return {
|
||||
saved: outcome.saved,
|
||||
fileName: outcome.fileName,
|
||||
warningCount: outcome.diagnostics.warnings.length,
|
||||
echartsErrorCount: outcome.diagnostics.echartsErrors.length,
|
||||
mermaidErrorCount: outcome.diagnostics.mermaidErrors.length
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadDocx(result: DocxExportResult) {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = result.fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function createDocxDiagnosticsMessage(result: {
|
||||
warningCount: number;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}) {
|
||||
const messages = [];
|
||||
if (result.warningCount > 0) {
|
||||
messages.push(`${result.warningCount} 条提示`);
|
||||
}
|
||||
if (result.echartsErrorCount > 0) {
|
||||
messages.push(`${result.echartsErrorCount} 个 ECharts 图表失败`);
|
||||
}
|
||||
if (result.mermaidErrorCount > 0) {
|
||||
messages.push(`${result.mermaidErrorCount} 个 Mermaid 图表失败`);
|
||||
}
|
||||
return messages.length > 0
|
||||
? messages.join(",")
|
||||
: "纸张、样式和可编辑结构已写入文档";
|
||||
}
|
||||
+121
-14
@@ -4,8 +4,10 @@ import {
|
||||
type ExportConfig
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
type EditorState,
|
||||
type SelectionRange
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
redo,
|
||||
redoDepth,
|
||||
undo,
|
||||
undoDepth
|
||||
} from "@codemirror/commands";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
export type MarkdownEditorCommandGroup =
|
||||
| "history"
|
||||
| "text"
|
||||
| "block"
|
||||
| "insert"
|
||||
| "document";
|
||||
|
||||
export interface EditorCommandContext {
|
||||
view: EditorView;
|
||||
openPanel?: (panelId: string) => void;
|
||||
}
|
||||
|
||||
export interface MarkdownEditorCommand {
|
||||
id: string;
|
||||
group: MarkdownEditorCommandGroup;
|
||||
label: string;
|
||||
toolbarLabel: string;
|
||||
shortcut?: string;
|
||||
isEnabled?: (context: EditorCommandContext) => boolean;
|
||||
execute: (
|
||||
context: EditorCommandContext
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface MarkdownTextEdit {
|
||||
insert: string;
|
||||
selectionFrom: number;
|
||||
selectionTo: number;
|
||||
}
|
||||
|
||||
type SelectionEditor = (
|
||||
selectedText: string,
|
||||
range: SelectionRange,
|
||||
state: EditorState
|
||||
) => MarkdownTextEdit;
|
||||
|
||||
export function wrapMarkdownSelection(
|
||||
selectedText: string,
|
||||
prefix: string,
|
||||
suffix = prefix,
|
||||
placeholder = "文本"
|
||||
): MarkdownTextEdit {
|
||||
if (
|
||||
selectedText.length >= prefix.length + suffix.length &&
|
||||
selectedText.startsWith(prefix) &&
|
||||
selectedText.endsWith(suffix)
|
||||
) {
|
||||
const insert = selectedText.slice(
|
||||
prefix.length,
|
||||
selectedText.length - suffix.length
|
||||
);
|
||||
return {
|
||||
insert,
|
||||
selectionFrom: 0,
|
||||
selectionTo: insert.length
|
||||
};
|
||||
}
|
||||
|
||||
const content = selectedText || placeholder;
|
||||
return {
|
||||
insert: `${prefix}${content}${suffix}`,
|
||||
selectionFrom: prefix.length,
|
||||
selectionTo: prefix.length + content.length
|
||||
};
|
||||
}
|
||||
|
||||
function mapNonBlankLines(
|
||||
text: string,
|
||||
mapper: (line: string, index: number) => string
|
||||
) {
|
||||
let contentLineIndex = 0;
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (!line.trim()) {
|
||||
return line;
|
||||
}
|
||||
const mapped = mapper(line, contentLineIndex);
|
||||
contentLineIndex += 1;
|
||||
return mapped;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export type MarkdownLinePrefix =
|
||||
| "quote"
|
||||
| "unordered-list"
|
||||
| "ordered-list"
|
||||
| "task-list";
|
||||
|
||||
const linePrefixPatterns: Record<MarkdownLinePrefix, RegExp> = {
|
||||
quote: /^>\s?/u,
|
||||
"unordered-list": /^[-*+]\s+/u,
|
||||
"ordered-list": /^\d+[.)]\s+/u,
|
||||
"task-list": /^[-*+]\s+\[[ xX]\]\s+/u
|
||||
};
|
||||
|
||||
export function toggleMarkdownLinePrefix(
|
||||
text: string,
|
||||
kind: MarkdownLinePrefix
|
||||
) {
|
||||
const pattern = linePrefixPatterns[kind];
|
||||
const nonBlankLines = text
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
const shouldRemove =
|
||||
nonBlankLines.length > 0 &&
|
||||
nonBlankLines.every((line) => pattern.test(line));
|
||||
|
||||
return mapNonBlankLines(text, (line, index) => {
|
||||
if (shouldRemove) {
|
||||
return line.replace(pattern, "");
|
||||
}
|
||||
if (kind === "quote") {
|
||||
return `> ${line.replace(linePrefixPatterns.quote, "")}`;
|
||||
}
|
||||
if (kind === "ordered-list") {
|
||||
return `${index + 1}. ${line.replace(
|
||||
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
|
||||
""
|
||||
)}`;
|
||||
}
|
||||
if (kind === "task-list") {
|
||||
return `- [ ] ${line.replace(
|
||||
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
|
||||
""
|
||||
)}`;
|
||||
}
|
||||
return `- ${line.replace(
|
||||
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
|
||||
""
|
||||
)}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function setMarkdownHeading(text: string, level: number) {
|
||||
const prefix = level > 0 ? `${"#".repeat(level)} ` : "";
|
||||
return mapNonBlankLines(
|
||||
text,
|
||||
(line) => `${prefix}${line.replace(/^#{1,6}\s+/u, "")}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createMarkdownTable(
|
||||
columnCount: number,
|
||||
rowCount: number
|
||||
): MarkdownTextEdit {
|
||||
const columns = Math.trunc(columnCount);
|
||||
const rows = Math.trunc(rowCount);
|
||||
if (columns < 1 || columns > 10 || rows < 2 || rows > 10) {
|
||||
throw new RangeError("表格尺寸必须为 1~10 列、2~10 行");
|
||||
}
|
||||
|
||||
const headerCells = Array.from(
|
||||
{ length: columns },
|
||||
(_, index) => `列 ${index + 1}`
|
||||
);
|
||||
const separatorCells = Array.from(
|
||||
{ length: columns },
|
||||
() => "---"
|
||||
);
|
||||
const contentRow = `| ${Array.from(
|
||||
{ length: columns },
|
||||
() => "内容"
|
||||
).join(" | ")} |`;
|
||||
const insert = [
|
||||
`| ${headerCells.join(" | ")} |`,
|
||||
`| ${separatorCells.join(" | ")} |`,
|
||||
...Array.from({ length: rows - 1 }, () => contentRow)
|
||||
].join("\n");
|
||||
return {
|
||||
insert,
|
||||
selectionFrom: 2,
|
||||
selectionTo: 5
|
||||
};
|
||||
}
|
||||
|
||||
function replaceSelections(
|
||||
view: EditorView,
|
||||
createEdit: SelectionEditor
|
||||
) {
|
||||
const transaction = view.state.changeByRange((range) => {
|
||||
const selectedText = view.state.sliceDoc(range.from, range.to);
|
||||
const edit = createEdit(selectedText, range, view.state);
|
||||
return {
|
||||
changes: {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
insert: edit.insert
|
||||
},
|
||||
range: EditorSelection.range(
|
||||
range.from + edit.selectionFrom,
|
||||
range.from + edit.selectionTo
|
||||
)
|
||||
};
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function editSelectedLines(
|
||||
view: EditorView,
|
||||
transform: (text: string) => string
|
||||
) {
|
||||
const transaction = view.state.changeByRange((range) => {
|
||||
const startLine = view.state.doc.lineAt(range.from);
|
||||
const endProbe =
|
||||
range.to > range.from &&
|
||||
view.state.doc.lineAt(range.to).from === range.to
|
||||
? range.to - 1
|
||||
: range.to;
|
||||
const endLine = view.state.doc.lineAt(endProbe);
|
||||
const selectedLines = view.state.sliceDoc(
|
||||
startLine.from,
|
||||
endLine.to
|
||||
);
|
||||
const insert = transform(selectedLines);
|
||||
return {
|
||||
changes: {
|
||||
from: startLine.from,
|
||||
to: endLine.to,
|
||||
insert
|
||||
},
|
||||
range: EditorSelection.range(
|
||||
startLine.from,
|
||||
startLine.from + insert.length
|
||||
)
|
||||
};
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function insertMarkdownBlock(
|
||||
block: string,
|
||||
range?: SelectionRange,
|
||||
state?: EditorState
|
||||
): MarkdownTextEdit {
|
||||
const before =
|
||||
range && state ? state.sliceDoc(0, range.from) : "";
|
||||
const after = range && state ? state.sliceDoc(range.to) : "";
|
||||
const leading =
|
||||
!before || before.endsWith("\n\n")
|
||||
? ""
|
||||
: before.endsWith("\n")
|
||||
? "\n"
|
||||
: "\n\n";
|
||||
const trailing =
|
||||
!after || after.startsWith("\n\n")
|
||||
? ""
|
||||
: after.startsWith("\n")
|
||||
? "\n"
|
||||
: "\n\n";
|
||||
const insert = `${leading}${block}${trailing}`;
|
||||
return {
|
||||
insert,
|
||||
selectionFrom: leading.length,
|
||||
selectionTo: leading.length + block.length
|
||||
};
|
||||
}
|
||||
|
||||
export function insertMarkdownTable(
|
||||
view: EditorView,
|
||||
columnCount: number,
|
||||
rowCount: number
|
||||
) {
|
||||
replaceSelections(view, (_, range, state) => {
|
||||
const table = createMarkdownTable(columnCount, rowCount);
|
||||
const block = insertMarkdownBlock(
|
||||
table.insert,
|
||||
range,
|
||||
state
|
||||
);
|
||||
return {
|
||||
insert: block.insert,
|
||||
selectionFrom: block.selectionFrom + table.selectionFrom,
|
||||
selectionTo: block.selectionFrom + table.selectionTo
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createInlineCommand(
|
||||
id: string,
|
||||
label: string,
|
||||
toolbarLabel: string,
|
||||
prefix: string,
|
||||
suffix = prefix,
|
||||
placeholder = "文本",
|
||||
shortcut?: string
|
||||
): MarkdownEditorCommand {
|
||||
return {
|
||||
id,
|
||||
group: "text",
|
||||
label,
|
||||
toolbarLabel,
|
||||
...(shortcut ? { shortcut } : {}),
|
||||
execute: ({ view }) =>
|
||||
replaceSelections(view, (selectedText) =>
|
||||
wrapMarkdownSelection(
|
||||
selectedText,
|
||||
prefix,
|
||||
suffix,
|
||||
placeholder
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function createLineCommand(
|
||||
id: string,
|
||||
label: string,
|
||||
toolbarLabel: string,
|
||||
kind: MarkdownLinePrefix,
|
||||
shortcut?: string
|
||||
): MarkdownEditorCommand {
|
||||
return {
|
||||
id,
|
||||
group: "block",
|
||||
label,
|
||||
toolbarLabel,
|
||||
...(shortcut ? { shortcut } : {}),
|
||||
execute: ({ view }) =>
|
||||
editSelectedLines(view, (text) =>
|
||||
toggleMarkdownLinePrefix(text, kind)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
const historyCommands: MarkdownEditorCommand[] = [
|
||||
{
|
||||
id: "undo",
|
||||
group: "history",
|
||||
label: "撤销",
|
||||
toolbarLabel: "↶",
|
||||
shortcut: "Mod-z",
|
||||
isEnabled: ({ view }) => undoDepth(view.state) > 0,
|
||||
execute: ({ view }) => {
|
||||
undo(view);
|
||||
view.focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
group: "history",
|
||||
label: "重做",
|
||||
toolbarLabel: "↷",
|
||||
shortcut: "Mod-Shift-z",
|
||||
isEnabled: ({ view }) => redoDepth(view.state) > 0,
|
||||
execute: ({ view }) => {
|
||||
redo(view);
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const textCommands: MarkdownEditorCommand[] = [
|
||||
createInlineCommand(
|
||||
"bold",
|
||||
"加粗",
|
||||
"B",
|
||||
"**",
|
||||
"**",
|
||||
"粗体文本",
|
||||
"Mod-b"
|
||||
),
|
||||
createInlineCommand(
|
||||
"italic",
|
||||
"斜体",
|
||||
"I",
|
||||
"*",
|
||||
"*",
|
||||
"斜体文本",
|
||||
"Mod-i"
|
||||
),
|
||||
createInlineCommand(
|
||||
"strikethrough",
|
||||
"删除线",
|
||||
"S",
|
||||
"~~",
|
||||
"~~",
|
||||
"删除线文本",
|
||||
"Mod-Shift-x"
|
||||
),
|
||||
createInlineCommand(
|
||||
"inline-code",
|
||||
"行内代码",
|
||||
"</>",
|
||||
"`",
|
||||
"`",
|
||||
"code"
|
||||
)
|
||||
];
|
||||
|
||||
const blockCommands: MarkdownEditorCommand[] = [
|
||||
{
|
||||
id: "code-block",
|
||||
group: "block",
|
||||
label: "代码块",
|
||||
toolbarLabel: "代码块",
|
||||
execute: ({ view }) =>
|
||||
replaceSelections(view, (selectedText) =>
|
||||
wrapMarkdownSelection(
|
||||
selectedText,
|
||||
"```\n",
|
||||
"\n```",
|
||||
"code"
|
||||
)
|
||||
)
|
||||
},
|
||||
createLineCommand("quote", "引用", "❞", "quote"),
|
||||
createLineCommand(
|
||||
"unordered-list",
|
||||
"无序列表",
|
||||
"• 列表",
|
||||
"unordered-list",
|
||||
"Mod-Shift-8"
|
||||
),
|
||||
createLineCommand(
|
||||
"ordered-list",
|
||||
"有序列表",
|
||||
"1. 列表",
|
||||
"ordered-list",
|
||||
"Mod-Shift-7"
|
||||
),
|
||||
createLineCommand("task-list", "任务列表", "☑", "task-list")
|
||||
];
|
||||
|
||||
const insertCommands: MarkdownEditorCommand[] = [
|
||||
{
|
||||
id: "table",
|
||||
group: "insert",
|
||||
label: "插入表格",
|
||||
toolbarLabel: "表格",
|
||||
execute: ({ view, openPanel }) => {
|
||||
if (openPanel) {
|
||||
openPanel("table");
|
||||
return;
|
||||
}
|
||||
insertMarkdownTable(view, 3, 3);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "horizontal-rule",
|
||||
group: "insert",
|
||||
label: "插入分隔线",
|
||||
toolbarLabel: "—",
|
||||
execute: ({ view }) =>
|
||||
replaceSelections(view, (_, range, state) =>
|
||||
insertMarkdownBlock("---", range, state)
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const headingCommands = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index): MarkdownEditorCommand => {
|
||||
const level = index + 1;
|
||||
return {
|
||||
id: `heading-${level}`,
|
||||
group: "block",
|
||||
label: `${level} 级标题`,
|
||||
toolbarLabel: `H${level}`,
|
||||
execute: ({ view }) =>
|
||||
editSelectedLines(view, (text) =>
|
||||
setMarkdownHeading(text, level)
|
||||
)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const markdownEditorCommands: readonly MarkdownEditorCommand[] =
|
||||
[
|
||||
...historyCommands,
|
||||
...headingCommands,
|
||||
...textCommands,
|
||||
...blockCommands,
|
||||
...insertCommands
|
||||
];
|
||||
|
||||
const markdownEditorCommandMap = new Map(
|
||||
markdownEditorCommands.map((command) => [command.id, command])
|
||||
);
|
||||
|
||||
export function getMarkdownEditorCommand(commandId: string) {
|
||||
return markdownEditorCommandMap.get(commandId);
|
||||
}
|
||||
|
||||
export function executeMarkdownEditorCommand(
|
||||
commandId: string,
|
||||
context: EditorCommandContext
|
||||
) {
|
||||
const command = getMarkdownEditorCommand(commandId);
|
||||
if (
|
||||
!command ||
|
||||
(command.isEnabled && !command.isEnabled(context))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
void command.execute(context);
|
||||
return true;
|
||||
}
|
||||
@@ -1,448 +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 { 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 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")
|
||||
) {
|
||||
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);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
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 previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
const flow = await previewer.preview(
|
||||
content,
|
||||
[
|
||||
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"
|
||||
)
|
||||
],
|
||||
this.root
|
||||
);
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -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,8 +139,39 @@ 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) => {
|
||||
try {
|
||||
const result = await withHiddenWindowAnimationFrames(
|
||||
window,
|
||||
async () => window.__mdToPdfRender?.(payload, "pdf")
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error("PDF 分页运行时不可用");
|
||||
}
|
||||
desktopPdfBridge.complete(requestId, result);
|
||||
} catch (reason: unknown) {
|
||||
desktopPdfBridge.fail(
|
||||
requestId,
|
||||
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||
);
|
||||
}
|
||||
});
|
||||
desktopPdfBridge.ready();
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
@@ -97,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;
|
||||
|
||||
Vendored
-41
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import type {
|
||||
ExportConfig,
|
||||
PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import type { MarkdownImageResource } from "./application-backend";
|
||||
|
||||
export interface PdfExportRequest {
|
||||
markdown: string;
|
||||
fileName: string;
|
||||
language: string;
|
||||
resources: MarkdownImageResource[];
|
||||
exportConfig: ExportConfig;
|
||||
}
|
||||
|
||||
@@ -25,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
|
||||
request.markdown,
|
||||
request.fileName,
|
||||
request.language,
|
||||
request.resources,
|
||||
request.exportConfig
|
||||
]);
|
||||
}
|
||||
@@ -60,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
|
||||
@@ -76,7 +83,7 @@ export function parseContentDispositionFileName(
|
||||
return (
|
||||
contentDisposition
|
||||
?.match(/filename="([^"]+)"/i)?.[1]
|
||||
?.trim() || "document.pdf"
|
||||
?.trim() || fallbackFileName
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,11 +136,38 @@ export async function requestPdfExport(
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadPdf(blob: Blob, fileName: string) {
|
||||
export async function requestDesktopPdfExport(
|
||||
payload: PagedDocumentPayload
|
||||
): Promise<PdfExportResult> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
throw new Error("桌面 PDF 引擎不可用");
|
||||
}
|
||||
|
||||
const result = await bridge.generatePdf(payload);
|
||||
const pdfBytes = Uint8Array.from(result.pdf);
|
||||
return {
|
||||
blob: new Blob([pdfBytes.buffer], { type: "application/pdf" }),
|
||||
fileName: payload.fileName.replace(/\.(md|markdown)$/i, "") + ".pdf",
|
||||
pageCount: result.pageCount,
|
||||
echartsErrorCount: result.echartsErrors.length,
|
||||
mermaidErrorCount: result.mermaidErrors.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadPdf(blob: Blob, fileName: string) {
|
||||
if (window.mdToPdfDesktop) {
|
||||
return window.mdToPdfDesktop.savePdf(
|
||||
fileName,
|
||||
new Uint8Array(await blob.arrayBuffer())
|
||||
);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
const wideImageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAABkAAAAOEAQMAAADDg2/hAAAABlBMVEVPRuUOpntYb4NEAAAB+klEQVR42u3bAQEAMAiAMPuXvjm4WwMCMAMAAAAAAAAAAAAAAHHvE0KECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEi5G4IAAAAAAAAAAAAAADUuRWECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiREg9BAAAAAAAAAAAAAAA6twKQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESKkHgIAAAAAAAAAAAAAAHVuBSFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRUg8BAAAAAAAAAAAAAIA6t4IQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECKmHAAAAAAAAAAAAAABAnVtBiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoTUQwAAAAAAAAAAAAAAoM6tIESIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFC6iEAAAAAAAAAAAAAABC3woXq+yBn0OwAAAAASUVORK5CYII=";
|
||||
const portraitImageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAA4QAAAcIAQMAAABevZ3IAAAABlBMVEUPdm5e6tS6V5BUAAAB3ElEQVR42u3NMQ0AAAgDsPk3DQ52cpDWQBMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACo5prRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0fhoXJUddU5DUXZIAAAAASUVORK5CYII=";
|
||||
const boundaryImageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAABLAAAANwAQMAAAAFoAG2AAAABlBMVEXCQQz7vyQwzQKmAAACUElEQVR42u3aoREAQAgDQZr8JqnyDXhkxOJwqzNXb67nMv7CwsLCwsLCwsLCwsLCwsLCwsI6sbI4+2NhYWFhYWFhYWFhYWFhYWFhYd1Y1kAsLCwsLCwsLCwsLCwsLCwsLP2WkRILCwsLCwsLCwsLCwsLCwsLS79lpMTCwsLCwsLCwsLCwsLCwsLC0m9hYWFhYWFhYWFhYWFhYWFhYem3rIFYWFhYWFhYWFhYWFhYWFhYWPotIyUWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFpd+yBmJhYWFhYWFhYWFhYWFhYWFh6beMlFhYWFhYWFhYWFhYWFhYWFhY+i0sLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhaWfstIiYWFhYWFhYWFhYWFhYWFhYWl3zJSYmFhYWFhYWFhYWFhYWFhYWHpt7CwsLCwsLCwsLCwsLCwsLCw9FvWQCwsLCwsLCwsLCwsLCwsLCws/ZaREgsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFhaXfwsLCwsLCwsLCwsLCwsLCwsLSb1kDsbCwsLCwsLCwsLCwsLCwsLD0W0ZKLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLC0u/ZaTEwsLCwsLCwsLCwsLCwsLCwtJvGSmxsLCwsLCwsLCwsLCwsLCwsPRbWFhYWFhYWFiRrA8Rs81BfKlBmwAAAABJRU5ErkJggg==";
|
||||
|
||||
export const sampleMarkdown = `---
|
||||
title: Markdown PDF 媒体分页示例
|
||||
author: 内网文档团队
|
||||
keywords:
|
||||
- Markdown
|
||||
- PDF
|
||||
- 媒体分页
|
||||
---
|
||||
|
||||
# Markdown PDF 媒体分页示例
|
||||
|
||||
这份文档集中展示常用排版、不同尺寸图片、Mermaid 与 ECharts 的渲染和分页效果。
|
||||
|
||||
## 基础排版
|
||||
|
||||
- [x] Markdown 实时预览
|
||||
- [x] 图片、Mermaid 与 ECharts
|
||||
- [x] 快速预览和精确 PDF 预览
|
||||
- [ ] 根据实际文档继续扩展主题
|
||||
|
||||
| 能力 | 示例用途 |
|
||||
| --- | --- |
|
||||
| 表格与任务列表 | 验证主题基础样式 |
|
||||
| 数学公式 | 验证字体和基线 |
|
||||
| 媒体块 | 验证限宽、缩放与分页 |
|
||||
|
||||
行内公式:$E = mc^2$。
|
||||
|
||||
> 预览与 PDF 共用相同的渲染结果、主题 CSS 和分页结构。
|
||||
|
||||
## 网络图片
|
||||
|
||||
下面只保留一张网络图片,用于验证远程资源获取与离线失败提示。
|
||||
|
||||

|
||||
|
||||
## Base64 图片块
|
||||
|
||||
### 横向图片:限宽基准
|
||||
|
||||
1600 × 900 的横图会先按正文宽度等比例收缩,收缩后的高度才是分页算法使用的基准高度。
|
||||
|
||||
![Base64 横向图片 1600×900][base64-wide]
|
||||
|
||||
### 边界图片:上一页空白回填
|
||||
|
||||
这一段用于形成稳定的分页边界。分页算法会保留标题、图注和间距的原始尺寸,只计算图片主体还能使用的高度。如果主体只需轻微缩小,并且缩放率仍在允许范围内,就尝试回填到上一页空白区域。
|
||||
|
||||
为了覆盖长文档场景,这里继续补充少量正文,使媒体块靠近默认 A4 页面底部。媒体元素必须按照源文档顺序串行处理,前一个媒体完成重排以后,后一个媒体才基于新的版面重新计算。
|
||||
|
||||
![Base64 边界图片 1200×880][base64-boundary]
|
||||
|
||||
### 纵向图片:单页高度适配
|
||||
|
||||
900 × 1800 的纵图在限宽后仍可能超过单页内容区,此时应继续等比例缩小并完整放在一页内。
|
||||
|
||||
![Base64 纵向图片 900×1800][base64-portrait]
|
||||
|
||||
## Mermaid 尺寸示例
|
||||
|
||||
### 横向流程图:限宽
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["Markdown"] --> B["统一 HTML"]
|
||||
B --> C["主题 CSS"]
|
||||
C --> D["网页预览"]
|
||||
C --> E["PDF 导出"]
|
||||
\`\`\`
|
||||
|
||||
### 纵向流程图:高度和分页
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TB
|
||||
A["读取文档"] --> B["解析 Front Matter"]
|
||||
B --> C["渲染 Markdown"]
|
||||
C --> D["渲染 Mermaid"]
|
||||
D --> E["渲染 ECharts"]
|
||||
E --> F["等待图片与字体"]
|
||||
F --> G["首次分页"]
|
||||
G --> H["串行检查媒体块"]
|
||||
H --> I["必要时重新分页"]
|
||||
I --> J["生成 PDF"]
|
||||
\`\`\`
|
||||
|
||||
## ECharts 尺寸示例
|
||||
|
||||
### 紧凑图表:40mm
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 紧凑柱状图(40mm)
|
||||
height: 40mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [研发, 产品, 设计, 运营]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [28, 18, 15, 22]
|
||||
\`\`\`
|
||||
|
||||
### 标准图表:58mm
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 产品能力树(58mm)
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: tree
|
||||
orient: LR
|
||||
label:
|
||||
position: left
|
||||
data:
|
||||
- name: 文档平台
|
||||
children:
|
||||
- name: Markdown
|
||||
children:
|
||||
- name: Mermaid
|
||||
- name: ECharts
|
||||
- name: PDF
|
||||
children:
|
||||
- name: 快速预览
|
||||
- name: 精确预览
|
||||
\`\`\`
|
||||
|
||||
大型图表的 caption 保持原尺寸,仅缩放图表主体。
|
||||
|
||||
### 大型图表:85mm
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 模块工作量占比(85mm)
|
||||
height: 85mm
|
||||
option:
|
||||
series:
|
||||
- type: treemap
|
||||
roam: false
|
||||
nodeClick: false
|
||||
breadcrumb:
|
||||
show: false
|
||||
label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
data:
|
||||
- name: 渲染
|
||||
children:
|
||||
- { name: Markdown, value: 32 }
|
||||
- { name: ECharts, value: 24 }
|
||||
- { name: Mermaid, value: 18 }
|
||||
- name: 导出
|
||||
children:
|
||||
- { name: PDF, value: 28 }
|
||||
- { name: 主题, value: 12 }
|
||||
\`\`\`
|
||||
|
||||
## 串行媒体块
|
||||
|
||||
下面连续放置图片、Mermaid 和 ECharts,用于观察长文档中多个媒体元素是否始终按源文档顺序计算。
|
||||
|
||||
![Base64 串行边界图片][base64-boundary]
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["图片完成"] --> B["Mermaid 计算"]
|
||||
B --> C["ECharts 计算"]
|
||||
\`\`\`
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 串行媒体收尾图(58mm)
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: pie
|
||||
radius: [35%, 70%]
|
||||
data:
|
||||
- { name: 图片, value: 36 }
|
||||
- { name: Mermaid, value: 28 }
|
||||
- { name: ECharts, value: 36 }
|
||||
\`\`\`
|
||||
|
||||
> 所有媒体仅在额外缩放不超过阈值时回填;否则保持完整媒体块并移动到下一页。
|
||||
|
||||
[base64-wide]: data:image/png;base64,${wideImageBase64}
|
||||
[base64-portrait]: data:image/png;base64,${portraitImageBase64}
|
||||
[base64-boundary]: data:image/png;base64,${boundaryImageBase64}
|
||||
`;
|
||||
+537
-24
@@ -24,8 +24,7 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.file-button {
|
||||
button {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #b8c2bd;
|
||||
@@ -75,6 +74,24 @@ button:disabled {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.topbar-brand-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #c8d5cf;
|
||||
border-radius: 999px;
|
||||
background: #edf3f0;
|
||||
color: #527061;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.035em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -84,13 +101,31 @@ button:disabled {
|
||||
border-left: 1px solid #d4dad7;
|
||||
}
|
||||
|
||||
.document-summary > strong {
|
||||
.document-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-name-row > strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unsaved-badge {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #d9bd86;
|
||||
border-radius: 999px;
|
||||
background: #fff7e7;
|
||||
color: #8a5a13;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.document-summary .render-status {
|
||||
max-width: 100%;
|
||||
margin-top: 3px;
|
||||
@@ -120,17 +155,236 @@ h1 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
.more-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
.more-menu-popover {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
z-index: 30;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
display: flex;
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
border: 1px solid #c8d1cc;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.more-menu-popover button {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 11px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.more-menu-popover button:hover,
|
||||
.more-menu-popover button:focus-visible {
|
||||
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;
|
||||
clip: rect(0 0 0 0);
|
||||
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 {
|
||||
@@ -430,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 {
|
||||
@@ -528,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;
|
||||
}
|
||||
@@ -562,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;
|
||||
@@ -654,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%;
|
||||
@@ -832,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;
|
||||
@@ -963,7 +1433,7 @@ textarea:focus {
|
||||
min-height: 650px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
.markdown-editor {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
@@ -1026,10 +1496,53 @@ textarea:focus {
|
||||
.editor-layout {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.export-toast-progress span {
|
||||
width: 100%;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.topbar-brand-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user