release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。 完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。 统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。 发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。 验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
This commit is contained in:
@@ -9,6 +9,7 @@ coverage/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.serena/
|
||||||
tmp/
|
tmp/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ flowchart LR
|
|||||||
- 应用服务:`packages/application`。
|
- 应用服务:`packages/application`。
|
||||||
- 共享模型:`packages/core`。
|
- 共享模型:`packages/core`。
|
||||||
- Markdown 渲染:`packages/renderer`。
|
- Markdown 渲染:`packages/renderer`。
|
||||||
|
- 连续预览与分页引擎:`packages/preview-engine`。
|
||||||
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
||||||
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
||||||
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
||||||
@@ -102,6 +103,7 @@ packages/application/ 共享应用服务、主题注册和图片资源处理
|
|||||||
packages/core/ 导出配置和主题清单等共享模型
|
packages/core/ 导出配置和主题清单等共享模型
|
||||||
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
||||||
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
||||||
|
packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||||
themes/ 内置主题
|
themes/ 内置主题
|
||||||
deploy/ Docker、Compose、Nginx 和部署文档
|
deploy/ Docker、Compose、Nginx 和部署文档
|
||||||
docs/ 进度、架构和部署文档
|
docs/ 进度、架构和部署文档
|
||||||
|
|||||||
@@ -4,23 +4,27 @@
|
|||||||
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
|
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
|
||||||
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
|
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
|
||||||
|
|
||||||
当前版本:`v0.4.5`。
|
当前版本:`v0.5.0`。
|
||||||
|
|
||||||
## v0.4.5 主要能力
|
## v0.5.0 主要能力
|
||||||
|
|
||||||
- 新建、打开和保存 Markdown;新文档可直接导出 PDF;
|
- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速
|
||||||
- Windows 注册 `.md`、`.markdown` 文件关联,系统打开文件时自动加载;
|
预览继续提供分页近似,精确预览和导出 PDF 保持权威分页;
|
||||||
- 桌面窗口位置、尺寸和最大化状态延迟持久化,并在多显示器变化后自动
|
- 分页引擎从 Web 应用抽取为 `@md-to-pdf/preview-engine`,Web、
|
||||||
修正到可见工作区;
|
Playwright PDF 与 Electron PDF 直接复用同一运行时;
|
||||||
- “打开 Markdown”保留为独立主按钮,新建、保存和主题目录收纳在“更多”
|
- 快速预览支持按修改位置复用稳定前缀,只重新分页受影响的后缀;
|
||||||
菜单;
|
- Web 链接只处理当前文档锚点和 HTTP/HTTPS 外链;外链在新浏览器窗口
|
||||||
- 桌面端提供可直接放置自定义主题的本地目录;
|
打开,本地路径及不支持协议不执行导航;
|
||||||
- 图片、Mermaid 和 ECharts 采用串行媒体分页:每个媒体块至多重排一次,
|
- Desktop 支持 HTTP/HTTPS、系统协议、本地相对或绝对路径和 `file:`
|
||||||
严格保持文档顺序;
|
URI;Markdown 链接可选择在当前窗口或新窗口打开;
|
||||||
- 媒体先按内容限宽计算基准高度。当上一页空白接近媒体所需空间时,允许
|
- Desktop 支持多窗口和同一文件单例。窗口聚焦时检查磁盘文件变化,并
|
||||||
将媒体内容等比例缩小回填;图片标题或图表标题保持原字号且与媒体成块;
|
提示是否重新加载;
|
||||||
- 示例文档覆盖横图、竖图、Base64 图片、Mermaid、ECharts 以及不同尺寸
|
- 四个内置主题统一命名为 Typora Github、Typora Pixyll、
|
||||||
的分页边界。
|
Typora whitey 和 Typora Clean,默认使用 Typora Github;
|
||||||
|
- 围栏代码块输出 Typora 兼容的 `.md-fences` DOM,并避免内部
|
||||||
|
`<code>` 重复套用行内代码样式;
|
||||||
|
- 桌面发行链会在每次打包前完整重建内嵌 Web 依赖,避免安装包携带陈旧
|
||||||
|
前端资源。
|
||||||
|
|
||||||
## Web 与桌面端
|
## Web 与桌面端
|
||||||
|
|
||||||
@@ -30,6 +34,11 @@
|
|||||||
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||||
| 保存 Markdown | 浏览器下载 | 原生另存为 |
|
| 保存 Markdown | 浏览器下载 | 原生另存为 |
|
||||||
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||||
|
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
||||||
|
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
||||||
|
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
|
||||||
|
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
|
||||||
|
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
|
||||||
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||||
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||||
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||||
@@ -44,16 +53,18 @@ flowchart LR
|
|||||||
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
||||||
B --> C["统一 #write HTML"]
|
B --> C["统一 #write HTML"]
|
||||||
D["主题 CSS 与导出配置"] --> C
|
D["主题 CSS 与导出配置"] --> C
|
||||||
C --> E["共享分页运行时"]
|
C --> E["共享 Preview Engine"]
|
||||||
E --> F["Web 快速预览"]
|
E --> F["连续预览"]
|
||||||
E --> G["Playwright Chromium"]
|
E --> G["快速分页预览"]
|
||||||
E --> H["Electron Chromium"]
|
E --> H["Playwright Chromium"]
|
||||||
G --> I["Web PDF"]
|
E --> I["Electron Chromium"]
|
||||||
H --> J["Desktop PDF"]
|
H --> J["Web PDF"]
|
||||||
|
I --> K["Desktop PDF"]
|
||||||
```
|
```
|
||||||
|
|
||||||
快速预览适合编辑反馈;精确预览直接展示服务端生成的 PDF,是 Web 最终
|
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
|
||||||
分页的权威结果。桌面 PDF 复用相同分页载荷和运行时。
|
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
|
||||||
|
载荷和运行时。
|
||||||
|
|
||||||
## 项目目录
|
## 项目目录
|
||||||
|
|
||||||
@@ -67,6 +78,7 @@ packages/
|
|||||||
renderer/ Markdown 到安全 #write HTML 的渲染管线
|
renderer/ Markdown 到安全 #write HTML 的渲染管线
|
||||||
application/ 共享渲染、主题注册和图片资源应用服务
|
application/ 共享渲染、主题注册和图片资源应用服务
|
||||||
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
|
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
|
||||||
|
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||||
themes/ 内置主题与主题清单
|
themes/ 内置主题与主题清单
|
||||||
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
||||||
docs/ 项目进度与主题开发说明
|
docs/ 项目进度与主题开发说明
|
||||||
@@ -75,9 +87,10 @@ logos/ 应用图标源文件
|
|||||||
.local/themes/ 本地开发和 Compose 自定义主题目录
|
.local/themes/ 本地开发和 Compose 自定义主题目录
|
||||||
```
|
```
|
||||||
|
|
||||||
包依赖保持单向:`core` 提供基础协议;`markdown-echarts` 提供独立图表
|
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
|
||||||
能力;`renderer` 消费两者生成安全 HTML;`application` 组合渲染器、主题
|
提供独立图表能力;`renderer` 消费两者生成安全 HTML;`application`
|
||||||
和资源用例;三个 `apps/*` 只负责各自平台的界面、传输与 PDF 适配。
|
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
|
||||||
|
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
|
||||||
|
|
||||||
各包的目录和用法:
|
各包的目录和用法:
|
||||||
|
|
||||||
@@ -85,6 +98,7 @@ logos/ 应用图标源文件
|
|||||||
- [core](packages/core/README.md)
|
- [core](packages/core/README.md)
|
||||||
- [renderer](packages/renderer/README.md)
|
- [renderer](packages/renderer/README.md)
|
||||||
- [markdown-echarts](packages/markdown-echarts/README.md)
|
- [markdown-echarts](packages/markdown-echarts/README.md)
|
||||||
|
- [preview-engine](packages/preview-engine/README.md)
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -114,8 +128,9 @@ npm run desktop:package
|
|||||||
npm run make -w @md-to-pdf/desktop
|
npm run make -w @md-to-pdf/desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
产物位于 `apps/desktop/out/v0.4.5/`。正式安装包命名为
|
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能
|
||||||
`md-to-pdf-0.4.5-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/`。
|
||||||
|
正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
||||||
未配置代码签名。
|
未配置代码签名。
|
||||||
|
|
||||||
## 文档与媒体
|
## 文档与媒体
|
||||||
@@ -134,8 +149,9 @@ ECharts 围栏语法、支持系列和安全边界详见
|
|||||||
|
|
||||||
## 主题
|
## 主题
|
||||||
|
|
||||||
仓库内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题。主题由
|
仓库内置 Typora Github、Typora Pixyll、Typora whitey 和
|
||||||
版本化 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
Typora Clean 四套主题,默认使用 Typora Github。主题由版本化
|
||||||
|
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||||
|
|
||||||
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
||||||
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
|
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
|
||||||
|
|||||||
+21
-3
@@ -8,7 +8,11 @@ preload、进程内应用服务、原生 Markdown 打开与保存、同目录图
|
|||||||
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||||
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||||
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||||
双击文件或使用“打开方式”会复用已有应用窗口并加载文档。
|
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
|
||||||
|
一个窗口,其他文件可以同时使用多个窗口。
|
||||||
|
|
||||||
|
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
||||||
|
重新加载,避免阅读或导出磁盘旧版本。
|
||||||
|
|
||||||
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
|
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
|
||||||
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
|
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
|
||||||
@@ -36,9 +40,13 @@ npm run desktop:package
|
|||||||
npm run make -w @md-to-pdf/desktop
|
npm run make -w @md-to-pdf/desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建
|
||||||
|
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web,
|
||||||
|
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
|
||||||
|
|
||||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以
|
`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以
|
||||||
`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
提示。
|
提示。
|
||||||
|
|
||||||
@@ -57,6 +65,16 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
|
|||||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||||
`mdpdf://bundle/` 自定义协议加载。
|
`mdpdf://bundle/` 自定义协议加载。
|
||||||
|
|
||||||
|
## 链接与窗口行为
|
||||||
|
|
||||||
|
- 当前文档 `#anchor` 在当前预览中定位;
|
||||||
|
- HTTP/HTTPS 链接交给系统默认浏览器;
|
||||||
|
- `mailto:` 等系统协议交给操作系统处理;
|
||||||
|
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
|
||||||
|
- Markdown 文件会提示在当前窗口或新窗口打开;
|
||||||
|
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
|
||||||
|
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
|
||||||
|
|
||||||
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||||
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"productName": "Markdown PDF 导出器",
|
"productName": "Markdown PDF 导出器",
|
||||||
"description": "Markdown PDF 导出器桌面端",
|
"description": "Markdown PDF 导出器桌面端",
|
||||||
@@ -9,12 +9,13 @@
|
|||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||||
|
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||||
"build:main": "node scripts/build-main.mjs",
|
"build:main": "node scripts/build-main.mjs",
|
||||||
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||||
"package": "npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
"package": "npm run build:embedded-web && 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",
|
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
DESKTOP_MARKDOWN_OPENED,
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
DESKTOP_OPEN_MARKDOWN,
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_DOCUMENT_LINK,
|
||||||
DESKTOP_OPEN_THEME_DIRECTORY,
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
DESKTOP_REFRESH_THEMES,
|
DESKTOP_REFRESH_THEMES,
|
||||||
DESKTOP_RENDER_MARKDOWN,
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
DESKTOP_SAVE_MARKDOWN,
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
DESKTOP_START_NEW_MARKDOWN,
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
DESKTOP_SAVE_PDF
|
DESKTOP_SAVE_PDF
|
||||||
} from "./channels.js";
|
} from "./channels.js";
|
||||||
@@ -23,14 +25,23 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
|||||||
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
|
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
|
||||||
discardPendingMarkdown: () =>
|
discardPendingMarkdown: () =>
|
||||||
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
|
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
|
||||||
onMarkdownOpened: (listener: () => void) => {
|
onMarkdownOpened: (
|
||||||
const handleOpened = () => listener();
|
listener: (reason: "open" | "reload" | "replace") => void
|
||||||
|
) => {
|
||||||
|
const handleOpened = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
reason: "open" | "reload" | "replace"
|
||||||
|
) => listener(reason);
|
||||||
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
return () =>
|
return () =>
|
||||||
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
},
|
},
|
||||||
startNewMarkdown: () =>
|
startNewMarkdown: () =>
|
||||||
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
||||||
|
setDocumentDirty: (dirty: boolean) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
|
||||||
|
openDocumentLink: (href: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
|
||||||
saveMarkdown: (fileName: string, markdown: string) =>
|
saveMarkdown: (fileName: string, markdown: string) =>
|
||||||
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
||||||
openThemeDirectory: () =>
|
openThemeDirectory: () =>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export const DESKTOP_DISCARD_PENDING_MARKDOWN =
|
|||||||
"md-to-pdf:desktop:discard-pending-markdown";
|
"md-to-pdf:desktop:discard-pending-markdown";
|
||||||
export const DESKTOP_MARKDOWN_OPENED =
|
export const DESKTOP_MARKDOWN_OPENED =
|
||||||
"md-to-pdf:desktop:markdown-opened";
|
"md-to-pdf:desktop:markdown-opened";
|
||||||
|
export const DESKTOP_SET_DOCUMENT_DIRTY =
|
||||||
|
"md-to-pdf:desktop:set-document-dirty";
|
||||||
|
export const DESKTOP_OPEN_DOCUMENT_LINK =
|
||||||
|
"md-to-pdf:desktop:open-document-link";
|
||||||
export const DESKTOP_START_NEW_MARKDOWN =
|
export const DESKTOP_START_NEW_MARKDOWN =
|
||||||
"md-to-pdf:desktop:start-new-markdown";
|
"md-to-pdf:desktop:start-new-markdown";
|
||||||
export const DESKTOP_SAVE_MARKDOWN =
|
export const DESKTOP_SAVE_MARKDOWN =
|
||||||
|
|||||||
@@ -0,0 +1,925 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
screen,
|
||||||
|
shell,
|
||||||
|
type IpcMainInvokeEvent,
|
||||||
|
type Rectangle
|
||||||
|
} from "electron";
|
||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
realpath,
|
||||||
|
stat,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH,
|
||||||
|
type ApplicationService
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_GENERATE_PDF,
|
||||||
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
DESKTOP_LIST_THEMES,
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
|
DESKTOP_OPEN_DOCUMENT_LINK,
|
||||||
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
|
DESKTOP_REFRESH_THEMES,
|
||||||
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_PDF,
|
||||||
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
|
DESKTOP_START_NEW_MARKDOWN
|
||||||
|
} from "./channels.js";
|
||||||
|
import {
|
||||||
|
parseMarkdownRenderRequest,
|
||||||
|
parseThemeId
|
||||||
|
} from "./application-contract.js";
|
||||||
|
import { ElectronPdfGenerator } from "./electron-pdf-generator.js";
|
||||||
|
import { resolveDesktopDocumentLinkAction } from "./desktop-document-link.js";
|
||||||
|
import {
|
||||||
|
isMarkdownFilePath,
|
||||||
|
createMarkdownDocumentKey,
|
||||||
|
readMarkdownFileSnapshot,
|
||||||
|
type MarkdownFileSnapshot
|
||||||
|
} from "./markdown-file.js";
|
||||||
|
import { parsePagedDocumentPayload } from "./pdf-contract.js";
|
||||||
|
import {
|
||||||
|
loadWindowState,
|
||||||
|
resolveWindowBounds,
|
||||||
|
saveWindowState,
|
||||||
|
type WindowState
|
||||||
|
} from "./window-state.js";
|
||||||
|
|
||||||
|
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||||
|
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||||
|
const CASCADE_OFFSET = 32;
|
||||||
|
const FOCUS_CHECK_SUPPRESSION_MS = 800;
|
||||||
|
|
||||||
|
type MarkdownOpenReason = "open" | "reload" | "replace";
|
||||||
|
|
||||||
|
interface PendingOpenedMarkdown {
|
||||||
|
snapshot: MarkdownFileSnapshot;
|
||||||
|
reason: MarkdownOpenReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowSession {
|
||||||
|
window: BrowserWindow;
|
||||||
|
dirty: boolean;
|
||||||
|
allowClose: boolean;
|
||||||
|
currentFilePath: string | undefined;
|
||||||
|
documentKey: string | undefined;
|
||||||
|
documentRoot: string | undefined;
|
||||||
|
baselineHash: string | undefined;
|
||||||
|
pendingOpenedMarkdown: PendingOpenedMarkdown | undefined;
|
||||||
|
focusCheckRunning: boolean;
|
||||||
|
suppressFocusCheckUntil: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopApplicationControllerOptions {
|
||||||
|
applicationService: ApplicationService;
|
||||||
|
applicationUrl: string;
|
||||||
|
renderUrl: string;
|
||||||
|
preloadPath: string;
|
||||||
|
pdfPreloadPath: string;
|
||||||
|
iconPath: string;
|
||||||
|
themeDirectory: string;
|
||||||
|
windowStatePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusWindow(window: BrowserWindow) {
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.isMinimized()) {
|
||||||
|
window.restore();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DesktopApplicationController {
|
||||||
|
readonly #applicationService: ApplicationService;
|
||||||
|
readonly #applicationUrl: string;
|
||||||
|
readonly #applicationOrigin: string;
|
||||||
|
readonly #preloadPath: string;
|
||||||
|
readonly #iconPath: string;
|
||||||
|
readonly #themeDirectory: string;
|
||||||
|
readonly #windowStatePath: string;
|
||||||
|
readonly #pdfGenerator: ElectronPdfGenerator;
|
||||||
|
readonly #sessions = new Map<number, WindowSession>();
|
||||||
|
readonly #documentSessions = new Map<string, WindowSession>();
|
||||||
|
#primarySession: WindowSession | undefined;
|
||||||
|
#savedWindowState: WindowState | undefined;
|
||||||
|
#saveWindowStateTimer: NodeJS.Timeout | undefined;
|
||||||
|
#pendingWindowStateWrite = Promise.resolve();
|
||||||
|
#latestWindowState: WindowState | undefined;
|
||||||
|
|
||||||
|
constructor(options: DesktopApplicationControllerOptions) {
|
||||||
|
this.#applicationService = options.applicationService;
|
||||||
|
this.#applicationUrl = options.applicationUrl;
|
||||||
|
this.#applicationOrigin = new URL(options.applicationUrl).origin;
|
||||||
|
this.#preloadPath = options.preloadPath;
|
||||||
|
this.#iconPath = options.iconPath;
|
||||||
|
this.#themeDirectory = options.themeDirectory;
|
||||||
|
this.#windowStatePath = options.windowStatePath;
|
||||||
|
this.#pdfGenerator = new ElectronPdfGenerator({
|
||||||
|
renderUrl: options.renderUrl,
|
||||||
|
preloadPath: options.pdfPreloadPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this.#savedWindowState = await loadWindowState(
|
||||||
|
this.#windowStatePath
|
||||||
|
);
|
||||||
|
this.#registerIpcHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWindow(filePath?: string) {
|
||||||
|
let pendingSnapshot: MarkdownFileSnapshot | undefined;
|
||||||
|
if (filePath) {
|
||||||
|
pendingSnapshot = await readMarkdownFileSnapshot(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
const existing = this.#findDocumentSession(
|
||||||
|
pendingSnapshot.documentKey
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
focusWindow(existing.window);
|
||||||
|
return existing.window;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.#createWindow(pendingSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #createWindow(pendingSnapshot?: MarkdownFileSnapshot) {
|
||||||
|
const isPrimary = !this.#primarySession;
|
||||||
|
const restoredWindowState = isPrimary
|
||||||
|
? this.#latestWindowState ?? this.#savedWindowState
|
||||||
|
: undefined;
|
||||||
|
const initialBounds = this.#resolveInitialBounds(isPrimary);
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
||||||
|
icon: this.#iconPath,
|
||||||
|
...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: this.#preloadPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const session: WindowSession = {
|
||||||
|
window,
|
||||||
|
dirty: false,
|
||||||
|
allowClose: false,
|
||||||
|
currentFilePath: undefined,
|
||||||
|
documentKey: undefined,
|
||||||
|
documentRoot: undefined,
|
||||||
|
baselineHash: undefined,
|
||||||
|
pendingOpenedMarkdown: pendingSnapshot
|
||||||
|
? { snapshot: pendingSnapshot, reason: "open" }
|
||||||
|
: undefined,
|
||||||
|
focusCheckRunning: false,
|
||||||
|
suppressFocusCheckUntil: 0
|
||||||
|
};
|
||||||
|
this.#sessions.set(window.webContents.id, session);
|
||||||
|
if (isPrimary) {
|
||||||
|
this.#primarySession = session;
|
||||||
|
this.#captureWindowState();
|
||||||
|
this.#bindPrimaryWindowState(session);
|
||||||
|
}
|
||||||
|
this.#lockDownWindow(window);
|
||||||
|
this.#bindWindowLifecycle(session);
|
||||||
|
|
||||||
|
window.once("ready-to-show", () => {
|
||||||
|
if (
|
||||||
|
isPrimary &&
|
||||||
|
restoredWindowState?.maximized &&
|
||||||
|
!window.isDestroyed()
|
||||||
|
) {
|
||||||
|
window.maximize();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
});
|
||||||
|
await window.loadURL(this.#applicationUrl);
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openExternalMarkdown(filePath: string) {
|
||||||
|
const snapshot = await readMarkdownFileSnapshot(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
const existing = this.#findDocumentSession(snapshot.documentKey);
|
||||||
|
if (existing) {
|
||||||
|
focusWindow(existing.window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#createWindow(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
focusMostRecentWindow() {
|
||||||
|
const focused = BrowserWindow.getFocusedWindow();
|
||||||
|
const focusedSession = focused
|
||||||
|
? [...this.#sessions.values()].find(
|
||||||
|
(session) => session.window === focused
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (focusedSession) {
|
||||||
|
focusWindow(focusedSession.window);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const session = [...this.#sessions.values()].at(-1);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
focusWindow(session.window);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushWindowState() {
|
||||||
|
if (this.#saveWindowStateTimer) {
|
||||||
|
clearTimeout(this.#saveWindowStateTimer);
|
||||||
|
this.#saveWindowStateTimer = undefined;
|
||||||
|
}
|
||||||
|
this.#captureWindowState();
|
||||||
|
await this.#persistWindowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeResources() {
|
||||||
|
await this.#pdfGenerator.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
#resolveInitialBounds(isPrimary: boolean): Rectangle {
|
||||||
|
const displays = screen
|
||||||
|
.getAllDisplays()
|
||||||
|
.map((display) => display.workArea);
|
||||||
|
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
|
||||||
|
if (isPrimary) {
|
||||||
|
return resolveWindowBounds(
|
||||||
|
(this.#latestWindowState ?? this.#savedWindowState)?.bounds,
|
||||||
|
displays,
|
||||||
|
primaryWorkArea,
|
||||||
|
DEFAULT_WINDOW_SIZE,
|
||||||
|
MINIMUM_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference =
|
||||||
|
BrowserWindow.getFocusedWindow() ??
|
||||||
|
[...this.#sessions.values()].at(-1)?.window;
|
||||||
|
const referenceBounds =
|
||||||
|
reference && !reference.isDestroyed()
|
||||||
|
? reference.getNormalBounds()
|
||||||
|
: primaryWorkArea;
|
||||||
|
return resolveWindowBounds(
|
||||||
|
{
|
||||||
|
x: referenceBounds.x + CASCADE_OFFSET,
|
||||||
|
y: referenceBounds.y + CASCADE_OFFSET,
|
||||||
|
width: referenceBounds.width,
|
||||||
|
height: referenceBounds.height
|
||||||
|
},
|
||||||
|
displays,
|
||||||
|
primaryWorkArea,
|
||||||
|
DEFAULT_WINDOW_SIZE,
|
||||||
|
MINIMUM_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#lockDownWindow(window: BrowserWindow) {
|
||||||
|
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||||
|
window.webContents.on("will-navigate", (event, targetUrl) => {
|
||||||
|
if (new URL(targetUrl).origin !== this.#applicationOrigin) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const applicationSession = window.webContents.session;
|
||||||
|
applicationSession.setPermissionCheckHandler(() => false);
|
||||||
|
applicationSession.setPermissionRequestHandler(
|
||||||
|
(_webContents, _permission, callback) => callback(false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bindWindowLifecycle(session: WindowSession) {
|
||||||
|
const { window } = session;
|
||||||
|
const webContentsId = window.webContents.id;
|
||||||
|
window.on("focus", () => {
|
||||||
|
void this.#checkForExternalChange(session);
|
||||||
|
});
|
||||||
|
window.on("close", (event) => {
|
||||||
|
if (!session.dirty || session.allowClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void this.#confirmDirtyWindowClose(session);
|
||||||
|
});
|
||||||
|
window.on("closed", () => {
|
||||||
|
const wasPrimary = this.#primarySession === session;
|
||||||
|
this.#sessions.delete(webContentsId);
|
||||||
|
if (
|
||||||
|
session.documentKey &&
|
||||||
|
this.#documentSessions.get(session.documentKey) === session
|
||||||
|
) {
|
||||||
|
this.#documentSessions.delete(session.documentKey);
|
||||||
|
}
|
||||||
|
if (wasPrimary) {
|
||||||
|
this.#promotePrimarySession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#promotePrimarySession() {
|
||||||
|
const nextPrimary = [...this.#sessions.values()]
|
||||||
|
.filter((session) => !session.window.isDestroyed())
|
||||||
|
.at(-1);
|
||||||
|
this.#primarySession = nextPrimary;
|
||||||
|
if (!nextPrimary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#bindPrimaryWindowState(nextPrimary);
|
||||||
|
this.#captureWindowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #confirmDirtyWindowClose(session: WindowSession) {
|
||||||
|
if (session.window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await dialog.showMessageBox(session.window, {
|
||||||
|
type: "warning",
|
||||||
|
title: "关闭未保存文档",
|
||||||
|
message: "当前文档有未保存修改,确定要关闭窗口吗?",
|
||||||
|
detail: "关闭后,尚未保存的修改将会丢失。",
|
||||||
|
buttons: ["取消", "仍然关闭"],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
noLink: true
|
||||||
|
});
|
||||||
|
session.suppressFocusCheckUntil =
|
||||||
|
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
|
||||||
|
if (result.response === 1 && !session.window.isDestroyed()) {
|
||||||
|
session.allowClose = true;
|
||||||
|
session.window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #checkForExternalChange(session: WindowSession) {
|
||||||
|
if (
|
||||||
|
session.focusCheckRunning ||
|
||||||
|
session.window.isDestroyed() ||
|
||||||
|
!session.currentFilePath ||
|
||||||
|
!session.baselineHash ||
|
||||||
|
Date.now() < session.suppressFocusCheckUntil
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.focusCheckRunning = true;
|
||||||
|
try {
|
||||||
|
let snapshot: MarkdownFileSnapshot;
|
||||||
|
try {
|
||||||
|
snapshot = await readMarkdownFileSnapshot(
|
||||||
|
session.currentFilePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await dialog.showMessageBox(session.window, {
|
||||||
|
type: "error",
|
||||||
|
title: "无法检查文件",
|
||||||
|
message: "当前 Markdown 文件已被删除或无法读取。",
|
||||||
|
detail:
|
||||||
|
error instanceof Error ? error.message : "未知文件错误",
|
||||||
|
buttons: ["确定"],
|
||||||
|
defaultId: 0,
|
||||||
|
noLink: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (snapshot.contentHash === session.baselineHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dialog.showMessageBox(session.window, {
|
||||||
|
type: "warning",
|
||||||
|
title: "文件已在外部修改",
|
||||||
|
message: session.dirty
|
||||||
|
? "文件已在外部修改,重新载入会丢弃当前未保存内容。"
|
||||||
|
: "文件已在外部修改,是否重新载入?",
|
||||||
|
detail: snapshot.filePath,
|
||||||
|
buttons: ["暂不载入", "重新载入"],
|
||||||
|
defaultId: 1,
|
||||||
|
cancelId: 0,
|
||||||
|
noLink: true
|
||||||
|
});
|
||||||
|
if (result.response === 1) {
|
||||||
|
session.baselineHash = snapshot.contentHash;
|
||||||
|
session.pendingOpenedMarkdown = {
|
||||||
|
snapshot,
|
||||||
|
reason: "reload"
|
||||||
|
};
|
||||||
|
session.window.webContents.send(
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
|
"reload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
session.focusCheckRunning = false;
|
||||||
|
session.suppressFocusCheckUntil =
|
||||||
|
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#bindPrimaryWindowState(session: WindowSession) {
|
||||||
|
const schedule = () => {
|
||||||
|
this.#captureWindowState();
|
||||||
|
if (this.#saveWindowStateTimer) {
|
||||||
|
clearTimeout(this.#saveWindowStateTimer);
|
||||||
|
}
|
||||||
|
this.#saveWindowStateTimer = setTimeout(() => {
|
||||||
|
this.#saveWindowStateTimer = undefined;
|
||||||
|
void this.#persistWindowState();
|
||||||
|
}, 2_000);
|
||||||
|
};
|
||||||
|
session.window.on("move", schedule);
|
||||||
|
session.window.on("resize", schedule);
|
||||||
|
session.window.on("maximize", schedule);
|
||||||
|
session.window.on("unmaximize", schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
#captureWindowState() {
|
||||||
|
const window = this.#primarySession?.window;
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#latestWindowState = {
|
||||||
|
version: 1,
|
||||||
|
bounds: window.getNormalBounds(),
|
||||||
|
maximized: window.isMaximized()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#persistWindowState() {
|
||||||
|
if (!this.#latestWindowState) {
|
||||||
|
return this.#pendingWindowStateWrite;
|
||||||
|
}
|
||||||
|
const state = structuredClone(this.#latestWindowState);
|
||||||
|
this.#pendingWindowStateWrite = this.#pendingWindowStateWrite
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => saveWindowState(this.#windowStatePath, state))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn("无法保存窗口状态", error);
|
||||||
|
});
|
||||||
|
return this.#pendingWindowStateWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#findDocumentSession(documentKey: string) {
|
||||||
|
const committed = this.#documentSessions.get(documentKey);
|
||||||
|
if (committed) {
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
return [...this.#sessions.values()].find(
|
||||||
|
(session) =>
|
||||||
|
session.pendingOpenedMarkdown?.snapshot.documentKey ===
|
||||||
|
documentKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#getSession(event: IpcMainInvokeEvent) {
|
||||||
|
const session = this.#sessions.get(event.sender.id);
|
||||||
|
let origin: string | undefined;
|
||||||
|
try {
|
||||||
|
origin = event.senderFrame
|
||||||
|
? new URL(event.senderFrame.url).origin
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
origin = undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
event.sender !== session.window.webContents ||
|
||||||
|
origin !== this.#applicationOrigin
|
||||||
|
) {
|
||||||
|
throw new Error("拒绝未授权的桌面请求");
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
#commitSnapshot(
|
||||||
|
session: WindowSession,
|
||||||
|
snapshot: MarkdownFileSnapshot
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
session.documentKey &&
|
||||||
|
this.#documentSessions.get(session.documentKey) === session
|
||||||
|
) {
|
||||||
|
this.#documentSessions.delete(session.documentKey);
|
||||||
|
}
|
||||||
|
session.currentFilePath = snapshot.filePath;
|
||||||
|
session.documentKey = snapshot.documentKey;
|
||||||
|
session.documentRoot = path.dirname(snapshot.filePath);
|
||||||
|
session.baselineHash = snapshot.contentHash;
|
||||||
|
session.dirty = false;
|
||||||
|
this.#documentSessions.set(snapshot.documentKey, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearDocument(session: WindowSession) {
|
||||||
|
if (
|
||||||
|
session.documentKey &&
|
||||||
|
this.#documentSessions.get(session.documentKey) === session
|
||||||
|
) {
|
||||||
|
this.#documentSessions.delete(session.documentKey);
|
||||||
|
}
|
||||||
|
session.currentFilePath = undefined;
|
||||||
|
session.documentKey = undefined;
|
||||||
|
session.documentRoot = undefined;
|
||||||
|
session.baselineHash = undefined;
|
||||||
|
session.pendingOpenedMarkdown = undefined;
|
||||||
|
session.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#registerIpcHandlers() {
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_OPEN_DOCUMENT_LINK,
|
||||||
|
async (event, unsafeHref: unknown) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
if (
|
||||||
|
typeof unsafeHref !== "string" ||
|
||||||
|
unsafeHref.length === 0 ||
|
||||||
|
unsafeHref.length > 8_192
|
||||||
|
) {
|
||||||
|
throw new Error("文档链接无效");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.#openDocumentLink(session, unsafeHref);
|
||||||
|
} catch (error) {
|
||||||
|
await dialog.showMessageBox(session.window, {
|
||||||
|
type: "error",
|
||||||
|
title: "无法打开链接",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "无法打开文档链接",
|
||||||
|
detail: unsafeHref,
|
||||||
|
buttons: ["确定"],
|
||||||
|
defaultId: 0,
|
||||||
|
noLink: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
const selection = await dialog.showOpenDialog(session.window, {
|
||||||
|
title: "打开 Markdown",
|
||||||
|
properties: ["openFile"],
|
||||||
|
filters: [
|
||||||
|
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const filePath = selection.filePaths[0];
|
||||||
|
if (selection.canceled || !filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const snapshot = await readMarkdownFileSnapshot(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
const existing = this.#findDocumentSession(snapshot.documentKey);
|
||||||
|
if (existing && existing !== session) {
|
||||||
|
focusWindow(existing.window);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.#commitSnapshot(session, snapshot);
|
||||||
|
return snapshot.document;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
const pending = session.pendingOpenedMarkdown;
|
||||||
|
session.pendingOpenedMarkdown = undefined;
|
||||||
|
if (!pending) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this.#commitSnapshot(session, pending.snapshot);
|
||||||
|
return pending.snapshot.document;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
session.pendingOpenedMarkdown = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
|
||||||
|
this.#clearDocument(this.#getSession(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
|
async (event, unsafeDirty: unknown) => {
|
||||||
|
if (typeof unsafeDirty !== "boolean") {
|
||||||
|
throw new Error("文档修改状态无效");
|
||||||
|
}
|
||||||
|
this.#getSession(event).dirty = unsafeDirty;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
unsafeFileName: unknown,
|
||||||
|
unsafeMarkdown: unknown
|
||||||
|
) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
if (
|
||||||
|
typeof unsafeFileName !== "string" ||
|
||||||
|
unsafeFileName.length === 0 ||
|
||||||
|
unsafeFileName.length > 500 ||
|
||||||
|
typeof unsafeMarkdown !== "string" ||
|
||||||
|
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
) {
|
||||||
|
throw new Error("Markdown 保存参数无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPath = session.currentFilePath;
|
||||||
|
if (!targetPath) {
|
||||||
|
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
||||||
|
path.basename(unsafeFileName)
|
||||||
|
)
|
||||||
|
? path.basename(unsafeFileName)
|
||||||
|
: `${path.basename(unsafeFileName)}.md`;
|
||||||
|
const selection = await dialog.showSaveDialog(session.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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetKey = createMarkdownDocumentKey(
|
||||||
|
path.resolve(targetPath)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
targetKey = createMarkdownDocumentKey(
|
||||||
|
await realpath(targetPath)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
error instanceof Error &&
|
||||||
|
"code" in error &&
|
||||||
|
error.code === "ENOENT"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existing = this.#findDocumentSession(targetKey);
|
||||||
|
if (existing && existing !== session) {
|
||||||
|
focusWindow(existing.window);
|
||||||
|
throw new Error("该 Markdown 已在另一个窗口中打开");
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(targetPath, unsafeMarkdown, "utf8");
|
||||||
|
const snapshot = await readMarkdownFileSnapshot(
|
||||||
|
targetPath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
const canonicalExisting = this.#findDocumentSession(
|
||||||
|
snapshot.documentKey
|
||||||
|
);
|
||||||
|
if (canonicalExisting && canonicalExisting !== session) {
|
||||||
|
focusWindow(canonicalExisting.window);
|
||||||
|
throw new Error("该 Markdown 已在另一个窗口中打开");
|
||||||
|
}
|
||||||
|
this.#commitSnapshot(session, snapshot);
|
||||||
|
return { fileName: snapshot.document.fileName };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
|
||||||
|
this.#getSession(event);
|
||||||
|
await mkdir(this.#themeDirectory, { recursive: true });
|
||||||
|
const errorMessage = await shell.openPath(this.#themeDirectory);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(
|
||||||
|
`无法打开自定义主题目录:${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.#themeDirectory;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
|
||||||
|
this.#getSession(event);
|
||||||
|
this.#applicationService.invalidateThemes();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
async (event, unsafeRequest: unknown) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
return this.#applicationService.render(
|
||||||
|
parseMarkdownRenderRequest(unsafeRequest),
|
||||||
|
session.documentRoot
|
||||||
|
? {
|
||||||
|
localRoot: session.documentRoot,
|
||||||
|
allowUnrestrictedLocalFiles: true
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
|
||||||
|
this.#getSession(event);
|
||||||
|
return this.#applicationService.listThemes();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
async (event, unsafeThemeId: unknown) => {
|
||||||
|
this.#getSession(event);
|
||||||
|
const css = await this.#applicationService.getThemeCss(
|
||||||
|
parseThemeId(unsafeThemeId)
|
||||||
|
);
|
||||||
|
if (css === undefined) {
|
||||||
|
throw new Error("未找到指定主题");
|
||||||
|
}
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_GENERATE_PDF,
|
||||||
|
async (event, unsafePayload: unknown) => {
|
||||||
|
this.#getSession(event);
|
||||||
|
const result = await this.#pdfGenerator.generate(
|
||||||
|
parsePagedDocumentPayload(unsafePayload)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
pdf: result.pdf,
|
||||||
|
pageCount: result.pageCount,
|
||||||
|
echartsErrors: result.echartsErrors,
|
||||||
|
mermaidErrors: result.mermaidErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_PDF,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
unsafeFileName: unknown,
|
||||||
|
unsafePdf: unknown
|
||||||
|
) => {
|
||||||
|
const session = this.#getSession(event);
|
||||||
|
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(session.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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #openDocumentLink(
|
||||||
|
session: WindowSession,
|
||||||
|
href: string
|
||||||
|
) {
|
||||||
|
const action = resolveDesktopDocumentLinkAction(
|
||||||
|
href,
|
||||||
|
session.currentFilePath
|
||||||
|
);
|
||||||
|
if (action.type === "ignore" || action.type === "anchor") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === "external") {
|
||||||
|
await shell.openExternal(action.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetStats;
|
||||||
|
try {
|
||||||
|
targetStats = await stat(action.filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
"code" in error &&
|
||||||
|
error.code === "ENOENT"
|
||||||
|
) {
|
||||||
|
await dialog.showMessageBox(session.window, {
|
||||||
|
type: "warning",
|
||||||
|
title: "找不到文件",
|
||||||
|
message: "链接指向的本地文件不存在。",
|
||||||
|
detail: action.filePath,
|
||||||
|
buttons: ["确定"],
|
||||||
|
defaultId: 0,
|
||||||
|
noLink: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetStats.isFile() &&
|
||||||
|
isMarkdownFilePath(action.filePath)
|
||||||
|
) {
|
||||||
|
const snapshot = await readMarkdownFileSnapshot(
|
||||||
|
action.filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
const existing = this.#findDocumentSession(
|
||||||
|
snapshot.documentKey
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
focusWindow(existing.window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await dialog.showMessageBox(session.window, {
|
||||||
|
type: "question",
|
||||||
|
title: "打开 Markdown",
|
||||||
|
message: `如何打开 ${snapshot.document.fileName}?`,
|
||||||
|
detail: session.dirty
|
||||||
|
? "当前窗口有未保存修改;选择当前窗口会放弃这些修改。"
|
||||||
|
: snapshot.filePath,
|
||||||
|
buttons: ["取消", "当前窗口打开", "新窗口打开"],
|
||||||
|
defaultId: 2,
|
||||||
|
cancelId: 0,
|
||||||
|
noLink: true
|
||||||
|
});
|
||||||
|
if (result.response === 1) {
|
||||||
|
session.pendingOpenedMarkdown = {
|
||||||
|
snapshot,
|
||||||
|
reason: "replace"
|
||||||
|
};
|
||||||
|
session.window.webContents.send(
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
|
"replace"
|
||||||
|
);
|
||||||
|
} else if (result.response === 2) {
|
||||||
|
await this.#createWindow(snapshot);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = await shell.openPath(action.filePath);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(`系统无法打开该路径:${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export type DesktopDocumentLinkAction =
|
||||||
|
| {
|
||||||
|
type: "anchor";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "external";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "local";
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ignore";
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeLocalPath(href: string) {
|
||||||
|
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(pathOnly);
|
||||||
|
} catch {
|
||||||
|
throw new Error("本地链接的 URL 编码无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDesktopDocumentLinkAction(
|
||||||
|
href: string,
|
||||||
|
markdownFilePath?: string
|
||||||
|
): DesktopDocumentLinkAction {
|
||||||
|
const link = classifyDocumentLink(href);
|
||||||
|
if (link.kind === "anchor") {
|
||||||
|
return { type: "anchor", href: link.normalizedHref };
|
||||||
|
}
|
||||||
|
if (link.kind === "network" || link.kind === "protocol") {
|
||||||
|
return { type: "external", href: link.normalizedHref };
|
||||||
|
}
|
||||||
|
if (link.kind !== "local") {
|
||||||
|
return { type: "ignore" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.scheme === "file") {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
type: "local",
|
||||||
|
filePath: path.normalize(fileURLToPath(new URL(link.href)))
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new Error("file: 链接格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeLocalPath(link.href);
|
||||||
|
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
|
||||||
|
return { type: "local", filePath: path.normalize(decoded) };
|
||||||
|
}
|
||||||
|
if (!markdownFilePath) {
|
||||||
|
throw new Error("当前文档尚未保存,无法解析相对本地链接");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "local",
|
||||||
|
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
|
||||||
|
};
|
||||||
|
}
|
||||||
+53
-451
@@ -1,79 +1,32 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
|
||||||
dialog,
|
|
||||||
ipcMain,
|
|
||||||
Menu,
|
Menu,
|
||||||
net,
|
net,
|
||||||
protocol,
|
protocol,
|
||||||
screen,
|
session
|
||||||
session,
|
|
||||||
shell,
|
|
||||||
type IpcMainInvokeEvent
|
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import {
|
import { access, mkdir } from "node:fs/promises";
|
||||||
access,
|
|
||||||
mkdir,
|
|
||||||
writeFile
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
createApplicationService,
|
createApplicationService,
|
||||||
MAXIMUM_MARKDOWN_LENGTH,
|
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
|
import { parseThemeResourceUrl } from "./application-contract.js";
|
||||||
import {
|
import {
|
||||||
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
DesktopApplicationController
|
||||||
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
} from "./desktop-application-controller.js";
|
||||||
DESKTOP_GET_THEME_CSS,
|
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||||
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 {
|
import {
|
||||||
findMarkdownFileArgument,
|
findMarkdownFileArgument,
|
||||||
isMarkdownFilePath,
|
isMarkdownFilePath
|
||||||
readMarkdownDocument,
|
|
||||||
type OpenedMarkdownDocument
|
|
||||||
} from "./markdown-file.js";
|
} from "./markdown-file.js";
|
||||||
import {
|
|
||||||
loadWindowState,
|
|
||||||
resolveWindowBounds,
|
|
||||||
saveWindowState,
|
|
||||||
type WindowState
|
|
||||||
} from "./window-state.js";
|
|
||||||
|
|
||||||
const APP_SCHEME = "mdpdf";
|
const APP_SCHEME = "mdpdf";
|
||||||
const APP_HOST = "bundle";
|
const APP_HOST = "bundle";
|
||||||
const THEME_HOST = "theme";
|
const THEME_HOST = "theme";
|
||||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
let applicationController: DesktopApplicationController | undefined;
|
||||||
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
|
||||||
let applicationController:
|
|
||||||
| {
|
|
||||||
window: BrowserWindow;
|
|
||||||
openMarkdownFile(filePath: string): Promise<void>;
|
|
||||||
flushWindowState(): Promise<void>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||||
process.argv,
|
process.argv,
|
||||||
process.cwd()
|
process.cwd()
|
||||||
@@ -130,29 +83,16 @@ function encodeThemeAssetPath(assetPath: string) {
|
|||||||
.join("/");
|
.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) {
|
function dispatchExternalMarkdown(filePath: string) {
|
||||||
if (!applicationController) {
|
if (!applicationController) {
|
||||||
pendingExternalMarkdownPath = filePath;
|
pendingExternalMarkdownPath = filePath;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void applicationController.openMarkdownFile(filePath).catch(
|
void applicationController.openExternalMarkdown(filePath).catch(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
console.warn("无法打开系统传入的 Markdown 文件", error);
|
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
focusApplicationWindow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDesktopThemeDirectory() {
|
function getDesktopThemeDirectory() {
|
||||||
@@ -226,12 +166,10 @@ async function registerApplicationProtocol(
|
|||||||
} catch {
|
} catch {
|
||||||
return new Response("Bad request", { status: 400 });
|
return new Response("Bad request", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = path.resolve(webRoot, relativePath);
|
const target = path.resolve(webRoot, relativePath);
|
||||||
if (!isContainedPath(webRoot, target)) {
|
if (!isContainedPath(webRoot, target)) {
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await access(target);
|
await access(target);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -248,376 +186,6 @@ async function registerApplicationProtocol(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
let windowStateReadyToQuit = false;
|
let windowStateReadyToQuit = false;
|
||||||
|
|
||||||
@@ -631,8 +199,8 @@ if (!hasSingleInstanceLock) {
|
|||||||
);
|
);
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
dispatchExternalMarkdown(filePath);
|
dispatchExternalMarkdown(filePath);
|
||||||
} else {
|
} else if (!applicationController?.focusMostRecentWindow()) {
|
||||||
focusApplicationWindow();
|
void applicationController?.createWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.on("open-file", (event, filePath) => {
|
app.on("open-file", (event, filePath) => {
|
||||||
@@ -641,6 +209,11 @@ if (!hasSingleInstanceLock) {
|
|||||||
dispatchExternalMarkdown(filePath);
|
dispatchExternalMarkdown(filePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (!applicationController?.focusMostRecentWindow()) {
|
||||||
|
void applicationController?.createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
app.on("before-quit", (event) => {
|
app.on("before-quit", (event) => {
|
||||||
if (windowStateReadyToQuit || !applicationController) {
|
if (windowStateReadyToQuit || !applicationController) {
|
||||||
return;
|
return;
|
||||||
@@ -649,8 +222,14 @@ if (!hasSingleInstanceLock) {
|
|||||||
void applicationController.flushWindowState().finally(() => {
|
void applicationController.flushWindowState().finally(() => {
|
||||||
windowStateReadyToQuit = true;
|
windowStateReadyToQuit = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
|
setTimeout(() => {
|
||||||
|
windowStateReadyToQuit = false;
|
||||||
|
}, 1_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
void applicationController?.closeResources();
|
||||||
|
});
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -658,18 +237,41 @@ if (!hasSingleInstanceLock) {
|
|||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||||
}
|
}
|
||||||
await mkdir(getDesktopThemeDirectory(), { recursive: true });
|
const themeDirectory = getDesktopThemeDirectory();
|
||||||
|
await mkdir(themeDirectory, { recursive: true });
|
||||||
const applicationService = createDesktopApplicationService();
|
const applicationService = createDesktopApplicationService();
|
||||||
await registerApplicationProtocol(applicationService);
|
await registerApplicationProtocol(applicationService);
|
||||||
applicationController =
|
|
||||||
await createApplication(applicationService);
|
const developmentUrl = getCommandLineValue("web-url");
|
||||||
|
const applicationUrl =
|
||||||
|
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
||||||
|
applicationController = new DesktopApplicationController({
|
||||||
|
applicationService,
|
||||||
|
applicationUrl,
|
||||||
|
renderUrl: new URL(
|
||||||
|
"/preview-frame.html?target=pdf",
|
||||||
|
applicationUrl
|
||||||
|
).href,
|
||||||
|
preloadPath: path.join(
|
||||||
|
currentDirectory,
|
||||||
|
"app-preload.cjs"
|
||||||
|
),
|
||||||
|
pdfPreloadPath: path.join(
|
||||||
|
currentDirectory,
|
||||||
|
"pdf-preload.cjs"
|
||||||
|
),
|
||||||
|
iconPath: getApplicationIcon(),
|
||||||
|
themeDirectory,
|
||||||
|
windowStatePath: path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"window-state.json"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
await applicationController.initialize();
|
||||||
|
|
||||||
const startupMarkdownPath = pendingExternalMarkdownPath;
|
const startupMarkdownPath = pendingExternalMarkdownPath;
|
||||||
pendingExternalMarkdownPath = undefined;
|
pendingExternalMarkdownPath = undefined;
|
||||||
if (startupMarkdownPath) {
|
await applicationController.createWindow(startupMarkdownPath);
|
||||||
await applicationController.openMarkdownFile(
|
|
||||||
startupMarkdownPath
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.error("桌面应用启动失败", error);
|
console.error("桌面应用启动失败", error);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readFile, stat } from "node:fs/promises";
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, realpath, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const markdownExtensions = new Set([".md", ".markdown"]);
|
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||||
@@ -8,6 +9,24 @@ export interface OpenedMarkdownDocument {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MarkdownFileSnapshot {
|
||||||
|
document: OpenedMarkdownDocument;
|
||||||
|
filePath: string;
|
||||||
|
documentKey: string;
|
||||||
|
contentHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownContentHash(markdown: string) {
|
||||||
|
return createHash("sha256").update(markdown, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownDocumentKey(filePath: string) {
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
|
return process.platform === "win32"
|
||||||
|
? normalized.toLocaleLowerCase("en-US")
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function isMarkdownFilePath(filePath: string) {
|
export function isMarkdownFilePath(filePath: string) {
|
||||||
return markdownExtensions.has(path.extname(filePath).toLowerCase());
|
return markdownExtensions.has(path.extname(filePath).toLowerCase());
|
||||||
}
|
}
|
||||||
@@ -58,3 +77,20 @@ export async function readMarkdownDocument(
|
|||||||
fileName: path.basename(filePath)
|
fileName: path.basename(filePath)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readMarkdownFileSnapshot(
|
||||||
|
filePath: string,
|
||||||
|
maximumLength: number
|
||||||
|
): Promise<MarkdownFileSnapshot> {
|
||||||
|
const canonicalPath = await realpath(filePath);
|
||||||
|
const document = await readMarkdownDocument(
|
||||||
|
canonicalPath,
|
||||||
|
maximumLength
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
filePath: canonicalPath,
|
||||||
|
documentKey: createMarkdownDocumentKey(canonicalPath),
|
||||||
|
contentHash: createMarkdownContentHash(document.markdown)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
|
||||||
|
|
||||||
|
describe("桌面文档链接动作", () => {
|
||||||
|
const markdownPath = path.resolve("D:/文档/章节/当前.md");
|
||||||
|
|
||||||
|
it("保留锚点并把网络和自定义协议交给系统", () => {
|
||||||
|
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
|
||||||
|
type: "anchor",
|
||||||
|
href: "#标题"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("//example.com/path")
|
||||||
|
).toEqual({
|
||||||
|
type: "external",
|
||||||
|
href: "https://example.com/path"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("mailto:user@example.com")
|
||||||
|
).toEqual({
|
||||||
|
type: "external",
|
||||||
|
href: "mailto:user@example.com"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
|
||||||
|
).toEqual({
|
||||||
|
type: "external",
|
||||||
|
href: "obsidian://open?vault=docs"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("允许相对路径越过 Markdown 目录", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(
|
||||||
|
"../../共享/说明.pdf#page=2",
|
||||||
|
markdownPath
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.resolve(
|
||||||
|
path.dirname(markdownPath),
|
||||||
|
"../../共享/说明.pdf"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("解析绝对路径、file URI 和 URL 编码", () => {
|
||||||
|
const absolutePath = path.resolve("C:/资料/项目说明.md");
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.normalize(absolutePath)
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(
|
||||||
|
pathToFileURL(absolutePath).href,
|
||||||
|
markdownPath
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.normalize(absolutePath)
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(
|
||||||
|
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
|
||||||
|
markdownPath
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.resolve(
|
||||||
|
path.dirname(markdownPath),
|
||||||
|
"../共享/说明.pdf"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝危险链接和无文件基准的相对路径", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("javascript:alert(1)")
|
||||||
|
).toEqual({ type: "ignore" });
|
||||||
|
expect(() =>
|
||||||
|
resolveDesktopDocumentLinkAction("../说明.pdf")
|
||||||
|
).toThrow("尚未保存");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,12 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
createMarkdownContentHash,
|
||||||
|
createMarkdownDocumentKey,
|
||||||
findMarkdownFileArgument,
|
findMarkdownFileArgument,
|
||||||
isMarkdownFilePath,
|
isMarkdownFilePath,
|
||||||
readMarkdownDocument
|
readMarkdownDocument,
|
||||||
|
readMarkdownFileSnapshot
|
||||||
} from "../src/markdown-file.js";
|
} from "../src/markdown-file.js";
|
||||||
|
|
||||||
describe("桌面 Markdown 文件参数", () => {
|
describe("桌面 Markdown 文件参数", () => {
|
||||||
@@ -72,4 +75,33 @@ describe("桌面 Markdown 文件参数", () => {
|
|||||||
readMarkdownDocument("C:\\docs\\说明.txt", 100)
|
readMarkdownDocument("C:\\docs\\说明.txt", 100)
|
||||||
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
|
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("使用真实路径和内容哈希创建文件快照", async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
|
||||||
|
);
|
||||||
|
const filePath = path.join(directory, "快照.md");
|
||||||
|
try {
|
||||||
|
await writeFile(filePath, "# 第一版", "utf8");
|
||||||
|
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
||||||
|
|
||||||
|
expect(snapshot.document).toEqual({
|
||||||
|
markdown: "# 第一版",
|
||||||
|
fileName: "快照.md"
|
||||||
|
});
|
||||||
|
expect(snapshot.filePath).toBe(filePath);
|
||||||
|
expect(snapshot.documentKey).toBe(
|
||||||
|
createMarkdownDocumentKey(filePath)
|
||||||
|
);
|
||||||
|
expect(snapshot.contentHash).toBe(
|
||||||
|
createMarkdownContentHash("# 第一版")
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(filePath, "# 第二版", "utf8");
|
||||||
|
const changed = await readMarkdownFileSnapshot(filePath, 100);
|
||||||
|
expect(changed.contentHash).not.toBe(snapshot.contentHash);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
interface PackageManifest {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||||
|
|
||||||
|
function readManifest(path: string): PackageManifest {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("桌面发行构建链", () => {
|
||||||
|
it("打包前按依赖顺序完整重建内嵌 Web", () => {
|
||||||
|
const rootManifest = readManifest(`${projectRoot}/package.json`);
|
||||||
|
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
|
||||||
|
const webBuild = rootManifest.scripts?.["build:web-runtime"] ?? "";
|
||||||
|
const expectedWorkspaces = [
|
||||||
|
"@md-to-pdf/markdown-echarts",
|
||||||
|
"@md-to-pdf/core",
|
||||||
|
"@md-to-pdf/renderer",
|
||||||
|
"@md-to-pdf/application",
|
||||||
|
"@md-to-pdf/preview-engine",
|
||||||
|
"@md-to-pdf/web"
|
||||||
|
];
|
||||||
|
|
||||||
|
let previousIndex = -1;
|
||||||
|
for (const workspace of expectedWorkspaces) {
|
||||||
|
const workspaceIndex = webBuild.indexOf(workspace);
|
||||||
|
expect(workspaceIndex).toBeGreaterThan(previousIndex);
|
||||||
|
previousIndex = workspaceIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(desktopManifest.scripts?.["build:embedded-web"]).toBe(
|
||||||
|
"npm --prefix ../.. run build:web-runtime"
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.package).toMatch(
|
||||||
|
/^npm run build:embedded-web && /
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.make).toMatch(
|
||||||
|
/^npm run build:embedded-web && /
|
||||||
|
);
|
||||||
|
expect(rootManifest.scripts?.["desktop:package"]).toBe(
|
||||||
|
"npm run package -w @md-to-pdf/desktop"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,11 +12,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
"@mermaid-js/layout-elk": "0.2.2",
|
"@md-to-pdf/preview-engine": "0.1.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"mermaid": "11.16.0",
|
|
||||||
"pagedjs": "^0.4.3",
|
|
||||||
"pdfjs-dist": "6.1.200",
|
"pdfjs-dist": "6.1.200",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
|
|||||||
+151
-13
@@ -27,7 +27,7 @@ import {
|
|||||||
createPagedPreviewRenderRequest,
|
createPagedPreviewRenderRequest,
|
||||||
isPagedPreviewFrameMessage,
|
isPagedPreviewFrameMessage,
|
||||||
type PagedPreviewPayload
|
type PagedPreviewPayload
|
||||||
} from "./paged-preview";
|
} from "@md-to-pdf/preview-engine";
|
||||||
import {
|
import {
|
||||||
getNearestPageNumber,
|
getNearestPageNumber,
|
||||||
getPreviewPageScrollTop
|
getPreviewPageScrollTop
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
downloadMarkdown,
|
downloadMarkdown,
|
||||||
ensureMarkdownFileName
|
ensureMarkdownFileName
|
||||||
} from "./document-file";
|
} from "./document-file";
|
||||||
|
import { resolveWebDocumentLinkAction } from "./document-link";
|
||||||
import {
|
import {
|
||||||
type ThemeSummary,
|
type ThemeSummary,
|
||||||
useThemeResources
|
useThemeResources
|
||||||
@@ -64,7 +65,7 @@ const PrecisePdfPreview = lazy(async () => {
|
|||||||
return { default: module.PrecisePdfPreview };
|
return { default: module.PrecisePdfPreview };
|
||||||
});
|
});
|
||||||
|
|
||||||
type PreviewMode = "quick" | "precise";
|
type PreviewMode = "quick" | "precise" | "continuous";
|
||||||
type DocumentKind = "sample" | "new" | "file";
|
type DocumentKind = "sample" | "new" | "file";
|
||||||
const noImageResources: never[] = [];
|
const noImageResources: never[] = [];
|
||||||
|
|
||||||
@@ -121,8 +122,11 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
const consumeOpenedMarkdown = async () => {
|
const consumeOpenedMarkdown = async (
|
||||||
|
reason: "open" | "reload" | "replace" = "open"
|
||||||
|
) => {
|
||||||
if (
|
if (
|
||||||
|
reason === "open" &&
|
||||||
documentDirtyRef.current &&
|
documentDirtyRef.current &&
|
||||||
!window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?")
|
!window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?")
|
||||||
) {
|
) {
|
||||||
@@ -160,8 +164,8 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const removeListener = bridge.onMarkdownOpened(() => {
|
const removeListener = bridge.onMarkdownOpened((reason) => {
|
||||||
void consumeOpenedMarkdown();
|
void consumeOpenedMarkdown(reason);
|
||||||
});
|
});
|
||||||
void consumeOpenedMarkdown();
|
void consumeOpenedMarkdown();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -170,6 +174,18 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bridge = window.mdToPdfDesktop;
|
||||||
|
if (!bridge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void bridge.setDocumentDirty(documentDirty).catch(
|
||||||
|
(reason: unknown) => {
|
||||||
|
console.warn("无法同步桌面文档修改状态", reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [documentDirty]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!moreMenuOpen) {
|
if (!moreMenuOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -359,6 +375,11 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.data.type === "link") {
|
||||||
|
handlePagedPreviewLink(event.data.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.data.requestId !== previewRequestIdRef.current) {
|
if (event.data.requestId !== previewRequestIdRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -369,7 +390,11 @@ export function App() {
|
|||||||
setMermaidError("");
|
setMermaidError("");
|
||||||
setQuickPreviewContentHeight(undefined);
|
setQuickPreviewContentHeight(undefined);
|
||||||
setPreviewPagination({ current: 0, total: 0 });
|
setPreviewPagination({ current: 0, total: 0 });
|
||||||
setStatus("正在分页…");
|
setStatus(
|
||||||
|
event.data.layout === "continuous"
|
||||||
|
? "正在更新连续预览…"
|
||||||
|
: "正在分页…"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,12 +414,20 @@ export function App() {
|
|||||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
setStatus(
|
||||||
|
event.data.layout === "continuous"
|
||||||
|
? "连续预览已更新"
|
||||||
|
: `预览已分页(${event.data.pageCount} 页)`
|
||||||
|
);
|
||||||
setQuickPreviewContentHeight(event.data.contentHeight);
|
setQuickPreviewContentHeight(event.data.contentHeight);
|
||||||
setPreviewPagination({
|
setPreviewPagination(
|
||||||
|
event.data.layout === "continuous"
|
||||||
|
? { current: 0, total: 0 }
|
||||||
|
: {
|
||||||
current: event.data.pageCount > 0 ? 1 : 0,
|
current: event.data.pageCount > 0 ? 1 : 0,
|
||||||
total: event.data.pageCount
|
total: event.data.pageCount
|
||||||
});
|
}
|
||||||
|
);
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
synchronizeScroll("editor");
|
synchronizeScroll("editor");
|
||||||
window.requestAnimationFrame(updatePreviewCurrentPage);
|
window.requestAnimationFrame(updatePreviewCurrentPage);
|
||||||
@@ -435,11 +468,14 @@ export function App() {
|
|||||||
frameWindow.postMessage(
|
frameWindow.postMessage(
|
||||||
createPagedPreviewRenderRequest(
|
createPagedPreviewRenderRequest(
|
||||||
previewRequestIdRef.current,
|
previewRequestIdRef.current,
|
||||||
previewPayload
|
previewPayload,
|
||||||
|
previewMode === "continuous"
|
||||||
|
? "continuous"
|
||||||
|
: "paged"
|
||||||
),
|
),
|
||||||
window.location.origin
|
window.location.origin
|
||||||
);
|
);
|
||||||
}, [previewFrameReady, previewPayload]);
|
}, [previewFrameReady, previewMode, previewPayload]);
|
||||||
|
|
||||||
function getOuterPreviewScroller() {
|
function getOuterPreviewScroller() {
|
||||||
const previewScroller = previewScrollRef.current;
|
const previewScroller = previewScrollRef.current;
|
||||||
@@ -456,6 +492,79 @@ export function App() {
|
|||||||
return getOuterPreviewScroller();
|
return getOuterPreviewScroller();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePagedPreviewLink(href: string) {
|
||||||
|
const action = resolveWebDocumentLinkAction(href);
|
||||||
|
if (action.type !== "anchor" && window.mdToPdfDesktop) {
|
||||||
|
void window.mdToPdfDesktop.openDocumentLink(href).catch(
|
||||||
|
(reason: unknown) => {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "无法打开文档链接"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === "open") {
|
||||||
|
window.open(
|
||||||
|
action.href,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type !== "anchor") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = previewFrameRef.current;
|
||||||
|
const frameDocument = frame?.contentDocument;
|
||||||
|
const scrollElement = getPreviewScroller();
|
||||||
|
if (!frame || !frameDocument || !scrollElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAnchorId = action.href.slice(1);
|
||||||
|
const anchorIds = [rawAnchorId];
|
||||||
|
try {
|
||||||
|
const decodedAnchorId = decodeURIComponent(rawAnchorId);
|
||||||
|
if (decodedAnchorId !== rawAnchorId) {
|
||||||
|
anchorIds.push(decodedAnchorId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 保留原始片段继续查找,兼容不完整的百分号编码。
|
||||||
|
}
|
||||||
|
const target = anchorIds
|
||||||
|
.map(
|
||||||
|
(anchorId) =>
|
||||||
|
frameDocument.getElementById(anchorId) ??
|
||||||
|
frameDocument.getElementsByName(anchorId).item(0)
|
||||||
|
)
|
||||||
|
.find((element) => Boolean(element));
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollerViewportTop =
|
||||||
|
scrollElement === document.scrollingElement
|
||||||
|
? 0
|
||||||
|
: scrollElement.getBoundingClientRect().top;
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
const frameScale =
|
||||||
|
frame.offsetWidth > 0
|
||||||
|
? frameRect.width / frame.offsetWidth
|
||||||
|
: 1;
|
||||||
|
const targetTop = getPreviewPageScrollTop(
|
||||||
|
scrollElement.scrollTop,
|
||||||
|
frameRect.top +
|
||||||
|
target.getBoundingClientRect().top * frameScale,
|
||||||
|
scrollerViewportTop
|
||||||
|
);
|
||||||
|
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
|
||||||
|
window.requestAnimationFrame(updatePreviewCurrentPage);
|
||||||
|
}
|
||||||
|
|
||||||
function synchronizeScroll(origin: "editor" | "preview") {
|
function synchronizeScroll(origin: "editor" | "preview") {
|
||||||
const source =
|
const source =
|
||||||
origin === "editor" ? editorRef.current : getPreviewScroller();
|
origin === "editor" ? editorRef.current : getPreviewScroller();
|
||||||
@@ -487,6 +596,9 @@ export function App() {
|
|||||||
updatePrecisePreviewCurrentPage();
|
updatePrecisePreviewCurrentPage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (previewModeRef.current === "continuous") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const frame = previewFrameRef.current;
|
const frame = previewFrameRef.current;
|
||||||
const frameDocument = frame?.contentDocument;
|
const frameDocument = frame?.contentDocument;
|
||||||
const scrollElement = getPreviewScroller();
|
const scrollElement = getPreviewScroller();
|
||||||
@@ -840,8 +952,12 @@ export function App() {
|
|||||||
if (mode === previewMode) {
|
if (mode === previewMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const keepsFrame =
|
||||||
|
previewMode !== "precise" && mode !== "precise";
|
||||||
setPreviewMode(mode);
|
setPreviewMode(mode);
|
||||||
|
if (!keepsFrame) {
|
||||||
setPreviewFrameReady(false);
|
setPreviewFrameReady(false);
|
||||||
|
}
|
||||||
setQuickPreviewContentHeight(undefined);
|
setQuickPreviewContentHeight(undefined);
|
||||||
setPreviewPagination({ current: 0, total: 0 });
|
setPreviewPagination({ current: 0, total: 0 });
|
||||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||||
@@ -1041,6 +1157,18 @@ export function App() {
|
|||||||
>
|
>
|
||||||
精确
|
精确
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
previewMode === "continuous" ? "is-active" : ""
|
||||||
|
}
|
||||||
|
aria-pressed={previewMode === "continuous"}
|
||||||
|
onClick={() =>
|
||||||
|
handlePreviewModeChange("continuous")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
连续
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<label className="theme-control">
|
<label className="theme-control">
|
||||||
<span className="sr-only">预览主题</span>
|
<span className="sr-only">预览主题</span>
|
||||||
@@ -1090,12 +1218,20 @@ export function App() {
|
|||||||
className="preview-scroll"
|
className="preview-scroll"
|
||||||
onScroll={handlePreviewScroll}
|
onScroll={handlePreviewScroll}
|
||||||
>
|
>
|
||||||
{previewMode === "quick" ? (
|
{previewMode !== "precise" ? (
|
||||||
<div className="paper" style={paperStyle}>
|
<div
|
||||||
|
className={`paper${
|
||||||
|
previewMode === "continuous"
|
||||||
|
? " continuous-paper"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
style={paperStyle}
|
||||||
|
>
|
||||||
{previewPayload ? (
|
{previewPayload ? (
|
||||||
<iframe
|
<iframe
|
||||||
ref={previewFrameRef}
|
ref={previewFrameRef}
|
||||||
className="preview-frame"
|
className="preview-frame"
|
||||||
|
scrolling="no"
|
||||||
style={previewFrameStyle}
|
style={previewFrameStyle}
|
||||||
title="文档内容预览"
|
title="文档内容预览"
|
||||||
sandbox="allow-same-origin allow-scripts"
|
sandbox="allow-same-origin allow-scripts"
|
||||||
@@ -1122,6 +1258,8 @@ export function App() {
|
|||||||
<PrecisePdfPreview
|
<PrecisePdfPreview
|
||||||
blob={cachedPdf.blob}
|
blob={cachedPdf.blob}
|
||||||
zoomPercent={previewZoom}
|
zoomPercent={previewZoom}
|
||||||
|
onExternalLink={handlePagedPreviewLink}
|
||||||
|
onNavigateToPage={handlePreviewPageChange}
|
||||||
onPageCount={handlePrecisePageCount}
|
onPageCount={handlePrecisePageCount}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
AnnotationLayer,
|
||||||
getDocument,
|
getDocument,
|
||||||
GlobalWorkerOptions,
|
GlobalWorkerOptions,
|
||||||
RenderingCancelledException,
|
RenderingCancelledException,
|
||||||
@@ -20,10 +21,15 @@ import {
|
|||||||
getPdfCanvasRenderScale,
|
getPdfCanvasRenderScale,
|
||||||
getPdfPageCssDimensions
|
getPdfPageCssDimensions
|
||||||
} from "./pdf-page-geometry";
|
} from "./pdf-page-geometry";
|
||||||
|
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
|
||||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||||
|
|
||||||
|
type PdfJsAnnotationLinkService = Parameters<
|
||||||
|
AnnotationLayer["render"]
|
||||||
|
>[0]["linkService"];
|
||||||
|
|
||||||
interface PdfPageDescriptor {
|
interface PdfPageDescriptor {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -33,12 +39,16 @@ interface PdfPageDescriptor {
|
|||||||
export interface PrecisePdfPreviewProps {
|
export interface PrecisePdfPreviewProps {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
zoomPercent: number;
|
zoomPercent: number;
|
||||||
|
onExternalLink?: (href: string) => void;
|
||||||
|
onNavigateToPage?: (pageNumber: number) => void;
|
||||||
onPageCount?: (pageCount: number) => void;
|
onPageCount?: (pageCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrecisePdfPageProps {
|
interface PrecisePdfPageProps {
|
||||||
document: PDFDocumentProxy;
|
document: PDFDocumentProxy;
|
||||||
descriptor: PdfPageDescriptor;
|
descriptor: PdfPageDescriptor;
|
||||||
|
onExternalLink: (href: string) => void;
|
||||||
|
onNavigateToPage: (pageNumber: number) => void;
|
||||||
renderQueue: PdfPageRenderQueue;
|
renderQueue: PdfPageRenderQueue;
|
||||||
zoomPercent: number;
|
zoomPercent: number;
|
||||||
}
|
}
|
||||||
@@ -92,12 +102,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
|||||||
function PrecisePdfPage({
|
function PrecisePdfPage({
|
||||||
document,
|
document,
|
||||||
descriptor,
|
descriptor,
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage,
|
||||||
renderQueue,
|
renderQueue,
|
||||||
zoomPercent
|
zoomPercent
|
||||||
}: PrecisePdfPageProps) {
|
}: PrecisePdfPageProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const annotationLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const [displayWidth, setDisplayWidth] = useState(0);
|
const [displayWidth, setDisplayWidth] = useState(0);
|
||||||
const [renderError, setRenderError] = useState("");
|
const [renderError, setRenderError] = useState("");
|
||||||
const [renderState, setRenderState] = useState<
|
const [renderState, setRenderState] = useState<
|
||||||
@@ -158,11 +171,13 @@ function PrecisePdfPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const textContainer = textLayerRef.current;
|
const textContainer = textLayerRef.current;
|
||||||
|
const annotationContainer = annotationLayerRef.current;
|
||||||
if (
|
if (
|
||||||
!nearViewport ||
|
!nearViewport ||
|
||||||
displayWidth <= 0 ||
|
displayWidth <= 0 ||
|
||||||
!canvas ||
|
!canvas ||
|
||||||
!textContainer
|
!textContainer ||
|
||||||
|
!annotationContainer
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,6 +186,7 @@ function PrecisePdfPage({
|
|||||||
let page: PDFPageProxy | undefined;
|
let page: PDFPageProxy | undefined;
|
||||||
let renderTask: RenderTask | undefined;
|
let renderTask: RenderTask | undefined;
|
||||||
let textLayer: TextLayer | undefined;
|
let textLayer: TextLayer | undefined;
|
||||||
|
let annotationLayer: AnnotationLayer | undefined;
|
||||||
|
|
||||||
setRenderError("");
|
setRenderError("");
|
||||||
setRenderState("queued");
|
setRenderState("queued");
|
||||||
@@ -212,6 +228,7 @@ function PrecisePdfPage({
|
|||||||
canvas.style.width = `${displayViewport.width}px`;
|
canvas.style.width = `${displayViewport.width}px`;
|
||||||
canvas.style.height = `${displayViewport.height}px`;
|
canvas.style.height = `${displayViewport.height}px`;
|
||||||
textContainer.replaceChildren();
|
textContainer.replaceChildren();
|
||||||
|
annotationContainer.replaceChildren();
|
||||||
|
|
||||||
renderTask = page.render({
|
renderTask = page.render({
|
||||||
canvas,
|
canvas,
|
||||||
@@ -229,7 +246,43 @@ function PrecisePdfPage({
|
|||||||
textContentSource: page.streamTextContent(),
|
textContentSource: page.streamTextContent(),
|
||||||
viewport: displayViewport
|
viewport: displayViewport
|
||||||
});
|
});
|
||||||
await textLayer.render();
|
const annotations = await page.getAnnotations({
|
||||||
|
intent: "display"
|
||||||
|
});
|
||||||
|
const linkService = createPdfAnnotationLinkService(
|
||||||
|
document,
|
||||||
|
descriptor.pageNumber,
|
||||||
|
{
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage
|
||||||
|
}
|
||||||
|
);
|
||||||
|
annotationLayer = new AnnotationLayer({
|
||||||
|
div: annotationContainer,
|
||||||
|
accessibilityManager: undefined,
|
||||||
|
annotationCanvasMap: undefined,
|
||||||
|
annotationEditorUIManager: undefined,
|
||||||
|
page,
|
||||||
|
viewport: displayViewport,
|
||||||
|
structTreeLayer: undefined,
|
||||||
|
commentManager: undefined,
|
||||||
|
linkService,
|
||||||
|
annotationStorage: document.annotationStorage
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
textLayer.render(),
|
||||||
|
annotationLayer.render({
|
||||||
|
viewport: displayViewport,
|
||||||
|
div: annotationContainer,
|
||||||
|
annotations,
|
||||||
|
page,
|
||||||
|
linkService:
|
||||||
|
linkService as unknown as PdfJsAnnotationLinkService,
|
||||||
|
annotationStorage: document.annotationStorage,
|
||||||
|
renderForms: false,
|
||||||
|
enableScripting: false
|
||||||
|
})
|
||||||
|
]);
|
||||||
} catch (reason: unknown) {
|
} catch (reason: unknown) {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
@@ -251,10 +304,12 @@ function PrecisePdfPage({
|
|||||||
cancelQueuedRender();
|
cancelQueuedRender();
|
||||||
renderTask?.cancel();
|
renderTask?.cancel();
|
||||||
textLayer?.cancel();
|
textLayer?.cancel();
|
||||||
|
annotationLayer?.destroy();
|
||||||
page?.cleanup();
|
page?.cleanup();
|
||||||
canvas.width = 0;
|
canvas.width = 0;
|
||||||
canvas.height = 0;
|
canvas.height = 0;
|
||||||
textContainer.replaceChildren();
|
textContainer.replaceChildren();
|
||||||
|
annotationContainer.replaceChildren();
|
||||||
setRenderState("idle");
|
setRenderState("idle");
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -264,6 +319,8 @@ function PrecisePdfPage({
|
|||||||
displayWidth,
|
displayWidth,
|
||||||
document,
|
document,
|
||||||
nearViewport,
|
nearViewport,
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage,
|
||||||
renderQueue
|
renderQueue
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -282,6 +339,10 @@ function PrecisePdfPage({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div ref={textLayerRef} className="textLayer" />
|
<div ref={textLayerRef} className="textLayer" />
|
||||||
|
<div
|
||||||
|
ref={annotationLayerRef}
|
||||||
|
className="annotationLayer"
|
||||||
|
/>
|
||||||
{renderState !== "rendered" ? (
|
{renderState !== "rendered" ? (
|
||||||
<span className="precise-pdf-page-placeholder">
|
<span className="precise-pdf-page-placeholder">
|
||||||
{renderState === "rendering"
|
{renderState === "rendering"
|
||||||
@@ -299,6 +360,8 @@ function PrecisePdfPage({
|
|||||||
export function PrecisePdfPreview({
|
export function PrecisePdfPreview({
|
||||||
blob,
|
blob,
|
||||||
zoomPercent,
|
zoomPercent,
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage,
|
||||||
onPageCount
|
onPageCount
|
||||||
}: PrecisePdfPreviewProps) {
|
}: PrecisePdfPreviewProps) {
|
||||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||||
@@ -308,6 +371,19 @@ export function PrecisePdfPreview({
|
|||||||
() => new PdfPageRenderQueue(1),
|
() => new PdfPageRenderQueue(1),
|
||||||
[document]
|
[document]
|
||||||
);
|
);
|
||||||
|
const externalLinkRef = useRef(onExternalLink);
|
||||||
|
const navigateToPageRef = useRef(onNavigateToPage);
|
||||||
|
externalLinkRef.current = onExternalLink;
|
||||||
|
navigateToPageRef.current = onNavigateToPage;
|
||||||
|
const handleExternalLink = useCallback((href: string) => {
|
||||||
|
externalLinkRef.current?.(href);
|
||||||
|
}, []);
|
||||||
|
const handleNavigateToPage = useCallback(
|
||||||
|
(pageNumber: number) => {
|
||||||
|
navigateToPageRef.current?.(pageNumber);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||||
|
|
||||||
@@ -386,6 +462,8 @@ export function PrecisePdfPreview({
|
|||||||
key={page.pageNumber}
|
key={page.pageNumber}
|
||||||
document={document}
|
document={document}
|
||||||
descriptor={page}
|
descriptor={page}
|
||||||
|
onExternalLink={handleExternalLink}
|
||||||
|
onNavigateToPage={handleNavigateToPage}
|
||||||
renderQueue={renderQueue}
|
renderQueue={renderQueue}
|
||||||
zoomPercent={zoomPercent}
|
zoomPercent={zoomPercent}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export type WebDocumentLinkAction =
|
||||||
|
| {
|
||||||
|
type: "anchor";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "open";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ignore";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveWebDocumentLinkAction(
|
||||||
|
href: string
|
||||||
|
): WebDocumentLinkAction {
|
||||||
|
const link = classifyDocumentLink(href);
|
||||||
|
if (link.kind === "anchor") {
|
||||||
|
return {
|
||||||
|
type: "anchor",
|
||||||
|
href: link.normalizedHref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
link.kind === "network" ||
|
||||||
|
(link.kind === "protocol" &&
|
||||||
|
(link.scheme === "mailto" || link.scheme === "tel"))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "open",
|
||||||
|
href: link.normalizedHref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "ignore" };
|
||||||
|
}
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
|
||||||
import katexCss from "katex/dist/katex.min.css?inline";
|
|
||||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
|
||||||
import type {
|
|
||||||
PagedDocumentRenderResult,
|
|
||||||
PagedDocumentTimings
|
|
||||||
} from "@md-to-pdf/core";
|
|
||||||
import { Previewer } from "pagedjs";
|
|
||||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
|
||||||
import {
|
|
||||||
fitDocumentImagesToPage,
|
|
||||||
prepareDocumentImageBlocks
|
|
||||||
} from "./document-image-fit";
|
|
||||||
import {
|
|
||||||
applyMediaBackfillCandidates,
|
|
||||||
findMediaBackfillCandidates,
|
|
||||||
getMediaBackfillIdsInDocumentOrder,
|
|
||||||
prepareMediaBackfillBlocks
|
|
||||||
} from "./media-page-backfill";
|
|
||||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
|
||||||
import {
|
|
||||||
fitOversizedMermaidToPage,
|
|
||||||
getPageContentDimensions
|
|
||||||
} from "./mermaid-page-fit";
|
|
||||||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
|
||||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
|
||||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
|
||||||
import { enablePrintMediaForPreview } from "./preview-styles";
|
|
||||||
import "./paged-table-handler";
|
|
||||||
import {
|
|
||||||
buildPagedMediaCss,
|
|
||||||
documentBaseCss,
|
|
||||||
documentGeometryCss,
|
|
||||||
formatPageNumber,
|
|
||||||
type PagedPreviewPayload
|
|
||||||
} from "./paged-preview";
|
|
||||||
import type { PagedRenderTarget } from "./paged-render-target";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
PagedDocumentRenderResult,
|
|
||||||
PagedDocumentTimings
|
|
||||||
} from "@md-to-pdf/core";
|
|
||||||
|
|
||||||
export interface PagedDocumentRenderOptions {
|
|
||||||
target: PagedRenderTarget;
|
|
||||||
shouldContinue?: () => boolean;
|
|
||||||
mermaidOutput?: MermaidOutputMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForImages(container: ParentNode) {
|
|
||||||
const images = Array.from(container.querySelectorAll("img"));
|
|
||||||
return Promise.all(
|
|
||||||
images.map(async (image) => {
|
|
||||||
if (image.complete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await image.decode();
|
|
||||||
} catch {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
image.addEventListener("load", () => resolve(), { once: true });
|
|
||||||
image.addEventListener("error", () => resolve(), { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stylesheet(
|
|
||||||
documentRef: Document,
|
|
||||||
css: string,
|
|
||||||
name: string
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
[`${documentRef.location.href}#${name}`]: css
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountMeasurementContainer(
|
|
||||||
documentRef: Document,
|
|
||||||
content: DocumentFragment,
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
target: PagedRenderTarget
|
|
||||||
) {
|
|
||||||
const pageContent = getPageContentDimensions(payload.exportConfig);
|
|
||||||
const style = documentRef.createElement("style");
|
|
||||||
const themeCss =
|
|
||||||
target === "preview"
|
|
||||||
? enablePrintMediaForPreview(payload.themeCss)
|
|
||||||
: payload.themeCss;
|
|
||||||
style.dataset.pagedMeasurementStyle = "true";
|
|
||||||
style.textContent = [
|
|
||||||
documentBaseCss,
|
|
||||||
highlightCss,
|
|
||||||
katexCss,
|
|
||||||
echartsCss,
|
|
||||||
themeCss,
|
|
||||||
documentGeometryCss
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const host = documentRef.createElement("div");
|
|
||||||
host.dataset.pagedMeasurementHost = "true";
|
|
||||||
host.style.cssText = [
|
|
||||||
"position: fixed",
|
|
||||||
"left: -100000px",
|
|
||||||
"top: 0",
|
|
||||||
`width: ${pageContent.width}px`,
|
|
||||||
"height: auto",
|
|
||||||
"opacity: 0",
|
|
||||||
"pointer-events: none",
|
|
||||||
"contain: layout style paint"
|
|
||||||
].join(";");
|
|
||||||
|
|
||||||
documentRef.head.append(style);
|
|
||||||
host.append(content);
|
|
||||||
documentRef.body.append(host);
|
|
||||||
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
restore() {
|
|
||||||
content.append(...Array.from(host.childNodes));
|
|
||||||
host.remove();
|
|
||||||
style.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPageNumbers(
|
|
||||||
container: ParentNode,
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
totalPages: number
|
|
||||||
) {
|
|
||||||
if (!payload.exportConfig.footer.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alignment = payload.exportConfig.footer.alignment;
|
|
||||||
const pages = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [pageIndex, page] of pages.entries()) {
|
|
||||||
const content = page.querySelector<HTMLElement>(
|
|
||||||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
|
||||||
);
|
|
||||||
if (!content) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
content.textContent = formatPageNumber(
|
|
||||||
payload.exportConfig.footer,
|
|
||||||
pageIndex,
|
|
||||||
totalPages
|
|
||||||
);
|
|
||||||
content.setAttribute("data-page-number-rendered", "true");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTrailingPdfPageBreak(container: ParentNode) {
|
|
||||||
const pages = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
|
||||||
);
|
|
||||||
pages
|
|
||||||
.at(-1)
|
|
||||||
?.style.setProperty("break-after", "auto", "important");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PagedDocumentRuntime {
|
|
||||||
private activePreviewer: Previewer | undefined;
|
|
||||||
private mermaidRenderSequence = 0;
|
|
||||||
private mermaidPromise:
|
|
||||||
| Promise<(typeof import("mermaid"))["default"]>
|
|
||||||
| undefined;
|
|
||||||
private echartsPromise:
|
|
||||||
| Promise<
|
|
||||||
typeof import("@md-to-pdf/markdown-echarts/browser")
|
|
||||||
>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
constructor(private readonly root: HTMLElement) {}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (!this.activePreviewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activePreviewer.chunker.destroy();
|
|
||||||
this.activePreviewer.polisher.destroy();
|
|
||||||
this.activePreviewer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadMermaid() {
|
|
||||||
if (!this.mermaidPromise) {
|
|
||||||
this.mermaidPromise = Promise.all([
|
|
||||||
import("mermaid"),
|
|
||||||
import("@mermaid-js/layout-elk")
|
|
||||||
]).then(
|
|
||||||
([{ default: mermaid }, { default: elkLayouts }]) => {
|
|
||||||
mermaid.registerLayoutLoaders(elkLayouts);
|
|
||||||
return mermaid;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.mermaidPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadECharts() {
|
|
||||||
if (!this.echartsPromise) {
|
|
||||||
this.echartsPromise = import(
|
|
||||||
"@md-to-pdf/markdown-echarts/browser"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.echartsPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderECharts(
|
|
||||||
container: ParentNode,
|
|
||||||
payload: PagedPreviewPayload
|
|
||||||
) {
|
|
||||||
if (!payload.features.includes("echarts")) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { renderEChartsBlocks } = await this.loadECharts();
|
|
||||||
const outcomes = await renderEChartsBlocks(container, {
|
|
||||||
outputMode: "inline-svg",
|
|
||||||
concurrency: 2,
|
|
||||||
timeoutMs: 5_000
|
|
||||||
});
|
|
||||||
|
|
||||||
return outcomes.flatMap((outcome, index) =>
|
|
||||||
outcome.success
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
`图表 ${index + 1}:${
|
|
||||||
outcome.error?.message ?? "未知错误"
|
|
||||||
}`
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async replaceEChartsSvgWithImages(container: ParentNode) {
|
|
||||||
const { freezeEChartsSvg } = await this.loadECharts();
|
|
||||||
const figures = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(
|
|
||||||
".md-echarts:not(.md-echarts-error)"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
for (const figure of figures) {
|
|
||||||
const host = figure.querySelector<HTMLElement>(
|
|
||||||
".md-echarts-host"
|
|
||||||
);
|
|
||||||
const svg = host?.querySelector<SVGSVGElement>("svg");
|
|
||||||
if (!host || !svg) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const label =
|
|
||||||
host.getAttribute("aria-label") || "ECharts 图表";
|
|
||||||
host.replaceChildren(
|
|
||||||
freezeEChartsSvg(host, svg, "svg-image", label)
|
|
||||||
);
|
|
||||||
figure.dataset.echartsRendered = "svg-image";
|
|
||||||
}
|
|
||||||
await waitForImages(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderMermaid(
|
|
||||||
container: DocumentFragment,
|
|
||||||
payload: PagedPreviewPayload
|
|
||||||
) {
|
|
||||||
if (!payload.features.includes("mermaid")) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(
|
|
||||||
".mermaid[data-mermaid-pending]"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mermaid = await this.loadMermaid();
|
|
||||||
mermaid.initialize(
|
|
||||||
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
|
||||||
);
|
|
||||||
const outcomes = await renderMermaidDefinitions(
|
|
||||||
nodes.map((node) => node.textContent ?? ""),
|
|
||||||
async (definition) => {
|
|
||||||
this.mermaidRenderSequence += 1;
|
|
||||||
return mermaid.render(
|
|
||||||
`mermaid-paged-document-${this.mermaidRenderSequence}`,
|
|
||||||
definition
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
for (const [index, outcome] of outcomes.entries()) {
|
|
||||||
const node = nodes[index];
|
|
||||||
if (!node) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.removeAttribute("data-mermaid-pending");
|
|
||||||
if (outcome.success) {
|
|
||||||
node.innerHTML = outcome.svg;
|
|
||||||
outcome.bindFunctions?.(node);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
outcome.error instanceof Error
|
|
||||||
? outcome.error.message
|
|
||||||
: "未知错误";
|
|
||||||
node.classList.add("mermaid-error");
|
|
||||||
node.textContent =
|
|
||||||
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
|
||||||
errors.push(`图表 ${index + 1}:${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
options: PagedDocumentRenderOptions
|
|
||||||
): Promise<PagedDocumentRenderResult | undefined> {
|
|
||||||
const totalStartedAt = performance.now();
|
|
||||||
const shouldContinue = options.shouldContinue ?? (() => true);
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const setupStartedAt = performance.now();
|
|
||||||
const documentRef = this.root.ownerDocument;
|
|
||||||
this.destroy();
|
|
||||||
this.root.replaceChildren();
|
|
||||||
documentRef.documentElement.dataset.renderTarget = options.target;
|
|
||||||
documentRef.documentElement.lang =
|
|
||||||
payload.metadata.language || "zh-CN";
|
|
||||||
documentRef.title =
|
|
||||||
payload.metadata.title || "Markdown 分页文档";
|
|
||||||
|
|
||||||
const template = documentRef.createElement("template");
|
|
||||||
template.innerHTML = payload.articleHtml;
|
|
||||||
const content = template.content;
|
|
||||||
const setupMs = performance.now() - setupStartedAt;
|
|
||||||
|
|
||||||
const mermaidStartedAt = performance.now();
|
|
||||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
|
||||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
|
||||||
|
|
||||||
let echartsErrors: string[] = [];
|
|
||||||
let echartsMs = 0;
|
|
||||||
let echartsFitMs = 0;
|
|
||||||
let mermaidFitMs = 0;
|
|
||||||
let mermaidConversionMs = 0;
|
|
||||||
if (
|
|
||||||
payload.features.includes("echarts") ||
|
|
||||||
content.querySelector(".mermaid svg") ||
|
|
||||||
content.querySelector("img.md-document-image")
|
|
||||||
) {
|
|
||||||
const measurement = mountMeasurementContainer(
|
|
||||||
documentRef,
|
|
||||||
content,
|
|
||||||
payload,
|
|
||||||
options.target
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await documentRef.fonts.ready;
|
|
||||||
|
|
||||||
const echartsStartedAt = performance.now();
|
|
||||||
echartsErrors = await this.renderECharts(
|
|
||||||
measurement.host,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
echartsMs = performance.now() - echartsStartedAt;
|
|
||||||
await waitForImages(measurement.host);
|
|
||||||
prepareDocumentImageBlocks(measurement.host);
|
|
||||||
prepareMediaBackfillBlocks(measurement.host);
|
|
||||||
fitDocumentImagesToPage(
|
|
||||||
measurement.host,
|
|
||||||
payload.exportConfig,
|
|
||||||
{ prepareBlocks: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const echartsFitStartedAt = performance.now();
|
|
||||||
fitOversizedEChartsToPage(
|
|
||||||
measurement.host,
|
|
||||||
payload.exportConfig
|
|
||||||
);
|
|
||||||
await this.replaceEChartsSvgWithImages(measurement.host);
|
|
||||||
echartsFitMs =
|
|
||||||
performance.now() - echartsFitStartedAt;
|
|
||||||
|
|
||||||
const mermaidFitStartedAt = performance.now();
|
|
||||||
fitOversizedMermaidToPage(
|
|
||||||
measurement.host,
|
|
||||||
payload.exportConfig
|
|
||||||
);
|
|
||||||
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
|
||||||
|
|
||||||
const mermaidConversionStartedAt = performance.now();
|
|
||||||
if (options.mermaidOutput === "svg-image") {
|
|
||||||
replaceMermaidSvgWithImages(measurement.host);
|
|
||||||
await waitForImages(measurement.host);
|
|
||||||
}
|
|
||||||
mermaidConversionMs =
|
|
||||||
performance.now() - mermaidConversionStartedAt;
|
|
||||||
} finally {
|
|
||||||
measurement.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceWaitStartedAt = performance.now();
|
|
||||||
await documentRef.fonts.ready;
|
|
||||||
await waitForImages(content);
|
|
||||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
|
||||||
|
|
||||||
const paginationStartedAt = performance.now();
|
|
||||||
const repaginationSource = content.cloneNode(
|
|
||||||
true
|
|
||||||
) as DocumentFragment;
|
|
||||||
const stylesheets = [
|
|
||||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
|
||||||
stylesheet(documentRef, highlightCss, "highlight"),
|
|
||||||
stylesheet(documentRef, katexCss, "katex"),
|
|
||||||
stylesheet(documentRef, echartsCss, "echarts"),
|
|
||||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
|
||||||
stylesheet(
|
|
||||||
documentRef,
|
|
||||||
documentGeometryCss,
|
|
||||||
"document-geometry"
|
|
||||||
),
|
|
||||||
stylesheet(
|
|
||||||
documentRef,
|
|
||||||
buildPagedMediaCss(payload.exportConfig, payload),
|
|
||||||
"paged-media"
|
|
||||||
)
|
|
||||||
];
|
|
||||||
let previewer = new Previewer();
|
|
||||||
this.activePreviewer = previewer;
|
|
||||||
let flow = await previewer.preview(
|
|
||||||
content,
|
|
||||||
stylesheets,
|
|
||||||
this.root
|
|
||||||
);
|
|
||||||
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
|
||||||
repaginationSource
|
|
||||||
);
|
|
||||||
let mediaCandidates = new Map(
|
|
||||||
findMediaBackfillCandidates(this.root).map((candidate) => [
|
|
||||||
candidate.id,
|
|
||||||
candidate
|
|
||||||
])
|
|
||||||
);
|
|
||||||
for (const mediaId of mediaIds) {
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const candidate = mediaCandidates.get(mediaId);
|
|
||||||
if (
|
|
||||||
!candidate ||
|
|
||||||
applyMediaBackfillCandidates(repaginationSource, [
|
|
||||||
candidate
|
|
||||||
]) === 0
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
previewer.chunker.destroy();
|
|
||||||
previewer.polisher.destroy();
|
|
||||||
previewer = new Previewer();
|
|
||||||
this.activePreviewer = previewer;
|
|
||||||
flow = await previewer.preview(
|
|
||||||
repaginationSource.cloneNode(true) as DocumentFragment,
|
|
||||||
stylesheets,
|
|
||||||
this.root
|
|
||||||
);
|
|
||||||
mediaCandidates = new Map(
|
|
||||||
findMediaBackfillCandidates(this.root).map(
|
|
||||||
(nextCandidate) => [
|
|
||||||
nextCandidate.id,
|
|
||||||
nextCandidate
|
|
||||||
]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const paginationMs = performance.now() - paginationStartedAt;
|
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalizeStartedAt = performance.now();
|
|
||||||
applyPageNumbers(this.root, payload, flow.total);
|
|
||||||
if (options.target === "pdf") {
|
|
||||||
removeTrailingPdfPageBreak(this.root);
|
|
||||||
}
|
|
||||||
const finalizeMs = performance.now() - finalizeStartedAt;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageCount: flow.total,
|
|
||||||
echartsErrors,
|
|
||||||
mermaidErrors,
|
|
||||||
timings: {
|
|
||||||
setupMs,
|
|
||||||
echartsMs,
|
|
||||||
echartsFitMs,
|
|
||||||
mermaidMs,
|
|
||||||
mermaidFitMs,
|
|
||||||
mermaidConversionMs,
|
|
||||||
resourceWaitMs,
|
|
||||||
paginationMs,
|
|
||||||
finalizeMs,
|
|
||||||
totalMs: performance.now() - totalStartedAt
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__mdToPdfRender?: (
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
target?: PagedRenderTarget
|
|
||||||
) => Promise<PagedDocumentRenderResult>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { PagedDocumentRuntime } from "./paged-document-runtime";
|
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||||
import {
|
import {
|
||||||
isPagedPreviewRenderRequest,
|
isPagedPreviewRenderRequest,
|
||||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
PagedDocumentRuntime,
|
||||||
|
resolveMermaidOutputMode,
|
||||||
|
resolvePagedRenderTarget,
|
||||||
type PagedPreviewFrameMessage,
|
type PagedPreviewFrameMessage,
|
||||||
type PagedPreviewPayload
|
type PagedPreviewPayload
|
||||||
} from "./paged-preview";
|
} from "@md-to-pdf/preview-engine";
|
||||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
import katexCss from "katex/dist/katex.min.css?inline";
|
||||||
import {
|
import {
|
||||||
beginPreviewFrameRender,
|
beginPreviewFrameRender,
|
||||||
completePreviewFrameRender
|
completePreviewFrameRender
|
||||||
@@ -23,7 +26,11 @@ function getPreviewRoot() {
|
|||||||
const previewRoot = getPreviewRoot();
|
const previewRoot = getPreviewRoot();
|
||||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||||
const runtime = new PagedDocumentRuntime(previewRoot);
|
const runtime = new PagedDocumentRuntime(previewRoot, {
|
||||||
|
highlightCss,
|
||||||
|
katexCss,
|
||||||
|
echartsCss
|
||||||
|
});
|
||||||
document.documentElement.dataset.renderTarget = renderTarget;
|
document.documentElement.dataset.renderTarget = renderTarget;
|
||||||
|
|
||||||
let latestRequestId = 0;
|
let latestRequestId = 0;
|
||||||
@@ -33,9 +40,47 @@ function post(message: PagedPreviewFrameMessage) {
|
|||||||
window.parent.postMessage(message, window.location.origin);
|
window.parent.postMessage(message, window.location.origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function measurePreviewContentHeight() {
|
||||||
|
const rootRect = previewRoot.getBoundingClientRect();
|
||||||
|
const documentRect =
|
||||||
|
document.documentElement.getBoundingClientRect();
|
||||||
|
return Math.ceil(
|
||||||
|
Math.max(
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.scrollHeight,
|
||||||
|
previewRoot.scrollHeight,
|
||||||
|
rootRect.bottom - documentRect.top
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const target =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest<HTMLAnchorElement>("a[href]")
|
||||||
|
: undefined;
|
||||||
|
const href = target?.getAttribute("href")?.trim();
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
post({
|
||||||
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
type: "link",
|
||||||
|
href
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
|
||||||
async function renderPagedPreview(
|
async function renderPagedPreview(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload,
|
||||||
|
layout: "paged" | "continuous"
|
||||||
) {
|
) {
|
||||||
if (requestId !== latestRequestId) {
|
if (requestId !== latestRequestId) {
|
||||||
return;
|
return;
|
||||||
@@ -45,11 +90,17 @@ async function renderPagedPreview(
|
|||||||
post({
|
post({
|
||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendering",
|
type: "rendering",
|
||||||
requestId
|
requestId,
|
||||||
|
layout
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runtime.render(payload, {
|
const result =
|
||||||
|
layout === "continuous"
|
||||||
|
? await runtime.renderContinuous(payload, {
|
||||||
|
shouldContinue: () => requestId === latestRequestId
|
||||||
|
})
|
||||||
|
: await runtime.render(payload, {
|
||||||
target: renderTarget,
|
target: renderTarget,
|
||||||
mermaidOutput,
|
mermaidOutput,
|
||||||
shouldContinue: () => requestId === latestRequestId
|
shouldContinue: () => requestId === latestRequestId
|
||||||
@@ -62,7 +113,8 @@ async function renderPagedPreview(
|
|||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId,
|
requestId,
|
||||||
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
layout,
|
||||||
|
contentHeight: measurePreviewContentHeight(),
|
||||||
...result
|
...result
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -121,12 +173,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
latestRequestId = Math.max(latestRequestId, event.data.requestId);
|
latestRequestId = Math.max(latestRequestId, event.data.requestId);
|
||||||
const { requestId, payload } = event.data;
|
const { requestId, layout, payload } = event.data;
|
||||||
renderQueue = renderQueue
|
renderQueue = renderQueue
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
try {
|
try {
|
||||||
await renderPagedPreview(requestId, payload);
|
await renderPagedPreview(requestId, payload, layout);
|
||||||
} catch (reason: unknown) {
|
} catch (reason: unknown) {
|
||||||
if (requestId !== latestRequestId) {
|
if (requestId !== latestRequestId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Vendored
-41
@@ -1,41 +0,0 @@
|
|||||||
declare module "pagedjs" {
|
|
||||||
export interface PagedFlow {
|
|
||||||
total: number;
|
|
||||||
pages: HTMLElement[];
|
|
||||||
performance: number;
|
|
||||||
size: {
|
|
||||||
width: { value: number; unit: string };
|
|
||||||
height: { value: number; unit: string };
|
|
||||||
format?: string;
|
|
||||||
orientation?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PagedStylesheet = string | Record<string, string>;
|
|
||||||
|
|
||||||
export class Previewer {
|
|
||||||
chunker: {
|
|
||||||
destroy(): void;
|
|
||||||
};
|
|
||||||
polisher: {
|
|
||||||
destroy(): void;
|
|
||||||
};
|
|
||||||
preview(
|
|
||||||
content?: HTMLElement | DocumentFragment | string,
|
|
||||||
stylesheets?: PagedStylesheet[],
|
|
||||||
renderTo?: HTMLElement | string
|
|
||||||
): Promise<PagedFlow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Handler {
|
|
||||||
constructor(
|
|
||||||
chunker: unknown,
|
|
||||||
polisher: unknown,
|
|
||||||
caller: unknown
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerHandlers(
|
|
||||||
...handlers: Array<typeof Handler>
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||||
|
import { decodeLocalDocumentLinkFromPdf } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export type PdfDestination = string | unknown[];
|
||||||
|
|
||||||
|
export interface PdfAnnotationLinkCallbacks {
|
||||||
|
onExternalLink: (href: string) => void;
|
||||||
|
onNavigateToPage: (pageNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfAnnotationLinkService {
|
||||||
|
externalLinkEnabled: boolean;
|
||||||
|
addLinkAttributes(
|
||||||
|
link: HTMLAnchorElement,
|
||||||
|
url: string,
|
||||||
|
newWindow?: boolean
|
||||||
|
): void;
|
||||||
|
getDestinationHash(destination: PdfDestination): string;
|
||||||
|
getAnchorUrl(anchor: string): string;
|
||||||
|
goToDestination(destination: PdfDestination): Promise<void>;
|
||||||
|
executeNamedAction(action: string): void;
|
||||||
|
executeSetOCGState(action: object): Promise<void>;
|
||||||
|
getAttachmentContent(id: string): Promise<undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePdfDestinationPage(
|
||||||
|
document: Pick<
|
||||||
|
PDFDocumentProxy,
|
||||||
|
"getDestination" | "getPageIndex" | "numPages"
|
||||||
|
>,
|
||||||
|
destination: PdfDestination
|
||||||
|
) {
|
||||||
|
const explicitDestination =
|
||||||
|
typeof destination === "string"
|
||||||
|
? await document.getDestination(destination)
|
||||||
|
: destination;
|
||||||
|
if (
|
||||||
|
!Array.isArray(explicitDestination) ||
|
||||||
|
explicitDestination.length === 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageReference = explicitDestination[0];
|
||||||
|
let pageNumber: number;
|
||||||
|
if (
|
||||||
|
typeof pageReference === "number" &&
|
||||||
|
Number.isInteger(pageReference)
|
||||||
|
) {
|
||||||
|
pageNumber = pageReference + 1;
|
||||||
|
} else if (
|
||||||
|
pageReference &&
|
||||||
|
typeof pageReference === "object"
|
||||||
|
) {
|
||||||
|
pageNumber =
|
||||||
|
(await document.getPageIndex(
|
||||||
|
pageReference as Parameters<
|
||||||
|
PDFDocumentProxy["getPageIndex"]
|
||||||
|
>[0]
|
||||||
|
)) + 1;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNumber >= 1 && pageNumber <= document.numPages
|
||||||
|
? pageNumber
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePdfNamedActionPage(
|
||||||
|
action: string,
|
||||||
|
currentPage: number,
|
||||||
|
pageCount: number
|
||||||
|
) {
|
||||||
|
switch (action) {
|
||||||
|
case "FirstPage":
|
||||||
|
return 1;
|
||||||
|
case "LastPage":
|
||||||
|
return pageCount;
|
||||||
|
case "NextPage":
|
||||||
|
case "PageDown":
|
||||||
|
return Math.min(pageCount, currentPage + 1);
|
||||||
|
case "PrevPage":
|
||||||
|
case "PageUp":
|
||||||
|
return Math.max(1, currentPage - 1);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPdfAnnotationLinkService(
|
||||||
|
document: Pick<
|
||||||
|
PDFDocumentProxy,
|
||||||
|
"getDestination" | "getPageIndex" | "numPages"
|
||||||
|
>,
|
||||||
|
currentPage: number,
|
||||||
|
callbacks: PdfAnnotationLinkCallbacks
|
||||||
|
): PdfAnnotationLinkService {
|
||||||
|
return {
|
||||||
|
externalLinkEnabled: true,
|
||||||
|
addLinkAttributes(link, url) {
|
||||||
|
const resolvedUrl =
|
||||||
|
decodeLocalDocumentLinkFromPdf(url) ?? url;
|
||||||
|
link.href = "#";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.title ||= resolvedUrl;
|
||||||
|
link.dataset.pdfExternalHref = resolvedUrl;
|
||||||
|
link.onclick = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
callbacks.onExternalLink(resolvedUrl);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getDestinationHash() {
|
||||||
|
return "#pdf-destination";
|
||||||
|
},
|
||||||
|
getAnchorUrl() {
|
||||||
|
return "#pdf-action";
|
||||||
|
},
|
||||||
|
async goToDestination(destination) {
|
||||||
|
try {
|
||||||
|
const pageNumber = await resolvePdfDestinationPage(
|
||||||
|
document,
|
||||||
|
destination
|
||||||
|
);
|
||||||
|
if (pageNumber !== undefined) {
|
||||||
|
callbacks.onNavigateToPage(pageNumber);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 损坏或无法解析的 PDF 目标保持无行为。
|
||||||
|
}
|
||||||
|
},
|
||||||
|
executeNamedAction(action) {
|
||||||
|
const pageNumber = resolvePdfNamedActionPage(
|
||||||
|
action,
|
||||||
|
currentPage,
|
||||||
|
document.numPages
|
||||||
|
);
|
||||||
|
if (pageNumber !== undefined) {
|
||||||
|
callbacks.onNavigateToPage(pageNumber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async executeSetOCGState() {
|
||||||
|
// 精确预览不改变 PDF 可选内容组状态。
|
||||||
|
},
|
||||||
|
async getAttachmentContent() {
|
||||||
|
// 精确预览不下载或执行 PDF 内嵌附件。
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+41
-1
@@ -583,7 +583,8 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.precise-pdf-page canvas,
|
.precise-pdf-page canvas,
|
||||||
.precise-pdf-page .textLayer {
|
.precise-pdf-page .textLayer,
|
||||||
|
.precise-pdf-page .annotationLayer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
@@ -617,6 +618,45 @@ textarea:focus {
|
|||||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.precise-pdf-page .annotationLayer {
|
||||||
|
z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
color-scheme: only light;
|
||||||
|
pointer-events: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precise-pdf-page .annotationLayer section {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: initial;
|
||||||
|
pointer-events: auto;
|
||||||
|
user-select: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precise-pdf-page
|
||||||
|
.annotationLayer
|
||||||
|
.linkAnnotation
|
||||||
|
> a {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.precise-pdf-page
|
||||||
|
.annotationLayer
|
||||||
|
.linkAnnotation
|
||||||
|
> a:hover,
|
||||||
|
.precise-pdf-page
|
||||||
|
.annotationLayer
|
||||||
|
.linkAnnotation
|
||||||
|
> a:focus-visible {
|
||||||
|
background: rgb(31 111 83 / 14%);
|
||||||
|
outline: 1px solid rgb(31 111 83 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
.precise-pdf-page .textLayer :is(span, br) {
|
.precise-pdf-page .textLayer :is(span, br) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
|
|||||||
Vendored
+5
-1
@@ -32,8 +32,12 @@ declare global {
|
|||||||
| undefined
|
| undefined
|
||||||
>;
|
>;
|
||||||
discardPendingMarkdown(): Promise<void>;
|
discardPendingMarkdown(): Promise<void>;
|
||||||
onMarkdownOpened(listener: () => void): () => void;
|
onMarkdownOpened(
|
||||||
|
listener: (reason: "open" | "reload" | "replace") => void
|
||||||
|
): () => void;
|
||||||
startNewMarkdown(): Promise<void>;
|
startNewMarkdown(): Promise<void>;
|
||||||
|
setDocumentDirty(dirty: boolean): Promise<void>;
|
||||||
|
openDocumentLink(href: string): Promise<void>;
|
||||||
saveMarkdown(
|
saveMarkdown(
|
||||||
fileName: string,
|
fileName: string,
|
||||||
markdown: string
|
markdown: string
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
|
|
||||||
describe("应用版本", () => {
|
describe("应用版本", () => {
|
||||||
it("从统一构建版本生成标题徽标", () => {
|
it("从统一构建版本生成标题徽标", () => {
|
||||||
expect(APP_VERSION).toBe("0.4.5");
|
expect(APP_VERSION).toBe("0.5.0");
|
||||||
expect(APP_VERSION_LABEL).toBe("v0.4.5");
|
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveWebDocumentLinkAction } from "../src/document-link";
|
||||||
|
|
||||||
|
describe("Web 文档链接行为", () => {
|
||||||
|
it("保留文档锚点", () => {
|
||||||
|
expect(resolveWebDocumentLinkAction("#section")).toEqual({
|
||||||
|
type: "anchor",
|
||||||
|
href: "#section"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在外部窗口打开 HTTP 链接和浏览器协议", () => {
|
||||||
|
expect(
|
||||||
|
resolveWebDocumentLinkAction("//example.com/path")
|
||||||
|
).toEqual({
|
||||||
|
type: "open",
|
||||||
|
href: "https://example.com/path"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveWebDocumentLinkAction("mailto:a@example.com")
|
||||||
|
).toEqual({
|
||||||
|
type: "open",
|
||||||
|
href: "mailto:a@example.com"
|
||||||
|
});
|
||||||
|
expect(resolveWebDocumentLinkAction("tel:+8612345")).toEqual({
|
||||||
|
type: "open",
|
||||||
|
href: "tel:+8612345"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略本地、未知和危险地址", () => {
|
||||||
|
for (const href of [
|
||||||
|
"../docs/a.md",
|
||||||
|
"C:/docs/a.md",
|
||||||
|
"file:///C:/docs/a.md",
|
||||||
|
"obsidian://open?vault=x",
|
||||||
|
"javascript:alert(1)"
|
||||||
|
]) {
|
||||||
|
expect(resolveWebDocumentLinkAction(href)).toEqual({
|
||||||
|
type: "ignore"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
createPdfAnnotationLinkService,
|
||||||
|
resolvePdfDestinationPage,
|
||||||
|
resolvePdfNamedActionPage
|
||||||
|
} from "../src/pdf-annotation-links";
|
||||||
|
|
||||||
|
function createDocument() {
|
||||||
|
return {
|
||||||
|
numPages: 5,
|
||||||
|
getDestination: vi.fn(async (name: string) =>
|
||||||
|
name === "chapter" ? [{ num: 7, gen: 0 }] : null
|
||||||
|
),
|
||||||
|
getPageIndex: vi.fn(async () => 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PDF 精确预览链接服务", () => {
|
||||||
|
it("解析命名目标、显式引用和页索引目标", async () => {
|
||||||
|
const pdfDocument = createDocument();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolvePdfDestinationPage(pdfDocument, "chapter")
|
||||||
|
).resolves.toBe(3);
|
||||||
|
await expect(
|
||||||
|
resolvePdfDestinationPage(pdfDocument, [1, { name: "Fit" }])
|
||||||
|
).resolves.toBe(2);
|
||||||
|
await expect(
|
||||||
|
resolvePdfDestinationPage(pdfDocument, "missing")
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将外部 URI 交给平台链接策略且阻止默认导航", () => {
|
||||||
|
const onExternalLink = vi.fn();
|
||||||
|
const service = createPdfAnnotationLinkService(
|
||||||
|
createDocument(),
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage: vi.fn()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
service.addLinkAttributes(
|
||||||
|
link,
|
||||||
|
"https://example.com/document"
|
||||||
|
);
|
||||||
|
document.body.append(link);
|
||||||
|
|
||||||
|
expect(link.dataset.pdfExternalHref).toBe(
|
||||||
|
"https://example.com/document"
|
||||||
|
);
|
||||||
|
expect(link.rel).toBe("noopener noreferrer");
|
||||||
|
expect(
|
||||||
|
link.dispatchEvent(
|
||||||
|
new MouseEvent("click", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(onExternalLink).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/document"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在交给平台策略前还原 PDF 中编码的本地链接", () => {
|
||||||
|
const onExternalLink = vi.fn();
|
||||||
|
const service = createPdfAnnotationLinkService(
|
||||||
|
createDocument(),
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage: vi.fn()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
service.addLinkAttributes(
|
||||||
|
link,
|
||||||
|
encodeLocalDocumentLinkForPdf("../README.md") ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(link.dataset.pdfExternalHref).toBe("../README.md");
|
||||||
|
link.click();
|
||||||
|
expect(onExternalLink).toHaveBeenCalledWith("../README.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("导航到 PDF 内部目标并忽略损坏目标", async () => {
|
||||||
|
const onNavigateToPage = vi.fn();
|
||||||
|
const service = createPdfAnnotationLinkService(
|
||||||
|
createDocument(),
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
onExternalLink: vi.fn(),
|
||||||
|
onNavigateToPage
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.goToDestination("chapter");
|
||||||
|
await service.goToDestination("missing");
|
||||||
|
|
||||||
|
expect(onNavigateToPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNavigateToPage).toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只处理受支持的 PDF 命名翻页动作", () => {
|
||||||
|
expect(resolvePdfNamedActionPage("FirstPage", 3, 5)).toBe(1);
|
||||||
|
expect(resolvePdfNamedActionPage("LastPage", 3, 5)).toBe(5);
|
||||||
|
expect(resolvePdfNamedActionPage("NextPage", 5, 5)).toBe(5);
|
||||||
|
expect(resolvePdfNamedActionPage("PrevPage", 1, 5)).toBe(1);
|
||||||
|
expect(
|
||||||
|
resolvePdfNamedActionPage("Print", 3, 5)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ 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/application/package.json packages/application/package.json
|
||||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
|
COPY packages/preview-engine/package.json packages/preview-engine/package.json
|
||||||
COPY packages/renderer/package.json packages/renderer/package.json
|
COPY packages/renderer/package.json packages/renderer/package.json
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ RUN npm run build -w @md-to-pdf/markdown-echarts \
|
|||||||
&& npm run build -w @md-to-pdf/core \
|
&& npm run build -w @md-to-pdf/core \
|
||||||
&& npm run build -w @md-to-pdf/renderer \
|
&& npm run build -w @md-to-pdf/renderer \
|
||||||
&& npm run build -w @md-to-pdf/application \
|
&& npm run build -w @md-to-pdf/application \
|
||||||
|
&& npm run build -w @md-to-pdf/preview-engine \
|
||||||
&& npm run build -w @md-to-pdf/web \
|
&& npm run build -w @md-to-pdf/web \
|
||||||
&& npm run build -w @md-to-pdf/server
|
&& npm run build -w @md-to-pdf/server
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ 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/application/package.json packages/application/package.json
|
||||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
|
COPY packages/preview-engine/package.json packages/preview-engine/package.json
|
||||||
COPY packages/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
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ 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/application/package.json packages/application/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||||
|
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
|
||||||
COPY --chown=pwuser:pwuser packages/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
|
||||||
@@ -83,6 +87,7 @@ 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/application/dist ./packages/application/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
||||||
|
COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist
|
||||||
COPY --chown=pwuser:pwuser --from=build /app/packages/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
|
||||||
|
|||||||
+7
-7
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
|
|||||||
可以直接指定版本化镜像名称构建:
|
可以直接指定版本化镜像名称构建:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
|
|||||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0'
|
||||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
|
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.5 应用代码和生产
|
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.0 应用代码和生产
|
||||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||||
|
|
||||||
## 主题挂载
|
## 主题挂载
|
||||||
@@ -69,9 +69,9 @@ docker compose -f deploy\compose.yaml up -d
|
|||||||
目录下每套主题都应包含 `theme.json`。主题规范详见
|
目录下每套主题都应包含 `theme.json`。主题规范详见
|
||||||
[`docs/THEMES.md`](../docs/THEMES.md)。
|
[`docs/THEMES.md`](../docs/THEMES.md)。
|
||||||
|
|
||||||
从 v0.4.0 起,公司内部私有版本已内置 GitHub、Pixyll 和 Whitey 三套
|
当前内置 Typora Github、Typora Pixyll、Typora whitey 和
|
||||||
Typora 默认主题;挂载目录仍可用于本机扩展主题。内置主题与挂载主题 ID
|
Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,无需重复
|
||||||
重复时以内置版本为准。
|
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
|
||||||
|
|
||||||
## 运行参数
|
## 运行参数
|
||||||
|
|
||||||
|
|||||||
+95
-29
@@ -49,6 +49,12 @@ Web/Compose 发布仍保持 `v0.4.1`。
|
|||||||
本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序
|
本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序
|
||||||
串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。
|
串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。
|
||||||
|
|
||||||
|
`v0.5.0` 完成渲染架构和桌面文档工作流升级:分页与连续预览抽取为共享
|
||||||
|
`packages/preview-engine`,快速预览支持从修改位置复用稳定前缀;Web
|
||||||
|
增加连续预览和受控外链,Desktop 支持多窗口、同文件单例、本地链接及
|
||||||
|
聚焦时外部文件变化提示。四套主题统一命名,并修复围栏代码块的 Typora
|
||||||
|
DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -470,6 +476,37 @@ Web/Compose 发布仍保持 `v0.4.1`。
|
|||||||
- 根 README、Desktop README、四个工作区包 README、进度文档和发布
|
- 根 README、Desktop README、四个工作区包 README、进度文档和发布
|
||||||
规范随版本同步更新。
|
规范随版本同步更新。
|
||||||
|
|
||||||
|
### 3.19 v0.5.0 共享预览引擎与桌面多窗口
|
||||||
|
|
||||||
|
- 新增 `packages/preview-engine`,从 `apps/web` 抽取 Paged.js 运行时、
|
||||||
|
Mermaid、ECharts、图片尺寸适配、媒体回填、跨页表格和共享页面 CSS。
|
||||||
|
- Web 快速预览、服务端 Playwright PDF 与 Electron PDF 直接使用同一个
|
||||||
|
Preview Engine;Markdown 解析仍由 `packages/renderer` 的 markdown-it
|
||||||
|
管线负责,应用服务继续统一主题与图片资源。
|
||||||
|
- 新增“连续”预览模式,不创建纸张和分页节点,适合高频编辑;快速预览
|
||||||
|
保留分页反馈,精确预览与最终 PDF 继续作为权威结果。
|
||||||
|
- 增量分页根据源块稳定身份识别最早修改位置,复用此前稳定分页前缀,并
|
||||||
|
串行重算受影响后缀;最终 PDF 不依赖客户端缓存。
|
||||||
|
- Web 只处理当前文档锚点和 HTTP/HTTPS 外链,外链使用浏览器新窗口;
|
||||||
|
本地路径及不支持协议不会替换预览 iframe。
|
||||||
|
- Desktop 支持 HTTP/HTTPS、系统协议、相对或绝对本地路径及 `file:`
|
||||||
|
URI;Markdown 链接提示在当前窗口或新窗口打开。
|
||||||
|
- Desktop 改为多窗口,并按规范化文件路径保持同一文件单例;窗口聚焦时
|
||||||
|
比较磁盘修改时间,检测到外部变化后提示是否重新加载。
|
||||||
|
- 主窗口关闭后会选取仍存活的最新窗口作为状态持久化主窗口;窗口位置、
|
||||||
|
尺寸和最大化状态仍按约 2 秒防抖写入并在启动时修正到可见工作区。
|
||||||
|
- Core 新增跨端文档链接分类与 PDF 本地链接编码协议;精确 PDF 使用
|
||||||
|
`mdpdf.local.invalid` 暂存本地链接,PDF.js 注释层解码后交给平台。
|
||||||
|
- 内置主题名称统一为 Typora Github、Typora Pixyll、Typora whitey 和
|
||||||
|
Typora Clean,默认主题为 Typora Github;删除强制覆盖主题链接样式的
|
||||||
|
`!important`,保留主题自身设计。
|
||||||
|
- markdown-it 普通围栏输出 `pre.md-fences` 和可选 `lang`;Preview
|
||||||
|
Engine 仅重置内部语义 `code` 的重复行内盒模型,代码块与行内代码均
|
||||||
|
保持主题预期,Mermaid/ECharts 专用围栏不受影响。
|
||||||
|
- 根级 `build:web-runtime` 统一内嵌 Web 的六个工作区构建顺序;Desktop
|
||||||
|
`package` 和 `make` 强制先调用该脚本,防止安装包复制陈旧
|
||||||
|
`apps/web/dist`。
|
||||||
|
|
||||||
## 4. 已执行验证
|
## 4. 已执行验证
|
||||||
|
|
||||||
2026-07-28 在当前完整工作区成功执行:
|
2026-07-28 在当前完整工作区成功执行:
|
||||||
@@ -484,17 +521,44 @@ git diff --check
|
|||||||
结果:
|
结果:
|
||||||
|
|
||||||
- ECharts 围栏测试:36 项通过;
|
- ECharts 围栏测试:36 项通过;
|
||||||
- 共享配置测试:9 项通过;
|
- Core 共享协议测试:15 项通过;
|
||||||
- 渲染器测试:12 项通过;
|
- 渲染器测试:14 项通过;
|
||||||
- 应用服务测试:9 项通过;
|
- 应用服务测试:11 项通过;
|
||||||
- 前端测试:87 项通过;
|
- Preview Engine 测试:53 项通过;
|
||||||
|
- 前端测试:54 项通过;
|
||||||
- 后端测试:29 项通过;
|
- 后端测试:29 项通过;
|
||||||
- 桌面端测试:20 项通过;
|
- 桌面端测试:26 项通过;
|
||||||
- 全项目共 202 项测试通过;
|
- 全项目共 238 项测试通过;
|
||||||
- 全项目类型检查通过;
|
- 全项目类型检查通过;
|
||||||
- 生产构建通过;
|
- 生产构建通过;
|
||||||
- `git diff --check` 通过。
|
- `git diff --check` 通过。
|
||||||
|
|
||||||
|
`v0.5.0` 双端与发行验收结果:
|
||||||
|
|
||||||
|
- 正式镜像 `yixiong/md-to-pdf:v0.5.0` 内容 ID 为
|
||||||
|
`sha256:13a0955f3629e4e96d7af9d38230ead5ff6b8b70be2b35d5f8da9fdeb1f66474`,
|
||||||
|
大小 640,294,504 字节;Compose 使用该内容 ID 健康运行在
|
||||||
|
`http://localhost:8080`。
|
||||||
|
- Web 快速、连续和精确预览完成真实浏览器验证:连续模式只有外层滚动,
|
||||||
|
ECharts 使用完整内容宽度和配置高度;快速预览后缀编辑保留稳定前缀。
|
||||||
|
- Web 外链样式、受控新窗口和本地链接禁用通过;精确 PDF 注释层保留
|
||||||
|
HTTP/HTTPS 与锚点,并能将 `.invalid` 地址解码为原始本地路径。
|
||||||
|
- Desktop 多窗口、同一文件单例、主窗口关闭后的状态接管、最大化防抖
|
||||||
|
写入和重启恢复通过;本机安装版已升级至 v0.5.0。
|
||||||
|
- 围栏代码块在 Typora Github 中显示完整外框且内部不再逐行出现行内
|
||||||
|
代码方框;Web 与最终 Desktop 安装版均由用户确认,ECharts 围栏未受
|
||||||
|
普通代码块兼容样式影响。
|
||||||
|
- 正式 NSIS 为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,95,004,759
|
||||||
|
字节,SHA-256 为
|
||||||
|
`60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92`。
|
||||||
|
- 正式 ZIP 为 `md-to-pdf-0.5.0-x86_64.zip`,132,485,574 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8`。
|
||||||
|
- 安装版内嵌 `paged-preview` Bundle 与本地最新 Web Bundle 的
|
||||||
|
SHA-256 均为
|
||||||
|
`BD29762A2E3CF1A2AAC4DD055D623B42C754DF46A3E773DD99B01CDFDAE36CE5`;
|
||||||
|
`.md`、`.markdown` OpenWithProgids 注册正常。
|
||||||
|
|
||||||
`v0.4.5` 双端与媒体分页验收结果:
|
`v0.4.5` 双端与媒体分页验收结果:
|
||||||
|
|
||||||
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
|
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
|
||||||
@@ -740,21 +804,22 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
## 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-markdown-render.ts` 负责 Markdown 防抖请求、取消和
|
||||||
渲染错误状态。
|
渲染错误状态。
|
||||||
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
|
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
|
||||||
- `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。
|
- `packages/preview-engine/src/paged-document-runtime.ts` 是连续预览、
|
||||||
- `apps/web/src/mermaid-page-fit.ts` 负责超高 Mermaid 的单页等比例适配。
|
快速预览和 PDF 共用的媒体渲染、资源等待和 Paged.js 运行时。
|
||||||
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
|
- `packages/preview-engine/src/continuous-preview.ts` 与
|
||||||
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
|
`incremental-pagination.ts` 分别负责无分页 DOM 更新和后缀分页缓存。
|
||||||
|
- `packages/preview-engine/src/paged-preview.ts` 负责分页协议、共享文档
|
||||||
|
CSS、页眉页码和 Typora 代码围栏结构兼容。
|
||||||
|
- Mermaid、ECharts、图片适配、媒体回填及跨页表格实现均位于
|
||||||
|
`packages/preview-engine/src/`,不得在 Web 或 Desktop 复制第二套。
|
||||||
- `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/document-link.ts` 与 `pdf-annotation-links.ts` 负责 Web
|
||||||
|
链接动作和精确 PDF 注释层适配。
|
||||||
- `apps/web/src/PreviewPageControl.tsx` 和 `preview-page.ts` 负责当前页输入、
|
- `apps/web/src/PreviewPageControl.tsx` 和 `preview-page.ts` 负责当前页输入、
|
||||||
页码边界、滚动页识别和快速跳转。
|
页码边界、滚动页识别和快速跳转。
|
||||||
- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
|
- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
|
||||||
@@ -769,15 +834,17 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
||||||
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
||||||
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
||||||
- `apps/desktop/src/main.ts` 负责 Electron 生命周期、自定义应用协议、
|
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
|
||||||
应用窗口、IPC 校验和原生 PDF 保存。
|
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
|
||||||
|
组装、协议和生命周期。
|
||||||
|
- `apps/desktop/src/desktop-document-link.ts` 负责本地链接解析和
|
||||||
|
Markdown 目标识别。
|
||||||
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
|
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
|
||||||
调度、网络限制、打印和窗口复用。
|
调度、网络限制、打印和窗口复用。
|
||||||
- `apps/desktop/src/app-preload.ts` 与 `pdf-preload.ts` 分别定义应用
|
- `apps/desktop/src/app-preload.ts` 与 `pdf-preload.ts` 分别定义应用
|
||||||
窗口和 PDF 运行窗口的最小 IPC 能力。
|
窗口和 PDF 运行窗口的最小 IPC 能力。
|
||||||
- `.local/themes/typora-*` 仍可作为用户本机副本;v0.4.0 的三套发布版本
|
- `.local/themes/` 只用于外挂自定义主题;四套发布主题已经内置,无需
|
||||||
已复制到 `themes/` 并标记为内置主题。
|
重复挂载。历史副本保存在 `.local/theme-backups`,均被 Git 忽略。
|
||||||
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
|
|
||||||
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
|
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
|
||||||
未来改变分发范围前必须重新完成许可证审查。
|
未来改变分发范围前必须重新完成许可证审查。
|
||||||
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
||||||
@@ -786,20 +853,19 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
目录读取;远程图片仅保留有界内存缓存。
|
目录读取;远程图片仅保留有界内存缓存。
|
||||||
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
||||||
导出,不依赖 Fastify 或本地 HTTP Server。
|
导出,不依赖 Fastify 或本地 HTTP Server。
|
||||||
- `v0.4.5` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
|
- `v0.5.0` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
|
||||||
Compose 健康检查,当前 Compose 容器保持运行。
|
Compose 健康检查,当前 Compose 容器保持运行。
|
||||||
|
- Desktop `package` 和 `make` 必须经过 `build:embedded-web`;禁止直接
|
||||||
|
调用 electron-builder 复制未经本轮编译的 `apps/web/dist`。
|
||||||
|
|
||||||
## 6. 推荐接手顺序
|
## 6. 推荐接手顺序
|
||||||
|
|
||||||
### 阶段一:v0.4.6 超链接导航
|
### 阶段一:v0.5.x 稳定性与性能观测
|
||||||
|
|
||||||
- 在快速预览 iframe 中统一拦截链接点击,避免外部页面替换分页预览;
|
- 为增量分页补充超长文档性能基线,记录稳定前缀页数和后缀重算耗时;
|
||||||
- 文档内 `#anchor` 保持在当前预览中定位,并兼容分页后的标题锚点;
|
- 覆盖 Windows 100%、125% 和 150% 显示缩放的快速/精确分页差异;
|
||||||
- `http:`、`https:`、`mailto:` 等允许协议通过受控新窗口或桌面系统
|
- 扩展 Desktop 多窗口、文件外部变化和链接打开的真实端到端回归;
|
||||||
浏览器打开;
|
- 持续检查连续预览中媒体宽高、单滚动容器和资源释放。
|
||||||
- 拒绝 `javascript:`、`file:`、未知协议及未授权本地资源;
|
|
||||||
- Web 与 Desktop 共用链接分类规则,各平台只实现打开动作适配;
|
|
||||||
- 增加快速预览、精确预览和 Desktop 的链接行为测试。
|
|
||||||
|
|
||||||
### 阶段二:导出配置扩展
|
### 阶段二:导出配置扩展
|
||||||
|
|
||||||
|
|||||||
Generated
+24
-10
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
@@ -539,11 +539,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
"@mermaid-js/layout-elk": "0.2.2",
|
"@md-to-pdf/preview-engine": "0.1.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"mermaid": "11.16.0",
|
|
||||||
"pagedjs": "^0.4.3",
|
|
||||||
"pdfjs-dist": "6.1.200",
|
"pdfjs-dist": "6.1.200",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
@@ -1956,6 +1954,10 @@
|
|||||||
"resolved": "packages/markdown-echarts",
|
"resolved": "packages/markdown-echarts",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@md-to-pdf/preview-engine": {
|
||||||
|
"resolved": "packages/preview-engine",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@md-to-pdf/renderer": {
|
"node_modules/@md-to-pdf/renderer": {
|
||||||
"resolved": "packages/renderer",
|
"resolved": "packages/renderer",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -5505,7 +5507,6 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0",
|
"tslib": "2.3.0",
|
||||||
@@ -5516,7 +5517,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
@@ -9558,7 +9558,6 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0"
|
"tslib": "2.3.0"
|
||||||
@@ -9568,7 +9567,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"packages/application": {
|
"packages/application": {
|
||||||
@@ -9612,6 +9610,22 @@
|
|||||||
"markdown-it": "^14.3.0"
|
"markdown-it": "^14.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/preview-engine": {
|
||||||
|
"name": "@md-to-pdf/preview-engine",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
|
"@mermaid-js/layout-elk": "0.2.2",
|
||||||
|
"echarts": "6.1.0",
|
||||||
|
"mermaid": "11.16.0",
|
||||||
|
"pagedjs": "^0.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"happy-dom": "^20.11.1",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/renderer": {
|
"packages/renderer": {
|
||||||
"name": "@md-to-pdf/renderer",
|
"name": "@md-to-pdf/renderer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
+9
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -8,20 +8,22 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "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 && npm run build -w @md-to-pdf/desktop",
|
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||||
"dev": "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 && concurrently -k -n markdown-echarts,core,renderer,application,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:server\" \"npm:dev:web\"",
|
"build:web-runtime": "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/preview-engine && npm run build -w @md-to-pdf/web",
|
||||||
|
"dev": "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/preview-engine && concurrently -k -n markdown-echarts,core,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||||
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||||
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
||||||
"dev:application": "npm run dev -w @md-to-pdf/application",
|
"dev:application": "npm run dev -w @md-to-pdf/application",
|
||||||
|
"dev:preview-engine": "npm run dev -w @md-to-pdf/preview-engine",
|
||||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||||
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
||||||
"desktop:dev": "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 && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
"desktop:dev": "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/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||||
"desktop:package": "npm run build -w @md-to-pdf/web && npm run package -w @md-to-pdf/desktop",
|
"desktop:package": "npm run package -w @md-to-pdf/desktop",
|
||||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ src/
|
|||||||
index.ts 公共导出入口
|
index.ts 公共导出入口
|
||||||
tests/
|
tests/
|
||||||
application-service.test.ts
|
application-service.test.ts
|
||||||
|
bundled-themes.test.ts
|
||||||
image-resources.test.ts
|
image-resources.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建
|
|||||||
服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录,
|
服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录,
|
||||||
Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
||||||
|
|
||||||
|
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||||
|
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
||||||
|
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
||||||
|
|
||||||
## 开发与验证
|
## 开发与验证
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { lookup } from "node:dns/promises";
|
|||||||
import { readFile, realpath, stat } from "node:fs/promises";
|
import { readFile, realpath, stat } from "node:fs/promises";
|
||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
|
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
|
||||||
|
|
||||||
export const MAXIMUM_IMAGE_COUNT = 50;
|
export const MAXIMUM_IMAGE_COUNT = 50;
|
||||||
@@ -22,6 +23,7 @@ export interface MarkdownImageResource {
|
|||||||
|
|
||||||
export interface ImageResolutionContext {
|
export interface ImageResolutionContext {
|
||||||
localRoot?: string;
|
localRoot?: string;
|
||||||
|
allowUnrestrictedLocalFiles?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedMarkdownImages {
|
export interface ResolvedMarkdownImages {
|
||||||
@@ -313,6 +315,46 @@ async function loadLocalImage(root: string, source: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeUnrestrictedLocalPath(source: string) {
|
||||||
|
if (/^file:/iu.test(source)) {
|
||||||
|
try {
|
||||||
|
return fileURLToPath(new URL(source));
|
||||||
|
} catch {
|
||||||
|
throw new Error("file: 图片路径格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pathOnly = source.split(/[?#]/u, 1)[0] ?? "";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(pathOnly);
|
||||||
|
} catch {
|
||||||
|
throw new Error("图片路径 URL 编码无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnrestrictedLocalImage(
|
||||||
|
root: string,
|
||||||
|
source: string
|
||||||
|
) {
|
||||||
|
const decoded = decodeUnrestrictedLocalPath(source);
|
||||||
|
const target =
|
||||||
|
path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)
|
||||||
|
? path.normalize(decoded)
|
||||||
|
: path.resolve(root, decoded);
|
||||||
|
const realTarget = await realpath(target);
|
||||||
|
const targetStat = await stat(realTarget);
|
||||||
|
if (!targetStat.isFile()) {
|
||||||
|
throw new Error("图片资源不是文件");
|
||||||
|
}
|
||||||
|
if (targetStat.size > MAXIMUM_IMAGE_BYTES) {
|
||||||
|
throw new Error("本地图片超过单文件大小限制");
|
||||||
|
}
|
||||||
|
const content = await readFile(realTarget);
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
contentType: detectImageContentType(content)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseUploadedResources(value: unknown) {
|
function parseUploadedResources(value: unknown) {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return new Map<string, LoadedImage>();
|
return new Map<string, LoadedImage>();
|
||||||
@@ -413,12 +455,26 @@ export function createImageResourceResolver(
|
|||||||
if (/^https?:\/\//iu.test(source)) {
|
if (/^https?:\/\//iu.test(source)) {
|
||||||
image = await loadRemote(source);
|
image = await loadRemote(source);
|
||||||
} else {
|
} else {
|
||||||
const normalized = normalizeDocumentAssetPath(source);
|
let normalized: string | undefined;
|
||||||
const uploadedImage = uploaded.get(normalized);
|
try {
|
||||||
|
normalized = normalizeDocumentAssetPath(source);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.allowUnrestrictedLocalFiles) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uploadedImage = normalized
|
||||||
|
? uploaded.get(normalized)
|
||||||
|
: undefined;
|
||||||
if (uploadedImage) {
|
if (uploadedImage) {
|
||||||
image = uploadedImage;
|
image = uploadedImage;
|
||||||
} else if (context.localRoot) {
|
} else if (context.localRoot) {
|
||||||
image = await loadLocalImage(context.localRoot, source);
|
image = context.allowUnrestrictedLocalFiles
|
||||||
|
? await loadUnrestrictedLocalImage(
|
||||||
|
context.localRoot,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
: await loadLocalImage(context.localRoot, source);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("未提供对应素材目录");
|
throw new Error("未提供对应素材目录");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const bundledThemeNames = {
|
||||||
|
"typora-github": "Typora Github",
|
||||||
|
"typora-pixyll": "Typora Pixyll",
|
||||||
|
"typora-whitey": "Typora whitey",
|
||||||
|
"typora-like": "Typora Clean"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("内置主题清单", () => {
|
||||||
|
it("使用稳定主题 ID 和面向用户的显示名称", async () => {
|
||||||
|
const themesRoot = fileURLToPath(
|
||||||
|
new URL("../../../themes/", import.meta.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [id, name] of Object.entries(bundledThemeNames)) {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
`${themesRoot}${id}/theme.json`,
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
) as { id?: unknown; name?: unknown };
|
||||||
|
|
||||||
|
expect(manifest.id).toBe(id);
|
||||||
|
expect(manifest.name).toBe(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
createImageResourceResolver,
|
createImageResourceResolver,
|
||||||
@@ -59,4 +63,41 @@ describe("Markdown 图片资源安全", () => {
|
|||||||
);
|
);
|
||||||
expect(failed.warnings[0]).toContain("网络不可用");
|
expect(failed.warnings[0]).toContain("网络不可用");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("仅在显式授权时允许桌面读取上级、绝对及 file URI 图片", async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-local-images-")
|
||||||
|
);
|
||||||
|
const documentRoot = path.join(directory, "docs");
|
||||||
|
const imagePath = path.join(directory, "共享图片.png");
|
||||||
|
try {
|
||||||
|
await writeFile(imagePath, png);
|
||||||
|
const resolve = createImageResourceResolver();
|
||||||
|
const sources = [
|
||||||
|
"../共享图片.png",
|
||||||
|
imagePath,
|
||||||
|
pathToFileURL(imagePath).href
|
||||||
|
];
|
||||||
|
const markdown = sources
|
||||||
|
.map((source) => `})`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const restricted = await resolve(markdown, undefined, {
|
||||||
|
localRoot: documentRoot
|
||||||
|
});
|
||||||
|
expect(restricted.warnings).toHaveLength(3);
|
||||||
|
|
||||||
|
const unrestricted = await resolve(markdown, undefined, {
|
||||||
|
localRoot: documentRoot,
|
||||||
|
allowUnrestrictedLocalFiles: true
|
||||||
|
});
|
||||||
|
expect(unrestricted.warnings).toEqual([]);
|
||||||
|
expect(unrestricted.sources).toHaveLength(3);
|
||||||
|
for (const dataUrl of unrestricted.sources.values()) {
|
||||||
|
expect(dataUrl).toMatch(/^data:image\/png;base64,/u);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
document.ts Markdown 文档、分页载荷、结果与耗时模型
|
document.ts Markdown 文档、分页载荷、结果与耗时模型
|
||||||
|
document-link.ts 跨端链接分类与 PDF 本地链接编码协议
|
||||||
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
|
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
|
||||||
theme.ts 主题清单、主题能力与 CSS 载荷模型
|
theme.ts 主题清单、主题能力与 CSS 载荷模型
|
||||||
index.ts 公共导出入口
|
index.ts 公共导出入口
|
||||||
tests/
|
tests/
|
||||||
document.test.ts
|
document.test.ts
|
||||||
|
document-link.test.ts
|
||||||
export-config.test.ts
|
export-config.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -20,11 +22,13 @@ tests/
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import {
|
import {
|
||||||
|
classifyDocumentLink,
|
||||||
createPagedDocumentPayload,
|
createPagedDocumentPayload,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
exportConfigSchema
|
exportConfigSchema
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
const link = classifyDocumentLink("../docs/example.md");
|
||||||
const config = exportConfigSchema.parse(defaultExportConfig);
|
const config = exportConfigSchema.parse(defaultExportConfig);
|
||||||
const payload = createPagedDocumentPayload({
|
const payload = createPagedDocumentPayload({
|
||||||
document,
|
document,
|
||||||
@@ -34,6 +38,11 @@ const payload = createPagedDocumentPayload({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
|
||||||
|
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
|
||||||
|
Markdown 窗口。精确 PDF 使用保留的 `.invalid` 地址暂存本地链接,
|
||||||
|
PDF.js 注释层展示时再安全解码,避免 Chromium 将相对路径误转成站点 URL。
|
||||||
|
|
||||||
新增跨端字段时,应先在这里定义类型、默认值、Zod 校验和兼容迁移,再由
|
新增跨端字段时,应先在这里定义类型、默认值、Zod 校验和兼容迁移,再由
|
||||||
各适配层消费,避免 Web 与 Desktop 分别维护协议。
|
各适配层消费,避免 Web 与 Desktop 分别维护协议。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
export type DocumentLinkKind =
|
||||||
|
| "anchor"
|
||||||
|
| "network"
|
||||||
|
| "protocol"
|
||||||
|
| "local"
|
||||||
|
| "unsafe"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
|
export interface ClassifiedDocumentLink {
|
||||||
|
kind: DocumentLinkKind;
|
||||||
|
href: string;
|
||||||
|
normalizedHref: string;
|
||||||
|
scheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemePattern = /^([a-z][a-z\d+.-]*):/iu;
|
||||||
|
const windowsAbsolutePathPattern = /^[a-z]:[\\/]/iu;
|
||||||
|
const windowsUncPathPattern = /^\\\\[^\\]/u;
|
||||||
|
const unsafeSchemePattern = /^(?:data|javascript|vbscript)$/u;
|
||||||
|
const controlCharacterPattern = /[\u0000-\u001f\u007f]/u;
|
||||||
|
const pdfLocalLinkOrigin = "https://mdpdf.local.invalid";
|
||||||
|
const pdfLocalLinkPath = "/document-link";
|
||||||
|
|
||||||
|
function result(
|
||||||
|
kind: DocumentLinkKind,
|
||||||
|
href: string,
|
||||||
|
normalizedHref = href,
|
||||||
|
scheme?: string
|
||||||
|
): ClassifiedDocumentLink {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
href,
|
||||||
|
normalizedHref,
|
||||||
|
...(scheme ? { scheme } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyDocumentLink(
|
||||||
|
rawHref: string
|
||||||
|
): ClassifiedDocumentLink {
|
||||||
|
const href = rawHref.trim();
|
||||||
|
if (!href || controlCharacterPattern.test(href)) {
|
||||||
|
return result("invalid", href);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href.startsWith("#")) {
|
||||||
|
return result("anchor", href);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
windowsAbsolutePathPattern.test(href) ||
|
||||||
|
windowsUncPathPattern.test(href)
|
||||||
|
) {
|
||||||
|
return result("local", href);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href.startsWith("//")) {
|
||||||
|
return result("network", href, `https:${href}`, "https");
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemeMatch = schemePattern.exec(href);
|
||||||
|
if (!schemeMatch) {
|
||||||
|
return result("local", href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = schemeMatch[1]?.toLowerCase() ?? "";
|
||||||
|
if (unsafeSchemePattern.test(scheme)) {
|
||||||
|
return result("unsafe", href, href, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme === "http" || scheme === "https") {
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
if (!url.hostname) {
|
||||||
|
return result("invalid", href, href, scheme);
|
||||||
|
}
|
||||||
|
return result("network", href, href, scheme);
|
||||||
|
} catch {
|
||||||
|
return result("invalid", href, href, scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme === "file") {
|
||||||
|
return result("local", href, href, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result("protocol", href, href, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeDocumentLink(rawHref: string) {
|
||||||
|
const { kind } = classifyDocumentLink(rawHref);
|
||||||
|
return kind !== "unsafe" && kind !== "invalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeLocalDocumentLinkForPdf(rawHref: string) {
|
||||||
|
const link = classifyDocumentLink(rawHref);
|
||||||
|
if (link.kind !== "local") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const encoded = new URL(pdfLocalLinkPath, pdfLocalLinkOrigin);
|
||||||
|
encoded.searchParams.set("href", link.href);
|
||||||
|
return encoded.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeLocalDocumentLinkFromPdf(rawHref: string) {
|
||||||
|
let encoded: URL;
|
||||||
|
try {
|
||||||
|
encoded = new URL(rawHref);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
encoded.origin !== pdfLocalLinkOrigin ||
|
||||||
|
encoded.pathname !== pdfLocalLinkPath
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const href = encoded.searchParams.get("href");
|
||||||
|
if (!href || classifyDocumentLink(href).kind !== "local") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return href;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./document.js";
|
export * from "./document.js";
|
||||||
|
export * from "./document-link.js";
|
||||||
export * from "./export-config.js";
|
export * from "./export-config.js";
|
||||||
export * from "./theme.js";
|
export * from "./theme.js";
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyDocumentLink,
|
||||||
|
decodeLocalDocumentLinkFromPdf,
|
||||||
|
encodeLocalDocumentLinkForPdf,
|
||||||
|
isSafeDocumentLink
|
||||||
|
} from "../src/document-link.js";
|
||||||
|
|
||||||
|
describe("文档链接分类", () => {
|
||||||
|
it("识别文档锚点和网络链接", () => {
|
||||||
|
expect(classifyDocumentLink("#章节一").kind).toBe("anchor");
|
||||||
|
expect(
|
||||||
|
classifyDocumentLink("https://example.com/a").kind
|
||||||
|
).toBe("network");
|
||||||
|
expect(
|
||||||
|
classifyDocumentLink("//example.com/a").normalizedHref
|
||||||
|
).toBe("https://example.com/a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("识别浏览器协议处理器和未知协议", () => {
|
||||||
|
expect(classifyDocumentLink("mailto:a@example.com")).toMatchObject({
|
||||||
|
kind: "protocol",
|
||||||
|
scheme: "mailto"
|
||||||
|
});
|
||||||
|
expect(classifyDocumentLink("tel:+8612345")).toMatchObject({
|
||||||
|
kind: "protocol",
|
||||||
|
scheme: "tel"
|
||||||
|
});
|
||||||
|
expect(classifyDocumentLink("obsidian://open?vault=x")).toMatchObject({
|
||||||
|
kind: "protocol",
|
||||||
|
scheme: "obsidian"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("识别跨目录、绝对路径、UNC 和 file URI", () => {
|
||||||
|
for (const href of [
|
||||||
|
"../docs/a.md",
|
||||||
|
"/opt/docs/a.md",
|
||||||
|
"C:/docs/a.md",
|
||||||
|
"D:\\docs\\a.md",
|
||||||
|
"\\\\server\\share\\a.md",
|
||||||
|
"file:///C:/docs/a.md"
|
||||||
|
]) {
|
||||||
|
expect(classifyDocumentLink(href).kind).toBe("local");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝危险协议、控制字符和无效网络地址", () => {
|
||||||
|
for (const href of [
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"vbscript:msgbox(1)",
|
||||||
|
"data:text/html;base64,WA=="
|
||||||
|
]) {
|
||||||
|
expect(classifyDocumentLink(href).kind).toBe("unsafe");
|
||||||
|
expect(isSafeDocumentLink(href)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(classifyDocumentLink("https://[invalid").kind).toBe(
|
||||||
|
"invalid"
|
||||||
|
);
|
||||||
|
expect(classifyDocumentLink("a\u0000b").kind).toBe("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("为 PDF 注释编码并还原本地链接", () => {
|
||||||
|
for (const href of [
|
||||||
|
"../docs/说明.md#章节",
|
||||||
|
"C:/docs/a.md",
|
||||||
|
"file:///D:/docs/a.md"
|
||||||
|
]) {
|
||||||
|
const encoded = encodeLocalDocumentLinkForPdf(href);
|
||||||
|
expect(encoded).toMatch(
|
||||||
|
/^https:\/\/mdpdf\.local\.invalid\/document-link\?/u
|
||||||
|
);
|
||||||
|
expect(decodeLocalDocumentLinkFromPdf(encoded ?? "")).toBe(
|
||||||
|
href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不编码网络链接且拒绝伪造的 PDF 本地链接", () => {
|
||||||
|
expect(
|
||||||
|
encodeLocalDocumentLinkForPdf("https://example.com")
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
decodeLocalDocumentLinkFromPdf(
|
||||||
|
"https://mdpdf.local.invalid/document-link?href=https%3A%2F%2Fexample.com"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
decodeLocalDocumentLinkFromPdf(
|
||||||
|
"https://example.com/document-link?href=..%2Fa.md"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,7 @@ describe("导出配置", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("使用 A4 和 16mm 作为默认纸张配置", () => {
|
it("使用 A4 和 16mm 作为默认纸张配置", () => {
|
||||||
|
expect(defaultExportConfig.themeId).toBe("typora-github");
|
||||||
expect(defaultExportConfig.paper).toEqual({
|
expect(defaultExportConfig.paper).toEqual({
|
||||||
format: "A4",
|
format: "A4",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
||||||
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
||||||
|
本项目由 `@md-to-pdf/renderer` 接入围栏插件,再由
|
||||||
|
`@md-to-pdf/preview-engine` 统一完成连续预览、分页适配和 SVG 冻结。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -281,6 +283,8 @@ Chromium `page.pdf()`。
|
|||||||
|
|
||||||
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
|
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
|
||||||
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
|
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
|
||||||
|
ECharts 围栏使用专用 `figure.md-echarts` DOM,不会套用普通代码围栏的
|
||||||
|
`pre.md-fences` 样式。
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# @md-to-pdf/preview-engine
|
||||||
|
|
||||||
|
Markdown PDF 导出器的共享预览与分页引擎。Web 连续/快速预览、服务端
|
||||||
|
Playwright PDF 和 Electron PDF 均通过同一套运行时完成媒体渲染、尺寸
|
||||||
|
适配与 Paged.js 分页。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── index.ts 公共导出入口
|
||||||
|
├── paged-document-runtime.ts 连续与分页渲染主流程
|
||||||
|
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
|
||||||
|
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
|
||||||
|
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
|
||||||
|
├── paged-table-handler.ts 跨页表格表头处理
|
||||||
|
├── media-page-backfill.ts 图片与图表按文档顺序回填
|
||||||
|
├── document-image-fit.ts Markdown 图片单页适配
|
||||||
|
├── mermaid-*.ts Mermaid 配置、渲染与尺寸适配
|
||||||
|
├── echarts-page-fit.ts ECharts 单页适配
|
||||||
|
├── diagram-page-fit.ts 图像类元素的共享几何计算
|
||||||
|
├── pdf-document-links.ts PDF 本地链接暂存编码
|
||||||
|
├── preview-styles.ts 预览/打印媒体样式转换
|
||||||
|
└── paged-render-target.ts Preview 与 PDF 运行目标
|
||||||
|
tests/ 引擎单元测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
宿主负责提供渲染根节点和三份第三方样式,公共引擎不依赖 Vite 的
|
||||||
|
`?inline` 导入语法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { PagedDocumentRuntime } from "@md-to-pdf/preview-engine";
|
||||||
|
|
||||||
|
const runtime = new PagedDocumentRuntime(root, {
|
||||||
|
highlightCss,
|
||||||
|
katexCss,
|
||||||
|
echartsCss
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.render(payload, {
|
||||||
|
target: "preview",
|
||||||
|
mermaidOutput: "svg-image"
|
||||||
|
});
|
||||||
|
|
||||||
|
const continuous = await runtime.renderContinuous(payload);
|
||||||
|
```
|
||||||
|
|
||||||
|
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
|
||||||
|
纸张尺寸、页边距、页眉页脚及 Markdown 功能标识都随载荷传入。
|
||||||
|
|
||||||
|
## 连续预览与增量分页
|
||||||
|
|
||||||
|
- `renderContinuous()` 不创建纸张和分页节点,只更新统一 `#write` DOM;
|
||||||
|
- 连续模式仍等待并渲染 Mermaid、ECharts、图片和字体;
|
||||||
|
- 快速分页为源块生成稳定身份,编辑后保留修改点之前的分页结果;
|
||||||
|
- 重新计算从最早受影响位置开始,后续页面串行生成;
|
||||||
|
- 缓存不参与 Playwright/Electron 的最终 PDF 权威输出。
|
||||||
|
|
||||||
|
围栏代码块遵循 Typora DOM 约定:外层 `pre.md-fences` 负责主题样式,
|
||||||
|
内部语义 `<code>` 会清除重复的行内代码背景、边框、内边距和字号缩放,
|
||||||
|
不使用 `!important`,自定义主题仍可用更具体规则覆盖。
|
||||||
|
|
||||||
|
## 媒体分页
|
||||||
|
|
||||||
|
图片、Mermaid 和 ECharts 必须与紧邻标题作为同一媒体块。引擎按 DOM
|
||||||
|
文档顺序串行处理,每个媒体元素至多回填或缩放一次。图片先按内容限宽
|
||||||
|
得到基准高度;上一页空白与媒体需求接近时,才尝试只缩放媒体主体回填,
|
||||||
|
标题始终保持自然尺寸。
|
||||||
|
|
||||||
|
## PDF 链接
|
||||||
|
|
||||||
|
`preparePdfDocumentLinks()` 仅在 PDF 目标中将本地链接编码为
|
||||||
|
`https://mdpdf.local.invalid/...`。PDF.js 注释层通过 core 解码后交给
|
||||||
|
Web/Desktop 平台处理,HTTP/HTTPS 和文档内锚点保持原始 PDF 行为。
|
||||||
|
|
||||||
|
## 设计边界
|
||||||
|
|
||||||
|
- 本包不解析 Markdown;语义 HTML 由 `@md-to-pdf/renderer` 生成。
|
||||||
|
- 本包不读取文件、不发 HTTP 请求,也不决定 Web 或 Electron 平台行为。
|
||||||
|
- 第三方展示样式由宿主注入,便于浏览器、Playwright 和 Electron 复用。
|
||||||
|
- 媒体块按文档顺序串行判断回填,每个媒体元素至多触发一次重排。
|
||||||
|
- 连续与分页模式共享媒体渲染、链接和样式契约,不维护两套引擎。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test -w @md-to-pdf/preview-engine
|
||||||
|
npm run typecheck -w @md-to-pdf/preview-engine
|
||||||
|
npm run build -w @md-to-pdf/preview-engine
|
||||||
|
```
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@md-to-pdf/preview-engine",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
|
"@mermaid-js/layout-elk": "0.2.2",
|
||||||
|
"echarts": "6.1.0",
|
||||||
|
"mermaid": "11.16.0",
|
||||||
|
"pagedjs": "^0.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"happy-dom": "^20.11.1",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export function findCommonPrefixLength(
|
||||||
|
previous: readonly string[],
|
||||||
|
next: readonly string[]
|
||||||
|
) {
|
||||||
|
const maximum = Math.min(previous.length, next.length);
|
||||||
|
let index = 0;
|
||||||
|
while (index < maximum && previous[index] === next[index]) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNodeSignature(node: Node) {
|
||||||
|
if (node instanceof Element) {
|
||||||
|
return `element:${node.outerHTML}`;
|
||||||
|
}
|
||||||
|
return `node:${node.nodeType}:${node.textContent ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountContinuousRenderStage(
|
||||||
|
root: HTMLElement,
|
||||||
|
articleTemplate: HTMLElement,
|
||||||
|
content: DocumentFragment
|
||||||
|
) {
|
||||||
|
const article = articleTemplate.cloneNode(false) as HTMLElement;
|
||||||
|
article.dataset.continuousRenderStage = "true";
|
||||||
|
article.append(content);
|
||||||
|
root.append(article);
|
||||||
|
|
||||||
|
return {
|
||||||
|
article,
|
||||||
|
takeContent() {
|
||||||
|
const rendered = article.ownerDocument.createDocumentFragment();
|
||||||
|
rendered.append(...Array.from(article.childNodes));
|
||||||
|
return rendered;
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
article.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
ensurePageBreakAfter,
|
ensurePageBreakAfter,
|
||||||
getPageContentDimensions,
|
getPageContentDimensions,
|
||||||
getPrecedingMediaTitleHeight
|
getPrecedingMediaTitleHeight
|
||||||
} from "./diagram-page-fit";
|
} from "./diagram-page-fit.js";
|
||||||
|
|
||||||
function getImageBlock(image: HTMLImageElement) {
|
function getImageBlock(image: HTMLImageElement) {
|
||||||
const parent = image.parentElement;
|
const parent = image.parentElement;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ExportConfig } from "@md-to-pdf/core";
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
import { fitSvgDiagramsToPage } from "./diagram-page-fit.js";
|
||||||
|
|
||||||
export function fitOversizedEChartsToPage(
|
export function fitOversizedEChartsToPage(
|
||||||
container: ParentNode,
|
container: ParentNode,
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
export interface PagedBreakTokenLike {
|
||||||
|
node?: Node;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedPageLike {
|
||||||
|
element: HTMLElement;
|
||||||
|
startToken?: PagedBreakTokenLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncrementalPaginationPlan {
|
||||||
|
prefixLength: number;
|
||||||
|
invalidationPageIndex: number;
|
||||||
|
startToken: PagedBreakTokenLike | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeReference(node: Node | undefined) {
|
||||||
|
if (!node) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (node instanceof HTMLElement && node.dataset.ref) {
|
||||||
|
return node.dataset.ref;
|
||||||
|
}
|
||||||
|
return node.parentElement?.dataset.ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageContainsReference(
|
||||||
|
page: PagedPageLike,
|
||||||
|
reference: string
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
page.element.querySelectorAll<HTMLElement>("[data-ref]")
|
||||||
|
).some((element) => element.dataset.ref === reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSourceNodePageIndex(
|
||||||
|
pages: readonly PagedPageLike[],
|
||||||
|
sourceNode: Node | undefined
|
||||||
|
) {
|
||||||
|
const reference = getNodeReference(sourceNode);
|
||||||
|
if (!reference) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const pageIndex = pages.findIndex((page) =>
|
||||||
|
pageContainsReference(page, reference)
|
||||||
|
);
|
||||||
|
return pageIndex >= 0 ? pageIndex : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIncrementalPaginationPlan(
|
||||||
|
prefixLength: number,
|
||||||
|
previousNodeCount: number,
|
||||||
|
sourceNodes: readonly Node[],
|
||||||
|
pages: readonly PagedPageLike[]
|
||||||
|
): IncrementalPaginationPlan | undefined {
|
||||||
|
if (
|
||||||
|
prefixLength <= 0 ||
|
||||||
|
pages.length === 0 ||
|
||||||
|
prefixLength > sourceNodes.length ||
|
||||||
|
prefixLength > previousNodeCount
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundaryNode =
|
||||||
|
sourceNodes[prefixLength] ??
|
||||||
|
sourceNodes[prefixLength - 1];
|
||||||
|
const boundaryPageIndex =
|
||||||
|
findSourceNodePageIndex(pages, boundaryNode);
|
||||||
|
if (boundaryPageIndex === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 媒体块允许回填到上一页,因此变化边界必须额外向前回退一页。
|
||||||
|
const invalidationPageIndex = Math.max(
|
||||||
|
0,
|
||||||
|
boundaryPageIndex - 1
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
prefixLength,
|
||||||
|
invalidationPageIndex,
|
||||||
|
startToken: pages[invalidationPageIndex]?.startToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignPagedSourceReferences(
|
||||||
|
root: Node,
|
||||||
|
createReference: () => string
|
||||||
|
) {
|
||||||
|
const elements: Element[] = [];
|
||||||
|
if (root instanceof Element) {
|
||||||
|
elements.push(root);
|
||||||
|
}
|
||||||
|
const queryRoot = root as Node & {
|
||||||
|
querySelectorAll?: ParentNode["querySelectorAll"];
|
||||||
|
};
|
||||||
|
if (typeof queryRoot.querySelectorAll === "function") {
|
||||||
|
elements.push(...Array.from(queryRoot.querySelectorAll("*")));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (!element.hasAttribute("data-ref")) {
|
||||||
|
element.setAttribute("data-ref", createReference());
|
||||||
|
}
|
||||||
|
if (element.id) {
|
||||||
|
element.setAttribute("data-id", element.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export * from "./diagram-page-fit.js";
|
||||||
|
export * from "./continuous-preview.js";
|
||||||
|
export * from "./document-image-fit.js";
|
||||||
|
export * from "./echarts-page-fit.js";
|
||||||
|
export * from "./incremental-pagination.js";
|
||||||
|
export * from "./media-page-backfill.js";
|
||||||
|
export * from "./mermaid-config.js";
|
||||||
|
export * from "./mermaid-page-fit.js";
|
||||||
|
export * from "./mermaid-renderer.js";
|
||||||
|
export * from "./mermaid-static-image.js";
|
||||||
|
export * from "./paged-break-token.js";
|
||||||
|
export * from "./paged-document-runtime.js";
|
||||||
|
export * from "./paged-preview.js";
|
||||||
|
export * from "./paged-render-target.js";
|
||||||
|
export * from "./pdf-document-links.js";
|
||||||
|
export * from "./preview-styles.js";
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
parseSvgViewBox,
|
parseSvgViewBox,
|
||||||
type DiagramDimensions,
|
type DiagramDimensions,
|
||||||
type DiagramPageFit
|
type DiagramPageFit
|
||||||
} from "./diagram-page-fit";
|
} from "./diagram-page-fit.js";
|
||||||
|
|
||||||
export type MermaidDimensions = DiagramDimensions;
|
export type MermaidDimensions = DiagramDimensions;
|
||||||
export type MermaidPageFit = DiagramPageFit;
|
export type MermaidPageFit = DiagramPageFit;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ export interface PagedPreviewRenderRequest {
|
|||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
type: "render";
|
type: "render";
|
||||||
requestId: number;
|
requestId: number;
|
||||||
|
layout: "paged" | "continuous";
|
||||||
payload: PagedPreviewPayload;
|
payload: PagedPreviewPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,15 +22,22 @@ export type PagedPreviewFrameMessage =
|
|||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
type: "ready";
|
type: "ready";
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
|
type: "link";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
type: "rendering";
|
type: "rendering";
|
||||||
requestId: number;
|
requestId: number;
|
||||||
|
layout: "paged" | "continuous";
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||||
type: "rendered";
|
type: "rendered";
|
||||||
requestId: number;
|
requestId: number;
|
||||||
|
layout: "paged" | "continuous";
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
contentHeight: number;
|
contentHeight: number;
|
||||||
echartsErrors: string[];
|
echartsErrors: string[];
|
||||||
@@ -67,6 +75,20 @@ svg {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#write pre.md-fences > code {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.md-document-image-block {
|
.md-document-image-block {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
@@ -133,6 +155,60 @@ body,
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const documentInteractionCss = `
|
||||||
|
:where(#write a[href]) {
|
||||||
|
color: var(--md-link-color, var(--md-accent, #0969da));
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 0.16em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(#write a[href]:hover) {
|
||||||
|
color: var(--md-link-hover-color, #0550ae);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(#write a[href]:focus-visible) {
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const continuousDocumentGeometryCss = `
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#preview-root {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-root {
|
||||||
|
padding: 34px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#write {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 16mm !important;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 3px 16px rgb(34 43 38 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#write[data-continuous-render-stage="true"] {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 auto auto 0 !important;
|
||||||
|
z-index: -1 !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function cssString(value: string) {
|
function cssString(value: string) {
|
||||||
return JSON.stringify(value)
|
return JSON.stringify(value)
|
||||||
.replace(/\u2028/g, "\\2028 ")
|
.replace(/\u2028/g, "\\2028 ")
|
||||||
@@ -398,12 +474,14 @@ html[data-render-target="pdf"] .pagedjs_page {
|
|||||||
|
|
||||||
export function createPagedPreviewRenderRequest(
|
export function createPagedPreviewRenderRequest(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload,
|
||||||
|
layout: "paged" | "continuous" = "paged"
|
||||||
): PagedPreviewRenderRequest {
|
): PagedPreviewRenderRequest {
|
||||||
return {
|
return {
|
||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "render",
|
type: "render",
|
||||||
requestId,
|
requestId,
|
||||||
|
layout,
|
||||||
payload
|
payload
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -420,6 +498,8 @@ export function isPagedPreviewRenderRequest(
|
|||||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
||||||
candidate.type === "render" &&
|
candidate.type === "render" &&
|
||||||
Number.isInteger(candidate.requestId) &&
|
Number.isInteger(candidate.requestId) &&
|
||||||
|
(candidate.layout === "paged" ||
|
||||||
|
candidate.layout === "continuous") &&
|
||||||
Boolean(candidate.payload) &&
|
Boolean(candidate.payload) &&
|
||||||
typeof candidate.payload?.articleHtml === "string" &&
|
typeof candidate.payload?.articleHtml === "string" &&
|
||||||
typeof candidate.payload?.themeCss === "string"
|
typeof candidate.payload?.themeCss === "string"
|
||||||
@@ -436,24 +516,54 @@ export function isPagedPreviewFrameMessage(
|
|||||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||||
if (
|
if (
|
||||||
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||||
!["ready", "rendering", "rendered", "error"].includes(
|
!["ready", "link", "rendering", "rendered", "error"].includes(
|
||||||
candidate.type ?? ""
|
candidate.type ?? ""
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidate.type !== "rendered") {
|
if (candidate.type === "ready") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate.type === "link") {
|
||||||
return (
|
return (
|
||||||
|
typeof candidate.href === "string" &&
|
||||||
|
candidate.href.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.type === "rendering") {
|
||||||
|
return (
|
||||||
Number.isInteger(candidate.requestId) &&
|
Number.isInteger(candidate.requestId) &&
|
||||||
Number.isInteger(candidate.pageCount) &&
|
(candidate.layout === "paged" ||
|
||||||
typeof candidate.contentHeight === "number" &&
|
candidate.layout === "continuous")
|
||||||
Number.isFinite(candidate.contentHeight) &&
|
);
|
||||||
candidate.contentHeight > 0 &&
|
}
|
||||||
Array.isArray(candidate.echartsErrors) &&
|
|
||||||
Array.isArray(candidate.mermaidErrors)
|
if (candidate.type === "error") {
|
||||||
|
return (
|
||||||
|
Number.isInteger(candidate.requestId) &&
|
||||||
|
typeof candidate.message === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.type !== "rendered") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rendered = candidate as Partial<
|
||||||
|
Extract<PagedPreviewFrameMessage, { type: "rendered" }>
|
||||||
|
>;
|
||||||
|
return (
|
||||||
|
Number.isInteger(rendered.requestId) &&
|
||||||
|
(rendered.layout === "paged" ||
|
||||||
|
rendered.layout === "continuous") &&
|
||||||
|
Number.isInteger(rendered.pageCount) &&
|
||||||
|
typeof rendered.contentHeight === "number" &&
|
||||||
|
Number.isFinite(rendered.contentHeight) &&
|
||||||
|
rendered.contentHeight > 0 &&
|
||||||
|
Array.isArray(rendered.echartsErrors) &&
|
||||||
|
Array.isArray(rendered.mermaidErrors)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { Handler, registerHandlers } from "pagedjs";
|
import { Handler, registerHandlers } from "pagedjs";
|
||||||
import { resolveBreakTokenElement } from "./paged-break-token";
|
import { resolveBreakTokenElement } from "./paged-break-token.js";
|
||||||
|
|
||||||
interface PagedBreakToken {
|
interface PagedBreakToken {
|
||||||
node?: Node;
|
node?: Node;
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
declare module "pagedjs" {
|
||||||
|
export interface PagedBreakToken {
|
||||||
|
node?: Node;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedPage {
|
||||||
|
element: HTMLElement;
|
||||||
|
startToken?: PagedBreakToken;
|
||||||
|
endToken?: PagedBreakToken;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedFlow {
|
||||||
|
total: number;
|
||||||
|
pages: PagedPage[];
|
||||||
|
performance: number;
|
||||||
|
size: {
|
||||||
|
width: { value: number; unit: string };
|
||||||
|
height: { value: number; unit: string };
|
||||||
|
format?: string;
|
||||||
|
orientation?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PagedStylesheet = string | Record<string, string>;
|
||||||
|
|
||||||
|
export class Previewer {
|
||||||
|
chunker: {
|
||||||
|
source: DocumentFragment;
|
||||||
|
pages: PagedPage[];
|
||||||
|
total: number;
|
||||||
|
rendered: boolean;
|
||||||
|
pagesArea: HTMLElement;
|
||||||
|
hooks: {
|
||||||
|
afterParsed: {
|
||||||
|
trigger(
|
||||||
|
parsed: DocumentFragment,
|
||||||
|
chunker: unknown
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
afterRendered: {
|
||||||
|
trigger(
|
||||||
|
pages: PagedPage[],
|
||||||
|
chunker: unknown
|
||||||
|
): Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
loadFonts(): Promise<void>;
|
||||||
|
removePages(fromIndex?: number): void;
|
||||||
|
render(
|
||||||
|
parsed: DocumentFragment,
|
||||||
|
startAt?: PagedBreakToken
|
||||||
|
): Promise<{ done: boolean; canceled?: boolean }>;
|
||||||
|
destroy(): void;
|
||||||
|
};
|
||||||
|
polisher: {
|
||||||
|
destroy(): void;
|
||||||
|
};
|
||||||
|
preview(
|
||||||
|
content?: HTMLElement | DocumentFragment | string,
|
||||||
|
stylesheets?: PagedStylesheet[],
|
||||||
|
renderTo?: HTMLElement | string
|
||||||
|
): Promise<PagedFlow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Handler {
|
||||||
|
constructor(
|
||||||
|
chunker: unknown,
|
||||||
|
polisher: unknown,
|
||||||
|
caller: unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHandlers(
|
||||||
|
...handlers: Array<typeof Handler>
|
||||||
|
): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export function preparePdfDocumentLinks(root: ParentNode) {
|
||||||
|
let rewritten = 0;
|
||||||
|
for (const link of root.querySelectorAll<HTMLAnchorElement>(
|
||||||
|
"a[href]"
|
||||||
|
)) {
|
||||||
|
const href = link.getAttribute("href");
|
||||||
|
if (!href) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const encoded = encodeLocalDocumentLinkForPdf(href);
|
||||||
|
if (!encoded) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
link.setAttribute("href", encoded);
|
||||||
|
rewritten += 1;
|
||||||
|
}
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
findCommonPrefixLength,
|
||||||
|
mountContinuousRenderStage
|
||||||
|
} from "../src/continuous-preview.js";
|
||||||
|
|
||||||
|
describe("连续预览增量边界", () => {
|
||||||
|
it("从首个变化块开始替换后缀", () => {
|
||||||
|
expect(
|
||||||
|
findCommonPrefixLength(
|
||||||
|
["标题", "段落 A", "图表", "结尾"],
|
||||||
|
["标题", "段落 A", "新图表", "新结尾"]
|
||||||
|
)
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("处理新增、删除和完全相同的文档", () => {
|
||||||
|
expect(findCommonPrefixLength(["A"], ["A", "B"])).toBe(1);
|
||||||
|
expect(findCommonPrefixLength(["A", "B"], ["A"])).toBe(1);
|
||||||
|
expect(findCommonPrefixLength(["A", "B"], ["A", "B"])).toBe(2);
|
||||||
|
expect(findCommonPrefixLength(["A"], ["B"])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将变化后缀挂载到真实 DOM 测量且提交时不保留包装层", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
const article = document.createElement("article");
|
||||||
|
article.id = "write";
|
||||||
|
article.className = "theme-document";
|
||||||
|
const suffix = document.createDocumentFragment();
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.textContent = "图表";
|
||||||
|
const chart = document.createElement("figure");
|
||||||
|
chart.className = "md-echarts";
|
||||||
|
suffix.append(heading, chart);
|
||||||
|
document.body.append(root);
|
||||||
|
|
||||||
|
const stage = mountContinuousRenderStage(
|
||||||
|
root,
|
||||||
|
article,
|
||||||
|
suffix
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stage.article.isConnected).toBe(true);
|
||||||
|
expect(stage.article.parentElement).toBe(root);
|
||||||
|
expect(stage.article.id).toBe("write");
|
||||||
|
expect(stage.article.className).toBe("theme-document");
|
||||||
|
expect(stage.article.dataset.continuousRenderStage).toBe("true");
|
||||||
|
expect(Array.from(stage.article.children)).toEqual([
|
||||||
|
heading,
|
||||||
|
chart
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rendered = stage.takeContent();
|
||||||
|
expect(Array.from(rendered.children)).toEqual([heading, chart]);
|
||||||
|
expect(stage.article.childNodes).toHaveLength(0);
|
||||||
|
|
||||||
|
stage.dispose();
|
||||||
|
expect(stage.article.isConnected).toBe(false);
|
||||||
|
expect(root.childNodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { fitDocumentImagesToPage } from "../src/document-image-fit";
|
import { fitDocumentImagesToPage } from "../src/document-image-fit.js";
|
||||||
|
|
||||||
describe("Markdown 图片单页适配", () => {
|
describe("Markdown 图片单页适配", () => {
|
||||||
it("标记独立图片段落并将超高图片缩放到单页", () => {
|
it("标记独立图片段落并将超高图片缩放到单页", () => {
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
|
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit.js";
|
||||||
|
|
||||||
describe("ECharts 单页高度适配", () => {
|
describe("ECharts 单页高度适配", () => {
|
||||||
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assignPagedSourceReferences,
|
||||||
|
createIncrementalPaginationPlan,
|
||||||
|
findSourceNodePageIndex
|
||||||
|
} from "../src/incremental-pagination.js";
|
||||||
|
|
||||||
|
function createSourceNode(reference: string) {
|
||||||
|
const node = document.createElement("section");
|
||||||
|
node.dataset.ref = reference;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPage(
|
||||||
|
references: string[],
|
||||||
|
startReference?: string
|
||||||
|
) {
|
||||||
|
const element = document.createElement("article");
|
||||||
|
for (const reference of references) {
|
||||||
|
element.append(createSourceNode(reference));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
startToken: startReference
|
||||||
|
? {
|
||||||
|
node: createSourceNode(startReference),
|
||||||
|
offset: 0
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("快速预览增量分页计划", () => {
|
||||||
|
it("从变化块所在页的前一页开始续排", () => {
|
||||||
|
const sourceNodes = [
|
||||||
|
createSourceNode("a"),
|
||||||
|
createSourceNode("b"),
|
||||||
|
createSourceNode("c"),
|
||||||
|
createSourceNode("d")
|
||||||
|
];
|
||||||
|
const pages = [
|
||||||
|
createPage(["a"]),
|
||||||
|
createPage(["b"], "b"),
|
||||||
|
createPage(["c"], "c"),
|
||||||
|
createPage(["d"], "d")
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createIncrementalPaginationPlan(
|
||||||
|
2,
|
||||||
|
sourceNodes.length,
|
||||||
|
sourceNodes,
|
||||||
|
pages
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
prefixLength: 2,
|
||||||
|
invalidationPageIndex: 1,
|
||||||
|
startToken: pages[1]?.startToken
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("尾部新增时以旧文档最后一个块定位并回退一页", () => {
|
||||||
|
const sourceNodes = [
|
||||||
|
createSourceNode("a"),
|
||||||
|
createSourceNode("b")
|
||||||
|
];
|
||||||
|
const pages = [
|
||||||
|
createPage(["a"]),
|
||||||
|
createPage(["b"], "b")
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createIncrementalPaginationPlan(
|
||||||
|
2,
|
||||||
|
sourceNodes.length,
|
||||||
|
sourceNodes,
|
||||||
|
pages
|
||||||
|
)?.invalidationPageIndex
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无法验证边界时拒绝使用旧分页缓存", () => {
|
||||||
|
const sourceNodes = [createSourceNode("a")];
|
||||||
|
const pages = [createPage(["other"])];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createIncrementalPaginationPlan(
|
||||||
|
1,
|
||||||
|
sourceNodes.length,
|
||||||
|
sourceNodes,
|
||||||
|
pages
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
createIncrementalPaginationPlan(
|
||||||
|
0,
|
||||||
|
sourceNodes.length,
|
||||||
|
sourceNodes,
|
||||||
|
pages
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过 data-ref 在分页结果中反查源节点页码", () => {
|
||||||
|
const pages = [
|
||||||
|
createPage(["a"]),
|
||||||
|
createPage(["b", "c"])
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
findSourceNodePageIndex(pages, createSourceNode("c"))
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只为新节点补充分页引用并保留既有引用", () => {
|
||||||
|
const root = document.createElement("article");
|
||||||
|
root.dataset.ref = "existing-root";
|
||||||
|
root.id = "write";
|
||||||
|
const child = document.createElement("p");
|
||||||
|
child.id = "paragraph";
|
||||||
|
root.append(child);
|
||||||
|
let sequence = 0;
|
||||||
|
|
||||||
|
assignPagedSourceReferences(root, () => {
|
||||||
|
sequence += 1;
|
||||||
|
return `generated-${sequence}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root.dataset.ref).toBe("existing-root");
|
||||||
|
expect(root.dataset.id).toBe("write");
|
||||||
|
expect(child.dataset.ref).toBe("generated-1");
|
||||||
|
expect(child.dataset.id).toBe("paragraph");
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -7,8 +7,8 @@ import {
|
|||||||
findMediaBackfillCandidates,
|
findMediaBackfillCandidates,
|
||||||
getMediaBackfillIdsInDocumentOrder,
|
getMediaBackfillIdsInDocumentOrder,
|
||||||
prepareMediaBackfillBlocks
|
prepareMediaBackfillBlocks
|
||||||
} from "../src/media-page-backfill";
|
} from "../src/media-page-backfill.js";
|
||||||
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit";
|
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit.js";
|
||||||
|
|
||||||
function rect(width: number, height: number) {
|
function rect(width: number, height: number) {
|
||||||
return {
|
return {
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { defaultExportConfig } from "@md-to-pdf/core";
|
|||||||
import {
|
import {
|
||||||
createMermaidSiteConfig,
|
createMermaidSiteConfig,
|
||||||
MERMAID_SECURE_CONFIG_KEYS
|
MERMAID_SECURE_CONFIG_KEYS
|
||||||
} from "../src/mermaid-config";
|
} from "../src/mermaid-config.js";
|
||||||
|
|
||||||
describe("Mermaid 站点配置", () => {
|
describe("Mermaid 站点配置", () => {
|
||||||
it("默认使用 default 主题和 classic 外观", () => {
|
it("默认使用 default 主题和 classic 外观", () => {
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
calculateMermaidPageFit,
|
calculateMermaidPageFit,
|
||||||
getPageContentDimensions,
|
getPageContentDimensions,
|
||||||
parseSvgViewBox
|
parseSvgViewBox
|
||||||
} from "../src/mermaid-page-fit";
|
} from "../src/mermaid-page-fit.js";
|
||||||
|
|
||||||
describe("Mermaid 单页高度适配", () => {
|
describe("Mermaid 单页高度适配", () => {
|
||||||
it("解析 SVG viewBox 尺寸", () => {
|
it("解析 SVG viewBox 尺寸", () => {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { renderMermaidDefinitions } from "../src/mermaid-renderer";
|
import { renderMermaidDefinitions } from "../src/mermaid-renderer.js";
|
||||||
|
|
||||||
describe("Mermaid 独立图表渲染", () => {
|
describe("Mermaid 独立图表渲染", () => {
|
||||||
it("单个图表失败后继续渲染后续图表", async () => {
|
it("单个图表失败后继续渲染后续图表", async () => {
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
getLockedMermaidBoxSize,
|
getLockedMermaidBoxSize,
|
||||||
getMermaidImageAlt,
|
getMermaidImageAlt,
|
||||||
resolveMermaidOutputMode
|
resolveMermaidOutputMode
|
||||||
} from "../src/mermaid-static-image";
|
} from "../src/mermaid-static-image.js";
|
||||||
|
|
||||||
describe("Mermaid 静态 SVG 图片", () => {
|
describe("Mermaid 静态 SVG 图片", () => {
|
||||||
it("生成无需外部请求的 UTF-8 Data URL", () => {
|
it("生成无需外部请求的 UTF-8 Data URL", () => {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveBreakTokenElement } from "../src/paged-break-token";
|
import { resolveBreakTokenElement } from "../src/paged-break-token.js";
|
||||||
|
|
||||||
describe("Paged.js 分页断点", () => {
|
describe("Paged.js 分页断点", () => {
|
||||||
it("直接返回元素断点", () => {
|
it("直接返回元素断点", () => {
|
||||||
+65
-1
@@ -2,14 +2,17 @@ import { defaultExportConfig } from "@md-to-pdf/core";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildPagedMediaCss,
|
buildPagedMediaCss,
|
||||||
|
continuousDocumentGeometryCss,
|
||||||
createPagedPreviewRenderRequest,
|
createPagedPreviewRenderRequest,
|
||||||
|
documentBaseCss,
|
||||||
documentGeometryCss,
|
documentGeometryCss,
|
||||||
|
documentInteractionCss,
|
||||||
formatPageNumber,
|
formatPageNumber,
|
||||||
isPagedPreviewFrameMessage,
|
isPagedPreviewFrameMessage,
|
||||||
isPagedPreviewRenderRequest,
|
isPagedPreviewRenderRequest,
|
||||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type PagedPreviewPayload
|
type PagedPreviewPayload
|
||||||
} from "../src/paged-preview";
|
} from "../src/paged-preview.js";
|
||||||
|
|
||||||
const payload: PagedPreviewPayload = {
|
const payload: PagedPreviewPayload = {
|
||||||
articleHtml: '<article id="write"><h1>分页测试</h1></article>',
|
articleHtml: '<article id="write"><h1>分页测试</h1></article>',
|
||||||
@@ -154,6 +157,43 @@ describe("分页预览协议", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("避免 Typora 围栏容器重复应用行内代码盒模型", () => {
|
||||||
|
expect(documentBaseCss).toContain(
|
||||||
|
"#write pre.md-fences > code"
|
||||||
|
);
|
||||||
|
expect(documentBaseCss).toContain("background: transparent;");
|
||||||
|
expect(documentBaseCss).toContain("font-size: inherit;");
|
||||||
|
expect(documentBaseCss).not.toMatch(
|
||||||
|
/#write pre\.md-fences > code\s*\{[^}]*!important/su
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("以低优先级兜底样式保留链接识别和焦点反馈", () => {
|
||||||
|
expect(documentInteractionCss).toContain(":where(#write a[href])");
|
||||||
|
expect(documentInteractionCss).toContain(
|
||||||
|
"text-decoration-line: underline;"
|
||||||
|
);
|
||||||
|
expect(documentInteractionCss).toContain(":focus-visible");
|
||||||
|
expect(documentInteractionCss).toContain("--md-accent");
|
||||||
|
expect(documentInteractionCss).not.toContain("!important");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("连续预览由内容高度驱动且不创建内部滚动容器", () => {
|
||||||
|
expect(continuousDocumentGeometryCss).toContain(
|
||||||
|
"overflow: hidden !important"
|
||||||
|
);
|
||||||
|
expect(continuousDocumentGeometryCss).toContain(
|
||||||
|
"min-height: 0 !important"
|
||||||
|
);
|
||||||
|
expect(continuousDocumentGeometryCss).toContain(
|
||||||
|
'[data-continuous-render-stage="true"]'
|
||||||
|
);
|
||||||
|
expect(continuousDocumentGeometryCss).toContain(
|
||||||
|
"position: fixed !important"
|
||||||
|
);
|
||||||
|
expect(continuousDocumentGeometryCss).not.toContain("100vh");
|
||||||
|
});
|
||||||
|
|
||||||
it("生成并识别分页渲染请求", () => {
|
it("生成并识别分页渲染请求", () => {
|
||||||
const request = createPagedPreviewRenderRequest(7, payload);
|
const request = createPagedPreviewRenderRequest(7, payload);
|
||||||
|
|
||||||
@@ -161,9 +201,17 @@ describe("分页预览协议", () => {
|
|||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "render",
|
type: "render",
|
||||||
requestId: 7,
|
requestId: 7,
|
||||||
|
layout: "paged",
|
||||||
payload
|
payload
|
||||||
});
|
});
|
||||||
expect(isPagedPreviewRenderRequest(request)).toBe(true);
|
expect(isPagedPreviewRenderRequest(request)).toBe(true);
|
||||||
|
expect(
|
||||||
|
createPagedPreviewRenderRequest(
|
||||||
|
8,
|
||||||
|
payload,
|
||||||
|
"continuous"
|
||||||
|
).layout
|
||||||
|
).toBe("continuous");
|
||||||
expect(
|
expect(
|
||||||
isPagedPreviewRenderRequest({
|
isPagedPreviewRenderRequest({
|
||||||
...request,
|
...request,
|
||||||
@@ -173,11 +221,26 @@ describe("分页预览协议", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("只接受分页 iframe 协议消息", () => {
|
it("只接受分页 iframe 协议消息", () => {
|
||||||
|
expect(
|
||||||
|
isPagedPreviewFrameMessage({
|
||||||
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
type: "link",
|
||||||
|
href: "https://example.com"
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPagedPreviewFrameMessage({
|
||||||
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
type: "link",
|
||||||
|
href: ""
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
isPagedPreviewFrameMessage({
|
isPagedPreviewFrameMessage({
|
||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId: 3,
|
requestId: 3,
|
||||||
|
layout: "paged",
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
contentHeight: 2400,
|
contentHeight: 2400,
|
||||||
echartsErrors: [],
|
echartsErrors: [],
|
||||||
@@ -189,6 +252,7 @@ describe("分页预览协议", () => {
|
|||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId: 3,
|
requestId: 3,
|
||||||
|
layout: "paged",
|
||||||
pageCount: 2,
|
pageCount: 2,
|
||||||
echartsErrors: [],
|
echartsErrors: [],
|
||||||
mermaidErrors: []
|
mermaidErrors: []
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolvePagedRenderTarget } from "../src/paged-render-target";
|
import { resolvePagedRenderTarget } from "../src/paged-render-target.js";
|
||||||
|
|
||||||
describe("分页文档渲染目标", () => {
|
describe("分页文档渲染目标", () => {
|
||||||
it("默认使用网页预览目标", () => {
|
it("默认使用网页预览目标", () => {
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { preparePdfDocumentLinks } from "../src/pdf-document-links.js";
|
||||||
|
|
||||||
|
describe("PDF 本地链接准备", () => {
|
||||||
|
it("只改写本地链接并保留网络、协议和锚点", () => {
|
||||||
|
const root = document.createElement("article");
|
||||||
|
root.innerHTML = [
|
||||||
|
'<a href="../README.md">本地</a>',
|
||||||
|
'<a href="file:///C:/docs/a.md">文件</a>',
|
||||||
|
'<a href="https://example.com">网络</a>',
|
||||||
|
'<a href="mailto:a@example.com">邮件</a>',
|
||||||
|
'<a href="#chapter">锚点</a>'
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
expect(preparePdfDocumentLinks(root)).toBe(2);
|
||||||
|
const hrefs = Array.from(root.querySelectorAll("a")).map(
|
||||||
|
(link) => link.getAttribute("href")
|
||||||
|
);
|
||||||
|
expect(hrefs[0]).toContain(
|
||||||
|
"https://mdpdf.local.invalid/document-link?"
|
||||||
|
);
|
||||||
|
expect(hrefs[1]).toContain(
|
||||||
|
"https://mdpdf.local.invalid/document-link?"
|
||||||
|
);
|
||||||
|
expect(hrefs.slice(2)).toEqual([
|
||||||
|
"https://example.com",
|
||||||
|
"mailto:a@example.com",
|
||||||
|
"#chapter"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { enablePrintMediaForPreview } from "../src/preview-styles";
|
import { enablePrintMediaForPreview } from "../src/preview-styles.js";
|
||||||
|
|
||||||
describe("打印媒体预览", () => {
|
describe("打印媒体预览", () => {
|
||||||
it("将打印媒体规则转换为屏幕预览规则", () => {
|
it("将打印媒体规则转换为屏幕预览规则", () => {
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,10 +17,12 @@ tests/
|
|||||||
## 支持能力
|
## 支持能力
|
||||||
|
|
||||||
- 标题锚点、表格、任务列表、脚注和代码高亮;
|
- 标题锚点、表格、任务列表、脚注和代码高亮;
|
||||||
|
- markdown-it 原生围栏代码块及 Typora `.md-fences` DOM 兼容;
|
||||||
- KaTeX 数学公式;
|
- KaTeX 数学公式;
|
||||||
- Mermaid 安全占位;
|
- Mermaid 安全占位;
|
||||||
- `@md-to-pdf/markdown-echarts` ECharts YAML 围栏;
|
- `@md-to-pdf/markdown-echarts` ECharts YAML 围栏;
|
||||||
- 图片标题 DOM 与资源引用收集;
|
- 图片标题 DOM 与资源引用收集;
|
||||||
|
- 保留由 Web/Desktop 平台适配器处理的安全文档链接;
|
||||||
- `sanitize-html` 白名单过滤和统一 `<article id="write">` 输出。
|
- `sanitize-html` 白名单过滤和统一 `<article id="write">` 输出。
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
@@ -33,6 +35,16 @@ const result = renderMarkdown("# 示例", {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
普通围栏代码块输出为:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<pre class="md-fences" lang="ts"><code class="language-ts">...</code></pre>
|
||||||
|
```
|
||||||
|
|
||||||
|
`.md-fences` 让 Typora 主题负责完整代码块外观;共享预览引擎会移除内部
|
||||||
|
`code` 重复套用的行内代码盒模型,同时保留 highlight.js 的高亮 span。
|
||||||
|
Mermaid 和 ECharts 围栏继续使用各自的专用 DOM,不经过该分支。
|
||||||
|
|
||||||
实际应用优先通过 `@md-to-pdf/application` 调用,以便同时完成主题和图片
|
实际应用优先通过 `@md-to-pdf/application` 调用,以便同时完成主题和图片
|
||||||
资源处理。
|
资源处理。
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
RenderedMarkdownDocument,
|
RenderedMarkdownDocument,
|
||||||
ThemeFeature
|
ThemeFeature
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
classifyDocumentLink,
|
||||||
|
isSafeDocumentLink
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
markdownItECharts,
|
markdownItECharts,
|
||||||
type MarkdownItEChartsErrorContext
|
type MarkdownItEChartsErrorContext
|
||||||
@@ -85,6 +89,11 @@ const markdown = new MarkdownIt(markdownOptions)
|
|||||||
trust: false
|
trust: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultValidateLink = markdown.validateLink.bind(markdown);
|
||||||
|
markdown.validateLink = (href) =>
|
||||||
|
defaultValidateLink(href) ||
|
||||||
|
classifyDocumentLink(href).scheme === "file";
|
||||||
|
|
||||||
const defaultFenceRenderer = markdown.renderer.rules.fence;
|
const defaultFenceRenderer = markdown.renderer.rules.fence;
|
||||||
const defaultImageRenderer = markdown.renderer.rules.image;
|
const defaultImageRenderer = markdown.renderer.rules.image;
|
||||||
const defaultParagraphOpenRenderer =
|
const defaultParagraphOpenRenderer =
|
||||||
@@ -178,13 +187,21 @@ markdown.renderer.rules.fence = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (defaultFenceRenderer) {
|
if (defaultFenceRenderer) {
|
||||||
return defaultFenceRenderer(
|
const renderedFence = defaultFenceRenderer(
|
||||||
tokens,
|
tokens,
|
||||||
index,
|
index,
|
||||||
options,
|
options,
|
||||||
environment,
|
environment,
|
||||||
renderer
|
renderer
|
||||||
);
|
);
|
||||||
|
const languageAttribute = language
|
||||||
|
? ` lang="${escapeAttribute(language)}"`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return renderedFence.replace(
|
||||||
|
/^<pre>/u,
|
||||||
|
`<pre class="md-fences"${languageAttribute}>`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderFallbackFence(tokens, index, renderer);
|
return renderFallbackFence(tokens, index, renderer);
|
||||||
@@ -270,7 +287,7 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
|
|||||||
input: ["type", "checked", "disabled", "class"],
|
input: ["type", "checked", "disabled", "class"],
|
||||||
li: ["class", "value"],
|
li: ["class", "value"],
|
||||||
ol: ["class", "start"],
|
ol: ["class", "start"],
|
||||||
pre: ["class", "hidden"],
|
pre: ["class", "hidden", "lang"],
|
||||||
span: ["class", "style"],
|
span: ["class", "style"],
|
||||||
td: ["colspan", "rowspan", "style"],
|
td: ["colspan", "rowspan", "style"],
|
||||||
th: ["colspan", "rowspan", "scope", "style"]
|
th: ["colspan", "rowspan", "scope", "style"]
|
||||||
@@ -279,6 +296,7 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
|
|||||||
allowedSchemesByTag: {
|
allowedSchemesByTag: {
|
||||||
img: ["http", "https", "data"]
|
img: ["http", "https", "data"]
|
||||||
},
|
},
|
||||||
|
allowedSchemesAppliedToAttributes: ["src", "cite"],
|
||||||
allowedStyles: {
|
allowedStyles: {
|
||||||
span: {
|
span: {
|
||||||
height: [lengthStylePattern],
|
height: [lengthStylePattern],
|
||||||
@@ -299,9 +317,19 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
transformTags: {
|
transformTags: {
|
||||||
a: sanitizeHtml.simpleTransform("a", {
|
a(tagName, attributes) {
|
||||||
|
const href = attributes.href;
|
||||||
|
if (href && !isSafeDocumentLink(href)) {
|
||||||
|
delete attributes.href;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
attribs: {
|
||||||
|
...attributes,
|
||||||
rel: "noopener noreferrer"
|
rel: "noopener noreferrer"
|
||||||
})
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ const answer = 42;
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("为围栏代码块输出 Typora 主题兼容容器", () => {
|
||||||
|
const result = renderMarkdown(`
|
||||||
|
\`\`\`
|
||||||
|
无语言代码块
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`ts
|
||||||
|
const answer = 42;
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'<pre class="md-fences"><code>无语言代码块\n</code></pre>'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'<pre class="md-fences" lang="ts"><code class="language-ts">'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain("hljs-keyword");
|
||||||
|
});
|
||||||
|
|
||||||
it("保留 Markdown 表格的列对齐语义", () => {
|
it("保留 Markdown 表格的列对齐语义", () => {
|
||||||
const result = renderMarkdown(`
|
const result = renderMarkdown(`
|
||||||
| 左对齐 | 居中 | 右对齐 |
|
| 左对齐 | 居中 | 右对齐 |
|
||||||
@@ -211,6 +231,38 @@ option:
|
|||||||
expect(result.bodyHtml).toContain("<script>");
|
expect(result.bodyHtml).toContain("<script>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("保留平台适配器可处理的文档链接", () => {
|
||||||
|
const result = renderMarkdown(`
|
||||||
|
[锚点](#章节一)
|
||||||
|
[网络](https://example.com/a)
|
||||||
|
[协议相对](//example.com/a)
|
||||||
|
[邮件](mailto:a@example.com)
|
||||||
|
[电话](tel:+8612345)
|
||||||
|
[上级目录](../docs/a.md)
|
||||||
|
[绝对路径](C:/docs/a.md)
|
||||||
|
[本地 URI](<file:///C:/docs/a.md>)
|
||||||
|
[自定义协议](obsidian://open?vault=x)
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(result.bodyHtml).toContain('href="#%E7%AB%A0%E8%8A%82%E4%B8%80"');
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'href="https://example.com/a"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain('href="//example.com/a"');
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'href="mailto:a@example.com"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain('href="tel:+8612345"');
|
||||||
|
expect(result.bodyHtml).toContain('href="../docs/a.md"');
|
||||||
|
expect(result.bodyHtml).toContain('href="C:/docs/a.md"');
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'href="file:///C:/docs/a.md"'
|
||||||
|
);
|
||||||
|
expect(result.bodyHtml).toContain(
|
||||||
|
'href="obsidian://open?vault=x"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("提取图片引用并使用受控资源地址替换", () => {
|
it("提取图片引用并使用受控资源地址替换", () => {
|
||||||
const source =
|
const source =
|
||||||
"\n\n";
|
"\n\n";
|
||||||
|
|||||||
+16
-4
@@ -1,9 +1,21 @@
|
|||||||
# 测试
|
# 测试
|
||||||
|
|
||||||
后续测试分为:
|
项目测试分为:
|
||||||
|
|
||||||
- 导出配置与主题清单单元测试;
|
- 导出配置与主题清单单元测试;
|
||||||
- Markdown 渲染快照测试;
|
- Markdown、代码围栏和链接分类测试;
|
||||||
- HTML 预览接口测试;
|
- HTML 预览接口测试;
|
||||||
- PDF 生成端到端测试;
|
- 连续预览、增量分页、媒体回填和 PDF 链接测试;
|
||||||
- 临时文件和路径安全测试。
|
- Desktop 多窗口、同文件单例、文件变化和发行构建链测试;
|
||||||
|
- PDF 生成、临时文件和路径安全测试。
|
||||||
|
|
||||||
|
完整验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器与 Electron 端到端验收结果记录在 `docs/PROGRESS.md`。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 1,
|
"manifestVersion": 1,
|
||||||
"id": "typora-github",
|
"id": "typora-github",
|
||||||
"name": "Typora 默认(GitHub)",
|
"name": "Typora Github",
|
||||||
"version": "1.14.7",
|
"version": "1.14.7",
|
||||||
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
||||||
"author": "Typora",
|
"author": "Typora",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 1,
|
"manifestVersion": 1,
|
||||||
"id": "typora-like",
|
"id": "typora-like",
|
||||||
"name": "Typora 风格",
|
"name": "Typora Clean",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "项目自有的简洁技术文档主题,不复制 Typora 官方主题代码。",
|
"description": "项目自有的简洁技术文档主题,不复制 Typora 官方主题代码。",
|
||||||
"author": "md-to-pdf contributors",
|
"author": "md-to-pdf contributors",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 1,
|
"manifestVersion": 1,
|
||||||
"id": "typora-pixyll",
|
"id": "typora-pixyll",
|
||||||
"name": "Typora 默认(Pixyll)",
|
"name": "Typora Pixyll",
|
||||||
"version": "1.14.7",
|
"version": "1.14.7",
|
||||||
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
||||||
"author": "Typora",
|
"author": "Typora",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 1,
|
"manifestVersion": 1,
|
||||||
"id": "typora-whitey",
|
"id": "typora-whitey",
|
||||||
"name": "Typora 默认(Whitey)",
|
"name": "Typora whitey",
|
||||||
"version": "1.14.7",
|
"version": "1.14.7",
|
||||||
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
||||||
"author": "Typora",
|
"author": "Typora",
|
||||||
|
|||||||
Reference in New Issue
Block a user