6 Commits
Author SHA1 Message Date
SkyJourney 58087d0c7e release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。

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

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

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

验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
2026-07-28 18:01:22 +08:00
SkyJourney 925b0d1485 release: 发布 v0.4.5
新增能力:Web 与 Desktop 支持新建和保存 Markdown;桌面端注册 .md/.markdown 打开程序,支持单实例文件转交、自定义主题目录以及窗口位置、尺寸和最大化状态的延迟原子持久化与屏幕越界修正。

界面与兼容性:保留打开 Markdown 为独立主操作,将新建、保存和主题目录收纳到更多菜单;Web 移除本地素材目录入口并明确默认不支持本地素材;新文档默认使用一级标题或首行合法化生成文件名,可不保存直接导出 PDF。

渲染修复:图片、Mermaid 和 ECharts 按文档顺序串行执行媒体分页回填,每个媒体元素至多重排一次;图片以限宽后的高度作为缩放基准,只缩放媒体主体并保持标题尺寸;ECharts 冻结为 SVG 图片后参与统一分页。

示例与文档:默认示例仅保留一张网络图片,增加有效 Base64 横图、竖图、边界图片及多尺寸 Mermaid/ECharts;更新根 README、Desktop README、部署文档、四个 packages README、PROGRESS 和 AGENTS 发布规范;登记 v0.4.6 超链接导航问题。

部署与验证:构建并运行 yixiong/md-to-pdf:v0.4.5 正式镜像,Compose 健康且 PDF 冒烟无 Mermaid/ECharts 错误;生成 NSIS 安装包和免安装 ZIP,本机已升级到 NSIS v0.4.5 并清理旧 Squirrel 安装;202 项测试、类型检查、生产构建和 git diff --check 全部通过。

发布产物:Setup.exe SHA-256 9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D;ZIP SHA-256 97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850;当前 Windows 产物未配置代码签名。
2026-07-28 13:31:42 +08:00
SkyJourney ac54ce46be chore: 精简桌面端语言包 2026-07-27 23:16:47 +08:00
SkyJourney 06407650e0 feat: 发布标准 Windows 安装向导 2026-07-27 23:01:22 +08:00
SkyJourney 8d91d08809 fix: 移除桌面端原生菜单栏 2026-07-27 22:23:39 +08:00
SkyJourney 32a37293d0 fix: 完善本地与网络图片渲染 2026-07-27 22:12:16 +08:00
106 changed files with 9787 additions and 5330 deletions
+1
View File
@@ -9,6 +9,7 @@ coverage/
Thumbs.db
.idea/
.vscode/
.serena/
tmp/
playwright-report/
test-results/
+20 -7
View File
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
- 支持主题清单与 CSS 主题扩展;
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeXMermaid
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeXMermaid 和 ECharts
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
- 使用 Docker Compose 在内网部署;
- 文档只在当前请求中临时处理,不建立文档历史数据库。
@@ -75,15 +76,19 @@ flowchart LR
- 前端:React、TypeScript、Vite。
- 后端:Node.js、TypeScript、Fastify。
- 桌面端:Electron、electron-builder、NSIS。
- 应用服务:`packages/application`
- 共享模型:`packages/core`
- Markdown 渲染:`packages/renderer`
- PDF 引擎:计划使用 Playwright Chromium
- 连续预览与分页引擎:`packages/preview-engine`
- ECharts 协议与运行时:`packages/markdown-echarts`
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
- Markdownmarkdown-it 及 `@mdit/plugin-*` 插件。
- 元数据:gray-matter。
- 安全过滤:sanitize-html。
- 代码高亮:highlight.js。
- 数学公式:KaTeX。
- 图表:Mermaid。
- 图表:Mermaid、ECharts
- 校验:Zod。
- 测试:Vitest。
- 部署:Docker、Docker Compose。
@@ -92,12 +97,15 @@ flowchart LR
```text
apps/web/ 前端编辑、配置与预览界面
apps/server/ HTTP API、主题读取及未来 PDF 服务
apps/server/ HTTP API、主题读取和 Playwright PDF 服务
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
packages/application/ 共享应用服务、主题注册和图片资源处理
packages/core/ 导出配置和主题清单等共享模型
packages/renderer/ Markdown 到安全 HTML 的渲染管线
themes/ 内置主题及未来可安装主题
docker/ 容器化配置
tests/ 跨包和端到端测试
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
themes/ 内置主题
deploy/ Docker、Compose、Nginx 和部署文档
docs/ 进度、架构和部署文档
```
@@ -186,6 +194,11 @@ chore: 初始化项目骨架
```
- 不修改或重写已有提交,除非用户明确要求。
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
记录本版本更新,不得使用只有版本号的标签说明。
## 9. 当前交接入口
+150 -101
View File
@@ -1,25 +1,104 @@
# Markdown PDF 导出器
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。
一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
## 当前状态
当前版本:`v0.5.0`
已经实现:
## v0.5.0 主要能力
- React Markdown 编辑与本地文件读取;
- Fastify 预览接口
- 共享导出配置模型;
- 动态主题清单、CSS 和静态资源接口
- Markdown、Front Matter、安全过滤和扩展语法渲染核心
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid
- iframe 隔离的真实分页预览、双向滚动同步与主题切换
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存
- 快速预览与精确预览共用的 50%~400% 显示缩放;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
- 新增“连续”预览模式:不分页、低测量开销,适合编辑时快速刷新;快速
预览继续提供分页近似,精确预览和导出 PDF 保持权威分页
- 分页引擎从 Web 应用抽取为 `@md-to-pdf/preview-engine`Web、
Playwright PDF 与 Electron PDF 直接复用同一运行时
- 快速预览支持按修改位置复用稳定前缀,只重新分页受影响的后缀
- Web 链接只处理当前文档锚点和 HTTP/HTTPS 外链;外链在新浏览器窗口
打开,本地路径及不支持协议不执行导航
- Desktop 支持 HTTP/HTTPS、系统协议、本地相对或绝对路径和 `file:`
URI;Markdown 链接可选择在当前窗口或新窗口打开
- Desktop 支持多窗口和同一文件单例。窗口聚焦时检查磁盘文件变化,并
提示是否重新加载
- 四个内置主题统一命名为 Typora Github、Typora Pixyll、
Typora whitey 和 Typora Clean,默认使用 Typora Github
- 围栏代码块输出 Typora 兼容的 `.md-fences` DOM,并避免内部
`<code>` 重复套用行内代码样式;
- 桌面发行链会在每次打包前完整重建内嵌 Web 依赖,避免安装包携带陈旧
前端资源。
本地相对资源文件处理将在后续阶段实现。
## Web 与桌面端
| 能力 | Web / Docker | Windows Desktop |
| --- | --- | --- |
| 新建 Markdown | 支持 | 支持 |
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
| 保存 Markdown | 浏览器下载 | 原生另存为 |
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
DesktopWeb 端可使用 Base64/Data URL 或受限公网图片。
## 渲染架构
```mermaid
flowchart LR
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
B --> C["统一 #write HTML"]
D["主题 CSS 与导出配置"] --> C
C --> E["共享 Preview Engine"]
E --> F["连续预览"]
E --> G["快速分页预览"]
E --> H["Playwright Chromium"]
E --> I["Electron Chromium"]
H --> J["Web PDF"]
I --> K["Desktop PDF"]
```
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
载荷和运行时。
## 项目目录
```text
apps/
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
server/ Fastify API、Playwright PDF 与容器运行入口
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
packages/
core/ 跨端文档、导出配置和主题协议
renderer/ Markdown 到安全 #write HTML 的渲染管线
application/ 共享渲染、主题注册和图片资源应用服务
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
themes/ 内置主题与主题清单
deploy/ Dockerfile、Compose、Nginx 和部署文档
docs/ 项目进度与主题开发说明
scripts/ 主题导入等维护脚本
logos/ 应用图标源文件
.local/themes/ 本地开发和 Compose 自定义主题目录
```
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
提供独立图表能力;`renderer` 消费两者生成安全 HTML`application`
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
各包的目录和用法:
- [application](packages/application/README.md)
- [core](packages/core/README.md)
- [renderer](packages/renderer/README.md)
- [markdown-echarts](packages/markdown-echarts/README.md)
- [preview-engine](packages/preview-engine/README.md)
## 本地开发
@@ -32,110 +111,69 @@ npm run dev
默认地址:
- 前端http://localhost:5173
- 后端http://localhost:3001
- Webhttp://localhost:5173
- APIhttp://localhost:3001
- 健康检查:http://localhost:3001/api/health
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
桌面端开发:
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
## Mermaid 配置与样式
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
- 布局:Dagre、ELK
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
- 外观:Classic、Hand-drawn、Neo
- 字体:任意本地 CSS 字体族。
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
````markdown
```mermaid
---
config:
layout: elk
theme: base
look: handDrawn
fontFamily: Microsoft YaHei
themeVariables:
primaryColor: "#dbeafe"
primaryBorderColor: "#2563eb"
flowchart:
wrappingWidth: 360
---
flowchart TB
classDef important fill:#fee2e2,stroke:#dc2626
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
style A fill:#dcfce7,stroke:#16a34a
class B important
```powershell
npm run desktop:dev
```
````
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
桌面端目录包和 Windows 安装包:
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
### Mermaid 长标签
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
需要自动换行时可以使用 Mermaid Markdown String
````markdown
```mermaid
flowchart LR
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
```powershell
npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
````
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`
桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,不能
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.0/`
正式安装包命名为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。
````markdown
```mermaid
flowchart LR
A["较长节点"] --> B["普通节点"]
classDef compact font-size:12px;
class A compact;
```
````
## 文档与媒体
## 本地导入 Typora 默认主题
Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、
KaTeX、Mermaid 和安全的 ECharts YAML 围栏。
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录:
独立一行的 `![标题](地址)` 会生成带标题的图片块。分页引擎将图片、
Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行
计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时
只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免
跨页、重复重排和后续媒体顺序变化。
ECharts 围栏语法、支持系列和安全边界详见
[markdown-echarts 包文档](packages/markdown-echarts/README.md)。
## 主题
仓库内置 Typora Github、Typora Pixyll、Typora whitey 和
Typora Clean 四套主题,默认使用 Typora Github。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/`
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
刷新本地开发副本:
```powershell
npm run theme:import-typora
```
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
```powershell
npm run theme:import-typora -- --replace
```
旧副本会移动到 `.local\theme-backups`。
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
## Docker Desktop 部署
## Docker Compose 部署
```powershell
docker compose -f deploy\compose.yaml up --build -d
```
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
[AIO 容器部署说明](deploy/README.md)。
默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
配置详见 [AIO 容器部署说明](deploy/README.md)。
## 测试与构建
## 验证
```powershell
npm test
@@ -144,7 +182,18 @@ npm run build
git diff --check
```
## 隐私原则
涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、
Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端
验证,并检查 PDF 页面渲染结果。
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
## 安全与隐私
- 不建立 Markdown、图片或 PDF 历史数据库;
- 文档和资源只在当前请求或当前桌面会话中处理;
- Markdown 原始 HTML 不执行,输出经过白名单过滤;
- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML
- 本地资源阻止路径穿越和符号链接越界;
- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时;
- PDF 页面阻止未授权外部网络访问。
当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。
+51 -8
View File
@@ -1,8 +1,23 @@
# Desktop 桌面端
`apps/desktop` `v0.4.0` 新增的 Electron 桌面应用。当前已经完成
Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron
Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
安装包。
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
落在屏幕外。Windows 安装包会注册 `.md``.markdown` 文件关联,
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
一个窗口,其他文件可以同时使用多个窗口。
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
重新加载,避免阅读或导出磁盘旧版本。
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。
## 开发
@@ -25,10 +40,22 @@ npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名,
Windows 首次运行可能显示“未知发布者”提示。
`package``make` 会先调用根级 `build:web-runtime`,完整重建
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.5.0/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.5.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
发布,不提交进 Git 历史。
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
产品名称、窗口标题、安装包名称和开始菜单名称保持为
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
@@ -38,11 +65,27 @@ Windows 首次运行可能显示“未知发布者”提示。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。
## 链接与窗口行为
- 当前文档 `#anchor` 在当前预览中定位;
- HTTP/HTTPS 链接交给系统默认浏览器;
- `mailto:` 等系统协议交给操作系统处理;
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
- Markdown 文件会提示在当前窗口或新窗口打开;
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
## 安全边界
- 应用窗口和 PDF 窗口均关闭 Node Integration
- 启用 Context Isolation 和 Chromium Sandbox
- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口;
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
的窄接口;
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
链接越界;
- 主进程校验 IPC 发送者和分页载荷;
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
- 应用页面使用自定义协议,不使用 `file://`
+64
View File
@@ -0,0 +1,64 @@
!macro customInstall
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown" \
"" \
"Markdown 文档"
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
"" \
'"$INSTDIR\md-to-pdf.exe",0'
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
WriteRegStr SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\shell\open\command" \
"" \
'"$INSTDIR\md-to-pdf.exe" "%1"'
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
".md" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \
".markdown" \
""
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
CreateShortCut \
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
"$INSTDIR\md-to-pdf.exe" \
"" \
"$INSTDIR\md-to-pdf.exe"
CreateShortCut \
"$DESKTOP\Markdown PDF 导出器.lnk" \
"$INSTDIR\md-to-pdf.exe" \
"" \
"$INSTDIR\md-to-pdf.exe"
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
!macro customUnInstall
DeleteRegValue SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegValue SHCTX \
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegKey SHCTX \
"Software\Classes\Applications\md-to-pdf.exe"
DeleteRegKey SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown"
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
+72
View File
@@ -0,0 +1,72 @@
const path = require("node:path");
const { version } = require("./package.json");
const windowsIcon = path.resolve(
__dirname,
"../../logos/desktop/windows/app.ico"
);
module.exports = {
appId: "com.md-to-pdf.desktop",
productName: "Markdown PDF 导出器",
copyright: "Copyright © YIXIONG Tech.ltd",
asar: true,
electronLanguages: ["zh-CN", "en-US"],
directories: {
output: `out/v${version}`
},
files: [
"dist/**/*",
"package.json"
],
extraResources: [
{
from: "../web/dist",
to: "dist",
filter: ["**/*"]
},
{
from: "../../themes",
to: "themes",
filter: ["**/*"]
},
{
from: windowsIcon,
to: "app.ico"
},
{
from: "../../logos/desktop/macos/app.icns",
to: "app.icns"
}
],
win: {
target: [
{
target: "nsis",
arch: ["x64"]
},
{
target: "zip",
arch: ["x64"]
}
],
icon: windowsIcon,
executableName: "md-to-pdf",
artifactName: `md-to-pdf-${version}-x86_64.\${ext}`
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
perMachine: false,
allowElevation: true,
createDesktopShortcut: false,
createStartMenuShortcut: false,
shortcutName: "Markdown PDF 导出器",
uninstallDisplayName: "Markdown PDF 导出器",
installerIcon: windowsIcon,
uninstallerIcon: windowsIcon,
installerLanguages: ["zh_CN"],
include: "build/installer.nsh",
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
}
};
-47
View File
@@ -1,47 +0,0 @@
const path = require("node:path");
const windowsIcon = path.resolve(
__dirname,
"../../logos/desktop/windows/app.ico"
);
const macosIcon = path.resolve(
__dirname,
"../../logos/desktop/macos/app.icns"
);
const { version } = require("../../package.json");
module.exports = {
buildIdentifier: `v${version}`,
packagerConfig: {
asar: true,
prune: false,
executableName: "md-to-pdf",
icon: process.platform === "darwin" ? macosIcon : windowsIcon,
ignore: [
/^\/node_modules(?:\/|$)/,
/^\/out(?:\/|$)/,
/^\/src(?:\/|$)/,
/^\/tests(?:\/|$)/,
/^\/tsconfig\.json$/
],
extraResource: [
path.resolve(__dirname, "../web/dist"),
path.resolve(__dirname, "../../themes"),
windowsIcon,
macosIcon
]
},
makers: [
{
name: "@electron-forge/maker-squirrel",
config: {
name: "markdown_pdf_exporter",
exe: "md-to-pdf.exe",
setupIcon: windowsIcon
}
},
{
name: "@electron-forge/maker-zip",
platforms: ["win32", "darwin"]
}
]
};
+7 -10
View File
@@ -1,32 +1,29 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
"author": "md-to-pdf contributors",
"author": "YIXIONG Tech.ltd",
"type": "module",
"main": "dist/main.js",
"dependencies": {
"@md-to-pdf/application": "0.4.0"
},
"scripts": {
"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:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
"package": "npm run build && electron-forge package",
"make": "npm run build && electron-forge make",
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
"make": "npm run build:embedded-web && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"devDependencies": {
"@electron-forge/cli": "7.11.2",
"@electron-forge/maker-squirrel": "7.11.2",
"@electron-forge/maker-zip": "7.11.2",
"@md-to-pdf/application": "0.4.1",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"electron-builder": "26.15.3",
"esbuild": "^0.25.0",
"typescript": "^5.9.3",
"vitest": "^4.1.10",
+37
View File
@@ -2,14 +2,51 @@ import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_DOCUMENT_LINK,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
consumePendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
discardPendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
onMarkdownOpened: (
listener: (reason: "open" | "reload" | "replace") => void
) => {
const handleOpened = (
_event: Electron.IpcRendererEvent,
reason: "open" | "reload" | "replace"
) => listener(reason);
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
return () =>
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
},
startNewMarkdown: () =>
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
setDocumentDirty: (dirty: boolean) =>
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
openDocumentLink: (href: string) =>
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
openThemeDirectory: () =>
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
+2 -1
View File
@@ -12,7 +12,8 @@ export function parseMarkdownRenderRequest(
}
return {
markdown: value.markdown,
language: value.language
language: value.language,
resources: value.resources
};
}
+20
View File
@@ -1,5 +1,25 @@
export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf:desktop:render-markdown";
export const DESKTOP_OPEN_MARKDOWN =
"md-to-pdf:desktop:open-markdown";
export const DESKTOP_CONSUME_PENDING_MARKDOWN =
"md-to-pdf:desktop:consume-pending-markdown";
export const DESKTOP_DISCARD_PENDING_MARKDOWN =
"md-to-pdf:desktop:discard-pending-markdown";
export const DESKTOP_MARKDOWN_OPENED =
"md-to-pdf:desktop:markdown-opened";
export const DESKTOP_SET_DOCUMENT_DIRTY =
"md-to-pdf:desktop:set-document-dirty";
export const DESKTOP_OPEN_DOCUMENT_LINK =
"md-to-pdf:desktop:open-document-link";
export const DESKTOP_START_NEW_MARKDOWN =
"md-to-pdf:desktop:start-new-markdown";
export const DESKTOP_SAVE_MARKDOWN =
"md-to-pdf:desktop:save-markdown";
export const DESKTOP_OPEN_THEME_DIRECTORY =
"md-to-pdf:desktop:open-theme-directory";
export const DESKTOP_REFRESH_THEMES =
"md-to-pdf:desktop:refresh-themes";
export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS =
@@ -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}`);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { classifyDocumentLink } from "@md-to-pdf/core";
export type DesktopDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "external";
href: string;
}
| {
type: "local";
filePath: string;
}
| {
type: "ignore";
};
function decodeLocalPath(href: string) {
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
try {
return decodeURIComponent(pathOnly);
} catch {
throw new Error("本地链接的 URL 编码无效");
}
}
export function resolveDesktopDocumentLinkAction(
href: string,
markdownFilePath?: string
): DesktopDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return { type: "anchor", href: link.normalizedHref };
}
if (link.kind === "network" || link.kind === "protocol") {
return { type: "external", href: link.normalizedHref };
}
if (link.kind !== "local") {
return { type: "ignore" };
}
if (link.scheme === "file") {
try {
return {
type: "local",
filePath: path.normalize(fileURLToPath(new URL(link.href)))
};
} catch {
throw new Error("file: 链接格式无效");
}
}
const decoded = decodeLocalPath(link.href);
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
return { type: "local", filePath: path.normalize(decoded) };
}
if (!markdownFilePath) {
throw new Error("当前文档尚未保存,无法解析相对本地链接");
}
return {
type: "local",
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
};
}
+114 -177
View File
@@ -1,42 +1,36 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
net,
protocol,
session,
type IpcMainInvokeEvent
session
} from "electron";
import { access, writeFile } from "node:fs/promises";
import { access, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
createApplicationService,
type ApplicationService
} from "@md-to-pdf/application";
import { parseThemeResourceUrl } from "./application-contract.js";
import {
DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
DesktopApplicationController
} from "./desktop-application-controller.js";
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.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";
findMarkdownFileArgument,
isMarkdownFilePath
} from "./markdown-file.js";
const APP_SCHEME = "mdpdf";
const APP_HOST = "bundle";
const THEME_HOST = "theme";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
let applicationController: DesktopApplicationController | undefined;
let pendingExternalMarkdownPath = findMarkdownFileArgument(
process.argv,
process.cwd()
);
protocol.registerSchemesAsPrivileged([
{
@@ -89,15 +83,31 @@ function encodeThemeAssetPath(assetPath: string) {
.join("/");
}
function dispatchExternalMarkdown(filePath: string) {
if (!applicationController) {
pendingExternalMarkdownPath = filePath;
return;
}
void applicationController.openExternalMarkdown(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
}
function getDesktopThemeDirectory() {
return app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.resolve(currentDirectory, "../../..", ".local", "themes");
}
function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../..");
return createApplicationService({
bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes")
: path.join(projectRoot, "themes"),
localRoot: app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.join(projectRoot, ".local", "themes"),
localRoot: getDesktopThemeDirectory(),
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
onWarning: (message) => console.warn(message)
@@ -156,12 +166,10 @@ async function registerApplicationProtocol(
} catch {
return new Response("Bad request", { status: 400 });
}
const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 });
}
try {
await access(target);
} catch {
@@ -178,170 +186,99 @@ 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 hasSingleInstanceLock = app.requestSingleInstanceLock();
let windowStateReadyToQuit = false;
if (!hasSingleInstanceLock) {
app.quit();
} else {
app.on("second-instance", (_event, arguments_, workingDirectory) => {
const filePath = findMarkdownFileArgument(
arguments_,
workingDirectory
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
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 window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(currentDirectory, "app-preload.cjs")
app.on("open-file", (event, filePath) => {
event.preventDefault();
if (isMarkdownFilePath(filePath)) {
dispatchExternalMarkdown(filePath);
}
});
lockDownWindow(window, new URL(applicationUrl).origin);
const pdfGenerator = new ElectronPdfGenerator({
renderUrl,
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
app.on("activate", () => {
if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
const validateSender = (event: IpcMainInvokeEvent) =>
event.sender === window.webContents &&
event.senderFrame !== null &&
new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).origin;
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest)
);
app.on("before-quit", (event) => {
if (windowStateReadyToQuit || !applicationController) {
return;
}
);
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
return applicationService.listThemes();
event.preventDefault();
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
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 保存参数无效");
app.on("will-quit", () => {
void applicationController?.closeResources();
});
app
.whenReady()
.then(async () => {
Menu.setApplicationMenu(null);
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
const themeDirectory = getDesktopThemeDirectory();
await mkdir(themeDirectory, { recursive: true });
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
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"] }]
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"
)
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
return true;
}
);
await applicationController.initialize();
window.once("ready-to-show", () => window.show());
window.on("closed", () => {
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);
const startupMarkdownPath = pendingExternalMarkdownPath;
pendingExternalMarkdownPath = undefined;
await applicationController.createWindow(startupMarkdownPath);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
}
app
.whenReady()
.then(async () => {
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
await createApplication(applicationService);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
+96
View File
@@ -0,0 +1,96 @@
import { createHash } from "node:crypto";
import { readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
const markdownExtensions = new Set([".md", ".markdown"]);
export interface OpenedMarkdownDocument {
markdown: string;
fileName: string;
}
export interface MarkdownFileSnapshot {
document: OpenedMarkdownDocument;
filePath: string;
documentKey: string;
contentHash: string;
}
export function createMarkdownContentHash(markdown: string) {
return createHash("sha256").update(markdown, "utf8").digest("hex");
}
export function createMarkdownDocumentKey(filePath: string) {
const normalized = path.normalize(filePath);
return process.platform === "win32"
? normalized.toLocaleLowerCase("en-US")
: normalized;
}
export function isMarkdownFilePath(filePath: string) {
return markdownExtensions.has(path.extname(filePath).toLowerCase());
}
export function findMarkdownFileArgument(
arguments_: string[],
workingDirectory: string
) {
for (const argument of arguments_) {
if (!argument || argument.startsWith("-")) {
continue;
}
const candidate = path.isAbsolute(argument)
? path.normalize(argument)
: path.resolve(workingDirectory, argument);
if (isMarkdownFilePath(candidate)) {
return candidate;
}
}
return undefined;
}
export async function readMarkdownDocument(
filePath: string,
maximumLength: number
): Promise<OpenedMarkdownDocument> {
if (!isMarkdownFilePath(filePath)) {
throw new Error("只允许打开 .md 或 .markdown 文件");
}
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
throw new Error("Markdown 长度限制无效");
}
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
throw new Error("Markdown 路径不是文件");
}
if (fileStats.size > maximumLength * 4) {
throw new Error("Markdown 文件过大");
}
const markdown = await readFile(filePath, "utf8");
if (markdown.length > maximumLength) {
throw new Error("Markdown 文件过大");
}
return {
markdown,
fileName: path.basename(filePath)
};
}
export async function readMarkdownFileSnapshot(
filePath: string,
maximumLength: number
): Promise<MarkdownFileSnapshot> {
const canonicalPath = await realpath(filePath);
const document = await readMarkdownDocument(
canonicalPath,
maximumLength
);
return {
document,
filePath: canonicalPath,
documentKey: createMarkdownDocumentKey(canonicalPath),
contentHash: createMarkdownContentHash(document.markdown)
};
}
+200
View File
@@ -0,0 +1,200 @@
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from "node:fs/promises";
import path from "node:path";
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowState {
version: 1;
bounds: WindowBounds;
maximized: boolean;
}
export interface WindowSize {
width: number;
height: number;
}
const WINDOW_STATE_VERSION = 1;
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function parseWindowBounds(value: unknown): WindowBounds | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const bounds = value as Partial<WindowBounds>;
if (
!isFiniteNumber(bounds.x) ||
!isFiniteNumber(bounds.y) ||
!isFiniteNumber(bounds.width) ||
!isFiniteNumber(bounds.height) ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return undefined;
}
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height)
};
}
export function parseWindowState(value: unknown): WindowState | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const state = value as Partial<WindowState>;
const bounds = parseWindowBounds(state.bounds);
if (
state.version !== WINDOW_STATE_VERSION ||
!bounds ||
typeof state.maximized !== "boolean"
) {
return undefined;
}
return {
version: WINDOW_STATE_VERSION,
bounds,
maximized: state.maximized
};
}
function intersectionArea(left: WindowBounds, right: WindowBounds) {
const width = Math.max(
0,
Math.min(left.x + left.width, right.x + right.width) -
Math.max(left.x, right.x)
);
const height = Math.max(
0,
Math.min(left.y + left.height, right.y + right.height) -
Math.max(left.y, right.y)
);
return width * height;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
const width = Math.min(
Math.max(1, Math.round(size.width)),
workArea.width
);
const height = Math.min(
Math.max(1, Math.round(size.height)),
workArea.height
);
return {
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + (workArea.height - height) / 2),
width,
height
};
}
export function resolveWindowBounds(
savedBounds: WindowBounds | undefined,
workAreas: WindowBounds[],
primaryWorkArea: WindowBounds,
defaultSize: WindowSize,
minimumSize: WindowSize
): WindowBounds {
if (!savedBounds) {
return centerBounds(defaultSize, primaryWorkArea);
}
let selectedArea = primaryWorkArea;
let selectedIntersection = 0;
for (const workArea of workAreas) {
const area = intersectionArea(savedBounds, workArea);
if (area > selectedIntersection) {
selectedArea = workArea;
selectedIntersection = area;
}
}
if (selectedIntersection === 0) {
return centerBounds(
{
width: Math.min(
Math.max(savedBounds.width, minimumSize.width),
primaryWorkArea.width
),
height: Math.min(
Math.max(savedBounds.height, minimumSize.height),
primaryWorkArea.height
)
},
primaryWorkArea
);
}
const width = Math.min(
Math.max(savedBounds.width, minimumSize.width),
selectedArea.width
);
const height = Math.min(
Math.max(savedBounds.height, minimumSize.height),
selectedArea.height
);
return {
x: clamp(
savedBounds.x,
selectedArea.x,
selectedArea.x + selectedArea.width - width
),
y: clamp(
savedBounds.y,
selectedArea.y,
selectedArea.y + selectedArea.height - height
),
width,
height
};
}
export async function loadWindowState(filePath: string) {
try {
return parseWindowState(
JSON.parse(await readFile(filePath, "utf8")) as unknown
);
} catch {
return undefined;
}
}
export async function saveWindowState(
filePath: string,
state: WindowState
) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8"
);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
}
@@ -0,0 +1,88 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
describe("桌面文档链接动作", () => {
const markdownPath = path.resolve("D:/文档/章节/当前.md");
it("保留锚点并把网络和自定义协议交给系统", () => {
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
type: "anchor",
href: "#标题"
});
expect(
resolveDesktopDocumentLinkAction("//example.com/path")
).toEqual({
type: "external",
href: "https://example.com/path"
});
expect(
resolveDesktopDocumentLinkAction("mailto:user@example.com")
).toEqual({
type: "external",
href: "mailto:user@example.com"
});
expect(
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
).toEqual({
type: "external",
href: "obsidian://open?vault=docs"
});
});
it("允许相对路径越过 Markdown 目录", () => {
expect(
resolveDesktopDocumentLinkAction(
"../../共享/说明.pdf#page=2",
markdownPath
)
).toEqual({
type: "local",
filePath: path.resolve(
path.dirname(markdownPath),
"../../共享/说明.pdf"
)
});
});
it("解析绝对路径、file URI 和 URL 编码", () => {
const absolutePath = path.resolve("C:/资料/项目说明.md");
expect(
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
).toEqual({
type: "local",
filePath: path.normalize(absolutePath)
});
expect(
resolveDesktopDocumentLinkAction(
pathToFileURL(absolutePath).href,
markdownPath
)
).toEqual({
type: "local",
filePath: path.normalize(absolutePath)
});
expect(
resolveDesktopDocumentLinkAction(
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
markdownPath
)
).toEqual({
type: "local",
filePath: path.resolve(
path.dirname(markdownPath),
"../共享/说明.pdf"
)
});
});
it("拒绝危险链接和无文件基准的相对路径", () => {
expect(
resolveDesktopDocumentLinkAction("javascript:alert(1)")
).toEqual({ type: "ignore" });
expect(() =>
resolveDesktopDocumentLinkAction("../说明.pdf")
).toThrow("尚未保存");
});
});
+107
View File
@@ -0,0 +1,107 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
createMarkdownContentHash,
createMarkdownDocumentKey,
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument,
readMarkdownFileSnapshot
} from "../src/markdown-file.js";
describe("桌面 Markdown 文件参数", () => {
it("识别两种 Markdown 扩展名且忽略大小写", () => {
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
});
it("从启动参数中提取绝对 Markdown 路径", () => {
expect(
findMarkdownFileArgument(
[
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
"--original-process-start-time=1",
"D:\\文档\\项目周报.md"
],
"C:\\Windows"
)
).toBe(path.normalize("D:\\文档\\项目周报.md"));
});
it("根据第二实例的工作目录解析相对路径", () => {
expect(
findMarkdownFileArgument(
["md-to-pdf.exe", "docs\\readme.markdown"],
"D:\\项目"
)
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
});
it("忽略非 Markdown 文件及 Chromium 参数", () => {
expect(
findMarkdownFileArgument(
["md-to-pdf.exe", "--inspect", "notes.txt"],
"C:\\项目"
)
).toBeUndefined();
});
it("统一读取 Markdown 并限制解码后的字符数", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-markdown-")
);
const filePath = path.join(directory, "示例.md");
try {
await writeFile(filePath, "# 示例", "utf8");
await expect(
readMarkdownDocument(filePath, 20)
).resolves.toEqual({
markdown: "# 示例",
fileName: "示例.md"
});
await expect(
readMarkdownDocument(filePath, 2)
).rejects.toThrow("Markdown 文件过大");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("拒绝非 Markdown 扩展名", async () => {
await expect(
readMarkdownDocument("C:\\docs\\说明.txt", 100)
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
});
it("使用真实路径和内容哈希创建文件快照", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
);
const filePath = path.join(directory, "快照.md");
try {
await writeFile(filePath, "# 第一版", "utf8");
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
expect(snapshot.document).toEqual({
markdown: "# 第一版",
fileName: "快照.md"
});
expect(snapshot.filePath).toBe(filePath);
expect(snapshot.documentKey).toBe(
createMarkdownDocumentKey(filePath)
);
expect(snapshot.contentHash).toBe(
createMarkdownContentHash("# 第一版")
);
await writeFile(filePath, "# 第二版", "utf8");
const changed = await readMarkdownFileSnapshot(filePath, 100);
expect(changed.contentHash).not.toBe(snapshot.contentHash);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+50
View File
@@ -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"
);
});
});
+165
View File
@@ -0,0 +1,165 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
loadWindowState,
parseWindowState,
resolveWindowBounds,
saveWindowState
} from "../src/window-state.js";
const primaryWorkArea = {
x: 0,
y: 0,
width: 1920,
height: 1040
};
const defaultSize = { width: 1440, height: 960 };
const minimumSize = { width: 1024, height: 720 };
describe("桌面窗口状态", () => {
it("校验并规范化持久化状态", () => {
expect(
parseWindowState({
version: 1,
bounds: {
x: 120.4,
y: 80.6,
width: 1280.2,
height: 800.8
},
maximized: true
})
).toEqual({
version: 1,
bounds: {
x: 120,
y: 81,
width: 1280,
height: 801
},
maximized: true
});
});
it("拒绝损坏或不受支持的状态", () => {
expect(parseWindowState(undefined)).toBeUndefined();
expect(
parseWindowState({
version: 2,
bounds: primaryWorkArea,
maximized: false
})
).toBeUndefined();
expect(
parseWindowState({
version: 1,
bounds: { x: 0, y: 0, width: -1, height: 720 },
maximized: false
})
).toBeUndefined();
});
it("首次启动时在主显示器居中", () => {
expect(
resolveWindowBounds(
undefined,
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 240,
y: 40,
width: 1440,
height: 960
});
});
it("将部分溢出的窗口完整收回原显示器", () => {
expect(
resolveWindowBounds(
{ x: 1700, y: 900, width: 1280, height: 800 },
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 640,
y: 240,
width: 1280,
height: 800
});
});
it("显示器移除后在主显示器居中恢复", () => {
expect(
resolveWindowBounds(
{ x: 3000, y: 120, width: 1200, height: 780 },
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 360,
y: 130,
width: 1200,
height: 780
});
});
it("窗口大于工作区时缩小到可完整展示", () => {
const compactWorkArea = {
x: 1920,
y: 0,
width: 1280,
height: 680
};
expect(
resolveWindowBounds(
{ x: 2000, y: -100, width: 1800, height: 1000 },
[primaryWorkArea, compactWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 1920,
y: 0,
width: 1280,
height: 680
});
});
it("通过临时文件原子保存并可重新载入", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-window-")
);
const filePath = path.join(directory, "window-state.json");
const state = {
version: 1 as const,
bounds: { x: 10, y: 20, width: 1200, height: 800 },
maximized: false
};
try {
await saveWindowState(filePath, state);
await expect(loadWindowState(filePath)).resolves.toEqual(state);
const updatedState = {
...state,
bounds: { x: 30, y: 40, width: 1280, height: 900 },
maximized: true
};
await saveWindowState(filePath, updatedState);
await expect(loadWindowState(filePath)).resolves.toEqual(
updatedState
);
expect(await readdir(directory)).toEqual(["window-state.json"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -11,7 +11,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/application": "0.4.0",
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2",
+4 -3
View File
@@ -26,6 +26,7 @@ import {
interface RenderRequestBody {
markdown?: unknown;
language?: unknown;
resources?: unknown;
}
interface PdfRequestBody extends RenderRequestBody {
@@ -108,7 +109,7 @@ function createPdfServerTiming(
export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: options.logger ?? true,
bodyLimit: 2 * 1024 * 1024
bodyLimit: 24 * 1024 * 1024
});
const applicationService =
options.applicationService ??
@@ -214,7 +215,7 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/render",
async (request, reply) => {
try {
return applicationService.render(request.body ?? {});
return await applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
@@ -267,7 +268,7 @@ export function buildApp(options: BuildAppOptions = {}) {
const markdownStartedAt = performance.now();
let rendered;
try {
rendered = applicationService.render(request.body ?? {});
rendered = await applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
+1 -3
View File
@@ -12,11 +12,9 @@
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2",
"@md-to-pdf/preview-engine": "0.1.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"mermaid": "11.16.0",
"pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200",
"react": "^19.2.0",
"react-dom": "^19.2.0"
+469 -312
View File
@@ -27,7 +27,7 @@ import {
createPagedPreviewRenderRequest,
isPagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
} from "@md-to-pdf/preview-engine";
import {
getNearestPageNumber,
getPreviewPageScrollTop
@@ -48,307 +48,38 @@ import {
savePreviewZoom
} from "./preview-zoom";
import { useMarkdownRender } from "./use-markdown-render";
import {
createMarkdownFileName,
downloadMarkdown,
ensureMarkdownFileName
} from "./document-file";
import { resolveWebDocumentLinkAction } from "./document-link";
import {
type ThemeSummary,
useThemeResources
} from "./use-theme-resources";
import { sampleMarkdown } from "./sample-markdown";
const PrecisePdfPreview = lazy(async () => {
const module = await import("./PrecisePdfPreview");
return { default: module.PrecisePdfPreview };
});
type PreviewMode = "quick" | "precise";
const sampleMarkdown = `---
title: Markdown PDF 示例
author: 内网文档团队
keywords:
- Markdown
- PDF
---
# Markdown PDF 示例
这是一份使用 **Typora 风格主题** 渲染的预览文档。
## 常用内容
- [x] Markdown 实时预览
- [x] 表格与任务列表
- [x] 数学公式、Mermaid 与 ECharts
- [ ] Chromium PDF 导出
| 功能 | 当前状态 |
| --- | --- |
| HTML 预览 | 已实现 |
| PDF 下载 | 下一阶段 |
行内公式:$E = mc^2$
\`\`\`mermaid
flowchart LR
A["Markdown"] --> B["统一 HTML"]
B --> C["主题 CSS"]
C --> D["网页预览"]
C --> E["PDF 导出"]
\`\`\`
## ECharts 图表
### 基础柱状图
\`\`\`echarts
caption: 各模块文档数量
height: 58mm
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- type: bar
data: [28, 18, 15, 22]
\`\`\`
### Tree 树图
\`\`\`echarts
caption: 产品能力树
height: 58mm
option:
series:
- type: tree
orient: LR
label:
position: left
data:
- name: 文档平台
children:
- name: Markdown
children:
- name: Mermaid
- name: ECharts
- name: PDF
children:
- name: 快速预览
- name: 精确预览
\`\`\`
### Treemap 矩形树图
\`\`\`echarts
caption: 模块工作量占比
height: 58mm
option:
series:
- type: treemap
roam: false
nodeClick: false
breadcrumb:
show: false
label:
show: true
formatter: "{b}"
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
borderRadius: 3
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
levels:
- itemStyle:
borderWidth: 0
gapWidth: 2
- upperLabel:
show: true
height: 28
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
- label:
show: true
formatter: "{b}"
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
borderRadius: 3
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
data:
- name: 渲染
children:
- { name: Markdown, value: 32 }
- { name: ECharts, value: 24 }
- { name: Mermaid, value: 18 }
- name: 导出
children:
- { name: PDF, value: 28 }
- { name: 主题, value: 12 }
\`\`\`
### Sunburst 旭日图
\`\`\`echarts
caption: 文档内容层级
height: 58mm
option:
series:
- type: sunburst
radius: [15%, 90%]
data:
- name: 技术文档
children:
- { name: 架构, value: 18 }
- { name: 接口, value: 24 }
- name: 项目文档
children:
- { name: 周报, value: 16 }
- { name: 计划, value: 12 }
\`\`\`
### Graph 关系图
\`\`\`echarts
caption: 渲染链路关系
height: 58mm
option:
series:
- type: graph
layout: circular
roam: false
label:
show: true
data:
- { name: Markdown, symbolSize: 42 }
- { name: HTML, symbolSize: 38 }
- { name: 预览, symbolSize: 34 }
- { name: PDF, symbolSize: 34 }
links:
- { source: Markdown, target: HTML }
- { source: HTML, target: 预览 }
- { source: HTML, target: PDF }
\`\`\`
### Sankey 桑基图
\`\`\`echarts
caption: 文档流转量
height: 58mm
option:
series:
- type: sankey
data:
- { name: Markdown }
- { name: HTML }
- { name: 快速预览 }
- { name: 精确预览 }
- { name: PDF }
links:
- { source: Markdown, target: HTML, value: 100 }
- { source: HTML, target: 快速预览, value: 55 }
- { source: HTML, target: 精确预览, value: 25 }
- { source: HTML, target: PDF, value: 20 }
\`\`\`
### Chord 和弦图
\`\`\`echarts
caption: 团队协作关系
height: 58mm
option:
series:
- type: chord
label:
show: true
data:
- { name: 研发 }
- { name: 产品 }
- { name: 设计 }
- { name: 运营 }
links:
- { source: 研发, target: 产品, value: 12 }
- { source: 产品, target: 设计, value: 8 }
- { source: 设计, target: 运营, value: 6 }
- { source: 运营, target: 研发, value: 4 }
\`\`\`
### Funnel 漏斗图
\`\`\`echarts
caption: 文档发布转化
height: 58mm
option:
series:
- type: funnel
sort: descending
label:
formatter: "{b}: {c}"
data:
- { name: 草稿, value: 100 }
- { name: 评审, value: 76 }
- { name: 定稿, value: 58 }
- { name: 发布, value: 45 }
\`\`\`
### Gauge 仪表盘
\`\`\`echarts
caption: 发布完成率
height: 58mm
option:
series:
- type: gauge
min: 0
max: 100
progress:
show: true
detail:
formatter: "{value}%"
data:
- { name: 完成率, value: 78 }
\`\`\`
### PictorialBar 象形柱图
\`\`\`echarts
caption: 各渠道文档数量
height: 58mm
option:
xAxis:
type: category
data: [知识库, 周报, 手册, 方案]
yAxis:
type: value
series:
- type: pictorialBar
symbol: "path://M0,10 L5,0 L10,10 Z"
symbolRepeat: true
symbolSize: [14, 10]
data: [12, 20, 16, 24]
\`\`\`
> 预览和 PDF 将共用同一份 HTML 与 CSS。
`;
type PreviewMode = "quick" | "precise" | "continuous";
type DocumentKind = "sample" | "new" | "file";
const noImageResources: never[] = [];
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
const [documentKind, setDocumentKind] =
useState<DocumentKind>("sample");
const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown);
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [themeRefreshKey, setThemeRefreshKey] = useState(0);
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
const [appError, setAppError] = useState("");
@@ -369,6 +100,8 @@ export function App() {
total: 0
});
const editorRef = useRef<HTMLTextAreaElement>(null);
const markdownFileInputRef = useRef<HTMLInputElement>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
@@ -378,9 +111,108 @@ export function App() {
const scrollSyncOriginRef = useRef<
"editor" | "preview" | undefined
>(undefined);
const documentDirty = markdown !== savedMarkdown;
const documentDirtyRef = useRef(documentDirty);
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
documentDirtyRef.current = documentDirty;
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
let active = true;
const consumeOpenedMarkdown = async (
reason: "open" | "reload" | "replace" = "open"
) => {
if (
reason === "open" &&
documentDirtyRef.current &&
!window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?")
) {
try {
await bridge.discardPendingMarkdown();
} catch (reason) {
if (active) {
setAppError(
reason instanceof Error
? reason.message
: "无法丢弃待打开的 Markdown 文件"
);
}
}
return;
}
try {
const opened = await bridge.consumePendingMarkdown();
if (!active || !opened) {
return;
}
setAppError("");
setMarkdown(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
if (active) {
setAppError(
reason instanceof Error
? reason.message
: "无法打开 Markdown 文件"
);
}
}
};
const removeListener = bridge.onMarkdownOpened((reason) => {
void consumeOpenedMarkdown(reason);
});
void consumeOpenedMarkdown();
return () => {
active = false;
removeListener();
};
}, []);
useEffect(() => {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
return;
}
void bridge.setDocumentDirty(documentDirty).catch(
(reason: unknown) => {
console.warn("无法同步桌面文档修改状态", reason);
}
);
}, [documentDirty]);
useEffect(() => {
if (!moreMenuOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!moreMenuRef.current?.contains(event.target)
) {
setMoreMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMoreMenuOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [moreMenuOpen]);
const handleThemesLoaded = useCallback(
(availableThemes: ThemeSummary[]) => {
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
setExportConfig((currentConfig) => {
if (
availableThemes.some(
@@ -397,6 +229,10 @@ export function App() {
? { ...currentConfig, themeId: preferredTheme.id }
: currentConfig;
});
if (themeRefreshTargetRef.current === loadedRefreshKey) {
themeRefreshTargetRef.current = undefined;
setStatus("主题清单已刷新");
}
},
[]
);
@@ -407,6 +243,7 @@ export function App() {
const { error: renderError, result } = useMarkdownRender(
markdown,
"zh-CN",
noImageResources,
{
onRenderStart: handleRenderStart,
onStatusChange: setStatus
@@ -416,7 +253,11 @@ export function App() {
error: themeError,
themeCss,
themes
} = useThemeResources(exportConfig.themeId, handleThemesLoaded);
} = useThemeResources(
exportConfig.themeId,
handleThemesLoaded,
themeRefreshKey
);
const error =
renderError ||
themeError ||
@@ -427,6 +268,13 @@ export function App() {
pdfError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const effectiveFileName =
documentKind === "new" || !fileName
? createMarkdownFileName(markdown)
: fileName;
const documentDisplayName =
documentKind === "new" ? "新建文档" : fileName;
const showUnsavedState = documentKind === "new" || documentDirty;
const paperDimensions = getPaperDimensionsMm(
exportConfig.paper.format,
exportConfig.paper.orientation
@@ -457,21 +305,22 @@ export function App() {
result && themeCss
? createPagedDocumentPayload({
document: result,
fileName,
fileName: effectiveFileName,
themeCss,
exportConfig
})
: undefined,
[exportConfig, fileName, result, themeCss]
[effectiveFileName, exportConfig, result, themeCss]
);
const pdfRequest = useMemo<PdfExportRequest>(
() => ({
markdown,
fileName,
fileName: effectiveFileName,
language: "zh-CN",
resources: noImageResources,
exportConfig
}),
[exportConfig, fileName, markdown]
[effectiveFileName, exportConfig, markdown]
);
const pdfCacheKey = useMemo(
() => createPdfExportCacheKey(pdfRequest),
@@ -526,6 +375,11 @@ export function App() {
return;
}
if (event.data.type === "link") {
handlePagedPreviewLink(event.data.href);
return;
}
if (event.data.requestId !== previewRequestIdRef.current) {
return;
}
@@ -536,7 +390,11 @@ export function App() {
setMermaidError("");
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
setStatus("正在分页…");
setStatus(
event.data.layout === "continuous"
? "正在更新连续预览…"
: "正在分页…"
);
return;
}
@@ -556,12 +414,20 @@ export function App() {
? `Mermaid${event.data.mermaidErrors.join("")}`
: ""
);
setStatus(`预览已分页(${event.data.pageCount} 页)`);
setStatus(
event.data.layout === "continuous"
? "连续预览已更新"
: `预览已分页(${event.data.pageCount} 页)`
);
setQuickPreviewContentHeight(event.data.contentHeight);
setPreviewPagination({
current: event.data.pageCount > 0 ? 1 : 0,
total: event.data.pageCount
});
setPreviewPagination(
event.data.layout === "continuous"
? { current: 0, total: 0 }
: {
current: event.data.pageCount > 0 ? 1 : 0,
total: event.data.pageCount
}
);
window.requestAnimationFrame(() => {
synchronizeScroll("editor");
window.requestAnimationFrame(updatePreviewCurrentPage);
@@ -602,11 +468,14 @@ export function App() {
frameWindow.postMessage(
createPagedPreviewRenderRequest(
previewRequestIdRef.current,
previewPayload
previewPayload,
previewMode === "continuous"
? "continuous"
: "paged"
),
window.location.origin
);
}, [previewFrameReady, previewPayload]);
}, [previewFrameReady, previewMode, previewPayload]);
function getOuterPreviewScroller() {
const previewScroller = previewScrollRef.current;
@@ -623,6 +492,79 @@ export function App() {
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") {
const source =
origin === "editor" ? editorRef.current : getPreviewScroller();
@@ -654,6 +596,9 @@ export function App() {
updatePrecisePreviewCurrentPage();
return;
}
if (previewModeRef.current === "continuous") {
return;
}
const frame = previewFrameRef.current;
const frameDocument = frame?.contentDocument;
const scrollElement = getPreviewScroller();
@@ -803,6 +748,24 @@ export function App() {
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
}, []);
function confirmDiscardChanges() {
return (
!documentDirty ||
window.confirm("当前文档有未保存修改,是否放弃这些修改?")
);
}
function handleOpenMarkdown() {
if (!confirmDiscardChanges()) {
return;
}
if (window.mdToPdfDesktop) {
void handleDesktopOpenMarkdown();
return;
}
markdownFileInputRef.current?.click();
}
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
@@ -811,8 +774,12 @@ export function App() {
try {
setAppError("");
setMarkdown(await file.text());
const content = await file.text();
setMarkdown(content);
setFileName(file.name);
setDocumentKind("file");
setSavedMarkdown(content);
setStatus("Markdown 已载入;Web 端不读取本地相对图片");
} catch {
setAppError("无法读取所选 Markdown 文件");
} finally {
@@ -820,6 +787,110 @@ export function App() {
}
}
async function handleDesktopOpenMarkdown() {
try {
setAppError("");
const opened = await window.mdToPdfDesktop?.openMarkdown();
if (!opened) {
return;
}
setMarkdown(opened.markdown);
setFileName(opened.fileName);
setDocumentKind("file");
setSavedMarkdown(opened.markdown);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开 Markdown 文件"
);
}
}
async function handleNewMarkdown() {
setMoreMenuOpen(false);
if (!confirmDiscardChanges()) {
return;
}
try {
setAppError("");
await window.mdToPdfDesktop?.startNewMarkdown();
setMarkdown("");
setFileName("");
setDocumentKind("new");
setSavedMarkdown("");
setStatus("新建文档尚未保存,可直接编辑或导出 PDF");
window.requestAnimationFrame(() => editorRef.current?.focus());
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法新建 Markdown"
);
}
}
async function handleSaveMarkdown() {
setMoreMenuOpen(false);
const suggestedName = ensureMarkdownFileName(fileName, markdown);
try {
setAppError("");
const bridge = window.mdToPdfDesktop;
let savedFileName = suggestedName;
if (bridge) {
const saved = await bridge.saveMarkdown(suggestedName, markdown);
if (!saved) {
setStatus("已取消 Markdown 保存");
return;
}
savedFileName = saved.fileName;
} else {
downloadMarkdown(markdown, suggestedName);
}
setFileName(savedFileName);
setDocumentKind("file");
setSavedMarkdown(markdown);
setStatus(`Markdown 已保存:${savedFileName}`);
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "Markdown 保存失败"
);
}
}
async function handleOpenThemeDirectory() {
setMoreMenuOpen(false);
try {
setAppError("");
const themeDirectory =
await window.mdToPdfDesktop?.openThemeDirectory();
if (themeDirectory) {
setStatus(`自定义主题目录:${themeDirectory}`);
}
} catch (reason) {
setAppError(
reason instanceof Error
? reason.message
: "无法打开自定义主题目录"
);
}
}
async function handleRefreshThemes() {
setMoreMenuOpen(false);
try {
setAppError("");
await window.mdToPdfDesktop?.refreshThemes();
setStatus("正在刷新主题清单…");
setThemeRefreshKey((key) => {
const nextKey = key + 1;
themeRefreshTargetRef.current = nextKey;
return nextKey;
});
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法刷新主题清单"
);
}
}
async function generatePdf(download: boolean) {
if (exportingPdf) {
return;
@@ -881,8 +952,12 @@ export function App() {
if (mode === previewMode) {
return;
}
const keepsFrame =
previewMode !== "precise" && mode !== "precise";
setPreviewMode(mode);
setPreviewFrameReady(false);
if (!keepsFrame) {
setPreviewFrameReady(false);
}
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
previewScrollRef.current?.scrollTo({ top: 0 });
@@ -909,7 +984,14 @@ export function App() {
</div>
<div className="document-summary">
<span className="panel-kicker"></span>
<strong title={fileName}>{fileName}</strong>
<div className="document-name-row">
<strong title={documentDisplayName}>
{documentDisplayName}
</strong>
{showUnsavedState ? (
<span className="unsaved-badge"></span>
) : null}
</div>
<span
className={`render-status${error ? " is-error" : ""}`}
aria-live="polite"
@@ -920,14 +1002,67 @@ export function App() {
</div>
</div>
<div className="topbar-actions">
<label className="file-button">
Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
<button type="button" onClick={handleOpenMarkdown}>
Markdown
</button>
<div className="more-menu" ref={moreMenuRef}>
<button
type="button"
aria-controls="document-actions-menu"
aria-expanded={moreMenuOpen}
aria-haspopup="menu"
onClick={() => setMoreMenuOpen((open) => !open)}
>
</button>
{moreMenuOpen ? (
<div
id="document-actions-menu"
className="more-menu-popover"
role="menu"
>
<button
type="button"
role="menuitem"
onClick={() => void handleNewMarkdown()}
>
Markdown
</button>
<button
type="button"
role="menuitem"
onClick={() => void handleSaveMarkdown()}
>
Markdown
</button>
{window.mdToPdfDesktop ? (
<>
<button
type="button"
role="menuitem"
onClick={() => void handleOpenThemeDirectory()}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => void handleRefreshThemes()}
>
</button>
</>
) : null}
</div>
) : null}
</div>
<input
ref={markdownFileInputRef}
hidden
type="file"
accept=".md,.markdown,text/markdown"
onChange={handleFileChange}
/>
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
@@ -1022,6 +1157,18 @@ export function App() {
>
</button>
<button
type="button"
className={
previewMode === "continuous" ? "is-active" : ""
}
aria-pressed={previewMode === "continuous"}
onClick={() =>
handlePreviewModeChange("continuous")
}
>
</button>
</span>
<label className="theme-control">
<span className="sr-only"></span>
@@ -1071,12 +1218,20 @@ export function App() {
className="preview-scroll"
onScroll={handlePreviewScroll}
>
{previewMode === "quick" ? (
<div className="paper" style={paperStyle}>
{previewMode !== "precise" ? (
<div
className={`paper${
previewMode === "continuous"
? " continuous-paper"
: ""
}`}
style={paperStyle}
>
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
scrolling="no"
style={previewFrameStyle}
title="文档内容预览"
sandbox="allow-same-origin allow-scripts"
@@ -1103,6 +1258,8 @@ export function App() {
<PrecisePdfPreview
blob={cachedPdf.blob}
zoomPercent={previewZoom}
onExternalLink={handlePagedPreviewLink}
onNavigateToPage={handlePreviewPageChange}
onPageCount={handlePrecisePageCount}
/>
</Suspense>
+80 -2
View File
@@ -7,6 +7,7 @@ import {
useState
} from "react";
import {
AnnotationLayer,
getDocument,
GlobalWorkerOptions,
RenderingCancelledException,
@@ -20,10 +21,15 @@ import {
getPdfCanvasRenderScale,
getPdfPageCssDimensions
} from "./pdf-page-geometry";
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
import { PdfPageRenderQueue } from "./pdf-render-queue";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
type PdfJsAnnotationLinkService = Parameters<
AnnotationLayer["render"]
>[0]["linkService"];
interface PdfPageDescriptor {
pageNumber: number;
width: number;
@@ -33,12 +39,16 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps {
blob: Blob;
zoomPercent: number;
onExternalLink?: (href: string) => void;
onNavigateToPage?: (pageNumber: number) => void;
onPageCount?: (pageCount: number) => void;
}
interface PrecisePdfPageProps {
document: PDFDocumentProxy;
descriptor: PdfPageDescriptor;
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
renderQueue: PdfPageRenderQueue;
zoomPercent: number;
}
@@ -92,12 +102,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({
document,
descriptor,
onExternalLink,
onNavigateToPage,
renderQueue,
zoomPercent
}: PrecisePdfPageProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const textLayerRef = useRef<HTMLDivElement>(null);
const annotationLayerRef = useRef<HTMLDivElement>(null);
const [displayWidth, setDisplayWidth] = useState(0);
const [renderError, setRenderError] = useState("");
const [renderState, setRenderState] = useState<
@@ -158,11 +171,13 @@ function PrecisePdfPage({
useEffect(() => {
const canvas = canvasRef.current;
const textContainer = textLayerRef.current;
const annotationContainer = annotationLayerRef.current;
if (
!nearViewport ||
displayWidth <= 0 ||
!canvas ||
!textContainer
!textContainer ||
!annotationContainer
) {
return;
}
@@ -171,6 +186,7 @@ function PrecisePdfPage({
let page: PDFPageProxy | undefined;
let renderTask: RenderTask | undefined;
let textLayer: TextLayer | undefined;
let annotationLayer: AnnotationLayer | undefined;
setRenderError("");
setRenderState("queued");
@@ -212,6 +228,7 @@ function PrecisePdfPage({
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
textContainer.replaceChildren();
annotationContainer.replaceChildren();
renderTask = page.render({
canvas,
@@ -229,7 +246,43 @@ function PrecisePdfPage({
textContentSource: page.streamTextContent(),
viewport: displayViewport
});
await textLayer.render();
const annotations = await page.getAnnotations({
intent: "display"
});
const linkService = createPdfAnnotationLinkService(
document,
descriptor.pageNumber,
{
onExternalLink,
onNavigateToPage
}
);
annotationLayer = new AnnotationLayer({
div: annotationContainer,
accessibilityManager: undefined,
annotationCanvasMap: undefined,
annotationEditorUIManager: undefined,
page,
viewport: displayViewport,
structTreeLayer: undefined,
commentManager: undefined,
linkService,
annotationStorage: document.annotationStorage
});
await Promise.all([
textLayer.render(),
annotationLayer.render({
viewport: displayViewport,
div: annotationContainer,
annotations,
page,
linkService:
linkService as unknown as PdfJsAnnotationLinkService,
annotationStorage: document.annotationStorage,
renderForms: false,
enableScripting: false
})
]);
} catch (reason: unknown) {
if (
disposed ||
@@ -251,10 +304,12 @@ function PrecisePdfPage({
cancelQueuedRender();
renderTask?.cancel();
textLayer?.cancel();
annotationLayer?.destroy();
page?.cleanup();
canvas.width = 0;
canvas.height = 0;
textContainer.replaceChildren();
annotationContainer.replaceChildren();
setRenderState("idle");
};
}, [
@@ -264,6 +319,8 @@ function PrecisePdfPage({
displayWidth,
document,
nearViewport,
onExternalLink,
onNavigateToPage,
renderQueue
]);
@@ -282,6 +339,10 @@ function PrecisePdfPage({
aria-hidden="true"
/>
<div ref={textLayerRef} className="textLayer" />
<div
ref={annotationLayerRef}
className="annotationLayer"
/>
{renderState !== "rendered" ? (
<span className="precise-pdf-page-placeholder">
{renderState === "rendering"
@@ -299,6 +360,8 @@ function PrecisePdfPage({
export function PrecisePdfPreview({
blob,
zoomPercent,
onExternalLink,
onNavigateToPage,
onPageCount
}: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -308,6 +371,19 @@ export function PrecisePdfPreview({
() => new PdfPageRenderQueue(1),
[document]
);
const externalLinkRef = useRef(onExternalLink);
const navigateToPageRef = useRef(onNavigateToPage);
externalLinkRef.current = onExternalLink;
navigateToPageRef.current = onNavigateToPage;
const handleExternalLink = useCallback((href: string) => {
externalLinkRef.current?.(href);
}, []);
const handleNavigateToPage = useCallback(
(pageNumber: number) => {
navigateToPageRef.current?.(pageNumber);
},
[]
);
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
@@ -386,6 +462,8 @@ export function PrecisePdfPreview({
key={page.pageNumber}
document={document}
descriptor={page}
onExternalLink={handleExternalLink}
onNavigateToPage={handleNavigateToPage}
renderQueue={renderQueue}
zoomPercent={zoomPercent}
/>
+6
View File
@@ -12,6 +12,12 @@ export interface ThemeSummary {
export interface MarkdownRenderInput {
markdown: string;
language: string;
resources?: MarkdownImageResource[];
}
export interface MarkdownImageResource {
path: string;
data: string;
}
function throwIfAborted(signal?: AbortSignal) {
+110
View File
@@ -0,0 +1,110 @@
const fallbackBaseName = "未命名文档";
const windowsReservedName =
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
function stripMarkdownFormatting(value: string) {
return value
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/<\/?[A-Za-z][^>]*>/gu, " ")
.replace(/[`*_~]/gu, "")
.replace(/^\s{0,3}(?:>\s*|[-+*]\s+|\d+[.)]\s+)/u, "")
.trim();
}
export function legalizeMarkdownBaseName(value: string) {
const normalized = stripMarkdownFormatting(value)
.normalize("NFKC")
.replace(/[^\p{L}\p{N}\s_-]+/gu, " ")
.replace(/\s+/gu, " ")
.trim()
.replace(/[ .]+$/u, "");
const shortened = Array.from(normalized).slice(0, 100).join("").trim();
if (!shortened) {
return fallbackBaseName;
}
return windowsReservedName.test(shortened)
? `文档 ${shortened}`
: shortened;
}
export function deriveMarkdownBaseName(markdown: string) {
const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u);
let inFrontMatter = lines[0]?.trim() === "---";
let inFence = false;
let fenceMarker = "";
let fenceLength = 0;
let firstBodyLine = "";
for (let index = inFrontMatter ? 1 : 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const trimmed = line.trim();
if (inFrontMatter) {
if (trimmed === "---" || trimmed === "...") {
inFrontMatter = false;
}
continue;
}
if (inFence) {
const closingFence = trimmed.match(/^(`+|~+)\s*$/u)?.[1];
if (
closingFence?.[0] === fenceMarker &&
closingFence.length >= fenceLength
) {
inFence = false;
fenceMarker = "";
fenceLength = 0;
}
continue;
}
const fence = trimmed.match(/^(`{3,}|~{3,})/u)?.[1];
if (fence) {
if (!inFence) {
inFence = true;
fenceMarker = fence[0] ?? "";
fenceLength = fence.length;
}
continue;
}
if (inFence || !trimmed) {
continue;
}
const heading = line.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u)?.[1];
if (heading) {
return legalizeMarkdownBaseName(heading);
}
if (!firstBodyLine) {
firstBodyLine = line;
}
}
return legalizeMarkdownBaseName(firstBodyLine);
}
export function createMarkdownFileName(markdown: string) {
return `${deriveMarkdownBaseName(markdown)}.md`;
}
export function ensureMarkdownFileName(fileName: string, markdown: string) {
const trimmed = fileName.trim();
if (!trimmed) {
return createMarkdownFileName(markdown);
}
return /\.(?:md|markdown)$/iu.test(trimmed)
? trimmed
: `${trimmed}.md`;
}
export function downloadMarkdown(markdown: string, fileName: string) {
const url = URL.createObjectURL(
new Blob([markdown], { type: "text/markdown;charset=utf-8" })
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
+39
View File
@@ -0,0 +1,39 @@
import { classifyDocumentLink } from "@md-to-pdf/core";
export type WebDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "open";
href: string;
}
| {
type: "ignore";
};
export function resolveWebDocumentLinkAction(
href: string
): WebDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return {
type: "anchor",
href: link.normalizedHref
};
}
if (
link.kind === "network" ||
(link.kind === "protocol" &&
(link.scheme === "mailto" || link.scheme === "tel"))
) {
return {
type: "open",
href: link.normalizedHref
};
}
return { type: "ignore" };
}
-448
View File
@@ -1,448 +0,0 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
import { createMermaidSiteConfig } from "./mermaid-config";
import {
fitOversizedMermaidToPage,
getPageContentDimensions
} from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
import type { MermaidOutputMode } from "./mermaid-static-image";
import { enablePrintMediaForPreview } from "./preview-styles";
import "./paged-table-handler";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
type PagedPreviewPayload
} from "./paged-preview";
import type { PagedRenderTarget } from "./paged-render-target";
export type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
export interface PagedDocumentRenderOptions {
target: PagedRenderTarget;
shouldContinue?: () => boolean;
mermaidOutput?: MermaidOutputMode;
}
function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
return Promise.all(
images.map(async (image) => {
if (image.complete) {
return;
}
try {
await image.decode();
} catch {
await new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
}
})
);
}
function stylesheet(
documentRef: Document,
css: string,
name: string
) {
return {
[`${documentRef.location.href}#${name}`]: css
};
}
function mountMeasurementContainer(
documentRef: Document,
content: DocumentFragment,
payload: PagedPreviewPayload,
target: PagedRenderTarget
) {
const pageContent = getPageContentDimensions(payload.exportConfig);
const style = documentRef.createElement("style");
const themeCss =
target === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss;
style.dataset.pagedMeasurementStyle = "true";
style.textContent = [
documentBaseCss,
highlightCss,
katexCss,
echartsCss,
themeCss,
documentGeometryCss
].join("\n");
const host = documentRef.createElement("div");
host.dataset.pagedMeasurementHost = "true";
host.style.cssText = [
"position: fixed",
"left: -100000px",
"top: 0",
`width: ${pageContent.width}px`,
"height: auto",
"opacity: 0",
"pointer-events: none",
"contain: layout style paint"
].join(";");
documentRef.head.append(style);
host.append(content);
documentRef.body.append(host);
return {
host,
restore() {
content.append(...Array.from(host.childNodes));
host.remove();
style.remove();
}
};
}
function applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
function removeTrailingPdfPageBreak(container: ParentNode) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
pages
.at(-1)
?.style.setProperty("break-after", "auto", "important");
}
export class PagedDocumentRuntime {
private activePreviewer: Previewer | undefined;
private mermaidRenderSequence = 0;
private mermaidPromise:
| Promise<(typeof import("mermaid"))["default"]>
| undefined;
private echartsPromise:
| Promise<
typeof import("@md-to-pdf/markdown-echarts/browser")
>
| undefined;
constructor(private readonly root: HTMLElement) {}
destroy() {
if (!this.activePreviewer) {
return;
}
this.activePreviewer.chunker.destroy();
this.activePreviewer.polisher.destroy();
this.activePreviewer = undefined;
}
private loadMermaid() {
if (!this.mermaidPromise) {
this.mermaidPromise = Promise.all([
import("mermaid"),
import("@mermaid-js/layout-elk")
]).then(
([{ default: mermaid }, { default: elkLayouts }]) => {
mermaid.registerLayoutLoaders(elkLayouts);
return mermaid;
}
);
}
return this.mermaidPromise;
}
private loadECharts() {
if (!this.echartsPromise) {
this.echartsPromise = import(
"@md-to-pdf/markdown-echarts/browser"
);
}
return this.echartsPromise;
}
private async renderECharts(
container: ParentNode,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("echarts")) {
return [];
}
const { renderEChartsBlocks } = await this.loadECharts();
const outcomes = await renderEChartsBlocks(container, {
outputMode: "inline-svg",
concurrency: 2,
timeoutMs: 5_000
});
return outcomes.flatMap((outcome, index) =>
outcome.success
? []
: [
`图表 ${index + 1}${
outcome.error?.message ?? "未知错误"
}`
]
);
}
private async renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await this.loadMermaid();
mermaid.initialize(
createMermaidSiteConfig(payload.exportConfig.mermaid)
);
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
this.mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-document-${this.mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error
? outcome.error.message
: "未知错误";
node.classList.add("mermaid-error");
node.textContent =
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
return errors;
}
async render(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
if (!shouldContinue()) {
return undefined;
}
const setupStartedAt = performance.now();
const documentRef = this.root.ownerDocument;
this.destroy();
this.root.replaceChildren();
documentRef.documentElement.dataset.renderTarget = options.target;
documentRef.documentElement.lang =
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 分页文档";
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
const content = template.content;
const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now();
const mermaidErrors = await this.renderMermaid(content, payload);
const mermaidMs = performance.now() - mermaidStartedAt;
let echartsErrors: string[] = [];
let echartsMs = 0;
let echartsFitMs = 0;
let mermaidFitMs = 0;
let mermaidConversionMs = 0;
if (
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg")
) {
const measurement = mountMeasurementContainer(
documentRef,
content,
payload,
options.target
);
try {
await documentRef.fonts.ready;
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
measurement.host,
payload
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host);
const echartsFitStartedAt = performance.now();
fitOversizedEChartsToPage(
measurement.host,
payload.exportConfig
);
echartsFitMs =
performance.now() - echartsFitStartedAt;
const mermaidFitStartedAt = performance.now();
fitOversizedMermaidToPage(
measurement.host,
payload.exportConfig
);
mermaidFitMs = performance.now() - mermaidFitStartedAt;
const mermaidConversionStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") {
replaceMermaidSvgWithImages(measurement.host);
await waitForImages(measurement.host);
}
mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
} finally {
measurement.restore();
}
}
if (!shouldContinue()) {
return undefined;
}
const resourceWaitStartedAt = performance.now();
await documentRef.fonts.ready;
await waitForImages(content);
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
const paginationStartedAt = performance.now();
const previewer = new Previewer();
this.activePreviewer = previewer;
const flow = await previewer.preview(
content,
[
stylesheet(documentRef, documentBaseCss, "document-base"),
stylesheet(documentRef, highlightCss, "highlight"),
stylesheet(documentRef, katexCss, "katex"),
stylesheet(documentRef, echartsCss, "echarts"),
stylesheet(documentRef, payload.themeCss, "theme"),
stylesheet(
documentRef,
documentGeometryCss,
"document-geometry"
),
stylesheet(
documentRef,
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
],
this.root
);
const paginationMs = performance.now() - paginationStartedAt;
if (!shouldContinue()) {
return undefined;
}
const finalizeStartedAt = performance.now();
applyPageNumbers(this.root, payload, flow.total);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
}
const finalizeMs = performance.now() - finalizeStartedAt;
return {
pageCount: flow.total,
echartsErrors,
mermaidErrors,
timings: {
setupMs,
echartsMs,
echartsFitMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
resourceWaitMs,
paginationMs,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
}
}
declare global {
interface Window {
__mdToPdfRender?: (
payload: PagedPreviewPayload,
target?: PagedRenderTarget
) => Promise<PagedDocumentRenderResult>;
}
}
+67 -15
View File
@@ -1,12 +1,15 @@
import { PagedDocumentRuntime } from "./paged-document-runtime";
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import {
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
import { resolvePagedRenderTarget } from "./paged-render-target";
import { resolveMermaidOutputMode } from "./mermaid-static-image";
} from "@md-to-pdf/preview-engine";
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import {
beginPreviewFrameRender,
completePreviewFrameRender
@@ -23,7 +26,11 @@ function getPreviewRoot() {
const previewRoot = getPreviewRoot();
const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot);
const runtime = new PagedDocumentRuntime(previewRoot, {
highlightCss,
katexCss,
echartsCss
});
document.documentElement.dataset.renderTarget = renderTarget;
let latestRequestId = 0;
@@ -33,9 +40,47 @@ function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin);
}
function measurePreviewContentHeight() {
const rootRect = previewRoot.getBoundingClientRect();
const documentRect =
document.documentElement.getBoundingClientRect();
return Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight,
rootRect.bottom - documentRect.top
)
);
}
document.addEventListener(
"click",
(event) => {
const target =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href]")
: undefined;
const href = target?.getAttribute("href")?.trim();
if (!href) {
return;
}
event.preventDefault();
event.stopPropagation();
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "link",
href
});
},
{ capture: true }
);
async function renderPagedPreview(
requestId: number,
payload: PagedPreviewPayload
payload: PagedPreviewPayload,
layout: "paged" | "continuous"
) {
if (requestId !== latestRequestId) {
return;
@@ -45,15 +90,21 @@ async function renderPagedPreview(
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering",
requestId
requestId,
layout
});
try {
const result = await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
const result =
layout === "continuous"
? await runtime.renderContinuous(payload, {
shouldContinue: () => requestId === latestRequestId
})
: await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
@@ -62,7 +113,8 @@ async function renderPagedPreview(
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
contentHeight: Math.ceil(document.documentElement.scrollHeight),
layout,
contentHeight: measurePreviewContentHeight(),
...result
});
} finally {
@@ -121,12 +173,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
}
latestRequestId = Math.max(latestRequestId, event.data.requestId);
const { requestId, payload } = event.data;
const { requestId, layout, payload } = event.data;
renderQueue = renderQueue
.catch(() => undefined)
.then(async () => {
try {
await renderPagedPreview(requestId, payload);
await renderPagedPreview(requestId, payload, layout);
} catch (reason: unknown) {
if (requestId !== latestRequestId) {
return;
-41
View File
@@ -1,41 +0,0 @@
declare module "pagedjs" {
export interface PagedFlow {
total: number;
pages: HTMLElement[];
performance: number;
size: {
width: { value: number; unit: string };
height: { value: number; unit: string };
format?: string;
orientation?: string;
};
}
export type PagedStylesheet = string | Record<string, string>;
export class Previewer {
chunker: {
destroy(): void;
};
polisher: {
destroy(): void;
};
preview(
content?: HTMLElement | DocumentFragment | string,
stylesheets?: PagedStylesheet[],
renderTo?: HTMLElement | string
): Promise<PagedFlow>;
}
export class Handler {
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
);
}
export function registerHandlers(
...handlers: Array<typeof Handler>
): void;
}
+152
View File
@@ -0,0 +1,152 @@
import type { PDFDocumentProxy } from "pdfjs-dist";
import { decodeLocalDocumentLinkFromPdf } from "@md-to-pdf/core";
export type PdfDestination = string | unknown[];
export interface PdfAnnotationLinkCallbacks {
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
}
export interface PdfAnnotationLinkService {
externalLinkEnabled: boolean;
addLinkAttributes(
link: HTMLAnchorElement,
url: string,
newWindow?: boolean
): void;
getDestinationHash(destination: PdfDestination): string;
getAnchorUrl(anchor: string): string;
goToDestination(destination: PdfDestination): Promise<void>;
executeNamedAction(action: string): void;
executeSetOCGState(action: object): Promise<void>;
getAttachmentContent(id: string): Promise<undefined>;
}
export async function resolvePdfDestinationPage(
document: Pick<
PDFDocumentProxy,
"getDestination" | "getPageIndex" | "numPages"
>,
destination: PdfDestination
) {
const explicitDestination =
typeof destination === "string"
? await document.getDestination(destination)
: destination;
if (
!Array.isArray(explicitDestination) ||
explicitDestination.length === 0
) {
return undefined;
}
const pageReference = explicitDestination[0];
let pageNumber: number;
if (
typeof pageReference === "number" &&
Number.isInteger(pageReference)
) {
pageNumber = pageReference + 1;
} else if (
pageReference &&
typeof pageReference === "object"
) {
pageNumber =
(await document.getPageIndex(
pageReference as Parameters<
PDFDocumentProxy["getPageIndex"]
>[0]
)) + 1;
} else {
return undefined;
}
return pageNumber >= 1 && pageNumber <= document.numPages
? pageNumber
: undefined;
}
export function resolvePdfNamedActionPage(
action: string,
currentPage: number,
pageCount: number
) {
switch (action) {
case "FirstPage":
return 1;
case "LastPage":
return pageCount;
case "NextPage":
case "PageDown":
return Math.min(pageCount, currentPage + 1);
case "PrevPage":
case "PageUp":
return Math.max(1, currentPage - 1);
default:
return undefined;
}
}
export function createPdfAnnotationLinkService(
document: Pick<
PDFDocumentProxy,
"getDestination" | "getPageIndex" | "numPages"
>,
currentPage: number,
callbacks: PdfAnnotationLinkCallbacks
): PdfAnnotationLinkService {
return {
externalLinkEnabled: true,
addLinkAttributes(link, url) {
const resolvedUrl =
decodeLocalDocumentLinkFromPdf(url) ?? url;
link.href = "#";
link.rel = "noopener noreferrer";
link.title ||= resolvedUrl;
link.dataset.pdfExternalHref = resolvedUrl;
link.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
callbacks.onExternalLink(resolvedUrl);
return false;
};
},
getDestinationHash() {
return "#pdf-destination";
},
getAnchorUrl() {
return "#pdf-action";
},
async goToDestination(destination) {
try {
const pageNumber = await resolvePdfDestinationPage(
document,
destination
);
if (pageNumber !== undefined) {
callbacks.onNavigateToPage(pageNumber);
}
} catch {
// 损坏或无法解析的 PDF 目标保持无行为。
}
},
executeNamedAction(action) {
const pageNumber = resolvePdfNamedActionPage(
action,
currentPage,
document.numPages
);
if (pageNumber !== undefined) {
callbacks.onNavigateToPage(pageNumber);
}
},
async executeSetOCGState() {
// 精确预览不改变 PDF 可选内容组状态。
},
async getAttachmentContent() {
// 精确预览不下载或执行 PDF 内嵌附件。
return undefined;
}
};
}
+3
View File
@@ -2,11 +2,13 @@ import type {
ExportConfig,
PagedDocumentPayload
} from "@md-to-pdf/core";
import type { MarkdownImageResource } from "./application-backend";
export interface PdfExportRequest {
markdown: string;
fileName: string;
language: string;
resources: MarkdownImageResource[];
exportConfig: ExportConfig;
}
@@ -28,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
request.markdown,
request.fileName,
request.language,
request.resources,
request.exportConfig
]);
}
+194
View File
@@ -0,0 +1,194 @@
const wideImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAABkAAAAOEAQMAAADDg2/hAAAABlBMVEVPRuUOpntYb4NEAAAB+klEQVR42u3bAQEAMAiAMPuXvjm4WwMCMAMAAAAAAAAAAAAAAHHvE0KECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEi5G4IAAAAAAAAAAAAAADUuRWECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiREg9BAAAAAAAAAAAAAAA6twKQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESKkHgIAAAAAAAAAAAAAAHVuBSFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRUg8BAAAAAAAAAAAAAIA6t4IQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECKmHAAAAAAAAAAAAAABAnVtBiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoTUQwAAAAAAAAAAAAAAoM6tIESIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFC6iEAAAAAAAAAAAAAABC3woXq+yBn0OwAAAAASUVORK5CYII=";
const portraitImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAAA4QAAAcIAQMAAABevZ3IAAAABlBMVEUPdm5e6tS6V5BUAAAB3ElEQVR42u3NMQ0AAAgDsPk3DQ52cpDWQBMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACo5prRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0fhoXJUddU5DUXZIAAAAASUVORK5CYII=";
const boundaryImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAABLAAAANwAQMAAAAFoAG2AAAABlBMVEXCQQz7vyQwzQKmAAACUElEQVR42u3aoREAQAgDQZr8JqnyDXhkxOJwqzNXb67nMv7CwsLCwsLCwsLCwsLCwsLCwsI6sbI4+2NhYWFhYWFhYWFhYWFhYWFhYd1Y1kAsLCwsLCwsLCwsLCwsLCwsLP2WkRILCwsLCwsLCwsLCwsLCwsLS79lpMTCwsLCwsLCwsLCwsLCwsLC0m9hYWFhYWFhYWFhYWFhYWFhYem3rIFYWFhYWFhYWFhYWFhYWFhYWPotIyUWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFpd+yBmJhYWFhYWFhYWFhYWFhYWFh6beMlFhYWFhYWFhYWFhYWFhYWFhY+i0sLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhaWfstIiYWFhYWFhYWFhYWFhYWFhYWl3zJSYmFhYWFhYWFhYWFhYWFhYWHpt7CwsLCwsLCwsLCwsLCwsLCw9FvWQCwsLCwsLCwsLCwsLCwsLCws/ZaREgsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFhaXfwsLCwsLCwsLCwsLCwsLCwsLSb1kDsbCwsLCwsLCwsLCwsLCwsLD0W0ZKLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLC0u/ZaTEwsLCwsLCwsLCwsLCwsLCwtJvGSmxsLCwsLCwsLCwsLCwsLCwsPRbWFhYWFhYWFiRrA8Rs81BfKlBmwAAAABJRU5ErkJggg==";
export const sampleMarkdown = `---
title: Markdown PDF 媒体分页示例
author: 内网文档团队
keywords:
- Markdown
- PDF
- 媒体分页
---
# Markdown PDF 媒体分页示例
这份文档集中展示常用排版、不同尺寸图片、Mermaid 与 ECharts 的渲染和分页效果。
## 基础排版
- [x] Markdown 实时预览
- [x] 图片、Mermaid 与 ECharts
- [x] 快速预览和精确 PDF 预览
- [ ] 根据实际文档继续扩展主题
| 能力 | 示例用途 |
| --- | --- |
| 表格与任务列表 | 验证主题基础样式 |
| 数学公式 | 验证字体和基线 |
| 媒体块 | 验证限宽、缩放与分页 |
行内公式:$E = mc^2$
> 预览与 PDF 共用相同的渲染结果、主题 CSS 和分页结构。
## 网络图片
下面只保留一张网络图片,用于验证远程资源获取与离线失败提示。
![NASA 地球网络图片](https://images-assets.nasa.gov/image/PIA18033/PIA18033~medium.jpg)
## Base64 图片块
### 横向图片:限宽基准
1600 × 900 的横图会先按正文宽度等比例收缩,收缩后的高度才是分页算法使用的基准高度。
![Base64 横向图片 1600×900][base64-wide]
### 边界图片:上一页空白回填
这一段用于形成稳定的分页边界。分页算法会保留标题、图注和间距的原始尺寸,只计算图片主体还能使用的高度。如果主体只需轻微缩小,并且缩放率仍在允许范围内,就尝试回填到上一页空白区域。
为了覆盖长文档场景,这里继续补充少量正文,使媒体块靠近默认 A4 页面底部。媒体元素必须按照源文档顺序串行处理,前一个媒体完成重排以后,后一个媒体才基于新的版面重新计算。
![Base64 边界图片 1200×880][base64-boundary]
### 纵向图片:单页高度适配
900 × 1800 的纵图在限宽后仍可能超过单页内容区,此时应继续等比例缩小并完整放在一页内。
![Base64 纵向图片 900×1800][base64-portrait]
## Mermaid 尺寸示例
### 横向流程图:限宽
\`\`\`mermaid
flowchart LR
A["Markdown"] --> B["统一 HTML"]
B --> C["主题 CSS"]
C --> D["网页预览"]
C --> E["PDF 导出"]
\`\`\`
### 纵向流程图:高度和分页
\`\`\`mermaid
flowchart TB
A["读取文档"] --> B["解析 Front Matter"]
B --> C["渲染 Markdown"]
C --> D["渲染 Mermaid"]
D --> E["渲染 ECharts"]
E --> F["等待图片与字体"]
F --> G["首次分页"]
G --> H["串行检查媒体块"]
H --> I["必要时重新分页"]
I --> J["生成 PDF"]
\`\`\`
## ECharts 尺寸示例
### 紧凑图表:40mm
\`\`\`echarts
caption: 紧凑柱状图(40mm
height: 40mm
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- type: bar
data: [28, 18, 15, 22]
\`\`\`
### 标准图表:58mm
\`\`\`echarts
caption: 产品能力树(58mm
height: 58mm
option:
series:
- type: tree
orient: LR
label:
position: left
data:
- name: 文档平台
children:
- name: Markdown
children:
- name: Mermaid
- name: ECharts
- name: PDF
children:
- name: 快速预览
- name: 精确预览
\`\`\`
大型图表的 caption 保持原尺寸,仅缩放图表主体。
### 大型图表:85mm
\`\`\`echarts
caption: 模块工作量占比(85mm
height: 85mm
option:
series:
- type: treemap
roam: false
nodeClick: false
breadcrumb:
show: false
label:
show: true
formatter: "{b}"
data:
- name: 渲染
children:
- { name: Markdown, value: 32 }
- { name: ECharts, value: 24 }
- { name: Mermaid, value: 18 }
- name: 导出
children:
- { name: PDF, value: 28 }
- { name: 主题, value: 12 }
\`\`\`
## 串行媒体块
下面连续放置图片、Mermaid 和 ECharts,用于观察长文档中多个媒体元素是否始终按源文档顺序计算。
![Base64 串行边界图片][base64-boundary]
\`\`\`mermaid
flowchart LR
A["图片完成"] --> B["Mermaid 计算"]
B --> C["ECharts 计算"]
\`\`\`
\`\`\`echarts
caption: 串行媒体收尾图(58mm
height: 58mm
option:
series:
- type: pie
radius: [35%, 70%]
data:
- { name: 图片, value: 36 }
- { name: Mermaid, value: 28 }
- { name: ECharts, value: 36 }
\`\`\`
> 所有媒体仅在额外缩放不超过阈值时回填;否则保持完整媒体块并移动到下一页。
[base64-wide]: data:image/png;base64,${wideImageBase64}
[base64-portrait]: data:image/png;base64,${portraitImageBase64}
[base64-boundary]: data:image/png;base64,${boundaryImageBase64}
`;
+89 -12
View File
@@ -24,8 +24,7 @@ textarea {
font: inherit;
}
button,
.file-button {
button {
min-height: 40px;
padding: 0 16px;
border: 1px solid #b8c2bd;
@@ -102,13 +101,31 @@ button:disabled {
border-left: 1px solid #d4dad7;
}
.document-summary > strong {
.document-name-row {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.document-name-row > strong {
overflow: hidden;
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.unsaved-badge {
flex: none;
padding: 1px 6px;
border: 1px solid #d9bd86;
border-radius: 999px;
background: #fff7e7;
color: #8a5a13;
font-size: 0.62rem;
font-weight: 800;
}
.document-summary .render-status {
max-width: 100%;
margin-top: 3px;
@@ -138,17 +155,37 @@ h1 {
gap: 10px;
}
.file-button {
display: inline-flex;
align-items: center;
.more-menu {
position: relative;
}
.file-button input {
.more-menu-popover {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
z-index: 30;
top: calc(100% + 8px);
right: 0;
display: flex;
min-width: 220px;
padding: 6px;
border: 1px solid #c8d1cc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
flex-direction: column;
}
.more-menu-popover button {
width: 100%;
min-height: 36px;
padding: 0 11px;
border: 0;
background: transparent;
text-align: left;
}
.more-menu-popover button:hover,
.more-menu-popover button:focus-visible {
background: #edf3f0;
}
.editor-layout {
@@ -546,7 +583,8 @@ textarea:focus {
}
.precise-pdf-page canvas,
.precise-pdf-page .textLayer {
.precise-pdf-page .textLayer,
.precise-pdf-page .annotationLayer {
position: absolute;
inset: 0;
}
@@ -580,6 +618,45 @@ textarea:focus {
--min-font-size-inv: calc(1 / var(--min-font-size));
}
.precise-pdf-page .annotationLayer {
z-index: 2;
overflow: hidden;
color-scheme: only light;
pointer-events: none;
transform-origin: 0 0;
}
.precise-pdf-page .annotationLayer section {
position: absolute;
box-sizing: border-box;
text-align: initial;
pointer-events: auto;
user-select: none;
transform-origin: 0 0;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a {
position: absolute;
inset: 0;
cursor: pointer;
outline: none;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:hover,
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:focus-visible {
background: rgb(31 111 83 / 14%);
outline: 1px solid rgb(31 111 83 / 45%);
}
.precise-pdf-page .textLayer :is(span, br) {
position: absolute;
color: transparent;
+10 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
import { renderMarkdownDocument } from "./application-backend";
import type { MarkdownImageResource } from "./application-backend";
export interface MarkdownRenderCallbacks {
onRenderStart?: () => void;
@@ -10,6 +11,7 @@ export interface MarkdownRenderCallbacks {
export function useMarkdownRender(
markdown: string,
language: string,
resources: MarkdownImageResource[],
callbacks: MarkdownRenderCallbacks = {}
) {
const [result, setResult] =
@@ -25,7 +27,7 @@ export function useMarkdownRender(
setError("");
void renderMarkdownDocument(
{ markdown, language },
{ markdown, language, resources },
controller.signal
)
.then((payload) => {
@@ -46,7 +48,13 @@ export function useMarkdownRender(
window.clearTimeout(timer);
controller.abort();
};
}, [language, markdown, onRenderStart, onStatusChange]);
}, [
language,
markdown,
onRenderStart,
onStatusChange,
resources
]);
return { error, result };
}
+8 -4
View File
@@ -9,7 +9,11 @@ export type { ThemeSummary } from "./application-backend";
export function useThemeResources(
themeId: string,
onThemesLoaded: (themes: ThemeSummary[]) => void
onThemesLoaded: (
themes: ThemeSummary[],
refreshKey: number
) => void,
refreshKey = 0
) {
const [themes, setThemes] = useState<ThemeSummary[]>([]);
const [themeCss, setThemeCss] = useState("");
@@ -23,7 +27,7 @@ export function useThemeResources(
.then(({ themes: availableThemes }) => {
setThemes(availableThemes);
setCatalogError("");
onThemesLoaded(availableThemes);
onThemesLoaded(availableThemes, refreshKey);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
@@ -34,7 +38,7 @@ export function useThemeResources(
});
return () => controller.abort();
}, [onThemesLoaded]);
}, [onThemesLoaded, refreshKey]);
useEffect(() => {
const controller = new AbortController();
@@ -52,7 +56,7 @@ export function useThemeResources(
});
return () => controller.abort();
}, [themeId]);
}, [refreshKey, themeId]);
return {
error: catalogError || cssError,
+31
View File
@@ -17,9 +17,40 @@ declare global {
}
interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
consumePendingMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
discardPendingMarkdown(): Promise<void>;
onMarkdownOpened(
listener: (reason: "open" | "reload" | "replace") => void
): () => void;
startNewMarkdown(): Promise<void>;
setDocumentDirty(dirty: boolean): Promise<void>;
openDocumentLink(href: string): Promise<void>;
saveMarkdown(
fileName: string,
markdown: string
): Promise<{ fileName: string } | undefined>;
openThemeDirectory(): Promise<string>;
refreshThemes(): Promise<void>;
renderMarkdown(input: {
markdown: string;
language: string;
resources?: Array<{
path: string;
data: string;
}>;
}): Promise<RenderedMarkdownDocument>;
listThemes(): Promise<{
themes: Array<{
+2 -2
View File
@@ -6,7 +6,7 @@ import {
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.4.0");
expect(APP_VERSION_LABEL).toBe("v0.4.0");
expect(APP_VERSION).toBe("0.5.0");
expect(APP_VERSION_LABEL).toBe("v0.5.0");
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
createMarkdownFileName,
deriveMarkdownBaseName,
ensureMarkdownFileName,
legalizeMarkdownBaseName
} from "../src/document-file";
describe("Markdown 默认文件名", () => {
it("优先使用 Front Matter 与代码围栏之外的一级标题", () => {
expect(
deriveMarkdownBaseName(`---
title: 元数据标题
---
第一行正文
\`\`\`markdown
# 围栏标题
\`\`\`
# 2026 年研发周报:第 31 周`)
).toBe("2026 年研发周报 第 31 周");
});
it("没有一级标题时使用第一行非空正文", () => {
expect(
createMarkdownFileName(
"\n\n> **本周完成** [API 重构](https://example.com)\n后续内容"
)
).toBe("本周完成 API 重构.md");
});
it("四字符围栏不会被较短的同类围栏提前关闭", () => {
expect(
deriveMarkdownBaseName(`\`\`\`\`markdown
# 围栏内标题
\`\`\`
# 仍在围栏内
\`\`\`\`
# 正文标题`)
).toBe("正文标题");
});
it("清理非法字符并规避 Windows 保留名称", () => {
expect(legalizeMarkdownBaseName('季度总结:<测试> / "版本"')).toBe(
"季度总结 测试 版本"
);
expect(legalizeMarkdownBaseName("CON")).toBe("文档 CON");
});
it("空文档使用回退文件名并限制名称长度", () => {
expect(createMarkdownFileName(" \n")).toBe("未命名文档.md");
expect(legalizeMarkdownBaseName("文".repeat(150))).toHaveLength(100);
});
it("保留已有 Markdown 扩展名", () => {
expect(ensureMarkdownFileName("报告.markdown", "# 报告")).toBe(
"报告.markdown"
);
expect(ensureMarkdownFileName("报告", "# 报告")).toBe("报告.md");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolveWebDocumentLinkAction } from "../src/document-link";
describe("Web 文档链接行为", () => {
it("保留文档锚点", () => {
expect(resolveWebDocumentLinkAction("#section")).toEqual({
type: "anchor",
href: "#section"
});
});
it("在外部窗口打开 HTTP 链接和浏览器协议", () => {
expect(
resolveWebDocumentLinkAction("//example.com/path")
).toEqual({
type: "open",
href: "https://example.com/path"
});
expect(
resolveWebDocumentLinkAction("mailto:a@example.com")
).toEqual({
type: "open",
href: "mailto:a@example.com"
});
expect(resolveWebDocumentLinkAction("tel:+8612345")).toEqual({
type: "open",
href: "tel:+8612345"
});
});
it("忽略本地、未知和危险地址", () => {
for (const href of [
"../docs/a.md",
"C:/docs/a.md",
"file:///C:/docs/a.md",
"obsidian://open?vault=x",
"javascript:alert(1)"
]) {
expect(resolveWebDocumentLinkAction(href)).toEqual({
type: "ignore"
});
}
});
});
+118
View File
@@ -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();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { inflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { sampleMarkdown } from "../src/sample-markdown";
function readPngDimensions(base64: string) {
const png = Buffer.from(base64, "base64");
expect(png.subarray(0, 8)).toEqual(
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
);
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
const bitDepth = png[24] ?? 0;
const colorType = png[25] ?? 0;
const samplesPerPixel =
colorType === 0 || colorType === 3
? 1
: colorType === 2
? 3
: colorType === 4
? 2
: 4;
const idatParts: Buffer[] = [];
let offset = 8;
while (offset + 12 <= png.length) {
const length = png.readUInt32BE(offset);
const type = png.toString("ascii", offset + 4, offset + 8);
if (type === "IDAT") {
idatParts.push(
png.subarray(offset + 8, offset + 8 + length)
);
}
offset += 12 + length;
}
const decoded = inflateSync(Buffer.concat(idatParts));
const rowBytes = Math.ceil(
(width * bitDepth * samplesPerPixel) / 8
);
expect(decoded.length).toBe((rowBytes + 1) * height);
return { width, height };
}
describe("默认示例 Markdown", () => {
it("仅保留一张网络图片并提供三种 Base64 图片尺寸", () => {
expect(
sampleMarkdown.match(/https?:\/\/[^\s)]+/gu)
).toHaveLength(1);
expect(
sampleMarkdown.match(/data:image\/png;base64,/gu)
).toHaveLength(3);
expect(sampleMarkdown).toContain("1600×900");
expect(sampleMarkdown).toContain("900×1800");
expect(sampleMarkdown).toContain("1200×880");
});
it("所有内嵌 PNG 均可完整解压且尺寸符合描述", () => {
const images = Array.from(
sampleMarkdown.matchAll(
/data:image\/png;base64,([A-Za-z0-9+/=]+)/gu
),
(match) => readPngDimensions(match[1] ?? "")
);
expect(images).toEqual([
{ width: 1600, height: 900 },
{ width: 900, height: 1800 },
{ width: 1200, height: 880 }
]);
});
it("覆盖 Mermaid、ECharts 分档和串行媒体场景", () => {
expect(
sampleMarkdown.match(/```mermaid/gu)
).toHaveLength(3);
expect(
sampleMarkdown.match(/```echarts/gu)
).toHaveLength(4);
expect(sampleMarkdown).toContain("height: 40mm");
expect(sampleMarkdown).toContain("height: 58mm");
expect(sampleMarkdown).toContain("height: 85mm");
expect(sampleMarkdown).toContain("## 串行媒体块");
});
});
+5
View File
@@ -13,6 +13,7 @@ COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/preview-engine/package.json packages/preview-engine/package.json
COPY packages/renderer/package.json packages/renderer/package.json
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/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 \
&& 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/application/package.json packages/application/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
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/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/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 --from=production-dependencies /app/node_modules ./node_modules
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/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/preview-engine/dist ./packages/preview-engine/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser themes ./themes
COPY deploy/nginx.conf /etc/nginx/nginx.conf
+7 -7
View File
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.0'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0'
docker compose -f deploy\compose.yaml build
```
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.0'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.0'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build
```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.0 应用代码和生产
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.0 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
@@ -69,9 +69,9 @@ docker compose -f deploy\compose.yaml up -d
目录下每套主题都应包含 `theme.json`。主题规范详见
[`docs/THEMES.md`](../docs/THEMES.md)。
从 v0.4.0 起,公司内部私有版本已内置 GitHub、Pixyll 和 Whitey 三套
Typora 默认主题;挂载目录仍可用于本机扩展主题。内置主题与挂载主题 ID
重复时以内置版本为准。
当前内置 Typora Github、Typora Pixyll、Typora whitey
Typora Clean 四套主题;挂载目录只用于额外的本机扩展主题,无需重复
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
## 运行参数
+296 -33
View File
@@ -1,6 +1,6 @@
# Markdown PDF 导出器进度
最后更新:2026-07-27
最后更新:2026-07-28
## 1. 当前概况
@@ -31,11 +31,29 @@ fa07472 feat: 实现网页实时预览
ec48bce chore: 初始化项目骨架
```
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
`v0.4.0` 桌面端阶段已经完成:共享应用服务、无 Server 的 Electron
运行时、三套内置主题、统一品牌资源、Windows 安装包以及 Docker Compose
发布候选均已通过验证。
`v0.4.1` 图片资源阶段已完成Web 素材目录、Desktop 同目录资源、
受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。
`v0.4.2` 为仅面向桌面端的维护版本:移除 Windows 原生应用菜单栏;
Web/Compose 发布仍保持 `v0.4.1`
`v0.4.3` 将桌面发行包迁移为标准 NSIS 安装向导,并使用全英文、无空格、
包含版本与平台的安装包文件名;Web/Compose 发布仍保持 `v0.4.1`
`v0.4.4` 仅精简 Electron 桌面发行包的语言资源,保留简体中文和英文;
Web/Compose 发布仍保持 `v0.4.1`
`v0.4.5` 同步更新 Web 与 Desktop:新增新建/保存 Markdown、桌面文件
关联、窗口状态恢复、自定义主题目录和收纳式“更多”菜单;Web 明确取消
本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序
串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。
`v0.5.0` 完成渲染架构和桌面文档工作流升级:分页与连续预览抽取为共享
`packages/preview-engine`,快速预览支持从修改位置复用稳定前缀;Web
增加连续预览和受控外链,Desktop 支持多窗口、同文件单例、本地链接及
聚焦时外部文件变化提示。四套主题统一命名,并修复围栏代码块的 Typora
DOM 兼容和桌面发行包复用陈旧 Web 产物的问题。
## 2. 已完成
@@ -376,9 +394,122 @@ ec48bce chore: 初始化项目骨架
- Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过
构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。
### 3.14 v0.4.1 图片资源与标题分页
- `packages/application` 统一提取 Markdown 图片引用,将验证后的本地或
远程图片转换为 Data URL,再交给 Web 预览、Playwright PDF 和 Electron
PDF 共用的 HTML。
- Web 增加素材目录选择,支持把 Markdown 的关联图片作为受限 Base64
资源随渲染和 PDF 请求提交;请求体上限同步提高到 24 MiB。
- Desktop 增加原生“打开 Markdown”,主进程记录文档目录并直接读取相对
图片,不启动 Server,也不允许读取文档目录之外的文件。
- 相对路径支持中文、空格和百分号编码;拒绝绝对路径、`..` 穿越和符号
链接越界。
- 远程图片支持 HTTP/HTTPS、逐跳重定向校验、10 秒超时、格式嗅探与
5 分钟缓存;阻止 localhost、私网和链路本地目标,并兼容企业代理常用
的域名 Fake-IP 网段。
- 每篇文档最多 50 个图片引用,单图不超过 8 MiB,总图片不超过 15 MiB
支持 PNG、JPEG、GIF、WebP、AVIF 和受限 SVG。
- 独立图片使用 `figure + img + figcaption``[]` 中的文本显示为图片
标题;行内混排图片继续保持普通段落语义。
- 图片与标题整块分页。超高图先扣除标题区实际高度,再等比例缩放到单页,
与 Mermaid 和 ECharts 遵循相同的不可跨页规则。
- 默认示例增加 NASA 中等尺寸图与 Unsplash 超高竖图,直接覆盖正常图片
和超高图片分页场景。
### 3.15 v0.4.2 桌面窗口精简
- Electron 主进程不再创建默认应用菜单,移除窗口顶部的
`File / Edit / View / Window` 原生菜单栏。
- 应用窗口同时启用菜单栏自动隐藏,避免 Windows 恢复默认菜单。
- 该维护版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 镜像继续
使用已验证的 `v0.4.1`
### 3.16 v0.4.3 标准 Windows 安装向导
- 桌面发行链路由 Electron Forge Squirrel 迁移到 electron-builder
26.15.3 的 NSIS assisted installer。
- 安装向导提供当前用户/所有用户安装选项、安装目录选择、安装进度和完成
页面;主程序继续使用 `md-to-pdf.exe`
- 正式安装包固定命名为
`md-to-pdf-0.4.3-x86_64-Setup.exe`,免安装包命名为
`md-to-pdf-0.4.3-x86_64.zip`
- 项目自有 `build/installer.nsh` 显式创建桌面和开始菜单快捷方式,
安装完成后直接启动主程序,不依赖 `.lnk`
- 卸载时显式清理桌面快捷方式、开始菜单快捷方式及其目录。
- 安装包和校验文件作为 Gitea `v0.4.3` Release 附件发布,不写入 Git
历史。
### 3.17 v0.4.4 桌面语言包精简
- Electron 发行包只保留 `zh-CN``en-US` 两种 Chromium 语言资源。
- 不裁剪 ICU、ANGLE、Direct3D、Dawn、SwiftShader 或其他 Chromium
运行时组件,保证桌面预览与 PDF 导出在不同图形环境下的可靠性。
- 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持
`v0.4.1`
### 3.18 v0.4.5 文档工作流与媒体分页
- Web 与 Desktop 均支持新建 Markdown;未保存时使用“未命名文档”状态,
不显示伪造源文件名,并允许直接导出 PDF。
- Web 保存使用浏览器下载;Desktop 使用原生另存为。默认文件名优先取
第一个一级标题,没有一级标题时取首行可见文本,并清理 Windows 非法
字符、保留名和末尾空格或句点。
- “打开 Markdown”继续作为顶部独立强操作;新建、保存及桌面自定义主题
目录收纳到“更多”菜单,Web 不再显示本地素材目录入口。
- Windows 安装包注册 `.md``.markdown` 文件关联;系统打开文件会
转交给现有单实例窗口并自动加载。
- Desktop 监听窗口位置、尺寸和最大化变化,延迟写入完整状态;退出前
刷新待写状态。下次启动按当前显示器工作区限制尺寸并修正坐标,避免
多显示器断开后窗口落在屏幕外。
- Desktop 会建立自定义主题目录,“更多”菜单可直接在资源管理器打开;
目录允许留空,放入合法主题后由共享主题注册器读取。
- 图片、Mermaid 和 ECharts 统一视为带标题的媒体块。分页按 DOM 文档
顺序串行处理,每个媒体元素只允许一次回填或缩放重排。
- 图片基准高度按原尺寸先收缩到内容限宽后计算;媒体块只有在上一页空白
与所需空间比例达到阈值时才尝试回填。缩放只作用于图片或图表内容,
标题保持原字号和自然高度。
- ECharts 在分页前冻结为 SVG 图片,三类媒体共享回填决策与边界检查;
无法安全回填时仍移至下一页,保证顺序、不跨页且不覆盖后续内容。
- 默认示例仅保留一张网络图片,并新增有效 Base64 横图、不同尺寸图片、
Mermaid 和 ECharts,用于观察媒体分页、标题配对和渲染效果。
- 根 README、Desktop README、四个工作区包 README、进度文档和发布
规范随版本同步更新。
### 3.19 v0.5.0 共享预览引擎与桌面多窗口
- 新增 `packages/preview-engine`,从 `apps/web` 抽取 Paged.js 运行时、
Mermaid、ECharts、图片尺寸适配、媒体回填、跨页表格和共享页面 CSS。
- Web 快速预览、服务端 Playwright PDF 与 Electron PDF 直接使用同一个
Preview EngineMarkdown 解析仍由 `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. 已执行验证
2026-07-27 在当前完整工作区成功执行:
2026-07-28 在当前完整工作区成功执行:
```text
npm test
@@ -390,17 +521,73 @@ git diff --check
结果:
- ECharts 围栏测试:36 项通过;
- 共享配置测试:9 项通过;
- 渲染器测试:10 项通过;
- 应用服务测试:5 项通过;
- 前端测试:67 项通过;
- Core 共享协议测试:15 项通过;
- 渲染器测试:14 项通过;
- 应用服务测试:11 项通过;
- Preview Engine 测试:53 项通过;
- 前端测试:54 项通过;
- 后端测试:29 项通过;
- 桌面端测试:7 项通过;
- 全项目共 163 项测试通过;
- 桌面端测试:26 项通过;
- 全项目共 238 项测试通过;
- 全项目类型检查通过;
- 生产构建通过;
- `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` 双端与媒体分页验收结果:
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
`sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`
Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`
- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF
Mermaid 与 ECharts 错误数均为 0。
- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只
出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。
- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、
928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。
- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过;
`output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。
- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题
目录能够打开且可识别已有主题。
- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸
测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。
- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`
94,998,489 字节,SHA-256 为
`9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`
- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`132,477,245 字节,
SHA-256 为
`97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`
- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN``en-US` 语言包,
Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。
- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、
桌面与开始菜单快捷方式、`.md``.markdown` 打开注册均验证通过,
安装版应用启动正常。
Electron 桌面端第一阶段验证结果:
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
@@ -546,6 +733,74 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB,
当前 Authenticode 状态为未签名。
2026-07-27 已完成 `v0.4.1` 图片资源发布候选验证:
- 用户提供的 `tmp/数据工程规划.md` 成功解析 2 张本地 PNG,生成约
5.0 MB 内嵌 HTML,图片自然宽度均为 8192px,零资源告警。
- Web 真实选择 Markdown 与 `.assets` 目录后生成 4 页快速预览,两张
本地图片均完整位于各自页面内容区。
- Wikimedia SVG、NASA JPEG 和 Unsplash JPEG 三种公网图片均完成下载、
格式识别、Data URL 内嵌和自然尺寸解码。
- 1200×1800 超高网络图在扣除标题高度后缩放;最终图片约 977px 高,
图加标题约 1001px 高,完整位于同一页且未复制、裁切或跨页。
- 默认示例的快速预览和精确预览均为 7 页,两个图片标题可见;容器
Playwright PDF 的精确预览同样为 7 页。
- Electron 目录版显示 `v0.4.1`,使用 Electron 内置 Chromium 成功渲染
两张默认网络图片;Windows 主程序仍为 `md-to-pdf.exe`
- 最终 Squirrel 安装包为
`Markdown PDF 导出器-0.4.1 Setup.exe`144,166,400 字节,SHA-256
`FC6687494255EE21224B65C1CA2D520316D568CCE8EB99A608A0DEF160611F4E`
Authenticode 状态为未签名。
- 本地镜像 `yixiong/md-to-pdf:v0.4.1` 内容 ID 为
`sha256:1ded8a8a11ecb8b69eaa116bb9129dd4ec039e9918b047169fe5f8add2af3a80`
Compose 容器健康运行在 `http://localhost:8080`
2026-07-27 已完成 `v0.4.2` 桌面维护版本验证:
- 仅重新构建桌面端及其内嵌 Web 静态资源,没有重新构建或替换
Web/Compose 发布镜像。
- Windows 目录版视觉检查通过:默认原生菜单栏已经移除,应用内容直接
位于系统标题栏下方,版本徽标显示 `v0.4.2`
- Windows 主程序继续使用英文文件名 `md-to-pdf.exe`
- Squirrel 安装包为 `Markdown PDF 导出器-0.4.2 Setup.exe`
144,166,400 字节,SHA-256 为
`59DA57522C99EB6B16A9591AFDEC349B785BF9B76950504B314273520152D41B`
Authenticode 状态为未签名。
2026-07-27 已完成 `v0.4.3` 标准安装向导验证:
- NSIS 中文辅助安装向导正确显示安装范围和安装目录选择,版本显示为
`0.4.3`
- 当前用户安装成功,安装目录为
`%LOCALAPPDATA%\Programs\md-to-pdf`,卸载项版本为 `0.4.3`
- 桌面和开始菜单快捷方式均存在,实际目标均为安装目录中的
`md-to-pdf.exe`;从开始菜单快捷方式启动成功。
- 静默卸载返回 0,安装目录、桌面快捷方式和开始菜单目录全部清理;
随后重新安装成功,最终本机保持已安装状态。
- 正式安装包为 `md-to-pdf-0.4.3-x86_64-Setup.exe`
103,346,230 字节,SHA-256 为
`C027B4AC604889C7E7E00BA3CE08A12616312BC9250268775B1B7B578A7C4817`
Authenticode 状态为未签名。
- 免安装包为 `md-to-pdf-0.4.3-x86_64.zip`143,784,617 字节,
SHA-256 为
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`
2026-07-27 已完成 `v0.4.4` 桌面语言包精简验证:
- 目录版只包含 `zh-CN.pak``en-US.pak`,语言资源从 55 个、
48,911,653 字节降为 2 个、1,137,843 字节。
- 目录版总大小从 378,084,454 字节降为 330,310,644 字节,减少
47,773,810 字节;Chromium 图形与软件渲染后备组件均保持完整。
- Windows 主程序版本为 `0.4.4`,目录版成功启动并保持应用、GPU、
渲染器等 4 个 Electron 进程正常运行。
- 正式安装包为 `md-to-pdf-0.4.4-x86_64-Setup.exe`94,991,759 字节,
SHA-256 为
`0827FD87A8EEC8B034DF738909CB6A20231245B507AE4A0BAA579AFA03A942DE`
Authenticode 状态为未签名。
- 免安装包为 `md-to-pdf-0.4.4-x86_64.zip`132,470,241 字节,
SHA-256 为
`D6CDB8DAE30676BAB6B2A037567164476AD294588B102A6FBBE9458ABCE5CCC0`
## 5. 当前注意事项
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
@@ -553,13 +808,18 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
渲染错误状态。
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
- `apps/web/src/mermaid-page-fit.ts` 负责超高 Mermaid 的单页等比例适配
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理
- `packages/preview-engine/src/paged-document-runtime.ts` 是连续预览、
快速预览和 PDF 共用的媒体渲染、资源等待和 Paged.js 运行时
- `packages/preview-engine/src/continuous-preview.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/document-link.ts``pdf-annotation-links.ts` 负责 Web
链接动作和精确 PDF 注释层适配。
- `apps/web/src/PreviewPageControl.tsx``preview-page.ts` 负责当前页输入、
页码边界、滚动页识别和快速跳转。
- `apps/web/src/preview-zoom.ts``PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
@@ -574,35 +834,38 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
`apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配。
- `apps/desktop/src/main.ts` 负责 Electron 生命周期、自定义应用协议、
应用窗口、IPC 校验和原生 PDF 保存。
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
组装、协议和生命周期。
- `apps/desktop/src/desktop-document-link.ts` 负责本地链接解析和
Markdown 目标识别。
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
调度、网络限制、打印和窗口复用。
- `apps/desktop/src/app-preload.ts``pdf-preload.ts` 分别定义应用
窗口和 PDF 运行窗口的最小 IPC 能力。
- `.local/themes/typora-*` 仍可作为用户本机副本;v0.4.0 的三套发布版本
已复制到 `themes/` 并标记为内置主题
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
- `.local/themes/` 只用于外挂自定义主题;四套发布主题已经内置,无需
重复挂载。历史副本保存在 `.local/theme-backups`,均被 Git 忽略
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
未来改变分发范围前必须重新完成许可证审查。
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
目录读取;远程图片仅保留有界内存缓存。
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
导出,不依赖 Fastify 或本地 HTTP Server。
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
当前 Compose 容器保持运行。
- `v0.5.0` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
Compose 健康检查,当前 Compose 容器保持运行。
- Desktop `package``make` 必须经过 `build:embedded-web`;禁止直接
调用 electron-builder 复制未经本轮编译的 `apps/web/dist`
## 6. 推荐接手顺序
### 阶段一:桌面文档工作区与资源安全
### 阶段一:v0.5.x 稳定性与性能观测
- Markdown 文件夹或 ZIP
- 相对图片解析
- 每请求独立临时目录
- 路径穿越和符号链接越界防护;
- 文件数量、单文件大小、总大小和超时限制;
- 请求结束可靠清理。
- 为增量分页补充超长文档性能基线,记录稳定前缀页数和后缀重算耗时
- 覆盖 Windows 100%、125% 和 150% 显示缩放的快速/精确分页差异
- 扩展 Desktop 多窗口、文件外部变化和链接打开的真实端到端回归
- 持续检查连续预览中媒体宽高、单滚动容器和资源释放。
### 阶段二:导出配置扩展
+1353 -4038
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"description": "可配置、可主题化的 Markdown PDF 导出工具",
"workspaces": [
@@ -8,20 +8,22 @@
"packages/*"
],
"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",
"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": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
"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:core": "npm run dev -w @md-to-pdf/core",
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
"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:web": "npm run dev -w @md-to-pdf/web",
"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:package": "npm run build -w @md-to-pdf/web && npm run package -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 && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
"desktop:package": "npm run package -w @md-to-pdf/desktop",
"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",
"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"
"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/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": {
"node": ">=22"
+51
View File
@@ -0,0 +1,51 @@
# @md-to-pdf/application
Web Server 与 Electron Desktop 共用的应用服务层,组合
`@md-to-pdf/core``@md-to-pdf/renderer`,统一处理 Markdown 渲染、
主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和
平台能力适配。
## 目录结构
```text
src/
application-service.ts 渲染、主题和资源用例入口
image-resources.ts 本地、Base64 与受限远程图片处理
theme-registry.ts 内置及自定义主题扫描、校验与缓存
index.ts 公共导出入口
tests/
application-service.test.ts
bundled-themes.test.ts
image-resources.test.ts
```
## 使用
```ts
import { createApplicationService } from "@md-to-pdf/application";
const service = createApplicationService({
bundledRoot,
localRoot
});
const themes = await service.listThemes();
const document = await service.render(request);
```
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录,
Desktop 才会以 Markdown 所在目录为边界解析相对资源。
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
## 开发与验证
```powershell
npm run dev -w @md-to-pdf/application
npm run test -w @md-to-pdf/application
npm run typecheck -w @md-to-pdf/application
npm run build -w @md-to-pdf/application
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@md-to-pdf/application",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -6,12 +6,18 @@ import {
createThemeRegistry,
type ThemeRegistryOptions
} from "./theme-registry.js";
import {
createImageResourceResolver,
type ImageResolutionContext,
type ImageResourceResolverOptions
} from "./image-resources.js";
export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000;
export interface MarkdownRenderRequest {
markdown?: unknown;
language?: unknown;
resources?: unknown;
}
export class ApplicationRequestError extends Error {
@@ -25,15 +31,21 @@ export class ApplicationRequestError extends Error {
}
}
export interface ApplicationServiceOptions extends ThemeRegistryOptions {}
export interface ApplicationServiceOptions
extends ThemeRegistryOptions,
ImageResourceResolverOptions {}
export function createApplicationService(
options: ApplicationServiceOptions
) {
const themes = createThemeRegistry(options);
const resolveImages = createImageResourceResolver(options);
function render(request: MarkdownRenderRequest) {
const { markdown, language } = request;
async function render(
request: MarkdownRenderRequest,
context: ImageResolutionContext = {}
) {
const { markdown, language, resources } = request;
if (typeof markdown !== "string") {
throw new ApplicationRequestError(
400,
@@ -57,8 +69,11 @@ export function createApplicationService(
}
try {
const images = await resolveImages(markdown, resources, context);
return renderMarkdown(markdown, {
...(language ? { language } : {})
...(language ? { language } : {}),
imageSourceMap: images.sources,
warnings: images.warnings
});
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
@@ -68,6 +83,16 @@ export function createApplicationService(
error.message
);
}
if (error instanceof Error) {
const tooLarge = /|/u.test(error.message);
throw new ApplicationRequestError(
tooLarge ? 413 : 400,
tooLarge
? "IMAGE_RESOURCES_TOO_LARGE"
: "INVALID_IMAGE_RESOURCES",
error.message
);
}
throw error;
}
}
+496
View File
@@ -0,0 +1,496 @@
import { lookup } from "node:dns/promises";
import { readFile, realpath, stat } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
export const MAXIMUM_IMAGE_COUNT = 50;
export const MAXIMUM_IMAGE_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
export const REMOTE_IMAGE_TIMEOUT_MS = 10_000;
export const MAXIMUM_REMOTE_REDIRECTS = 3;
const failedImageDataUrl = `data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="120" viewBox="0 0 640 120"><rect width="640" height="120" fill="#f8fafc"/><rect x="1" y="1" width="638" height="118" fill="none" stroke="#cbd5e1"/><text x="320" y="67" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#64748b">图片加载失败</text></svg>`
).toString("base64")}`;
export interface MarkdownImageResource {
path: string;
contentType?: string;
data: string;
}
export interface ImageResolutionContext {
localRoot?: string;
allowUnrestrictedLocalFiles?: boolean;
}
export interface ResolvedMarkdownImages {
sources: ReadonlyMap<string, string>;
warnings: string[];
}
export interface ImageResourceResolverOptions {
remoteLoader?: (url: string) => Promise<LoadedImage>;
}
export interface LoadedImage {
content: Buffer;
contentType: string;
}
function isPrivateIpv4(
address: string,
allowProxyBenchmarkRange = false
) {
const parts = address.split(".").map(Number);
const [first, second] = parts;
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ||
first === undefined ||
second === undefined
) {
return true;
}
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(!allowProxyBenchmarkRange &&
first === 198 &&
(second === 18 || second === 19)) ||
first >= 224
);
}
function isPrivateIpAddress(
address: string,
allowProxyBenchmarkRange = false
) {
const normalized = address.toLowerCase();
if (isIP(normalized) === 4) {
return isPrivateIpv4(normalized, allowProxyBenchmarkRange);
}
if (isIP(normalized) !== 6) {
return true;
}
if (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
/^fe[89ab]/u.test(normalized)
) {
return true;
}
const mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1];
return mapped
? isPrivateIpv4(mapped, allowProxyBenchmarkRange)
: false;
}
async function assertPublicRemoteUrl(url: URL) {
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("仅支持 HTTP/HTTPS 图片");
}
if (
url.username ||
url.password ||
url.hostname.toLowerCase() === "localhost"
) {
throw new Error("图片地址包含不允许的认证或本地主机");
}
if (isIP(url.hostname)) {
if (isPrivateIpAddress(url.hostname)) {
throw new Error("图片地址指向非公网 IP");
}
return;
}
const addresses = await lookup(url.hostname, {
all: true,
verbatim: true
});
if (
addresses.length === 0 ||
addresses.some(({ address }) => isPrivateIpAddress(address, true))
) {
throw new Error("图片域名解析到非公网 IP");
}
}
function detectImageContentType(content: Buffer) {
if (
content.length >= 8 &&
content.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return "image/png";
}
if (
content.length >= 3 &&
content[0] === 0xff &&
content[1] === 0xd8 &&
content[2] === 0xff
) {
return "image/jpeg";
}
const prefix = content.subarray(0, 12).toString("ascii");
if (prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a")) {
return "image/gif";
}
if (prefix.startsWith("RIFF") && prefix.endsWith("WEBP")) {
return "image/webp";
}
if (
content.length >= 12 &&
content.subarray(4, 12).toString("ascii").startsWith("ftyp") &&
/(?:avif|avis)/u.test(content.subarray(8, 32).toString("ascii"))
) {
return "image/avif";
}
const textPrefix = content.subarray(0, Math.min(content.length, 4096))
.toString("utf8")
.replace(/^\uFEFF/u, "")
.trimStart();
if (
/^(?:<\?xml[\s\S]*?\?>\s*)?<svg[\s>]/iu.test(textPrefix)
) {
const svg = content.toString("utf8");
if (
/<script\b|<foreignObject\b|\son\w+\s*=|(?:href|src)\s*=\s*["'](?:https?:|\/\/)/iu.test(
svg
)
) {
throw new Error("SVG 包含不安全的脚本或外部资源");
}
return "image/svg+xml";
}
throw new Error("资源不是受支持的图片格式");
}
function imageToDataUrl(image: LoadedImage) {
return `data:${image.contentType};base64,${image.content.toString("base64")}`;
}
async function readResponseBody(response: Response) {
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAXIMUM_IMAGE_BYTES
) {
throw new Error("远程图片超过单文件大小限制");
}
if (!response.body) {
throw new Error("远程图片响应为空");
}
const chunks: Buffer[] = [];
let total = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > MAXIMUM_IMAGE_BYTES) {
await reader.cancel();
throw new Error("远程图片超过单文件大小限制");
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
export async function downloadRemoteImage(
source: string,
fetcher: typeof fetch = fetch
): Promise<LoadedImage> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REMOTE_IMAGE_TIMEOUT_MS
);
try {
let current = new URL(source);
for (
let redirectCount = 0;
redirectCount <= MAXIMUM_REMOTE_REDIRECTS;
redirectCount += 1
) {
await assertPublicRemoteUrl(current);
const response = await fetcher(current, {
redirect: "manual",
signal: controller.signal,
headers: {
accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
"user-agent": "md-to-pdf/0.4.1"
}
});
if (
response.status >= 300 &&
response.status < 400 &&
response.headers.has("location")
) {
if (redirectCount === MAXIMUM_REMOTE_REDIRECTS) {
throw new Error("远程图片重定向次数过多");
}
current = new URL(response.headers.get("location")!, current);
continue;
}
if (!response.ok) {
throw new Error(`远程图片返回 HTTP ${response.status}`);
}
const content = await readResponseBody(response);
return {
content,
contentType: detectImageContentType(content)
};
}
throw new Error("远程图片重定向次数过多");
} finally {
clearTimeout(timeout);
}
}
export function normalizeDocumentAssetPath(source: string) {
const pathOnly = source.split(/[?#]/u, 1)[0] ?? "";
let decoded: string;
try {
decoded = decodeURIComponent(pathOnly);
} catch {
throw new Error("图片路径 URL 编码无效");
}
const normalizedSeparators = decoded.replace(/\\/gu, "/");
if (
normalizedSeparators.startsWith("/") ||
/^[a-z]:\//iu.test(normalizedSeparators) ||
normalizedSeparators.includes("\0")
) {
throw new Error("不允许绝对图片路径");
}
const segments = normalizedSeparators
.split("/")
.filter((segment) => segment && segment !== ".");
if (segments.length === 0 || segments.includes("..")) {
throw new Error("图片路径为空或越过文档目录");
}
return segments.join("/");
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function loadLocalImage(root: string, source: string) {
const normalized = normalizeDocumentAssetPath(source);
const realRoot = await realpath(root);
const target = path.resolve(realRoot, ...normalized.split("/"));
const realTarget = await realpath(target);
if (!isContainedPath(realRoot, realTarget)) {
throw new Error("图片路径越过文档目录");
}
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 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) {
if (value === undefined) {
return new Map<string, LoadedImage>();
}
if (!Array.isArray(value)) {
throw new Error("图片素材必须是数组");
}
if (value.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const result = new Map<string, LoadedImage>();
let totalBytes = 0;
for (const item of value) {
if (
typeof item !== "object" ||
item === null ||
!("path" in item) ||
!("data" in item) ||
typeof item.path !== "string" ||
typeof item.data !== "string" ||
item.data.length > Math.ceil(MAXIMUM_IMAGE_BYTES * 4 / 3) + 16
) {
throw new Error("图片素材参数无效");
}
const normalized = normalizeDocumentAssetPath(item.path);
if (result.has(normalized)) {
throw new Error(`图片素材路径重复:${normalized}`);
}
const content = Buffer.from(item.data, "base64");
if (content.length === 0 || content.length > MAXIMUM_IMAGE_BYTES) {
throw new Error(`图片素材大小无效:${normalized}`);
}
totalBytes += content.length;
if (totalBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("图片素材总大小超过限制");
}
result.set(normalized, {
content,
contentType: detectImageContentType(content)
});
}
return result;
}
export function createImageResourceResolver(
options: ImageResourceResolverOptions = {}
) {
const remoteLoader = options.remoteLoader ?? downloadRemoteImage;
const remoteCache = new Map<
string,
{ expiresAt: number; promise: Promise<LoadedImage> }
>();
function loadRemote(source: string) {
const cached = remoteCache.get(source);
if (cached && cached.expiresAt > Date.now()) {
return cached.promise;
}
const promise = remoteLoader(source);
remoteCache.set(source, {
expiresAt: Date.now() + 5 * 60_000,
promise
});
if (remoteCache.size > 100) {
const oldest = remoteCache.keys().next().value as string | undefined;
if (oldest) {
remoteCache.delete(oldest);
}
}
void promise.catch(() => {
if (remoteCache.get(source)?.promise === promise) {
remoteCache.delete(source);
}
});
return promise;
}
return async function resolve(
markdown: string,
resources: unknown,
context: ImageResolutionContext = {}
): Promise<ResolvedMarkdownImages> {
const references = extractMarkdownImageSources(markdown);
if (references.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const uploaded = parseUploadedResources(resources);
const sources = new Map<string, string>();
const warnings: string[] = [];
let resolvedBytes = 0;
for (const source of references) {
if (source.startsWith("data:")) {
continue;
}
try {
let image: LoadedImage;
if (/^https?:\/\//iu.test(source)) {
image = await loadRemote(source);
} else {
let normalized: string | undefined;
try {
normalized = normalizeDocumentAssetPath(source);
} catch (error) {
if (!context.allowUnrestrictedLocalFiles) {
throw error;
}
}
const uploadedImage = normalized
? uploaded.get(normalized)
: undefined;
if (uploadedImage) {
image = uploadedImage;
} else if (context.localRoot) {
image = context.allowUnrestrictedLocalFiles
? await loadUnrestrictedLocalImage(
context.localRoot,
source
)
: await loadLocalImage(context.localRoot, source);
} else {
throw new Error("未提供对应素材目录");
}
}
resolvedBytes += image.content.length;
if (resolvedBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("文档图片总大小超过限制");
}
sources.set(source, imageToDataUrl(image));
} catch (error) {
const message =
error instanceof Error ? error.message : "未知错误";
warnings.push(`图片 ${source} 加载失败:${message}`);
sources.set(source, failedImageDataUrl);
}
}
return { sources, warnings };
};
}
+14
View File
@@ -6,6 +6,20 @@ export {
type ApplicationServiceOptions,
type MarkdownRenderRequest
} from "./application-service.js";
export {
MAXIMUM_IMAGE_BYTES,
MAXIMUM_IMAGE_COUNT,
MAXIMUM_TOTAL_IMAGE_BYTES,
REMOTE_IMAGE_TIMEOUT_MS,
createImageResourceResolver,
downloadRemoteImage,
normalizeDocumentAssetPath,
type ImageResolutionContext,
type ImageResourceResolverOptions,
type LoadedImage,
type MarkdownImageResource,
type ResolvedMarkdownImages
} from "./image-resources.js";
export {
createThemeRegistry,
type ThemeRecord,
@@ -80,7 +80,7 @@ describe("共享应用服务", () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const document = service.render({
const document = await service.render({
markdown: "# 文档\n\n<script>alert('xss')</script>",
language: "zh-CN"
});
@@ -94,7 +94,7 @@ describe("共享应用服务", () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
expect(() => service.render({ markdown: 42 })).toThrow(
await expect(service.render({ markdown: 42 })).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_MARKDOWN"
@@ -102,6 +102,39 @@ describe("共享应用服务", () => {
);
});
it("解析上传素材与桌面文档目录中的相对图片", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const image = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
const assetRoot = join(temporaryDirectory!, "document");
await mkdir(join(assetRoot, "文档.assets"), { recursive: true });
await writeFile(join(assetRoot, "文档.assets", "本地.png"), image);
const local = await service.render(
{
markdown:
"![本地](./%E6%96%87%E6%A1%A3.assets/%E6%9C%AC%E5%9C%B0.png)"
},
{ localRoot: assetRoot }
);
const uploaded = await service.render({
markdown: "![网页](文档.assets/网页.png)",
resources: [
{
path: "文档.assets/网页.png",
data: image.toString("base64")
}
]
});
expect(local.articleHtml).toContain("data:image/png;base64,");
expect(uploaded.articleHtml).toContain("data:image/png;base64,");
expect(local.articleHtml).toContain("md-document-image");
expect(uploaded.warnings).toEqual([]);
});
it("列出主题并使用调用方提供的资源 URL", async () => {
const roots = await createThemeFixture();
const service = createApplicationService({
@@ -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);
}
});
});
@@ -0,0 +1,103 @@
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 {
createImageResourceResolver,
downloadRemoteImage,
normalizeDocumentAssetPath
} from "../src/index.js";
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
describe("Markdown 图片资源安全", () => {
it("解码合法相对路径并拒绝越界与绝对路径", () => {
expect(
normalizeDocumentAssetPath(
"./%E6%96%87%E6%A1%A3.assets/a%20b.png?raw=1"
)
).toBe("文档.assets/a b.png");
expect(() => normalizeDocumentAssetPath("../secret.png")).toThrow(
"越过文档目录"
);
expect(() => normalizeDocumentAssetPath("C:/secret.png")).toThrow(
"绝对图片路径"
);
});
it("在发起请求前拒绝直接指向本机的远程地址", async () => {
const fetcher = vi.fn<typeof fetch>();
await expect(
downloadRemoteImage("http://127.0.0.1/private.png", fetcher)
).rejects.toThrow("非公网 IP");
expect(fetcher).not.toHaveBeenCalled();
});
it("缓存成功的远程图片并把失败转换为可诊断占位", async () => {
const remoteLoader = vi
.fn()
.mockResolvedValueOnce({
content: png,
contentType: "image/png"
})
.mockRejectedValueOnce(new Error("网络不可用"));
const resolve = createImageResourceResolver({ remoteLoader });
const first = await resolve(
"![a](https://example.com/a.png)\n![a2](https://example.com/a.png)",
undefined
);
const failed = await resolve(
"![b](https://example.com/b.png)",
undefined
);
expect(remoteLoader).toHaveBeenCalledTimes(2);
expect(first.sources.get("https://example.com/a.png")).toMatch(
/^data:image\/png;base64,/u
);
expect(failed.sources.get("https://example.com/b.png")).toMatch(
/^data:image\/svg\+xml;base64,/u
);
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) => `![图](${source.replace(/ /gu, "%20")})`)
.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 });
}
});
});
+56
View File
@@ -0,0 +1,56 @@
# @md-to-pdf/core
跨 Web、Server、Desktop 和渲染器共享的纯数据模型与校验规则。该包不
依赖浏览器、Electron 或 Fastify,负责保证各运行端使用同一份协议。
## 目录结构
```text
src/
document.ts Markdown 文档、分页载荷、结果与耗时模型
document-link.ts 跨端链接分类与 PDF 本地链接编码协议
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
theme.ts 主题清单、主题能力与 CSS 载荷模型
index.ts 公共导出入口
tests/
document.test.ts
document-link.test.ts
export-config.test.ts
```
## 使用
```ts
import {
classifyDocumentLink,
createPagedDocumentPayload,
defaultExportConfig,
exportConfigSchema
} from "@md-to-pdf/core";
const link = classifyDocumentLink("../docs/example.md");
const config = exportConfigSchema.parse(defaultExportConfig);
const payload = createPagedDocumentPayload({
document,
fileName: "example.md",
themeCss,
exportConfig: config
});
```
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
Markdown 窗口。精确 PDF 使用保留的 `.invalid` 地址暂存本地链接,
PDF.js 注释层展示时再安全解码,避免 Chromium 将相对路径误转成站点 URL。
新增跨端字段时,应先在这里定义类型、默认值、Zod 校验和兼容迁移,再由
各适配层消费,避免 Web 与 Desktop 分别维护协议。
## 开发与验证
```powershell
npm run dev -w @md-to-pdf/core
npm run test -w @md-to-pdf/core
npm run typecheck -w @md-to-pdf/core
npm run build -w @md-to-pdf/core
```
+123
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from "./document.js";
export * from "./document-link.js";
export * from "./export-config.js";
export * from "./theme.js";
+94
View File
@@ -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 作为默认纸张配置", () => {
expect(defaultExportConfig.themeId).toBe("typora-github");
expect(defaultExportConfig.paper).toEqual({
format: "A4",
orientation: "portrait",
+31
View File
@@ -5,6 +5,26 @@
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
本项目由 `@md-to-pdf/renderer` 接入围栏插件,再由
`@md-to-pdf/preview-engine` 统一完成连续预览、分页适配和 SVG 冻结。
## 目录结构
```text
src/
markdown-it-plugin.ts ECharts YAML 围栏与错误占位
parse-yaml.ts 受限 YAML 解析
protocol.ts 围栏协议与基础校验
security-policy.ts URL、函数、原型和规模安全策略
semantic-validator.ts 系列数据与坐标系语义校验
dom-contract.ts 宿主与浏览器运行时共用的 DOM 协议
browser.ts 浏览器入口
browser/ ECharts 按需加载、渲染和 SVG 冻结
index.ts Markdown 侧公共导出
styles/
echarts.css 图表、标题和错误占位样式
tests/ 解析、安全、语义、插件和真实 SVG 测试
```
## 基本语法
@@ -263,6 +283,8 @@ Chromium `page.pdf()`。
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
ECharts 围栏使用专用 `figure.md-echarts` DOM,不会套用普通代码围栏的
`pre.md-fences` 样式。
## 安全边界
@@ -289,6 +311,15 @@ Chromium `page.pdf()`。
3. 注册该系列的语义验证器;
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
## 开发与验证
```powershell
npm run dev -w @md-to-pdf/markdown-echarts
npm run test -w @md-to-pdf/markdown-echarts
npm run typecheck -w @md-to-pdf/markdown-echarts
npm run build -w @md-to-pdf/markdown-echarts
```
参考:
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
+92
View File
@@ -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
```
+35
View File
@@ -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();
}
};
}
@@ -30,6 +30,58 @@ export interface SvgDiagramFitOptions {
const PAGE_FIT_EPSILON_PX = 1;
function parsePixelValue(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function getPrecedingMediaTitleHeight(
element: HTMLElement
) {
const title = element.previousElementSibling;
if (
!(title instanceof HTMLElement) ||
!/^H[1-6]$/.test(title.tagName)
) {
return 0;
}
const style = getComputedStyle(title);
title.style.setProperty("break-after", "avoid-page", "important");
title.style.setProperty(
"page-break-after",
"avoid",
"important"
);
return (
title.getBoundingClientRect().height +
parsePixelValue(style.marginTop) +
parsePixelValue(style.marginBottom)
);
}
export function ensurePageBreakAfter(element: HTMLElement) {
const existingMarker = element.nextElementSibling;
if (
existingMarker instanceof HTMLElement &&
existingMarker.dataset.mediaPageBreak === "true"
) {
return existingMarker;
}
const marker = element.ownerDocument.createElement("div");
marker.dataset.mediaPageBreak = "true";
marker.setAttribute("aria-hidden", "true");
marker.style.setProperty("height", "0", "important");
marker.style.setProperty("break-before", "page", "important");
marker.style.setProperty(
"page-break-before",
"always",
"important"
);
element.after(marker);
return marker;
}
export function parseSvgViewBox(
value: string | null
): DiagramDimensions | undefined {
@@ -144,9 +196,17 @@ export function fitSvgDiagramsToPage(
continue;
}
const availablePageContent =
const resolvedPageContent =
options.resolvePageContent?.(container, pageContent) ??
pageContent;
const availablePageContent = {
width: resolvedPageContent.width,
height: Math.max(
0,
resolvedPageContent.height -
getPrecedingMediaTitleHeight(container)
)
};
const fit = calculateDiagramPageFit(
intrinsic,
availablePageContent
@@ -157,6 +217,13 @@ export function fitSvgDiagramsToPage(
container.dataset.pageHeightFitted = "true";
container.style.setProperty("margin-block", "0", "important");
container.style.setProperty("break-after", "page", "important");
container.style.setProperty(
"page-break-after",
"always",
"important"
);
ensurePageBreakAfter(container);
if (!svg.getAttribute("viewBox")) {
svg.setAttribute(
"viewBox",
@@ -0,0 +1,106 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
calculateDiagramPageFit,
ensurePageBreakAfter,
getPageContentDimensions,
getPrecedingMediaTitleHeight
} from "./diagram-page-fit.js";
function getImageBlock(image: HTMLImageElement) {
const parent = image.parentElement;
if (parent?.classList.contains("md-document-image-block")) {
return parent;
}
if (
parent?.tagName === "P" &&
Array.from(parent.childNodes).every(
(node) =>
node === image ||
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
)
) {
parent.classList.add("md-document-image-block");
return parent;
}
return undefined;
}
export function prepareDocumentImageBlocks(root: ParentNode) {
const images = Array.from(
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
);
let prepared = 0;
for (const image of images) {
if (getImageBlock(image)) {
prepared += 1;
}
}
return prepared;
}
export function fitDocumentImagesToPage(
root: ParentNode,
config: ExportConfig,
options: { prepareBlocks?: boolean } = {}
) {
const pageContent = getPageContentDimensions(config);
if (options.prepareBlocks !== false) {
prepareDocumentImageBlocks(root);
}
const images = Array.from(
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
);
for (const image of images) {
const block = getImageBlock(image);
const width = image.naturalWidth;
const height = image.naturalHeight;
if (width <= 0 || height <= 0) {
continue;
}
const imageHeight = image.getBoundingClientRect().height;
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
const titleHeight = block
? getPrecedingMediaTitleHeight(block)
: 0;
const fit = calculateDiagramPageFit(
{ width, height },
{
width: pageContent.width,
height: Math.max(
0,
pageContent.height - nonImageHeight - titleHeight
)
}
);
if (!fit.scaled) {
continue;
}
if (block?.classList.contains("md-document-image-block")) {
block.dataset.pageHeightFitted = "true";
block.style.setProperty("margin-block", "0", "important");
block.style.setProperty("break-after", "page", "important");
block.style.setProperty(
"page-break-after",
"always",
"important"
);
ensurePageBreakAfter(block);
}
image.dataset.pageHeightFitted = "true";
image.style.setProperty("display", "block");
image.style.setProperty("width", `${fit.width}px`, "important");
image.style.setProperty("height", `${fit.height}px`, "important");
image.style.setProperty("max-width", "100%", "important");
image.style.setProperty(
"max-height",
`${fit.height}px`,
"important"
);
image.style.setProperty("object-fit", "contain");
image.style.setProperty("margin-inline", "auto");
}
}
@@ -1,5 +1,5 @@
import type { ExportConfig } from "@md-to-pdf/core";
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
import { fitSvgDiagramsToPage } from "./diagram-page-fit.js";
export function fitOversizedEChartsToPage(
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);
}
}
}
+16
View File
@@ -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";
@@ -0,0 +1,397 @@
export type MediaBackfillKind = "image" | "mermaid" | "echarts";
export interface MediaBackfillGeometry {
remainingHeight: number;
fixedHeight: number;
baselineVisualHeight: number;
}
export interface MediaBackfillCandidate {
id: string;
scale: number;
}
export interface MediaBackfillOptions {
minimumScale?: number;
safetyGapPx?: number;
}
const MEDIA_BLOCK_SELECTOR = [
".md-document-image-block",
".mermaid:not(.mermaid-error)",
".md-echarts:not(.md-echarts-error)"
].join(", ");
const DEFAULT_MINIMUM_SCALE = 0.88;
const DEFAULT_SAFETY_GAP_PX = 2;
const SCALE_EPSILON = 0.001;
function finitePositive(value: string | undefined) {
const parsed = Number.parseFloat(value ?? "");
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function getMediaKind(block: Element): MediaBackfillKind | undefined {
if (block.classList.contains("md-document-image-block")) {
return "image";
}
if (block.classList.contains("mermaid")) {
return "mermaid";
}
if (block.classList.contains("md-echarts")) {
return "echarts";
}
return undefined;
}
function getMediaVisual(
block: Element,
kind: MediaBackfillKind
): HTMLElement | SVGSVGElement | undefined {
if (kind === "image") {
return (
block.querySelector<HTMLImageElement>("img.md-document-image") ??
undefined
);
}
if (kind === "echarts") {
return (
block.querySelector<HTMLElement>(".md-echarts-host") ??
undefined
);
}
return (
block.querySelector<SVGSVGElement>("svg") ??
block.querySelector<HTMLImageElement>("img") ??
undefined
);
}
function parsePixelValue(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getOuterBottom(element: HTMLElement) {
const rect = element.getBoundingClientRect();
return (
rect.bottom +
parsePixelValue(getComputedStyle(element).marginBottom)
);
}
function getOuterTop(element: HTMLElement) {
const rect = element.getBoundingClientRect();
return (
rect.top -
parsePixelValue(getComputedStyle(element).marginTop)
);
}
function getMediaGroupTop(
block: HTMLElement,
pageContentTop: number
) {
const title = block.previousElementSibling;
if (
title instanceof HTMLElement &&
/^H[1-6]$/.test(title.tagName)
) {
return Math.max(
pageContentTop,
getOuterTop(title)
);
}
return Math.max(
pageContentTop,
getOuterTop(block)
);
}
function getLastContentBottom(pageContent: HTMLElement) {
const contentRect = pageContent.getBoundingClientRect();
const leafElements = Array.from(
pageContent.querySelectorAll<HTMLElement>("[data-ref]")
).filter(
(element) => !element.querySelector("[data-ref]")
);
let bottom = contentRect.top;
for (const element of leafElements) {
const rect = element.getBoundingClientRect();
if (
rect.width <= 0 ||
rect.height <= 0 ||
rect.bottom <= contentRect.top ||
rect.top >= contentRect.bottom
) {
continue;
}
bottom = Math.max(
bottom,
Math.min(getOuterBottom(element), contentRect.bottom)
);
}
return bottom;
}
function hasForcedBreakBefore(block: HTMLElement) {
const title = block.previousElementSibling;
const elements = [
block,
title instanceof HTMLElement && /^H[1-6]$/.test(title.tagName)
? title
: undefined
].filter((element): element is HTMLElement => Boolean(element));
return elements.some((element) => {
const style = getComputedStyle(element);
return (
style.breakBefore === "page" ||
style.pageBreakBefore === "always"
);
});
}
export function calculateMediaBackfillScale(
geometry: MediaBackfillGeometry,
options: MediaBackfillOptions = {}
) {
const minimumScale =
options.minimumScale ?? DEFAULT_MINIMUM_SCALE;
const safetyGapPx =
options.safetyGapPx ?? DEFAULT_SAFETY_GAP_PX;
if (
!Number.isFinite(geometry.remainingHeight) ||
!Number.isFinite(geometry.fixedHeight) ||
!Number.isFinite(geometry.baselineVisualHeight) ||
geometry.remainingHeight <= 0 ||
geometry.fixedHeight < 0 ||
geometry.baselineVisualHeight <= 0 ||
!Number.isFinite(minimumScale) ||
minimumScale <= 0 ||
minimumScale >= 1 ||
!Number.isFinite(safetyGapPx) ||
safetyGapPx < 0
) {
return undefined;
}
const availableVisualHeight =
geometry.remainingHeight -
geometry.fixedHeight -
safetyGapPx;
const scale =
availableVisualHeight / geometry.baselineVisualHeight;
if (
scale < minimumScale ||
scale >= 1 - SCALE_EPSILON
) {
return undefined;
}
return scale;
}
export function prepareMediaBackfillBlocks(root: ParentNode) {
const blocks = Array.from(
root.querySelectorAll<HTMLElement>(MEDIA_BLOCK_SELECTOR)
);
let prepared = 0;
for (const [index, block] of blocks.entries()) {
const kind = getMediaKind(block);
const visual = kind ? getMediaVisual(block, kind) : undefined;
const rect = visual?.getBoundingClientRect();
if (
!kind ||
!visual ||
!rect ||
rect.width <= 0 ||
rect.height <= 0
) {
continue;
}
block.dataset.mediaBackfillId = `media-${index + 1}`;
block.dataset.mediaBackfillKind = kind;
block.dataset.mediaBaselineWidth = String(rect.width);
block.dataset.mediaBaselineHeight = String(rect.height);
prepared += 1;
}
return prepared;
}
export function getMediaBackfillIdsInDocumentOrder(
root: ParentNode
) {
return Array.from(
root.querySelectorAll<HTMLElement>(
"[data-media-backfill-id]"
)
).flatMap((block) =>
block.dataset.mediaBackfillId
? [block.dataset.mediaBackfillId]
: []
);
}
export function findMediaBackfillCandidates(
root: ParentNode,
options: MediaBackfillOptions = {}
) {
const pages = Array.from(
root.querySelectorAll<HTMLElement>(".pagedjs_page")
);
const candidates: MediaBackfillCandidate[] = [];
const candidateIds = new Set<string>();
for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) {
const previousPage = pages[pageIndex - 1];
const currentPage = pages[pageIndex];
const previousContent =
previousPage?.querySelector<HTMLElement>(
".pagedjs_page_content"
);
const currentContent =
currentPage?.querySelector<HTMLElement>(
".pagedjs_page_content"
);
if (!previousContent || !currentContent) {
continue;
}
const mediaBlocks = Array.from(
currentContent.querySelectorAll<HTMLElement>(
"[data-media-backfill-id]"
)
).sort(
(left, right) =>
left.getBoundingClientRect().top -
right.getBoundingClientRect().top
);
const block = mediaBlocks[0];
if (!block) {
continue;
}
const kind = getMediaKind(block);
const visual = kind ? getMediaVisual(block, kind) : undefined;
const id = block.dataset.mediaBackfillId;
const baselineVisualHeight = finitePositive(
block.dataset.mediaBaselineHeight
);
if (!kind || !visual || !id || !baselineVisualHeight) {
continue;
}
if (hasForcedBreakBefore(block)) {
continue;
}
const currentContentRect = currentContent.getBoundingClientRect();
const visualHeight = visual.getBoundingClientRect().height;
const mediaGroupTop = getMediaGroupTop(
block,
currentContentRect.top
);
const fixedHeight = Math.max(
0,
getOuterBottom(block) -
mediaGroupTop -
visualHeight
);
const previousContentRect =
previousContent.getBoundingClientRect();
const previousFlowBottom =
getLastContentBottom(previousContent);
const remainingHeight = Math.max(
0,
previousContentRect.bottom - previousFlowBottom
);
const scale = calculateMediaBackfillScale(
{
remainingHeight,
fixedHeight,
baselineVisualHeight
},
options
);
if (scale === undefined || candidateIds.has(id)) {
continue;
}
candidateIds.add(id);
candidates.push({ id, scale });
}
return candidates;
}
function setVisualSize(
visual: HTMLElement | SVGSVGElement,
width: number,
height: number
) {
visual.style.setProperty("display", "block");
visual.style.setProperty("width", `${width}px`, "important");
visual.style.setProperty("height", `${height}px`, "important");
visual.style.setProperty("max-width", "100%", "important");
visual.style.setProperty("max-height", `${height}px`, "important");
visual.style.setProperty("margin-inline", "auto", "important");
}
export function applyMediaBackfillCandidates(
source: ParentNode,
candidates: MediaBackfillCandidate[]
) {
let applied = 0;
for (const candidate of candidates) {
const block = Array.from(
source.querySelectorAll<HTMLElement>(
"[data-media-backfill-id]"
)
).find(
(element) =>
element.dataset.mediaBackfillId === candidate.id
);
const kind = block ? getMediaKind(block) : undefined;
const baselineWidth = finitePositive(
block?.dataset.mediaBaselineWidth
);
const baselineHeight = finitePositive(
block?.dataset.mediaBaselineHeight
);
const visual =
block && kind ? getMediaVisual(block, kind) : undefined;
if (
!block ||
!kind ||
!visual ||
!baselineWidth ||
!baselineHeight ||
!Number.isFinite(candidate.scale) ||
candidate.scale <= 0 ||
candidate.scale >= 1
) {
continue;
}
const width = baselineWidth * candidate.scale;
const height = baselineHeight * candidate.scale;
block.dataset.mediaBackfilled = "true";
block.dataset.mediaBackfillScale = String(candidate.scale);
setVisualSize(visual, width, height);
if (kind === "image") {
visual.style.setProperty("object-fit", "contain");
} else if (kind === "echarts") {
const svg = block.querySelector<SVGSVGElement>(
".md-echarts-host svg"
);
if (svg) {
setVisualSize(svg, width, height);
}
visual.style.removeProperty("aspect-ratio");
}
applied += 1;
}
return applied;
}
@@ -6,7 +6,7 @@ import {
parseSvgViewBox,
type DiagramDimensions,
type DiagramPageFit
} from "./diagram-page-fit";
} from "./diagram-page-fit.js";
export type MermaidDimensions = DiagramDimensions;
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;
type: "render";
requestId: number;
layout: "paged" | "continuous";
payload: PagedPreviewPayload;
}
@@ -21,15 +22,22 @@ export type PagedPreviewFrameMessage =
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "ready";
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "link";
href: string;
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "rendering";
requestId: number;
layout: "paged" | "continuous";
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "rendered";
requestId: number;
layout: "paged" | "continuous";
pageCount: number;
contentHeight: number;
echartsErrors: string[];
@@ -67,6 +75,39 @@ svg {
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 {
break-inside: avoid;
page-break-inside: avoid;
text-align: center;
}
.md-document-image-block > .md-document-image {
display: block;
margin-inline: auto;
}
.md-document-image-caption {
margin-top: 0.55em;
color: #64748b;
font-size: 0.875em;
line-height: 1.5;
text-align: center;
}
#write table {
width: 100%;
table-layout: auto;
@@ -114,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) {
return JSON.stringify(value)
.replace(/\u2028/g, "\\2028 ")
@@ -313,6 +408,11 @@ ${buildFooterCss(config)}
page-break-inside: avoid;
}
#write .md-document-image-block {
break-inside: avoid;
page-break-inside: avoid;
}
${
config.print.pageBreakBeforeH1
? `#write h1:not(:first-child) {
@@ -374,12 +474,14 @@ html[data-render-target="pdf"] .pagedjs_page {
export function createPagedPreviewRenderRequest(
requestId: number,
payload: PagedPreviewPayload
payload: PagedPreviewPayload,
layout: "paged" | "continuous" = "paged"
): PagedPreviewRenderRequest {
return {
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "render",
requestId,
layout,
payload
};
}
@@ -396,6 +498,8 @@ export function isPagedPreviewRenderRequest(
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
candidate.type === "render" &&
Number.isInteger(candidate.requestId) &&
(candidate.layout === "paged" ||
candidate.layout === "continuous") &&
Boolean(candidate.payload) &&
typeof candidate.payload?.articleHtml === "string" &&
typeof candidate.payload?.themeCss === "string"
@@ -412,24 +516,54 @@ export function isPagedPreviewFrameMessage(
const candidate = value as Partial<PagedPreviewFrameMessage>;
if (
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
!["ready", "rendering", "rendered", "error"].includes(
!["ready", "link", "rendering", "rendered", "error"].includes(
candidate.type ?? ""
)
) {
return false;
}
if (candidate.type !== "rendered") {
if (candidate.type === "ready") {
return true;
}
if (candidate.type === "link") {
return (
typeof candidate.href === "string" &&
candidate.href.trim().length > 0
);
}
if (candidate.type === "rendering") {
return (
Number.isInteger(candidate.requestId) &&
(candidate.layout === "paged" ||
candidate.layout === "continuous")
);
}
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(candidate.requestId) &&
Number.isInteger(candidate.pageCount) &&
typeof candidate.contentHeight === "number" &&
Number.isFinite(candidate.contentHeight) &&
candidate.contentHeight > 0 &&
Array.isArray(candidate.echartsErrors) &&
Array.isArray(candidate.mermaidErrors)
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,5 +1,5 @@
import { Handler, registerHandlers } from "pagedjs";
import { resolveBreakTokenElement } from "./paged-break-token";
import { resolveBreakTokenElement } from "./paged-break-token.js";
interface PagedBreakToken {
node?: Node;
+78
View File
@@ -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);
});
});
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitDocumentImagesToPage } from "../src/document-image-fit.js";
describe("Markdown 图片单页适配", () => {
it("标记独立图片段落并将超高图片缩放到单页", () => {
const root = document.createElement("div");
root.innerHTML =
'<p> <img class="md-document-image" src="data:image/png;base64,AA=="> </p>';
const image = root.querySelector("img")!;
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 3000 }
});
fitDocumentImagesToPage(root, defaultExportConfig);
expect(image.parentElement?.classList).toContain(
"md-document-image-block"
);
expect(image.dataset.pageHeightFitted).toBe("true");
expect(image.parentElement?.dataset.pageHeightFitted).toBe("true");
expect(
image.parentElement?.style.getPropertyValue("margin-block")
).toBe("0");
expect(Number.parseFloat(image.style.height)).toBeCloseTo(
1000.5748,
3
);
expect(Number.parseFloat(image.style.width)).toBeCloseTo(
333.5249,
3
);
});
it("普通横图保持主题控制的尺寸", () => {
const root = document.createElement("div");
root.innerHTML =
'<p><img class="md-document-image" src="data:image/png;base64,AA=="></p>';
const image = root.querySelector("img")!;
Object.defineProperties(image, {
naturalWidth: { value: 1600 },
naturalHeight: { value: 900 }
});
fitDocumentImagesToPage(root, defaultExportConfig);
expect(image.dataset.pageHeightFitted).toBeUndefined();
expect(image.style.height).toBe("");
});
});
@@ -2,12 +2,13 @@
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit.js";
describe("ECharts 单页高度适配", () => {
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
const root = document.createElement("div");
root.innerHTML = `
<h2 style="margin: 10px 0"></h2>
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 2000px">
<svg width="1000" height="2000"></svg>
@@ -15,22 +16,43 @@ describe("ECharts 单页高度适配", () => {
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const title = root.querySelector<HTMLElement>("h2");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
const svg = root.querySelector<SVGSVGElement>("svg");
if (title) {
title.getBoundingClientRect = () => ({
x: 0,
y: 0,
top: 0,
left: 0,
right: 700,
bottom: 40,
width: 700,
height: 40,
toJSON: () => ({})
});
}
fitOversizedEChartsToPage(root, defaultExportConfig);
expect(figure?.dataset.pageHeightFitted).toBe("true");
expect(title?.style.breakAfter).toBe("avoid-page");
expect(figure?.style.breakAfter).toBe("page");
expect(figure?.style.pageBreakAfter).toBe("always");
expect(figure?.nextElementSibling).toHaveProperty(
"dataset.mediaPageBreak",
"true"
);
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
1000.5748,
960.5748,
3
);
expect(host?.style.aspectRatio).toBe("");
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
1000.5748,
960.5748,
3
);
});
@@ -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");
});
});
@@ -0,0 +1,329 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
applyMediaBackfillCandidates,
calculateMediaBackfillScale,
findMediaBackfillCandidates,
getMediaBackfillIdsInDocumentOrder,
prepareMediaBackfillBlocks
} from "../src/media-page-backfill.js";
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit.js";
function rect(width: number, height: number) {
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({})
};
}
function positionedRect(
top: number,
width: number,
height: number
) {
return {
...rect(width, height),
y: top,
top,
bottom: top + height
};
}
describe("媒体分页空白回填", () => {
it("全页适配时保留前置标题高度并避免标题孤行", () => {
const root = document.createElement("div");
root.innerHTML = `
<h2 style="margin: 10px 0 12px">媒体标题</h2>
<div class="mermaid"></div>
`;
const title = root.querySelector<HTMLElement>("h2")!;
const block = root.querySelector<HTMLElement>(".mermaid")!;
title.getBoundingClientRect = () =>
positionedRect(100, 600, 40);
expect(getPrecedingMediaTitleHeight(block)).toBe(40);
expect(title.style.getPropertyValue("break-after")).toBe(
"avoid-page"
);
expect(title.style.getPropertyPriority("break-after")).toBe(
"important"
);
});
it("按限宽后的基准高度计算额外缩放率", () => {
expect(
calculateMediaBackfillScale({
remainingHeight: 420,
fixedHeight: 30,
baselineVisualHeight: 430
})
).toBeCloseTo(388 / 430, 6);
});
it("缩小超过阈值或无需缩小时不回填", () => {
expect(
calculateMediaBackfillScale({
remainingHeight: 300,
fixedHeight: 30,
baselineVisualHeight: 430
})
).toBeUndefined();
expect(
calculateMediaBackfillScale({
remainingHeight: 500,
fixedHeight: 30,
baselineVisualHeight: 430
})
).toBeUndefined();
});
it("记录图片限宽后的实际基准尺寸", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-document-image-block">
<img class="md-document-image">
<figcaption>标题</figcaption>
</figure>
`;
const image = root.querySelector("img")!;
image.getBoundingClientRect = () => rect(640, 480);
expect(prepareMediaBackfillBlocks(root)).toBe(1);
const figure = root.querySelector<HTMLElement>("figure")!;
expect(figure.dataset.mediaBackfillKind).toBe("image");
expect(figure.dataset.mediaBaselineWidth).toBe("640");
expect(figure.dataset.mediaBaselineHeight).toBe("480");
});
it("保持媒体元素在源文档中的串行处理顺序", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure data-media-backfill-id="media-2"></figure>
<div><figure data-media-backfill-id="media-1"></figure></div>
<figure></figure>
`;
expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([
"media-2",
"media-1"
]);
});
it("根据上一页剩余区域生成一次回填候选", () => {
const root = document.createElement("div");
root.innerHTML = `
<div class="pagedjs_page">
<div class="pagedjs_page_content">
<div
class="pagedjs_page_content_flow"
data-ref="previous-content"
>上一页内容</div>
</div>
</div>
<div class="pagedjs_page">
<div class="pagedjs_page_content">
<h2 data-ref="media-title">图片标题</h2>
<figure
class="md-document-image-block"
data-media-backfill-id="media-1"
data-media-baseline-width="600"
data-media-baseline-height="400"
>
<img class="md-document-image">
</figure>
</div>
</div>
`;
const pageContents = root.querySelectorAll<HTMLElement>(
".pagedjs_page_content"
);
const previousFlow =
pageContents[0]!.firstElementChild as HTMLElement;
const title = root.querySelector<HTMLElement>("h2")!;
const block = root.querySelector<HTMLElement>("figure")!;
const image = root.querySelector<HTMLImageElement>("img")!;
pageContents[0]!.getBoundingClientRect = () =>
positionedRect(100, 700, 1_000);
previousFlow.getBoundingClientRect = () =>
positionedRect(100, 700, 550);
pageContents[1]!.getBoundingClientRect = () =>
positionedRect(100, 700, 1_000);
title.getBoundingClientRect = () =>
positionedRect(100, 600, 40);
block.getBoundingClientRect = () =>
positionedRect(150, 600, 330);
image.getBoundingClientRect = () =>
positionedRect(150, 600, 300);
expect(findMediaBackfillCandidates(root)).toEqual([
{ id: "media-1", scale: 0.92 }
]);
});
it("显式分页的媒体块不会回填到上一页", () => {
const root = document.createElement("div");
root.innerHTML = `
<div class="pagedjs_page">
<div class="pagedjs_page_content"></div>
</div>
<div class="pagedjs_page">
<div class="pagedjs_page_content">
<h2 data-ref="media-title" style="break-before: page">
图片标题
</h2>
<figure
class="md-document-image-block"
data-ref="media-block"
data-media-backfill-id="media-1"
data-media-baseline-width="600"
data-media-baseline-height="400"
>
<img class="md-document-image">
</figure>
</div>
</div>
`;
const pageContents = root.querySelectorAll<HTMLElement>(
".pagedjs_page_content"
);
const title = root.querySelector<HTMLElement>("h2")!;
const block = root.querySelector<HTMLElement>("figure")!;
const image = root.querySelector<HTMLImageElement>("img")!;
pageContents[0]!.getBoundingClientRect = () =>
positionedRect(100, 700, 1_000);
pageContents[1]!.getBoundingClientRect = () =>
positionedRect(100, 700, 1_000);
title.getBoundingClientRect = () =>
positionedRect(100, 600, 40);
block.getBoundingClientRect = () =>
positionedRect(150, 600, 330);
image.getBoundingClientRect = () =>
positionedRect(150, 600, 300);
expect(findMediaBackfillCandidates(root)).toEqual([]);
});
it("忽略媒体之前其他内容的显式分页规则", () => {
const root = document.createElement("div");
root.innerHTML = `
<div class="pagedjs_page">
<div class="pagedjs_page_content">
<div data-ref="previous-content">上一页内容</div>
</div>
</div>
<div class="pagedjs_page">
<div class="pagedjs_page_content">
<h1 data-ref="earlier-heading" style="break-before: page">
本页章节
</h1>
<h2 data-ref="media-title">图片标题</h2>
<figure
class="md-document-image-block"
data-ref="media-block"
data-media-backfill-id="media-1"
data-media-baseline-width="600"
data-media-baseline-height="400"
>
<img class="md-document-image">
</figure>
</div>
</div>
`;
const pageContents = root.querySelectorAll<HTMLElement>(
".pagedjs_page_content"
);
const previousFlow = root.querySelector<HTMLElement>(
"[data-ref='previous-content']"
)!;
const earlierHeading = root.querySelector<HTMLElement>("h1")!;
const title = root.querySelector<HTMLElement>("h2")!;
const block = root.querySelector<HTMLElement>("figure")!;
const image = root.querySelector<HTMLImageElement>("img")!;
pageContents[0]!.getBoundingClientRect = () =>
positionedRect(100, 700, 1_000);
previousFlow.getBoundingClientRect = () =>
positionedRect(100, 700, 550);
pageContents[1]!.getBoundingClientRect = () =>
positionedRect(100, 700, 1_000);
earlierHeading.getBoundingClientRect = () =>
positionedRect(100, 600, 40);
title.getBoundingClientRect = () =>
positionedRect(150, 600, 40);
block.getBoundingClientRect = () =>
positionedRect(200, 600, 330);
image.getBoundingClientRect = () =>
positionedRect(200, 600, 300);
expect(findMediaBackfillCandidates(root)).toEqual([
{ id: "media-1", scale: 0.92 }
]);
});
it("将候选缩放应用到图片和 ECharts 主体", () => {
const root = document.createElement("div");
root.innerHTML = `
<h2>图片标题保持原尺寸</h2>
<figure
class="md-document-image-block"
data-media-backfill-id="media-1"
data-media-backfill-kind="image"
data-media-baseline-width="800"
data-media-baseline-height="600"
>
<img class="md-document-image">
</figure>
<figure
class="md-echarts"
data-media-backfill-id="media-2"
data-media-backfill-kind="echarts"
data-media-baseline-width="700"
data-media-baseline-height="500"
>
<div class="md-echarts-host"><svg></svg></div>
</figure>
<div
class="mermaid"
data-media-backfill-id="media-3"
data-media-backfill-kind="mermaid"
data-media-baseline-width="600"
data-media-baseline-height="400"
>
<svg></svg>
</div>
`;
expect(
applyMediaBackfillCandidates(root, [
{ id: "media-1", scale: 0.9 },
{ id: "media-2", scale: 0.88 },
{ id: "media-3", scale: 0.9 }
])
).toBe(3);
const title = root.querySelector<HTMLElement>("h2")!;
const image = root.querySelector<HTMLImageElement>(
"img.md-document-image"
)!;
const host = root.querySelector<HTMLElement>(".md-echarts-host")!;
const svg = root.querySelector<SVGSVGElement>("svg")!;
const mermaidSvg = root.querySelector<SVGSVGElement>(
".mermaid svg"
)!;
expect(title.getAttribute("style")).toBeNull();
expect(image.style.width).toBe("720px");
expect(image.style.height).toBe("540px");
expect(host.style.width).toBe("616px");
expect(host.style.height).toBe("440px");
expect(svg.style.width).toBe("616px");
expect(svg.style.height).toBe("440px");
expect(mermaidSvg.style.width).toBe("540px");
expect(mermaidSvg.style.height).toBe("360px");
});
});
@@ -3,7 +3,7 @@ import { defaultExportConfig } from "@md-to-pdf/core";
import {
createMermaidSiteConfig,
MERMAID_SECURE_CONFIG_KEYS
} from "../src/mermaid-config";
} from "../src/mermaid-config.js";
describe("Mermaid 站点配置", () => {
it("默认使用 default 主题和 classic 外观", () => {
@@ -4,7 +4,7 @@ import {
calculateMermaidPageFit,
getPageContentDimensions,
parseSvgViewBox
} from "../src/mermaid-page-fit";
} from "../src/mermaid-page-fit.js";
describe("Mermaid 单页高度适配", () => {
it("解析 SVG viewBox 尺寸", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { renderMermaidDefinitions } from "../src/mermaid-renderer";
import { renderMermaidDefinitions } from "../src/mermaid-renderer.js";
describe("Mermaid 独立图表渲染", () => {
it("单个图表失败后继续渲染后续图表", async () => {
@@ -4,7 +4,7 @@ import {
getLockedMermaidBoxSize,
getMermaidImageAlt,
resolveMermaidOutputMode
} from "../src/mermaid-static-image";
} from "../src/mermaid-static-image.js";
describe("Mermaid 静态 SVG 图片", () => {
it("生成无需外部请求的 UTF-8 Data URL", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveBreakTokenElement } from "../src/paged-break-token";
import { resolveBreakTokenElement } from "../src/paged-break-token.js";
describe("Paged.js 分页断点", () => {
it("直接返回元素断点", () => {
@@ -2,14 +2,17 @@ import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import {
buildPagedMediaCss,
continuousDocumentGeometryCss,
createPagedPreviewRenderRequest,
documentBaseCss,
documentGeometryCss,
documentInteractionCss,
formatPageNumber,
isPagedPreviewFrameMessage,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
type PagedPreviewPayload
} from "../src/paged-preview";
} from "../src/paged-preview.js";
const payload: PagedPreviewPayload = {
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("生成并识别分页渲染请求", () => {
const request = createPagedPreviewRenderRequest(7, payload);
@@ -161,9 +201,17 @@ describe("分页预览协议", () => {
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "render",
requestId: 7,
layout: "paged",
payload
});
expect(isPagedPreviewRenderRequest(request)).toBe(true);
expect(
createPagedPreviewRenderRequest(
8,
payload,
"continuous"
).layout
).toBe("continuous");
expect(
isPagedPreviewRenderRequest({
...request,
@@ -173,11 +221,26 @@ describe("分页预览协议", () => {
});
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(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
layout: "paged",
pageCount: 2,
contentHeight: 2400,
echartsErrors: [],
@@ -189,6 +252,7 @@ describe("分页预览协议", () => {
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
layout: "paged",
pageCount: 2,
echartsErrors: [],
mermaidErrors: []
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolvePagedRenderTarget } from "../src/paged-render-target";
import { resolvePagedRenderTarget } from "../src/paged-render-target.js";
describe("分页文档渲染目标", () => {
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,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { enablePrintMediaForPreview } from "../src/preview-styles";
import { enablePrintMediaForPreview } from "../src/preview-styles.js";
describe("打印媒体预览", () => {
it("将打印媒体规则转换为屏幕预览规则", () => {
+21
View File
@@ -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"
]
}
+58
View File
@@ -0,0 +1,58 @@
# @md-to-pdf/renderer
将 Markdown 转换为统一、安全的 `#write` HTML 文档。Web 预览、服务端
PDF 和桌面端 PDF 均通过应用服务复用本包,不维护第二套 Markdown 解析
逻辑。
## 目录结构
```text
src/
render-markdown.ts Front Matter、Markdown 扩展、安全过滤与元数据
index.ts 公共导出入口
tests/
render-markdown.test.ts
```
## 支持能力
- 标题锚点、表格、任务列表、脚注和代码高亮;
- markdown-it 原生围栏代码块及 Typora `.md-fences` DOM 兼容;
- KaTeX 数学公式;
- Mermaid 安全占位;
- `@md-to-pdf/markdown-echarts` ECharts YAML 围栏;
- 图片标题 DOM 与资源引用收集;
- 保留由 Web/Desktop 平台适配器处理的安全文档链接;
- `sanitize-html` 白名单过滤和统一 `<article id="write">` 输出。
## 使用
```ts
import { renderMarkdown } from "@md-to-pdf/renderer";
const result = renderMarkdown("# 示例", {
language: "zh-CN"
});
```
普通围栏代码块输出为:
```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` 调用,以便同时完成主题和图片
资源处理。
## 开发与验证
```powershell
npm run dev -w @md-to-pdf/renderer
npm run test -w @md-to-pdf/renderer
npm run typecheck -w @md-to-pdf/renderer
npm run build -w @md-to-pdf/renderer
```
+173 -7
View File
@@ -7,6 +7,10 @@ import type {
RenderedMarkdownDocument,
ThemeFeature
} from "@md-to-pdf/core";
import {
classifyDocumentLink,
isSafeDocumentLink
} from "@md-to-pdf/core";
import {
markdownItECharts,
type MarkdownItEChartsErrorContext
@@ -34,6 +38,8 @@ export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
export interface RenderMarkdownOptions {
language?: string;
imageSourceMap?: ReadonlyMap<string, string>;
warnings?: readonly string[];
}
export interface RenderedMarkdown
@@ -83,10 +89,89 @@ const markdown = new MarkdownIt(markdownOptions)
trust: false
});
const defaultValidateLink = markdown.validateLink.bind(markdown);
markdown.validateLink = (href) =>
defaultValidateLink(href) ||
classifyDocumentLink(href).scheme === "file";
const defaultFenceRenderer = markdown.renderer.rules.fence;
const defaultImageRenderer = markdown.renderer.rules.image;
const defaultParagraphOpenRenderer =
markdown.renderer.rules.paragraph_open;
const defaultParagraphCloseRenderer =
markdown.renderer.rules.paragraph_close;
const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u;
const tableTextAlignStylePattern = /^(?:left|center|right)$/u;
interface MarkdownRenderEnvironment {
warnings?: string[];
imageSourceMap?: ReadonlyMap<string, string>;
standaloneImage?: boolean;
}
function getStandaloneImage(
token: Token | undefined
): Token | undefined {
if (token?.type !== "inline") {
return undefined;
}
const meaningful = (token.children ?? []).filter(
(child) =>
child.type !== "text" || child.content.trim().length > 0
);
return meaningful.length === 1 && meaningful[0]?.type === "image"
? meaningful[0]
: undefined;
}
markdown.renderer.rules.paragraph_open = (
tokens,
index,
options,
environment,
renderer
) => {
const standaloneImage = getStandaloneImage(tokens[index + 1]);
(environment as MarkdownRenderEnvironment).standaloneImage =
Boolean(standaloneImage);
if (standaloneImage) {
return '<figure class="md-document-image-block">\n';
}
return defaultParagraphOpenRenderer
? defaultParagraphOpenRenderer(
tokens,
index,
options,
environment,
renderer
)
: renderer.renderToken(tokens, index, options);
};
markdown.renderer.rules.paragraph_close = (
tokens,
index,
options,
environment,
renderer
) => {
const renderEnvironment =
environment as MarkdownRenderEnvironment;
if (renderEnvironment.standaloneImage) {
renderEnvironment.standaloneImage = false;
return "</figure>\n";
}
return defaultParagraphCloseRenderer
? defaultParagraphCloseRenderer(
tokens,
index,
options,
environment,
renderer
)
: renderer.renderToken(tokens, index, options);
};
markdown.renderer.rules.fence = (
tokens,
index,
@@ -102,18 +187,66 @@ markdown.renderer.rules.fence = (
}
if (defaultFenceRenderer) {
return defaultFenceRenderer(
const renderedFence = defaultFenceRenderer(
tokens,
index,
options,
environment,
renderer
);
const languageAttribute = language
? ` lang="${escapeAttribute(language)}"`
: "";
return renderedFence.replace(
/^<pre>/u,
`<pre class="md-fences"${languageAttribute}>`
);
}
return renderFallbackFence(tokens, index, renderer);
};
markdown.renderer.rules.image = (
tokens,
index,
options,
environment,
renderer
) => {
const token = tokens[index];
const renderEnvironment =
environment as MarkdownRenderEnvironment;
const imageSourceMap = renderEnvironment.imageSourceMap;
const source = token?.attrGet("src");
const resolvedSource = source
? imageSourceMap?.get(source)
: undefined;
if (token) {
if (resolvedSource) {
token.attrSet("src", resolvedSource);
}
token.attrJoin("class", "md-document-image");
}
const imageHtml = defaultImageRenderer
? defaultImageRenderer(
tokens,
index,
options,
environment,
renderer
)
: renderer.renderToken(tokens, index, options);
if (renderEnvironment.standaloneImage && token) {
return (
imageHtml +
`<figcaption class="md-document-image-caption">${markdown.utils.escapeHtml(token.content)}</figcaption>`
);
}
return imageHtml;
};
markdown.use(markdownItECharts, {
onError(error, context: MarkdownItEChartsErrorContext) {
const environment = context.environment as
@@ -154,7 +287,7 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
input: ["type", "checked", "disabled", "class"],
li: ["class", "value"],
ol: ["class", "start"],
pre: ["class", "hidden"],
pre: ["class", "hidden", "lang"],
span: ["class", "style"],
td: ["colspan", "rowspan", "style"],
th: ["colspan", "rowspan", "scope", "style"]
@@ -163,6 +296,7 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
allowedSchemesByTag: {
img: ["http", "https", "data"]
},
allowedSchemesAppliedToAttributes: ["src", "cite"],
allowedStyles: {
span: {
height: [lengthStylePattern],
@@ -183,9 +317,19 @@ const safeHtmlOptions: sanitizeHtml.IOptions = {
}
},
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer"
})
a(tagName, attributes) {
const href = attributes.href;
if (href && !isSafeDocumentLink(href)) {
delete attributes.href;
}
return {
tagName,
attribs: {
...attributes,
rel: "noopener noreferrer"
}
};
}
}
};
@@ -193,10 +337,11 @@ export function renderMarkdown(
source: string,
options: RenderMarkdownOptions = {}
): RenderedMarkdown {
const warnings: string[] = [];
const warnings = [...(options.warnings ?? [])];
const parsed = parseFrontMatter(source);
const environment = {
warnings
warnings,
imageSourceMap: options.imageSourceMap
};
const rendered = markdown.render(parsed.content, environment);
const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions);
@@ -217,6 +362,27 @@ export function renderMarkdown(
};
}
function collectImageSources(tokens: readonly Token[], sources: Set<string>) {
for (const token of tokens) {
if (token.type === "image") {
const source = token.attrGet("src");
if (source) {
sources.add(source);
}
}
if (token.children) {
collectImageSources(token.children, sources);
}
}
}
export function extractMarkdownImageSources(source: string) {
const parsed = parseFrontMatter(source);
const sources = new Set<string>();
collectImageSources(markdown.parse(parsed.content, {}), sources);
return [...sources];
}
function parseFrontMatter(source: string) {
try {
return matter(source);

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