Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925b0d1485 | ||
|
|
ac54ce46be | ||
|
|
06407650e0 | ||
|
|
8d91d08809 | ||
|
|
32a37293d0 | ||
|
|
06b30d084e | ||
|
|
4d60741f5a | ||
|
|
92a5bfd016 | ||
|
|
f5a46f79f1 | ||
|
|
1db21c80fc | ||
|
|
ad18149c10 | ||
|
|
b459def232 | ||
|
|
d93728bb8b |
@@ -13,4 +13,5 @@ tmp/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
output/
|
output/
|
||||||
|
/apps/desktop/out/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
|||||||
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
|
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
|
||||||
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
|
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
|
||||||
- 支持主题清单与 CSS 主题扩展;
|
- 支持主题清单与 CSS 主题扩展;
|
||||||
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX、Mermaid 和 ECharts;
|
||||||
|
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
|
||||||
- 使用 Docker Compose 在内网部署;
|
- 使用 Docker Compose 在内网部署;
|
||||||
- 文档只在当前请求中临时处理,不建立文档历史数据库。
|
- 文档只在当前请求中临时处理,不建立文档历史数据库。
|
||||||
|
|
||||||
@@ -75,15 +76,18 @@ flowchart LR
|
|||||||
|
|
||||||
- 前端:React、TypeScript、Vite。
|
- 前端:React、TypeScript、Vite。
|
||||||
- 后端:Node.js、TypeScript、Fastify。
|
- 后端:Node.js、TypeScript、Fastify。
|
||||||
|
- 桌面端:Electron、electron-builder、NSIS。
|
||||||
|
- 应用服务:`packages/application`。
|
||||||
- 共享模型:`packages/core`。
|
- 共享模型:`packages/core`。
|
||||||
- Markdown 渲染:`packages/renderer`。
|
- Markdown 渲染:`packages/renderer`。
|
||||||
- PDF 引擎:计划使用 Playwright Chromium。
|
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
||||||
|
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
||||||
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
||||||
- 元数据:gray-matter。
|
- 元数据:gray-matter。
|
||||||
- 安全过滤:sanitize-html。
|
- 安全过滤:sanitize-html。
|
||||||
- 代码高亮:highlight.js。
|
- 代码高亮:highlight.js。
|
||||||
- 数学公式:KaTeX。
|
- 数学公式:KaTeX。
|
||||||
- 图表:Mermaid。
|
- 图表:Mermaid、ECharts。
|
||||||
- 校验:Zod。
|
- 校验:Zod。
|
||||||
- 测试:Vitest。
|
- 测试:Vitest。
|
||||||
- 部署:Docker、Docker Compose。
|
- 部署:Docker、Docker Compose。
|
||||||
@@ -92,12 +96,14 @@ flowchart LR
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
apps/web/ 前端编辑、配置与预览界面
|
apps/web/ 前端编辑、配置与预览界面
|
||||||
apps/server/ HTTP API、主题读取及未来 PDF 服务
|
apps/server/ HTTP API、主题读取和 Playwright PDF 服务
|
||||||
|
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
|
||||||
|
packages/application/ 共享应用服务、主题注册和图片资源处理
|
||||||
packages/core/ 导出配置和主题清单等共享模型
|
packages/core/ 导出配置和主题清单等共享模型
|
||||||
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
||||||
themes/ 内置主题及未来可安装主题
|
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
||||||
docker/ 容器化配置
|
themes/ 内置主题
|
||||||
tests/ 跨包和端到端测试
|
deploy/ Docker、Compose、Nginx 和部署文档
|
||||||
docs/ 进度、架构和部署文档
|
docs/ 进度、架构和部署文档
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -186,6 +192,11 @@ chore: 初始化项目骨架
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 不修改或重写已有提交,除非用户明确要求。
|
- 不修改或重写已有提交,除非用户明确要求。
|
||||||
|
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
|
||||||
|
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
|
||||||
|
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
|
||||||
|
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
|
||||||
|
记录本版本更新,不得使用只有版本号的标签说明。
|
||||||
|
|
||||||
## 9. 当前交接入口
|
## 9. 当前交接入口
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,90 @@
|
|||||||
# Markdown PDF 导出器
|
# Markdown PDF 导出器
|
||||||
|
|
||||||
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。
|
一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
|
||||||
|
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
|
||||||
|
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
|
||||||
|
|
||||||
## 当前状态
|
当前版本:`v0.4.5`。
|
||||||
|
|
||||||
已经实现:
|
## v0.4.5 主要能力
|
||||||
|
|
||||||
- React Markdown 编辑与本地文件读取;
|
- 新建、打开和保存 Markdown;新文档可直接导出 PDF;
|
||||||
- Fastify 预览接口;
|
- Windows 注册 `.md`、`.markdown` 文件关联,系统打开文件时自动加载;
|
||||||
- 共享导出配置模型;
|
- 桌面窗口位置、尺寸和最大化状态延迟持久化,并在多显示器变化后自动
|
||||||
- 动态主题清单、CSS 和静态资源接口;
|
修正到可见工作区;
|
||||||
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
- “打开 Markdown”保留为独立主按钮,新建、保存和主题目录收纳在“更多”
|
||||||
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
菜单;
|
||||||
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
|
- 桌面端提供可直接放置自定义主题的本地目录;
|
||||||
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
|
- 图片、Mermaid 和 ECharts 采用串行媒体分页:每个媒体块至多重排一次,
|
||||||
- 五种纸张、页边距、页眉、页码和浏览器配置缓存;
|
严格保持文档顺序;
|
||||||
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
|
- 媒体先按内容限宽计算基准高度。当上一页空白接近媒体所需空间时,允许
|
||||||
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
|
将媒体内容等比例缩小回填;图片标题或图表标题保持原字号且与媒体成块;
|
||||||
|
- 示例文档覆盖横图、竖图、Base64 图片、Mermaid、ECharts 以及不同尺寸
|
||||||
|
的分页边界。
|
||||||
|
|
||||||
本地相对资源文件处理将在后续阶段实现。
|
## Web 与桌面端
|
||||||
|
|
||||||
|
| 能力 | Web / Docker | Windows Desktop |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新建 Markdown | 支持 | 支持 |
|
||||||
|
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||||
|
| 保存 Markdown | 浏览器下载 | 原生另存为 |
|
||||||
|
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||||
|
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||||
|
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||||
|
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||||
|
|
||||||
|
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
||||||
|
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
||||||
|
|
||||||
|
## 渲染架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
||||||
|
B --> C["统一 #write HTML"]
|
||||||
|
D["主题 CSS 与导出配置"] --> C
|
||||||
|
C --> E["共享分页运行时"]
|
||||||
|
E --> F["Web 快速预览"]
|
||||||
|
E --> G["Playwright Chromium"]
|
||||||
|
E --> H["Electron Chromium"]
|
||||||
|
G --> I["Web PDF"]
|
||||||
|
H --> J["Desktop PDF"]
|
||||||
|
```
|
||||||
|
|
||||||
|
快速预览适合编辑反馈;精确预览直接展示服务端生成的 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 运行时
|
||||||
|
themes/ 内置主题与主题清单
|
||||||
|
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
||||||
|
docs/ 项目进度与主题开发说明
|
||||||
|
scripts/ 主题导入等维护脚本
|
||||||
|
logos/ 应用图标源文件
|
||||||
|
.local/themes/ 本地开发和 Compose 自定义主题目录
|
||||||
|
```
|
||||||
|
|
||||||
|
包依赖保持单向:`core` 提供基础协议;`markdown-echarts` 提供独立图表
|
||||||
|
能力;`renderer` 消费两者生成安全 HTML;`application` 组合渲染器、主题
|
||||||
|
和资源用例;三个 `apps/*` 只负责各自平台的界面、传输与 PDF 适配。
|
||||||
|
|
||||||
|
各包的目录和用法:
|
||||||
|
|
||||||
|
- [application](packages/application/README.md)
|
||||||
|
- [core](packages/core/README.md)
|
||||||
|
- [renderer](packages/renderer/README.md)
|
||||||
|
- [markdown-echarts](packages/markdown-echarts/README.md)
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -31,83 +97,67 @@ npm run dev
|
|||||||
|
|
||||||
默认地址:
|
默认地址:
|
||||||
|
|
||||||
- 前端:http://localhost:5173
|
- Web:http://localhost:5173
|
||||||
- 后端:http://localhost:3001
|
- API:http://localhost:3001
|
||||||
- 健康检查:http://localhost:3001/api/health
|
- 健康检查:http://localhost:3001/api/health
|
||||||
|
|
||||||
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
|
桌面端开发:
|
||||||
|
|
||||||
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
|
```powershell
|
||||||
|
npm run desktop:dev
|
||||||
## Mermaid 长标签
|
|
||||||
|
|
||||||
流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
flowchart:
|
|
||||||
wrappingWidth: 360
|
|
||||||
---
|
|
||||||
flowchart TB
|
|
||||||
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
需要自动换行时可以使用 Mermaid Markdown String:
|
桌面端目录包和 Windows 安装包:
|
||||||
|
|
||||||
````markdown
|
```powershell
|
||||||
```mermaid
|
npm run desktop:package
|
||||||
flowchart LR
|
npm run make -w @md-to-pdf/desktop
|
||||||
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`:
|
产物位于 `apps/desktop/out/v0.4.5/`。正式安装包命名为
|
||||||
|
`md-to-pdf-0.4.5-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)。
|
||||||
|
|
||||||
|
## 主题
|
||||||
|
|
||||||
|
仓库内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题。主题由
|
||||||
|
版本化 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||||
|
|
||||||
|
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
||||||
|
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
|
||||||
|
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
|
||||||
|
|
||||||
|
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
|
||||||
|
刷新本地开发副本:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run theme:import-typora
|
npm run theme:import-typora
|
||||||
```
|
```
|
||||||
|
|
||||||
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。
|
## Docker Compose 部署
|
||||||
|
|
||||||
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run theme:import-typora -- --replace
|
|
||||||
```
|
|
||||||
|
|
||||||
旧副本会移动到 `.local\theme-backups`。
|
|
||||||
|
|
||||||
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
|
|
||||||
|
|
||||||
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
|
|
||||||
|
|
||||||
## Docker Desktop 部署
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f deploy\compose.yaml up --build -d
|
docker compose -f deploy\compose.yaml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
|
默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
|
||||||
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
|
配置详见 [AIO 容器部署说明](deploy/README.md)。
|
||||||
[AIO 容器部署说明](deploy/README.md)。
|
|
||||||
|
|
||||||
## 测试与构建
|
## 验证
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm test
|
npm test
|
||||||
@@ -116,7 +166,18 @@ npm run build
|
|||||||
git diff --check
|
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,73 @@
|
|||||||
|
# Desktop 桌面端
|
||||||
|
|
||||||
|
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
|
||||||
|
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
|
||||||
|
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
|
||||||
|
安装包。
|
||||||
|
|
||||||
|
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||||
|
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||||
|
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||||
|
双击文件或使用“打开方式”会复用已有应用窗口并加载文档。
|
||||||
|
|
||||||
|
“打开 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
|
||||||
|
```
|
||||||
|
|
||||||
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
|
`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以
|
||||||
|
`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
|
提示。
|
||||||
|
|
||||||
|
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
|
||||||
|
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
|
||||||
|
发布,不提交进 Git 历史。
|
||||||
|
|
||||||
|
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
|
||||||
|
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
|
||||||
|
|
||||||
|
产品名称、窗口标题、安装包名称和开始菜单名称保持为
|
||||||
|
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
|
||||||
|
`md-to-pdf.exe`。
|
||||||
|
|
||||||
|
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。
|
||||||
|
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||||
|
`mdpdf://bundle/` 自定义协议加载。
|
||||||
|
|
||||||
|
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||||
|
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||||
|
- 启用 Context Isolation 和 Chromium Sandbox;
|
||||||
|
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
|
||||||
|
的窄接口;
|
||||||
|
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
|
||||||
|
链接越界;
|
||||||
|
- 主进程校验 IPC 发送者和分页载荷;
|
||||||
|
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
|
||||||
|
- 应用页面使用自定义协议,不使用 `file://`。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
!macro customInstall
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown" \
|
||||||
|
"" \
|
||||||
|
"Markdown 文档"
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
|
||||||
|
"" \
|
||||||
|
'"$INSTDIR\md-to-pdf.exe",0'
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
|
||||||
|
"" \
|
||||||
|
'"$INSTDIR\md-to-pdf.exe" "%1"'
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\.md\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown" \
|
||||||
|
""
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\.markdown\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown" \
|
||||||
|
""
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\Applications\md-to-pdf.exe\shell\open\command" \
|
||||||
|
"" \
|
||||||
|
'"$INSTDIR\md-to-pdf.exe" "%1"'
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
|
||||||
|
".md" \
|
||||||
|
""
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
|
||||||
|
".markdown" \
|
||||||
|
""
|
||||||
|
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
|
||||||
|
CreateShortCut \
|
||||||
|
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
|
||||||
|
"$INSTDIR\md-to-pdf.exe" \
|
||||||
|
"" \
|
||||||
|
"$INSTDIR\md-to-pdf.exe"
|
||||||
|
CreateShortCut \
|
||||||
|
"$DESKTOP\Markdown PDF 导出器.lnk" \
|
||||||
|
"$INSTDIR\md-to-pdf.exe" \
|
||||||
|
"" \
|
||||||
|
"$INSTDIR\md-to-pdf.exe"
|
||||||
|
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
|
||||||
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro customUnInstall
|
||||||
|
DeleteRegValue SHCTX \
|
||||||
|
"Software\Classes\.md\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown"
|
||||||
|
DeleteRegValue SHCTX \
|
||||||
|
"Software\Classes\.markdown\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown"
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\Applications\md-to-pdf.exe"
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown"
|
||||||
|
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
|
||||||
|
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
|
||||||
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
|
!macroend
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
const { version } = require("./package.json");
|
||||||
|
|
||||||
|
const windowsIcon = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../logos/desktop/windows/app.ico"
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appId: "com.md-to-pdf.desktop",
|
||||||
|
productName: "Markdown PDF 导出器",
|
||||||
|
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: 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: "md-to-pdf",
|
||||||
|
artifactName: `md-to-pdf-${version}-x86_64.\${ext}`
|
||||||
|
},
|
||||||
|
nsis: {
|
||||||
|
oneClick: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
perMachine: false,
|
||||||
|
allowElevation: true,
|
||||||
|
createDesktopShortcut: false,
|
||||||
|
createStartMenuShortcut: false,
|
||||||
|
shortcutName: "Markdown PDF 导出器",
|
||||||
|
uninstallDisplayName: "Markdown PDF 导出器",
|
||||||
|
installerIcon: windowsIcon,
|
||||||
|
uninstallerIcon: windowsIcon,
|
||||||
|
installerLanguages: ["zh_CN"],
|
||||||
|
include: "build/installer.nsh",
|
||||||
|
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@md-to-pdf/desktop",
|
||||||
|
"version": "0.4.5",
|
||||||
|
"private": true,
|
||||||
|
"productName": "Markdown PDF 导出器",
|
||||||
|
"description": "Markdown PDF 导出器桌面端",
|
||||||
|
"author": "YIXIONG Tech.ltd",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||||
|
"build:main": "node scripts/build-main.mjs",
|
||||||
|
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||||
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
|
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||||
|
"package": "npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||||
|
"make": "npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@md-to-pdf/application": "0.4.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"electron": "43.2.0",
|
||||||
|
"electron-builder": "26.15.3",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"wait-on": "^9.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ["src/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
platform: "node",
|
||||||
|
format: "esm",
|
||||||
|
external: ["electron"],
|
||||||
|
outfile: "dist/main.js",
|
||||||
|
banner: {
|
||||||
|
js: [
|
||||||
|
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
|
||||||
|
"const require = __mdToPdfCreateRequire(import.meta.url);"
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import type { PagedDocumentPayload } from "@md-to-pdf/core";
|
||||||
|
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
DESKTOP_GENERATE_PDF,
|
||||||
|
DESKTOP_LIST_THEMES,
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
|
DESKTOP_REFRESH_THEMES,
|
||||||
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
|
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: () => void) => {
|
||||||
|
const handleOpened = () => listener();
|
||||||
|
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
|
},
|
||||||
|
startNewMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
||||||
|
saveMarkdown: (fileName: string, markdown: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, 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)
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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("/")
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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_START_NEW_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:start-new-markdown";
|
||||||
|
export const DESKTOP_SAVE_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:save-markdown";
|
||||||
|
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 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";
|
||||||
@@ -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,684 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
Menu,
|
||||||
|
net,
|
||||||
|
protocol,
|
||||||
|
screen,
|
||||||
|
session,
|
||||||
|
shell,
|
||||||
|
type IpcMainInvokeEvent
|
||||||
|
} from "electron";
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
mkdir,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createApplicationService,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH,
|
||||||
|
type ApplicationService
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
DESKTOP_GENERATE_PDF,
|
||||||
|
DESKTOP_LIST_THEMES,
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
|
DESKTOP_REFRESH_THEMES,
|
||||||
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_PDF,
|
||||||
|
DESKTOP_START_NEW_MARKDOWN
|
||||||
|
} from "./channels.js";
|
||||||
|
import {
|
||||||
|
parseMarkdownRenderRequest,
|
||||||
|
parseThemeId,
|
||||||
|
parseThemeResourceUrl
|
||||||
|
} from "./application-contract.js";
|
||||||
|
import {
|
||||||
|
DESKTOP_PDF_PARTITION,
|
||||||
|
ElectronPdfGenerator
|
||||||
|
} from "./electron-pdf-generator.js";
|
||||||
|
import { parsePagedDocumentPayload } from "./pdf-contract.js";
|
||||||
|
import {
|
||||||
|
findMarkdownFileArgument,
|
||||||
|
isMarkdownFilePath,
|
||||||
|
readMarkdownDocument,
|
||||||
|
type OpenedMarkdownDocument
|
||||||
|
} from "./markdown-file.js";
|
||||||
|
import {
|
||||||
|
loadWindowState,
|
||||||
|
resolveWindowBounds,
|
||||||
|
saveWindowState,
|
||||||
|
type WindowState
|
||||||
|
} from "./window-state.js";
|
||||||
|
|
||||||
|
const APP_SCHEME = "mdpdf";
|
||||||
|
const APP_HOST = "bundle";
|
||||||
|
const THEME_HOST = "theme";
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||||
|
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||||
|
let applicationController:
|
||||||
|
| {
|
||||||
|
window: BrowserWindow;
|
||||||
|
openMarkdownFile(filePath: string): Promise<void>;
|
||||||
|
flushWindowState(): Promise<void>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||||
|
process.argv,
|
||||||
|
process.cwd()
|
||||||
|
);
|
||||||
|
|
||||||
|
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 focusApplicationWindow() {
|
||||||
|
const window = applicationController?.window;
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.isMinimized()) {
|
||||||
|
window.restore();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchExternalMarkdown(filePath: string) {
|
||||||
|
if (!applicationController) {
|
||||||
|
pendingExternalMarkdownPath = filePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void applicationController.openMarkdownFile(filePath).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
focusApplicationWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopThemeDirectory() {
|
||||||
|
return app.isPackaged
|
||||||
|
? path.join(app.getPath("userData"), "themes")
|
||||||
|
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`,
|
||||||
|
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 !== 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)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
|
||||||
|
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||||
|
window.webContents.on("will-navigate", (event, targetUrl) => {
|
||||||
|
if (new URL(targetUrl).origin !== allowedOrigin) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const applicationSession = window.webContents.session;
|
||||||
|
applicationSession.setPermissionCheckHandler(() => false);
|
||||||
|
applicationSession.setPermissionRequestHandler(
|
||||||
|
(_webContents, _permission, callback) => callback(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApplication(
|
||||||
|
applicationService: ApplicationService
|
||||||
|
) {
|
||||||
|
const developmentUrl = getCommandLineValue("web-url");
|
||||||
|
const applicationUrl =
|
||||||
|
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
||||||
|
const renderUrl = new URL(
|
||||||
|
"/preview-frame.html?target=pdf",
|
||||||
|
applicationUrl
|
||||||
|
).href;
|
||||||
|
const windowStatePath = path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"window-state.json"
|
||||||
|
);
|
||||||
|
const savedWindowState = await loadWindowState(windowStatePath);
|
||||||
|
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
|
||||||
|
const initialBounds = resolveWindowBounds(
|
||||||
|
savedWindowState?.bounds,
|
||||||
|
screen.getAllDisplays().map((display) => display.workArea),
|
||||||
|
primaryWorkArea,
|
||||||
|
DEFAULT_WINDOW_SIZE,
|
||||||
|
MINIMUM_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
||||||
|
icon: getApplicationIcon(),
|
||||||
|
...initialBounds,
|
||||||
|
minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width),
|
||||||
|
minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height),
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
preload: path.join(currentDirectory, "app-preload.cjs")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let saveWindowStateTimer: NodeJS.Timeout | undefined;
|
||||||
|
let pendingWindowStateWrite = Promise.resolve();
|
||||||
|
let latestWindowState: WindowState = {
|
||||||
|
version: 1,
|
||||||
|
bounds: window.getNormalBounds(),
|
||||||
|
maximized: window.isMaximized()
|
||||||
|
};
|
||||||
|
const captureWindowState = () => {
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
latestWindowState = {
|
||||||
|
version: 1,
|
||||||
|
bounds: window.getNormalBounds(),
|
||||||
|
maximized: window.isMaximized()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const persistWindowState = () => {
|
||||||
|
const state = structuredClone(latestWindowState);
|
||||||
|
pendingWindowStateWrite = pendingWindowStateWrite
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => saveWindowState(windowStatePath, state))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn("无法保存窗口状态", error);
|
||||||
|
});
|
||||||
|
return pendingWindowStateWrite;
|
||||||
|
};
|
||||||
|
const scheduleWindowStateSave = () => {
|
||||||
|
captureWindowState();
|
||||||
|
if (saveWindowStateTimer) {
|
||||||
|
clearTimeout(saveWindowStateTimer);
|
||||||
|
}
|
||||||
|
saveWindowStateTimer = setTimeout(() => {
|
||||||
|
saveWindowStateTimer = undefined;
|
||||||
|
void persistWindowState();
|
||||||
|
}, 2_000);
|
||||||
|
};
|
||||||
|
const flushWindowState = async () => {
|
||||||
|
if (saveWindowStateTimer) {
|
||||||
|
clearTimeout(saveWindowStateTimer);
|
||||||
|
saveWindowStateTimer = undefined;
|
||||||
|
}
|
||||||
|
captureWindowState();
|
||||||
|
await persistWindowState();
|
||||||
|
};
|
||||||
|
window.on("move", scheduleWindowStateSave);
|
||||||
|
window.on("resize", scheduleWindowStateSave);
|
||||||
|
window.on("maximize", scheduleWindowStateSave);
|
||||||
|
window.on("unmaximize", scheduleWindowStateSave);
|
||||||
|
lockDownWindow(window, new URL(applicationUrl).origin);
|
||||||
|
|
||||||
|
const pdfGenerator = new ElectronPdfGenerator({
|
||||||
|
renderUrl,
|
||||||
|
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
|
||||||
|
});
|
||||||
|
const validateSender = (event: IpcMainInvokeEvent) =>
|
||||||
|
event.sender === window.webContents &&
|
||||||
|
event.senderFrame !== null &&
|
||||||
|
new URL(event.senderFrame.url).origin ===
|
||||||
|
new URL(applicationUrl).origin;
|
||||||
|
let documentRoot: string | undefined;
|
||||||
|
let currentMarkdownPath: string | undefined;
|
||||||
|
let pendingOpenedMarkdown:
|
||||||
|
| {
|
||||||
|
document: OpenedMarkdownDocument;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const queueOpenedMarkdown = async (filePath: string) => {
|
||||||
|
const opened = await readMarkdownDocument(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
pendingOpenedMarkdown = {
|
||||||
|
document: opened,
|
||||||
|
filePath
|
||||||
|
};
|
||||||
|
window.webContents.send(DESKTOP_MARKDOWN_OPENED);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
const selection = await dialog.showOpenDialog(window, {
|
||||||
|
title: "打开 Markdown",
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [
|
||||||
|
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const filePath = selection.filePaths[0];
|
||||||
|
if (selection.canceled || !filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const opened = await readMarkdownDocument(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
documentRoot = path.dirname(filePath);
|
||||||
|
currentMarkdownPath = filePath;
|
||||||
|
return opened;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
const pending = pendingOpenedMarkdown;
|
||||||
|
pendingOpenedMarkdown = undefined;
|
||||||
|
if (!pending) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
currentMarkdownPath = pending.filePath;
|
||||||
|
documentRoot = path.dirname(pending.filePath);
|
||||||
|
return pending.document;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
pendingOpenedMarkdown = undefined;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
currentMarkdownPath = undefined;
|
||||||
|
documentRoot = undefined;
|
||||||
|
pendingOpenedMarkdown = undefined;
|
||||||
|
});
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
unsafeFileName: unknown,
|
||||||
|
unsafeMarkdown: unknown
|
||||||
|
) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof unsafeFileName !== "string" ||
|
||||||
|
unsafeFileName.length === 0 ||
|
||||||
|
unsafeFileName.length > 500 ||
|
||||||
|
typeof unsafeMarkdown !== "string" ||
|
||||||
|
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
) {
|
||||||
|
throw new Error("Markdown 保存参数无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPath = currentMarkdownPath;
|
||||||
|
if (!targetPath) {
|
||||||
|
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
||||||
|
path.basename(unsafeFileName)
|
||||||
|
)
|
||||||
|
? path.basename(unsafeFileName)
|
||||||
|
: `${path.basename(unsafeFileName)}.md`;
|
||||||
|
const selection = await dialog.showSaveDialog(window, {
|
||||||
|
title: "保存 Markdown",
|
||||||
|
defaultPath: path.join(
|
||||||
|
app.getPath("documents"),
|
||||||
|
suggestedName
|
||||||
|
),
|
||||||
|
filters: [
|
||||||
|
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if (selection.canceled || !selection.filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
|
||||||
|
? selection.filePath
|
||||||
|
: `${selection.filePath}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(targetPath, unsafeMarkdown, "utf8");
|
||||||
|
currentMarkdownPath = targetPath;
|
||||||
|
documentRoot = path.dirname(targetPath);
|
||||||
|
return { fileName: path.basename(targetPath) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面主题请求");
|
||||||
|
}
|
||||||
|
const themeDirectory = getDesktopThemeDirectory();
|
||||||
|
await mkdir(themeDirectory, { recursive: true });
|
||||||
|
const errorMessage = await shell.openPath(themeDirectory);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(`无法打开自定义主题目录:${errorMessage}`);
|
||||||
|
}
|
||||||
|
return themeDirectory;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面主题请求");
|
||||||
|
}
|
||||||
|
applicationService.invalidateThemes();
|
||||||
|
});
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
async (event, unsafeRequest: unknown) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面渲染请求");
|
||||||
|
}
|
||||||
|
return applicationService.render(
|
||||||
|
parseMarkdownRenderRequest(unsafeRequest),
|
||||||
|
documentRoot ? { localRoot: documentRoot } : {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面主题请求");
|
||||||
|
}
|
||||||
|
return applicationService.listThemes();
|
||||||
|
});
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
async (event, unsafeThemeId: unknown) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面主题请求");
|
||||||
|
}
|
||||||
|
const css = await applicationService.getThemeCss(
|
||||||
|
parseThemeId(unsafeThemeId)
|
||||||
|
);
|
||||||
|
if (css === undefined) {
|
||||||
|
throw new Error("未找到指定主题");
|
||||||
|
}
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_GENERATE_PDF,
|
||||||
|
async (event, unsafePayload: unknown) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面 PDF 请求");
|
||||||
|
}
|
||||||
|
const payload = parsePagedDocumentPayload(unsafePayload);
|
||||||
|
const result = await pdfGenerator.generate(payload);
|
||||||
|
return {
|
||||||
|
pdf: result.pdf,
|
||||||
|
pageCount: result.pageCount,
|
||||||
|
echartsErrors: result.echartsErrors,
|
||||||
|
mermaidErrors: result.mermaidErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_PDF,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
unsafeFileName: unknown,
|
||||||
|
unsafePdf: unknown
|
||||||
|
) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面 PDF 保存请求");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof unsafeFileName !== "string" ||
|
||||||
|
unsafeFileName.length === 0 ||
|
||||||
|
unsafeFileName.length > 500 ||
|
||||||
|
!(unsafePdf instanceof Uint8Array) ||
|
||||||
|
unsafePdf.byteLength === 0 ||
|
||||||
|
unsafePdf.byteLength > 200 * 1024 * 1024
|
||||||
|
) {
|
||||||
|
throw new Error("PDF 保存参数无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
|
||||||
|
? path.basename(unsafeFileName)
|
||||||
|
: `${path.basename(unsafeFileName)}.pdf`;
|
||||||
|
const selection = await dialog.showSaveDialog(window, {
|
||||||
|
title: "导出 PDF",
|
||||||
|
defaultPath: path.join(app.getPath("documents"), suggestedName),
|
||||||
|
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
|
||||||
|
});
|
||||||
|
if (selection.canceled || !selection.filePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await writeFile(selection.filePath, unsafePdf);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
window.once("ready-to-show", () => {
|
||||||
|
if (savedWindowState?.maximized) {
|
||||||
|
window.maximize();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
});
|
||||||
|
window.on("close", () => {
|
||||||
|
void flushWindowState();
|
||||||
|
});
|
||||||
|
window.on("closed", () => {
|
||||||
|
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY);
|
||||||
|
ipcMain.removeHandler(DESKTOP_REFRESH_THEMES);
|
||||||
|
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
|
||||||
|
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
|
||||||
|
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
|
||||||
|
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
|
||||||
|
void pdfGenerator.close();
|
||||||
|
});
|
||||||
|
await window.loadURL(applicationUrl);
|
||||||
|
return {
|
||||||
|
window,
|
||||||
|
openMarkdownFile: queueOpenedMarkdown,
|
||||||
|
flushWindowState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
let windowStateReadyToQuit = false;
|
||||||
|
|
||||||
|
if (!hasSingleInstanceLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", (_event, arguments_, workingDirectory) => {
|
||||||
|
const filePath = findMarkdownFileArgument(
|
||||||
|
arguments_,
|
||||||
|
workingDirectory
|
||||||
|
);
|
||||||
|
if (filePath) {
|
||||||
|
dispatchExternalMarkdown(filePath);
|
||||||
|
} else {
|
||||||
|
focusApplicationWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("open-file", (event, filePath) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isMarkdownFilePath(filePath)) {
|
||||||
|
dispatchExternalMarkdown(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("before-quit", (event) => {
|
||||||
|
if (windowStateReadyToQuit || !applicationController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void applicationController.flushWindowState().finally(() => {
|
||||||
|
windowStateReadyToQuit = true;
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app
|
||||||
|
.whenReady()
|
||||||
|
.then(async () => {
|
||||||
|
Menu.setApplicationMenu(null);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||||
|
}
|
||||||
|
await mkdir(getDesktopThemeDirectory(), { recursive: true });
|
||||||
|
const applicationService = createDesktopApplicationService();
|
||||||
|
await registerApplicationProtocol(applicationService);
|
||||||
|
applicationController =
|
||||||
|
await createApplication(applicationService);
|
||||||
|
const startupMarkdownPath = pendingExternalMarkdownPath;
|
||||||
|
pendingExternalMarkdownPath = undefined;
|
||||||
|
if (startupMarkdownPath) {
|
||||||
|
await applicationController.openMarkdownFile(
|
||||||
|
startupMarkdownPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("桌面应用启动失败", error);
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||||
|
|
||||||
|
export interface OpenedMarkdownDocument {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
exportConfigSchema,
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
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:" && requested.host === "theme") {
|
||||||
|
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,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,75 @@
|
|||||||
|
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 {
|
||||||
|
findMarkdownFileArgument,
|
||||||
|
isMarkdownFilePath,
|
||||||
|
readMarkdownDocument
|
||||||
|
} 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 文件");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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,
|
||||||
|
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"
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { performance } from "node:perf_hooks";
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_VERSION,
|
EXPORT_CONFIG_VERSION,
|
||||||
|
createPagedDocumentPayload,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
exportConfigSchema,
|
exportConfigSchema,
|
||||||
supportedPaperFormats
|
supportedPaperFormats
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer";
|
import {
|
||||||
|
ApplicationRequestError,
|
||||||
|
createApplicationService,
|
||||||
|
type ApplicationService
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||||
import {
|
import {
|
||||||
createPdfGenerator,
|
createPdfGenerator,
|
||||||
PdfEngineClosedError,
|
PdfEngineClosedError,
|
||||||
@@ -16,11 +22,11 @@ import {
|
|||||||
PdfRenderTimeoutError,
|
PdfRenderTimeoutError,
|
||||||
type PdfGenerator
|
type PdfGenerator
|
||||||
} from "./pdf-engine.js";
|
} from "./pdf-engine.js";
|
||||||
import { createThemeRegistry } from "./theme-registry.js";
|
|
||||||
|
|
||||||
interface RenderRequestBody {
|
interface RenderRequestBody {
|
||||||
markdown?: unknown;
|
markdown?: unknown;
|
||||||
language?: unknown;
|
language?: unknown;
|
||||||
|
resources?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PdfRequestBody extends RenderRequestBody {
|
interface PdfRequestBody extends RenderRequestBody {
|
||||||
@@ -29,43 +35,6 @@ interface PdfRequestBody extends RenderRequestBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
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 encodeRfc5987(value: string) {
|
function encodeRfc5987(value: string) {
|
||||||
return encodeURIComponent(value).replace(
|
return encodeURIComponent(value).replace(
|
||||||
/[!'()*]/g,
|
/[!'()*]/g,
|
||||||
@@ -99,6 +68,7 @@ export interface BuildAppOptions {
|
|||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
pdfGenerator?: PdfGenerator;
|
pdfGenerator?: PdfGenerator;
|
||||||
prewarmPdfBrowser?: boolean;
|
prewarmPdfBrowser?: boolean;
|
||||||
|
applicationService?: ApplicationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
function milliseconds(value: number) {
|
function milliseconds(value: number) {
|
||||||
@@ -121,6 +91,8 @@ function createPdfServerTiming(
|
|||||||
`navigation;dur=${milliseconds(timings.navigationMs)}`,
|
`navigation;dur=${milliseconds(timings.navigationMs)}`,
|
||||||
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
|
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
|
||||||
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
|
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
|
||||||
|
`echarts;dur=${milliseconds(timings.echartsMs)}`,
|
||||||
|
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
|
||||||
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
|
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
|
||||||
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
|
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
|
||||||
`mermaid-conversion;dur=${milliseconds(
|
`mermaid-conversion;dur=${milliseconds(
|
||||||
@@ -137,13 +109,16 @@ function createPdfServerTiming(
|
|||||||
export function buildApp(options: BuildAppOptions = {}) {
|
export function buildApp(options: BuildAppOptions = {}) {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: options.logger ?? true,
|
logger: options.logger ?? true,
|
||||||
bodyLimit: 2 * 1024 * 1024
|
bodyLimit: 24 * 1024 * 1024
|
||||||
});
|
|
||||||
const themes = createThemeRegistry({
|
|
||||||
bundledRoot: resolve(projectRoot, "themes"),
|
|
||||||
localRoot:
|
|
||||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes")
|
|
||||||
});
|
});
|
||||||
|
const applicationService =
|
||||||
|
options.applicationService ??
|
||||||
|
createApplicationService({
|
||||||
|
bundledRoot: resolve(projectRoot, "themes"),
|
||||||
|
localRoot:
|
||||||
|
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||||
|
onWarning: (message) => app.log.warn(message)
|
||||||
|
});
|
||||||
const pdfGenerator =
|
const pdfGenerator =
|
||||||
options.pdfGenerator ?? createPdfGenerator();
|
options.pdfGenerator ?? createPdfGenerator();
|
||||||
|
|
||||||
@@ -182,23 +157,16 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
planned: []
|
planned: []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/themes", async () => ({
|
app.get("/api/themes", async () =>
|
||||||
themes: (await themes.list()).map(({ manifest, source }) => ({
|
applicationService.listThemes()
|
||||||
id: manifest.id,
|
);
|
||||||
name: manifest.name,
|
|
||||||
version: manifest.version,
|
|
||||||
description: manifest.description,
|
|
||||||
bundled: manifest.bundled,
|
|
||||||
source
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.get<{ Params: { themeId: string } }>(
|
app.get<{ Params: { themeId: string } }>(
|
||||||
"/api/themes/:themeId/css",
|
"/api/themes/:themeId/css",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { themeId } = request.params;
|
const { themeId } = request.params;
|
||||||
|
|
||||||
const css = await themes.getCss(themeId);
|
const css = await applicationService.getThemeCss(themeId);
|
||||||
if (css === undefined) {
|
if (css === undefined) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
error: "THEME_NOT_FOUND",
|
error: "THEME_NOT_FOUND",
|
||||||
@@ -217,7 +185,7 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
"/api/themes/:themeId/assets/*",
|
"/api/themes/:themeId/assets/*",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const asset = await themes.getAsset(
|
const asset = await applicationService.getThemeAsset(
|
||||||
request.params.themeId,
|
request.params.themeId,
|
||||||
request.params["*"]
|
request.params["*"]
|
||||||
);
|
);
|
||||||
@@ -246,17 +214,17 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
app.post<{ Body: RenderRequestBody }>(
|
app.post<{ Body: RenderRequestBody }>(
|
||||||
"/api/render",
|
"/api/render",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const validated = validateMarkdownRequest(request.body);
|
try {
|
||||||
if ("statusCode" in validated) {
|
return await applicationService.render(request.body ?? {});
|
||||||
return reply.code(validated.statusCode).send({
|
} catch (error) {
|
||||||
error: validated.error,
|
if (error instanceof ApplicationRequestError) {
|
||||||
message: validated.message
|
return reply.code(error.statusCode).send({
|
||||||
});
|
error: error.code,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderMarkdown(validated.markdown, {
|
|
||||||
...(validated.language ? { language: validated.language } : {})
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -264,14 +232,6 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
"/api/pdf",
|
"/api/pdf",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const requestStartedAt = performance.now();
|
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(
|
const parsedConfig = exportConfigSchema.safeParse(
|
||||||
request.body?.exportConfig
|
request.body?.exportConfig
|
||||||
);
|
);
|
||||||
@@ -294,7 +254,9 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const themeStartedAt = performance.now();
|
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;
|
const themeMs = performance.now() - themeStartedAt;
|
||||||
if (themeCss === undefined) {
|
if (themeCss === undefined) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -304,23 +266,32 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markdownStartedAt = performance.now();
|
const markdownStartedAt = performance.now();
|
||||||
const rendered = renderMarkdown(validated.markdown, {
|
let rendered;
|
||||||
...(validated.language ? { language: validated.language } : {})
|
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 markdownMs = performance.now() - markdownStartedAt;
|
const markdownMs = performance.now() - markdownStartedAt;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const generated = await pdfGenerator.generate({
|
const generated = await pdfGenerator.generate(
|
||||||
articleHtml: rendered.articleHtml,
|
createPagedDocumentPayload({
|
||||||
fileName:
|
document: rendered,
|
||||||
typeof request.body.fileName === "string"
|
fileName:
|
||||||
? request.body.fileName
|
typeof request.body.fileName === "string"
|
||||||
: "文档.md",
|
? request.body.fileName
|
||||||
metadata: rendered.metadata,
|
: "文档.md",
|
||||||
features: rendered.features,
|
themeCss,
|
||||||
themeCss,
|
exportConfig: parsedConfig.data
|
||||||
exportConfig: parsedConfig.data
|
})
|
||||||
});
|
);
|
||||||
const pdfFileName = createPdfFileName(request.body.fileName);
|
const pdfFileName = createPdfFileName(request.body.fileName);
|
||||||
const requestMs = performance.now() - requestStartedAt;
|
const requestMs = performance.now() - requestStartedAt;
|
||||||
const serverTiming = createPdfServerTiming(
|
const serverTiming = createPdfServerTiming(
|
||||||
@@ -352,6 +323,10 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
.header("cache-control", "no-store")
|
.header("cache-control", "no-store")
|
||||||
.header("server-timing", serverTiming)
|
.header("server-timing", serverTiming)
|
||||||
.header("x-pdf-page-count", String(generated.pageCount))
|
.header("x-pdf-page-count", String(generated.pageCount))
|
||||||
|
.header(
|
||||||
|
"x-echarts-error-count",
|
||||||
|
String(generated.echartsErrors.length)
|
||||||
|
)
|
||||||
.header(
|
.header(
|
||||||
"x-mermaid-error-count",
|
"x-mermaid-error-count",
|
||||||
String(generated.mermaidErrors.length)
|
String(generated.mermaidErrors.length)
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { chromium, type Browser } from "playwright";
|
import { chromium, type Browser } from "playwright";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import type { ExportConfig } from "@md-to-pdf/core";
|
import type {
|
||||||
|
PagedDocumentPayload,
|
||||||
|
PagedDocumentRenderResult
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export interface PdfRenderPayload {
|
export type PdfRenderPayload = PagedDocumentPayload;
|
||||||
articleHtml: string;
|
|
||||||
fileName: string;
|
|
||||||
metadata: {
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
subject: string;
|
|
||||||
keywords: string[];
|
|
||||||
language: string;
|
|
||||||
};
|
|
||||||
features: string[];
|
|
||||||
themeCss: string;
|
|
||||||
exportConfig: ExportConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PdfGenerationResult {
|
export interface PdfGenerationResult {
|
||||||
pdf: Buffer;
|
pdf: Buffer;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
|
echartsErrors: string[];
|
||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
timings: PdfGenerationTimings;
|
timings: PdfGenerationTimings;
|
||||||
}
|
}
|
||||||
@@ -31,6 +22,8 @@ export interface PdfGenerationTimings {
|
|||||||
navigationMs: number;
|
navigationMs: number;
|
||||||
documentRenderMs: number;
|
documentRenderMs: number;
|
||||||
runtimeSetupMs: number;
|
runtimeSetupMs: number;
|
||||||
|
echartsMs: number;
|
||||||
|
echartsFitMs: number;
|
||||||
mermaidMs: number;
|
mermaidMs: number;
|
||||||
mermaidFitMs: number;
|
mermaidFitMs: number;
|
||||||
mermaidConversionMs: number;
|
mermaidConversionMs: number;
|
||||||
@@ -62,22 +55,6 @@ export interface PdfEngineRuntimeLimits {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PagedRuntimeResult {
|
|
||||||
pageCount: number;
|
|
||||||
contentHeight: number;
|
|
||||||
mermaidErrors: string[];
|
|
||||||
timings: {
|
|
||||||
setupMs: number;
|
|
||||||
mermaidMs: number;
|
|
||||||
mermaidFitMs: number;
|
|
||||||
mermaidConversionMs: number;
|
|
||||||
resourceWaitMs: number;
|
|
||||||
paginationMs: number;
|
|
||||||
finalizeMs: number;
|
|
||||||
totalMs: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PdfDocumentResult
|
interface PdfDocumentResult
|
||||||
extends Omit<PdfGenerationResult, "timings"> {
|
extends Omit<PdfGenerationResult, "timings"> {
|
||||||
timings: Pick<
|
timings: Pick<
|
||||||
@@ -85,6 +62,8 @@ interface PdfDocumentResult
|
|||||||
| "navigationMs"
|
| "navigationMs"
|
||||||
| "documentRenderMs"
|
| "documentRenderMs"
|
||||||
| "runtimeSetupMs"
|
| "runtimeSetupMs"
|
||||||
|
| "echartsMs"
|
||||||
|
| "echartsFitMs"
|
||||||
| "mermaidMs"
|
| "mermaidMs"
|
||||||
| "mermaidFitMs"
|
| "mermaidFitMs"
|
||||||
| "mermaidConversionMs"
|
| "mermaidConversionMs"
|
||||||
@@ -232,6 +211,46 @@ export function isAllowedPdfRequestUrl(
|
|||||||
return url.origin === renderOrigin;
|
return url.origin === renderOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitWithPdfTimeout<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
timeoutMs: number
|
||||||
|
) {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new PdfRenderTimeoutError(timeoutMs));
|
||||||
|
}, timeoutMs);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeContextWithin(
|
||||||
|
context: Awaited<ReturnType<Browser["newContext"]>>,
|
||||||
|
timeoutMs: number
|
||||||
|
) {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
context.close().catch(() => undefined),
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
timeout = setTimeout(resolve, Math.min(timeoutMs, 5_000));
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PlaywrightPdfGenerator implements PdfGenerator {
|
export class PlaywrightPdfGenerator implements PdfGenerator {
|
||||||
private readonly renderOrigin: string;
|
private readonly renderOrigin: string;
|
||||||
private readonly renderUrl: string;
|
private readonly renderUrl: string;
|
||||||
@@ -284,7 +303,7 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
throw new PdfEngineClosedError();
|
throw new PdfEngineClosedError();
|
||||||
}
|
}
|
||||||
await this.getBrowser();
|
await waitWithPdfTimeout(this.getBrowser(), this.timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
@@ -330,57 +349,67 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
queuedAt: number,
|
queuedAt: number,
|
||||||
queueMs: number
|
queueMs: number
|
||||||
): Promise<PdfGenerationResult> {
|
): Promise<PdfGenerationResult> {
|
||||||
const browserStartedAt = performance.now();
|
let context:
|
||||||
const browser = await this.getBrowser();
|
| Awaited<ReturnType<Browser["newContext"]>>
|
||||||
const browserMs = performance.now() - browserStartedAt;
|
| undefined;
|
||||||
const contextStartedAt = performance.now();
|
let expired = false;
|
||||||
const context = await browser.newContext({
|
|
||||||
locale: payload.metadata.language || "zh-CN",
|
|
||||||
serviceWorkers: "block"
|
|
||||||
});
|
|
||||||
const contextMs = performance.now() - contextStartedAt;
|
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await context.route("**/*", async (route) => {
|
const operation = (async (): Promise<PdfGenerationResult> => {
|
||||||
if (
|
const browserStartedAt = performance.now();
|
||||||
isAllowedPdfRequestUrl(
|
const browser = await this.getBrowser();
|
||||||
route.request().url(),
|
const browserMs = performance.now() - browserStartedAt;
|
||||||
this.renderOrigin
|
if (expired) {
|
||||||
)
|
throw new PdfRenderTimeoutError(this.timeoutMs);
|
||||||
) {
|
|
||||||
await route.continue();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await route.abort("blockedbyclient");
|
|
||||||
});
|
|
||||||
|
|
||||||
const operation = this.renderWithContext(context, payload);
|
const contextStartedAt = performance.now();
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const createdContext = await browser.newContext({
|
||||||
timeout = setTimeout(() => {
|
locale: payload.metadata.language || "zh-CN",
|
||||||
reject(new PdfRenderTimeoutError(this.timeoutMs));
|
serviceWorkers: "block"
|
||||||
}, this.timeoutMs);
|
});
|
||||||
});
|
context = createdContext;
|
||||||
|
const contextMs = performance.now() - contextStartedAt;
|
||||||
const generated = await Promise.race([
|
if (expired) {
|
||||||
operation,
|
await closeContextWithin(createdContext, this.timeoutMs);
|
||||||
timeoutPromise
|
throw new PdfRenderTimeoutError(this.timeoutMs);
|
||||||
]);
|
|
||||||
return {
|
|
||||||
...generated,
|
|
||||||
timings: {
|
|
||||||
queueMs,
|
|
||||||
browserMs,
|
|
||||||
contextMs,
|
|
||||||
...generated.timings,
|
|
||||||
totalMs: performance.now() - queuedAt
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
await createdContext.route("**/*", async (route) => {
|
||||||
|
if (
|
||||||
|
isAllowedPdfRequestUrl(
|
||||||
|
route.request().url(),
|
||||||
|
this.renderOrigin
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.abort("blockedbyclient");
|
||||||
|
});
|
||||||
|
|
||||||
|
const generated = await this.renderWithContext(
|
||||||
|
createdContext,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...generated,
|
||||||
|
timings: {
|
||||||
|
queueMs,
|
||||||
|
browserMs,
|
||||||
|
contextMs,
|
||||||
|
...generated.timings,
|
||||||
|
totalMs: performance.now() - queuedAt
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
return await waitWithPdfTimeout(operation, this.timeoutMs);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeout) {
|
expired = true;
|
||||||
clearTimeout(timeout);
|
if (context) {
|
||||||
|
await closeContextWithin(context, this.timeoutMs);
|
||||||
}
|
}
|
||||||
await context.close().catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,13 +436,13 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
|
|
||||||
const documentRenderStartedAt = performance.now();
|
const documentRenderStartedAt = performance.now();
|
||||||
const renderResult = await page.evaluate(
|
const renderResult = await page.evaluate(
|
||||||
async (renderPayload): Promise<PagedRuntimeResult> => {
|
async (renderPayload): Promise<PagedDocumentRenderResult> => {
|
||||||
const renderer = (
|
const renderer = (
|
||||||
window as typeof window & {
|
window as typeof window & {
|
||||||
__mdToPdfRender?: (
|
__mdToPdfRender?: (
|
||||||
payload: PdfRenderPayload,
|
payload: PdfRenderPayload,
|
||||||
target: "pdf"
|
target: "pdf"
|
||||||
) => Promise<PagedRuntimeResult>;
|
) => Promise<PagedDocumentRenderResult>;
|
||||||
}
|
}
|
||||||
).__mdToPdfRender;
|
).__mdToPdfRender;
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
@@ -445,11 +474,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
|
|||||||
return {
|
return {
|
||||||
pdf,
|
pdf,
|
||||||
pageCount: renderResult.pageCount,
|
pageCount: renderResult.pageCount,
|
||||||
|
echartsErrors: renderResult.echartsErrors,
|
||||||
mermaidErrors: renderResult.mermaidErrors,
|
mermaidErrors: renderResult.mermaidErrors,
|
||||||
timings: {
|
timings: {
|
||||||
navigationMs,
|
navigationMs,
|
||||||
documentRenderMs,
|
documentRenderMs,
|
||||||
runtimeSetupMs: renderResult.timings.setupMs,
|
runtimeSetupMs: renderResult.timings.setupMs,
|
||||||
|
echartsMs: renderResult.timings.echartsMs,
|
||||||
|
echartsFitMs: renderResult.timings.echartsFitMs,
|
||||||
mermaidMs: renderResult.timings.mermaidMs,
|
mermaidMs: renderResult.timings.mermaidMs,
|
||||||
mermaidFitMs: renderResult.timings.mermaidFitMs,
|
mermaidFitMs: renderResult.timings.mermaidFitMs,
|
||||||
mermaidConversionMs:
|
mermaidConversionMs:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
createPdfContentDisposition,
|
createPdfContentDisposition,
|
||||||
createPdfFileName
|
createPdfFileName
|
||||||
} from "../src/app.js";
|
} from "../src/app.js";
|
||||||
import { createThemeRegistry } from "../src/theme-registry.js";
|
import { createThemeRegistry } from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
PdfEngineOverloadedError,
|
PdfEngineOverloadedError,
|
||||||
type PdfGenerator
|
type PdfGenerator
|
||||||
@@ -110,6 +110,20 @@ describe("预览 API", () => {
|
|||||||
expect(response.json().error).toBe("INVALID_MARKDOWN");
|
expect(response.json().error).toBe("INVALID_MARKDOWN");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("将无效 Front Matter 映射为文档请求错误", async () => {
|
||||||
|
app = buildApp({ logger: false });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/render",
|
||||||
|
payload: {
|
||||||
|
markdown: "---\ntitle: [未闭合\n---\n\n# 文档"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("INVALID_FRONT_MATTER");
|
||||||
|
});
|
||||||
|
|
||||||
it("返回内置主题 CSS", async () => {
|
it("返回内置主题 CSS", async () => {
|
||||||
app = buildApp();
|
app = buildApp();
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -237,6 +251,94 @@ describe("预览 API", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("忽略无效本地主题而不影响其他主题", async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||||
|
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||||
|
const localRoot = join(temporaryDirectory, "local");
|
||||||
|
const validThemeRoot = join(localRoot, "valid-theme");
|
||||||
|
const invalidThemeRoot = join(localRoot, "invalid-theme");
|
||||||
|
await mkdir(bundledRoot, { recursive: true });
|
||||||
|
await mkdir(validThemeRoot, { recursive: true });
|
||||||
|
await mkdir(invalidThemeRoot, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(validThemeRoot, "theme.json"),
|
||||||
|
JSON.stringify(localThemeManifest("valid-theme")),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(validThemeRoot, "theme.css"),
|
||||||
|
"#write { color: #333; }",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const onWarning = vi.fn();
|
||||||
|
const registry = createThemeRegistry({
|
||||||
|
bundledRoot,
|
||||||
|
localRoot,
|
||||||
|
onWarning
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(registry.list()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
manifest: expect.objectContaining({ id: "valid-theme" })
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(onWarning).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("invalid-theme")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缓存主题目录扫描并支持主动失效", async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||||
|
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||||
|
const localRoot = join(temporaryDirectory, "local");
|
||||||
|
const firstThemeRoot = join(localRoot, "first-theme");
|
||||||
|
const secondThemeRoot = join(localRoot, "second-theme");
|
||||||
|
await mkdir(bundledRoot, { recursive: true });
|
||||||
|
await mkdir(firstThemeRoot, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(firstThemeRoot, "theme.json"),
|
||||||
|
JSON.stringify(localThemeManifest("first-theme")),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(firstThemeRoot, "theme.css"),
|
||||||
|
"#write { color: #333; }",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const registry = createThemeRegistry({
|
||||||
|
bundledRoot,
|
||||||
|
localRoot,
|
||||||
|
cacheTtlMs: 60_000
|
||||||
|
});
|
||||||
|
await expect(registry.list()).resolves.toHaveLength(1);
|
||||||
|
|
||||||
|
await mkdir(secondThemeRoot, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(secondThemeRoot, "theme.json"),
|
||||||
|
JSON.stringify(localThemeManifest("second-theme")),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(secondThemeRoot, "theme.css"),
|
||||||
|
"#write { color: #666; }",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await expect(registry.list()).resolves.toHaveLength(1);
|
||||||
|
|
||||||
|
registry.invalidate();
|
||||||
|
await expect(registry.list()).resolves.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
manifest: expect.objectContaining({ id: "first-theme" })
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
manifest: expect.objectContaining({ id: "second-theme" })
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("拒绝主题 CSS 导入外部地址", async () => {
|
it("拒绝主题 CSS 导入外部地址", async () => {
|
||||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||||
@@ -267,6 +369,7 @@ describe("PDF API", () => {
|
|||||||
const generate = vi.fn(async () => ({
|
const generate = vi.fn(async () => ({
|
||||||
pdf: Buffer.from("%PDF-test"),
|
pdf: Buffer.from("%PDF-test"),
|
||||||
pageCount: 3,
|
pageCount: 3,
|
||||||
|
echartsErrors: ["图表 2"],
|
||||||
mermaidErrors: ["图表 1"],
|
mermaidErrors: ["图表 1"],
|
||||||
timings: {
|
timings: {
|
||||||
queueMs: 0,
|
queueMs: 0,
|
||||||
@@ -275,6 +378,8 @@ describe("PDF API", () => {
|
|||||||
navigationMs: 3,
|
navigationMs: 3,
|
||||||
documentRenderMs: 4,
|
documentRenderMs: 4,
|
||||||
runtimeSetupMs: 0.1,
|
runtimeSetupMs: 0.1,
|
||||||
|
echartsMs: 0.15,
|
||||||
|
echartsFitMs: 0.18,
|
||||||
mermaidMs: 0.2,
|
mermaidMs: 0.2,
|
||||||
mermaidFitMs: 0.3,
|
mermaidFitMs: 0.3,
|
||||||
mermaidConversionMs: 0.35,
|
mermaidConversionMs: 0.35,
|
||||||
@@ -313,6 +418,7 @@ describe("PDF API", () => {
|
|||||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
|
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
|
||||||
);
|
);
|
||||||
expect(response.headers["x-pdf-page-count"]).toBe("3");
|
expect(response.headers["x-pdf-page-count"]).toBe("3");
|
||||||
|
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||||
expect(response.headers["x-mermaid-error-count"]).toBe("1");
|
expect(response.headers["x-mermaid-error-count"]).toBe("1");
|
||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"document-render;dur=4.0"
|
"document-render;dur=4.0"
|
||||||
@@ -320,6 +426,9 @@ describe("PDF API", () => {
|
|||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"pdf-print;dur=5.0"
|
"pdf-print;dur=5.0"
|
||||||
);
|
);
|
||||||
|
expect(response.headers["server-timing"]).toContain(
|
||||||
|
"echarts;dur=0.1"
|
||||||
|
);
|
||||||
expect(response.headers["server-timing"]).toContain(
|
expect(response.headers["server-timing"]).toContain(
|
||||||
"mermaid;dur=0.2"
|
"mermaid;dur=0.2"
|
||||||
);
|
);
|
||||||
@@ -357,6 +466,29 @@ describe("PDF API", () => {
|
|||||||
expect(response.json().error).toBe("INVALID_EXPORT_CONFIG");
|
expect(response.json().error).toBe("INVALID_EXPORT_CONFIG");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("在 PDF 生成前拒绝无效 Front Matter", async () => {
|
||||||
|
const generate = vi.fn();
|
||||||
|
app = buildApp({
|
||||||
|
logger: false,
|
||||||
|
pdfGenerator: {
|
||||||
|
generate,
|
||||||
|
close: vi.fn(async () => undefined)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/pdf",
|
||||||
|
payload: {
|
||||||
|
markdown: "---\ntitle: [未闭合\n---\n",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("INVALID_FRONT_MATTER");
|
||||||
|
expect(generate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("映射 PDF 队列已满错误", async () => {
|
it("映射 PDF 队列已满错误", async () => {
|
||||||
app = buildApp({
|
app = buildApp({
|
||||||
logger: false,
|
logger: false,
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ function payload(): PdfRenderPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
function fakeBrowser(
|
||||||
|
options: { hangOnGoto?: boolean; hangOnNewContext?: boolean } = {}
|
||||||
|
) {
|
||||||
|
let releaseNewContext: (() => void) | undefined;
|
||||||
const route = vi.fn();
|
const route = vi.fn();
|
||||||
const closeContext = vi.fn(async () => undefined);
|
const closeContext = vi.fn(async () => undefined);
|
||||||
const closeBrowser = vi.fn(async () => undefined);
|
const closeBrowser = vi.fn(async () => undefined);
|
||||||
@@ -44,10 +47,12 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
|||||||
waitForFunction: vi.fn(async () => undefined),
|
waitForFunction: vi.fn(async () => undefined),
|
||||||
evaluate: vi.fn(async () => ({
|
evaluate: vi.fn(async () => ({
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
contentHeight: 2200,
|
echartsErrors: [],
|
||||||
mermaidErrors: [],
|
mermaidErrors: [],
|
||||||
timings: {
|
timings: {
|
||||||
setupMs: 1,
|
setupMs: 1,
|
||||||
|
echartsMs: 1.5,
|
||||||
|
echartsFitMs: 1.75,
|
||||||
mermaidMs: 2,
|
mermaidMs: 2,
|
||||||
mermaidFitMs: 3,
|
mermaidFitMs: 3,
|
||||||
mermaidConversionMs: 3.5,
|
mermaidConversionMs: 3.5,
|
||||||
@@ -65,7 +70,14 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
|||||||
close: closeContext
|
close: closeContext
|
||||||
};
|
};
|
||||||
const browser = {
|
const browser = {
|
||||||
newContext: vi.fn(async () => context),
|
newContext: options.hangOnNewContext
|
||||||
|
? vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<typeof context>((resolve) => {
|
||||||
|
releaseNewContext = () => resolve(context);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: vi.fn(async () => context),
|
||||||
close: closeBrowser,
|
close: closeBrowser,
|
||||||
on: vi.fn()
|
on: vi.fn()
|
||||||
} as unknown as Browser;
|
} as unknown as Browser;
|
||||||
@@ -77,6 +89,9 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
|||||||
context,
|
context,
|
||||||
page,
|
page,
|
||||||
pdfBuffer,
|
pdfBuffer,
|
||||||
|
releaseNewContext() {
|
||||||
|
releaseNewContext?.();
|
||||||
|
},
|
||||||
route
|
route
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,6 +194,8 @@ describe("Playwright PDF 生成器", () => {
|
|||||||
navigationMs: expect.any(Number),
|
navigationMs: expect.any(Number),
|
||||||
documentRenderMs: expect.any(Number),
|
documentRenderMs: expect.any(Number),
|
||||||
runtimeSetupMs: 1,
|
runtimeSetupMs: 1,
|
||||||
|
echartsMs: 1.5,
|
||||||
|
echartsFitMs: 1.75,
|
||||||
mermaidMs: 2,
|
mermaidMs: 2,
|
||||||
mermaidFitMs: 3,
|
mermaidFitMs: 3,
|
||||||
mermaidConversionMs: 3.5,
|
mermaidConversionMs: 3.5,
|
||||||
@@ -240,4 +257,39 @@ describe("Playwright PDF 生成器", () => {
|
|||||||
expect(fake.closeContext).toHaveBeenCalledTimes(1);
|
expect(fake.closeContext).toHaveBeenCalledTimes(1);
|
||||||
await generator.close();
|
await generator.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("浏览器启动阶段也受生成超时限制", async () => {
|
||||||
|
const fake = fakeBrowser();
|
||||||
|
let releaseBrowser: (() => void) | undefined;
|
||||||
|
const generator = new PlaywrightPdfGenerator({
|
||||||
|
launchBrowser: () =>
|
||||||
|
new Promise<Browser>((resolve) => {
|
||||||
|
releaseBrowser = () => resolve(fake.browser);
|
||||||
|
}),
|
||||||
|
timeoutMs: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
|
||||||
|
PdfRenderTimeoutError
|
||||||
|
);
|
||||||
|
releaseBrowser?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await generator.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("上下文创建阶段也受生成超时限制并清理迟到上下文", async () => {
|
||||||
|
const fake = fakeBrowser({ hangOnNewContext: true });
|
||||||
|
const generator = new PlaywrightPdfGenerator({
|
||||||
|
launchBrowser: async () => fake.browser,
|
||||||
|
timeoutMs: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
|
||||||
|
PdfRenderTimeoutError
|
||||||
|
);
|
||||||
|
fake.releaseNewContext();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(fake.closeContext).toHaveBeenCalledTimes(1);
|
||||||
|
await generator.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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:;"
|
||||||
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
|
"@mermaid-js/layout-elk": "0.2.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "11.16.0",
|
||||||
"pagedjs": "^0.4.3",
|
"pagedjs": "^0.4.3",
|
||||||
"pdfjs-dist": "6.1.200",
|
"pdfjs-dist": "6.1.200",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<style>
|
||||||
|
html[data-rendering="true"] body {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<title>分页预览</title>
|
<title>分页预览</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
|
lengthToMillimeters,
|
||||||
paperFormatLabels,
|
paperFormatLabels,
|
||||||
|
supportedMermaidLayouts,
|
||||||
|
supportedMermaidLooks,
|
||||||
|
supportedMermaidThemes,
|
||||||
supportedPaperFormats,
|
supportedPaperFormats,
|
||||||
type ExportConfig,
|
type ExportConfig,
|
||||||
type FooterConfig,
|
type FooterConfig,
|
||||||
type HeaderConfig
|
type HeaderConfig,
|
||||||
|
type MermaidExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -30,9 +35,47 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
|||||||
custom: "自定义模板"
|
custom: "自定义模板"
|
||||||
};
|
};
|
||||||
|
|
||||||
function millimeters(value: string) {
|
const mermaidLayoutLabels: Record<
|
||||||
return Number.parseFloat(value.replace(/mm$/i, ""));
|
MermaidExportConfig["layout"],
|
||||||
}
|
string
|
||||||
|
> = {
|
||||||
|
dagre: "Dagre",
|
||||||
|
elk: "ELK"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mermaidThemeLabels: Record<
|
||||||
|
MermaidExportConfig["theme"],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
default: "Default",
|
||||||
|
base: "Base(可定制基础主题)",
|
||||||
|
dark: "Dark",
|
||||||
|
forest: "Forest",
|
||||||
|
neutral: "Neutral",
|
||||||
|
neo: "Neo",
|
||||||
|
"neo-dark": "Neo Dark",
|
||||||
|
redux: "Redux",
|
||||||
|
"redux-dark": "Redux Dark",
|
||||||
|
"redux-color": "Redux Color",
|
||||||
|
"redux-dark-color": "Redux Dark Color"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mermaidLookLabels: Record<
|
||||||
|
MermaidExportConfig["look"],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
classic: "Classic",
|
||||||
|
handDrawn: "Hand-drawn",
|
||||||
|
neo: "Neo"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mermaidFontFamilies = [
|
||||||
|
"Segoe UI, Microsoft YaHei, sans-serif",
|
||||||
|
"Microsoft YaHei, sans-serif",
|
||||||
|
"Arial, Helvetica, sans-serif",
|
||||||
|
"Georgia, Times New Roman, serif",
|
||||||
|
"Consolas, Microsoft YaHei, monospace"
|
||||||
|
] as const;
|
||||||
|
|
||||||
function fitMarginPair(
|
function fitMarginPair(
|
||||||
first: number,
|
first: number,
|
||||||
@@ -57,16 +100,18 @@ function LengthInput({
|
|||||||
maximum,
|
maximum,
|
||||||
onChange
|
onChange
|
||||||
}: LengthInputProps) {
|
}: LengthInputProps) {
|
||||||
const [draft, setDraft] = useState(String(millimeters(value)));
|
const [draft, setDraft] = useState(
|
||||||
|
String(lengthToMillimeters(value))
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(String(millimeters(value)));
|
setDraft(String(lengthToMillimeters(value)));
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
function commit() {
|
function commit() {
|
||||||
const number = Number.parseFloat(draft);
|
const number = Number.parseFloat(draft);
|
||||||
if (!Number.isFinite(number)) {
|
if (!Number.isFinite(number)) {
|
||||||
setDraft(String(millimeters(value)));
|
setDraft(String(lengthToMillimeters(value)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = Math.min(Math.max(number, 0), maximum);
|
const normalized = Math.min(Math.max(number, 0), maximum);
|
||||||
@@ -99,6 +144,54 @@ function LengthInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MermaidFontInput({
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const normalized = draft.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
setDraft(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(normalized);
|
||||||
|
onChange(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="setting-field stacked">
|
||||||
|
<span>图表字体</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="mermaid-font-family-options"
|
||||||
|
maxLength={300}
|
||||||
|
value={draft}
|
||||||
|
onBlur={commit}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<datalist id="mermaid-font-family-options">
|
||||||
|
{mermaidFontFamilies.map((fontFamily) => (
|
||||||
|
<option key={fontFamily} value={fontFamily} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function updateHeaderSlot(
|
function updateHeaderSlot(
|
||||||
header: HeaderConfig,
|
header: HeaderConfig,
|
||||||
slot: "left" | "center" | "right",
|
slot: "left" | "center" | "right",
|
||||||
@@ -137,13 +230,13 @@ export function ExportSettingsDrawer({
|
|||||||
nextPaper.orientation
|
nextPaper.orientation
|
||||||
);
|
);
|
||||||
const [left, right] = fitMarginPair(
|
const [left, right] = fitMarginPair(
|
||||||
millimeters(nextPaper.margins.left),
|
lengthToMillimeters(nextPaper.margins.left),
|
||||||
millimeters(nextPaper.margins.right),
|
lengthToMillimeters(nextPaper.margins.right),
|
||||||
nextDimensions.width - 1
|
nextDimensions.width - 1
|
||||||
);
|
);
|
||||||
const [top, bottom] = fitMarginPair(
|
const [top, bottom] = fitMarginPair(
|
||||||
millimeters(nextPaper.margins.top),
|
lengthToMillimeters(nextPaper.margins.top),
|
||||||
millimeters(nextPaper.margins.bottom),
|
lengthToMillimeters(nextPaper.margins.bottom),
|
||||||
nextDimensions.height - 1
|
nextDimensions.height - 1
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -193,6 +286,16 @@ export function ExportSettingsDrawer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
mermaid: {
|
||||||
|
...config.mermaid,
|
||||||
|
...mermaid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-layer">
|
<div className="settings-layer">
|
||||||
<button
|
<button
|
||||||
@@ -276,7 +379,9 @@ export function ExportSettingsDrawer({
|
|||||||
label="上"
|
label="上"
|
||||||
value={margins.top}
|
value={margins.top}
|
||||||
maximum={
|
maximum={
|
||||||
dimensions.height - millimeters(margins.bottom) - verticalGap
|
dimensions.height -
|
||||||
|
lengthToMillimeters(margins.bottom) -
|
||||||
|
verticalGap
|
||||||
}
|
}
|
||||||
onChange={(value) => updateMargin("top", value)}
|
onChange={(value) => updateMargin("top", value)}
|
||||||
/>
|
/>
|
||||||
@@ -284,7 +389,9 @@ export function ExportSettingsDrawer({
|
|||||||
label="右"
|
label="右"
|
||||||
value={margins.right}
|
value={margins.right}
|
||||||
maximum={
|
maximum={
|
||||||
dimensions.width - millimeters(margins.left) - horizontalGap
|
dimensions.width -
|
||||||
|
lengthToMillimeters(margins.left) -
|
||||||
|
horizontalGap
|
||||||
}
|
}
|
||||||
onChange={(value) => updateMargin("right", value)}
|
onChange={(value) => updateMargin("right", value)}
|
||||||
/>
|
/>
|
||||||
@@ -292,7 +399,9 @@ export function ExportSettingsDrawer({
|
|||||||
label="下"
|
label="下"
|
||||||
value={margins.bottom}
|
value={margins.bottom}
|
||||||
maximum={
|
maximum={
|
||||||
dimensions.height - millimeters(margins.top) - verticalGap
|
dimensions.height -
|
||||||
|
lengthToMillimeters(margins.top) -
|
||||||
|
verticalGap
|
||||||
}
|
}
|
||||||
onChange={(value) => updateMargin("bottom", value)}
|
onChange={(value) => updateMargin("bottom", value)}
|
||||||
/>
|
/>
|
||||||
@@ -300,13 +409,82 @@ export function ExportSettingsDrawer({
|
|||||||
label="左"
|
label="左"
|
||||||
value={margins.left}
|
value={margins.left}
|
||||||
maximum={
|
maximum={
|
||||||
dimensions.width - millimeters(margins.right) - horizontalGap
|
dimensions.width -
|
||||||
|
lengthToMillimeters(margins.right) -
|
||||||
|
horizontalGap
|
||||||
}
|
}
|
||||||
onChange={(value) => updateMargin("left", value)}
|
onChange={(value) => updateMargin("left", value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>Mermaid 图表</h3>
|
||||||
|
<label className="setting-field">
|
||||||
|
<span>布局算法</span>
|
||||||
|
<select
|
||||||
|
value={config.mermaid.layout}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateMermaid({
|
||||||
|
layout: event.target
|
||||||
|
.value as MermaidExportConfig["layout"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{supportedMermaidLayouts.map((layout) => (
|
||||||
|
<option key={layout} value={layout}>
|
||||||
|
{mermaidLayoutLabels[layout]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="setting-field">
|
||||||
|
<span>图表主题</span>
|
||||||
|
<select
|
||||||
|
value={config.mermaid.theme}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateMermaid({
|
||||||
|
theme: event.target
|
||||||
|
.value as MermaidExportConfig["theme"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{supportedMermaidThemes.map((theme) => (
|
||||||
|
<option key={theme} value={theme}>
|
||||||
|
{mermaidThemeLabels[theme]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="setting-field">
|
||||||
|
<span>图表外观</span>
|
||||||
|
<select
|
||||||
|
value={config.mermaid.look}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateMermaid({
|
||||||
|
look: event.target
|
||||||
|
.value as MermaidExportConfig["look"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{supportedMermaidLooks.map((look) => (
|
||||||
|
<option key={look} value={look}>
|
||||||
|
{mermaidLookLabels[look]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<MermaidFontInput
|
||||||
|
value={config.mermaid.fontFamily}
|
||||||
|
onChange={(fontFamily) => updateMermaid({ fontFamily })}
|
||||||
|
/>
|
||||||
|
<p className="setting-hint">
|
||||||
|
单个 Mermaid 代码块中的 config frontmatter
|
||||||
|
优先于这里的全局设置;style、classDef 和 themeVariables
|
||||||
|
会继续按 Mermaid 原生语法生效。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="setting-section-title">
|
<div className="setting-section-title">
|
||||||
<h3>页眉</h3>
|
<h3>页眉</h3>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface PdfPageDescriptor {
|
|||||||
|
|
||||||
export interface PrecisePdfPreviewProps {
|
export interface PrecisePdfPreviewProps {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
|
zoomPercent: number;
|
||||||
onPageCount?: (pageCount: number) => void;
|
onPageCount?: (pageCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ interface PrecisePdfPageProps {
|
|||||||
document: PDFDocumentProxy;
|
document: PDFDocumentProxy;
|
||||||
descriptor: PdfPageDescriptor;
|
descriptor: PdfPageDescriptor;
|
||||||
renderQueue: PdfPageRenderQueue;
|
renderQueue: PdfPageRenderQueue;
|
||||||
|
zoomPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||||
@@ -90,7 +92,8 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
|||||||
function PrecisePdfPage({
|
function PrecisePdfPage({
|
||||||
document,
|
document,
|
||||||
descriptor,
|
descriptor,
|
||||||
renderQueue
|
renderQueue,
|
||||||
|
zoomPercent
|
||||||
}: PrecisePdfPageProps) {
|
}: PrecisePdfPageProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@@ -103,10 +106,12 @@ function PrecisePdfPage({
|
|||||||
const nearViewport = useNearViewport(containerRef);
|
const nearViewport = useNearViewport(containerRef);
|
||||||
const dimensions = getPdfPageCssDimensions(
|
const dimensions = getPdfPageCssDimensions(
|
||||||
descriptor.width,
|
descriptor.width,
|
||||||
descriptor.height
|
descriptor.height,
|
||||||
|
zoomPercent / 100
|
||||||
);
|
);
|
||||||
const pageStyle = {
|
const pageStyle = {
|
||||||
width: `${dimensions.width}px`,
|
width: `${dimensions.width}px`,
|
||||||
|
maxWidth: `${zoomPercent}%`,
|
||||||
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
||||||
} satisfies CSSProperties;
|
} satisfies CSSProperties;
|
||||||
|
|
||||||
@@ -293,6 +298,7 @@ function PrecisePdfPage({
|
|||||||
|
|
||||||
export function PrecisePdfPreview({
|
export function PrecisePdfPreview({
|
||||||
blob,
|
blob,
|
||||||
|
zoomPercent,
|
||||||
onPageCount
|
onPageCount
|
||||||
}: PrecisePdfPreviewProps) {
|
}: PrecisePdfPreviewProps) {
|
||||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||||
@@ -381,6 +387,7 @@ export function PrecisePdfPreview({
|
|||||||
document={document}
|
document={document}
|
||||||
descriptor={page}
|
descriptor={page}
|
||||||
renderQueue={renderQueue}
|
renderQueue={renderQueue}
|
||||||
|
zoomPercent={zoomPercent}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { normalizePreviewPageNumber } from "./preview-page";
|
||||||
|
|
||||||
|
interface PreviewPageControlProps {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
onNavigate: (pageNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewPageControl({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
onNavigate
|
||||||
|
}: PreviewPageControlProps) {
|
||||||
|
const [draft, setDraft] = useState(String(current));
|
||||||
|
const editingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingRef.current) {
|
||||||
|
setDraft(String(current));
|
||||||
|
}
|
||||||
|
}, [current, total]);
|
||||||
|
|
||||||
|
function commitPageNumber() {
|
||||||
|
const pageNumber = normalizePreviewPageNumber(draft, current, total);
|
||||||
|
setDraft(String(pageNumber));
|
||||||
|
if (pageNumber !== current) {
|
||||||
|
onNavigate(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="preview-page-control">
|
||||||
|
<span>第</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={total}
|
||||||
|
step={1}
|
||||||
|
value={draft}
|
||||||
|
aria-label="跳转页码"
|
||||||
|
onBlur={() => {
|
||||||
|
editingRef.current = false;
|
||||||
|
commitPageNumber();
|
||||||
|
}}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onFocus={(event) => {
|
||||||
|
editingRef.current = true;
|
||||||
|
event.currentTarget.select();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span aria-live="polite">/ {total} 页</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_PREVIEW_ZOOM,
|
||||||
|
MAXIMUM_PREVIEW_ZOOM,
|
||||||
|
MINIMUM_PREVIEW_ZOOM,
|
||||||
|
normalizePreviewZoom,
|
||||||
|
parsePreviewZoom,
|
||||||
|
PREVIEW_ZOOM_SLIDER_STEP
|
||||||
|
} from "./preview-zoom";
|
||||||
|
|
||||||
|
interface PreviewZoomControlProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewZoomControl({
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}: PreviewZoomControlProps) {
|
||||||
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(String(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function commitDraft() {
|
||||||
|
const normalized = parsePreviewZoom(draft, value);
|
||||||
|
setDraft(String(normalized));
|
||||||
|
onChange(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="preview-zoom-control"
|
||||||
|
role="group"
|
||||||
|
aria-label="预览缩放"
|
||||||
|
>
|
||||||
|
<span>缩放</span>
|
||||||
|
<input
|
||||||
|
className="preview-zoom-slider"
|
||||||
|
type="range"
|
||||||
|
aria-label="预览缩放滑块"
|
||||||
|
min={MINIMUM_PREVIEW_ZOOM}
|
||||||
|
max={MAXIMUM_PREVIEW_ZOOM}
|
||||||
|
step={PREVIEW_ZOOM_SLIDER_STEP}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange(normalizePreviewZoom(Number(event.target.value)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label className="preview-zoom-number">
|
||||||
|
<span className="sr-only">预览缩放百分比</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
aria-label="预览缩放百分比"
|
||||||
|
min={MINIMUM_PREVIEW_ZOOM}
|
||||||
|
max={MAXIMUM_PREVIEW_ZOOM}
|
||||||
|
step="1"
|
||||||
|
value={draft}
|
||||||
|
onBlur={commitDraft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-zoom-reset"
|
||||||
|
disabled={value === DEFAULT_PREVIEW_ZOOM}
|
||||||
|
onClick={() => onChange(DEFAULT_PREVIEW_ZOOM)}
|
||||||
|
>
|
||||||
|
100%
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const APP_VERSION = __APP_VERSION__;
|
||||||
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface ThemeSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
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,245 @@
|
|||||||
|
import {
|
||||||
|
getPaperDimensionsMm,
|
||||||
|
lengthToMillimeters,
|
||||||
|
millimetersToCssPixels,
|
||||||
|
type ExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface DiagramDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramPageFit extends DiagramDimensions {
|
||||||
|
scaled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SvgDiagramFitOptions {
|
||||||
|
containerSelector: string;
|
||||||
|
svgSelector?: string;
|
||||||
|
resolvePageContent?: (
|
||||||
|
container: HTMLElement,
|
||||||
|
pageContent: DiagramDimensions
|
||||||
|
) => DiagramDimensions;
|
||||||
|
onFit?: (
|
||||||
|
container: HTMLElement,
|
||||||
|
svg: SVGSVGElement,
|
||||||
|
fit: DiagramPageFit
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_FIT_EPSILON_PX = 1;
|
||||||
|
|
||||||
|
function parsePixelValue(value: string) {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrecedingMediaTitleHeight(
|
||||||
|
element: HTMLElement
|
||||||
|
) {
|
||||||
|
const title = element.previousElementSibling;
|
||||||
|
if (
|
||||||
|
!(title instanceof HTMLElement) ||
|
||||||
|
!/^H[1-6]$/.test(title.tagName)
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = getComputedStyle(title);
|
||||||
|
title.style.setProperty("break-after", "avoid-page", "important");
|
||||||
|
title.style.setProperty(
|
||||||
|
"page-break-after",
|
||||||
|
"avoid",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
title.getBoundingClientRect().height +
|
||||||
|
parsePixelValue(style.marginTop) +
|
||||||
|
parsePixelValue(style.marginBottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePageBreakAfter(element: HTMLElement) {
|
||||||
|
const existingMarker = element.nextElementSibling;
|
||||||
|
if (
|
||||||
|
existingMarker instanceof HTMLElement &&
|
||||||
|
existingMarker.dataset.mediaPageBreak === "true"
|
||||||
|
) {
|
||||||
|
return existingMarker;
|
||||||
|
}
|
||||||
|
const marker = element.ownerDocument.createElement("div");
|
||||||
|
marker.dataset.mediaPageBreak = "true";
|
||||||
|
marker.setAttribute("aria-hidden", "true");
|
||||||
|
marker.style.setProperty("height", "0", "important");
|
||||||
|
marker.style.setProperty("break-before", "page", "important");
|
||||||
|
marker.style.setProperty(
|
||||||
|
"page-break-before",
|
||||||
|
"always",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
element.after(marker);
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSvgViewBox(
|
||||||
|
value: string | null
|
||||||
|
): DiagramDimensions | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = value
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((part) => Number(part));
|
||||||
|
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||||
|
|
||||||
|
if (
|
||||||
|
values.length !== 4 ||
|
||||||
|
!Number.isFinite(width) ||
|
||||||
|
!Number.isFinite(height) ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSvgDimensions(
|
||||||
|
svg: SVGSVGElement
|
||||||
|
): DiagramDimensions | undefined {
|
||||||
|
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
|
||||||
|
const height = Number.parseFloat(
|
||||||
|
svg.getAttribute("height") ?? ""
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(width) ||
|
||||||
|
!Number.isFinite(height) ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageContentDimensions(
|
||||||
|
config: ExportConfig
|
||||||
|
): DiagramDimensions {
|
||||||
|
const paper = getPaperDimensionsMm(
|
||||||
|
config.paper.format,
|
||||||
|
config.paper.orientation
|
||||||
|
);
|
||||||
|
const width =
|
||||||
|
paper.width -
|
||||||
|
lengthToMillimeters(config.paper.margins.left) -
|
||||||
|
lengthToMillimeters(config.paper.margins.right);
|
||||||
|
const height =
|
||||||
|
paper.height -
|
||||||
|
lengthToMillimeters(config.paper.margins.top) -
|
||||||
|
lengthToMillimeters(config.paper.margins.bottom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: millimetersToCssPixels(width),
|
||||||
|
height: millimetersToCssPixels(height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDiagramPageFit(
|
||||||
|
intrinsic: DiagramDimensions,
|
||||||
|
pageContent: DiagramDimensions
|
||||||
|
): DiagramPageFit {
|
||||||
|
const heightAfterWidthFit =
|
||||||
|
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||||
|
|
||||||
|
if (heightAfterWidthFit <= pageContent.height) {
|
||||||
|
return {
|
||||||
|
width: pageContent.width,
|
||||||
|
height: heightAfterWidthFit,
|
||||||
|
scaled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = Math.max(
|
||||||
|
0,
|
||||||
|
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: (height * intrinsic.width) / intrinsic.height,
|
||||||
|
height,
|
||||||
|
scaled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitSvgDiagramsToPage(
|
||||||
|
root: ParentNode,
|
||||||
|
config: ExportConfig,
|
||||||
|
options: SvgDiagramFitOptions
|
||||||
|
) {
|
||||||
|
const pageContent = getPageContentDimensions(config);
|
||||||
|
const containers = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(options.containerSelector)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const container of containers) {
|
||||||
|
const svg = container.querySelector<SVGSVGElement>(
|
||||||
|
options.svgSelector ?? "svg"
|
||||||
|
);
|
||||||
|
const intrinsic = svg
|
||||||
|
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
|
||||||
|
parseSvgDimensions(svg)
|
||||||
|
: undefined;
|
||||||
|
if (!svg || !intrinsic) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPageContent =
|
||||||
|
options.resolvePageContent?.(container, pageContent) ??
|
||||||
|
pageContent;
|
||||||
|
const availablePageContent = {
|
||||||
|
width: resolvedPageContent.width,
|
||||||
|
height: Math.max(
|
||||||
|
0,
|
||||||
|
resolvedPageContent.height -
|
||||||
|
getPrecedingMediaTitleHeight(container)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
const fit = calculateDiagramPageFit(
|
||||||
|
intrinsic,
|
||||||
|
availablePageContent
|
||||||
|
);
|
||||||
|
if (!fit.scaled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.dataset.pageHeightFitted = "true";
|
||||||
|
container.style.setProperty("margin-block", "0", "important");
|
||||||
|
container.style.setProperty("break-after", "page", "important");
|
||||||
|
container.style.setProperty(
|
||||||
|
"page-break-after",
|
||||||
|
"always",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
ensurePageBreakAfter(container);
|
||||||
|
if (!svg.getAttribute("viewBox")) {
|
||||||
|
svg.setAttribute(
|
||||||
|
"viewBox",
|
||||||
|
`0 0 ${intrinsic.width} ${intrinsic.height}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||||
|
svg.style.setProperty("display", "block");
|
||||||
|
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||||
|
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||||
|
svg.style.setProperty("max-width", "100%", "important");
|
||||||
|
svg.style.setProperty(
|
||||||
|
"max-height",
|
||||||
|
`${fit.height}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
options.onFit?.(container, svg, fit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,106 @@
|
|||||||
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
calculateDiagramPageFit,
|
||||||
|
ensurePageBreakAfter,
|
||||||
|
getPageContentDimensions,
|
||||||
|
getPrecedingMediaTitleHeight
|
||||||
|
} from "./diagram-page-fit";
|
||||||
|
|
||||||
|
function getImageBlock(image: HTMLImageElement) {
|
||||||
|
const parent = image.parentElement;
|
||||||
|
if (parent?.classList.contains("md-document-image-block")) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parent?.tagName === "P" &&
|
||||||
|
Array.from(parent.childNodes).every(
|
||||||
|
(node) =>
|
||||||
|
node === image ||
|
||||||
|
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
parent.classList.add("md-document-image-block");
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareDocumentImageBlocks(root: ParentNode) {
|
||||||
|
const images = Array.from(
|
||||||
|
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||||
|
);
|
||||||
|
let prepared = 0;
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
if (getImageBlock(image)) {
|
||||||
|
prepared += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitDocumentImagesToPage(
|
||||||
|
root: ParentNode,
|
||||||
|
config: ExportConfig,
|
||||||
|
options: { prepareBlocks?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const pageContent = getPageContentDimensions(config);
|
||||||
|
if (options.prepareBlocks !== false) {
|
||||||
|
prepareDocumentImageBlocks(root);
|
||||||
|
}
|
||||||
|
const images = Array.from(
|
||||||
|
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
const block = getImageBlock(image);
|
||||||
|
const width = image.naturalWidth;
|
||||||
|
const height = image.naturalHeight;
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const imageHeight = image.getBoundingClientRect().height;
|
||||||
|
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
||||||
|
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
||||||
|
const titleHeight = block
|
||||||
|
? getPrecedingMediaTitleHeight(block)
|
||||||
|
: 0;
|
||||||
|
const fit = calculateDiagramPageFit(
|
||||||
|
{ width, height },
|
||||||
|
{
|
||||||
|
width: pageContent.width,
|
||||||
|
height: Math.max(
|
||||||
|
0,
|
||||||
|
pageContent.height - nonImageHeight - titleHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!fit.scaled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block?.classList.contains("md-document-image-block")) {
|
||||||
|
block.dataset.pageHeightFitted = "true";
|
||||||
|
block.style.setProperty("margin-block", "0", "important");
|
||||||
|
block.style.setProperty("break-after", "page", "important");
|
||||||
|
block.style.setProperty(
|
||||||
|
"page-break-after",
|
||||||
|
"always",
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
ensurePageBreakAfter(block);
|
||||||
|
}
|
||||||
|
image.dataset.pageHeightFitted = "true";
|
||||||
|
image.style.setProperty("display", "block");
|
||||||
|
image.style.setProperty("width", `${fit.width}px`, "important");
|
||||||
|
image.style.setProperty("height", `${fit.height}px`, "important");
|
||||||
|
image.style.setProperty("max-width", "100%", "important");
|
||||||
|
image.style.setProperty(
|
||||||
|
"max-height",
|
||||||
|
`${fit.height}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
image.style.setProperty("object-fit", "contain");
|
||||||
|
image.style.setProperty("margin-inline", "auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
|
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
||||||
|
|
||||||
|
export function fitOversizedEChartsToPage(
|
||||||
|
container: ParentNode,
|
||||||
|
config: ExportConfig
|
||||||
|
) {
|
||||||
|
fitSvgDiagramsToPage(container, config, {
|
||||||
|
containerSelector: ".md-echarts",
|
||||||
|
svgSelector: ".md-echarts-host svg",
|
||||||
|
resolvePageContent(figure, pageContent) {
|
||||||
|
const host = figure.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
const figureHeight = figure.getBoundingClientRect().height;
|
||||||
|
const hostHeight = host?.getBoundingClientRect().height ?? 0;
|
||||||
|
const nonChartHeight = Math.max(
|
||||||
|
0,
|
||||||
|
figureHeight - hostHeight
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: pageContent.width,
|
||||||
|
height: Math.max(0, pageContent.height - nonChartHeight)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onFit(figure, _svg, fit) {
|
||||||
|
const host = figure.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.style.setProperty(
|
||||||
|
"width",
|
||||||
|
`${fit.width}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
host.style.setProperty(
|
||||||
|
"height",
|
||||||
|
`${fit.height}px`,
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
host.style.setProperty("margin-inline", "auto", "important");
|
||||||
|
host.style.removeProperty("aspect-ratio");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
type ExportConfig
|
type ExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v2";
|
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
||||||
|
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
||||||
|
"md-to-pdf.export-config.v2";
|
||||||
|
|
||||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||||
|
|
||||||
@@ -18,7 +20,19 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue));
|
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 parsed = exportConfigSchema.safeParse(migratedValue);
|
||||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||||
} catch {
|
} catch {
|
||||||
return cloneDefaultExportConfig();
|
return cloneDefaultExportConfig();
|
||||||
@@ -28,7 +42,17 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
|||||||
export function loadExportConfig(
|
export function loadExportConfig(
|
||||||
storage: SettingsStorage = window.localStorage
|
storage: SettingsStorage = window.localStorage
|
||||||
) {
|
) {
|
||||||
return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY));
|
const currentValue = storage.getItem(EXPORT_CONFIG_STORAGE_KEY);
|
||||||
|
if (currentValue) {
|
||||||
|
return parseStoredExportConfig(currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
|
||||||
|
const config = parseStoredExportConfig(legacyValue);
|
||||||
|
if (legacyValue) {
|
||||||
|
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
}
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveExportConfig(
|
export function saveExportConfig(
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
export type MediaBackfillKind = "image" | "mermaid" | "echarts";
|
||||||
|
|
||||||
|
export interface MediaBackfillGeometry {
|
||||||
|
remainingHeight: number;
|
||||||
|
fixedHeight: number;
|
||||||
|
baselineVisualHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaBackfillCandidate {
|
||||||
|
id: string;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaBackfillOptions {
|
||||||
|
minimumScale?: number;
|
||||||
|
safetyGapPx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIA_BLOCK_SELECTOR = [
|
||||||
|
".md-document-image-block",
|
||||||
|
".mermaid:not(.mermaid-error)",
|
||||||
|
".md-echarts:not(.md-echarts-error)"
|
||||||
|
].join(", ");
|
||||||
|
const DEFAULT_MINIMUM_SCALE = 0.88;
|
||||||
|
const DEFAULT_SAFETY_GAP_PX = 2;
|
||||||
|
const SCALE_EPSILON = 0.001;
|
||||||
|
|
||||||
|
function finitePositive(value: string | undefined) {
|
||||||
|
const parsed = Number.parseFloat(value ?? "");
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaKind(block: Element): MediaBackfillKind | undefined {
|
||||||
|
if (block.classList.contains("md-document-image-block")) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (block.classList.contains("mermaid")) {
|
||||||
|
return "mermaid";
|
||||||
|
}
|
||||||
|
if (block.classList.contains("md-echarts")) {
|
||||||
|
return "echarts";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaVisual(
|
||||||
|
block: Element,
|
||||||
|
kind: MediaBackfillKind
|
||||||
|
): HTMLElement | SVGSVGElement | undefined {
|
||||||
|
if (kind === "image") {
|
||||||
|
return (
|
||||||
|
block.querySelector<HTMLImageElement>("img.md-document-image") ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === "echarts") {
|
||||||
|
return (
|
||||||
|
block.querySelector<HTMLElement>(".md-echarts-host") ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
block.querySelector<SVGSVGElement>("svg") ??
|
||||||
|
block.querySelector<HTMLImageElement>("img") ??
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePixelValue(value: string) {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOuterBottom(element: HTMLElement) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.bottom +
|
||||||
|
parsePixelValue(getComputedStyle(element).marginBottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOuterTop(element: HTMLElement) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.top -
|
||||||
|
parsePixelValue(getComputedStyle(element).marginTop)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaGroupTop(
|
||||||
|
block: HTMLElement,
|
||||||
|
pageContentTop: number
|
||||||
|
) {
|
||||||
|
const title = block.previousElementSibling;
|
||||||
|
if (
|
||||||
|
title instanceof HTMLElement &&
|
||||||
|
/^H[1-6]$/.test(title.tagName)
|
||||||
|
) {
|
||||||
|
return Math.max(
|
||||||
|
pageContentTop,
|
||||||
|
getOuterTop(title)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
pageContentTop,
|
||||||
|
getOuterTop(block)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastContentBottom(pageContent: HTMLElement) {
|
||||||
|
const contentRect = pageContent.getBoundingClientRect();
|
||||||
|
const leafElements = Array.from(
|
||||||
|
pageContent.querySelectorAll<HTMLElement>("[data-ref]")
|
||||||
|
).filter(
|
||||||
|
(element) => !element.querySelector("[data-ref]")
|
||||||
|
);
|
||||||
|
let bottom = contentRect.top;
|
||||||
|
|
||||||
|
for (const element of leafElements) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0 ||
|
||||||
|
rect.bottom <= contentRect.top ||
|
||||||
|
rect.top >= contentRect.bottom
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bottom = Math.max(
|
||||||
|
bottom,
|
||||||
|
Math.min(getOuterBottom(element), contentRect.bottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasForcedBreakBefore(block: HTMLElement) {
|
||||||
|
const title = block.previousElementSibling;
|
||||||
|
const elements = [
|
||||||
|
block,
|
||||||
|
title instanceof HTMLElement && /^H[1-6]$/.test(title.tagName)
|
||||||
|
? title
|
||||||
|
: undefined
|
||||||
|
].filter((element): element is HTMLElement => Boolean(element));
|
||||||
|
return elements.some((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return (
|
||||||
|
style.breakBefore === "page" ||
|
||||||
|
style.pageBreakBefore === "always"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateMediaBackfillScale(
|
||||||
|
geometry: MediaBackfillGeometry,
|
||||||
|
options: MediaBackfillOptions = {}
|
||||||
|
) {
|
||||||
|
const minimumScale =
|
||||||
|
options.minimumScale ?? DEFAULT_MINIMUM_SCALE;
|
||||||
|
const safetyGapPx =
|
||||||
|
options.safetyGapPx ?? DEFAULT_SAFETY_GAP_PX;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(geometry.remainingHeight) ||
|
||||||
|
!Number.isFinite(geometry.fixedHeight) ||
|
||||||
|
!Number.isFinite(geometry.baselineVisualHeight) ||
|
||||||
|
geometry.remainingHeight <= 0 ||
|
||||||
|
geometry.fixedHeight < 0 ||
|
||||||
|
geometry.baselineVisualHeight <= 0 ||
|
||||||
|
!Number.isFinite(minimumScale) ||
|
||||||
|
minimumScale <= 0 ||
|
||||||
|
minimumScale >= 1 ||
|
||||||
|
!Number.isFinite(safetyGapPx) ||
|
||||||
|
safetyGapPx < 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableVisualHeight =
|
||||||
|
geometry.remainingHeight -
|
||||||
|
geometry.fixedHeight -
|
||||||
|
safetyGapPx;
|
||||||
|
const scale =
|
||||||
|
availableVisualHeight / geometry.baselineVisualHeight;
|
||||||
|
if (
|
||||||
|
scale < minimumScale ||
|
||||||
|
scale >= 1 - SCALE_EPSILON
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareMediaBackfillBlocks(root: ParentNode) {
|
||||||
|
const blocks = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(MEDIA_BLOCK_SELECTOR)
|
||||||
|
);
|
||||||
|
let prepared = 0;
|
||||||
|
|
||||||
|
for (const [index, block] of blocks.entries()) {
|
||||||
|
const kind = getMediaKind(block);
|
||||||
|
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||||
|
const rect = visual?.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
!kind ||
|
||||||
|
!visual ||
|
||||||
|
!rect ||
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
block.dataset.mediaBackfillId = `media-${index + 1}`;
|
||||||
|
block.dataset.mediaBackfillKind = kind;
|
||||||
|
block.dataset.mediaBaselineWidth = String(rect.width);
|
||||||
|
block.dataset.mediaBaselineHeight = String(rect.height);
|
||||||
|
prepared += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaBackfillIdsInDocumentOrder(
|
||||||
|
root: ParentNode
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-media-backfill-id]"
|
||||||
|
)
|
||||||
|
).flatMap((block) =>
|
||||||
|
block.dataset.mediaBackfillId
|
||||||
|
? [block.dataset.mediaBackfillId]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMediaBackfillCandidates(
|
||||||
|
root: ParentNode,
|
||||||
|
options: MediaBackfillOptions = {}
|
||||||
|
) {
|
||||||
|
const pages = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||||
|
);
|
||||||
|
const candidates: MediaBackfillCandidate[] = [];
|
||||||
|
const candidateIds = new Set<string>();
|
||||||
|
|
||||||
|
for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) {
|
||||||
|
const previousPage = pages[pageIndex - 1];
|
||||||
|
const currentPage = pages[pageIndex];
|
||||||
|
const previousContent =
|
||||||
|
previousPage?.querySelector<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const currentContent =
|
||||||
|
currentPage?.querySelector<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
if (!previousContent || !currentContent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaBlocks = Array.from(
|
||||||
|
currentContent.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-media-backfill-id]"
|
||||||
|
)
|
||||||
|
).sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.getBoundingClientRect().top -
|
||||||
|
right.getBoundingClientRect().top
|
||||||
|
);
|
||||||
|
const block = mediaBlocks[0];
|
||||||
|
if (!block) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = getMediaKind(block);
|
||||||
|
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||||
|
const id = block.dataset.mediaBackfillId;
|
||||||
|
const baselineVisualHeight = finitePositive(
|
||||||
|
block.dataset.mediaBaselineHeight
|
||||||
|
);
|
||||||
|
if (!kind || !visual || !id || !baselineVisualHeight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasForcedBreakBefore(block)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentContentRect = currentContent.getBoundingClientRect();
|
||||||
|
const visualHeight = visual.getBoundingClientRect().height;
|
||||||
|
const mediaGroupTop = getMediaGroupTop(
|
||||||
|
block,
|
||||||
|
currentContentRect.top
|
||||||
|
);
|
||||||
|
const fixedHeight = Math.max(
|
||||||
|
0,
|
||||||
|
getOuterBottom(block) -
|
||||||
|
mediaGroupTop -
|
||||||
|
visualHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousContentRect =
|
||||||
|
previousContent.getBoundingClientRect();
|
||||||
|
const previousFlowBottom =
|
||||||
|
getLastContentBottom(previousContent);
|
||||||
|
const remainingHeight = Math.max(
|
||||||
|
0,
|
||||||
|
previousContentRect.bottom - previousFlowBottom
|
||||||
|
);
|
||||||
|
const scale = calculateMediaBackfillScale(
|
||||||
|
{
|
||||||
|
remainingHeight,
|
||||||
|
fixedHeight,
|
||||||
|
baselineVisualHeight
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (scale === undefined || candidateIds.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidateIds.add(id);
|
||||||
|
candidates.push({ id, scale });
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisualSize(
|
||||||
|
visual: HTMLElement | SVGSVGElement,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) {
|
||||||
|
visual.style.setProperty("display", "block");
|
||||||
|
visual.style.setProperty("width", `${width}px`, "important");
|
||||||
|
visual.style.setProperty("height", `${height}px`, "important");
|
||||||
|
visual.style.setProperty("max-width", "100%", "important");
|
||||||
|
visual.style.setProperty("max-height", `${height}px`, "important");
|
||||||
|
visual.style.setProperty("margin-inline", "auto", "important");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyMediaBackfillCandidates(
|
||||||
|
source: ParentNode,
|
||||||
|
candidates: MediaBackfillCandidate[]
|
||||||
|
) {
|
||||||
|
let applied = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const block = Array.from(
|
||||||
|
source.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-media-backfill-id]"
|
||||||
|
)
|
||||||
|
).find(
|
||||||
|
(element) =>
|
||||||
|
element.dataset.mediaBackfillId === candidate.id
|
||||||
|
);
|
||||||
|
const kind = block ? getMediaKind(block) : undefined;
|
||||||
|
const baselineWidth = finitePositive(
|
||||||
|
block?.dataset.mediaBaselineWidth
|
||||||
|
);
|
||||||
|
const baselineHeight = finitePositive(
|
||||||
|
block?.dataset.mediaBaselineHeight
|
||||||
|
);
|
||||||
|
const visual =
|
||||||
|
block && kind ? getMediaVisual(block, kind) : undefined;
|
||||||
|
if (
|
||||||
|
!block ||
|
||||||
|
!kind ||
|
||||||
|
!visual ||
|
||||||
|
!baselineWidth ||
|
||||||
|
!baselineHeight ||
|
||||||
|
!Number.isFinite(candidate.scale) ||
|
||||||
|
candidate.scale <= 0 ||
|
||||||
|
candidate.scale >= 1
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = baselineWidth * candidate.scale;
|
||||||
|
const height = baselineHeight * candidate.scale;
|
||||||
|
block.dataset.mediaBackfilled = "true";
|
||||||
|
block.dataset.mediaBackfillScale = String(candidate.scale);
|
||||||
|
setVisualSize(visual, width, height);
|
||||||
|
|
||||||
|
if (kind === "image") {
|
||||||
|
visual.style.setProperty("object-fit", "contain");
|
||||||
|
} else if (kind === "echarts") {
|
||||||
|
const svg = block.querySelector<SVGSVGElement>(
|
||||||
|
".md-echarts-host svg"
|
||||||
|
);
|
||||||
|
if (svg) {
|
||||||
|
setVisualSize(svg, width, height);
|
||||||
|
}
|
||||||
|
visual.style.removeProperty("aspect-ratio");
|
||||||
|
}
|
||||||
|
applied += 1;
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { MermaidExportConfig } from "@md-to-pdf/core";
|
||||||
import type { MermaidConfig } from "mermaid";
|
import type { MermaidConfig } from "mermaid";
|
||||||
|
|
||||||
export const MERMAID_SECURE_CONFIG_KEYS = [
|
export const MERMAID_SECURE_CONFIG_KEYS = [
|
||||||
@@ -10,13 +11,16 @@ export const MERMAID_SECURE_CONFIG_KEYS = [
|
|||||||
"themeCSS"
|
"themeCSS"
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createMermaidSiteConfig(): MermaidConfig {
|
export function createMermaidSiteConfig(
|
||||||
|
config: MermaidExportConfig
|
||||||
|
): MermaidConfig {
|
||||||
return {
|
return {
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
securityLevel: "strict",
|
securityLevel: "strict",
|
||||||
theme: "default",
|
layout: config.layout,
|
||||||
look: "classic",
|
theme: config.theme,
|
||||||
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
|
look: config.look,
|
||||||
|
fontFamily: config.fontFamily,
|
||||||
flowchart: {
|
flowchart: {
|
||||||
wrappingWidth: 320
|
wrappingWidth: 320
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,123 +1,26 @@
|
|||||||
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
calculateDiagramPageFit,
|
||||||
lengthToMillimeters,
|
fitSvgDiagramsToPage,
|
||||||
millimetersToCssPixels,
|
getPageContentDimensions,
|
||||||
type ExportConfig
|
parseSvgViewBox,
|
||||||
} from "@md-to-pdf/core";
|
type DiagramDimensions,
|
||||||
|
type DiagramPageFit
|
||||||
|
} from "./diagram-page-fit";
|
||||||
|
|
||||||
export interface MermaidDimensions {
|
export type MermaidDimensions = DiagramDimensions;
|
||||||
width: number;
|
export type MermaidPageFit = DiagramPageFit;
|
||||||
height: number;
|
export {
|
||||||
}
|
getPageContentDimensions,
|
||||||
|
parseSvgViewBox
|
||||||
export interface MermaidPageFit extends MermaidDimensions {
|
};
|
||||||
scaled: boolean;
|
export const calculateMermaidPageFit = calculateDiagramPageFit;
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_FIT_EPSILON_PX = 1;
|
|
||||||
|
|
||||||
export function parseSvgViewBox(
|
|
||||||
value: string | null
|
|
||||||
): MermaidDimensions | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = value
|
|
||||||
.trim()
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map((part) => Number(part));
|
|
||||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
|
||||||
|
|
||||||
if (
|
|
||||||
values.length !== 4 ||
|
|
||||||
!Number.isFinite(width) ||
|
|
||||||
!Number.isFinite(height) ||
|
|
||||||
width <= 0 ||
|
|
||||||
height <= 0
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPageContentDimensions(
|
|
||||||
config: ExportConfig
|
|
||||||
): MermaidDimensions {
|
|
||||||
const paper = getPaperDimensionsMm(
|
|
||||||
config.paper.format,
|
|
||||||
config.paper.orientation
|
|
||||||
);
|
|
||||||
const width =
|
|
||||||
paper.width -
|
|
||||||
lengthToMillimeters(config.paper.margins.left) -
|
|
||||||
lengthToMillimeters(config.paper.margins.right);
|
|
||||||
const height =
|
|
||||||
paper.height -
|
|
||||||
lengthToMillimeters(config.paper.margins.top) -
|
|
||||||
lengthToMillimeters(config.paper.margins.bottom);
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: millimetersToCssPixels(width),
|
|
||||||
height: millimetersToCssPixels(height)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateMermaidPageFit(
|
|
||||||
intrinsic: MermaidDimensions,
|
|
||||||
pageContent: MermaidDimensions
|
|
||||||
): MermaidPageFit {
|
|
||||||
const heightAfterWidthFit =
|
|
||||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
|
||||||
|
|
||||||
if (heightAfterWidthFit <= pageContent.height) {
|
|
||||||
return {
|
|
||||||
width: pageContent.width,
|
|
||||||
height: heightAfterWidthFit,
|
|
||||||
scaled: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = Math.max(
|
|
||||||
0,
|
|
||||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
width: (height * intrinsic.width) / intrinsic.height,
|
|
||||||
height,
|
|
||||||
scaled: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fitOversizedMermaidToPage(
|
export function fitOversizedMermaidToPage(
|
||||||
container: ParentNode,
|
container: ParentNode,
|
||||||
config: ExportConfig
|
config: ExportConfig
|
||||||
) {
|
) {
|
||||||
const pageContent = getPageContentDimensions(config);
|
fitSvgDiagramsToPage(container, config, {
|
||||||
const diagrams = Array.from(
|
containerSelector: ".mermaid"
|
||||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
});
|
||||||
);
|
|
||||||
|
|
||||||
for (const diagram of diagrams) {
|
|
||||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
|
||||||
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
|
|
||||||
if (!svg || !intrinsic) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fit = calculateMermaidPageFit(intrinsic, pageContent);
|
|
||||||
if (!fit.scaled) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
diagram.dataset.pageHeightFitted = "true";
|
|
||||||
diagram.style.setProperty("margin-block", "0", "important");
|
|
||||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
||||||
svg.style.setProperty("display", "block");
|
|
||||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
|
||||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
|
||||||
svg.style.setProperty("max-width", "100%", "important");
|
|
||||||
svg.style.setProperty("max-height", `${fit.height}px`, "important");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||||
import katexCss from "katex/dist/katex.min.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 { Previewer } from "pagedjs";
|
||||||
|
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||||
|
import {
|
||||||
|
fitDocumentImagesToPage,
|
||||||
|
prepareDocumentImageBlocks
|
||||||
|
} from "./document-image-fit";
|
||||||
|
import {
|
||||||
|
applyMediaBackfillCandidates,
|
||||||
|
findMediaBackfillCandidates,
|
||||||
|
getMediaBackfillIdsInDocumentOrder,
|
||||||
|
prepareMediaBackfillBlocks
|
||||||
|
} from "./media-page-backfill";
|
||||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||||
import { fitOversizedMermaidToPage } from "./mermaid-page-fit";
|
import {
|
||||||
|
fitOversizedMermaidToPage,
|
||||||
|
getPageContentDimensions
|
||||||
|
} from "./mermaid-page-fit";
|
||||||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
||||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||||||
|
import { enablePrintMediaForPreview } from "./preview-styles";
|
||||||
import "./paged-table-handler";
|
import "./paged-table-handler";
|
||||||
import {
|
import {
|
||||||
buildPagedMediaCss,
|
buildPagedMediaCss,
|
||||||
@@ -16,23 +36,10 @@ import {
|
|||||||
} from "./paged-preview";
|
} from "./paged-preview";
|
||||||
import type { PagedRenderTarget } from "./paged-render-target";
|
import type { PagedRenderTarget } from "./paged-render-target";
|
||||||
|
|
||||||
export interface PagedDocumentRenderResult {
|
export type {
|
||||||
pageCount: number;
|
PagedDocumentRenderResult,
|
||||||
contentHeight: number;
|
PagedDocumentTimings
|
||||||
mermaidErrors: string[];
|
} from "@md-to-pdf/core";
|
||||||
timings: PagedDocumentTimings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedDocumentTimings {
|
|
||||||
setupMs: number;
|
|
||||||
mermaidMs: number;
|
|
||||||
mermaidFitMs: number;
|
|
||||||
mermaidConversionMs: number;
|
|
||||||
resourceWaitMs: number;
|
|
||||||
paginationMs: number;
|
|
||||||
finalizeMs: number;
|
|
||||||
totalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedDocumentRenderOptions {
|
export interface PagedDocumentRenderOptions {
|
||||||
target: PagedRenderTarget;
|
target: PagedRenderTarget;
|
||||||
@@ -70,6 +77,55 @@ function stylesheet(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
function applyPageNumbers(
|
||||||
container: ParentNode,
|
container: ParentNode,
|
||||||
payload: PagedPreviewPayload,
|
payload: PagedPreviewPayload,
|
||||||
@@ -116,6 +172,11 @@ export class PagedDocumentRuntime {
|
|||||||
private mermaidPromise:
|
private mermaidPromise:
|
||||||
| Promise<(typeof import("mermaid"))["default"]>
|
| Promise<(typeof import("mermaid"))["default"]>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
private echartsPromise:
|
||||||
|
| Promise<
|
||||||
|
typeof import("@md-to-pdf/markdown-echarts/browser")
|
||||||
|
>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
constructor(private readonly root: HTMLElement) {}
|
constructor(private readonly root: HTMLElement) {}
|
||||||
|
|
||||||
@@ -131,9 +192,12 @@ export class PagedDocumentRuntime {
|
|||||||
|
|
||||||
private loadMermaid() {
|
private loadMermaid() {
|
||||||
if (!this.mermaidPromise) {
|
if (!this.mermaidPromise) {
|
||||||
this.mermaidPromise = import("mermaid").then(
|
this.mermaidPromise = Promise.all([
|
||||||
({ default: mermaid }) => {
|
import("mermaid"),
|
||||||
mermaid.initialize(createMermaidSiteConfig());
|
import("@mermaid-js/layout-elk")
|
||||||
|
]).then(
|
||||||
|
([{ default: mermaid }, { default: elkLayouts }]) => {
|
||||||
|
mermaid.registerLayoutLoaders(elkLayouts);
|
||||||
return mermaid;
|
return mermaid;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -142,6 +206,66 @@ export class PagedDocumentRuntime {
|
|||||||
return this.mermaidPromise;
|
return this.mermaidPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadECharts() {
|
||||||
|
if (!this.echartsPromise) {
|
||||||
|
this.echartsPromise = import(
|
||||||
|
"@md-to-pdf/markdown-echarts/browser"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.echartsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderECharts(
|
||||||
|
container: ParentNode,
|
||||||
|
payload: PagedPreviewPayload
|
||||||
|
) {
|
||||||
|
if (!payload.features.includes("echarts")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { renderEChartsBlocks } = await this.loadECharts();
|
||||||
|
const outcomes = await renderEChartsBlocks(container, {
|
||||||
|
outputMode: "inline-svg",
|
||||||
|
concurrency: 2,
|
||||||
|
timeoutMs: 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
return outcomes.flatMap((outcome, index) =>
|
||||||
|
outcome.success
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
`图表 ${index + 1}:${
|
||||||
|
outcome.error?.message ?? "未知错误"
|
||||||
|
}`
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceEChartsSvgWithImages(container: ParentNode) {
|
||||||
|
const { freezeEChartsSvg } = await this.loadECharts();
|
||||||
|
const figures = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
".md-echarts:not(.md-echarts-error)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const figure of figures) {
|
||||||
|
const host = figure.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
const svg = host?.querySelector<SVGSVGElement>("svg");
|
||||||
|
if (!host || !svg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
host.getAttribute("aria-label") || "ECharts 图表";
|
||||||
|
host.replaceChildren(
|
||||||
|
freezeEChartsSvg(host, svg, "svg-image", label)
|
||||||
|
);
|
||||||
|
figure.dataset.echartsRendered = "svg-image";
|
||||||
|
}
|
||||||
|
await waitForImages(container);
|
||||||
|
}
|
||||||
|
|
||||||
private async renderMermaid(
|
private async renderMermaid(
|
||||||
container: DocumentFragment,
|
container: DocumentFragment,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload
|
||||||
@@ -160,6 +284,9 @@ export class PagedDocumentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mermaid = await this.loadMermaid();
|
const mermaid = await this.loadMermaid();
|
||||||
|
mermaid.initialize(
|
||||||
|
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
||||||
|
);
|
||||||
const outcomes = await renderMermaidDefinitions(
|
const outcomes = await renderMermaidDefinitions(
|
||||||
nodes.map((node) => node.textContent ?? ""),
|
nodes.map((node) => node.textContent ?? ""),
|
||||||
async (definition) => {
|
async (definition) => {
|
||||||
@@ -227,16 +354,67 @@ export class PagedDocumentRuntime {
|
|||||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
const mermaidErrors = await this.renderMermaid(content, payload);
|
||||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
const mermaidMs = performance.now() - mermaidStartedAt;
|
||||||
|
|
||||||
const mermaidFitStartedAt = performance.now();
|
let echartsErrors: string[] = [];
|
||||||
fitOversizedMermaidToPage(content, payload.exportConfig);
|
let echartsMs = 0;
|
||||||
const mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
let echartsFitMs = 0;
|
||||||
|
let mermaidFitMs = 0;
|
||||||
|
let mermaidConversionMs = 0;
|
||||||
|
if (
|
||||||
|
payload.features.includes("echarts") ||
|
||||||
|
content.querySelector(".mermaid svg") ||
|
||||||
|
content.querySelector("img.md-document-image")
|
||||||
|
) {
|
||||||
|
const measurement = mountMeasurementContainer(
|
||||||
|
documentRef,
|
||||||
|
content,
|
||||||
|
payload,
|
||||||
|
options.target
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await documentRef.fonts.ready;
|
||||||
|
|
||||||
const mermaidConversionStartedAt = performance.now();
|
const echartsStartedAt = performance.now();
|
||||||
if (options.mermaidOutput === "svg-image") {
|
echartsErrors = await this.renderECharts(
|
||||||
replaceMermaidSvgWithImages(content);
|
measurement.host,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
echartsMs = performance.now() - echartsStartedAt;
|
||||||
|
await waitForImages(measurement.host);
|
||||||
|
prepareDocumentImageBlocks(measurement.host);
|
||||||
|
prepareMediaBackfillBlocks(measurement.host);
|
||||||
|
fitDocumentImagesToPage(
|
||||||
|
measurement.host,
|
||||||
|
payload.exportConfig,
|
||||||
|
{ prepareBlocks: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const echartsFitStartedAt = performance.now();
|
||||||
|
fitOversizedEChartsToPage(
|
||||||
|
measurement.host,
|
||||||
|
payload.exportConfig
|
||||||
|
);
|
||||||
|
await this.replaceEChartsSvgWithImages(measurement.host);
|
||||||
|
echartsFitMs =
|
||||||
|
performance.now() - echartsFitStartedAt;
|
||||||
|
|
||||||
|
const mermaidFitStartedAt = performance.now();
|
||||||
|
fitOversizedMermaidToPage(
|
||||||
|
measurement.host,
|
||||||
|
payload.exportConfig
|
||||||
|
);
|
||||||
|
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||||||
|
|
||||||
|
const mermaidConversionStartedAt = performance.now();
|
||||||
|
if (options.mermaidOutput === "svg-image") {
|
||||||
|
replaceMermaidSvgWithImages(measurement.host);
|
||||||
|
await waitForImages(measurement.host);
|
||||||
|
}
|
||||||
|
mermaidConversionMs =
|
||||||
|
performance.now() - mermaidConversionStartedAt;
|
||||||
|
} finally {
|
||||||
|
measurement.restore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const mermaidConversionMs =
|
|
||||||
performance.now() - mermaidConversionStartedAt;
|
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
if (!shouldContinue()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -248,28 +426,74 @@ export class PagedDocumentRuntime {
|
|||||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
||||||
|
|
||||||
const paginationStartedAt = performance.now();
|
const paginationStartedAt = performance.now();
|
||||||
const previewer = new Previewer();
|
const repaginationSource = content.cloneNode(
|
||||||
|
true
|
||||||
|
) as DocumentFragment;
|
||||||
|
const stylesheets = [
|
||||||
|
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||||
|
stylesheet(documentRef, highlightCss, "highlight"),
|
||||||
|
stylesheet(documentRef, katexCss, "katex"),
|
||||||
|
stylesheet(documentRef, echartsCss, "echarts"),
|
||||||
|
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||||
|
stylesheet(
|
||||||
|
documentRef,
|
||||||
|
documentGeometryCss,
|
||||||
|
"document-geometry"
|
||||||
|
),
|
||||||
|
stylesheet(
|
||||||
|
documentRef,
|
||||||
|
buildPagedMediaCss(payload.exportConfig, payload),
|
||||||
|
"paged-media"
|
||||||
|
)
|
||||||
|
];
|
||||||
|
let previewer = new Previewer();
|
||||||
this.activePreviewer = previewer;
|
this.activePreviewer = previewer;
|
||||||
const flow = await previewer.preview(
|
let flow = await previewer.preview(
|
||||||
content,
|
content,
|
||||||
[
|
stylesheets,
|
||||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
|
||||||
stylesheet(documentRef, highlightCss, "highlight"),
|
|
||||||
stylesheet(documentRef, katexCss, "katex"),
|
|
||||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
|
||||||
stylesheet(
|
|
||||||
documentRef,
|
|
||||||
documentGeometryCss,
|
|
||||||
"document-geometry"
|
|
||||||
),
|
|
||||||
stylesheet(
|
|
||||||
documentRef,
|
|
||||||
buildPagedMediaCss(payload.exportConfig, payload),
|
|
||||||
"paged-media"
|
|
||||||
)
|
|
||||||
],
|
|
||||||
this.root
|
this.root
|
||||||
);
|
);
|
||||||
|
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
||||||
|
repaginationSource
|
||||||
|
);
|
||||||
|
let mediaCandidates = new Map(
|
||||||
|
findMediaBackfillCandidates(this.root).map((candidate) => [
|
||||||
|
candidate.id,
|
||||||
|
candidate
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for (const mediaId of mediaIds) {
|
||||||
|
if (!shouldContinue()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const candidate = mediaCandidates.get(mediaId);
|
||||||
|
if (
|
||||||
|
!candidate ||
|
||||||
|
applyMediaBackfillCandidates(repaginationSource, [
|
||||||
|
candidate
|
||||||
|
]) === 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewer.chunker.destroy();
|
||||||
|
previewer.polisher.destroy();
|
||||||
|
previewer = new Previewer();
|
||||||
|
this.activePreviewer = previewer;
|
||||||
|
flow = await previewer.preview(
|
||||||
|
repaginationSource.cloneNode(true) as DocumentFragment,
|
||||||
|
stylesheets,
|
||||||
|
this.root
|
||||||
|
);
|
||||||
|
mediaCandidates = new Map(
|
||||||
|
findMediaBackfillCandidates(this.root).map(
|
||||||
|
(nextCandidate) => [
|
||||||
|
nextCandidate.id,
|
||||||
|
nextCandidate
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
const paginationMs = performance.now() - paginationStartedAt;
|
const paginationMs = performance.now() - paginationStartedAt;
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
if (!shouldContinue()) {
|
||||||
@@ -285,16 +509,12 @@ export class PagedDocumentRuntime {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pageCount: flow.total,
|
pageCount: flow.total,
|
||||||
contentHeight: Math.ceil(
|
echartsErrors,
|
||||||
Math.max(
|
|
||||||
documentRef.documentElement.scrollHeight,
|
|
||||||
documentRef.body.scrollHeight,
|
|
||||||
this.root.scrollHeight
|
|
||||||
)
|
|
||||||
),
|
|
||||||
mermaidErrors,
|
mermaidErrors,
|
||||||
timings: {
|
timings: {
|
||||||
setupMs,
|
setupMs,
|
||||||
|
echartsMs,
|
||||||
|
echartsFitMs,
|
||||||
mermaidMs,
|
mermaidMs,
|
||||||
mermaidFitMs,
|
mermaidFitMs,
|
||||||
mermaidConversionMs,
|
mermaidConversionMs,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
} from "./paged-preview";
|
} from "./paged-preview";
|
||||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
import { resolvePagedRenderTarget } from "./paged-render-target";
|
||||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
||||||
|
import {
|
||||||
|
beginPreviewFrameRender,
|
||||||
|
completePreviewFrameRender
|
||||||
|
} from "./preview-frame-visibility";
|
||||||
|
|
||||||
function getPreviewRoot() {
|
function getPreviewRoot() {
|
||||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||||
@@ -37,27 +41,35 @@ async function renderPagedPreview(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginPreviewFrameRender(document.documentElement, requestId);
|
||||||
post({
|
post({
|
||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendering",
|
type: "rendering",
|
||||||
requestId
|
requestId
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.render(payload, {
|
try {
|
||||||
target: renderTarget,
|
const result = await runtime.render(payload, {
|
||||||
mermaidOutput,
|
target: renderTarget,
|
||||||
shouldContinue: () => requestId === latestRequestId
|
mermaidOutput,
|
||||||
});
|
shouldContinue: () => requestId === latestRequestId
|
||||||
if (!result) {
|
});
|
||||||
return;
|
if (!result) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
post({
|
post({
|
||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId,
|
requestId,
|
||||||
...result
|
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||||
});
|
...result
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestRequestId) {
|
||||||
|
completePreviewFrameRender(document.documentElement, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__mdToPdfRender = async (
|
window.__mdToPdfRender = async (
|
||||||
@@ -75,6 +87,30 @@ window.__mdToPdfRender = async (
|
|||||||
};
|
};
|
||||||
document.documentElement.dataset.runtimeReady = "true";
|
document.documentElement.dataset.runtimeReady = "true";
|
||||||
|
|
||||||
|
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||||
|
if (desktopPdfBridge) {
|
||||||
|
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||||
|
const requestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
window.requestAnimationFrame = (callback) =>
|
||||||
|
window.setTimeout(() => callback(performance.now()), 0);
|
||||||
|
try {
|
||||||
|
const result = await window.__mdToPdfRender?.(payload, "pdf");
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("PDF 分页运行时不可用");
|
||||||
|
}
|
||||||
|
desktopPdfBridge.complete(requestId, result);
|
||||||
|
} catch (reason: unknown) {
|
||||||
|
desktopPdfBridge.fail(
|
||||||
|
requestId,
|
||||||
|
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.requestAnimationFrame = requestAnimationFrame;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
desktopPdfBridge.ready();
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||||
if (
|
if (
|
||||||
event.origin !== window.location.origin ||
|
event.origin !== window.location.origin ||
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
type ExportConfig
|
type ExportConfig,
|
||||||
|
type MarkdownDocumentMetadata,
|
||||||
|
type PagedDocumentPayload
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
|
||||||
|
|
||||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||||
|
|
||||||
export interface PreviewMetadata {
|
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||||
title: string;
|
export type PagedPreviewPayload = PagedDocumentPayload;
|
||||||
author: string;
|
|
||||||
subject: string;
|
|
||||||
keywords: string[];
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedPreviewPayload {
|
|
||||||
articleHtml: string;
|
|
||||||
fileName: string;
|
|
||||||
metadata: PreviewMetadata;
|
|
||||||
features: string[];
|
|
||||||
themeCss: string;
|
|
||||||
exportConfig: ExportConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagedPreviewRenderRequest {
|
export interface PagedPreviewRenderRequest {
|
||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
@@ -46,6 +32,7 @@ export type PagedPreviewFrameMessage =
|
|||||||
requestId: number;
|
requestId: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
contentHeight: number;
|
contentHeight: number;
|
||||||
|
echartsErrors: string[];
|
||||||
mermaidErrors: string[];
|
mermaidErrors: string[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -80,6 +67,25 @@ svg {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-document-image-block {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-document-image-block > .md-document-image {
|
||||||
|
display: block;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-document-image-caption {
|
||||||
|
margin-top: 0.55em;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
#write table {
|
#write table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
@@ -105,6 +111,11 @@ svg {
|
|||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-echarts {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const documentGeometryCss = `
|
export const documentGeometryCss = `
|
||||||
@@ -306,7 +317,8 @@ ${buildFooterCss(config)}
|
|||||||
#write pre,
|
#write pre,
|
||||||
#write blockquote,
|
#write blockquote,
|
||||||
#write .katex-display,
|
#write .katex-display,
|
||||||
#write .mermaid {
|
#write .mermaid,
|
||||||
|
#write .md-echarts {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +332,11 @@ ${buildFooterCss(config)}
|
|||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#write .md-document-image-block {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
${
|
${
|
||||||
config.print.pageBreakBeforeH1
|
config.print.pageBreakBeforeH1
|
||||||
? `#write h1:not(:first-child) {
|
? `#write h1:not(:first-child) {
|
||||||
@@ -342,16 +359,7 @@ ${
|
|||||||
html[data-render-target="preview"] {
|
html[data-render-target="preview"] {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: scroll;
|
overflow-y: hidden;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-render-target="preview"]::-webkit-scrollbar {
|
|
||||||
width: ${PREVIEW_SCROLLBAR_WIDTH_PX}px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-render-target="preview"]::-webkit-scrollbar:horizontal {
|
|
||||||
height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-render-target="preview"] body {
|
html[data-render-target="preview"] body {
|
||||||
@@ -426,10 +434,26 @@ export function isPagedPreviewFrameMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||||
return (
|
if (
|
||||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||||
["ready", "rendering", "rendered", "error"].includes(
|
!["ready", "rendering", "rendered", "error"].includes(
|
||||||
candidate.type ?? ""
|
candidate.type ?? ""
|
||||||
)
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.type !== "rendered") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Number.isInteger(candidate.requestId) &&
|
||||||
|
Number.isInteger(candidate.pageCount) &&
|
||||||
|
typeof candidate.contentHeight === "number" &&
|
||||||
|
Number.isFinite(candidate.contentHeight) &&
|
||||||
|
candidate.contentHeight > 0 &&
|
||||||
|
Array.isArray(candidate.echartsErrors) &&
|
||||||
|
Array.isArray(candidate.mermaidErrors)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export interface PdfExportRequest {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
resources: MarkdownImageResource[];
|
||||||
exportConfig: ExportConfig;
|
exportConfig: ExportConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,6 +16,7 @@ export interface PdfExportResult {
|
|||||||
blob: Blob;
|
blob: Blob;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
pageCount: number | undefined;
|
pageCount: number | undefined;
|
||||||
|
echartsErrorCount: number;
|
||||||
mermaidErrorCount: number;
|
mermaidErrorCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
|
|||||||
request.markdown,
|
request.markdown,
|
||||||
request.fileName,
|
request.fileName,
|
||||||
request.language,
|
request.language,
|
||||||
|
request.resources,
|
||||||
request.exportConfig
|
request.exportConfig
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -117,6 +124,10 @@ export async function requestPdfExport(
|
|||||||
pageCount: parseNumericHeader(
|
pageCount: parseNumericHeader(
|
||||||
response.headers.get("x-pdf-page-count")
|
response.headers.get("x-pdf-page-count")
|
||||||
),
|
),
|
||||||
|
echartsErrorCount:
|
||||||
|
parseNumericHeader(
|
||||||
|
response.headers.get("x-echarts-error-count")
|
||||||
|
) ?? 0,
|
||||||
mermaidErrorCount:
|
mermaidErrorCount:
|
||||||
parseNumericHeader(
|
parseNumericHeader(
|
||||||
response.headers.get("x-mermaid-error-count")
|
response.headers.get("x-mermaid-error-count")
|
||||||
@@ -124,11 +135,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 url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
anchor.download = fileName;
|
anchor.download = fileName;
|
||||||
anchor.click();
|
anchor.click();
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
export const PDF_POINTS_PER_INCH = 72;
|
import {
|
||||||
export const CSS_PIXELS_PER_INCH = 96;
|
CSS_PIXELS_PER_INCH,
|
||||||
|
PDF_POINTS_PER_INCH
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export const PDF_PAGE_CSS_SCALE =
|
export const PDF_PAGE_CSS_SCALE =
|
||||||
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
||||||
|
|
||||||
export function getPdfPageCssDimensions(
|
export function getPdfPageCssDimensions(
|
||||||
widthPoints: number,
|
widthPoints: number,
|
||||||
heightPoints: number
|
heightPoints: number,
|
||||||
|
displayScale = 1
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
width: widthPoints * PDF_PAGE_CSS_SCALE,
|
width: widthPoints * PDF_PAGE_CSS_SCALE * displayScale,
|
||||||
height: heightPoints * PDF_PAGE_CSS_SCALE
|
height: heightPoints * PDF_PAGE_CSS_SCALE * displayScale
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
interface PreviewFrameVisibilityTarget {
|
||||||
|
dataset: DOMStringMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginPreviewFrameRender(
|
||||||
|
target: PreviewFrameVisibilityTarget,
|
||||||
|
requestId: number
|
||||||
|
) {
|
||||||
|
target.dataset.rendering = "true";
|
||||||
|
target.dataset.renderingRequestId = String(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completePreviewFrameRender(
|
||||||
|
target: PreviewFrameVisibilityTarget,
|
||||||
|
requestId: number
|
||||||
|
) {
|
||||||
|
if (target.dataset.renderingRequestId !== String(requestId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete target.dataset.rendering;
|
||||||
|
delete target.dataset.renderingRequestId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -3,7 +3,50 @@ export interface PageVerticalRange {
|
|||||||
bottom: number;
|
bottom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PREVIEW_SCROLLBAR_WIDTH_PX = 14;
|
export function getPreviewPageScrollTop(
|
||||||
|
currentScrollTop: number,
|
||||||
|
pageViewportTop: number,
|
||||||
|
scrollerViewportTop: number
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(currentScrollTop) ||
|
||||||
|
!Number.isFinite(pageViewportTop) ||
|
||||||
|
!Number.isFinite(scrollerViewportTop)
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
currentScrollTop + pageViewportTop - scrollerViewportTop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePreviewPageNumber(
|
||||||
|
value: string,
|
||||||
|
current: number,
|
||||||
|
total: number
|
||||||
|
) {
|
||||||
|
if (!Number.isInteger(total) || total < 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
const fallback = Number.isInteger(current)
|
||||||
|
? Math.min(Math.max(current, 1), total)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
if (normalizedValue.length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(normalizedValue);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(parsed, 1), total);
|
||||||
|
}
|
||||||
|
|
||||||
export function getNearestPageNumber(
|
export function getNearestPageNumber(
|
||||||
viewportCenter: number,
|
viewportCenter: number,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export const PREVIEW_ZOOM_STORAGE_KEY = "md-to-pdf.preview-zoom.v1";
|
||||||
|
export const DEFAULT_PREVIEW_ZOOM = 100;
|
||||||
|
export const MINIMUM_PREVIEW_ZOOM = 50;
|
||||||
|
export const MAXIMUM_PREVIEW_ZOOM = 400;
|
||||||
|
export const PREVIEW_ZOOM_SLIDER_STEP = 5;
|
||||||
|
|
||||||
|
type PreviewZoomStorage = Pick<Storage, "getItem" | "setItem">;
|
||||||
|
|
||||||
|
export function normalizePreviewZoom(value: number) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return DEFAULT_PREVIEW_ZOOM;
|
||||||
|
}
|
||||||
|
return Math.min(
|
||||||
|
MAXIMUM_PREVIEW_ZOOM,
|
||||||
|
Math.max(MINIMUM_PREVIEW_ZOOM, Math.round(value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePreviewZoom(
|
||||||
|
value: string | null,
|
||||||
|
fallback = DEFAULT_PREVIEW_ZOOM
|
||||||
|
) {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
return normalizePreviewZoom(fallback);
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed)
|
||||||
|
? normalizePreviewZoom(parsed)
|
||||||
|
: normalizePreviewZoom(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPreviewZoom(
|
||||||
|
storage: PreviewZoomStorage = window.localStorage
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return parsePreviewZoom(storage.getItem(PREVIEW_ZOOM_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PREVIEW_ZOOM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePreviewZoom(
|
||||||
|
value: number,
|
||||||
|
storage: PreviewZoomStorage = window.localStorage
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
storage.setItem(
|
||||||
|
PREVIEW_ZOOM_STORAGE_KEY,
|
||||||
|
String(normalizePreviewZoom(value))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 预览缩放是非关键偏好,浏览器禁用缓存时保持当前会话值即可。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
`;
|
||||||
@@ -24,8 +24,7 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button {
|
||||||
.file-button {
|
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border: 1px solid #b8c2bd;
|
border: 1px solid #b8c2bd;
|
||||||
@@ -63,6 +62,75 @@ button:disabled {
|
|||||||
background: rgb(247 248 246 / 92%);
|
background: rgb(247 248 246 / 92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-primary {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand {
|
||||||
|
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;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(42vw, 520px);
|
||||||
|
padding-left: 28px;
|
||||||
|
border-left: 1px solid #d4dad7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
color: #3b755d;
|
color: #3b755d;
|
||||||
@@ -82,21 +150,42 @@ h1 {
|
|||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-button {
|
.more-menu {
|
||||||
display: inline-flex;
|
position: relative;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-button input {
|
.more-menu-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
z-index: 30;
|
||||||
height: 1px;
|
top: calc(100% + 8px);
|
||||||
overflow: hidden;
|
right: 0;
|
||||||
clip: rect(0 0 0 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-layout {
|
.editor-layout {
|
||||||
@@ -104,6 +193,11 @@ h1 {
|
|||||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
transition: grid-template-columns 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-layout.is-source-collapsed {
|
||||||
|
grid-template-columns: 48px minmax(520px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-panel,
|
.editor-panel,
|
||||||
@@ -119,6 +213,45 @@ h1 {
|
|||||||
background: #f6f7f5;
|
background: #f6f7f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-panel-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-content[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-toggle {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-color: #c9d4ce;
|
||||||
|
background: #f8fbf9;
|
||||||
|
color: #4f695e;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-restore {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f0f3f1;
|
||||||
|
color: #4f695e;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-restore:hover {
|
||||||
|
background: #e4ebe7;
|
||||||
|
color: #356c55;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-heading {
|
.panel-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -176,14 +309,109 @@ h1 {
|
|||||||
max-width: 68%;
|
max-width: 68%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-page-indicator {
|
.preview-toolbar {
|
||||||
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 20px;
|
||||||
|
border-bottom: 1px solid #d9dedb;
|
||||||
|
background: rgb(255 255 255 / 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-control {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
color: #5d7168;
|
color: #5d7168;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-page-control input {
|
||||||
|
width: 48px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid #c9d4ce;
|
||||||
|
border-radius: 7px;
|
||||||
|
outline: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: #356c55;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-control input:focus {
|
||||||
|
border-color: #74a88f;
|
||||||
|
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: #5d7168;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-slider {
|
||||||
|
width: 150px;
|
||||||
|
accent-color: #4f8a6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-number {
|
||||||
|
display: flex;
|
||||||
|
width: 68px;
|
||||||
|
height: 30px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #c9d4ce;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-number:focus-within {
|
||||||
|
border-color: #74a88f;
|
||||||
|
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-number input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 2px 0 8px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #356c55;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-number > span:last-child {
|
||||||
|
padding-right: 7px;
|
||||||
|
color: #7a8580;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-reset {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-color: #c9d4ce;
|
||||||
|
background: #f8fbf9;
|
||||||
|
color: #4f695e;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-reset:disabled {
|
||||||
|
color: #9ba49f;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-mode-control {
|
.preview-mode-control {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -290,6 +518,28 @@ textarea:focus {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0 30px;
|
padding: 0 30px;
|
||||||
|
scrollbar-color: #8d9993 #dfe4e1;
|
||||||
|
scrollbar-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar-track {
|
||||||
|
background: #dfe4e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar-thumb {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 3px solid #dfe4e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #8d9993;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6f7d76;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
@@ -319,7 +569,7 @@ textarea:focus {
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: safe center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 34px 30px;
|
padding: 34px 30px;
|
||||||
}
|
}
|
||||||
@@ -327,7 +577,6 @@ textarea:focus {
|
|||||||
.precise-pdf-page {
|
.precise-pdf-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
||||||
@@ -737,6 +986,21 @@ textarea:focus {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-primary {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-summary {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid #d4dad7;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -745,6 +1009,10 @@ textarea:focus {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-layout.is-source-collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-panel,
|
.editor-panel,
|
||||||
.preview-panel {
|
.preview-panel {
|
||||||
min-height: 650px;
|
min-height: 650px;
|
||||||
@@ -759,6 +1027,19 @@ textarea:focus {
|
|||||||
border-bottom: 1px solid #cbd2ce;
|
border-bottom: 1px solid #cbd2ce;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-layout.is-source-collapsed .editor-panel {
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-panel-restore {
|
||||||
|
height: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-scroll {
|
.preview-scroll {
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
@@ -775,12 +1056,40 @@ textarea:focus {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-control {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom-slider {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-control select {
|
.theme-control select {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.editor-layout {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
.topbar-brand-title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-drawer {
|
.settings-drawer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||||
|
import { renderMarkdownDocument } from "./application-backend";
|
||||||
|
import type { MarkdownImageResource } from "./application-backend";
|
||||||
|
|
||||||
|
export interface MarkdownRenderCallbacks {
|
||||||
|
onRenderStart?: () => void;
|
||||||
|
onStatusChange?: (status: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkdownRender(
|
||||||
|
markdown: string,
|
||||||
|
language: string,
|
||||||
|
resources: MarkdownImageResource[],
|
||||||
|
callbacks: MarkdownRenderCallbacks = {}
|
||||||
|
) {
|
||||||
|
const [result, setResult] =
|
||||||
|
useState<RenderedMarkdownDocument | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { onRenderStart, onStatusChange } = callbacks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
onStatusChange?.("正在渲染…");
|
||||||
|
onRenderStart?.();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
void renderMarkdownDocument(
|
||||||
|
{ markdown, language, resources },
|
||||||
|
controller.signal
|
||||||
|
)
|
||||||
|
.then((payload) => {
|
||||||
|
setResult(payload);
|
||||||
|
onStatusChange?.("预览已更新");
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "渲染失败"
|
||||||
|
);
|
||||||
|
onStatusChange?.("预览失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
language,
|
||||||
|
markdown,
|
||||||
|
onRenderStart,
|
||||||
|
onStatusChange,
|
||||||
|
resources
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { error, result };
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
getThemeCss,
|
||||||
|
listThemes,
|
||||||
|
type ThemeSummary
|
||||||
|
} from "./application-backend";
|
||||||
|
|
||||||
|
export type { ThemeSummary } from "./application-backend";
|
||||||
|
|
||||||
|
export function useThemeResources(
|
||||||
|
themeId: string,
|
||||||
|
onThemesLoaded: (
|
||||||
|
themes: ThemeSummary[],
|
||||||
|
refreshKey: number
|
||||||
|
) => void,
|
||||||
|
refreshKey = 0
|
||||||
|
) {
|
||||||
|
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||||
|
const [themeCss, setThemeCss] = useState("");
|
||||||
|
const [catalogError, setCatalogError] = useState("");
|
||||||
|
const [cssError, setCssError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
void listThemes(controller.signal)
|
||||||
|
.then(({ themes: availableThemes }) => {
|
||||||
|
setThemes(availableThemes);
|
||||||
|
setCatalogError("");
|
||||||
|
onThemesLoaded(availableThemes, refreshKey);
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setCatalogError(
|
||||||
|
reason instanceof Error ? reason.message : "无法加载主题清单"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [onThemesLoaded, refreshKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setThemeCss("");
|
||||||
|
setCssError("");
|
||||||
|
|
||||||
|
void getThemeCss(themeId, controller.signal)
|
||||||
|
.then(setThemeCss)
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setCssError(
|
||||||
|
reason instanceof Error ? reason.message : "无法加载主题"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [refreshKey, themeId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: catalogError || cssError,
|
||||||
|
themeCss,
|
||||||
|
themes
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,85 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PagedDocumentPayload,
|
||||||
|
PagedDocumentRenderResult,
|
||||||
|
RenderedMarkdownDocument
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
interface DesktopPdfResult {
|
||||||
|
pdf: Uint8Array;
|
||||||
|
pageCount: number;
|
||||||
|
echartsErrors: string[];
|
||||||
|
mermaidErrors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopApplicationBridge {
|
||||||
|
openMarkdown(): Promise<
|
||||||
|
| {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>;
|
||||||
|
consumePendingMarkdown(): Promise<
|
||||||
|
| {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>;
|
||||||
|
discardPendingMarkdown(): Promise<void>;
|
||||||
|
onMarkdownOpened(listener: () => void): () => void;
|
||||||
|
startNewMarkdown(): Promise<void>;
|
||||||
|
saveMarkdown(
|
||||||
|
fileName: string,
|
||||||
|
markdown: string
|
||||||
|
): Promise<{ fileName: string } | undefined>;
|
||||||
|
openThemeDirectory(): Promise<string>;
|
||||||
|
refreshThemes(): Promise<void>;
|
||||||
|
renderMarkdown(input: {
|
||||||
|
markdown: string;
|
||||||
|
language: string;
|
||||||
|
resources?: Array<{
|
||||||
|
path: string;
|
||||||
|
data: string;
|
||||||
|
}>;
|
||||||
|
}): Promise<RenderedMarkdownDocument>;
|
||||||
|
listThemes(): Promise<{
|
||||||
|
themes: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
bundled: boolean;
|
||||||
|
source: "bundled" | "local";
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
getThemeCss(themeId: string): Promise<string>;
|
||||||
|
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
||||||
|
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesktopPdfRuntimeBridge {
|
||||||
|
ready(): void;
|
||||||
|
onRender(
|
||||||
|
listener: (
|
||||||
|
requestId: number,
|
||||||
|
payload: PagedDocumentPayload
|
||||||
|
) => Promise<void> | void
|
||||||
|
): () => void;
|
||||||
|
complete(
|
||||||
|
requestId: number,
|
||||||
|
result: PagedDocumentRenderResult
|
||||||
|
): void;
|
||||||
|
fail(requestId: number, message: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
mdToPdfDesktop?: DesktopApplicationBridge;
|
||||||
|
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
APP_VERSION,
|
||||||
|
APP_VERSION_LABEL
|
||||||
|
} from "../src/app-version";
|
||||||
|
|
||||||
|
describe("应用版本", () => {
|
||||||
|
it("从统一构建版本生成标题徽标", () => {
|
||||||
|
expect(APP_VERSION).toBe("0.4.5");
|
||||||
|
expect(APP_VERSION_LABEL).toBe("v0.4.5");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from "vitest";
|
||||||
|
import {
|
||||||
|
getThemeCss,
|
||||||
|
listThemes,
|
||||||
|
renderMarkdownDocument
|
||||||
|
} from "../src/application-backend";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("应用后端适配器", () => {
|
||||||
|
it("在桌面环境通过窄 IPC 调用共享服务", async () => {
|
||||||
|
const rendered = {
|
||||||
|
rendererVersion: 1 as const,
|
||||||
|
articleHtml: '<article id="write">测试</article>',
|
||||||
|
bodyHtml: "测试",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
const renderMarkdown = vi.fn(async () => rendered);
|
||||||
|
const listDesktopThemes = vi.fn(async () => ({
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
id: "test",
|
||||||
|
name: "测试",
|
||||||
|
version: "1",
|
||||||
|
description: "",
|
||||||
|
bundled: true,
|
||||||
|
source: "bundled" as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
const getDesktopThemeCss = vi.fn(async () => "#write {}");
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
mdToPdfDesktop: {
|
||||||
|
renderMarkdown,
|
||||||
|
listThemes: listDesktopThemes,
|
||||||
|
getThemeCss: getDesktopThemeCss
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
renderMarkdownDocument({
|
||||||
|
markdown: "# 测试",
|
||||||
|
language: "zh-CN"
|
||||||
|
})
|
||||||
|
).resolves.toBe(rendered);
|
||||||
|
await expect(listThemes()).resolves.toEqual({
|
||||||
|
themes: [expect.objectContaining({ id: "test" })]
|
||||||
|
});
|
||||||
|
await expect(getThemeCss("test")).resolves.toBe("#write {}");
|
||||||
|
expect(renderMarkdown).toHaveBeenCalledWith({
|
||||||
|
markdown: "# 测试",
|
||||||
|
language: "zh-CN"
|
||||||
|
});
|
||||||
|
expect(getDesktopThemeCss).toHaveBeenCalledWith("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在浏览器环境保持现有 HTTP 协议", async () => {
|
||||||
|
vi.stubGlobal("window", {});
|
||||||
|
const fetcher = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
Response.json({
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write">测试</article>',
|
||||||
|
bodyHtml: "测试",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
Response.json({ themes: [] })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response("#write {}", { status: 200 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
|
||||||
|
await renderMarkdownDocument({
|
||||||
|
markdown: "# 测试",
|
||||||
|
language: "zh-CN"
|
||||||
|
});
|
||||||
|
await listThemes();
|
||||||
|
await getThemeCss("typora-like");
|
||||||
|
|
||||||
|
expect(fetcher).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"/api/render",
|
||||||
|
expect.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
expect(fetcher).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"/api/themes",
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
expect(fetcher).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"/api/themes/typora-like/css",
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createMarkdownFileName,
|
||||||
|
deriveMarkdownBaseName,
|
||||||
|
ensureMarkdownFileName,
|
||||||
|
legalizeMarkdownBaseName
|
||||||
|
} from "../src/document-file";
|
||||||
|
|
||||||
|
describe("Markdown 默认文件名", () => {
|
||||||
|
it("优先使用 Front Matter 与代码围栏之外的一级标题", () => {
|
||||||
|
expect(
|
||||||
|
deriveMarkdownBaseName(`---
|
||||||
|
title: 元数据标题
|
||||||
|
---
|
||||||
|
第一行正文
|
||||||
|
|
||||||
|
\`\`\`markdown
|
||||||
|
# 围栏标题
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
# 2026 年研发周报:第 31 周`)
|
||||||
|
).toBe("2026 年研发周报 第 31 周");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有一级标题时使用第一行非空正文", () => {
|
||||||
|
expect(
|
||||||
|
createMarkdownFileName(
|
||||||
|
"\n\n> **本周完成** [API 重构](https://example.com)\n后续内容"
|
||||||
|
)
|
||||||
|
).toBe("本周完成 API 重构.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("四字符围栏不会被较短的同类围栏提前关闭", () => {
|
||||||
|
expect(
|
||||||
|
deriveMarkdownBaseName(`\`\`\`\`markdown
|
||||||
|
# 围栏内标题
|
||||||
|
\`\`\`
|
||||||
|
# 仍在围栏内
|
||||||
|
\`\`\`\`
|
||||||
|
# 正文标题`)
|
||||||
|
).toBe("正文标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("清理非法字符并规避 Windows 保留名称", () => {
|
||||||
|
expect(legalizeMarkdownBaseName('季度总结:<测试> / "版本"')).toBe(
|
||||||
|
"季度总结 测试 版本"
|
||||||
|
);
|
||||||
|
expect(legalizeMarkdownBaseName("CON")).toBe("文档 CON");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空文档使用回退文件名并限制名称长度", () => {
|
||||||
|
expect(createMarkdownFileName(" \n")).toBe("未命名文档.md");
|
||||||
|
expect(legalizeMarkdownBaseName("文".repeat(150))).toHaveLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保留已有 Markdown 扩展名", () => {
|
||||||
|
expect(ensureMarkdownFileName("报告.markdown", "# 报告")).toBe(
|
||||||
|
"报告.markdown"
|
||||||
|
);
|
||||||
|
expect(ensureMarkdownFileName("报告", "# 报告")).toBe("报告.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fitDocumentImagesToPage } from "../src/document-image-fit";
|
||||||
|
|
||||||
|
describe("Markdown 图片单页适配", () => {
|
||||||
|
it("标记独立图片段落并将超高图片缩放到单页", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML =
|
||||||
|
'<p> <img class="md-document-image" src="data:image/png;base64,AA=="> </p>';
|
||||||
|
const image = root.querySelector("img")!;
|
||||||
|
Object.defineProperties(image, {
|
||||||
|
naturalWidth: { value: 1000 },
|
||||||
|
naturalHeight: { value: 3000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
fitDocumentImagesToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
|
expect(image.parentElement?.classList).toContain(
|
||||||
|
"md-document-image-block"
|
||||||
|
);
|
||||||
|
expect(image.dataset.pageHeightFitted).toBe("true");
|
||||||
|
expect(image.parentElement?.dataset.pageHeightFitted).toBe("true");
|
||||||
|
expect(
|
||||||
|
image.parentElement?.style.getPropertyValue("margin-block")
|
||||||
|
).toBe("0");
|
||||||
|
expect(Number.parseFloat(image.style.height)).toBeCloseTo(
|
||||||
|
1000.5748,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
expect(Number.parseFloat(image.style.width)).toBeCloseTo(
|
||||||
|
333.5249,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("普通横图保持主题控制的尺寸", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML =
|
||||||
|
'<p><img class="md-document-image" src="data:image/png;base64,AA=="></p>';
|
||||||
|
const image = root.querySelector("img")!;
|
||||||
|
Object.defineProperties(image, {
|
||||||
|
naturalWidth: { value: 1600 },
|
||||||
|
naturalHeight: { value: 900 }
|
||||||
|
});
|
||||||
|
|
||||||
|
fitDocumentImagesToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
|
expect(image.dataset.pageHeightFitted).toBeUndefined();
|
||||||
|
expect(image.style.height).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
|
||||||
|
|
||||||
|
describe("ECharts 单页高度适配", () => {
|
||||||
|
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<h2 style="margin: 10px 0">大型图表</h2>
|
||||||
|
<figure class="md-echarts">
|
||||||
|
<div class="md-echarts-host" style="height: 2000px">
|
||||||
|
<svg width="1000" height="2000"></svg>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||||
|
const title = root.querySelector<HTMLElement>("h2");
|
||||||
|
const host = root.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
const svg = root.querySelector<SVGSVGElement>("svg");
|
||||||
|
if (title) {
|
||||||
|
title.getBoundingClientRect = () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 700,
|
||||||
|
bottom: 40,
|
||||||
|
width: 700,
|
||||||
|
height: 40,
|
||||||
|
toJSON: () => ({})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
|
expect(figure?.dataset.pageHeightFitted).toBe("true");
|
||||||
|
expect(title?.style.breakAfter).toBe("avoid-page");
|
||||||
|
expect(figure?.style.breakAfter).toBe("page");
|
||||||
|
expect(figure?.style.pageBreakAfter).toBe("always");
|
||||||
|
expect(figure?.nextElementSibling).toHaveProperty(
|
||||||
|
"dataset.mediaPageBreak",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
|
||||||
|
960.5748,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
expect(host?.style.aspectRatio).toBe("");
|
||||||
|
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
|
||||||
|
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
|
||||||
|
960.5748,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("普通高度图表不改变作者设置的宿主高度", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure class="md-echarts">
|
||||||
|
<div class="md-echarts-host" style="height: 300px">
|
||||||
|
<svg viewBox="0 0 1000 400"></svg>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
`;
|
||||||
|
|
||||||
|
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
|
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||||
|
const host = root.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
|
||||||
|
expect(host?.style.height).toBe("300px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,24 +2,32 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_STORAGE_KEY,
|
EXPORT_CONFIG_STORAGE_KEY,
|
||||||
|
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
||||||
loadExportConfig,
|
loadExportConfig,
|
||||||
parseStoredExportConfig,
|
parseStoredExportConfig,
|
||||||
saveExportConfig
|
saveExportConfig
|
||||||
} from "../src/export-settings";
|
} from "../src/export-settings";
|
||||||
|
|
||||||
function createMemoryStorage(initialValue: string | null = null) {
|
function createMemoryStorage(
|
||||||
let value = initialValue;
|
initialValue: string | null = null,
|
||||||
|
legacyValue: string | null = null
|
||||||
|
) {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
if (initialValue) {
|
||||||
|
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||||
|
}
|
||||||
|
if (legacyValue) {
|
||||||
|
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
getItem(key: string) {
|
getItem(key: string) {
|
||||||
return key === EXPORT_CONFIG_STORAGE_KEY ? value : null;
|
return values.get(key) ?? null;
|
||||||
},
|
},
|
||||||
setItem(key: string, nextValue: string) {
|
setItem(key: string, nextValue: string) {
|
||||||
if (key === EXPORT_CONFIG_STORAGE_KEY) {
|
values.set(key, nextValue);
|
||||||
value = nextValue;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
read() {
|
read() {
|
||||||
return value;
|
return values.get(EXPORT_CONFIG_STORAGE_KEY) ?? null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,6 +52,28 @@ describe("导出设置缓存", () => {
|
|||||||
).toEqual(defaultExportConfig);
|
).toEqual(defaultExportConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
||||||
|
const legacyConfig = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
version: 2,
|
||||||
|
mermaid: undefined,
|
||||||
|
paper: {
|
||||||
|
...defaultExportConfig.paper,
|
||||||
|
format: "A5"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const storage = createMemoryStorage(
|
||||||
|
null,
|
||||||
|
JSON.stringify(legacyConfig)
|
||||||
|
);
|
||||||
|
|
||||||
|
const migrated = loadExportConfig(storage);
|
||||||
|
expect(migrated.version).toBe(3);
|
||||||
|
expect(migrated.paper.format).toBe("A5");
|
||||||
|
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
|
||||||
|
expect(storage.read()).toContain('"version":3');
|
||||||
|
});
|
||||||
|
|
||||||
it("保存并恢复经过校验的配置", () => {
|
it("保存并恢复经过校验的配置", () => {
|
||||||
const storage = createMemoryStorage();
|
const storage = createMemoryStorage();
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyMediaBackfillCandidates,
|
||||||
|
calculateMediaBackfillScale,
|
||||||
|
findMediaBackfillCandidates,
|
||||||
|
getMediaBackfillIdsInDocumentOrder,
|
||||||
|
prepareMediaBackfillBlocks
|
||||||
|
} from "../src/media-page-backfill";
|
||||||
|
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit";
|
||||||
|
|
||||||
|
function rect(width: number, height: number) {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: width,
|
||||||
|
bottom: height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
toJSON: () => ({})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionedRect(
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...rect(width, height),
|
||||||
|
y: top,
|
||||||
|
top,
|
||||||
|
bottom: top + height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("媒体分页空白回填", () => {
|
||||||
|
it("全页适配时保留前置标题高度并避免标题孤行", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<h2 style="margin: 10px 0 12px">媒体标题</h2>
|
||||||
|
<div class="mermaid"></div>
|
||||||
|
`;
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>(".mermaid")!;
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
|
||||||
|
expect(getPrecedingMediaTitleHeight(block)).toBe(40);
|
||||||
|
expect(title.style.getPropertyValue("break-after")).toBe(
|
||||||
|
"avoid-page"
|
||||||
|
);
|
||||||
|
expect(title.style.getPropertyPriority("break-after")).toBe(
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按限宽后的基准高度计算额外缩放率", () => {
|
||||||
|
expect(
|
||||||
|
calculateMediaBackfillScale({
|
||||||
|
remainingHeight: 420,
|
||||||
|
fixedHeight: 30,
|
||||||
|
baselineVisualHeight: 430
|
||||||
|
})
|
||||||
|
).toBeCloseTo(388 / 430, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缩小超过阈值或无需缩小时不回填", () => {
|
||||||
|
expect(
|
||||||
|
calculateMediaBackfillScale({
|
||||||
|
remainingHeight: 300,
|
||||||
|
fixedHeight: 30,
|
||||||
|
baselineVisualHeight: 430
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
calculateMediaBackfillScale({
|
||||||
|
remainingHeight: 500,
|
||||||
|
fixedHeight: 30,
|
||||||
|
baselineVisualHeight: 430
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("记录图片限宽后的实际基准尺寸", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure class="md-document-image-block">
|
||||||
|
<img class="md-document-image">
|
||||||
|
<figcaption>标题</figcaption>
|
||||||
|
</figure>
|
||||||
|
`;
|
||||||
|
const image = root.querySelector("img")!;
|
||||||
|
image.getBoundingClientRect = () => rect(640, 480);
|
||||||
|
|
||||||
|
expect(prepareMediaBackfillBlocks(root)).toBe(1);
|
||||||
|
const figure = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
expect(figure.dataset.mediaBackfillKind).toBe("image");
|
||||||
|
expect(figure.dataset.mediaBaselineWidth).toBe("640");
|
||||||
|
expect(figure.dataset.mediaBaselineHeight).toBe("480");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保持媒体元素在源文档中的串行处理顺序", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure data-media-backfill-id="media-2"></figure>
|
||||||
|
<div><figure data-media-backfill-id="media-1"></figure></div>
|
||||||
|
<figure></figure>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([
|
||||||
|
"media-2",
|
||||||
|
"media-1"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根据上一页剩余区域生成一次回填候选", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<div
|
||||||
|
class="pagedjs_page_content_flow"
|
||||||
|
data-ref="previous-content"
|
||||||
|
>上一页内容</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<h2 data-ref="media-title">图片标题</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const previousFlow =
|
||||||
|
pageContents[0]!.firstElementChild as HTMLElement;
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||||
|
pageContents[0]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
previousFlow.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 550);
|
||||||
|
pageContents[1]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
block.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 330);
|
||||||
|
image.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 300);
|
||||||
|
|
||||||
|
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||||
|
{ id: "media-1", scale: 0.92 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显式分页的媒体块不会回填到上一页", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<h2 data-ref="media-title" style="break-before: page">
|
||||||
|
图片标题
|
||||||
|
</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-ref="media-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||||
|
pageContents[0]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
pageContents[1]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
block.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 330);
|
||||||
|
image.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 300);
|
||||||
|
|
||||||
|
expect(findMediaBackfillCandidates(root)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略媒体之前其他内容的显式分页规则", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<div data-ref="previous-content">上一页内容</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<h1 data-ref="earlier-heading" style="break-before: page">
|
||||||
|
本页章节
|
||||||
|
</h1>
|
||||||
|
<h2 data-ref="media-title">图片标题</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-ref="media-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const previousFlow = root.querySelector<HTMLElement>(
|
||||||
|
"[data-ref='previous-content']"
|
||||||
|
)!;
|
||||||
|
const earlierHeading = root.querySelector<HTMLElement>("h1")!;
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||||
|
pageContents[0]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
previousFlow.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 550);
|
||||||
|
pageContents[1]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
earlierHeading.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 40);
|
||||||
|
block.getBoundingClientRect = () =>
|
||||||
|
positionedRect(200, 600, 330);
|
||||||
|
image.getBoundingClientRect = () =>
|
||||||
|
positionedRect(200, 600, 300);
|
||||||
|
|
||||||
|
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||||
|
{ id: "media-1", scale: 0.92 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将候选缩放应用到图片和 ECharts 主体", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<h2>图片标题保持原尺寸</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-backfill-kind="image"
|
||||||
|
data-media-baseline-width="800"
|
||||||
|
data-media-baseline-height="600"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
<figure
|
||||||
|
class="md-echarts"
|
||||||
|
data-media-backfill-id="media-2"
|
||||||
|
data-media-backfill-kind="echarts"
|
||||||
|
data-media-baseline-width="700"
|
||||||
|
data-media-baseline-height="500"
|
||||||
|
>
|
||||||
|
<div class="md-echarts-host"><svg></svg></div>
|
||||||
|
</figure>
|
||||||
|
<div
|
||||||
|
class="mermaid"
|
||||||
|
data-media-backfill-id="media-3"
|
||||||
|
data-media-backfill-kind="mermaid"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<svg></svg>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyMediaBackfillCandidates(root, [
|
||||||
|
{ id: "media-1", scale: 0.9 },
|
||||||
|
{ id: "media-2", scale: 0.88 },
|
||||||
|
{ id: "media-3", scale: 0.9 }
|
||||||
|
])
|
||||||
|
).toBe(3);
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>(
|
||||||
|
"img.md-document-image"
|
||||||
|
)!;
|
||||||
|
const host = root.querySelector<HTMLElement>(".md-echarts-host")!;
|
||||||
|
const svg = root.querySelector<SVGSVGElement>("svg")!;
|
||||||
|
const mermaidSvg = root.querySelector<SVGSVGElement>(
|
||||||
|
".mermaid svg"
|
||||||
|
)!;
|
||||||
|
expect(title.getAttribute("style")).toBeNull();
|
||||||
|
expect(image.style.width).toBe("720px");
|
||||||
|
expect(image.style.height).toBe("540px");
|
||||||
|
expect(host.style.width).toBe("616px");
|
||||||
|
expect(host.style.height).toBe("440px");
|
||||||
|
expect(svg.style.width).toBe("616px");
|
||||||
|
expect(svg.style.height).toBe("440px");
|
||||||
|
expect(mermaidSvg.style.width).toBe("540px");
|
||||||
|
expect(mermaidSvg.style.height).toBe("360px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
createMermaidSiteConfig,
|
createMermaidSiteConfig,
|
||||||
MERMAID_SECURE_CONFIG_KEYS
|
MERMAID_SECURE_CONFIG_KEYS
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
|
|
||||||
describe("Mermaid 站点配置", () => {
|
describe("Mermaid 站点配置", () => {
|
||||||
it("默认使用 default 主题和 classic 外观", () => {
|
it("默认使用 default 主题和 classic 外观", () => {
|
||||||
const config = createMermaidSiteConfig();
|
const config = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||||
expect(config.theme).toBe("default");
|
expect(config.theme).toBe("default");
|
||||||
expect(config.look).toBe("classic");
|
expect(config.look).toBe("classic");
|
||||||
expect(config.securityLevel).toBe("strict");
|
expect(config.securityLevel).toBe("strict");
|
||||||
@@ -29,9 +30,25 @@ describe("Mermaid 站点配置", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("每次创建独立的 secure 配置数组", () => {
|
it("每次创建独立的 secure 配置数组", () => {
|
||||||
const first = createMermaidSiteConfig();
|
const first = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||||
const second = createMermaidSiteConfig();
|
const second = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||||
expect(first.secure).toEqual(second.secure);
|
expect(first.secure).toEqual(second.secure);
|
||||||
expect(first.secure).not.toBe(second.secure);
|
expect(first.secure).not.toBe(second.secure);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("应用导出配置中的布局、主题、外观和字体", () => {
|
||||||
|
const config = createMermaidSiteConfig({
|
||||||
|
layout: "elk",
|
||||||
|
theme: "forest",
|
||||||
|
look: "handDrawn",
|
||||||
|
fontFamily: "Microsoft YaHei, sans-serif"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
layout: "elk",
|
||||||
|
theme: "forest",
|
||||||
|
look: "handDrawn",
|
||||||
|
fontFamily: "Microsoft YaHei, sans-serif"
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ describe("分页预览协议", () => {
|
|||||||
expect(css).toContain("padding: 34px 0");
|
expect(css).toContain("padding: 34px 0");
|
||||||
expect(css).toContain('html[data-render-target="preview"]');
|
expect(css).toContain('html[data-render-target="preview"]');
|
||||||
expect(css).toContain("overflow-x: hidden");
|
expect(css).toContain("overflow-x: hidden");
|
||||||
expect(css).toContain("overflow-y: scroll");
|
expect(css).toContain("overflow-y: hidden");
|
||||||
|
expect(css).not.toContain("scrollbar-color");
|
||||||
expect(css).toContain('html[data-render-target="pdf"]');
|
expect(css).toContain('html[data-render-target="pdf"]');
|
||||||
expect(css).toContain("height: auto !important");
|
expect(css).toContain("height: auto !important");
|
||||||
expect(css).toContain("overflow: visible !important");
|
expect(css).toContain("overflow: visible !important");
|
||||||
@@ -178,10 +179,21 @@ describe("分页预览协议", () => {
|
|||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId: 3,
|
requestId: 3,
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
contentHeight: 2300,
|
contentHeight: 2400,
|
||||||
|
echartsErrors: [],
|
||||||
mermaidErrors: []
|
mermaidErrors: []
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPagedPreviewFrameMessage({
|
||||||
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
type: "rendered",
|
||||||
|
requestId: 3,
|
||||||
|
pageCount: 2,
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
isPagedPreviewFrameMessage({
|
isPagedPreviewFrameMessage({
|
||||||
scope: "other",
|
scope: "other",
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import {
|
||||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from "vitest";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
createPdfExportCacheKey,
|
createPdfExportCacheKey,
|
||||||
|
downloadPdf,
|
||||||
getCachedPdfExport,
|
getCachedPdfExport,
|
||||||
getOrRequestPdfExport,
|
getOrRequestPdfExport,
|
||||||
parseContentDispositionFileName,
|
parseContentDispositionFileName,
|
||||||
|
requestDesktopPdfExport,
|
||||||
requestPdfExport,
|
requestPdfExport,
|
||||||
type CachedPdfExport,
|
type CachedPdfExport,
|
||||||
type PdfExportRequest
|
type PdfExportRequest
|
||||||
} from "../src/pdf-export";
|
} from "../src/pdf-export";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
describe("PDF 导出客户端", () => {
|
describe("PDF 导出客户端", () => {
|
||||||
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
|
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
|
||||||
const request: PdfExportRequest = {
|
const request: PdfExportRequest = {
|
||||||
@@ -23,6 +38,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
blob: new Blob(["%PDF-test"]),
|
blob: new Blob(["%PDF-test"]),
|
||||||
fileName: "测试.pdf",
|
fileName: "测试.pdf",
|
||||||
pageCount: 1,
|
pageCount: 1,
|
||||||
|
echartsErrorCount: 0,
|
||||||
mermaidErrorCount: 0
|
mermaidErrorCount: 0
|
||||||
};
|
};
|
||||||
const cache: CachedPdfExport = { key, result };
|
const cache: CachedPdfExport = { key, result };
|
||||||
@@ -65,6 +81,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
blob: new Blob(["%PDF-cached"]),
|
blob: new Blob(["%PDF-cached"]),
|
||||||
fileName: "测试.pdf",
|
fileName: "测试.pdf",
|
||||||
pageCount: 1,
|
pageCount: 1,
|
||||||
|
echartsErrorCount: 0,
|
||||||
mermaidErrorCount: 0
|
mermaidErrorCount: 0
|
||||||
};
|
};
|
||||||
const cache: CachedPdfExport = {
|
const cache: CachedPdfExport = {
|
||||||
@@ -95,6 +112,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
blob: new Blob(["%PDF-new"]),
|
blob: new Blob(["%PDF-new"]),
|
||||||
fileName: "测试.pdf",
|
fileName: "测试.pdf",
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
|
echartsErrorCount: 0,
|
||||||
mermaidErrorCount: 0
|
mermaidErrorCount: 0
|
||||||
};
|
};
|
||||||
const requester = vi.fn(async () => result);
|
const requester = vi.fn(async () => result);
|
||||||
@@ -128,6 +146,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
"content-disposition":
|
"content-disposition":
|
||||||
"attachment; filename=\"document.pdf\"",
|
"attachment; filename=\"document.pdf\"",
|
||||||
"x-pdf-page-count": "4",
|
"x-pdf-page-count": "4",
|
||||||
|
"x-echarts-error-count": "2",
|
||||||
"x-mermaid-error-count": "1"
|
"x-mermaid-error-count": "1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -149,6 +168,7 @@ describe("PDF 导出客户端", () => {
|
|||||||
);
|
);
|
||||||
expect(result.fileName).toBe("document.pdf");
|
expect(result.fileName).toBe("document.pdf");
|
||||||
expect(result.pageCount).toBe(4);
|
expect(result.pageCount).toBe(4);
|
||||||
|
expect(result.echartsErrorCount).toBe(2);
|
||||||
expect(result.mermaidErrorCount).toBe(1);
|
expect(result.mermaidErrorCount).toBe(1);
|
||||||
expect(await result.blob.text()).toBe("%PDF-test");
|
expect(await result.blob.text()).toBe("%PDF-test");
|
||||||
});
|
});
|
||||||
@@ -173,4 +193,49 @@ describe("PDF 导出客户端", () => {
|
|||||||
)
|
)
|
||||||
).rejects.toThrow("PDF 生成队列已满");
|
).rejects.toThrow("PDF 生成队列已满");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("桌面端使用共享分页载荷生成并保存 PDF", async () => {
|
||||||
|
const payload = createPagedDocumentPayload({
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write">测试</article>',
|
||||||
|
bodyHtml: "测试",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
fileName: "测试.md",
|
||||||
|
themeCss: "",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
});
|
||||||
|
const generatePdf = vi.fn(async () => ({
|
||||||
|
pdf: new TextEncoder().encode("%PDF-desktop"),
|
||||||
|
pageCount: 2,
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: ["图表 1"]
|
||||||
|
}));
|
||||||
|
const savePdf = vi.fn(async () => true);
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
mdToPdfDesktop: { generatePdf, savePdf }
|
||||||
|
});
|
||||||
|
|
||||||
|
const generated = await requestDesktopPdfExport(payload);
|
||||||
|
const saved = await downloadPdf(generated.blob, generated.fileName);
|
||||||
|
|
||||||
|
expect(generatePdf).toHaveBeenCalledWith(payload);
|
||||||
|
expect(generated.fileName).toBe("测试.pdf");
|
||||||
|
expect(generated.pageCount).toBe(2);
|
||||||
|
expect(generated.mermaidErrorCount).toBe(1);
|
||||||
|
expect(savePdf).toHaveBeenCalledWith(
|
||||||
|
"测试.pdf",
|
||||||
|
expect.any(Uint8Array)
|
||||||
|
);
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ describe("精确 PDF 页面尺寸", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("按预览缩放比例调整 PDF 页面显示尺寸", () => {
|
||||||
|
expect(
|
||||||
|
getPdfPageCssDimensions(595.275591, 841.889764, 1.5)
|
||||||
|
).toEqual({
|
||||||
|
width: expect.closeTo(1190.551182, 5),
|
||||||
|
height: expect.closeTo(1683.779528, 5)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("结合显示宽度和设备像素比计算 Canvas 比例", () => {
|
it("结合显示宽度和设备像素比计算 Canvas 比例", () => {
|
||||||
expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo(
|
expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo(
|
||||||
8 / 3
|
8 / 3
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
beginPreviewFrameRender,
|
||||||
|
completePreviewFrameRender
|
||||||
|
} from "../src/preview-frame-visibility";
|
||||||
|
|
||||||
|
function createTarget() {
|
||||||
|
return {
|
||||||
|
dataset: {} as DOMStringMap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("分页 iframe 渲染可见性", () => {
|
||||||
|
it("渲染期间隐藏,当前请求完成后恢复显示", () => {
|
||||||
|
const target = createTarget();
|
||||||
|
|
||||||
|
beginPreviewFrameRender(target, 12);
|
||||||
|
expect(target.dataset).toMatchObject({
|
||||||
|
rendering: "true",
|
||||||
|
renderingRequestId: "12"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(completePreviewFrameRender(target, 12)).toBe(true);
|
||||||
|
expect(target.dataset.rendering).toBeUndefined();
|
||||||
|
expect(target.dataset.renderingRequestId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("旧请求完成时不会提前显示新请求的半成品", () => {
|
||||||
|
const target = createTarget();
|
||||||
|
|
||||||
|
beginPreviewFrameRender(target, 12);
|
||||||
|
beginPreviewFrameRender(target, 13);
|
||||||
|
|
||||||
|
expect(completePreviewFrameRender(target, 12)).toBe(false);
|
||||||
|
expect(target.dataset).toMatchObject({
|
||||||
|
rendering: "true",
|
||||||
|
renderingRequestId: "13"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getNearestPageNumber } from "../src/preview-page";
|
import {
|
||||||
|
getNearestPageNumber,
|
||||||
|
getPreviewPageScrollTop,
|
||||||
|
normalizePreviewPageNumber
|
||||||
|
} from "../src/preview-page";
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ top: 0, bottom: 100 },
|
{ top: 0, bottom: 100 },
|
||||||
@@ -22,3 +26,35 @@ describe("getNearestPageNumber", () => {
|
|||||||
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
|
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizePreviewPageNumber", () => {
|
||||||
|
it("保留有效的整数页码", () => {
|
||||||
|
expect(normalizePreviewPageNumber("12", 3, 65)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将越界页码限制在有效范围", () => {
|
||||||
|
expect(normalizePreviewPageNumber("0", 3, 65)).toBe(1);
|
||||||
|
expect(normalizePreviewPageNumber("99", 3, 65)).toBe(65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无效输入恢复当前页", () => {
|
||||||
|
expect(normalizePreviewPageNumber("", 7, 65)).toBe(7);
|
||||||
|
expect(normalizePreviewPageNumber("3.5", 7, 65)).toBe(7);
|
||||||
|
expect(normalizePreviewPageNumber("abc", 7, 65)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有可用页面时返回 0", () => {
|
||||||
|
expect(normalizePreviewPageNumber("1", 0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPreviewPageScrollTop", () => {
|
||||||
|
it("按目标页相对滚动容器的位置计算滚动量", () => {
|
||||||
|
expect(getPreviewPageScrollTop(200, 900, 120)).toBe(980);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("避免返回负数或无效滚动量", () => {
|
||||||
|
expect(getPreviewPageScrollTop(20, 10, 100)).toBe(0);
|
||||||
|
expect(getPreviewPageScrollTop(Number.NaN, 10, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_PREVIEW_ZOOM,
|
||||||
|
MAXIMUM_PREVIEW_ZOOM,
|
||||||
|
MINIMUM_PREVIEW_ZOOM,
|
||||||
|
PREVIEW_ZOOM_STORAGE_KEY,
|
||||||
|
loadPreviewZoom,
|
||||||
|
normalizePreviewZoom,
|
||||||
|
parsePreviewZoom,
|
||||||
|
savePreviewZoom
|
||||||
|
} from "../src/preview-zoom";
|
||||||
|
|
||||||
|
function createMemoryStorage(initialValue: string | null = null) {
|
||||||
|
let value = initialValue;
|
||||||
|
return {
|
||||||
|
getItem(key: string) {
|
||||||
|
return key === PREVIEW_ZOOM_STORAGE_KEY ? value : null;
|
||||||
|
},
|
||||||
|
setItem(key: string, nextValue: string) {
|
||||||
|
if (key === PREVIEW_ZOOM_STORAGE_KEY) {
|
||||||
|
value = nextValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
read() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("预览缩放", () => {
|
||||||
|
it("默认使用 100%,并限制在 50% 到 400%", () => {
|
||||||
|
expect(parsePreviewZoom(null)).toBe(DEFAULT_PREVIEW_ZOOM);
|
||||||
|
expect(normalizePreviewZoom(20)).toBe(MINIMUM_PREVIEW_ZOOM);
|
||||||
|
expect(normalizePreviewZoom(450)).toBe(MAXIMUM_PREVIEW_ZOOM);
|
||||||
|
expect(normalizePreviewZoom(124.6)).toBe(125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无效手动输入回退到当前缩放值", () => {
|
||||||
|
expect(parsePreviewZoom("", 135)).toBe(135);
|
||||||
|
expect(parsePreviewZoom("not-a-number", 135)).toBe(135);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保存并恢复浏览器缩放偏好", () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
savePreviewZoom(145, storage);
|
||||||
|
expect(storage.read()).toBe("145");
|
||||||
|
expect(loadPreviewZoom(storage)).toBe(145);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { inflateSync } from "node:zlib";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sampleMarkdown } from "../src/sample-markdown";
|
||||||
|
|
||||||
|
function readPngDimensions(base64: string) {
|
||||||
|
const png = Buffer.from(base64, "base64");
|
||||||
|
expect(png.subarray(0, 8)).toEqual(
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||||
|
);
|
||||||
|
const width = png.readUInt32BE(16);
|
||||||
|
const height = png.readUInt32BE(20);
|
||||||
|
const bitDepth = png[24] ?? 0;
|
||||||
|
const colorType = png[25] ?? 0;
|
||||||
|
const samplesPerPixel =
|
||||||
|
colorType === 0 || colorType === 3
|
||||||
|
? 1
|
||||||
|
: colorType === 2
|
||||||
|
? 3
|
||||||
|
: colorType === 4
|
||||||
|
? 2
|
||||||
|
: 4;
|
||||||
|
const idatParts: Buffer[] = [];
|
||||||
|
let offset = 8;
|
||||||
|
while (offset + 12 <= png.length) {
|
||||||
|
const length = png.readUInt32BE(offset);
|
||||||
|
const type = png.toString("ascii", offset + 4, offset + 8);
|
||||||
|
if (type === "IDAT") {
|
||||||
|
idatParts.push(
|
||||||
|
png.subarray(offset + 8, offset + 8 + length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
offset += 12 + length;
|
||||||
|
}
|
||||||
|
const decoded = inflateSync(Buffer.concat(idatParts));
|
||||||
|
const rowBytes = Math.ceil(
|
||||||
|
(width * bitDepth * samplesPerPixel) / 8
|
||||||
|
);
|
||||||
|
expect(decoded.length).toBe((rowBytes + 1) * height);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("默认示例 Markdown", () => {
|
||||||
|
it("仅保留一张网络图片并提供三种 Base64 图片尺寸", () => {
|
||||||
|
expect(
|
||||||
|
sampleMarkdown.match(/https?:\/\/[^\s)]+/gu)
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
sampleMarkdown.match(/data:image\/png;base64,/gu)
|
||||||
|
).toHaveLength(3);
|
||||||
|
expect(sampleMarkdown).toContain("1600×900");
|
||||||
|
expect(sampleMarkdown).toContain("900×1800");
|
||||||
|
expect(sampleMarkdown).toContain("1200×880");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("所有内嵌 PNG 均可完整解压且尺寸符合描述", () => {
|
||||||
|
const images = Array.from(
|
||||||
|
sampleMarkdown.matchAll(
|
||||||
|
/data:image\/png;base64,([A-Za-z0-9+/=]+)/gu
|
||||||
|
),
|
||||||
|
(match) => readPngDimensions(match[1] ?? "")
|
||||||
|
);
|
||||||
|
expect(images).toEqual([
|
||||||
|
{ width: 1600, height: 900 },
|
||||||
|
{ width: 900, height: 1800 },
|
||||||
|
{ width: 1200, height: 880 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("覆盖 Mermaid、ECharts 分档和串行媒体场景", () => {
|
||||||
|
expect(
|
||||||
|
sampleMarkdown.match(/```mermaid/gu)
|
||||||
|
).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
sampleMarkdown.match(/```echarts/gu)
|
||||||
|
).toHaveLength(4);
|
||||||
|
expect(sampleMarkdown).toContain("height: 40mm");
|
||||||
|
expect(sampleMarkdown).toContain("height: 58mm");
|
||||||
|
expect(sampleMarkdown).toContain("height: 85mm");
|
||||||
|
expect(sampleMarkdown).toContain("## 串行媒体块");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const rootPackage = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
fileURLToPath(new URL("../../package.json", import.meta.url)),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
) as { version: string };
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
publicDir: fileURLToPath(
|
||||||
|
new URL("../../logos/web", import.meta.url)
|
||||||
|
),
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(rootPackage.version)
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
ARG RUNTIME_BASE_IMAGE=node:22-bookworm-slim
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -9,13 +11,21 @@ COPY package.json package-lock.json ./
|
|||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/core/package.json packages/core/package.json
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/application/package.json packages/application/package.json
|
||||||
|
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY tsconfig.base.json ./
|
COPY tsconfig.base.json ./
|
||||||
COPY apps ./apps
|
COPY apps ./apps
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
RUN npm run build
|
COPY logos ./logos
|
||||||
|
RUN npm run build -w @md-to-pdf/markdown-echarts \
|
||||||
|
&& npm run build -w @md-to-pdf/core \
|
||||||
|
&& npm run build -w @md-to-pdf/renderer \
|
||||||
|
&& npm run build -w @md-to-pdf/application \
|
||||||
|
&& npm run build -w @md-to-pdf/web \
|
||||||
|
&& npm run build -w @md-to-pdf/server
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS production-dependencies
|
FROM node:22-bookworm-slim AS production-dependencies
|
||||||
|
|
||||||
@@ -26,20 +36,28 @@ COPY package.json package-lock.json ./
|
|||||||
COPY apps/server/package.json apps/server/package.json
|
COPY apps/server/package.json apps/server/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
COPY packages/core/package.json packages/core/package.json
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/application/package.json packages/application/package.json
|
||||||
|
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runtime
|
FROM ${RUNTIME_BASE_IMAGE} AS runtime
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apt-get update \
|
ARG RUNTIME_BASE_IMAGE
|
||||||
&& apt-get install -y --no-install-recommends \
|
ARG REUSE_PLAYWRIGHT_RUNTIME=0
|
||||||
nginx \
|
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
||||||
fonts-noto-cjk \
|
echo "Reusing the Playwright runtime from ${RUNTIME_BASE_IMAGE}"; \
|
||||||
fonts-noto-color-emoji \
|
else \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
apt-get update \
|
||||||
&& groupadd --system pwuser \
|
&& apt-get install -y --no-install-recommends \
|
||||||
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser
|
nginx \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --system pwuser \
|
||||||
|
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
|
||||||
|
fi
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -47,15 +65,24 @@ COPY --chown=pwuser:pwuser package.json package-lock.json ./
|
|||||||
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
|
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
|
||||||
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
||||||
|
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
|
||||||
|
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
|
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
|
||||||
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
|
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
RUN npx playwright install --with-deps chromium \
|
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
|
||||||
|
test -x /ms-playwright/chromium-*/chrome-linux/chrome \
|
||||||
|
|| test -x /ms-playwright/chromium-*/chrome-linux64/chrome; \
|
||||||
|
else \
|
||||||
|
npx playwright install --with-deps chromium; \
|
||||||
|
fi \
|
||||||
&& chown -R pwuser:pwuser /ms-playwright
|
&& chown -R pwuser:pwuser /ms-playwright
|
||||||
|
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
|
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
||||||
|
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
|
||||||
|
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
|
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
|
||||||
COPY --chown=pwuser:pwuser themes ./themes
|
COPY --chown=pwuser:pwuser themes ./themes
|
||||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
.github
|
.github
|
||||||
.local
|
.local
|
||||||
.vscode
|
.vscode
|
||||||
|
apps/desktop
|
||||||
coverage
|
coverage
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
out
|
||||||
output
|
output
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
|||||||
@@ -34,13 +34,27 @@ docker compose -f deploy\compose.yaml down
|
|||||||
|
|
||||||
## 构建发布标签
|
## 构建发布标签
|
||||||
|
|
||||||
本地验证完成后,可以为镜像增加发布标签:
|
可以直接指定版本化镜像名称构建:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker tag md-to-pdf:local yixiong/md-to-pdf:v0.1.0
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||||
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令只创建本地标签,不会自动推送到镜像仓库。
|
该命令只生成本地镜像,不会自动推送到镜像仓库。
|
||||||
|
|
||||||
|
默认构建会安装项目锁定版本的 Playwright Chromium 及系统依赖。如果本机
|
||||||
|
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||||
|
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
|
||||||
|
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||||
|
docker compose -f deploy\compose.yaml build
|
||||||
|
```
|
||||||
|
|
||||||
|
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.5 应用代码和生产
|
||||||
|
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||||
|
|
||||||
## 主题挂载
|
## 主题挂载
|
||||||
|
|
||||||
@@ -55,13 +69,17 @@ docker compose -f deploy\compose.yaml up -d
|
|||||||
目录下每套主题都应包含 `theme.json`。主题规范详见
|
目录下每套主题都应包含 `theme.json`。主题规范详见
|
||||||
[`docs/THEMES.md`](../docs/THEMES.md)。
|
[`docs/THEMES.md`](../docs/THEMES.md)。
|
||||||
|
|
||||||
本机通过导入工具生成的 Typora 主题不会复制进镜像;只有显式挂载后,
|
从 v0.4.0 起,公司内部私有版本已内置 GitHub、Pixyll 和 Whitey 三套
|
||||||
容器才会读取这些本机文件。
|
Typora 默认主题;挂载目录仍可用于本机扩展主题。内置主题与挂载主题 ID
|
||||||
|
重复时以内置版本为准。
|
||||||
|
|
||||||
## 运行参数
|
## 运行参数
|
||||||
|
|
||||||
| 环境变量 | 默认值 | 说明 |
|
| 环境变量 | 默认值 | 说明 |
|
||||||
| --- | ---: | --- |
|
| --- | ---: | --- |
|
||||||
|
| `MD_TO_PDF_IMAGE` | `md-to-pdf:local` | 构建和运行的镜像标签 |
|
||||||
|
| `MD_TO_PDF_RUNTIME_BASE_IMAGE` | `node:22-bookworm-slim` | 可选运行时基础镜像 |
|
||||||
|
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
|
||||||
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
|
||||||
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
|
||||||
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
image: md-to-pdf:local
|
args:
|
||||||
|
RUNTIME_BASE_IMAGE: ${MD_TO_PDF_RUNTIME_BASE_IMAGE:-node:22-bookworm-slim}
|
||||||
|
REUSE_PLAYWRIGHT_RUNTIME: ${MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME:-0}
|
||||||
|
image: "${MD_TO_PDF_IMAGE:-md-to-pdf:local}"
|
||||||
init: true
|
init: true
|
||||||
ports:
|
ports:
|
||||||
- "${MD_TO_PDF_PORT:-8080}:8080"
|
- "${MD_TO_PDF_PORT:-8080}:8080"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Markdown PDF 导出器进度
|
# Markdown PDF 导出器进度
|
||||||
|
|
||||||
最后更新:2026-07-26
|
最后更新:2026-07-28
|
||||||
|
|
||||||
## 1. 当前概况
|
## 1. 当前概况
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ main
|
|||||||
已有提交:
|
已有提交:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
b459def fix: 修复预览与 PDF 渲染边界问题
|
||||||
|
d93728b feat: 完善 Mermaid 配置与预览缩放
|
||||||
d1b5880 feat: 实现 Chromium PDF 导出与精确预览
|
d1b5880 feat: 实现 Chromium PDF 导出与精确预览
|
||||||
d625523 fix: 修复长文档分页与滚动同步
|
d625523 fix: 修复长文档分页与滚动同步
|
||||||
1d93f31 feat: 实现真实分页预览
|
1d93f31 feat: 实现真实分页预览
|
||||||
@@ -29,7 +31,23 @@ fa07472 feat: 实现网页实时预览
|
|||||||
ec48bce chore: 初始化项目骨架
|
ec48bce chore: 初始化项目骨架
|
||||||
```
|
```
|
||||||
|
|
||||||
最新阶段已完成 Chromium PDF 导出、精确预览、PDF.js 渲染缓冲和 Mermaid 分页性能优化。
|
`v0.4.1` 图片资源阶段已经完成:Web 素材目录、Desktop 同目录资源、
|
||||||
|
受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
|
||||||
|
统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。
|
||||||
|
|
||||||
|
`v0.4.2` 为仅面向桌面端的维护版本:移除 Windows 原生应用菜单栏;
|
||||||
|
Web/Compose 发布仍保持 `v0.4.1`。
|
||||||
|
|
||||||
|
`v0.4.3` 将桌面发行包迁移为标准 NSIS 安装向导,并使用全英文、无空格、
|
||||||
|
包含版本与平台的安装包文件名;Web/Compose 发布仍保持 `v0.4.1`。
|
||||||
|
|
||||||
|
`v0.4.4` 仅精简 Electron 桌面发行包的语言资源,保留简体中文和英文;
|
||||||
|
Web/Compose 发布仍保持 `v0.4.1`。
|
||||||
|
|
||||||
|
`v0.4.5` 同步更新 Web 与 Desktop:新增新建/保存 Markdown、桌面文件
|
||||||
|
关联、窗口状态恢复、自定义主题目录和收纳式“更多”菜单;Web 明确取消
|
||||||
|
本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序
|
||||||
|
串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
@@ -46,7 +64,7 @@ ec48bce chore: 初始化项目骨架
|
|||||||
|
|
||||||
`packages/core/src/export-config.ts` 已包含:
|
`packages/core/src/export-config.ts` 已包含:
|
||||||
|
|
||||||
- 配置版本号;
|
- 版本 3 配置模型;
|
||||||
- A3、A4、A5、Letter、Legal、Tabloid 和自定义纸张;
|
- A3、A4、A5、Letter、Legal、Tabloid 和自定义纸张;
|
||||||
- 横向、纵向;
|
- 横向、纵向;
|
||||||
- 四边页边距;
|
- 四边页边距;
|
||||||
@@ -54,6 +72,7 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 页码位置和格式;
|
- 页码位置和格式;
|
||||||
- PDF 元数据;
|
- PDF 元数据;
|
||||||
- 打印背景、缩放和一级标题分页选项;
|
- 打印背景、缩放和一级标题分页选项;
|
||||||
|
- Mermaid 布局、主题、外观和字体;
|
||||||
- Zod 数据校验;
|
- Zod 数据校验;
|
||||||
- 默认 A4 技术文档预设。
|
- 默认 A4 技术文档预设。
|
||||||
|
|
||||||
@@ -74,7 +93,10 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 主题 CSS 相对资源重写与受限资源接口;
|
- 主题 CSS 相对资源重写与受限资源接口;
|
||||||
- 目录匹配、真实路径包含关系和外部 URL 安全检查。
|
- 目录匹配、真实路径包含关系和外部 URL 安全检查。
|
||||||
|
|
||||||
内置主题为项目自有实现,没有复制 Typora 官方默认主题。用户可以通过导入工具将本机已安装的 Typora 基础 CSS以及 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题复制到 Git 忽略的 `.local/themes`。这些副本仅供本机使用,不进入仓库、容器或发布包。
|
内置主题包含项目自有的 Typora 风格主题,以及按公司内部私有项目决策
|
||||||
|
复制的 GitHub、Pixyll 和 Whitey 三套 Typora 默认主题。三套复制主题的
|
||||||
|
来源和内部使用边界记录在 `themes/INTERNAL_THEME_NOTICE.md`;如未来公开、
|
||||||
|
商用或向公司外部分发,必须重新完成许可证审查。
|
||||||
|
|
||||||
### 2.4 Markdown 渲染核心
|
### 2.4 Markdown 渲染核心
|
||||||
|
|
||||||
@@ -127,7 +149,7 @@ ec48bce chore: 初始化项目骨架
|
|||||||
|
|
||||||
提交 `52cf816` 已完成:
|
提交 `52cf816` 已完成:
|
||||||
|
|
||||||
- 导出配置升级到版本 2;
|
- 导出配置升级到版本 3,并兼容迁移版本 2 浏览器缓存;
|
||||||
- 纸张收敛为 A3、A4、A5、US-Letter 和 US-Legal;
|
- 纸张收敛为 A3、A4、A5、US-Letter 和 US-Legal;
|
||||||
- 默认 A4 纵向和 16mm 四边页边距;
|
- 默认 A4 纵向和 16mm 四边页边距;
|
||||||
- 统一 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算;
|
- 统一 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算;
|
||||||
@@ -136,6 +158,7 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 页眉左中右内容和变量;
|
- 页眉左中右内容和变量;
|
||||||
- 五种页码样式、对齐和起始页码;
|
- 五种页码样式、对齐和起始页码;
|
||||||
- Mermaid 官方配置 frontmatter、默认主题与错误隔离;
|
- Mermaid 官方配置 frontmatter、默认主题与错误隔离;
|
||||||
|
- Mermaid 全局 Dagre/ELK 布局、官方主题、外观和字体设置;
|
||||||
- Markdown 表格对齐属性保留和自适应列宽;
|
- Markdown 表格对齐属性保留和自适应列宽;
|
||||||
- 纸张尺寸和页边距实时预览。
|
- 纸张尺寸和页边距实时预览。
|
||||||
|
|
||||||
@@ -190,9 +213,266 @@ ec48bce chore: 初始化项目骨架
|
|||||||
- 增加容器健康检查和统一进程退出处理。
|
- 增加容器健康检查和统一进程退出处理。
|
||||||
- Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。
|
- Nginx 显式以 JavaScript MIME 类型提供 PDF.js `.mjs` Worker。
|
||||||
|
|
||||||
|
### 3.6 Mermaid 配置与预览缩放
|
||||||
|
|
||||||
|
- Mermaid 固定为 `11.16.0`,ELK 使用官方
|
||||||
|
`@mermaid-js/layout-elk@0.2.2` 并按需加载核心布局代码。
|
||||||
|
- 全局导出设置支持 Dagre、ELK、官方主题、Classic、Hand-drawn、Neo
|
||||||
|
外观和自定义本地字体。
|
||||||
|
- 单图 `config` Front Matter 可以覆盖全局布局、主题、外观、字体、
|
||||||
|
`themeVariables` 和图表专属配置。
|
||||||
|
- `style`、`classDef` 和 `linkStyle` 保持 Mermaid 原生语义。
|
||||||
|
- `themeCSS`、严格安全模式和资源限制不能由 Markdown 覆盖。
|
||||||
|
- Mermaid 与 Paged.js 计算期间隐藏分页 iframe,避免显示官方渲染器的
|
||||||
|
临时测量节点。
|
||||||
|
- 快速预览和精确预览共用 50%~400% 显示缩放,支持滑块、手动输入、
|
||||||
|
100% 重置和独立浏览器缓存。
|
||||||
|
- 缩放不进入导出配置,不改变分页或 PDF;精确预览会按显示宽度重绘
|
||||||
|
邻近 Canvas,保持文字清晰。
|
||||||
|
|
||||||
|
### 3.7 渲染边界修复与复用重构
|
||||||
|
|
||||||
|
- PDF 总超时覆盖 Chromium 启动、上下文创建、页面渲染和有界清理,
|
||||||
|
浏览器预热也受同一超时约束。
|
||||||
|
- 当前预览模式的重复点击不再触发无意义的状态重置和 PDF 请求。
|
||||||
|
- Mermaid 分页测量使用实际主题、字体和盒模型,静态 SVG 图片锁定实际
|
||||||
|
小数尺寸,避免测量容器宽度与真实纸张不一致。
|
||||||
|
- 非法 Front Matter 在预览和 PDF API 中统一返回 400;无效本地主题
|
||||||
|
会被隔离并记录警告,不影响其他主题。
|
||||||
|
- `packages/core` 统一维护 Markdown 文档、分页载荷、分页结果和耗时类型,
|
||||||
|
前端快速预览与后端 PDF 使用同一个载荷构造函数。
|
||||||
|
- 前端 Markdown 防抖渲染与主题清单/CSS 加载已从 `App.tsx` 抽取为独立
|
||||||
|
Hook,保留原有取消请求和错误隔离行为。
|
||||||
|
- 主题注册表增加 1 秒目录扫描缓存和主动失效接口,减少同一请求链路中
|
||||||
|
重复遍历主题目录,同时兼顾本地主题热更新。
|
||||||
|
- CSS 像素与 PDF 点数换算只使用 core 中的共享常量;删除已经不参与协议
|
||||||
|
或逻辑的 `contentHeight` 字段。
|
||||||
|
|
||||||
|
### 3.8 Windows 显示缩放与快速预览分页差异
|
||||||
|
|
||||||
|
- 此前 65 页长文档在快速预览中生成 64 页、在精确预览和最终 PDF 中
|
||||||
|
生成 65 页;该差异并非由 Chromium 版本不同导致。
|
||||||
|
- 出现差异时,笔记本内屏使用 Windows 150% 系统显示缩放。切换到使用
|
||||||
|
100% 显示缩放的外接显示器后,快速预览与精确预览的分页结果基本一致。
|
||||||
|
- 当前定位是:Windows 显示缩放改变浏览器的 `devicePixelRatio`,并可能
|
||||||
|
影响字体栅格化和 CSS 布局中的子像素取整;单处差异很小,但会在长文档
|
||||||
|
分页过程中累计,最终造成页数差异。
|
||||||
|
- 精确预览直接展示服务端 Chromium 生成的 PDF,最终下载也复用同一结果,
|
||||||
|
因此精确预览和最终 PDF 继续作为权威分页结果;快速预览定位为编辑期间的
|
||||||
|
客户端近似分页,不承诺在所有显示缩放环境下与 PDF 页数完全一致。
|
||||||
|
- 后续优化快速预览时,应重点检查 `devicePixelRatio`、浏览器页面缩放、
|
||||||
|
字体度量和逐页高度取整;分页测量不得依赖物理像素,视觉缩放不得参与
|
||||||
|
文档排版和分页计算,并避免逐页取整造成累计误差。
|
||||||
|
- 后续分页回归应覆盖 Windows 100%、125% 和 150% 系统显示缩放,分别
|
||||||
|
记录快速预览、精确预览和最终 PDF 的页数及分页边界。
|
||||||
|
|
||||||
|
### 3.9 Markdown ECharts 图表
|
||||||
|
|
||||||
|
- 新增可独立复用的 `packages/markdown-echarts` 工作区,为 Markdown-it
|
||||||
|
提供安全的 `echarts` YAML 围栏。
|
||||||
|
- 围栏支持版本、固定高度或宽高比、主题、图注和 ECharts `option`;
|
||||||
|
高度默认 80mm,主题默认跟随文档。
|
||||||
|
- 当前支持 line、bar、scatter、pie、radar、heatmap、boxplot 和
|
||||||
|
candlestick 系列,并校验各系列的数据结构和必要坐标系。
|
||||||
|
- 每个系列必须使用非空 `series[].data`、`dataset.source` 或具有有效
|
||||||
|
上游数据的内置 transform;缺少数据等错误会显示包含配置路径的局部
|
||||||
|
占位,不中断整篇 Markdown。
|
||||||
|
- YAML 会转换为安全纯数据对象;禁止函数、自定义标签、锚点和别名、
|
||||||
|
外部 URL、HTML formatter、原型污染属性及 `custom` 系列。
|
||||||
|
- 浏览器按需加载 ECharts 模块,以 SVG 渲染,并可冻结为内联 SVG 或
|
||||||
|
SVG Data URL 图片;快速预览与 PDF 使用同一套运行时。
|
||||||
|
- 分页遵循 Mermaid 的不可拆分规则:当前页空间足够时原位放置,不足时
|
||||||
|
移到下一页,超高图表等比例缩放到单页内,不跨页切割。
|
||||||
|
- PDF 输出等待 ECharts、Mermaid、字体和图片全部完成后再分页,并分别
|
||||||
|
返回 ECharts 与 Mermaid 错误数量。
|
||||||
|
- 包内 README 已记录围栏语法、默认值、验证规则、错误行为、安全边界、
|
||||||
|
Markdown-it 接入和浏览器渲染方式。
|
||||||
|
|
||||||
|
### 3.10 工作区布局与页码导航
|
||||||
|
|
||||||
|
- 默认 Markdown 示例新增 ECharts 柱状图,打开网页即可验证 YAML 围栏、
|
||||||
|
SVG 渲染和分页。
|
||||||
|
- 文件名及渲染、分页、导出错误状态从源文本标题栏迁移到顶部应用标题
|
||||||
|
旁,长文件名支持省略和完整悬停提示。
|
||||||
|
- 源文本区域支持收起和展开;桌面端收起为 48px 左侧窄栏,预览区扩展
|
||||||
|
到剩余宽度并保持纸张居中,窄屏收起为 44px 顶部横条。
|
||||||
|
- 折叠内容使用真实 `display:none`,不会残留可聚焦文本框或进入无障碍
|
||||||
|
树;按钮提供 `aria-controls` 和 `aria-expanded`。
|
||||||
|
- 当前页从预览标题行移到缩放工具栏左侧,显示为
|
||||||
|
`第 [n] / 总页数 页`,避免重复信息。
|
||||||
|
- 页码输入支持 Enter 和失焦提交,自动限制到 1~总页数;空值和非法
|
||||||
|
小数恢复当前页。
|
||||||
|
- 快速预览直接定位 iframe 内 `.pagedjs_page`;精确预览定位
|
||||||
|
`.precise-pdf-page`。滚动预览时输入框同步当前页。
|
||||||
|
- 精确预览会动态识别桌面内部滚动容器或窄屏主窗口滚动容器,目标页顶端
|
||||||
|
与实际可视滚动区域对齐。
|
||||||
|
- 快速预览 iframe 只负责生成分页内容并回传自然高度,滚动统一交给外层
|
||||||
|
`.preview-scroll`;滚动条因此与精确预览一样贴在右侧预览容器边缘,
|
||||||
|
不再贴着居中的纸张右边缘。
|
||||||
|
- 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页
|
||||||
|
位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。
|
||||||
|
|
||||||
|
### 3.11 ECharts 第二阶段图表
|
||||||
|
|
||||||
|
- ECharts 系列支持从首版 8 种扩展到 17 种,新增 `tree`、`treemap`、
|
||||||
|
`sunburst`、`graph`、`sankey`、`chord`、`funnel`、`gauge` 和
|
||||||
|
`pictorialBar`。
|
||||||
|
- 当前使用 ECharts 6.1.0,`chord` 直接采用 ECharts 6 官方和弦图及
|
||||||
|
`ChordChart` 按需模块,不引入第三方扩展。
|
||||||
|
- `tree`、`treemap` 和 `sunburst` 校验递归层级结构;矩形树图和旭日图
|
||||||
|
的叶节点必须提供有限数值。
|
||||||
|
- `graph`、`sankey` 和 `chord` 校验节点、关系边、端点引用与边权重;
|
||||||
|
Graph 未指定布局时,有完整坐标则使用 `none`,否则默认使用 `force`。
|
||||||
|
- `funnel`、`gauge` 校验有限数值数据;`pictorialBar` 复用柱图的数据和
|
||||||
|
坐标轴规则,并允许不加载外部资源的 `path://` 内嵌矢量路径。
|
||||||
|
- 安全策略新增层级节点、关系节点和关系边数量限制,继续禁止外部图片、
|
||||||
|
跳转链接、函数、HTML formatter 和原型污染属性。
|
||||||
|
- 默认 Markdown 现在包含基础柱状图及九种第二阶段图表,每种均使用独立
|
||||||
|
YAML 围栏,可直接编辑和观察 SVG、分页及错误占位行为。
|
||||||
|
- Treemap 默认示例关闭节点下钻和面包屑,显式启用父级与叶节点标签,并
|
||||||
|
增加白色分隔边框,避免扁平数据区域缺少辨识信息或底部控件占用空间。
|
||||||
|
- Markdown/PDF 属于确定性的静态输出场景,浏览器引擎会在全局和系列级
|
||||||
|
强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使
|
||||||
|
预览或 PDF 停留在动画中间帧。
|
||||||
|
|
||||||
|
### 3.12 v0.4.0 Electron 桌面端
|
||||||
|
|
||||||
|
- 新增 `apps/desktop` Electron 43.2.0 工作区,使用 Electron Forge
|
||||||
|
7.11.2 打包。
|
||||||
|
- 根项目版本升级为 `0.4.0`,Vite 从根 `package.json` 注入统一版本,
|
||||||
|
工具标题右侧显示 `v0.4.0` 徽标。
|
||||||
|
- 应用窗口和 PDF 窗口均关闭 Node Integration,启用 Context Isolation
|
||||||
|
和 Chromium Sandbox,并使用不同 preload 暴露窄 IPC 接口。
|
||||||
|
- 桌面端 PDF 直接接收 Web 端已经构造的共享分页载荷,复用现有
|
||||||
|
`PagedDocumentRuntime`、主题 CSS、Mermaid 和 ECharts 运行时。
|
||||||
|
- 隐藏 PDF 窗口使用 Electron 自带 Chromium 的
|
||||||
|
`webContents.printToPDF()`,桌面发行包不携带 Playwright Chromium。
|
||||||
|
- Paged.js 的帧队列在纯隐藏窗口中不会推进;桌面 PDF 任务期间使用
|
||||||
|
计时器调度分页队列,分页完成后恢复原生 `requestAnimationFrame`。
|
||||||
|
- Chromium 打印前将透明、不可聚焦、不进入任务栏的 PDF 窗口短暂放到
|
||||||
|
屏幕外,使复杂分页页面拥有可打印的窗口表面,用户不会看到该窗口。
|
||||||
|
- PDF Buffer 通过受控 IPC 返回 Web 界面,桌面端使用系统“另存为”对话框
|
||||||
|
写入文件;Web 版继续使用浏览器下载行为。
|
||||||
|
- 生产页面通过 `mdpdf://bundle/` 自定义安全协议加载,不使用权限过宽的
|
||||||
|
`file://`;PDF Session 只允许同源、`data:`、`blob:` 和 `about:`
|
||||||
|
资源。
|
||||||
|
- Windows x64 未签名目录包、Squirrel `Setup.exe` 和 ZIP 已由 Forge
|
||||||
|
成功生成;`app.asar` 只包含
|
||||||
|
编译后的主进程、preload 和清单,Web 静态资源作为只读资源单独打包。
|
||||||
|
- 新增 `packages/application`,统一封装 Markdown 渲染、主题清单、主题
|
||||||
|
CSS 和主题资源读取;Fastify 仅作为 HTTP 适配器。
|
||||||
|
- Web 端通过应用后端抽象选择 HTTP 或 Electron IPC;桌面端通过受限 IPC
|
||||||
|
直接调用共享服务,不启动 Fastify,不占用本地 HTTP 端口。
|
||||||
|
- 主题资源通过 `mdpdf://theme/` 安全协议提供,应用窗口与独立 PDF
|
||||||
|
Session 均完成协议注册和来源限制。
|
||||||
|
- 修复 Electron 主进程 ESM Bundle 中 YAML 依赖动态加载 `process`
|
||||||
|
失败的问题,构建时注入 Node `createRequire`。
|
||||||
|
- `logos/` 作为统一品牌源:Web 使用 favicon、Apple Touch Icon 和
|
||||||
|
Web Manifest,Electron 使用 ICO、ICNS、窗口图标和安装器图标。
|
||||||
|
- Forge 输出目录包含版本号,正式内部交付使用 Squirrel `Setup.exe`,
|
||||||
|
ZIP 作为免安装辅助包;当前未签名。
|
||||||
|
|
||||||
|
### 3.13 v0.4.0 内置主题与发布产物
|
||||||
|
|
||||||
|
- `.local/themes` 中 GitHub、Pixyll 和 Whitey 三套主题已复制到
|
||||||
|
`themes/`,从 v0.4.0 起随 Web 容器和桌面安装包内置。
|
||||||
|
- 内置主题注册优先于相同 ID 的本地主题,避免 Compose 默认挂载旧副本
|
||||||
|
覆盖发布版本,并记录可诊断警告。
|
||||||
|
- Windows 正式安装包为
|
||||||
|
`Markdown PDF 导出器-0.4.0 Setup.exe`,约 137.48 MiB;SHA-256 为
|
||||||
|
`9C12F9A007E102BDB5213ABC5246F4480083FCA5D67DCD45354728BCFDB54220`。
|
||||||
|
- 中文产品名、窗口标题、安装包和开始菜单名称保持不变,Windows 主程序
|
||||||
|
与进程文件固定为 `md-to-pdf.exe`;目录包和 Squirrel NUPKG 均已验证。
|
||||||
|
- 本地镜像为 `yixiong/md-to-pdf:v0.4.0`;Compose 容器保持健康运行并
|
||||||
|
监听 `http://localhost:8080`。
|
||||||
|
- Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过
|
||||||
|
构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。
|
||||||
|
|
||||||
|
### 3.14 v0.4.1 图片资源与标题分页
|
||||||
|
|
||||||
|
- `packages/application` 统一提取 Markdown 图片引用,将验证后的本地或
|
||||||
|
远程图片转换为 Data URL,再交给 Web 预览、Playwright PDF 和 Electron
|
||||||
|
PDF 共用的 HTML。
|
||||||
|
- Web 增加素材目录选择,支持把 Markdown 的关联图片作为受限 Base64
|
||||||
|
资源随渲染和 PDF 请求提交;请求体上限同步提高到 24 MiB。
|
||||||
|
- Desktop 增加原生“打开 Markdown”,主进程记录文档目录并直接读取相对
|
||||||
|
图片,不启动 Server,也不允许读取文档目录之外的文件。
|
||||||
|
- 相对路径支持中文、空格和百分号编码;拒绝绝对路径、`..` 穿越和符号
|
||||||
|
链接越界。
|
||||||
|
- 远程图片支持 HTTP/HTTPS、逐跳重定向校验、10 秒超时、格式嗅探与
|
||||||
|
5 分钟缓存;阻止 localhost、私网和链路本地目标,并兼容企业代理常用
|
||||||
|
的域名 Fake-IP 网段。
|
||||||
|
- 每篇文档最多 50 个图片引用,单图不超过 8 MiB,总图片不超过 15 MiB;
|
||||||
|
支持 PNG、JPEG、GIF、WebP、AVIF 和受限 SVG。
|
||||||
|
- 独立图片使用 `figure + img + figcaption`,`[]` 中的文本显示为图片
|
||||||
|
标题;行内混排图片继续保持普通段落语义。
|
||||||
|
- 图片与标题整块分页。超高图先扣除标题区实际高度,再等比例缩放到单页,
|
||||||
|
与 Mermaid 和 ECharts 遵循相同的不可跨页规则。
|
||||||
|
- 默认示例增加 NASA 中等尺寸图与 Unsplash 超高竖图,直接覆盖正常图片
|
||||||
|
和超高图片分页场景。
|
||||||
|
|
||||||
|
### 3.15 v0.4.2 桌面窗口精简
|
||||||
|
|
||||||
|
- Electron 主进程不再创建默认应用菜单,移除窗口顶部的
|
||||||
|
`File / Edit / View / Window` 原生菜单栏。
|
||||||
|
- 应用窗口同时启用菜单栏自动隐藏,避免 Windows 恢复默认菜单。
|
||||||
|
- 该维护版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 镜像继续
|
||||||
|
使用已验证的 `v0.4.1`。
|
||||||
|
|
||||||
|
### 3.16 v0.4.3 标准 Windows 安装向导
|
||||||
|
|
||||||
|
- 桌面发行链路由 Electron Forge Squirrel 迁移到 electron-builder
|
||||||
|
26.15.3 的 NSIS assisted installer。
|
||||||
|
- 安装向导提供当前用户/所有用户安装选项、安装目录选择、安装进度和完成
|
||||||
|
页面;主程序继续使用 `md-to-pdf.exe`。
|
||||||
|
- 正式安装包固定命名为
|
||||||
|
`md-to-pdf-0.4.3-x86_64-Setup.exe`,免安装包命名为
|
||||||
|
`md-to-pdf-0.4.3-x86_64.zip`。
|
||||||
|
- 项目自有 `build/installer.nsh` 显式创建桌面和开始菜单快捷方式,
|
||||||
|
安装完成后直接启动主程序,不依赖 `.lnk`。
|
||||||
|
- 卸载时显式清理桌面快捷方式、开始菜单快捷方式及其目录。
|
||||||
|
- 安装包和校验文件作为 Gitea `v0.4.3` Release 附件发布,不写入 Git
|
||||||
|
历史。
|
||||||
|
|
||||||
|
### 3.17 v0.4.4 桌面语言包精简
|
||||||
|
|
||||||
|
- Electron 发行包只保留 `zh-CN` 和 `en-US` 两种 Chromium 语言资源。
|
||||||
|
- 不裁剪 ICU、ANGLE、Direct3D、Dawn、SwiftShader 或其他 Chromium
|
||||||
|
运行时组件,保证桌面预览与 PDF 导出在不同图形环境下的可靠性。
|
||||||
|
- 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持
|
||||||
|
`v0.4.1`。
|
||||||
|
|
||||||
|
### 3.18 v0.4.5 文档工作流与媒体分页
|
||||||
|
|
||||||
|
- Web 与 Desktop 均支持新建 Markdown;未保存时使用“未命名文档”状态,
|
||||||
|
不显示伪造源文件名,并允许直接导出 PDF。
|
||||||
|
- Web 保存使用浏览器下载;Desktop 使用原生另存为。默认文件名优先取
|
||||||
|
第一个一级标题,没有一级标题时取首行可见文本,并清理 Windows 非法
|
||||||
|
字符、保留名和末尾空格或句点。
|
||||||
|
- “打开 Markdown”继续作为顶部独立强操作;新建、保存及桌面自定义主题
|
||||||
|
目录收纳到“更多”菜单,Web 不再显示本地素材目录入口。
|
||||||
|
- Windows 安装包注册 `.md` 和 `.markdown` 文件关联;系统打开文件会
|
||||||
|
转交给现有单实例窗口并自动加载。
|
||||||
|
- Desktop 监听窗口位置、尺寸和最大化变化,延迟写入完整状态;退出前
|
||||||
|
刷新待写状态。下次启动按当前显示器工作区限制尺寸并修正坐标,避免
|
||||||
|
多显示器断开后窗口落在屏幕外。
|
||||||
|
- Desktop 会建立自定义主题目录,“更多”菜单可直接在资源管理器打开;
|
||||||
|
目录允许留空,放入合法主题后由共享主题注册器读取。
|
||||||
|
- 图片、Mermaid 和 ECharts 统一视为带标题的媒体块。分页按 DOM 文档
|
||||||
|
顺序串行处理,每个媒体元素只允许一次回填或缩放重排。
|
||||||
|
- 图片基准高度按原尺寸先收缩到内容限宽后计算;媒体块只有在上一页空白
|
||||||
|
与所需空间比例达到阈值时才尝试回填。缩放只作用于图片或图表内容,
|
||||||
|
标题保持原字号和自然高度。
|
||||||
|
- ECharts 在分页前冻结为 SVG 图片,三类媒体共享回填决策与边界检查;
|
||||||
|
无法安全回填时仍移至下一页,保证顺序、不跨页且不覆盖后续内容。
|
||||||
|
- 默认示例仅保留一张网络图片,并新增有效 Base64 横图、不同尺寸图片、
|
||||||
|
Mermaid 和 ECharts,用于观察媒体分页、标题配对和渲染效果。
|
||||||
|
- 根 README、Desktop README、四个工作区包 README、进度文档和发布
|
||||||
|
规范随版本同步更新。
|
||||||
|
|
||||||
## 4. 已执行验证
|
## 4. 已执行验证
|
||||||
|
|
||||||
2026-07-26 在当前完整工作区成功执行:
|
2026-07-28 在当前完整工作区成功执行:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
npm test
|
npm test
|
||||||
@@ -203,23 +483,134 @@ git diff --check
|
|||||||
|
|
||||||
结果:
|
结果:
|
||||||
|
|
||||||
- 共享配置测试:6 项通过;
|
- ECharts 围栏测试:36 项通过;
|
||||||
- 渲染器测试:7 项通过;
|
- 共享配置测试:9 项通过;
|
||||||
- 前端测试:47 项通过;
|
- 渲染器测试:12 项通过;
|
||||||
- 后端测试:17 项通过;
|
- 应用服务测试:9 项通过;
|
||||||
|
- 前端测试:87 项通过;
|
||||||
|
- 后端测试:29 项通过;
|
||||||
|
- 桌面端测试:20 项通过;
|
||||||
|
- 全项目共 202 项测试通过;
|
||||||
- 全项目类型检查通过;
|
- 全项目类型检查通过;
|
||||||
- 生产构建通过;
|
- 生产构建通过;
|
||||||
- `git diff --check` 通过。
|
- `git diff --check` 通过。
|
||||||
|
|
||||||
|
`v0.4.5` 双端与媒体分页验收结果:
|
||||||
|
|
||||||
|
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
|
||||||
|
为 `sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`;
|
||||||
|
Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`。
|
||||||
|
- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF,
|
||||||
|
Mermaid 与 ECharts 错误数均为 0。
|
||||||
|
- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只
|
||||||
|
出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。
|
||||||
|
- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、
|
||||||
|
928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。
|
||||||
|
- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过;
|
||||||
|
`output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。
|
||||||
|
- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题
|
||||||
|
目录能够打开且可识别已有主题。
|
||||||
|
- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸
|
||||||
|
测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。
|
||||||
|
- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`,
|
||||||
|
94,998,489 字节,SHA-256 为
|
||||||
|
`9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`。
|
||||||
|
- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`,132,477,245 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`。
|
||||||
|
- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN` 和 `en-US` 语言包,
|
||||||
|
Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。
|
||||||
|
- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、
|
||||||
|
桌面与开始菜单快捷方式、`.md` 和 `.markdown` 打开注册均验证通过,
|
||||||
|
安装版应用启动正常。
|
||||||
|
|
||||||
|
Electron 桌面端第一阶段验证结果:
|
||||||
|
|
||||||
|
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
|
||||||
|
`v0.4.0`。
|
||||||
|
- 最小 Markdown 通过隐藏 Electron Chromium 生成 1 页、52,308 字节
|
||||||
|
PDF,文字可搜索,并通过桌面原生保存通道落盘。
|
||||||
|
- 默认示例通过桌面端生成 4 页、439,501 字节 A4 PDF,Mermaid 和
|
||||||
|
10 个 ECharts 图表错误数均为 0。
|
||||||
|
- 将默认示例全部 4 页渲染为 PNG 后逐页检查,Tree、Treemap、Sunburst、
|
||||||
|
Graph、Sankey、Chord、Funnel、Gauge 和 PictorialBar 均完整显示,
|
||||||
|
未发现空白页、黑块、跨页切割或明显裁切。
|
||||||
|
- PDF.js 可以从 4 页中提取 670 个非空白字符,各页均包含可搜索文字。
|
||||||
|
- Forge 成功生成 Windows x64 未签名目录包,生产窗口通过
|
||||||
|
`mdpdf://bundle/index.html` 加载,版本徽标和界面静态资源正常。
|
||||||
|
- 精简后的 `app.asar` 约 554 KiB,Web 静态资源共 134 个文件、约
|
||||||
|
9.0 MiB;运行目录只包含 Electron 自带的一套 Chromium。
|
||||||
|
|
||||||
已使用浏览器插件完成视觉验证:
|
已使用浏览器插件完成视觉验证:
|
||||||
|
|
||||||
- 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG;
|
- 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位;
|
||||||
|
- 顶栏文件名、分页状态与操作按钮在桌面和窄屏均无重叠;
|
||||||
|
- 桌面端源文本收起后宽度从 576px 降为 48px,预览区从 864px 扩展
|
||||||
|
到 1392px;窄屏折叠条为 44px;
|
||||||
|
- 快速预览输入第 2 页后目标页顶端对齐 iframe 视口,滚动回首页和第二页
|
||||||
|
时输入框分别同步为 1 和 2;
|
||||||
|
- 页码输入 0 和 99 时分别限制到 1 和总页数;
|
||||||
|
- 精确预览输入第 2 页后,目标页与桌面滚动容器顶部误差为 0;
|
||||||
|
- 快速预览改用外层滚动容器后,右侧容器边界为 1440px,纸张边界约为
|
||||||
|
1400px,滚动条稳定贴在容器最右侧而非纸张边缘;
|
||||||
|
- 快速预览输入第 1 页后目标页与外层可视区域顶部误差为 0,外层滚动到
|
||||||
|
第 2 页时页码输入框同步更新为 2;
|
||||||
|
- 快速预览在 50% 和 150% 下的可滚动高度分别随内容显示高度缩放,
|
||||||
|
iframe 自身保持无滚动;切换精确/快速模式后仍使用同一右侧滚动条;
|
||||||
|
- 桌面端收起源文本后,预览滚动容器从 48px 延伸至窗口右边缘,纸张仍
|
||||||
|
在扩展后的预览区域内居中;
|
||||||
|
- 窄屏精确预览自动使用主窗口滚动,并正确跳转到第 2 页;
|
||||||
|
- 页码控件在窄屏独占首行,缩放控件在下一行铺满,没有与预览模式或主题
|
||||||
|
选择器重叠;
|
||||||
|
- 当前默认 Markdown 和主题资源加载正常,快速预览生成 2 页;
|
||||||
|
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
|
||||||
|
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
|
||||||
|
- 本轮浏览器回归期间控制台无警告或错误;
|
||||||
|
- 预览缩放在 50%、100%、150% 和 400% 下均按比例显示,快速与精确
|
||||||
|
预览的分页结果不受缩放影响;
|
||||||
|
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
|
||||||
|
`themeVariables`、`style` 和 `classDef` 的 Mermaid 样例在快速预览
|
||||||
|
与精确预览中均成功渲染;
|
||||||
|
- 在 Windows 150% 系统显示缩放的笔记本内屏上,65 页附件的快速预览
|
||||||
|
生成 64 页,67 个 Mermaid 全部使用静态 SVG;
|
||||||
- 同一附件的精确预览和最终 PDF 均为 65 页;
|
- 同一附件的精确预览和最终 PDF 均为 65 页;
|
||||||
|
- 切换到 Windows 100% 系统显示缩放的外接显示器后,快速预览与精确预览
|
||||||
|
的分页结果基本一致,进一步排除了 Chromium 版本差异这一判断方向;
|
||||||
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
|
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
|
||||||
- PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放;
|
- PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放;
|
||||||
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
|
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
|
||||||
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
|
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
|
||||||
|
|
||||||
|
ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||||
|
|
||||||
|
- 默认示例生成 10 个 ECharts SVG,包含原柱状图和九种新增系列,错误
|
||||||
|
占位数为 0,浏览器控制台无警告或错误;
|
||||||
|
- 快速预览生成 6 页,第 2~6 页各放置两张完整图表,所有图表边界均位于
|
||||||
|
当前页内容区内,没有跨页切割;
|
||||||
|
- 精确预览同样生成 6 页,逐页跳转正常;Chord、Funnel、Gauge 和
|
||||||
|
PictorialBar 的 PDF Canvas 视觉检查通过;
|
||||||
|
- 使用默认导出配置调用 PDF API 生成 4 页 PDF,响应中的 ECharts 和
|
||||||
|
Mermaid 错误数均为 0;
|
||||||
|
- PDF 中九种新增图表的标题均可搜索;将全部页面渲染为 PNG 后,Tree、
|
||||||
|
Treemap、Sunburst、Graph、Sankey、Chord、Funnel、Gauge 和
|
||||||
|
PictorialBar 均显示完整,未发现裁切、重叠、黑块或空白图表。
|
||||||
|
- 修正 Treemap 示例后再次检查快速与精确预览:底部下钻面包屑已隐藏,
|
||||||
|
五个叶节点边界清晰;SVG 中父级和叶节点共 7 个标签均处于可见状态。
|
||||||
|
- 进一步确认此前 Treemap 标签虽存在于 SVG,但被冻结在入场动画初始帧,
|
||||||
|
`fill-opacity` 约为 `0.000014`,肉眼近似完全透明;全局关闭动画后,
|
||||||
|
快速预览和精确 PDF 中的 2 个父级及 5 个叶节点标签均清晰可见。
|
||||||
|
|
||||||
|
2026-07-27 已重新构建并启动本地发布候选镜像:
|
||||||
|
|
||||||
|
- 使用完整多阶段 `deploy/Dockerfile` 构建
|
||||||
|
`yixiong/md-to-pdf:v0.3.0`,镜像大小约 612 MB;
|
||||||
|
- Compose 强制重建后容器健康检查通过,服务继续监听
|
||||||
|
`http://localhost:8080`;
|
||||||
|
- 首页返回 200,`POST /api/render` 的 ECharts 冒烟请求返回
|
||||||
|
`code`、`echarts` 功能标识和 `md-echarts` 安全占位;
|
||||||
|
- 容器内 `@md-to-pdf/markdown-echarts` 工作区包可正常加载;
|
||||||
|
- 当前镜像已通过发布前手动验收,并作为 `v0.3.0` 发布镜像保留运行。
|
||||||
|
|
||||||
此前主题阶段已使用浏览器插件完成视觉验证:
|
此前主题阶段已使用浏览器插件完成视觉验证:
|
||||||
|
|
||||||
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md`;
|
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md`;
|
||||||
@@ -246,12 +637,117 @@ git diff --check
|
|||||||
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
|
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
|
||||||
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
|
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
|
||||||
|
|
||||||
为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`。
|
`v0.3.0` 发布验收完成后,已停止并删除本项目 Compose 容器及网络以释放
|
||||||
|
本地资源;`yixiong/md-to-pdf:v0.3.0` 镜像继续保留。
|
||||||
|
|
||||||
|
2026-07-27 已构建并启动 `v0.3.1` 发布候选:
|
||||||
|
|
||||||
|
- 使用完整多阶段 Dockerfile 构建 `yixiong/md-to-pdf:v0.3.1`,镜像
|
||||||
|
大小约 612 MB;
|
||||||
|
- Compose 容器使用该镜像运行并通过健康检查,服务监听
|
||||||
|
`http://localhost:8080`;
|
||||||
|
- 容器首页返回 200,默认 Markdown 解析出 10 个 ECharts 围栏,
|
||||||
|
错误占位和渲染警告均为 0;
|
||||||
|
- 容器生成的默认配置 PDF 为 4 页、约 521 KiB,ECharts 和 Mermaid
|
||||||
|
渲染错误数均为 0;
|
||||||
|
- ECharts 已编译进按需加载的前端浏览器 Bundle,生产运行时不需要保留
|
||||||
|
独立的 `echarts` Node.js 包;
|
||||||
|
- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。
|
||||||
|
|
||||||
|
2026-07-27 已完成 `v0.4.0` 最终发布候选验证:
|
||||||
|
|
||||||
|
- 生成本地镜像 `yixiong/md-to-pdf:v0.4.0`,镜像内容 ID 为
|
||||||
|
`sha256:1bf4a136e9b822fa0925070cd522ea2adbad8b388d8e039d6509024d466666b3`;
|
||||||
|
- Compose 强制替换为 v0.4.0 后健康检查通过,首页返回 200,健康接口
|
||||||
|
返回 `ok`,主题接口返回 4 个 `bundled` 主题;
|
||||||
|
- 容器 Chromium 生成中文、表格、KaTeX、代码和 Mermaid 验收 PDF,
|
||||||
|
HTTP 200、1 页、147,374 字节,Mermaid/ECharts 错误数均为 0;
|
||||||
|
- PDF 渲染为 PNG 后检查通过,中文、公式、表格、代码、图表、页边距和
|
||||||
|
页码均正常,无裁切、重叠、黑块或空白;
|
||||||
|
- Compose 网页真实加载后标题旁显示 `v0.4.0`,主题选择器包含 4 个内置
|
||||||
|
主题,多页预览和图表正常,浏览器控制台无错误;
|
||||||
|
- Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB,
|
||||||
|
当前 Authenticode 状态为未签名。
|
||||||
|
|
||||||
|
2026-07-27 已完成 `v0.4.1` 图片资源发布候选验证:
|
||||||
|
|
||||||
|
- 用户提供的 `tmp/数据工程规划.md` 成功解析 2 张本地 PNG,生成约
|
||||||
|
5.0 MB 内嵌 HTML,图片自然宽度均为 8192px,零资源告警。
|
||||||
|
- Web 真实选择 Markdown 与 `.assets` 目录后生成 4 页快速预览,两张
|
||||||
|
本地图片均完整位于各自页面内容区。
|
||||||
|
- Wikimedia SVG、NASA JPEG 和 Unsplash JPEG 三种公网图片均完成下载、
|
||||||
|
格式识别、Data URL 内嵌和自然尺寸解码。
|
||||||
|
- 1200×1800 超高网络图在扣除标题高度后缩放;最终图片约 977px 高,
|
||||||
|
图加标题约 1001px 高,完整位于同一页且未复制、裁切或跨页。
|
||||||
|
- 默认示例的快速预览和精确预览均为 7 页,两个图片标题可见;容器
|
||||||
|
Playwright PDF 的精确预览同样为 7 页。
|
||||||
|
- Electron 目录版显示 `v0.4.1`,使用 Electron 内置 Chromium 成功渲染
|
||||||
|
两张默认网络图片;Windows 主程序仍为 `md-to-pdf.exe`。
|
||||||
|
- 最终 Squirrel 安装包为
|
||||||
|
`Markdown PDF 导出器-0.4.1 Setup.exe`,144,166,400 字节,SHA-256
|
||||||
|
为 `FC6687494255EE21224B65C1CA2D520316D568CCE8EB99A608A0DEF160611F4E`,
|
||||||
|
Authenticode 状态为未签名。
|
||||||
|
- 本地镜像 `yixiong/md-to-pdf:v0.4.1` 内容 ID 为
|
||||||
|
`sha256:1ded8a8a11ecb8b69eaa116bb9129dd4ec039e9918b047169fe5f8add2af3a80`;
|
||||||
|
Compose 容器健康运行在 `http://localhost:8080`。
|
||||||
|
|
||||||
|
2026-07-27 已完成 `v0.4.2` 桌面维护版本验证:
|
||||||
|
|
||||||
|
- 仅重新构建桌面端及其内嵌 Web 静态资源,没有重新构建或替换
|
||||||
|
Web/Compose 发布镜像。
|
||||||
|
- Windows 目录版视觉检查通过:默认原生菜单栏已经移除,应用内容直接
|
||||||
|
位于系统标题栏下方,版本徽标显示 `v0.4.2`。
|
||||||
|
- Windows 主程序继续使用英文文件名 `md-to-pdf.exe`。
|
||||||
|
- Squirrel 安装包为 `Markdown PDF 导出器-0.4.2 Setup.exe`,
|
||||||
|
144,166,400 字节,SHA-256 为
|
||||||
|
`59DA57522C99EB6B16A9591AFDEC349B785BF9B76950504B314273520152D41B`,
|
||||||
|
Authenticode 状态为未签名。
|
||||||
|
|
||||||
|
2026-07-27 已完成 `v0.4.3` 标准安装向导验证:
|
||||||
|
|
||||||
|
- NSIS 中文辅助安装向导正确显示安装范围和安装目录选择,版本显示为
|
||||||
|
`0.4.3`。
|
||||||
|
- 当前用户安装成功,安装目录为
|
||||||
|
`%LOCALAPPDATA%\Programs\md-to-pdf`,卸载项版本为 `0.4.3`。
|
||||||
|
- 桌面和开始菜单快捷方式均存在,实际目标均为安装目录中的
|
||||||
|
`md-to-pdf.exe`;从开始菜单快捷方式启动成功。
|
||||||
|
- 静默卸载返回 0,安装目录、桌面快捷方式和开始菜单目录全部清理;
|
||||||
|
随后重新安装成功,最终本机保持已安装状态。
|
||||||
|
- 正式安装包为 `md-to-pdf-0.4.3-x86_64-Setup.exe`,
|
||||||
|
103,346,230 字节,SHA-256 为
|
||||||
|
`C027B4AC604889C7E7E00BA3CE08A12616312BC9250268775B1B7B578A7C4817`,
|
||||||
|
Authenticode 状态为未签名。
|
||||||
|
- 免安装包为 `md-to-pdf-0.4.3-x86_64.zip`,143,784,617 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`。
|
||||||
|
|
||||||
|
2026-07-27 已完成 `v0.4.4` 桌面语言包精简验证:
|
||||||
|
|
||||||
|
- 目录版只包含 `zh-CN.pak` 和 `en-US.pak`,语言资源从 55 个、
|
||||||
|
48,911,653 字节降为 2 个、1,137,843 字节。
|
||||||
|
- 目录版总大小从 378,084,454 字节降为 330,310,644 字节,减少
|
||||||
|
47,773,810 字节;Chromium 图形与软件渲染后备组件均保持完整。
|
||||||
|
- Windows 主程序版本为 `0.4.4`,目录版成功启动并保持应用、GPU、
|
||||||
|
渲染器等 4 个 Electron 进程正常运行。
|
||||||
|
- 正式安装包为 `md-to-pdf-0.4.4-x86_64-Setup.exe`,94,991,759 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`0827FD87A8EEC8B034DF738909CB6A20231245B507AE4A0BAA579AFA03A942DE`,
|
||||||
|
Authenticode 状态为未签名。
|
||||||
|
- 免安装包为 `md-to-pdf-0.4.4-x86_64.zip`,132,470,241 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`D6CDB8DAE30676BAB6B2A037567164476AD294588B102A6FBBE9458ABCE5CCC0`。
|
||||||
|
|
||||||
## 5. 当前注意事项
|
## 5. 当前注意事项
|
||||||
|
|
||||||
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
||||||
|
- 已确认分页预览 iframe 内点击超链接会在 iframe 内执行导航,可能将当前
|
||||||
|
预览替换为空白页。该问题不阻塞 `v0.4.5`,计划在 `v0.4.6` 统一处理:
|
||||||
|
区分文档内锚点与外部链接、阻止危险协议,并为外部链接使用受控的新窗口
|
||||||
|
或系统浏览器打开策略。
|
||||||
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
|
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
|
||||||
|
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
|
||||||
|
渲染错误状态。
|
||||||
|
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
|
||||||
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
|
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
|
||||||
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
|
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
|
||||||
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
|
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
|
||||||
@@ -259,28 +755,51 @@ git diff --check
|
|||||||
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
|
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
|
||||||
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
|
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
|
||||||
- `apps/web/src/PrecisePdfPreview.tsx` 和 `pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
|
- `apps/web/src/PrecisePdfPreview.tsx` 和 `pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
|
||||||
|
- `apps/web/src/PreviewPageControl.tsx` 和 `preview-page.ts` 负责当前页输入、
|
||||||
|
页码边界、滚动页识别和快速跳转。
|
||||||
|
- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
|
||||||
|
- `packages/markdown-echarts` 负责 ECharts YAML 协议、验证、安全过滤、
|
||||||
|
Markdown-it 插件、浏览器 SVG 渲染和分页样式。
|
||||||
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
|
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
|
||||||
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
|
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
|
||||||
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
|
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
|
||||||
- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。
|
- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。
|
||||||
|
- `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的
|
||||||
|
共享模型。
|
||||||
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
||||||
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。
|
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
||||||
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交。
|
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
||||||
|
- `apps/desktop/src/main.ts` 负责 Electron 生命周期、自定义应用协议、
|
||||||
|
应用窗口、IPC 校验和原生 PDF 保存。
|
||||||
|
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
|
||||||
|
调度、网络限制、打印和窗口复用。
|
||||||
|
- `apps/desktop/src/app-preload.ts` 与 `pdf-preload.ts` 分别定义应用
|
||||||
|
窗口和 PDF 运行窗口的最小 IPC 能力。
|
||||||
|
- `.local/themes/typora-*` 仍可作为用户本机副本;v0.4.0 的三套发布版本
|
||||||
|
已复制到 `themes/` 并标记为内置主题。
|
||||||
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
|
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
|
||||||
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。
|
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
|
||||||
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
|
未来改变分发范围前必须重新完成许可证审查。
|
||||||
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。
|
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
||||||
|
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
|
||||||
|
- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
|
||||||
|
目录读取;远程图片仅保留有界内存缓存。
|
||||||
|
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
||||||
|
导出,不依赖 Fastify 或本地 HTTP Server。
|
||||||
|
- `v0.4.5` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
|
||||||
|
Compose 健康检查,当前 Compose 容器保持运行。
|
||||||
|
|
||||||
## 6. 推荐接手顺序
|
## 6. 推荐接手顺序
|
||||||
|
|
||||||
### 阶段一:资源与安全
|
### 阶段一:v0.4.6 超链接导航
|
||||||
|
|
||||||
- Markdown 文件夹或 ZIP;
|
- 在快速预览 iframe 中统一拦截链接点击,避免外部页面替换分页预览;
|
||||||
- 相对图片解析;
|
- 文档内 `#anchor` 保持在当前预览中定位,并兼容分页后的标题锚点;
|
||||||
- 每请求独立临时目录;
|
- `http:`、`https:`、`mailto:` 等允许协议通过受控新窗口或桌面系统
|
||||||
- 路径穿越和符号链接越界防护;
|
浏览器打开;
|
||||||
- 文件数量、单文件大小、总大小和超时限制;
|
- 拒绝 `javascript:`、`file:`、未知协议及未授权本地资源;
|
||||||
- 请求结束可靠清理。
|
- Web 与 Desktop 共用链接分类规则,各平台只实现打开动作适配;
|
||||||
|
- 增加快速预览、精确预览和 Desktop 的链接行为测试。
|
||||||
|
|
||||||
### 阶段二:导出配置扩展
|
### 阶段二:导出配置扩展
|
||||||
|
|
||||||
@@ -291,7 +810,7 @@ git diff --check
|
|||||||
## 7. 首版验收目标
|
## 7. 首版验收目标
|
||||||
|
|
||||||
- 可以选择或粘贴 Markdown;
|
- 可以选择或粘贴 Markdown;
|
||||||
- 网页正确预览常用 Markdown、公式和 Mermaid;
|
- 网页正确预览常用 Markdown、公式、Mermaid 和 ECharts;
|
||||||
- 可以下载真实 PDF;
|
- 可以下载真实 PDF;
|
||||||
- PDF 文本可搜索;
|
- PDF 文本可搜索;
|
||||||
- 预览与 PDF 基本一致;
|
- 预览与 PDF 基本一致;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ themes/<theme-id>/ 随项目分发的内置主题
|
|||||||
.local/themes/<theme-id>/ 仅供当前设备使用的本地主题
|
.local/themes/<theme-id>/ 仅供当前设备使用的本地主题
|
||||||
```
|
```
|
||||||
|
|
||||||
- 内置主题的 `bundled` 必须为 `true`,并具有允许项目分发的明确许可证。
|
- 内置主题的 `bundled` 必须为 `true`,并记录来源、许可证或内部使用边界。
|
||||||
- 本地主题的 `bundled` 必须为 `false`,整个 `.local/` 目录已被 Git 忽略。
|
- 本地主题的 `bundled` 必须为 `false`,整个 `.local/` 目录已被 Git 忽略。
|
||||||
- 主题目录名必须与清单中的 `id` 完全一致。
|
- 主题目录名必须与清单中的 `id` 完全一致。
|
||||||
|
|
||||||
@@ -262,11 +262,15 @@ npm run theme:import-typora -- --replace
|
|||||||
.local/theme-backups/
|
.local/theme-backups/
|
||||||
```
|
```
|
||||||
|
|
||||||
Typora 当前没有为这些文件提供允许项目再分发的明确许可证,因此导入结果只能保存在 `.local`,不得提交到 Git、打包进容器或随项目发布。
|
Typora 当前没有为这些文件提供允许公开再分发的明确许可证。普通导入结果
|
||||||
|
仍默认保存在 `.local`。本项目从 v0.4.0 起按公司内部私有项目决策,将
|
||||||
|
GitHub、Pixyll 和 Whitey 三套主题复制为内置资源,并在
|
||||||
|
`themes/INTERNAL_THEME_NOTICE.md` 记录来源和内部使用边界;如未来公开、
|
||||||
|
商用或向公司外部分发,必须重新完成许可证审查并替换或取得授权。
|
||||||
|
|
||||||
## 10. 提交内置主题前检查
|
## 10. 提交内置主题前检查
|
||||||
|
|
||||||
- 许可证明确允许再分发,并在清单和说明中记录。
|
- 许可证明确允许目标范围内使用,或已在清单和内部说明中记录限制。
|
||||||
- 主题目录不包含用户文档、临时文件或无关资源。
|
- 主题目录不包含用户文档、临时文件或无关资源。
|
||||||
- `npm test` 通过。
|
- `npm test` 通过。
|
||||||
- `npm run typecheck` 通过。
|
- `npm run typecheck` 通过。
|
||||||
|
|||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">Markdown PDF Logo</title>
|
||||||
|
<desc id="description">青绿色折角文档与 M 形透明负空间组成的标志</desc>
|
||||||
|
<path
|
||||||
|
fill="#32B8AE"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="
|
||||||
|
M 262 64
|
||||||
|
H 650
|
||||||
|
V 198
|
||||||
|
Q 650 254 706 254
|
||||||
|
H 840
|
||||||
|
V 850
|
||||||
|
Q 840 928 762 928
|
||||||
|
H 262
|
||||||
|
Q 184 928 184 850
|
||||||
|
V 142
|
||||||
|
Q 184 64 262 64
|
||||||
|
Z
|
||||||
|
|
||||||
|
M 282 792
|
||||||
|
V 498
|
||||||
|
Q 282 462 318 462
|
||||||
|
Q 334 462 350 478
|
||||||
|
L 512 640
|
||||||
|
L 674 478
|
||||||
|
Q 690 462 706 462
|
||||||
|
Q 742 462 742 498
|
||||||
|
V 792
|
||||||
|
H 650
|
||||||
|
V 626
|
||||||
|
L 548 728
|
||||||
|
Q 512 764 476 728
|
||||||
|
L 374 626
|
||||||
|
V 792
|
||||||
|
Z
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FFD166"
|
||||||
|
d="
|
||||||
|
M 678 76
|
||||||
|
Q 694 76 710 92
|
||||||
|
L 812 194
|
||||||
|
Q 828 210 828 226
|
||||||
|
H 716
|
||||||
|
Q 678 226 678 188
|
||||||
|
Z
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 953 B |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 330 B |
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 591 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |