12 Commits
Author SHA1 Message Date
SkyJourney 83e6559c2c release: 发布 v0.5.1
新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。

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

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

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

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

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

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

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

验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
2026-07-28 18:01:22 +08:00
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
SkyJourney 06b30d084e fix: 固定桌面端英文可执行文件名 2026-07-27 21:05:47 +08:00
SkyJourney 4d60741f5a feat: 发布 v0.4.0 桌面端 2026-07-27 20:56:39 +08:00
SkyJourney 92a5bfd016 feat: 扩展 ECharts 第二阶段图表支持
新增 tree、treemap、sunburst、graph、sankey、chord、funnel、gauge 和 pictorialBar 系列,并注册对应的 ECharts 按需模块。

完善层级与关系数据校验、节点和边数量限制、默认布局及错误占位规则,同时补充默认 Markdown 与 README 示例。

静态渲染全局关闭 ECharts 动画,修复 Treemap 标签冻结在透明动画帧的问题,并完成快速预览、精确 PDF、容器镜像和 147 项全项目测试验证。
2026-07-27 17:47:28 +08:00
SkyJourney f5a46f79f1 feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
2026-07-27 16:05:14 +08:00
SkyJourney 1db21c80fc docs: 记录显示缩放导致的分页差异 2026-07-27 12:50:28 +08:00
272 changed files with 28887 additions and 1172 deletions
+3
View File
@@ -3,14 +3,17 @@ dist/
coverage/ coverage/
.env .env
.env.local .env.local
.conda_env
.local/ .local/
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
.serena/
tmp/ tmp/
playwright-report/ playwright-report/
test-results/ test-results/
output/ output/
/apps/desktop/out/
*.tsbuildinfo *.tsbuildinfo
+20 -7
View File
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件; - 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式; - 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
- 支持主题清单与 CSS 主题扩展; - 支持主题清单与 CSS 主题扩展;
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeXMermaid - 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeXMermaid 和 ECharts
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
- 使用 Docker Compose 在内网部署; - 使用 Docker Compose 在内网部署;
- 文档只在当前请求中临时处理,不建立文档历史数据库。 - 文档只在当前请求中临时处理,不建立文档历史数据库。
@@ -75,15 +76,19 @@ flowchart LR
- 前端:React、TypeScript、Vite。 - 前端:React、TypeScript、Vite。
- 后端:Node.js、TypeScript、Fastify。 - 后端:Node.js、TypeScript、Fastify。
- 桌面端:Electron、electron-builder、NSIS。
- 应用服务:`packages/application`
- 共享模型:`packages/core` - 共享模型:`packages/core`
- Markdown 渲染:`packages/renderer` - Markdown 渲染:`packages/renderer`
- PDF 引擎:计划使用 Playwright Chromium - 连续预览与分页引擎:`packages/preview-engine`
- ECharts 协议与运行时:`packages/markdown-echarts`
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
- Markdownmarkdown-it 及 `@mdit/plugin-*` 插件。 - Markdownmarkdown-it 及 `@mdit/plugin-*` 插件。
- 元数据:gray-matter。 - 元数据:gray-matter。
- 安全过滤:sanitize-html。 - 安全过滤:sanitize-html。
- 代码高亮:highlight.js。 - 代码高亮:highlight.js。
- 数学公式:KaTeX。 - 数学公式:KaTeX。
- 图表:Mermaid。 - 图表:Mermaid、ECharts
- 校验:Zod。 - 校验:Zod。
- 测试:Vitest。 - 测试:Vitest。
- 部署:Docker、Docker Compose。 - 部署:Docker、Docker Compose。
@@ -92,12 +97,15 @@ flowchart LR
```text ```text
apps/web/ 前端编辑、配置与预览界面 apps/web/ 前端编辑、配置与预览界面
apps/server/ HTTP API、主题读取及未来 PDF 服务 apps/server/ HTTP API、主题读取和 Playwright PDF 服务
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
packages/application/ 共享应用服务、主题注册和图片资源处理
packages/core/ 导出配置和主题清单等共享模型 packages/core/ 导出配置和主题清单等共享模型
packages/renderer/ Markdown 到安全 HTML 的渲染管线 packages/renderer/ Markdown 到安全 HTML 的渲染管线
themes/ 内置主题及未来可安装主题 packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
docker/ 容器化配置 packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
tests/ 跨包和端到端测试 themes/ 内置主题
deploy/ Docker、Compose、Nginx 和部署文档
docs/ 进度、架构和部署文档 docs/ 进度、架构和部署文档
``` ```
@@ -186,6 +194,11 @@ chore: 初始化项目骨架
``` ```
- 不修改或重写已有提交,除非用户明确要求。 - 不修改或重写已有提交,除非用户明确要求。
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
记录本版本更新,不得使用只有版本号的标签说明。
## 9. 当前交接入口 ## 9. 当前交接入口
+147 -101
View File
@@ -1,25 +1,101 @@
# Markdown PDF 导出器 # Markdown PDF 导出器
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。 一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
## 当前状态 当前版本:`v0.5.1`
已经实现: ## v0.5.1 主要能力
- React Markdown 编辑与本地文件读取; - 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
- Fastify 预览接口 以及 3 套正式文档主题和 3 套标书主题
- 共享导出配置模型; - 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
- 动态主题清单、CSS 和静态资源接口 16mm 默认页边距,选择正式主题时自动采用其规范参数
- Markdown、Front Matter、安全过滤和扩展语法渲染核心; - 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid 签发人、密级、主送、抄送、印发信息、封面及落款等文档结构
- iframe 隔离的真实分页预览、双向滚动同步与主题切换; - 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测; 字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存 同时保留 `8px` 左侧内容留白
- 快速预览与精确预览共用的 50%~400% 显示缩放 - 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具 Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。 - “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
Web 端隐藏不适用的“另存为”。
本地相对资源文件处理将在后续阶段实现。 ## Web 与桌面端
| 能力 | Web / Docker | Windows Desktop |
| --- | --- | --- |
| 新建 Markdown | 支持 | 支持 |
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
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 +108,69 @@ npm run dev
默认地址: 默认地址:
- 前端http://localhost:5173 - Webhttp://localhost:5173
- 后端http://localhost:3001 - APIhttp://localhost:3001
- 健康检查:http://localhost:3001/api/health - 健康检查:http://localhost:3001/api/health
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。 桌面端开发:
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。 ```powershell
npm run desktop:dev
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 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
``` ```
````
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。 桌面端目录包和 Windows 安装包:
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。 ```powershell
npm run desktop:package
### Mermaid 长标签 npm run make -w @md-to-pdf/desktop
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
需要自动换行时可以使用 Mermaid Markdown String
````markdown
```mermaid
flowchart LR
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
``` ```
````
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef` 桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,不能
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.1/`
正式安装包命名为 `md-to-pdf-0.5.1-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。
````markdown ## 文档与媒体
```mermaid
flowchart LR
A["较长节点"] --> B["普通节点"]
classDef compact font-size:12px;
class A compact;
```
````
## 本地导入 Typora 默认主题 Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、
KaTeX、Mermaid 和安全的 ECharts YAML 围栏。
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录: 独立一行的 `![标题](地址)` 会生成带标题的图片块。分页引擎将图片、
Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行
计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时
只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免
跨页、重复重排和后续媒体顺序变化。
ECharts 围栏语法、支持系列和安全边界详见
[markdown-echarts 包文档](packages/markdown-echarts/README.md)。
## 主题
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
3 套标书主题,默认使用 Typora Github。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/`
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
刷新本地开发副本:
```powershell ```powershell
npm run theme:import-typora npm run theme:import-typora
``` ```
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。 ## Docker Compose 部署
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
```powershell
npm run theme:import-typora -- --replace
```
旧副本会移动到 `.local\theme-backups`。
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
## Docker Desktop 部署
```powershell ```powershell
docker compose -f deploy\compose.yaml up --build -d docker compose -f deploy\compose.yaml up --build -d
``` ```
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes` 默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见 配置详见 [AIO 容器部署说明](deploy/README.md)。
[AIO 容器部署说明](deploy/README.md)。
## 测试与构建 ## 验证
```powershell ```powershell
npm test npm test
@@ -144,7 +179,18 @@ npm run build
git diff --check git diff --check
``` ```
## 隐私原则 涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、
Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端
验证,并检查 PDF 页面渲染结果。
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 ## 安全与隐私
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
- 不建立 Markdown、图片或 PDF 历史数据库;
- 文档和资源只在当前请求或当前桌面会话中处理;
- Markdown 原始 HTML 不执行,输出经过白名单过滤;
- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML
- 本地资源阻止路径穿越和符号链接越界;
- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时;
- PDF 页面阻止未授权外部网络访问。
当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。
+92
View File
@@ -0,0 +1,92 @@
# Desktop 桌面端
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
安装包。
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
落在屏幕外。Windows 安装包会注册 `.md``.markdown` 文件关联,
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
一个窗口,其他文件可以同时使用多个窗口。
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
重新加载,避免阅读或导出磁盘旧版本。
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
## 开发
从仓库根目录运行:
```powershell
npm run desktop:dev
```
桌面端不启动 Fastify,也不占用本地 HTTP 端口。React 界面通过受限 IPC
调用 `packages/application` 中的 Markdown 渲染和主题服务,PDF 由
Electron 自带 Chromium 生成。Web 与 Docker 继续通过 Fastify HTTP
适配器使用同一套应用服务。
## 构建与打包
```powershell
npm run build -w @md-to-pdf/desktop
npm run desktop:package
npm run make -w @md-to-pdf/desktop
```
`package``make` 会先调用根级 `build:web-runtime`,完整重建
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web
然后才复制 `apps/web/dist`。不得绕过该链路直接调用 electron-builder。
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.5.1/`。该目录被 Git 忽略。公司内部分发以
`md-to-pdf-0.5.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
发布,不提交进 Git 历史。
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
产品名称、窗口标题、安装包名称和开始菜单名称保持为
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
`md-to-pdf.exe`
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。
## 链接与窗口行为
- 当前文档 `#anchor` 在当前预览中定位;
- HTTP/HTTPS 链接交给系统默认浏览器;
- `mailto:` 等系统协议交给操作系统处理;
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
- Markdown 文件会提示在当前窗口或新窗口打开;
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
## 安全边界
- 应用窗口和 PDF 窗口均关闭 Node Integration
- 启用 Context Isolation 和 Chromium Sandbox
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
的窄接口;
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
链接越界;
- 主进程校验 IPC 发送者和分页载荷;
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
- 应用页面使用自定义协议,不使用 `file://`
+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
+77
View File
@@ -0,0 +1,77 @@
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: "../../samples",
to: "samples",
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}`
}
};
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.5.1",
"private": true,
"productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端",
"author": "YIXIONG Tech.ltd",
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload",
"build: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: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": {
"@md-to-pdf/application": "0.4.1",
"@types/node": "^24.10.1",
"electron": "43.2.0",
"electron-builder": "26.15.3",
"esbuild": "^0.25.0",
"typescript": "^5.9.3",
"vitest": "^4.1.10",
"wait-on": "^9.0.1"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { build } from "esbuild";
await build({
entryPoints: ["src/main.ts"],
bundle: true,
platform: "node",
format: "esm",
external: ["electron"],
outfile: "dist/main.js",
banner: {
js: [
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
"const require = __mdToPdfCreateRequire(import.meta.url);"
].join("\n")
}
});
+81
View File
@@ -0,0 +1,81 @@
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_CREATE_NEW_WINDOW,
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_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED,
DESKTOP_SAVE_PDF
} from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
consumePendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
discardPendingMarkdown: () =>
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
onMarkdownOpened: (
listener: (reason: "open" | "reload" | "replace") => void
) => {
const handleOpened = (
_event: Electron.IpcRendererEvent,
reason: "open" | "reload" | "replace"
) => listener(reason);
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
return () =>
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
},
startNewMarkdown: () =>
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
createNewWindow: () =>
ipcRenderer.invoke(DESKTOP_CREATE_NEW_WINDOW),
resolveWindowClose: (close: boolean) =>
ipcRenderer.invoke(DESKTOP_RESOLVE_WINDOW_CLOSE, close),
onWindowCloseRequested: (listener: () => void) => {
const handleCloseRequested = () => listener();
ipcRenderer.on(
DESKTOP_WINDOW_CLOSE_REQUESTED,
handleCloseRequested
);
return () =>
ipcRenderer.removeListener(
DESKTOP_WINDOW_CLOSE_REQUESTED,
handleCloseRequested
);
},
setDocumentDirty: (dirty: boolean) =>
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
openDocumentLink: (href: string) =>
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
saveMarkdown: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
saveMarkdownAs: (fileName: string, markdown: string) =>
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
openThemeDirectory: () =>
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
getThemeCss: (themeId: string) =>
ipcRenderer.invoke(DESKTOP_GET_THEME_CSS, themeId),
generatePdf: (payload: PagedDocumentPayload) =>
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
savePdf: (fileName: string, pdf: Uint8Array) =>
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
});
+54
View File
@@ -0,0 +1,54 @@
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function parseMarkdownRenderRequest(
value: unknown
): MarkdownRenderRequest {
if (!isRecord(value)) {
return {};
}
return {
markdown: value.markdown,
language: value.language,
resources: value.resources
};
}
export function parseThemeId(value: unknown) {
if (
typeof value !== "string" ||
!/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(value)
) {
throw new Error("主题 ID 无效");
}
return value;
}
export function parseThemeResourceUrl(requestUrl: string) {
const url = new URL(requestUrl);
if (url.protocol !== "mdpdf:" || url.host !== "theme") {
return undefined;
}
let segments: string[];
try {
segments = url.pathname
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
} catch {
throw new Error("主题资源地址无效");
}
const [unsafeThemeId, ...assetSegments] = segments;
if (!unsafeThemeId || assetSegments.length === 0) {
throw new Error("主题资源地址无效");
}
return {
themeId: parseThemeId(unsafeThemeId),
assetPath: assetSegments.join("/")
};
}
+46
View File
@@ -0,0 +1,46 @@
export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf:desktop:render-markdown";
export const DESKTOP_OPEN_MARKDOWN =
"md-to-pdf:desktop:open-markdown";
export const DESKTOP_CONSUME_PENDING_MARKDOWN =
"md-to-pdf:desktop:consume-pending-markdown";
export const DESKTOP_DISCARD_PENDING_MARKDOWN =
"md-to-pdf:desktop:discard-pending-markdown";
export const DESKTOP_MARKDOWN_OPENED =
"md-to-pdf:desktop:markdown-opened";
export const DESKTOP_SET_DOCUMENT_DIRTY =
"md-to-pdf:desktop:set-document-dirty";
export const DESKTOP_OPEN_DOCUMENT_LINK =
"md-to-pdf:desktop:open-document-link";
export const DESKTOP_START_NEW_MARKDOWN =
"md-to-pdf:desktop:start-new-markdown";
export const DESKTOP_CREATE_NEW_WINDOW =
"md-to-pdf:desktop:create-new-window";
export const DESKTOP_WINDOW_CLOSE_REQUESTED =
"md-to-pdf:desktop:window-close-requested";
export const DESKTOP_RESOLVE_WINDOW_CLOSE =
"md-to-pdf:desktop:resolve-window-close";
export const DESKTOP_SAVE_MARKDOWN =
"md-to-pdf:desktop:save-markdown";
export const DESKTOP_SAVE_MARKDOWN_AS =
"md-to-pdf:desktop:save-markdown-as";
export const DESKTOP_OPEN_THEME_DIRECTORY =
"md-to-pdf:desktop:open-theme-directory";
export const DESKTOP_REFRESH_THEMES =
"md-to-pdf:desktop:refresh-themes";
export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS =
"md-to-pdf:desktop:get-theme-css";
export const DESKTOP_GENERATE_PDF =
"md-to-pdf:desktop:generate-pdf";
export const DESKTOP_SAVE_PDF =
"md-to-pdf:desktop:save-pdf";
export const PDF_RUNTIME_READY =
"md-to-pdf:desktop-pdf:ready";
export const PDF_RUNTIME_RENDER =
"md-to-pdf:desktop-pdf:render";
export const PDF_RUNTIME_COMPLETE =
"md-to-pdf:desktop-pdf:complete";
export const PDF_RUNTIME_FAIL =
"md-to-pdf:desktop-pdf:fail";
@@ -0,0 +1,966 @@
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_CREATE_NEW_WINDOW,
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_RESOLVE_WINDOW_CLOSE,
DESKTOP_SAVE_MARKDOWN,
DESKTOP_SAVE_MARKDOWN_AS,
DESKTOP_SAVE_PDF,
DESKTOP_SET_DOCUMENT_DIRTY,
DESKTOP_START_NEW_MARKDOWN,
DESKTOP_WINDOW_CLOSE_REQUESTED
} 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";
import { getWindowCloseDecision } from "./window-close-policy.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;
closeRequestPending: 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,
startBlank = false
) {
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,
closeRequestPending: 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();
});
const targetUrl = new URL(this.#applicationUrl);
if (startBlank) {
targetUrl.searchParams.set("new", "1");
}
await window.loadURL(targetUrl.href);
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) => {
const decision = getWindowCloseDecision(session);
if (decision === "allow") {
return;
}
event.preventDefault();
if (decision === "request") {
session.closeRequestPending = true;
session.window.webContents.send(
DESKTOP_WINDOW_CLOSE_REQUESTED
);
}
});
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 #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_CREATE_NEW_WINDOW, async (event) => {
this.#getSession(event);
await this.#createWindow(undefined, true);
});
ipcMain.handle(
DESKTOP_RESOLVE_WINDOW_CLOSE,
async (event, unsafeClose: unknown) => {
if (typeof unsafeClose !== "boolean") {
throw new Error("窗口关闭决策无效");
}
const session = this.#getSession(event);
session.closeRequestPending = false;
session.suppressFocusCheckUntil =
Date.now() + FOCUS_CHECK_SUPPRESSION_MS;
if (unsafeClose && !session.window.isDestroyed()) {
session.allowClose = true;
session.window.close();
}
}
);
ipcMain.handle(
DESKTOP_SET_DOCUMENT_DIRTY,
async (event, unsafeDirty: unknown) => {
if (typeof unsafeDirty !== "boolean") {
throw new Error("文档修改状态无效");
}
this.#getSession(event).dirty = unsafeDirty;
}
);
const saveMarkdown = async (
event: IpcMainInvokeEvent,
unsafeFileName: unknown,
unsafeMarkdown: unknown,
forceSaveAs: boolean
) => {
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 (forceSaveAs || !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: forceSaveAs
? "另存为 Markdown"
: "保存 Markdown",
defaultPath:
forceSaveAs && session.currentFilePath
? session.currentFilePath
: 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_SAVE_MARKDOWN,
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
saveMarkdown(
event,
unsafeFileName,
unsafeMarkdown,
false
)
);
ipcMain.handle(
DESKTOP_SAVE_MARKDOWN_AS,
(event, unsafeFileName: unknown, unsafeMarkdown: unknown) =>
saveMarkdown(
event,
unsafeFileName,
unsafeMarkdown,
true
)
);
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)
};
}
+342
View File
@@ -0,0 +1,342 @@
import {
BrowserWindow,
ipcMain,
type IpcMainEvent
} from "electron";
import { performance } from "node:perf_hooks";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
import {
createElectronPdfPrintOptions,
isAllowedPdfRuntimeUrl
} from "./pdf-contract.js";
export const DESKTOP_PDF_PARTITION =
"md-to-pdf-desktop-pdf";
export interface ElectronPdfGenerationResult {
pdf: Buffer;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
timings: {
documentRenderMs: number;
pdfPrintMs: number;
totalMs: number;
};
}
export interface ElectronPdfGeneratorOptions {
renderUrl: string;
preloadPath: string;
timeoutMs?: number;
}
interface PendingRender {
requestId: number;
resolve: (result: PagedDocumentRenderResult) => void;
reject: (reason: Error) => void;
}
async function waitWithTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(message)),
timeoutMs
);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export class ElectronPdfGenerator {
private readonly renderUrl: string;
private readonly preloadPath: string;
private readonly timeoutMs: number;
private windowPromise: Promise<BrowserWindow> | undefined;
private renderWindow: BrowserWindow | undefined;
private pendingRender: PendingRender | undefined;
private nextRequestId = 1;
private queue = Promise.resolve();
private closed = false;
constructor(options: ElectronPdfGeneratorOptions) {
this.renderUrl = options.renderUrl;
this.preloadPath = options.preloadPath;
this.timeoutMs = options.timeoutMs ?? 60_000;
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
}
generate(payload: PagedDocumentPayload) {
if (this.closed) {
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
}
const operation = this.queue.then(() => this.generateNow(payload));
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
async close() {
this.closed = true;
ipcMain.removeListener(
PDF_RUNTIME_COMPLETE,
this.handleRenderComplete
);
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
this.pendingRender = undefined;
const windowPromise = this.windowPromise;
this.windowPromise = undefined;
this.renderWindow = undefined;
if (!windowPromise) {
return;
}
try {
const window = await windowPromise;
if (!window.isDestroyed()) {
window.destroy();
}
} catch {
// 创建失败时没有需要关闭的窗口。
}
}
private async generateNow(payload: PagedDocumentPayload) {
const startedAt = performance.now();
const window = await this.getWindow();
const documentRenderStartedAt = performance.now();
const renderResult = await this.requestRender(window, payload);
const documentRenderMs =
performance.now() - documentRenderStartedAt;
await window.webContents.executeJavaScript(
"new Promise((resolve) => setTimeout(resolve, 0))"
);
const pdfPrintStartedAt = performance.now();
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, 50));
let pdf: Buffer;
try {
pdf = await waitWithTimeout(
window.webContents.printToPDF(
createElectronPdfPrintOptions(payload, renderResult)
),
this.timeoutMs,
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
);
} catch (error) {
if (!window.isDestroyed()) {
window.destroy();
}
throw error;
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
return {
pdf,
pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors,
timings: {
documentRenderMs,
pdfPrintMs,
totalMs: performance.now() - startedAt
}
} satisfies ElectronPdfGenerationResult;
}
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
this.windowPromise = undefined;
throw error;
});
}
return this.windowPromise;
}
private async createWindow() {
const window = new BrowserWindow({
show: false,
focusable: false,
opacity: 0,
skipTaskbar: true,
x: -32_000,
y: -32_000,
width: 1280,
height: 900,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: DESKTOP_PDF_PARTITION,
preload: this.preloadPath
}
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
const target = new URL(url);
const renderTarget = new URL(this.renderUrl);
if (
target.origin !== renderTarget.origin ||
target.pathname !== renderTarget.pathname
) {
event.preventDefault();
}
});
window.webContents.on("destroyed", () => {
if (this.windowPromise) {
this.windowPromise = undefined;
}
this.renderWindow = undefined;
this.pendingRender?.reject(
new Error("桌面 PDF 渲染窗口已关闭")
);
this.pendingRender = undefined;
});
window.webContents.session.webRequest.onBeforeRequest(
{ urls: ["*://*/*"] },
(details, callback) => {
callback({
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
});
}
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("桌面 PDF 分页运行时启动超时"));
}, this.timeoutMs);
const handleReady = (event: IpcMainEvent) => {
if (event.sender !== window.webContents) {
return;
}
cleanup();
resolve();
};
const cleanup = () => {
clearTimeout(timeout);
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
};
ipcMain.on(PDF_RUNTIME_READY, handleReady);
});
try {
await Promise.all([window.loadURL(this.renderUrl), ready]);
window.webContents.setZoomFactor(1);
this.renderWindow = window;
return window;
} catch (error) {
window.destroy();
throw error;
}
}
private requestRender(
window: BrowserWindow,
payload: PagedDocumentPayload
) {
if (this.pendingRender) {
throw new Error("桌面 PDF 渲染器状态异常");
}
const requestId = this.nextRequestId++;
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingRender?.requestId === requestId) {
this.pendingRender = undefined;
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
if (!window.isDestroyed()) {
window.destroy();
}
}
}, this.timeoutMs);
this.pendingRender = {
requestId,
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
}
};
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
});
}
private readonly handleRenderComplete = (
event: IpcMainEvent,
requestId: number,
result: PagedDocumentRenderResult
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.resolve(result);
};
private readonly handleRenderFailure = (
event: IpcMainEvent,
requestId: number,
message: string
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.reject(
new Error(
typeof message === "string" ? message : "桌面 PDF 分页失败"
)
);
};
}
+286
View File
@@ -0,0 +1,286 @@
import {
app,
Menu,
net,
protocol,
session
} from "electron";
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 {
DesktopApplicationController
} from "./desktop-application-controller.js";
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
import {
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([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
codeCache: true,
corsEnabled: true
}
}
]);
function getCommandLineValue(name: string) {
const prefix = `--${name}=`;
return process.argv
.find((argument) => argument.startsWith(prefix))
?.slice(prefix.length);
}
function getWebRoot() {
return app.isPackaged
? path.join(process.resourcesPath, "dist")
: path.resolve(currentDirectory, "../../web/dist");
}
function getApplicationIcon() {
if (app.isPackaged) {
return path.join(
process.resourcesPath,
process.platform === "darwin" ? "app.icns" : "app.ico"
);
}
const projectRoot = path.resolve(currentDirectory, "../../..");
return path.join(
projectRoot,
"logos",
"desktop",
process.platform === "darwin" ? "macos" : "windows",
process.platform === "darwin" ? "app.icns" : "app.ico"
);
}
function encodeThemeAssetPath(assetPath: string) {
return assetPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
function dispatchExternalMarkdown(filePath: string) {
if (!applicationController) {
pendingExternalMarkdownPath = filePath;
return;
}
void applicationController.openExternalMarkdown(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
}
function getDesktopThemeDirectory() {
return app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.resolve(currentDirectory, "../../..", ".local", "themes");
}
function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../..");
return createApplicationService({
bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes")
: path.join(projectRoot, "themes"),
localRoot: getDesktopThemeDirectory(),
createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
onWarning: (message) => console.warn(message)
});
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function registerApplicationProtocol(
applicationService: ApplicationService
) {
const webRoot = getWebRoot();
const handleRequest = async (request: Request) => {
const url = new URL(request.url);
if (url.host === THEME_HOST) {
try {
const resource = parseThemeResourceUrl(request.url);
if (!resource) {
return new Response("Not found", { status: 404 });
}
const asset = await applicationService.getThemeAsset(
resource.themeId,
resource.assetPath
);
if (!asset) {
return new Response("Not found", { status: 404 });
}
return new Response(asset.content, {
headers: {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=300",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
}
});
} catch {
return new Response("Not found", { status: 404 });
}
}
if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 });
}
let relativePath: string;
try {
relativePath =
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
"index.html";
} catch {
return new Response("Bad request", { status: 400 });
}
const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 });
}
try {
await access(target);
} catch {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(target).href);
};
await Promise.all([
protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_PDF_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest)
]);
}
const hasSingleInstanceLock = app.requestSingleInstanceLock();
let windowStateReadyToQuit = false;
if (!hasSingleInstanceLock) {
app.quit();
} else {
app.on("second-instance", (_event, arguments_, workingDirectory) => {
const filePath = findMarkdownFileArgument(
arguments_,
workingDirectory
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("open-file", (event, filePath) => {
event.preventDefault();
if (isMarkdownFilePath(filePath)) {
dispatchExternalMarkdown(filePath);
}
});
app.on("activate", () => {
if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
}
});
app.on("before-quit", (event) => {
if (windowStateReadyToQuit || !applicationController) {
return;
}
event.preventDefault();
void applicationController.flushWindowState().finally(() => {
windowStateReadyToQuit = true;
app.quit();
setTimeout(() => {
windowStateReadyToQuit = false;
}, 1_000);
});
});
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 developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
applicationController = new DesktopApplicationController({
applicationService,
applicationUrl,
renderUrl: new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href,
preloadPath: path.join(
currentDirectory,
"app-preload.cjs"
),
pdfPreloadPath: path.join(
currentDirectory,
"pdf-preload.cjs"
),
iconPath: getApplicationIcon(),
themeDirectory,
windowStatePath: path.join(
app.getPath("userData"),
"window-state.json"
)
});
await applicationController.initialize();
const startupMarkdownPath = pendingExternalMarkdownPath;
pendingExternalMarkdownPath = undefined;
await applicationController.createWindow(startupMarkdownPath);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
+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)
};
}
+124
View File
@@ -0,0 +1,124 @@
import {
exportConfigSchema,
themeFeatureSchema,
type PagedDocumentPayload,
type PagedDocumentRenderResult
} from "@md-to-pdf/core";
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(
record: Record<string, unknown>,
key: string,
maximum: number
) {
const value = record[key];
if (typeof value !== "string" || value.length > maximum) {
throw new Error(`${key} 无效`);
}
return value;
}
function readStringArray(value: unknown, key: string, maximum: number) {
if (
!Array.isArray(value) ||
value.length > maximum ||
value.some((item) => typeof item !== "string")
) {
throw new Error(`${key} 无效`);
}
return value as string[];
}
export function parsePagedDocumentPayload(
value: unknown
): PagedDocumentPayload {
if (!isRecord(value) || !isRecord(value.metadata)) {
throw new Error("PDF 分页载荷无效");
}
const metadata = value.metadata;
const features = readStringArray(value.features, "features", 20);
const parsedFeatures = features.map((feature) =>
themeFeatureSchema.parse(feature)
);
return {
articleHtml: readString(
value,
"articleHtml",
MAX_ARTICLE_HTML_LENGTH
),
fileName: readString(value, "fileName", 500),
metadata: {
title: readString(metadata, "title", 500),
author: readString(metadata, "author", 500),
subject: readString(metadata, "subject", 1_000),
keywords: readStringArray(
metadata.keywords,
"metadata.keywords",
100
),
language: readString(metadata, "language", 50)
},
features: parsedFeatures,
themeCss: readString(
value,
"themeCss",
MAX_THEME_CSS_LENGTH
),
exportConfig: exportConfigSchema.parse(value.exportConfig)
};
}
export interface ElectronPdfPrintOptions {
displayHeaderFooter: false;
margins: {
top: number;
right: number;
bottom: number;
left: number;
};
pageRanges: string;
preferCSSPageSize: true;
printBackground: boolean;
scale: number;
}
export function createElectronPdfPrintOptions(
payload: PagedDocumentPayload,
renderResult: PagedDocumentRenderResult
): ElectronPdfPrintOptions {
return {
displayHeaderFooter: false,
margins: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
preferCSSPageSize: true,
printBackground: payload.exportConfig.print.background,
scale: payload.exportConfig.print.scale
};
}
export function isAllowedPdfRuntimeUrl(
requestUrl: string,
renderUrl: string
) {
const requested = new URL(requestUrl);
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true;
}
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
return true;
}
return requested.origin === new URL(renderUrl).origin;
}
+41
View File
@@ -0,0 +1,41 @@
import {
contextBridge,
ipcRenderer,
type IpcRendererEvent
} from "electron";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
type RenderListener = (
requestId: number,
payload: PagedDocumentPayload
) => Promise<void> | void;
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
onRender: (listener: RenderListener) => {
const handler = (
_event: IpcRendererEvent,
requestId: number,
payload: PagedDocumentPayload
) => {
void listener(requestId, payload);
};
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
},
complete: (
requestId: number,
result: PagedDocumentRenderResult
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
fail: (requestId: number, message: string) =>
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
});
+16
View File
@@ -0,0 +1,16 @@
export interface WindowCloseState {
dirty: boolean;
allowClose: boolean;
closeRequestPending: boolean;
}
export type WindowCloseDecision = "allow" | "request" | "wait";
export function getWindowCloseDecision(
state: WindowCloseState
): WindowCloseDecision {
if (!state.dirty || state.allowClose) {
return "allow";
}
return state.closeRequestPending ? "wait" : "request";
}
+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 });
}
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
createPagedDocumentPayload,
defaultExportConfig
} from "@md-to-pdf/core";
import {
createElectronPdfPrintOptions,
isAllowedPdfRuntimeUrl,
parsePagedDocumentPayload
} from "../src/pdf-contract.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseThemeResourceUrl
} from "../src/application-contract.js";
const payload = createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"><h1>测试</h1></article>',
bodyHtml: "<h1>测试</h1>",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: ["mermaid"],
warnings: []
},
fileName: "测试.md",
themeCss: "#write { color: black; }",
exportConfig: defaultExportConfig
});
describe("桌面 PDF 载荷", () => {
it("校验并保留合法的共享分页载荷", () => {
expect(parsePagedDocumentPayload(payload)).toEqual(payload);
});
it("拒绝无效功能标识", () => {
expect(() =>
parsePagedDocumentPayload({
...payload,
features: ["remote-script"]
})
).toThrow();
});
it("映射为与 Playwright 对齐的 Electron 打印参数", () => {
expect(
createElectronPdfPrintOptions(payload, {
pageCount: 3,
echartsErrors: [],
mermaidErrors: [],
timings: {
setupMs: 0,
echartsMs: 0,
echartsFitMs: 0,
mermaidMs: 0,
mermaidFitMs: 0,
mermaidConversionMs: 0,
resourceWaitMs: 0,
paginationMs: 0,
finalizeMs: 0,
totalMs: 0
}
})
).toEqual({
displayHeaderFooter: false,
margins: { top: 0, right: 0, bottom: 0, left: 0 },
pageRanges: "1-3",
preferCSSPageSize: true,
printBackground: true,
scale: 1
});
});
});
describe("桌面 PDF 网络边界", () => {
const renderUrl =
"http://localhost:5173/preview-frame.html?target=pdf";
it("允许同源和内嵌资源", () => {
expect(
isAllowedPdfRuntimeUrl(
"http://localhost:5173/assets/runtime.js",
renderUrl
)
).toBe(true);
expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl))
.toBe(true);
expect(
isAllowedPdfRuntimeUrl(
"mdpdf://theme/typora-like/font.woff2",
renderUrl
)
).toBe(true);
});
it("拒绝外部网络资源", () => {
expect(
isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl)
).toBe(false);
});
});
describe("桌面应用服务 IPC 载荷", () => {
it("只提取 Markdown 渲染所需字段", () => {
expect(
parseMarkdownRenderRequest({
markdown: "# 测试",
language: "zh-CN",
ignored: true
})
).toEqual({
markdown: "# 测试",
language: "zh-CN"
});
});
it("校验主题 ID 与主题资源地址", () => {
expect(parseThemeId("typora-like")).toBe("typora-like");
expect(
parseThemeResourceUrl(
"mdpdf://theme/typora-like/fonts/test.woff2"
)
).toEqual({
themeId: "typora-like",
assetPath: "fonts/test.woff2"
});
expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效");
expect(
parseThemeResourceUrl("mdpdf://bundle/index.html")
).toBeUndefined();
});
});
+79
View File
@@ -0,0 +1,79 @@
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"
);
});
it("将内置主题示例作为独立资源复制到发行目录", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
expect(builderConfig).toContain('from: "../../samples"');
expect(builderConfig).toContain('to: "samples"');
});
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
const dockerfile = readFileSync(
`${projectRoot}/deploy/Dockerfile`,
"utf8"
);
const samplesBuildCopyIndex = dockerfile.indexOf(
"COPY samples ./samples"
);
const webBuildIndex = dockerfile.indexOf(
"npm run build -w @md-to-pdf/web"
);
expect(samplesBuildCopyIndex).toBeGreaterThan(-1);
expect(webBuildIndex).toBeGreaterThan(samplesBuildCopyIndex);
expect(dockerfile).toContain(
"COPY --chown=pwuser:pwuser --from=build /app/samples ./samples"
);
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { getWindowCloseDecision } from "../src/window-close-policy.js";
describe("桌面窗口关闭策略", () => {
it("干净文档或已授权窗口可以直接关闭", () => {
expect(
getWindowCloseDecision({
dirty: false,
allowClose: false,
closeRequestPending: false
})
).toBe("allow");
expect(
getWindowCloseDecision({
dirty: true,
allowClose: true,
closeRequestPending: false
})
).toBe("allow");
});
it("未保存文档只发出一次关闭确认请求", () => {
expect(
getWindowCloseDecision({
dirty: true,
allowClose: false,
closeRequestPending: false
})
).toBe("request");
expect(
getWindowCloseDecision({
dirty: true,
allowClose: false,
closeRequestPending: true
})
).toBe("wait");
});
});
+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 });
}
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"types": [
"node"
],
"noEmit": true
},
"include": [
"src",
"tests"
]
}
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false" "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
}, },
"dependencies": { "dependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0", "@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
+44 -114
View File
@@ -10,10 +10,11 @@ import {
supportedPaperFormats supportedPaperFormats
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { import {
MarkdownDocumentParseError, ApplicationRequestError,
RENDERER_VERSION, createApplicationService,
renderMarkdown type ApplicationService
} from "@md-to-pdf/renderer"; } from "@md-to-pdf/application";
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
import { import {
createPdfGenerator, createPdfGenerator,
PdfEngineClosedError, PdfEngineClosedError,
@@ -21,11 +22,11 @@ import {
PdfRenderTimeoutError, PdfRenderTimeoutError,
type PdfGenerator type PdfGenerator
} from "./pdf-engine.js"; } from "./pdf-engine.js";
import { createThemeRegistry } from "./theme-registry.js";
interface RenderRequestBody { interface RenderRequestBody {
markdown?: unknown; markdown?: unknown;
language?: unknown; language?: unknown;
resources?: unknown;
} }
interface PdfRequestBody extends RenderRequestBody { interface PdfRequestBody extends RenderRequestBody {
@@ -34,66 +35,6 @@ interface PdfRequestBody extends RenderRequestBody {
} }
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
const maximumMarkdownLength = 1_500_000;
function validateMarkdownRequest(
body: RenderRequestBody | undefined
):
| { markdown: string; language?: string }
| { statusCode: 400 | 413; error: string; message: string } {
const { markdown, language } = body ?? {};
if (typeof markdown !== "string") {
return {
statusCode: 400,
error: "INVALID_MARKDOWN",
message: "markdown 必须是字符串"
};
}
if (markdown.length > maximumMarkdownLength) {
return {
statusCode: 413,
error: "MARKDOWN_TOO_LARGE",
message: "Markdown 内容不能超过 1.5 MB"
};
}
if (language !== undefined && typeof language !== "string") {
return {
statusCode: 400,
error: "INVALID_LANGUAGE",
message: "language 必须是字符串"
};
}
return {
markdown,
...(typeof language === "string" ? { language } : {})
};
}
function renderMarkdownRequest(
markdown: string,
language: string | undefined
) {
try {
return {
success: true as const,
document: renderMarkdown(markdown, {
...(language ? { language } : {})
})
};
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
return {
success: false as const,
error: error.code,
message: error.message
};
}
throw error;
}
}
function encodeRfc5987(value: string) { function encodeRfc5987(value: string) {
return encodeURIComponent(value).replace( return encodeURIComponent(value).replace(
/[!'()*]/g, /[!'()*]/g,
@@ -127,6 +68,7 @@ export interface BuildAppOptions {
logger?: boolean; logger?: boolean;
pdfGenerator?: PdfGenerator; pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean; prewarmPdfBrowser?: boolean;
applicationService?: ApplicationService;
} }
function milliseconds(value: number) { function milliseconds(value: number) {
@@ -149,6 +91,8 @@ function createPdfServerTiming(
`navigation;dur=${milliseconds(timings.navigationMs)}`, `navigation;dur=${milliseconds(timings.navigationMs)}`,
`document-render;dur=${milliseconds(timings.documentRenderMs)}`, `document-render;dur=${milliseconds(timings.documentRenderMs)}`,
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`, `runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
`echarts;dur=${milliseconds(timings.echartsMs)}`,
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
`mermaid;dur=${milliseconds(timings.mermaidMs)}`, `mermaid;dur=${milliseconds(timings.mermaidMs)}`,
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`, `mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
`mermaid-conversion;dur=${milliseconds( `mermaid-conversion;dur=${milliseconds(
@@ -165,9 +109,11 @@ function createPdfServerTiming(
export function buildApp(options: BuildAppOptions = {}) { export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({ const app = Fastify({
logger: options.logger ?? true, logger: options.logger ?? true,
bodyLimit: 2 * 1024 * 1024 bodyLimit: 24 * 1024 * 1024
}); });
const themes = createThemeRegistry({ const applicationService =
options.applicationService ??
createApplicationService({
bundledRoot: resolve(projectRoot, "themes"), bundledRoot: resolve(projectRoot, "themes"),
localRoot: localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
@@ -211,23 +157,16 @@ export function buildApp(options: BuildAppOptions = {}) {
planned: [] planned: []
})); }));
app.get("/api/themes", async () => ({ app.get("/api/themes", async () =>
themes: (await themes.list()).map(({ manifest, source }) => ({ applicationService.listThemes()
id: manifest.id, );
name: manifest.name,
version: manifest.version,
description: manifest.description,
bundled: manifest.bundled,
source
}))
}));
app.get<{ Params: { themeId: string } }>( app.get<{ Params: { themeId: string } }>(
"/api/themes/:themeId/css", "/api/themes/:themeId/css",
async (request, reply) => { async (request, reply) => {
const { themeId } = request.params; const { themeId } = request.params;
const css = await themes.getCss(themeId); const css = await applicationService.getThemeCss(themeId);
if (css === undefined) { if (css === undefined) {
return reply.code(404).send({ return reply.code(404).send({
error: "THEME_NOT_FOUND", error: "THEME_NOT_FOUND",
@@ -246,7 +185,7 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/themes/:themeId/assets/*", "/api/themes/:themeId/assets/*",
async (request, reply) => { async (request, reply) => {
try { try {
const asset = await themes.getAsset( const asset = await applicationService.getThemeAsset(
request.params.themeId, request.params.themeId,
request.params["*"] request.params["*"]
); );
@@ -275,25 +214,17 @@ export function buildApp(options: BuildAppOptions = {}) {
app.post<{ Body: RenderRequestBody }>( app.post<{ Body: RenderRequestBody }>(
"/api/render", "/api/render",
async (request, reply) => { async (request, reply) => {
const validated = validateMarkdownRequest(request.body); try {
if ("statusCode" in validated) { return await applicationService.render(request.body ?? {});
return reply.code(validated.statusCode).send({ } catch (error) {
error: validated.error, if (error instanceof ApplicationRequestError) {
message: validated.message return reply.code(error.statusCode).send({
error: error.code,
message: error.message
}); });
} }
throw error;
const rendered = renderMarkdownRequest(
validated.markdown,
validated.language
);
if (!rendered.success) {
return reply.code(400).send({
error: rendered.error,
message: rendered.message
});
} }
return rendered.document;
} }
); );
@@ -301,14 +232,6 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/pdf", "/api/pdf",
async (request, reply) => { async (request, reply) => {
const requestStartedAt = performance.now(); const requestStartedAt = performance.now();
const validated = validateMarkdownRequest(request.body);
if ("statusCode" in validated) {
return reply.code(validated.statusCode).send({
error: validated.error,
message: validated.message
});
}
const parsedConfig = exportConfigSchema.safeParse( const parsedConfig = exportConfigSchema.safeParse(
request.body?.exportConfig request.body?.exportConfig
); );
@@ -331,7 +254,9 @@ export function buildApp(options: BuildAppOptions = {}) {
} }
const themeStartedAt = performance.now(); const themeStartedAt = performance.now();
const themeCss = await themes.getCss(parsedConfig.data.themeId); const themeCss = await applicationService.getThemeCss(
parsedConfig.data.themeId
);
const themeMs = performance.now() - themeStartedAt; const themeMs = performance.now() - themeStartedAt;
if (themeCss === undefined) { if (themeCss === undefined) {
return reply.code(404).send({ return reply.code(404).send({
@@ -341,18 +266,19 @@ export function buildApp(options: BuildAppOptions = {}) {
} }
const markdownStartedAt = performance.now(); const markdownStartedAt = performance.now();
const renderedResult = renderMarkdownRequest( let rendered;
validated.markdown, try {
validated.language rendered = await applicationService.render(request.body ?? {});
); } catch (error) {
const markdownMs = performance.now() - markdownStartedAt; if (error instanceof ApplicationRequestError) {
if (!renderedResult.success) { return reply.code(error.statusCode).send({
return reply.code(400).send({ error: error.code,
error: renderedResult.error, message: error.message
message: renderedResult.message
}); });
} }
const rendered = renderedResult.document; throw error;
}
const markdownMs = performance.now() - markdownStartedAt;
try { try {
const generated = await pdfGenerator.generate( const generated = await pdfGenerator.generate(
@@ -397,6 +323,10 @@ export function buildApp(options: BuildAppOptions = {}) {
.header("cache-control", "no-store") .header("cache-control", "no-store")
.header("server-timing", serverTiming) .header("server-timing", serverTiming)
.header("x-pdf-page-count", String(generated.pageCount)) .header("x-pdf-page-count", String(generated.pageCount))
.header(
"x-echarts-error-count",
String(generated.echartsErrors.length)
)
.header( .header(
"x-mermaid-error-count", "x-mermaid-error-count",
String(generated.mermaidErrors.length) String(generated.mermaidErrors.length)
+8
View File
@@ -10,6 +10,7 @@ export type PdfRenderPayload = PagedDocumentPayload;
export interface PdfGenerationResult { export interface PdfGenerationResult {
pdf: Buffer; pdf: Buffer;
pageCount: number; pageCount: number;
echartsErrors: string[];
mermaidErrors: string[]; mermaidErrors: string[];
timings: PdfGenerationTimings; timings: PdfGenerationTimings;
} }
@@ -21,6 +22,8 @@ export interface PdfGenerationTimings {
navigationMs: number; navigationMs: number;
documentRenderMs: number; documentRenderMs: number;
runtimeSetupMs: number; runtimeSetupMs: number;
echartsMs: number;
echartsFitMs: number;
mermaidMs: number; mermaidMs: number;
mermaidFitMs: number; mermaidFitMs: number;
mermaidConversionMs: number; mermaidConversionMs: number;
@@ -59,6 +62,8 @@ interface PdfDocumentResult
| "navigationMs" | "navigationMs"
| "documentRenderMs" | "documentRenderMs"
| "runtimeSetupMs" | "runtimeSetupMs"
| "echartsMs"
| "echartsFitMs"
| "mermaidMs" | "mermaidMs"
| "mermaidFitMs" | "mermaidFitMs"
| "mermaidConversionMs" | "mermaidConversionMs"
@@ -469,11 +474,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
return { return {
pdf, pdf,
pageCount: renderResult.pageCount, pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors, mermaidErrors: renderResult.mermaidErrors,
timings: { timings: {
navigationMs, navigationMs,
documentRenderMs, documentRenderMs,
runtimeSetupMs: renderResult.timings.setupMs, runtimeSetupMs: renderResult.timings.setupMs,
echartsMs: renderResult.timings.echartsMs,
echartsFitMs: renderResult.timings.echartsFitMs,
mermaidMs: renderResult.timings.mermaidMs, mermaidMs: renderResult.timings.mermaidMs,
mermaidFitMs: renderResult.timings.mermaidFitMs, mermaidFitMs: renderResult.timings.mermaidFitMs,
mermaidConversionMs: mermaidConversionMs:
+8 -1
View File
@@ -8,7 +8,7 @@ import {
createPdfContentDisposition, createPdfContentDisposition,
createPdfFileName createPdfFileName
} from "../src/app.js"; } from "../src/app.js";
import { createThemeRegistry } from "../src/theme-registry.js"; import { createThemeRegistry } from "@md-to-pdf/application";
import { import {
PdfEngineOverloadedError, PdfEngineOverloadedError,
type PdfGenerator type PdfGenerator
@@ -369,6 +369,7 @@ describe("PDF API", () => {
const generate = vi.fn(async () => ({ const generate = vi.fn(async () => ({
pdf: Buffer.from("%PDF-test"), pdf: Buffer.from("%PDF-test"),
pageCount: 3, pageCount: 3,
echartsErrors: ["图表 2"],
mermaidErrors: ["图表 1"], mermaidErrors: ["图表 1"],
timings: { timings: {
queueMs: 0, queueMs: 0,
@@ -377,6 +378,8 @@ describe("PDF API", () => {
navigationMs: 3, navigationMs: 3,
documentRenderMs: 4, documentRenderMs: 4,
runtimeSetupMs: 0.1, runtimeSetupMs: 0.1,
echartsMs: 0.15,
echartsFitMs: 0.18,
mermaidMs: 0.2, mermaidMs: 0.2,
mermaidFitMs: 0.3, mermaidFitMs: 0.3,
mermaidConversionMs: 0.35, mermaidConversionMs: 0.35,
@@ -415,6 +418,7 @@ describe("PDF API", () => {
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf" "filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
); );
expect(response.headers["x-pdf-page-count"]).toBe("3"); expect(response.headers["x-pdf-page-count"]).toBe("3");
expect(response.headers["x-echarts-error-count"]).toBe("1");
expect(response.headers["x-mermaid-error-count"]).toBe("1"); expect(response.headers["x-mermaid-error-count"]).toBe("1");
expect(response.headers["server-timing"]).toContain( expect(response.headers["server-timing"]).toContain(
"document-render;dur=4.0" "document-render;dur=4.0"
@@ -422,6 +426,9 @@ describe("PDF API", () => {
expect(response.headers["server-timing"]).toContain( expect(response.headers["server-timing"]).toContain(
"pdf-print;dur=5.0" "pdf-print;dur=5.0"
); );
expect(response.headers["server-timing"]).toContain(
"echarts;dur=0.1"
);
expect(response.headers["server-timing"]).toContain( expect(response.headers["server-timing"]).toContain(
"mermaid;dur=0.2" "mermaid;dur=0.2"
); );
+5
View File
@@ -47,9 +47,12 @@ function fakeBrowser(
waitForFunction: vi.fn(async () => undefined), waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => ({ evaluate: vi.fn(async () => ({
pageCount: 2, pageCount: 2,
echartsErrors: [],
mermaidErrors: [], mermaidErrors: [],
timings: { timings: {
setupMs: 1, setupMs: 1,
echartsMs: 1.5,
echartsFitMs: 1.75,
mermaidMs: 2, mermaidMs: 2,
mermaidFitMs: 3, mermaidFitMs: 3,
mermaidConversionMs: 3.5, mermaidConversionMs: 3.5,
@@ -191,6 +194,8 @@ describe("Playwright PDF 生成器", () => {
navigationMs: expect.any(Number), navigationMs: expect.any(Number),
documentRenderMs: expect.any(Number), documentRenderMs: expect.any(Number),
runtimeSetupMs: 1, runtimeSetupMs: 1,
echartsMs: 1.5,
echartsFitMs: 1.75,
mermaidMs: 2, mermaidMs: 2,
mermaidFitMs: 3, mermaidFitMs: 3,
mermaidConversionMs: 3.5, mermaidConversionMs: 3.5,
+12
View File
@@ -2,6 +2,18 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link
rel="apple-touch-icon"
href="/apple-touch-icon.png"
sizes="180x180"
/>
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#1f2937" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:; manifest-src 'self';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
+2 -3
View File
@@ -11,11 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2", "@md-to-pdf/markdown-echarts": "0.1.0",
"@md-to-pdf/preview-engine": "0.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"mermaid": "11.16.0",
"pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200", "pdfjs-dist": "6.1.200",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';" content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: mdpdf:; connect-src 'self';"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style> <style>
+1134 -122
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from "react";
type DocumentActionDialogProps =
| {
type: "new-location";
busy?: false;
onCurrentWindow: () => void;
onNewWindow: () => void;
onCancel: () => void;
}
| {
type: "unsaved";
busy: boolean;
onSave: () => void;
onDiscard: () => void;
onCancel: () => void;
};
export function DocumentActionDialog(
props: DocumentActionDialogProps
) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !props.busy) {
event.preventDefault();
props.onCancel();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [props]);
const isNewLocation = props.type === "new-location";
return (
<div className="document-dialog-layer">
<div className="document-dialog-backdrop" />
<section
className="document-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="document-dialog-title"
aria-describedby="document-dialog-description"
>
<span className="panel-kicker"></span>
<h2 id="document-dialog-title">
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
</h2>
<p id="document-dialog-description">
{isNewLocation
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
</p>
<div className="document-dialog-actions">
<button
type="button"
disabled={props.busy}
onClick={props.onCancel}
>
</button>
{isNewLocation ? (
<>
<button
type="button"
onClick={props.onNewWindow}
>
</button>
<button
type="button"
className="is-primary"
autoFocus
onClick={props.onCurrentWindow}
>
</button>
</>
) : (
<>
<button
type="button"
disabled={props.busy}
onClick={props.onDiscard}
>
</button>
<button
type="button"
className="is-primary"
disabled={props.busy}
autoFocus
onClick={props.onSave}
>
{props.busy ? "正在保存…" : "保存"}
</button>
</>
)}
</div>
</section>
</div>
);
}
+222 -2
View File
@@ -1,4 +1,5 @@
import { import {
defaultExportConfig,
getPaperDimensionsMm, getPaperDimensionsMm,
lengthToMillimeters, lengthToMillimeters,
paperFormatLabels, paperFormatLabels,
@@ -12,9 +13,11 @@ import {
type MermaidExportConfig type MermaidExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ThemeSummary } from "./application-backend";
interface ExportSettingsDrawerProps { interface ExportSettingsDrawerProps {
config: ExportConfig; config: ExportConfig;
theme: ThemeSummary | undefined;
onChange: (config: ExportConfig) => void; onChange: (config: ExportConfig) => void;
onClose: () => void; onClose: () => void;
onReset: () => void; onReset: () => void;
@@ -24,6 +27,9 @@ interface LengthInputProps {
label: string; label: string;
value: string; value: string;
maximum: number; maximum: number;
disabled?: boolean;
precision?: number;
step?: number;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
"page-total": "1 / 10", "page-total": "1 / 10",
"chinese-page-total": "第 1 页 / 共 10 页", "chinese-page-total": "第 1 页 / 共 10 页",
"dash-page": "- 1 -", "dash-page": "- 1 -",
"official-page": "— 1 —(标准公文)",
custom: "自定义模板" custom: "自定义模板"
}; };
@@ -98,6 +105,9 @@ function LengthInput({
label, label,
value, value,
maximum, maximum,
disabled = false,
precision = 1,
step = 0.1,
onChange onChange
}: LengthInputProps) { }: LengthInputProps) {
const [draft, setDraft] = useState( const [draft, setDraft] = useState(
@@ -115,7 +125,8 @@ function LengthInput({
return; return;
} }
const normalized = Math.min(Math.max(number, 0), maximum); const normalized = Math.min(Math.max(number, 0), maximum);
const rounded = Math.round(normalized * 10) / 10; const factor = 10 ** precision;
const rounded = Math.round(normalized * factor) / factor;
setDraft(String(rounded)); setDraft(String(rounded));
onChange(`${rounded}mm`); onChange(`${rounded}mm`);
} }
@@ -128,8 +139,9 @@ function LengthInput({
type="number" type="number"
min="0" min="0"
max={maximum} max={maximum}
step="0.1" step={step}
value={draft} value={draft}
disabled={disabled}
onBlur={commit} onBlur={commit}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -144,6 +156,57 @@ function LengthInput({
); );
} }
function PageDecorationFontInput({
label,
value,
disabled,
onChange
}: {
label: string;
value: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const normalized = draft.trim();
if (
!normalized ||
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
) {
setDraft(value);
return;
}
setDraft(normalized);
onChange(normalized);
}
return (
<label className="setting-field stacked">
<span>{label}</span>
<input
type="text"
maxLength={300}
value={draft}
disabled={disabled}
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</label>
);
}
function MermaidFontInput({ function MermaidFontInput({
value, value,
onChange onChange
@@ -208,6 +271,7 @@ function updateHeaderSlot(
export function ExportSettingsDrawer({ export function ExportSettingsDrawer({
config, config,
theme,
onChange, onChange,
onClose, onClose,
onReset onReset
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
value: string value: string
) { ) {
updatePaper({ updatePaper({
marginMode: "custom",
margins: { margins: {
...margins, ...margins,
[side]: value [side]: value
@@ -266,9 +331,22 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemeMargins() {
updatePaper({
marginMode: "theme",
margins: theme?.pageDefaults?.margins ?? {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
});
}
function updateHeader(header: Partial<HeaderConfig>) { function updateHeader(header: Partial<HeaderConfig>) {
onChange({ onChange({
...config, ...config,
pageDecorationsMode: "custom",
header: { header: {
...config.header, ...config.header,
...header ...header
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
function updateFooter(footer: Partial<FooterConfig>) { function updateFooter(footer: Partial<FooterConfig>) {
onChange({ onChange({
...config, ...config,
pageDecorationsMode: "custom",
footer: { footer: {
...config.footer, ...config.footer,
...footer ...footer
@@ -286,6 +365,19 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemePageDecorations() {
onChange({
...config,
pageDecorationsMode: "theme",
header: structuredClone(
theme?.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
)
});
}
function updateMermaid(mermaid: Partial<MermaidExportConfig>) { function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
onChange({ onChange({
...config, ...config,
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="margin-mode"
checked={config.paper.marginMode === "theme"}
onChange={followThemeMargins}
/>
</label>
<label>
<input
type="radio"
name="margin-mode"
checked={config.paper.marginMode === "custom"}
onChange={() => updatePaper({ marginMode: "custom" })}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.paper.marginMode === "theme"
? theme?.pageDefaults
? `当前采用“${theme.name}”推荐值`
: "当前主题未设置推荐值,采用全局默认 16mm"
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
</p>
<div className="margin-grid"> <div className="margin-grid">
<LengthInput <LengthInput
label="上" label="上"
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
onChange={(value) => updateMargin("left", value)} onChange={(value) => updateMargin("left", value)}
/> />
</div> </div>
{config.paper.marginMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemeMargins}
>
</button>
) : null}
</section> </section>
<section className="settings-section"> <section className="settings-section">
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
</p> </p>
</section> </section>
<section className="settings-section">
<h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "theme"}
onChange={followThemePageDecorations}
/>
</label>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "custom"}
onChange={() =>
onChange({
...config,
pageDecorationsMode: "custom"
})
}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.pageDecorationsMode === "theme"
? theme?.pageDefaults?.header ||
theme?.pageDefaults?.footer
? `当前采用“${theme.name}”推荐的页眉与页码`
: "当前主题未设置推荐值,采用全局默认配置"
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
</p>
{config.pageDecorationsMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemePageDecorations}
>
</button>
) : null}
</section>
<section className="settings-section"> <section className="settings-section">
<div className="setting-section-title"> <div className="setting-section-title">
<h3></h3> <h3></h3>
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
<p className="setting-hint"> <p className="setting-hint">
{"${title}"}{"${author}"} {"${filename}"} {"${title}"}{"${author}"} {"${filename}"}
</p> </p>
<PageDecorationFontInput
label="页眉字体"
value={config.header.fontFamily}
disabled={!config.header.enabled}
onChange={(fontFamily) => updateHeader({ fontFamily })}
/>
<LengthInput
label="页眉字号"
value={config.header.fontSize}
maximum={20}
disabled={!config.header.enabled}
precision={2}
step={0.01}
onChange={(fontSize) => updateHeader({ fontSize })}
/>
{(["left", "center", "right"] as const).map((slot) => { {(["left", "center", "right"] as const).map((slot) => {
const labels = { left: "左侧", center: "中间", right: "右侧" }; const labels = { left: "左侧", center: "中间", right: "右侧" };
return ( return (
@@ -567,6 +758,21 @@ export function ExportSettingsDrawer({
<span>{config.footer.enabled ? "开启" : "关闭"}</span> <span>{config.footer.enabled ? "开启" : "关闭"}</span>
</label> </label>
</div> </div>
<PageDecorationFontInput
label="页码字体"
value={config.footer.fontFamily}
disabled={!config.footer.enabled}
onChange={(fontFamily) => updateFooter({ fontFamily })}
/>
<LengthInput
label="页码字号"
value={config.footer.fontSize}
maximum={20}
disabled={!config.footer.enabled}
precision={2}
step={0.01}
onChange={(fontSize) => updateFooter({ fontSize })}
/>
<label className="setting-field"> <label className="setting-field">
<span></span> <span></span>
<select <select
@@ -616,6 +822,7 @@ export function ExportSettingsDrawer({
<option value="left"></option> <option value="left"></option>
<option value="center"></option> <option value="center"></option>
<option value="right"></option> <option value="right"></option>
<option value="outer"></option>
</select> </select>
</label> </label>
<label className="setting-field"> <label className="setting-field">
@@ -636,6 +843,19 @@ export function ExportSettingsDrawer({
} }
/> />
</label> </label>
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.footer.showOnFirstPage}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control"> <label className="switch-control divider-control">
<input <input
type="checkbox" type="checkbox"
+80 -2
View File
@@ -7,6 +7,7 @@ import {
useState useState
} from "react"; } from "react";
import { import {
AnnotationLayer,
getDocument, getDocument,
GlobalWorkerOptions, GlobalWorkerOptions,
RenderingCancelledException, RenderingCancelledException,
@@ -20,10 +21,15 @@ import {
getPdfCanvasRenderScale, getPdfCanvasRenderScale,
getPdfPageCssDimensions getPdfPageCssDimensions
} from "./pdf-page-geometry"; } from "./pdf-page-geometry";
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
import { PdfPageRenderQueue } from "./pdf-render-queue"; import { PdfPageRenderQueue } from "./pdf-render-queue";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl; GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
type PdfJsAnnotationLinkService = Parameters<
AnnotationLayer["render"]
>[0]["linkService"];
interface PdfPageDescriptor { interface PdfPageDescriptor {
pageNumber: number; pageNumber: number;
width: number; width: number;
@@ -33,12 +39,16 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps { export interface PrecisePdfPreviewProps {
blob: Blob; blob: Blob;
zoomPercent: number; zoomPercent: number;
onExternalLink?: (href: string) => void;
onNavigateToPage?: (pageNumber: number) => void;
onPageCount?: (pageCount: number) => void; onPageCount?: (pageCount: number) => void;
} }
interface PrecisePdfPageProps { interface PrecisePdfPageProps {
document: PDFDocumentProxy; document: PDFDocumentProxy;
descriptor: PdfPageDescriptor; descriptor: PdfPageDescriptor;
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
renderQueue: PdfPageRenderQueue; renderQueue: PdfPageRenderQueue;
zoomPercent: number; zoomPercent: number;
} }
@@ -92,12 +102,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({ function PrecisePdfPage({
document, document,
descriptor, descriptor,
onExternalLink,
onNavigateToPage,
renderQueue, renderQueue,
zoomPercent zoomPercent
}: PrecisePdfPageProps) { }: PrecisePdfPageProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const textLayerRef = useRef<HTMLDivElement>(null); const textLayerRef = useRef<HTMLDivElement>(null);
const annotationLayerRef = useRef<HTMLDivElement>(null);
const [displayWidth, setDisplayWidth] = useState(0); const [displayWidth, setDisplayWidth] = useState(0);
const [renderError, setRenderError] = useState(""); const [renderError, setRenderError] = useState("");
const [renderState, setRenderState] = useState< const [renderState, setRenderState] = useState<
@@ -158,11 +171,13 @@ function PrecisePdfPage({
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const textContainer = textLayerRef.current; const textContainer = textLayerRef.current;
const annotationContainer = annotationLayerRef.current;
if ( if (
!nearViewport || !nearViewport ||
displayWidth <= 0 || displayWidth <= 0 ||
!canvas || !canvas ||
!textContainer !textContainer ||
!annotationContainer
) { ) {
return; return;
} }
@@ -171,6 +186,7 @@ function PrecisePdfPage({
let page: PDFPageProxy | undefined; let page: PDFPageProxy | undefined;
let renderTask: RenderTask | undefined; let renderTask: RenderTask | undefined;
let textLayer: TextLayer | undefined; let textLayer: TextLayer | undefined;
let annotationLayer: AnnotationLayer | undefined;
setRenderError(""); setRenderError("");
setRenderState("queued"); setRenderState("queued");
@@ -212,6 +228,7 @@ function PrecisePdfPage({
canvas.style.width = `${displayViewport.width}px`; canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`; canvas.style.height = `${displayViewport.height}px`;
textContainer.replaceChildren(); textContainer.replaceChildren();
annotationContainer.replaceChildren();
renderTask = page.render({ renderTask = page.render({
canvas, canvas,
@@ -229,7 +246,43 @@ function PrecisePdfPage({
textContentSource: page.streamTextContent(), textContentSource: page.streamTextContent(),
viewport: displayViewport viewport: displayViewport
}); });
await textLayer.render(); const annotations = await page.getAnnotations({
intent: "display"
});
const linkService = createPdfAnnotationLinkService(
document,
descriptor.pageNumber,
{
onExternalLink,
onNavigateToPage
}
);
annotationLayer = new AnnotationLayer({
div: annotationContainer,
accessibilityManager: undefined,
annotationCanvasMap: undefined,
annotationEditorUIManager: undefined,
page,
viewport: displayViewport,
structTreeLayer: undefined,
commentManager: undefined,
linkService,
annotationStorage: document.annotationStorage
});
await Promise.all([
textLayer.render(),
annotationLayer.render({
viewport: displayViewport,
div: annotationContainer,
annotations,
page,
linkService:
linkService as unknown as PdfJsAnnotationLinkService,
annotationStorage: document.annotationStorage,
renderForms: false,
enableScripting: false
})
]);
} catch (reason: unknown) { } catch (reason: unknown) {
if ( if (
disposed || disposed ||
@@ -251,10 +304,12 @@ function PrecisePdfPage({
cancelQueuedRender(); cancelQueuedRender();
renderTask?.cancel(); renderTask?.cancel();
textLayer?.cancel(); textLayer?.cancel();
annotationLayer?.destroy();
page?.cleanup(); page?.cleanup();
canvas.width = 0; canvas.width = 0;
canvas.height = 0; canvas.height = 0;
textContainer.replaceChildren(); textContainer.replaceChildren();
annotationContainer.replaceChildren();
setRenderState("idle"); setRenderState("idle");
}; };
}, [ }, [
@@ -264,6 +319,8 @@ function PrecisePdfPage({
displayWidth, displayWidth,
document, document,
nearViewport, nearViewport,
onExternalLink,
onNavigateToPage,
renderQueue renderQueue
]); ]);
@@ -282,6 +339,10 @@ function PrecisePdfPage({
aria-hidden="true" aria-hidden="true"
/> />
<div ref={textLayerRef} className="textLayer" /> <div ref={textLayerRef} className="textLayer" />
<div
ref={annotationLayerRef}
className="annotationLayer"
/>
{renderState !== "rendered" ? ( {renderState !== "rendered" ? (
<span className="precise-pdf-page-placeholder"> <span className="precise-pdf-page-placeholder">
{renderState === "rendering" {renderState === "rendering"
@@ -299,6 +360,8 @@ function PrecisePdfPage({
export function PrecisePdfPreview({ export function PrecisePdfPreview({
blob, blob,
zoomPercent, zoomPercent,
onExternalLink,
onNavigateToPage,
onPageCount onPageCount
}: PrecisePdfPreviewProps) { }: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>(); const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -308,6 +371,19 @@ export function PrecisePdfPreview({
() => new PdfPageRenderQueue(1), () => new PdfPageRenderQueue(1),
[document] [document]
); );
const externalLinkRef = useRef(onExternalLink);
const navigateToPageRef = useRef(onNavigateToPage);
externalLinkRef.current = onExternalLink;
navigateToPageRef.current = onNavigateToPage;
const handleExternalLink = useCallback((href: string) => {
externalLinkRef.current?.(href);
}, []);
const handleNavigateToPage = useCallback(
(pageNumber: number) => {
navigateToPageRef.current?.(pageNumber);
},
[]
);
useEffect(() => () => renderQueue.dispose(), [renderQueue]); useEffect(() => () => renderQueue.dispose(), [renderQueue]);
@@ -386,6 +462,8 @@ export function PrecisePdfPreview({
key={page.pageNumber} key={page.pageNumber}
document={document} document={document}
descriptor={page} descriptor={page}
onExternalLink={handleExternalLink}
onNavigateToPage={handleNavigateToPage}
renderQueue={renderQueue} renderQueue={renderQueue}
zoomPercent={zoomPercent} zoomPercent={zoomPercent}
/> />
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from "react";
import { normalizePreviewPageNumber } from "./preview-page";
interface PreviewPageControlProps {
current: number;
total: number;
onNavigate: (pageNumber: number) => void;
}
export function PreviewPageControl({
current,
total,
onNavigate
}: PreviewPageControlProps) {
const [draft, setDraft] = useState(String(current));
const editingRef = useRef(false);
useEffect(() => {
if (!editingRef.current) {
setDraft(String(current));
}
}, [current, total]);
function commitPageNumber() {
const pageNumber = normalizePreviewPageNumber(draft, current, total);
setDraft(String(pageNumber));
if (pageNumber !== current) {
onNavigate(pageNumber);
}
}
return (
<label className="preview-page-control">
<span></span>
<input
type="number"
inputMode="numeric"
min={1}
max={total}
step={1}
value={draft}
aria-label="跳转页码"
onBlur={() => {
editingRef.current = false;
commitPageNumber();
}}
onChange={(event) => setDraft(event.target.value)}
onFocus={(event) => {
editingRef.current = true;
event.currentTarget.select();
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
<span aria-live="polite">/ {total} </span>
</label>
);
}
+2
View File
@@ -0,0 +1,2 @@
export const APP_VERSION = __APP_VERSION__;
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+116
View File
@@ -0,0 +1,116 @@
import type {
DocumentProfileName,
FooterConfig,
HeaderConfig,
PageMargins,
RenderedMarkdownDocument,
ThemeCategory
} from "@md-to-pdf/core";
export interface ThemeSummary {
id: string;
name: string;
version: string;
description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
pageDefaults?: {
margins: PageMargins;
header?: HeaderConfig;
footer?: FooterConfig;
};
bundled: boolean;
source: "bundled" | "local";
}
export interface MarkdownRenderInput {
markdown: string;
language: string;
resources?: MarkdownImageResource[];
}
export interface MarkdownImageResource {
path: string;
data: string;
}
function throwIfAborted(signal?: AbortSignal) {
signal?.throwIfAborted();
}
async function readErrorMessage(
response: Response,
fallback: string
) {
try {
const payload = (await response.json()) as { message?: unknown };
return typeof payload.message === "string"
? payload.message
: fallback;
} catch {
return fallback;
}
}
export async function renderMarkdownDocument(
input: MarkdownRenderInput,
signal?: AbortSignal
) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.renderMarkdown(input);
throwIfAborted(signal);
return result;
}
const response = await fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(input),
...(signal ? { signal } : {})
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, "渲染失败"));
}
return response.json() as Promise<RenderedMarkdownDocument>;
}
export async function listThemes(signal?: AbortSignal) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.listThemes();
throwIfAborted(signal);
return result;
}
const response = await fetch("/api/themes", {
...(signal ? { signal } : {})
});
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
}
export async function getThemeCss(
themeId: string,
signal?: AbortSignal
) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
throwIfAborted(signal);
return result;
}
const response = await fetch(
`/api/themes/${encodeURIComponent(themeId)}/css`,
{ ...(signal ? { signal } : {}) }
);
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
}
+41
View File
@@ -0,0 +1,41 @@
const sampleModules = import.meta.glob<string>(
"../../../samples/themes/*.md",
{
eager: true,
query: "?raw",
import: "default"
}
);
export interface BuiltinThemeSample {
themeId: string;
fileName: string;
markdown: string;
}
function getFileName(modulePath: string) {
return modulePath.split("/").at(-1) ?? "";
}
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
sampleModules
)
.map(([modulePath, markdown]) => {
const fileName = getFileName(modulePath);
return {
themeId: fileName.replace(/\.md$/i, ""),
fileName,
markdown
};
})
.sort((first, second) =>
first.themeId.localeCompare(second.themeId, "en")
);
const builtinThemeSampleMap = new Map(
builtinThemeSamples.map((sample) => [sample.themeId, sample])
);
export function getBuiltinThemeSample(themeId: string) {
return builtinThemeSampleMap.get(themeId);
}
+49
View File
@@ -0,0 +1,49 @@
const tutorialModules = import.meta.glob<string>(
"../../../samples/tutorials/*.md",
{
eager: true,
query: "?raw",
import: "default"
}
);
export interface BuiltinTutorial {
id: string;
name: string;
fileName: string;
themeId: string;
markdown: string;
}
const tutorialDefinitions = [
{
id: "echarts-tutorial",
name: "ECharts 图表教程",
themeId: "typora-github"
},
{
id: "formal-document-tutorial",
name: "公文主题使用教程",
themeId: "typora-github"
}
] as const;
const tutorialMarkdownById = new Map(
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
const fileName = modulePath.split("/").at(-1) ?? "";
return [fileName.replace(/\.md$/i, ""), markdown];
})
);
export const builtinTutorials: BuiltinTutorial[] =
tutorialDefinitions.map((definition) => {
const markdown = tutorialMarkdownById.get(definition.id);
if (!markdown) {
throw new Error(`缺少内置教程:${definition.id}.md`);
}
return {
...definition,
fileName: `${definition.id}.md`,
markdown
};
});
+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" };
}
+45
View File
@@ -0,0 +1,45 @@
export type DocumentShortcut =
| "new"
| "save"
| "save-as"
| "close";
export interface DocumentShortcutEvent {
key: string;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
shiftKey: boolean;
isComposing: boolean;
repeat: boolean;
}
export function getDocumentShortcut(
event: DocumentShortcutEvent,
desktop: boolean
): DocumentShortcut | undefined {
if (
event.isComposing ||
event.repeat ||
event.altKey ||
(!event.ctrlKey && !event.metaKey)
) {
return undefined;
}
const key = event.key.toLowerCase();
if (event.shiftKey) {
return desktop && key === "s" ? "save-as" : undefined;
}
switch (key) {
case "s":
return "save";
case "n":
return desktop ? "new" : undefined;
case "w":
return desktop ? "close" : undefined;
default:
return undefined;
}
}
+121 -14
View File
@@ -4,8 +4,10 @@ import {
type ExportConfig type ExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3"; export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY = export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
"md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
"md-to-pdf.export-config.v2"; "md-to-pdf.export-config.v2";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">; type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
return structuredClone(defaultExportConfig); return structuredClone(defaultExportConfig);
} }
function pickLegacyPageDecorations(value: unknown) {
if (typeof value !== "object" || value === null) {
return value;
}
const source = value as Record<string, unknown>;
if ("left" in source) {
return {
enabled: source.enabled,
height: source.height,
showDivider: source.showDivider,
fontSize: source.fontSize,
color: source.color,
left: source.left,
center: source.center,
right: source.right
};
}
return {
enabled: source.enabled,
height: source.height,
showDivider: source.showDivider,
fontSize: source.fontSize,
color: source.color,
alignment: source.alignment,
format: source.format,
template: source.template,
startFrom: source.startFrom
};
}
function usesLegacyPageDecorationDefaults(
header: unknown,
footer: unknown
) {
return (
JSON.stringify(pickLegacyPageDecorations(header)) ===
JSON.stringify(
pickLegacyPageDecorations(defaultExportConfig.header)
) &&
JSON.stringify(pickLegacyPageDecorations(footer)) ===
JSON.stringify(
pickLegacyPageDecorations(defaultExportConfig.footer)
)
);
}
function migrateStoredExportConfig(value: unknown): unknown {
if (
typeof value !== "object" ||
value === null ||
!("version" in value) ||
!("paper" in value)
) {
return value;
}
if (
value.version !== 2 &&
value.version !== 3 &&
value.version !== defaultExportConfig.version
) {
return value;
}
const source = value as Record<string, unknown>;
const hasPageDecorationsMode =
typeof source.pageDecorationsMode === "string";
const paper =
typeof value.paper === "object" && value.paper !== null
? value.paper
: {};
const margins =
"margins" in paper &&
typeof paper.margins === "object" &&
paper.margins !== null
? paper.margins
: {};
const usesLegacyDefaults =
JSON.stringify(margins) ===
JSON.stringify(defaultExportConfig.paper.margins);
const hasMarginMode =
typeof (paper as Record<string, unknown>).marginMode === "string";
return {
...value,
...(value.version === 2 || value.version === 3
? { version: defaultExportConfig.version }
: {}),
...(value.version === 2
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
: {}),
...(!hasPageDecorationsMode
? {
pageDecorationsMode: usesLegacyPageDecorationDefaults(
source.header,
source.footer
)
? "theme"
: "custom"
}
: {}),
paper: {
...paper,
...(!hasMarginMode
? {
marginMode: usesLegacyDefaults ? "theme" : "custom"
}
: {})
}
};
}
export function parseStoredExportConfig(rawValue: string | null): ExportConfig { export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
if (!rawValue) { if (!rawValue) {
return cloneDefaultExportConfig(); return cloneDefaultExportConfig();
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
try { try {
const value: unknown = JSON.parse(rawValue); const value: unknown = JSON.parse(rawValue);
const migratedValue = const migratedValue = migrateStoredExportConfig(value);
typeof value === "object" &&
value !== null &&
"version" in value &&
value.version === 2
? {
...value,
version: defaultExportConfig.version,
mermaid: structuredClone(defaultExportConfig.mermaid)
}
: value;
const parsed = exportConfigSchema.safeParse(migratedValue); const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig(); return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch { } catch {
@@ -47,7 +152,9 @@ export function loadExportConfig(
return parseStoredExportConfig(currentValue); return parseStoredExportConfig(currentValue);
} }
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY); const legacyValue =
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
const config = parseStoredExportConfig(legacyValue); const config = parseStoredExportConfig(legacyValue);
if (legacyValue) { if (legacyValue) {
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config)); storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
-123
View File
@@ -1,123 +0,0 @@
import {
getPaperDimensionsMm,
lengthToMillimeters,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
export interface MermaidDimensions {
width: number;
height: number;
}
export interface MermaidPageFit extends MermaidDimensions {
scaled: boolean;
}
const PAGE_FIT_EPSILON_PX = 1;
export function parseSvgViewBox(
value: string | null
): MermaidDimensions | undefined {
if (!value) {
return undefined;
}
const values = value
.trim()
.split(/[\s,]+/)
.map((part) => Number(part));
const [, , width = Number.NaN, height = Number.NaN] = values;
if (
values.length !== 4 ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
export function getPageContentDimensions(
config: ExportConfig
): MermaidDimensions {
const paper = getPaperDimensionsMm(
config.paper.format,
config.paper.orientation
);
const width =
paper.width -
lengthToMillimeters(config.paper.margins.left) -
lengthToMillimeters(config.paper.margins.right);
const height =
paper.height -
lengthToMillimeters(config.paper.margins.top) -
lengthToMillimeters(config.paper.margins.bottom);
return {
width: millimetersToCssPixels(width),
height: millimetersToCssPixels(height)
};
}
export function calculateMermaidPageFit(
intrinsic: MermaidDimensions,
pageContent: MermaidDimensions
): MermaidPageFit {
const heightAfterWidthFit =
(pageContent.width * intrinsic.height) / intrinsic.width;
if (heightAfterWidthFit <= pageContent.height) {
return {
width: pageContent.width,
height: heightAfterWidthFit,
scaled: false
};
}
const height = Math.max(
0,
pageContent.height - PAGE_FIT_EPSILON_PX
);
return {
width: (height * intrinsic.width) / intrinsic.height,
height,
scaled: true
};
}
export function fitOversizedMermaidToPage(
container: ParentNode,
config: ExportConfig
) {
const pageContent = getPageContentDimensions(config);
const diagrams = Array.from(
container.querySelectorAll<HTMLElement>(".mermaid")
);
for (const diagram of diagrams) {
const svg = diagram.querySelector<SVGSVGElement>("svg");
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
if (!svg || !intrinsic) {
continue;
}
const fit = calculateMermaidPageFit(intrinsic, pageContent);
if (!fit.scaled) {
continue;
}
diagram.dataset.pageHeightFitted = "true";
diagram.style.setProperty("margin-block", "0", "important");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.setProperty("display", "block");
svg.style.setProperty("width", `${fit.width}px`, "important");
svg.style.setProperty("height", `${fit.height}px`, "important");
svg.style.setProperty("max-width", "100%", "important");
svg.style.setProperty("max-height", `${fit.height}px`, "important");
}
}
-380
View File
@@ -1,380 +0,0 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
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,
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;
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 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 mermaidFitMs = 0;
let mermaidConversionMs = 0;
if (content.querySelector(".mermaid svg")) {
const measurement = mountMeasurementContainer(
documentRef,
content,
payload,
options.target
);
try {
await documentRef.fonts.ready;
await waitForImages(measurement.host);
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, 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,
mermaidErrors,
timings: {
setupMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
resourceWaitMs,
paginationMs,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
}
}
declare global {
interface Window {
__mdToPdfRender?: (
payload: PagedPreviewPayload,
target?: PagedRenderTarget
) => Promise<PagedDocumentRenderResult>;
}
}
+87 -10
View File
@@ -1,12 +1,15 @@
import { PagedDocumentRuntime } from "./paged-document-runtime"; import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import { import {
isPagedPreviewRenderRequest, isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE, PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage, type PagedPreviewFrameMessage,
type PagedPreviewPayload type PagedPreviewPayload
} from "./paged-preview"; } from "@md-to-pdf/preview-engine";
import { resolvePagedRenderTarget } from "./paged-render-target"; import highlightCss from "highlight.js/styles/github.css?inline";
import { resolveMermaidOutputMode } from "./mermaid-static-image"; import katexCss from "katex/dist/katex.min.css?inline";
import { import {
beginPreviewFrameRender, beginPreviewFrameRender,
completePreviewFrameRender completePreviewFrameRender
@@ -23,7 +26,11 @@ function getPreviewRoot() {
const previewRoot = getPreviewRoot(); const previewRoot = getPreviewRoot();
const renderTarget = resolvePagedRenderTarget(window.location.search); const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search); const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot); const runtime = new PagedDocumentRuntime(previewRoot, {
highlightCss,
katexCss,
echartsCss
});
document.documentElement.dataset.renderTarget = renderTarget; document.documentElement.dataset.renderTarget = renderTarget;
let latestRequestId = 0; let latestRequestId = 0;
@@ -33,9 +40,47 @@ function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin); window.parent.postMessage(message, window.location.origin);
} }
function measurePreviewContentHeight() {
const rootRect = previewRoot.getBoundingClientRect();
const documentRect =
document.documentElement.getBoundingClientRect();
return Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight,
rootRect.bottom - documentRect.top
)
);
}
document.addEventListener(
"click",
(event) => {
const target =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href]")
: undefined;
const href = target?.getAttribute("href")?.trim();
if (!href) {
return;
}
event.preventDefault();
event.stopPropagation();
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "link",
href
});
},
{ capture: true }
);
async function renderPagedPreview( async function renderPagedPreview(
requestId: number, requestId: number,
payload: PagedPreviewPayload payload: PagedPreviewPayload,
layout: "paged" | "continuous"
) { ) {
if (requestId !== latestRequestId) { if (requestId !== latestRequestId) {
return; return;
@@ -45,11 +90,17 @@ async function renderPagedPreview(
post({ post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering", type: "rendering",
requestId requestId,
layout
}); });
try { try {
const result = await runtime.render(payload, { const result =
layout === "continuous"
? await runtime.renderContinuous(payload, {
shouldContinue: () => requestId === latestRequestId
})
: await runtime.render(payload, {
target: renderTarget, target: renderTarget,
mermaidOutput, mermaidOutput,
shouldContinue: () => requestId === latestRequestId shouldContinue: () => requestId === latestRequestId
@@ -62,6 +113,8 @@ async function renderPagedPreview(
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered", type: "rendered",
requestId, requestId,
layout,
contentHeight: measurePreviewContentHeight(),
...result ...result
}); });
} finally { } finally {
@@ -86,6 +139,30 @@ window.__mdToPdfRender = async (
}; };
document.documentElement.dataset.runtimeReady = "true"; document.documentElement.dataset.runtimeReady = "true";
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
if (desktopPdfBridge) {
desktopPdfBridge.onRender(async (requestId, payload) => {
const requestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) =>
window.setTimeout(() => callback(performance.now()), 0);
try {
const result = await window.__mdToPdfRender?.(payload, "pdf");
if (!result) {
throw new Error("PDF 分页运行时不可用");
}
desktopPdfBridge.complete(requestId, result);
} catch (reason: unknown) {
desktopPdfBridge.fail(
requestId,
reason instanceof Error ? reason.message : "PDF 分页失败"
);
} finally {
window.requestAnimationFrame = requestAnimationFrame;
}
});
desktopPdfBridge.ready();
}
window.addEventListener("message", (event: MessageEvent<unknown>) => { window.addEventListener("message", (event: MessageEvent<unknown>) => {
if ( if (
event.origin !== window.location.origin || event.origin !== window.location.origin ||
@@ -96,12 +173,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
} }
latestRequestId = Math.max(latestRequestId, event.data.requestId); latestRequestId = Math.max(latestRequestId, event.data.requestId);
const { requestId, payload } = event.data; const { requestId, layout, payload } = event.data;
renderQueue = renderQueue renderQueue = renderQueue
.catch(() => undefined) .catch(() => undefined)
.then(async () => { .then(async () => {
try { try {
await renderPagedPreview(requestId, payload); await renderPagedPreview(requestId, payload, layout);
} catch (reason: unknown) { } catch (reason: unknown) {
if (requestId !== latestRequestId) { if (requestId !== latestRequestId) {
return; return;
-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;
}
};
}
+40 -2
View File
@@ -1,9 +1,14 @@
import type { ExportConfig } from "@md-to-pdf/core"; import type {
ExportConfig,
PagedDocumentPayload
} from "@md-to-pdf/core";
import type { MarkdownImageResource } from "./application-backend";
export interface PdfExportRequest { export interface PdfExportRequest {
markdown: string; markdown: string;
fileName: string; fileName: string;
language: string; language: string;
resources: MarkdownImageResource[];
exportConfig: ExportConfig; exportConfig: ExportConfig;
} }
@@ -11,6 +16,7 @@ export interface PdfExportResult {
blob: Blob; blob: Blob;
fileName: string; fileName: string;
pageCount: number | undefined; pageCount: number | undefined;
echartsErrorCount: number;
mermaidErrorCount: number; mermaidErrorCount: number;
} }
@@ -24,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
request.markdown, request.markdown,
request.fileName, request.fileName,
request.language, request.language,
request.resources,
request.exportConfig request.exportConfig
]); ]);
} }
@@ -117,6 +124,10 @@ export async function requestPdfExport(
pageCount: parseNumericHeader( pageCount: parseNumericHeader(
response.headers.get("x-pdf-page-count") response.headers.get("x-pdf-page-count")
), ),
echartsErrorCount:
parseNumericHeader(
response.headers.get("x-echarts-error-count")
) ?? 0,
mermaidErrorCount: mermaidErrorCount:
parseNumericHeader( parseNumericHeader(
response.headers.get("x-mermaid-error-count") response.headers.get("x-mermaid-error-count")
@@ -124,11 +135,38 @@ export async function requestPdfExport(
}; };
} }
export function downloadPdf(blob: Blob, fileName: string) { export async function requestDesktopPdfExport(
payload: PagedDocumentPayload
): Promise<PdfExportResult> {
const bridge = window.mdToPdfDesktop;
if (!bridge) {
throw new Error("桌面 PDF 引擎不可用");
}
const result = await bridge.generatePdf(payload);
const pdfBytes = Uint8Array.from(result.pdf);
return {
blob: new Blob([pdfBytes.buffer], { type: "application/pdf" }),
fileName: payload.fileName.replace(/\.(md|markdown)$/i, "") + ".pdf",
pageCount: result.pageCount,
echartsErrorCount: result.echartsErrors.length,
mermaidErrorCount: result.mermaidErrors.length
};
}
export async function downloadPdf(blob: Blob, fileName: string) {
if (window.mdToPdfDesktop) {
return window.mdToPdfDesktop.savePdf(
fileName,
new Uint8Array(await blob.arrayBuffer())
);
}
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
anchor.download = fileName; anchor.download = fileName;
anchor.click(); anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0); window.setTimeout(() => URL.revokeObjectURL(url), 0);
return true;
} }
+44 -1
View File
@@ -3,7 +3,50 @@ export interface PageVerticalRange {
bottom: number; bottom: number;
} }
export const PREVIEW_SCROLLBAR_WIDTH_PX = 14; export function getPreviewPageScrollTop(
currentScrollTop: number,
pageViewportTop: number,
scrollerViewportTop: number
) {
if (
!Number.isFinite(currentScrollTop) ||
!Number.isFinite(pageViewportTop) ||
!Number.isFinite(scrollerViewportTop)
) {
return 0;
}
return Math.max(
0,
currentScrollTop + pageViewportTop - scrollerViewportTop
);
}
export function normalizePreviewPageNumber(
value: string,
current: number,
total: number
) {
if (!Number.isInteger(total) || total < 1) {
return 0;
}
const normalizedValue = value.trim();
const fallback = Number.isInteger(current)
? Math.min(Math.max(current, 1), total)
: 1;
if (normalizedValue.length === 0) {
return fallback;
}
const parsed = Number(normalizedValue);
if (!Number.isInteger(parsed)) {
return fallback;
}
return Math.min(Math.max(parsed, 1), total);
}
export function getNearestPageNumber( export function getNearestPageNumber(
viewportCenter: number, viewportCenter: number,
+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}
`;
+443 -20
View File
@@ -24,8 +24,7 @@ textarea {
font: inherit; font: inherit;
} }
button, button {
.file-button {
min-height: 40px; min-height: 40px;
padding: 0 16px; padding: 0 16px;
border: 1px solid #b8c2bd; border: 1px solid #b8c2bd;
@@ -63,6 +62,75 @@ button:disabled {
background: rgb(247 248 246 / 92%); background: rgb(247 248 246 / 92%);
} }
.topbar-primary {
display: flex;
flex: 1;
align-items: center;
gap: 32px;
min-width: 0;
}
.topbar-brand {
flex: none;
}
.topbar-brand-title {
display: flex;
align-items: baseline;
gap: 9px;
}
.app-version {
padding: 2px 7px;
border: 1px solid #c8d5cf;
border-radius: 999px;
background: #edf3f0;
color: #527061;
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.035em;
white-space: nowrap;
}
.document-summary {
display: flex;
flex-direction: column;
min-width: 0;
max-width: min(42vw, 520px);
padding-left: 28px;
border-left: 1px solid #d4dad7;
}
.document-name-row {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.document-name-row > strong {
overflow: hidden;
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.unsaved-badge {
flex: none;
padding: 1px 6px;
border: 1px solid #d9bd86;
border-radius: 999px;
background: #fff7e7;
color: #8a5a13;
font-size: 0.62rem;
font-weight: 800;
}
.document-summary .render-status {
max-width: 100%;
margin-top: 3px;
}
.eyebrow { .eyebrow {
margin: 0 0 4px; margin: 0 0 4px;
color: #3b755d; color: #3b755d;
@@ -82,21 +150,108 @@ h1 {
.topbar-actions { .topbar-actions {
display: flex; display: flex;
flex: none;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
.file-button { .more-menu {
display: inline-flex; position: relative;
align-items: center;
} }
.file-button input { .more-menu-popover {
position: absolute; position: absolute;
width: 1px; z-index: 30;
top: calc(100% + 8px);
right: 0;
display: flex;
min-width: 220px;
padding: 6px;
border: 1px solid #c8d1cc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
flex-direction: column;
}
.more-menu-popover button {
width: 100%;
min-height: 36px;
padding: 0 11px;
border: 0;
background: transparent;
text-align: left;
}
.more-menu-popover button:hover,
.more-menu-popover button:focus-visible {
background: #edf3f0;
}
.more-menu-popover .menu-command {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.menu-command kbd {
color: #78857e;
font-family: inherit;
font-size: 0.68rem;
font-weight: 600;
}
.more-menu-submenu-item {
position: relative;
}
.menu-separator {
height: 1px; height: 1px;
overflow: hidden; margin: 5px 7px;
clip: rect(0 0 0 0); background: #e0e6e3;
}
.submenu-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.more-menu-submenu {
position: absolute;
z-index: 31;
top: -7px;
right: calc(100% + 8px);
width: 250px;
max-height: min(70vh, 560px);
padding: 6px;
overflow-y: auto;
border: 1px solid #c8d1cc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
}
.sample-menu-group {
display: flex;
flex-direction: column;
margin: 0;
}
.sample-menu-group + .sample-menu-group {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid #e3e8e5;
}
.sample-menu-group-label {
padding: 6px 11px 3px;
color: #718078;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.06em;
} }
.editor-layout { .editor-layout {
@@ -104,6 +259,11 @@ h1 {
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr); grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
flex: 1; flex: 1;
min-height: 0; min-height: 0;
transition: grid-template-columns 180ms ease;
}
.editor-layout.is-source-collapsed {
grid-template-columns: 48px minmax(520px, 1fr);
} }
.editor-panel, .editor-panel,
@@ -119,6 +279,45 @@ h1 {
background: #f6f7f5; background: #f6f7f5;
} }
.source-panel-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.source-panel-content[hidden] {
display: none;
}
.source-panel-toggle {
min-height: 32px;
padding: 0 10px;
border-color: #c9d4ce;
background: #f8fbf9;
color: #4f695e;
font-size: 0.7rem;
}
.source-panel-restore {
width: 100%;
height: 100%;
min-height: 0;
padding: 14px 0;
border: 0;
border-radius: 0;
background: #f0f3f1;
color: #4f695e;
font-size: 0.72rem;
letter-spacing: 0.08em;
writing-mode: vertical-rl;
}
.source-panel-restore:hover {
background: #e4ebe7;
color: #356c55;
}
.panel-heading { .panel-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -181,12 +380,43 @@ h1 {
flex: none; flex: none;
min-height: 44px; min-height: 44px;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: 12px;
padding: 6px 20px; padding: 6px 20px;
border-bottom: 1px solid #d9dedb; border-bottom: 1px solid #d9dedb;
background: rgb(255 255 255 / 58%); background: rgb(255 255 255 / 58%);
} }
.preview-page-control {
display: inline-flex;
flex: none;
align-items: center;
gap: 5px;
color: #5d7168;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.preview-page-control input {
width: 48px;
height: 30px;
padding: 0 6px;
border: 1px solid #c9d4ce;
border-radius: 7px;
outline: 0;
background: #fff;
color: #356c55;
font: inherit;
font-weight: 700;
text-align: center;
}
.preview-page-control input:focus {
border-color: #74a88f;
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
}
.preview-zoom-control { .preview-zoom-control {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -248,14 +478,6 @@ h1 {
color: #9ba49f; color: #9ba49f;
} }
.preview-page-indicator {
flex: none;
color: #5d7168;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.preview-mode-control { .preview-mode-control {
display: inline-flex; display: inline-flex;
flex: none; flex: none;
@@ -362,6 +584,28 @@ textarea:focus {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 0 30px; padding: 0 30px;
scrollbar-color: #8d9993 #dfe4e1;
scrollbar-width: auto;
}
.preview-scroll::-webkit-scrollbar {
width: 14px;
height: 14px;
}
.preview-scroll::-webkit-scrollbar-track {
background: #dfe4e1;
}
.preview-scroll::-webkit-scrollbar-thumb {
min-height: 48px;
border: 3px solid #dfe4e1;
border-radius: 999px;
background: #8d9993;
}
.preview-scroll::-webkit-scrollbar-thumb:hover {
background: #6f7d76;
} }
.paper { .paper {
@@ -405,7 +649,8 @@ textarea:focus {
} }
.precise-pdf-page canvas, .precise-pdf-page canvas,
.precise-pdf-page .textLayer { .precise-pdf-page .textLayer,
.precise-pdf-page .annotationLayer {
position: absolute; position: absolute;
inset: 0; inset: 0;
} }
@@ -439,6 +684,45 @@ textarea:focus {
--min-font-size-inv: calc(1 / var(--min-font-size)); --min-font-size-inv: calc(1 / var(--min-font-size));
} }
.precise-pdf-page .annotationLayer {
z-index: 2;
overflow: hidden;
color-scheme: only light;
pointer-events: none;
transform-origin: 0 0;
}
.precise-pdf-page .annotationLayer section {
position: absolute;
box-sizing: border-box;
text-align: initial;
pointer-events: auto;
user-select: none;
transform-origin: 0 0;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a {
position: absolute;
inset: 0;
cursor: pointer;
outline: none;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:hover,
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:focus-visible {
background: rgb(31 111 83 / 14%);
outline: 1px solid rgb(31 111 83 / 45%);
}
.precise-pdf-page .textLayer :is(span, br) { .precise-pdf-page .textLayer :is(span, br) {
position: absolute; position: absolute;
color: transparent; color: transparent;
@@ -531,6 +815,59 @@ textarea:focus {
inset: 0; inset: 0;
} }
.document-dialog-layer {
position: fixed;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
inset: 0;
}
.document-dialog-backdrop {
position: absolute;
background: rgb(24 35 30 / 46%);
inset: 0;
}
.document-dialog {
position: relative;
width: min(480px, 100%);
padding: 24px;
border: 1px solid #c9d2cd;
border-radius: 14px;
background: #fff;
box-shadow: 0 24px 70px rgb(20 34 28 / 28%);
}
.document-dialog h2 {
margin: 4px 0 10px;
color: #25332d;
font-family: Georgia, "Songti SC", serif;
font-size: 1.45rem;
font-weight: 500;
}
.document-dialog p {
margin: 0;
color: #5d6a64;
font-size: 0.88rem;
line-height: 1.65;
}
.document-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
.document-dialog-actions .is-primary {
border-color: #326b54;
background: #326b54;
color: #fff;
}
.settings-backdrop { .settings-backdrop {
position: absolute; position: absolute;
width: 100%; width: 100%;
@@ -709,6 +1046,19 @@ textarea:focus {
gap: 8px 18px; gap: 8px 18px;
} }
.margin-mode-control {
margin-bottom: 8px;
}
.margin-source-hint {
margin: 0 0 10px;
}
.margin-reset-action {
width: 100%;
margin-top: 10px;
}
.margin-grid .setting-field { .margin-grid .setting-field {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -808,6 +1158,21 @@ textarea:focus {
padding: 16px; padding: 16px;
} }
.topbar-primary {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.document-summary {
width: 100%;
max-width: none;
padding-top: 10px;
padding-left: 0;
border-top: 1px solid #d4dad7;
border-left: 0;
}
.topbar-actions { .topbar-actions {
flex-direction: column; flex-direction: column;
} }
@@ -816,6 +1181,10 @@ textarea:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.editor-layout.is-source-collapsed {
grid-template-columns: 1fr;
}
.editor-panel, .editor-panel,
.preview-panel { .preview-panel {
min-height: 650px; min-height: 650px;
@@ -830,6 +1199,19 @@ textarea:focus {
border-bottom: 1px solid #cbd2ce; border-bottom: 1px solid #cbd2ce;
} }
.editor-layout.is-source-collapsed .editor-panel {
height: 44px;
min-height: 44px;
}
.source-panel-restore {
height: 44px;
min-height: 44px;
padding: 0 16px;
letter-spacing: 0;
writing-mode: horizontal-tb;
}
.preview-scroll { .preview-scroll {
padding: 0 10px; padding: 0 10px;
} }
@@ -847,11 +1229,13 @@ textarea:focus {
} }
.preview-toolbar { .preview-toolbar {
justify-content: center; flex-wrap: wrap;
justify-content: space-between;
padding-inline: 10px; padding-inline: 10px;
} }
.preview-zoom-control { .preview-zoom-control {
flex: 1 0 100%;
width: 100%; width: 100%;
} }
@@ -865,8 +1249,47 @@ textarea:focus {
} }
} }
@media (prefers-reduced-motion: reduce) {
.editor-layout {
transition: none;
}
}
@media (max-width: 520px) { @media (max-width: 520px) {
.topbar-brand-title {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
.settings-drawer { .settings-drawer {
width: 100vw; width: 100vw;
} }
.more-menu-popover {
max-height: min(72vh, 620px);
overflow-y: auto;
}
.more-menu-submenu {
position: static;
width: auto;
max-height: 48vh;
margin: 4px 0 0 8px;
box-shadow: none;
}
.document-dialog-layer {
align-items: end;
padding: 12px;
}
.document-dialog {
padding: 20px;
}
.document-dialog-actions {
align-items: stretch;
flex-direction: column-reverse;
}
} }
+34
View File
@@ -0,0 +1,34 @@
import type { ThemeCategory } from "@md-to-pdf/core";
import type { ThemeSummary } from "./application-backend";
const themeCategoryOrder: readonly ThemeCategory[] = [
"general",
"red-letter",
"formal",
"tender"
];
const themeCategoryLabels: Record<ThemeCategory, string> = {
general: "通用主题",
"red-letter": "政企红头",
formal: "政企正式",
tender: "标书正式"
};
export interface ThemeGroup {
category: ThemeCategory;
label: string;
themes: ThemeSummary[];
}
export function groupThemesByCategory(
themes: readonly ThemeSummary[]
): ThemeGroup[] {
return themeCategoryOrder
.map((category) => ({
category,
label: themeCategoryLabels[category],
themes: themes.filter((theme) => theme.category === category)
}))
.filter((group) => group.themes.length > 0);
}
+92
View File
@@ -0,0 +1,92 @@
import {
defaultExportConfig,
resolvePageMargins,
type ExportConfig
} from "@md-to-pdf/core";
import type { ThemeSummary } from "./application-backend";
function marginsEqual(
first: ExportConfig["paper"]["margins"],
second: ExportConfig["paper"]["margins"]
) {
return (
first.top === second.top &&
first.right === second.right &&
first.bottom === second.bottom &&
first.left === second.left
);
}
function applyThemePageDecorations(
config: ExportConfig,
theme?: ThemeSummary
): ExportConfig {
if (config.pageDecorationsMode !== "theme") {
return config;
}
const header =
theme?.pageDefaults?.header ?? defaultExportConfig.header;
const footer =
theme?.pageDefaults?.footer ?? defaultExportConfig.footer;
if (
JSON.stringify(config.header) === JSON.stringify(header) &&
JSON.stringify(config.footer) === JSON.stringify(footer)
) {
return config;
}
return {
...config,
header: structuredClone(header),
footer: structuredClone(footer)
};
}
export function applyThemeMargins(
config: ExportConfig,
theme?: ThemeSummary
): ExportConfig {
if (config.paper.marginMode !== "theme") {
return config;
}
const margins = resolvePageMargins(
config.paper,
theme?.pageDefaults?.margins
);
if (marginsEqual(config.paper.margins, margins)) {
return config;
}
return {
...config,
paper: {
...config.paper,
margins
}
};
}
export function applyThemeDefaults(
config: ExportConfig,
theme?: ThemeSummary
): ExportConfig {
return applyThemePageDecorations(
applyThemeMargins(config, theme),
theme
);
}
export function selectTheme(
config: ExportConfig,
theme: ThemeSummary
): ExportConfig {
return applyThemeDefaults(
{
...config,
themeId: theme.id
},
theme
);
}
+14 -22
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RenderedMarkdownDocument } from "@md-to-pdf/core"; import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
import { renderMarkdownDocument } from "./application-backend";
import type { MarkdownImageResource } from "./application-backend";
export interface MarkdownRenderCallbacks { export interface MarkdownRenderCallbacks {
onRenderStart?: () => void; onRenderStart?: () => void;
@@ -9,6 +11,7 @@ export interface MarkdownRenderCallbacks {
export function useMarkdownRender( export function useMarkdownRender(
markdown: string, markdown: string,
language: string, language: string,
resources: MarkdownImageResource[],
callbacks: MarkdownRenderCallbacks = {} callbacks: MarkdownRenderCallbacks = {}
) { ) {
const [result, setResult] = const [result, setResult] =
@@ -23,27 +26,10 @@ export function useMarkdownRender(
onRenderStart?.(); onRenderStart?.();
setError(""); setError("");
void fetch("/api/render", { void renderMarkdownDocument(
method: "POST", { markdown, language, resources },
headers: { controller.signal
"content-type": "application/json" )
},
body: JSON.stringify({ markdown, language }),
signal: controller.signal
})
.then(async (response) => {
const payload = (await response.json()) as
| RenderedMarkdownDocument
| { message?: string };
if (!response.ok) {
throw new Error(
"message" in payload
? payload.message ?? "渲染失败"
: "渲染失败"
);
}
return payload as RenderedMarkdownDocument;
})
.then((payload) => { .then((payload) => {
setResult(payload); setResult(payload);
onStatusChange?.("预览已更新"); onStatusChange?.("预览已更新");
@@ -62,7 +48,13 @@ export function useMarkdownRender(
window.clearTimeout(timer); window.clearTimeout(timer);
controller.abort(); controller.abort();
}; };
}, [language, markdown, onRenderStart, onStatusChange]); }, [
language,
markdown,
onRenderStart,
onStatusChange,
resources
]);
return { error, result }; return { error, result };
} }
+16 -30
View File
@@ -1,17 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {
getThemeCss,
listThemes,
type ThemeSummary
} from "./application-backend";
export interface ThemeSummary { export type { ThemeSummary } from "./application-backend";
id: string;
name: string;
version: string;
description: string;
bundled: boolean;
source: "bundled" | "local";
}
export function useThemeResources( export function useThemeResources(
themeId: string, themeId: string,
onThemesLoaded: (themes: ThemeSummary[]) => void onThemesLoaded: (
themes: ThemeSummary[],
refreshKey: number
) => void,
refreshKey = 0
) { ) {
const [themes, setThemes] = useState<ThemeSummary[]>([]); const [themes, setThemes] = useState<ThemeSummary[]>([]);
const [themeCss, setThemeCss] = useState(""); const [themeCss, setThemeCss] = useState("");
@@ -21,19 +23,11 @@ export function useThemeResources(
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
void fetch("/api/themes", { void listThemes(controller.signal)
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
})
.then(({ themes: availableThemes }) => { .then(({ themes: availableThemes }) => {
setThemes(availableThemes); setThemes(availableThemes);
setCatalogError(""); setCatalogError("");
onThemesLoaded(availableThemes); onThemesLoaded(availableThemes, refreshKey);
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -44,22 +38,14 @@ export function useThemeResources(
}); });
return () => controller.abort(); return () => controller.abort();
}, [onThemesLoaded]); }, [onThemesLoaded, refreshKey]);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setThemeCss(""); setThemeCss("");
setCssError(""); setCssError("");
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, { void getThemeCss(themeId, controller.signal)
signal: controller.signal
})
.then(async (response) => {
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
})
.then(setThemeCss) .then(setThemeCss)
.catch((reason: unknown) => { .catch((reason: unknown) => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -70,7 +56,7 @@ export function useThemeResources(
}); });
return () => controller.abort(); return () => controller.abort();
}, [themeId]); }, [refreshKey, themeId]);
return { return {
error: catalogError || cssError, error: catalogError || cssError,
+99
View File
@@ -1 +1,100 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import type {
PagedDocumentPayload,
PagedDocumentRenderResult,
RenderedMarkdownDocument,
ThemeCategory,
DocumentProfileName
} from "@md-to-pdf/core";
declare global {
const __APP_VERSION__: string;
interface DesktopPdfResult {
pdf: Uint8Array;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
}
interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
consumePendingMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
discardPendingMarkdown(): Promise<void>;
onMarkdownOpened(
listener: (reason: "open" | "reload" | "replace") => void
): () => void;
startNewMarkdown(): Promise<void>;
createNewWindow(): Promise<void>;
resolveWindowClose(close: boolean): Promise<void>;
onWindowCloseRequested(listener: () => void): () => void;
setDocumentDirty(dirty: boolean): Promise<void>;
openDocumentLink(href: string): Promise<void>;
saveMarkdown(
fileName: string,
markdown: string
): Promise<{ fileName: string } | undefined>;
saveMarkdownAs(
fileName: string,
markdown: string
): Promise<{ fileName: string } | undefined>;
openThemeDirectory(): Promise<string>;
refreshThemes(): Promise<void>;
renderMarkdown(input: {
markdown: string;
language: string;
resources?: Array<{
path: string;
data: string;
}>;
}): Promise<RenderedMarkdownDocument>;
listThemes(): Promise<{
themes: Array<{
id: string;
name: string;
version: string;
description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
bundled: boolean;
source: "bundled" | "local";
}>;
}>;
getThemeCss(themeId: string): Promise<string>;
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
}
interface DesktopPdfRuntimeBridge {
ready(): void;
onRender(
listener: (
requestId: number,
payload: PagedDocumentPayload
) => Promise<void> | void
): () => void;
complete(
requestId: number,
result: PagedDocumentRenderResult
): void;
fail(requestId: number, message: string): void;
}
interface Window {
mdToPdfDesktop?: DesktopApplicationBridge;
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
APP_VERSION,
APP_VERSION_LABEL
} from "../src/app-version";
const webRoot = fileURLToPath(new URL("../", import.meta.url));
describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.5.1");
expect(APP_VERSION_LABEL).toBe("v0.5.1");
});
it("允许浏览器加载同源 Web Manifest", () => {
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
expect(indexHtml).toContain('rel="manifest"');
expect(indexHtml).toContain("manifest-src 'self'");
});
});
+124
View File
@@ -0,0 +1,124 @@
import {
afterEach,
describe,
expect,
it,
vi
} from "vitest";
import {
getThemeCss,
listThemes,
renderMarkdownDocument
} from "../src/application-backend";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("应用后端适配器", () => {
it("在桌面环境通过窄 IPC 调用共享服务", async () => {
const rendered = {
rendererVersion: 1 as const,
articleHtml: '<article id="write">测试</article>',
bodyHtml: "测试",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
};
const renderMarkdown = vi.fn(async () => rendered);
const listDesktopThemes = vi.fn(async () => ({
themes: [
{
id: "test",
name: "测试",
version: "1",
description: "",
bundled: true,
source: "bundled" as const
}
]
}));
const getDesktopThemeCss = vi.fn(async () => "#write {}");
vi.stubGlobal("window", {
mdToPdfDesktop: {
renderMarkdown,
listThemes: listDesktopThemes,
getThemeCss: getDesktopThemeCss
}
});
await expect(
renderMarkdownDocument({
markdown: "# 测试",
language: "zh-CN"
})
).resolves.toBe(rendered);
await expect(listThemes()).resolves.toEqual({
themes: [expect.objectContaining({ id: "test" })]
});
await expect(getThemeCss("test")).resolves.toBe("#write {}");
expect(renderMarkdown).toHaveBeenCalledWith({
markdown: "# 测试",
language: "zh-CN"
});
expect(getDesktopThemeCss).toHaveBeenCalledWith("test");
});
it("在浏览器环境保持现有 HTTP 协议", async () => {
vi.stubGlobal("window", {});
const fetcher = vi
.fn()
.mockResolvedValueOnce(
Response.json({
rendererVersion: 1,
articleHtml: '<article id="write">测试</article>',
bodyHtml: "测试",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
})
)
.mockResolvedValueOnce(
Response.json({ themes: [] })
)
.mockResolvedValueOnce(
new Response("#write {}", { status: 200 })
);
vi.stubGlobal("fetch", fetcher);
await renderMarkdownDocument({
markdown: "# 测试",
language: "zh-CN"
});
await listThemes();
await getThemeCss("typora-like");
expect(fetcher).toHaveBeenNthCalledWith(
1,
"/api/render",
expect.objectContaining({ method: "POST" })
);
expect(fetcher).toHaveBeenNthCalledWith(
2,
"/api/themes",
{}
);
expect(fetcher).toHaveBeenNthCalledWith(
3,
"/api/themes/typora-like/css",
{}
);
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
builtinThemeSamples,
getBuiltinThemeSample
} from "../src/builtin-theme-samples";
const expectedThemeIds = [
"enterprise-red-simple",
"formal-feasibility",
"formal-regulation",
"formal-report",
"gov-red-letter",
"gov-red-standard",
"red-briefing",
"tender-blind",
"tender-business-blue",
"tender-classic",
"typora-github",
"typora-like",
"typora-pixyll",
"typora-whitey"
];
describe("内置主题示例", () => {
it("为全部内置主题注册同名 Markdown 文件", () => {
expect(
builtinThemeSamples.map((sample) => sample.themeId).sort()
).toEqual(expectedThemeIds);
expect(
builtinThemeSamples.every(
(sample) => sample.fileName === `${sample.themeId}.md`
)
).toBe(true);
});
it("提供可直接编辑的非空 Markdown 内容", () => {
for (const sample of builtinThemeSamples) {
expect(sample.markdown.startsWith("---")).toBe(true);
expect(sample.markdown.length).toBeGreaterThan(300);
expect(getBuiltinThemeSample(sample.themeId)).toBe(sample);
}
expect(getBuiltinThemeSample("unknown-theme")).toBeUndefined();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { builtinTutorials } from "../src/builtin-tutorials";
describe("内置教程", () => {
it("注册 ECharts 与公文主题教程", () => {
expect(
builtinTutorials.map(({ id, name, themeId, fileName }) => ({
id,
name,
themeId,
fileName
}))
).toEqual([
{
id: "echarts-tutorial",
name: "ECharts 图表教程",
themeId: "typora-github",
fileName: "echarts-tutorial.md"
},
{
id: "formal-document-tutorial",
name: "公文主题使用教程",
themeId: "typora-github",
fileName: "formal-document-tutorial.md"
}
]);
});
it("教程内容完整且能够直接作为 Markdown 打开", () => {
for (const tutorial of builtinTutorials) {
expect(tutorial.markdown.startsWith("---")).toBe(true);
expect(tutorial.markdown.length).toBeGreaterThan(1_000);
}
expect(builtinTutorials[0].markdown).toContain("```echarts");
expect(builtinTutorials[1].markdown).toContain(
"document.profile: official"
);
});
});
+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"
});
}
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { getDocumentShortcut } from "../src/document-shortcuts";
function shortcutEvent(
key: string,
overrides: Partial<Parameters<typeof getDocumentShortcut>[0]> = {}
) {
return {
key,
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
repeat: false,
...overrides
};
}
describe("文档快捷键", () => {
it("在桌面端识别新建、保存、另存为和关闭", () => {
expect(getDocumentShortcut(shortcutEvent("n"), true)).toBe("new");
expect(getDocumentShortcut(shortcutEvent("S"), true)).toBe("save");
expect(
getDocumentShortcut(
shortcutEvent("S", { shiftKey: true }),
true
)
).toBe("save-as");
expect(getDocumentShortcut(shortcutEvent("w"), true)).toBe("close");
expect(
getDocumentShortcut(
shortcutEvent("s", { ctrlKey: false, metaKey: true }),
true
)
).toBe("save");
});
it("Web 端只接管保存", () => {
expect(getDocumentShortcut(shortcutEvent("n"), false)).toBeUndefined();
expect(getDocumentShortcut(shortcutEvent("w"), false)).toBeUndefined();
expect(getDocumentShortcut(shortcutEvent("s"), false)).toBe("save");
expect(
getDocumentShortcut(
shortcutEvent("s", { shiftKey: true }),
false
)
).toBeUndefined();
});
it("忽略组合输入、长按和带附加修饰键的按键", () => {
expect(
getDocumentShortcut(
shortcutEvent("s", { isComposing: true }),
true
)
).toBeUndefined();
expect(
getDocumentShortcut(shortcutEvent("s", { repeat: true }), true)
).toBeUndefined();
expect(
getDocumentShortcut(shortcutEvent("s", { altKey: true }), true)
).toBeUndefined();
expect(
getDocumentShortcut(
shortcutEvent("n", { shiftKey: true }),
true
)
).toBeUndefined();
expect(
getDocumentShortcut(
shortcutEvent("s", { shiftKey: true, altKey: true }),
true
)
).toBeUndefined();
});
});
+89 -7
View File
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core"; import { defaultExportConfig } from "@md-to-pdf/core";
import { import {
EXPORT_CONFIG_STORAGE_KEY, EXPORT_CONFIG_STORAGE_KEY,
LEGACY_EXPORT_CONFIG_STORAGE_KEY, LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
loadExportConfig, loadExportConfig,
parseStoredExportConfig, parseStoredExportConfig,
saveExportConfig saveExportConfig
@@ -10,14 +11,15 @@ import {
function createMemoryStorage( function createMemoryStorage(
initialValue: string | null = null, initialValue: string | null = null,
legacyValue: string | null = null legacyValue: string | null = null,
legacyKey = LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY
) { ) {
const values = new Map<string, string>(); const values = new Map<string, string>();
if (initialValue) { if (initialValue) {
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue); values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
} }
if (legacyValue) { if (legacyValue) {
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue); values.set(legacyKey, legacyValue);
} }
return { return {
getItem(key: string) { getItem(key: string) {
@@ -52,26 +54,106 @@ describe("导出设置缓存", () => {
).toEqual(defaultExportConfig); ).toEqual(defaultExportConfig);
}); });
it("将 v2 缓存迁移为 v3保留已有设置", () => { it("将 v2 缓存迁移为 v4让默认页边距跟随主题", () => {
const legacyConfig = { const legacyConfig = {
...defaultExportConfig, ...defaultExportConfig,
version: 2, version: 2,
mermaid: undefined, mermaid: undefined,
paper: { paper: {
...defaultExportConfig.paper, ...defaultExportConfig.paper,
marginMode: undefined,
format: "A5" format: "A5"
} }
}; };
const storage = createMemoryStorage( const storage = createMemoryStorage(
null, null,
JSON.stringify(legacyConfig) JSON.stringify(legacyConfig),
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
); );
const migrated = loadExportConfig(storage); const migrated = loadExportConfig(storage);
expect(migrated.version).toBe(3); expect(migrated.version).toBe(4);
expect(migrated.paper.format).toBe("A5"); expect(migrated.paper.format).toBe("A5");
expect(migrated.paper.marginMode).toBe("theme");
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid); expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
expect(storage.read()).toContain('"version":3'); expect(storage.read()).toContain('"version":4');
});
it("将改过页边距的 v3 缓存迁移为自定义模式", () => {
const legacyConfig = {
...defaultExportConfig,
version: 3,
paper: {
...defaultExportConfig.paper,
marginMode: undefined,
margins: {
top: "20mm",
right: "18mm",
bottom: "20mm",
left: "18mm"
}
}
};
const migrated = parseStoredExportConfig(
JSON.stringify(legacyConfig)
);
expect(migrated.version).toBe(4);
expect(migrated.paper.marginMode).toBe("custom");
expect(migrated.paper.margins).toEqual(legacyConfig.paper.margins);
});
it("将未改页眉页码的旧 v4 缓存迁移为跟随主题", () => {
const legacyConfig = {
...defaultExportConfig,
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
},
footer: {
...defaultExportConfig.footer,
fontFamily: undefined,
showOnFirstPage: undefined
}
};
const migrated = parseStoredExportConfig(
JSON.stringify(legacyConfig)
);
expect(migrated.pageDecorationsMode).toBe("theme");
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
expect(migrated.footer.showOnFirstPage).toBe(true);
});
it("保留旧 v4 缓存中用户修改过的页眉页码", () => {
const legacyConfig = {
...defaultExportConfig,
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
enabled: true,
fontFamily: undefined,
center: {
enabled: true,
content: "${title}"
}
},
footer: {
...defaultExportConfig.footer,
alignment: "right",
fontFamily: undefined,
showOnFirstPage: undefined
}
};
const migrated = parseStoredExportConfig(
JSON.stringify(legacyConfig)
);
expect(migrated.pageDecorationsMode).toBe("custom");
expect(migrated.header.enabled).toBe(true);
expect(migrated.header.center.content).toBe("${title}");
expect(migrated.footer.alignment).toBe("right");
}); });
it("保存并恢复经过校验的配置", () => { it("保存并恢复经过校验的配置", () => {
+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();
});
});
+67 -2
View File
@@ -1,15 +1,30 @@
import { describe, expect, it, vi } from "vitest"; import {
import { defaultExportConfig } from "@md-to-pdf/core"; afterEach,
describe,
expect,
it,
vi
} from "vitest";
import {
createPagedDocumentPayload,
defaultExportConfig
} from "@md-to-pdf/core";
import { import {
createPdfExportCacheKey, createPdfExportCacheKey,
downloadPdf,
getCachedPdfExport, getCachedPdfExport,
getOrRequestPdfExport, getOrRequestPdfExport,
parseContentDispositionFileName, parseContentDispositionFileName,
requestDesktopPdfExport,
requestPdfExport, requestPdfExport,
type CachedPdfExport, type CachedPdfExport,
type PdfExportRequest type PdfExportRequest
} from "../src/pdf-export"; } from "../src/pdf-export";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("PDF 导出客户端", () => { describe("PDF 导出客户端", () => {
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => { it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
const request: PdfExportRequest = { const request: PdfExportRequest = {
@@ -23,6 +38,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-test"]), blob: new Blob(["%PDF-test"]),
fileName: "测试.pdf", fileName: "测试.pdf",
pageCount: 1, pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0 mermaidErrorCount: 0
}; };
const cache: CachedPdfExport = { key, result }; const cache: CachedPdfExport = { key, result };
@@ -65,6 +81,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-cached"]), blob: new Blob(["%PDF-cached"]),
fileName: "测试.pdf", fileName: "测试.pdf",
pageCount: 1, pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0 mermaidErrorCount: 0
}; };
const cache: CachedPdfExport = { const cache: CachedPdfExport = {
@@ -95,6 +112,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-new"]), blob: new Blob(["%PDF-new"]),
fileName: "测试.pdf", fileName: "测试.pdf",
pageCount: 2, pageCount: 2,
echartsErrorCount: 0,
mermaidErrorCount: 0 mermaidErrorCount: 0
}; };
const requester = vi.fn(async () => result); const requester = vi.fn(async () => result);
@@ -128,6 +146,7 @@ describe("PDF 导出客户端", () => {
"content-disposition": "content-disposition":
"attachment; filename=\"document.pdf\"", "attachment; filename=\"document.pdf\"",
"x-pdf-page-count": "4", "x-pdf-page-count": "4",
"x-echarts-error-count": "2",
"x-mermaid-error-count": "1" "x-mermaid-error-count": "1"
} }
}); });
@@ -149,6 +168,7 @@ describe("PDF 导出客户端", () => {
); );
expect(result.fileName).toBe("document.pdf"); expect(result.fileName).toBe("document.pdf");
expect(result.pageCount).toBe(4); expect(result.pageCount).toBe(4);
expect(result.echartsErrorCount).toBe(2);
expect(result.mermaidErrorCount).toBe(1); expect(result.mermaidErrorCount).toBe(1);
expect(await result.blob.text()).toBe("%PDF-test"); expect(await result.blob.text()).toBe("%PDF-test");
}); });
@@ -173,4 +193,49 @@ describe("PDF 导出客户端", () => {
) )
).rejects.toThrow("PDF 生成队列已满"); ).rejects.toThrow("PDF 生成队列已满");
}); });
it("桌面端使用共享分页载荷生成并保存 PDF", async () => {
const payload = createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write">测试</article>',
bodyHtml: "测试",
metadata: {
title: "测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "测试.md",
themeCss: "",
exportConfig: defaultExportConfig
});
const generatePdf = vi.fn(async () => ({
pdf: new TextEncoder().encode("%PDF-desktop"),
pageCount: 2,
echartsErrors: [],
mermaidErrors: ["图表 1"]
}));
const savePdf = vi.fn(async () => true);
vi.stubGlobal("window", {
mdToPdfDesktop: { generatePdf, savePdf }
});
const generated = await requestDesktopPdfExport(payload);
const saved = await downloadPdf(generated.blob, generated.fileName);
expect(generatePdf).toHaveBeenCalledWith(payload);
expect(generated.fileName).toBe("测试.pdf");
expect(generated.pageCount).toBe(2);
expect(generated.mermaidErrorCount).toBe(1);
expect(savePdf).toHaveBeenCalledWith(
"测试.pdf",
expect.any(Uint8Array)
);
expect(saved).toBe(true);
});
}); });
+37 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getNearestPageNumber } from "../src/preview-page"; import {
getNearestPageNumber,
getPreviewPageScrollTop,
normalizePreviewPageNumber
} from "../src/preview-page";
const pages = [ const pages = [
{ top: 0, bottom: 100 }, { top: 0, bottom: 100 },
@@ -22,3 +26,35 @@ describe("getNearestPageNumber", () => {
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0); expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
}); });
}); });
describe("normalizePreviewPageNumber", () => {
it("保留有效的整数页码", () => {
expect(normalizePreviewPageNumber("12", 3, 65)).toBe(12);
});
it("将越界页码限制在有效范围", () => {
expect(normalizePreviewPageNumber("0", 3, 65)).toBe(1);
expect(normalizePreviewPageNumber("99", 3, 65)).toBe(65);
});
it("无效输入恢复当前页", () => {
expect(normalizePreviewPageNumber("", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("3.5", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("abc", 7, 65)).toBe(7);
});
it("没有可用页面时返回 0", () => {
expect(normalizePreviewPageNumber("1", 0, 0)).toBe(0);
});
});
describe("getPreviewPageScrollTop", () => {
it("按目标页相对滚动容器的位置计算滚动量", () => {
expect(getPreviewPageScrollTop(200, 900, 120)).toBe(980);
});
it("避免返回负数或无效滚动量", () => {
expect(getPreviewPageScrollTop(20, 10, 100)).toBe(0);
expect(getPreviewPageScrollTop(Number.NaN, 10, 0)).toBe(0);
});
});
+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("## 串行媒体块");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { ThemeSummary } from "../src/application-backend";
import { groupThemesByCategory } from "../src/theme-groups";
function createTheme(
id: string,
name: string,
category: ThemeSummary["category"]
): ThemeSummary {
return {
id,
name,
category,
version: "1.0.0",
description: "",
compatibleProfiles: [],
bundled: true,
source: "bundled"
};
}
describe("groupThemesByCategory", () => {
it("按稳定顺序输出非空主题分组", () => {
const groups = groupThemesByCategory([
createTheme("tender", "标书", "tender"),
createTheme("general", "通用", "general"),
createTheme("red", "红头", "red-letter")
]);
expect(groups.map(({ category, label }) => ({ category, label })))
.toEqual([
{ category: "general", label: "通用主题" },
{ category: "red-letter", label: "政企红头" },
{ category: "tender", label: "标书正式" }
]);
expect(groups[0]?.themes[0]?.id).toBe("general");
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core";
import type { ThemeSummary } from "../src/application-backend";
import {
applyThemeDefaults,
applyThemeMargins,
selectTheme
} from "../src/theme-margins";
function createTheme(
id: string,
margins?: ThemeSummary["pageDefaults"]
): ThemeSummary {
return {
id,
name: id,
version: "1.0.0",
description: "",
category: "formal",
compatibleProfiles: [],
pageDefaults: margins,
bundled: true,
source: "bundled"
};
}
const formalTheme = createTheme("formal", {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
}
});
const officialTheme = createTheme("official", {
...formalTheme.pageDefaults,
header: {
...defaultExportConfig.header,
fontFamily: '"Mdpdf Fandol Song", SimSun, serif'
},
footer: {
...defaultExportConfig.footer,
height: "7mm",
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
color: "#000000",
alignment: "outer",
format: "official-page",
showOnFirstPage: false
}
});
describe("主题推荐页边距", () => {
it("跟随模式切换主题时同步推荐值", () => {
const selected = selectTheme(defaultExportConfig, formalTheme);
expect(selected.themeId).toBe("formal");
expect(selected.paper.marginMode).toBe("theme");
expect(selected.paper.margins).toEqual(
formalTheme.pageDefaults?.margins
);
});
it("主题未声明推荐值时恢复全局 16mm", () => {
const config = selectTheme(defaultExportConfig, formalTheme);
const selected = selectTheme(config, createTheme("general"));
expect(selected.paper.margins).toEqual({
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
});
});
it("自定义模式切换主题时保留用户值", () => {
const config = {
...defaultExportConfig,
paper: {
...defaultExportConfig.paper,
marginMode: "custom" as const,
margins: {
top: "20mm",
right: "18mm",
bottom: "20mm",
left: "18mm"
}
}
};
const selected = selectTheme(config, formalTheme);
expect(selected.themeId).toBe("formal");
expect(selected.paper.margins).toEqual(config.paper.margins);
});
it("主题清单加载后补齐当前主题推荐值", () => {
expect(
applyThemeMargins(defaultExportConfig, formalTheme).paper.margins
).toEqual(formalTheme.pageDefaults?.margins);
});
it("跟随主题时同步推荐的页眉和页码", () => {
const selected = selectTheme(defaultExportConfig, officialTheme);
expect(selected.pageDecorationsMode).toBe("theme");
expect(selected.header.fontFamily).toContain("Fandol Song");
expect(selected.footer).toMatchObject({
alignment: "outer",
format: "official-page",
showOnFirstPage: false
});
});
it("页面装饰自定义模式切换主题时保留用户配置", () => {
const config = {
...defaultExportConfig,
pageDecorationsMode: "custom" as const,
footer: {
...defaultExportConfig.footer,
alignment: "left" as const,
format: "page" as const
}
};
const selected = selectTheme(config, officialTheme);
expect(selected.header).toEqual(config.header);
expect(selected.footer).toEqual(config.footer);
});
it("主题清单加载后补齐当前主题页面装饰", () => {
const applied = applyThemeDefaults(
defaultExportConfig,
officialTheme
);
expect(applied.footer.format).toBe("official-page");
expect(applied.footer.alignment).toBe("outer");
});
});
+14
View File
@@ -1,9 +1,23 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const rootPackage = JSON.parse(
readFileSync(
fileURLToPath(new URL("../../package.json", import.meta.url)),
"utf8"
)
) as { version: string };
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
publicDir: fileURLToPath(
new URL("../../logos/web", import.meta.url)
),
define: {
__APP_VERSION__: JSON.stringify(rootPackage.version)
},
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
+39 -5
View File
@@ -1,5 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
ARG RUNTIME_BASE_IMAGE=node:22-bookworm-slim
FROM node:22-bookworm-slim AS build FROM node:22-bookworm-slim AS build
WORKDIR /app WORKDIR /app
@@ -9,13 +11,24 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/preview-engine/package.json packages/preview-engine/package.json
COPY packages/renderer/package.json packages/renderer/package.json COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci RUN npm ci
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY apps ./apps COPY apps ./apps
COPY packages ./packages COPY packages ./packages
RUN npm run build COPY logos ./logos
COPY samples ./samples
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
FROM node:22-bookworm-slim AS production-dependencies FROM node:22-bookworm-slim AS production-dependencies
@@ -26,20 +39,29 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/preview-engine/package.json packages/preview-engine/package.json
COPY packages/renderer/package.json packages/renderer/package.json COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
FROM node:22-bookworm-slim AS runtime FROM ${RUNTIME_BASE_IMAGE} AS runtime
USER root USER root
RUN apt-get update \ ARG RUNTIME_BASE_IMAGE
ARG REUSE_PLAYWRIGHT_RUNTIME=0
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
echo "Reusing the Playwright runtime from ${RUNTIME_BASE_IMAGE}"; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
nginx \ nginx \
fonts-noto-cjk \ fonts-noto-cjk \
fonts-noto-color-emoji \ fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& groupadd --system pwuser \ && groupadd --system pwuser \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser && useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
fi
WORKDIR /app WORKDIR /app
@@ -47,16 +69,28 @@ COPY --chown=pwuser:pwuser package.json package-lock.json ./
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npx playwright install --with-deps chromium \ RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
test -x /ms-playwright/chromium-*/chrome-linux/chrome \
|| test -x /ms-playwright/chromium-*/chrome-linux64/chrome; \
else \
npx playwright install --with-deps chromium; \
fi \
&& chown -R pwuser:pwuser /ms-playwright && chown -R pwuser:pwuser /ms-playwright
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser --from=build /app/samples ./samples
COPY --chown=pwuser:pwuser themes ./themes COPY --chown=pwuser:pwuser themes ./themes
COPY deploy/nginx.conf /etc/nginx/nginx.conf COPY deploy/nginx.conf /etc/nginx/nginx.conf
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
+2
View File
@@ -2,9 +2,11 @@
.github .github
.local .local
.vscode .vscode
apps/desktop
coverage coverage
dist dist
node_modules node_modules
out
output output
playwright-report playwright-report
test-results test-results
+23 -5
View File
@@ -34,13 +34,27 @@ docker compose -f deploy\compose.yaml down
## 构建发布标签 ## 构建发布标签
本地验证完成后,可以为镜像增加发布标签 可以直接指定版本化镜像名称构建
```powershell ```powershell
docker tag md-to-pdf:local yixiong/md-to-pdf:v0.1.0 $env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
docker compose -f deploy\compose.yaml build
``` ```
该命令只创建本地标签,不会自动推送到镜像仓库。 该命令只生成本地镜像,不会自动推送到镜像仓库。
默认构建会安装项目锁定版本的 Playwright Chromium 及系统依赖。如果本机
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
$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.5.1 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载 ## 主题挂载
@@ -55,13 +69,17 @@ docker compose -f deploy\compose.yaml up -d
目录下每套主题都应包含 `theme.json`。主题规范详见 目录下每套主题都应包含 `theme.json`。主题规范详见
[`docs/THEMES.md`](../docs/THEMES.md)。 [`docs/THEMES.md`](../docs/THEMES.md)。
本机通过导入工具生成的 Typora 主题不会复制进镜像;只有显式挂载后, 当前内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
容器才会读取这些本机文件。 3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
## 运行参数 ## 运行参数
| 环境变量 | 默认值 | 说明 | | 环境变量 | 默认值 | 说明 |
| --- | ---: | --- | | --- | ---: | --- |
| `MD_TO_PDF_IMAGE` | `md-to-pdf:local` | 构建和运行的镜像标签 |
| `MD_TO_PDF_RUNTIME_BASE_IMAGE` | `node:22-bookworm-slim` | 可选运行时基础镜像 |
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 | | `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 | | `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 | | `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
+4 -1
View File
@@ -5,7 +5,10 @@ services:
build: build:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
image: md-to-pdf:local args:
RUNTIME_BASE_IMAGE: ${MD_TO_PDF_RUNTIME_BASE_IMAGE:-node:22-bookworm-slim}
REUSE_PLAYWRIGHT_RUNTIME: ${MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME:-0}
image: "${MD_TO_PDF_IMAGE:-md-to-pdf:local}"
init: true init: true
ports: ports:
- "${MD_TO_PDF_PORT:-8080}:8080" - "${MD_TO_PDF_PORT:-8080}:8080"
+634 -30
View File
@@ -1,6 +1,6 @@
# Markdown PDF 导出器进度 # Markdown PDF 导出器进度
最后更新:2026-07-27 最后更新:2026-07-29
## 1. 当前概况 ## 1. 当前概况
@@ -31,8 +31,35 @@ fa07472 feat: 实现网页实时预览
ec48bce chore: 初始化项目骨架 ec48bce chore: 初始化项目骨架
``` ```
最新阶段已完成渲染边界逻辑修复,以及前后端分页载荷、文档类型 `v0.4.1` 图片资源阶段已完成Web 素材目录、Desktop 同目录资源
物理单位、主题资源加载和 Markdown 请求逻辑的复用收敛。 受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
统一渲染链路。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 产物的问题。
`v0.5.1` 正式版新增 10 套政企与标书主题、规范页边距和页面装饰、
结构化公文 Front Matter、内置中文字体、教程及 14 份主题示例;同时
重组“更多”菜单,补齐桌面文件快捷键、未保存确认和跟随新路径的另存为,
并修复红头标题居中、代码块自动换行、左侧内容留白和跨页分页。Windows
安装包、Docker Compose、本机安装与发布验证均已完成。
## 2. 已完成 ## 2. 已完成
@@ -78,7 +105,10 @@ ec48bce chore: 初始化项目骨架
- 主题 CSS 相对资源重写与受限资源接口; - 主题 CSS 相对资源重写与受限资源接口;
- 目录匹配、真实路径包含关系和外部 URL 安全检查。 - 目录匹配、真实路径包含关系和外部 URL 安全检查。
内置主题项目自有实现,没有复制 Typora 官方默认主题。用户可以通过导入工具将本机已安装的 Typora 基础 CSS以及 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题复制到 Git 忽略的 `.local/themes`。这些副本仅供本机使用,不进入仓库、容器或发布包。 内置主题包含项目自有的 Typora 风格主题,以及按公司内部私有项目决策
复制的 GitHub、Pixyll 和 Whitey 三套 Typora 默认主题。三套复制主题的
来源和内部使用边界记录在 `themes/INTERNAL_THEME_NOTICE.md`;如未来公开、
商用或向公司外部分发,必须重新完成许可证审查。
### 2.4 Markdown 渲染核心 ### 2.4 Markdown 渲染核心
@@ -230,9 +260,330 @@ ec48bce chore: 初始化项目骨架
- CSS 像素与 PDF 点数换算只使用 core 中的共享常量;删除已经不参与协议 - CSS 像素与 PDF 点数换算只使用 core 中的共享常量;删除已经不参与协议
或逻辑的 `contentHeight` 字段。 或逻辑的 `contentHeight` 字段。
### 3.8 Windows 显示缩放与快速预览分页差异
- 此前 65 页长文档在快速预览中生成 64 页、在精确预览和最终 PDF 中
生成 65 页;该差异并非由 Chromium 版本不同导致。
- 出现差异时,笔记本内屏使用 Windows 150% 系统显示缩放。切换到使用
100% 显示缩放的外接显示器后,快速预览与精确预览的分页结果基本一致。
- 当前定位是:Windows 显示缩放改变浏览器的 `devicePixelRatio`,并可能
影响字体栅格化和 CSS 布局中的子像素取整;单处差异很小,但会在长文档
分页过程中累计,最终造成页数差异。
- 精确预览直接展示服务端 Chromium 生成的 PDF,最终下载也复用同一结果,
因此精确预览和最终 PDF 继续作为权威分页结果;快速预览定位为编辑期间的
客户端近似分页,不承诺在所有显示缩放环境下与 PDF 页数完全一致。
- 后续优化快速预览时,应重点检查 `devicePixelRatio`、浏览器页面缩放、
字体度量和逐页高度取整;分页测量不得依赖物理像素,视觉缩放不得参与
文档排版和分页计算,并避免逐页取整造成累计误差。
- 后续分页回归应覆盖 Windows 100%、125% 和 150% 系统显示缩放,分别
记录快速预览、精确预览和最终 PDF 的页数及分页边界。
### 3.9 Markdown ECharts 图表
- 新增可独立复用的 `packages/markdown-echarts` 工作区,为 Markdown-it
提供安全的 `echarts` YAML 围栏。
- 围栏支持版本、固定高度或宽高比、主题、图注和 ECharts `option`
高度默认 80mm,主题默认跟随文档。
- 当前支持 line、bar、scatter、pie、radar、heatmap、boxplot 和
candlestick 系列,并校验各系列的数据结构和必要坐标系。
- 每个系列必须使用非空 `series[].data``dataset.source` 或具有有效
上游数据的内置 transform;缺少数据等错误会显示包含配置路径的局部
占位,不中断整篇 Markdown。
- YAML 会转换为安全纯数据对象;禁止函数、自定义标签、锚点和别名、
外部 URL、HTML formatter、原型污染属性及 `custom` 系列。
- 浏览器按需加载 ECharts 模块,以 SVG 渲染,并可冻结为内联 SVG 或
SVG Data URL 图片;快速预览与 PDF 使用同一套运行时。
- 分页遵循 Mermaid 的不可拆分规则:当前页空间足够时原位放置,不足时
移到下一页,超高图表等比例缩放到单页内,不跨页切割。
- PDF 输出等待 ECharts、Mermaid、字体和图片全部完成后再分页,并分别
返回 ECharts 与 Mermaid 错误数量。
- 包内 README 已记录围栏语法、默认值、验证规则、错误行为、安全边界、
Markdown-it 接入和浏览器渲染方式。
### 3.10 工作区布局与页码导航
- 默认 Markdown 示例新增 ECharts 柱状图,打开网页即可验证 YAML 围栏、
SVG 渲染和分页。
- 文件名及渲染、分页、导出错误状态从源文本标题栏迁移到顶部应用标题
旁,长文件名支持省略和完整悬停提示。
- 源文本区域支持收起和展开;桌面端收起为 48px 左侧窄栏,预览区扩展
到剩余宽度并保持纸张居中,窄屏收起为 44px 顶部横条。
- 折叠内容使用真实 `display:none`,不会残留可聚焦文本框或进入无障碍
树;按钮提供 `aria-controls``aria-expanded`
- 当前页从预览标题行移到缩放工具栏左侧,显示为
`第 [n] / 总页数 页`,避免重复信息。
- 页码输入支持 Enter 和失焦提交,自动限制到 1~总页数;空值和非法
小数恢复当前页。
- 快速预览直接定位 iframe 内 `.pagedjs_page`;精确预览定位
`.precise-pdf-page`。滚动预览时输入框同步当前页。
- 精确预览会动态识别桌面内部滚动容器或窄屏主窗口滚动容器,目标页顶端
与实际可视滚动区域对齐。
- 快速预览 iframe 只负责生成分页内容并回传自然高度,滚动统一交给外层
`.preview-scroll`;滚动条因此与精确预览一样贴在右侧预览容器边缘,
不再贴着居中的纸张右边缘。
- 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页
位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。
### 3.11 ECharts 第二阶段图表
- ECharts 系列支持从首版 8 种扩展到 17 种,新增 `tree``treemap`
`sunburst``graph``sankey``chord``funnel``gauge`
`pictorialBar`
- 当前使用 ECharts 6.1.0`chord` 直接采用 ECharts 6 官方和弦图及
`ChordChart` 按需模块,不引入第三方扩展。
- `tree``treemap``sunburst` 校验递归层级结构;矩形树图和旭日图
的叶节点必须提供有限数值。
- `graph``sankey``chord` 校验节点、关系边、端点引用与边权重;
Graph 未指定布局时,有完整坐标则使用 `none`,否则默认使用 `force`
- `funnel``gauge` 校验有限数值数据;`pictorialBar` 复用柱图的数据和
坐标轴规则,并允许不加载外部资源的 `path://` 内嵌矢量路径。
- 安全策略新增层级节点、关系节点和关系边数量限制,继续禁止外部图片、
跳转链接、函数、HTML formatter 和原型污染属性。
- 默认 Markdown 现在包含基础柱状图及九种第二阶段图表,每种均使用独立
YAML 围栏,可直接编辑和观察 SVG、分页及错误占位行为。
- Treemap 默认示例关闭节点下钻和面包屑,显式启用父级与叶节点标签,并
增加白色分隔边框,避免扁平数据区域缺少辨识信息或底部控件占用空间。
- Markdown/PDF 属于确定性的静态输出场景,浏览器引擎会在全局和系列级
强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使
预览或 PDF 停留在动画中间帧。
### 3.12 v0.4.0 Electron 桌面端
- 新增 `apps/desktop` Electron 43.2.0 工作区,使用 Electron Forge
7.11.2 打包。
- 根项目版本升级为 `0.4.0`Vite 从根 `package.json` 注入统一版本,
工具标题右侧显示 `v0.4.0` 徽标。
- 应用窗口和 PDF 窗口均关闭 Node Integration,启用 Context Isolation
和 Chromium Sandbox,并使用不同 preload 暴露窄 IPC 接口。
- 桌面端 PDF 直接接收 Web 端已经构造的共享分页载荷,复用现有
`PagedDocumentRuntime`、主题 CSS、Mermaid 和 ECharts 运行时。
- 隐藏 PDF 窗口使用 Electron 自带 Chromium 的
`webContents.printToPDF()`,桌面发行包不携带 Playwright Chromium。
- Paged.js 的帧队列在纯隐藏窗口中不会推进;桌面 PDF 任务期间使用
计时器调度分页队列,分页完成后恢复原生 `requestAnimationFrame`
- Chromium 打印前将透明、不可聚焦、不进入任务栏的 PDF 窗口短暂放到
屏幕外,使复杂分页页面拥有可打印的窗口表面,用户不会看到该窗口。
- PDF Buffer 通过受控 IPC 返回 Web 界面,桌面端使用系统“另存为”对话框
写入文件;Web 版继续使用浏览器下载行为。
- 生产页面通过 `mdpdf://bundle/` 自定义安全协议加载,不使用权限过宽的
`file://`PDF Session 只允许同源、`data:``blob:``about:`
资源。
- Windows x64 未签名目录包、Squirrel `Setup.exe` 和 ZIP 已由 Forge
成功生成;`app.asar` 只包含
编译后的主进程、preload 和清单,Web 静态资源作为只读资源单独打包。
- 新增 `packages/application`,统一封装 Markdown 渲染、主题清单、主题
CSS 和主题资源读取;Fastify 仅作为 HTTP 适配器。
- Web 端通过应用后端抽象选择 HTTP 或 Electron IPC;桌面端通过受限 IPC
直接调用共享服务,不启动 Fastify,不占用本地 HTTP 端口。
- 主题资源通过 `mdpdf://theme/` 安全协议提供,应用窗口与独立 PDF
Session 均完成协议注册和来源限制。
- 修复 Electron 主进程 ESM Bundle 中 YAML 依赖动态加载 `process`
失败的问题,构建时注入 Node `createRequire`
- `logos/` 作为统一品牌源:Web 使用 favicon、Apple Touch Icon 和
Web ManifestElectron 使用 ICO、ICNS、窗口图标和安装器图标。
- Forge 输出目录包含版本号,正式内部交付使用 Squirrel `Setup.exe`
ZIP 作为免安装辅助包;当前未签名。
### 3.13 v0.4.0 内置主题与发布产物
- `.local/themes` 中 GitHub、Pixyll 和 Whitey 三套主题已复制到
`themes/`,从 v0.4.0 起随 Web 容器和桌面安装包内置。
- 内置主题注册优先于相同 ID 的本地主题,避免 Compose 默认挂载旧副本
覆盖发布版本,并记录可诊断警告。
- Windows 正式安装包为
`Markdown PDF 导出器-0.4.0 Setup.exe`,约 137.48 MiBSHA-256 为
`9C12F9A007E102BDB5213ABC5246F4480083FCA5D67DCD45354728BCFDB54220`
- 中文产品名、窗口标题、安装包和开始菜单名称保持不变,Windows 主程序
与进程文件固定为 `md-to-pdf.exe`;目录包和 Squirrel NUPKG 均已验证。
- 本地镜像为 `yixiong/md-to-pdf:v0.4.0`Compose 容器保持健康运行并
监听 `http://localhost:8080`
- Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过
构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。
### 3.14 v0.4.1 图片资源与标题分页
- `packages/application` 统一提取 Markdown 图片引用,将验证后的本地或
远程图片转换为 Data URL,再交给 Web 预览、Playwright PDF 和 Electron
PDF 共用的 HTML。
- Web 增加素材目录选择,支持把 Markdown 的关联图片作为受限 Base64
资源随渲染和 PDF 请求提交;请求体上限同步提高到 24 MiB。
- Desktop 增加原生“打开 Markdown”,主进程记录文档目录并直接读取相对
图片,不启动 Server,也不允许读取文档目录之外的文件。
- 相对路径支持中文、空格和百分号编码;拒绝绝对路径、`..` 穿越和符号
链接越界。
- 远程图片支持 HTTP/HTTPS、逐跳重定向校验、10 秒超时、格式嗅探与
5 分钟缓存;阻止 localhost、私网和链路本地目标,并兼容企业代理常用
的域名 Fake-IP 网段。
- 每篇文档最多 50 个图片引用,单图不超过 8 MiB,总图片不超过 15 MiB
支持 PNG、JPEG、GIF、WebP、AVIF 和受限 SVG。
- 独立图片使用 `figure + img + figcaption``[]` 中的文本显示为图片
标题;行内混排图片继续保持普通段落语义。
- 图片与标题整块分页。超高图先扣除标题区实际高度,再等比例缩放到单页,
与 Mermaid 和 ECharts 遵循相同的不可跨页规则。
- 默认示例增加 NASA 中等尺寸图与 Unsplash 超高竖图,直接覆盖正常图片
和超高图片分页场景。
### 3.15 v0.4.2 桌面窗口精简
- Electron 主进程不再创建默认应用菜单,移除窗口顶部的
`File / Edit / View / Window` 原生菜单栏。
- 应用窗口同时启用菜单栏自动隐藏,避免 Windows 恢复默认菜单。
- 该维护版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 镜像继续
使用已验证的 `v0.4.1`
### 3.16 v0.4.3 标准 Windows 安装向导
- 桌面发行链路由 Electron Forge Squirrel 迁移到 electron-builder
26.15.3 的 NSIS assisted installer。
- 安装向导提供当前用户/所有用户安装选项、安装目录选择、安装进度和完成
页面;主程序继续使用 `md-to-pdf.exe`
- 正式安装包固定命名为
`md-to-pdf-0.4.3-x86_64-Setup.exe`,免安装包命名为
`md-to-pdf-0.4.3-x86_64.zip`
- 项目自有 `build/installer.nsh` 显式创建桌面和开始菜单快捷方式,
安装完成后直接启动主程序,不依赖 `.lnk`
- 卸载时显式清理桌面快捷方式、开始菜单快捷方式及其目录。
- 安装包和校验文件作为 Gitea `v0.4.3` Release 附件发布,不写入 Git
历史。
### 3.17 v0.4.4 桌面语言包精简
- Electron 发行包只保留 `zh-CN``en-US` 两种 Chromium 语言资源。
- 不裁剪 ICU、ANGLE、Direct3D、Dawn、SwiftShader 或其他 Chromium
运行时组件,保证桌面预览与 PDF 导出在不同图形环境下的可靠性。
- 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持
`v0.4.1`
### 3.18 v0.4.5 文档工作流与媒体分页
- Web 与 Desktop 均支持新建 Markdown;未保存时使用“未命名文档”状态,
不显示伪造源文件名,并允许直接导出 PDF。
- Web 保存使用浏览器下载;Desktop 使用原生另存为。默认文件名优先取
第一个一级标题,没有一级标题时取首行可见文本,并清理 Windows 非法
字符、保留名和末尾空格或句点。
- “打开 Markdown”继续作为顶部独立强操作;新建、保存及桌面自定义主题
目录收纳到“更多”菜单,Web 不再显示本地素材目录入口。
- Windows 安装包注册 `.md``.markdown` 文件关联;系统打开文件会
转交给现有单实例窗口并自动加载。
- Desktop 监听窗口位置、尺寸和最大化变化,延迟写入完整状态;退出前
刷新待写状态。下次启动按当前显示器工作区限制尺寸并修正坐标,避免
多显示器断开后窗口落在屏幕外。
- Desktop 会建立自定义主题目录,“更多”菜单可直接在资源管理器打开;
目录允许留空,放入合法主题后由共享主题注册器读取。
- 图片、Mermaid 和 ECharts 统一视为带标题的媒体块。分页按 DOM 文档
顺序串行处理,每个媒体元素只允许一次回填或缩放重排。
- 图片基准高度按原尺寸先收缩到内容限宽后计算;媒体块只有在上一页空白
与所需空间比例达到阈值时才尝试回填。缩放只作用于图片或图表内容,
标题保持原字号和自然高度。
- ECharts 在分页前冻结为 SVG 图片,三类媒体共享回填决策与边界检查;
无法安全回填时仍移至下一页,保证顺序、不跨页且不覆盖后续内容。
- 默认示例仅保留一张网络图片,并新增有效 Base64 横图、不同尺寸图片、
Mermaid 和 ECharts,用于观察媒体分页、标题配对和渲染效果。
- 根 README、Desktop README、四个工作区包 README、进度文档和发布
规范随版本同步更新。
### 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`
### 3.20 v0.5.1 正式文档主题与桌面文件工作流
- 围栏代码按正文宽度自动换行,长单词允许安全断行;代码内容统一保留
`8px` 左侧内边距,并保留代码块外框、语法高亮和 Typora 兼容 DOM。
- 多行代码块按至少两行一组生成可分页分片,当前页能容纳一组时不再将
整块代码推到下一页;跨页分片保持连续边框和完整文本。
- 新增政企标准红头、函件红头、企业简版红头、简报红头 4 套红头主题,
正式制度、正式报告、可研报告 3 套正式文档主题,以及经典标书、商务蓝
标书、暗标 3 套标书主题。
- 主题清单新增分类、文档结构预设、推荐导出设置和页面装饰;没有主题推荐
时保持 16mm 默认页边距,正式主题自动采用规范页边距、页眉页脚和页码。
- Renderer 根据结构化 Front Matter 生成红头、文号、签发人、密级、
主送、正文、附件、落款、抄送和印发信息,也支持项目报告与标书封面。
- 红头标题使用居中网格布局,签发人不再挤压发文机关;正文、红头标题、
页眉页脚使用主题内置 Fandol 中文字体,降低跨机器显示差异。
- `samples/tutorials` 内置 ECharts 和公文主题教程,包含 YAML 示例、
字段说明、图表标题和图例渲染;公文教程保持 Typora Github 阅读主题。
- `samples/themes` 为全部 14 套主题提供示例;Desktop 发行配置将教程、
示例和字体随应用复制,打开主题示例时自动切换相应主题。
- 移除顶部加号入口,将文件操作、教程与示例、主题管理整合为三个菜单
分区;文件操作统一命名为“新建文件”“保存文件”“另存为文件”。
- Desktop 支持 `Ctrl+N``Ctrl+W``Ctrl+S``Ctrl+Shift+S`,新建和
关闭时对未保存内容进行确认;另存为成功后当前窗口追踪新文件路径,
Web 端不显示或拦截“另存为”。
## 4. 已执行验证 ## 4. 已执行验证
2026-07-27 在当前完整工作区成功执行: 2026-07-29 对 v0.5.1 完整工作区及最终代码块修复执行:
```text
npm test
npm run typecheck
npm run build
git diff --check
```
- 最终修复前的完整工作区共 65 个测试文件、286 项测试全部通过;将
左侧内边距调整到共享分页规则后,Preview Engine 18 项和 Application
9 项定向测试再次通过;
- Markdown ECharts、Core、Renderer、Application、Preview Engine、
Web、Server 和 Desktop 类型检查全部通过;
- Web 生产资源、Server 和 Desktop 主进程/Preload 构建通过,生产资源
中的应用版本为 `0.5.1`
- 保留的手动验收服务继续运行:Web 与主题 API 均返回 HTTP 200
Electron 应用及其 GPU、网络、渲染进程正常存活;
- `git diff --check` 通过,仅有 Git 对 Windows 工作区换行转换的提示。
- v0.5.1 Windows 目录版、NSIS 和 ZIP 经完整内嵌 Web 重建后生成;
目录版主程序文件版本为 `0.5.1`、产品版本为 `0.5.1.0`,启动后正常
响应,并保留独立验收窗口。
- 目录版包含 14 套主题、14 份主题示例、2 份教程、5 个 Fandol WOFF2
中文字体及其许可文件,语言包仅保留 `zh-CN``en-US`ZIP 中同样
检出主题、示例和字体资源。
- NSIS 安装包 `md-to-pdf-0.5.1-x86_64-Setup.exe` 为 114,688,014
字节,SHA-256 为
`676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC`
- 免安装包 `md-to-pdf-0.5.1-x86_64.zip` 为 152,183,607 字节,
SHA-256 为
`82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F`
- 主程序与安装包 Authenticode 状态均为未签名,与当前发行说明一致。
- 正式镜像 `yixiong/md-to-pdf:v0.5.1` 内容 ID 为
`sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276`
Compose 强制替换后健康运行在 `http://localhost:8080`
- 容器确认包含 14 套主题和 17 份 Markdown 教程/示例;生产 Bundle
同时包含代码自动换行、任意长单词断行和 `8px` 左侧内边距。
- 容器代码块回归 PDF 返回 HTTP 200,共 2 页、64,721 字节,
Mermaid/ECharts 错误数均为 0;视觉检查确认长 SQL 正确换行、跨页
连续、24 个字段完整且没有右侧越界。
- 本机 NSIS 安装项版本为 `0.5.1`,发布者为 `YIXIONG Tech.ltd`
用户完成安装和桌面端手动验收,未发现发布阻塞问题。
2026-07-28 在当前完整工作区成功执行:
```text ```text
npm test npm test
@@ -243,17 +594,113 @@ git diff --check
结果: 结果:
- 共享配置测试:9 项通过; - ECharts 围栏测试:36 项通过;
- 渲染器测试:8 项通过; - Core 共享协议测试:15 项通过;
- 前端测试:55 项通过; - 渲染器测试:14 项通过;
- 应用服务测试:11 项通过;
- Preview Engine 测试:53 项通过;
- 前端测试:54 项通过;
- 后端测试:29 项通过; - 后端测试:29 项通过;
- 桌面端测试:26 项通过;
- 全项目共 238 项测试通过;
- 全项目类型检查通过; - 全项目类型检查通过;
- 生产构建通过; - 生产构建通过;
- `git diff --check` 通过。 - `git diff --check` 通过。
`v0.5.0` 双端与发行验收结果:
- 正式镜像 `yixiong/md-to-pdf:v0.5.0` 内容 ID 为
`sha256:13a0955f3629e4e96d7af9d38230ead5ff6b8b70be2b35d5f8da9fdeb1f66474`
大小 640,294,504 字节;Compose 使用该内容 ID 健康运行在
`http://localhost:8080`
- Web 快速、连续和精确预览完成真实浏览器验证:连续模式只有外层滚动,
ECharts 使用完整内容宽度和配置高度;快速预览后缀编辑保留稳定前缀。
- Web 外链样式、受控新窗口和本地链接禁用通过;精确 PDF 注释层保留
HTTP/HTTPS 与锚点,并能将 `.invalid` 地址解码为原始本地路径。
- Desktop 多窗口、同一文件单例、主窗口关闭后的状态接管、最大化防抖
写入和重启恢复通过;本机安装版已升级至 v0.5.0。
- 围栏代码块在 Typora Github 中显示完整外框且内部不再逐行出现行内
代码方框;Web 与最终 Desktop 安装版均由用户确认,ECharts 围栏未受
普通代码块兼容样式影响。
- 正式 NSIS 为 `md-to-pdf-0.5.0-x86_64-Setup.exe`95,004,759
字节,SHA-256 为
`60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92`
- 正式 ZIP 为 `md-to-pdf-0.5.0-x86_64.zip`132,485,574 字节,
SHA-256 为
`D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8`
- 安装版内嵌 `paged-preview` Bundle 与本地最新 Web Bundle 的
SHA-256 均为
`BD29762A2E3CF1A2AAC4DD055D623B42C754DF46A3E773DD99B01CDFDAE36CE5`
`.md``.markdown` OpenWithProgids 注册正常。
`v0.4.5` 双端与媒体分页验收结果:
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
`sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`
Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`
- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF
Mermaid 与 ECharts 错误数均为 0。
- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只
出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。
- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、
928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。
- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过;
`output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。
- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题
目录能够打开且可识别已有主题。
- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸
测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。
- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`
94,998,489 字节,SHA-256 为
`9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`
- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`132,477,245 字节,
SHA-256 为
`97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`
- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN``en-US` 语言包,
Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。
- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、
桌面与开始菜单快捷方式、`.md``.markdown` 打开注册均验证通过,
安装版应用启动正常。
Electron 桌面端第一阶段验证结果:
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
`v0.4.0`
- 最小 Markdown 通过隐藏 Electron Chromium 生成 1 页、52,308 字节
PDF,文字可搜索,并通过桌面原生保存通道落盘。
- 默认示例通过桌面端生成 4 页、439,501 字节 A4 PDFMermaid 和
10 个 ECharts 图表错误数均为 0。
- 将默认示例全部 4 页渲染为 PNG 后逐页检查,Tree、Treemap、Sunburst、
Graph、Sankey、Chord、Funnel、Gauge 和 PictorialBar 均完整显示,
未发现空白页、黑块、跨页切割或明显裁切。
- PDF.js 可以从 4 页中提取 670 个非空白字符,各页均包含可搜索文字。
- Forge 成功生成 Windows x64 未签名目录包,生产窗口通过
`mdpdf://bundle/index.html` 加载,版本徽标和界面静态资源正常。
- 精简后的 `app.asar` 约 554 KiB,Web 静态资源共 134 个文件、约
9.0 MiB;运行目录只包含 Electron 自带的一套 Chromium。
已使用浏览器插件完成视觉验证: 已使用浏览器插件完成视觉验证:
- 重构后的初始 Markdown 和主题资源加载正常,快速预览生成 1 页 - 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位
- 顶栏文件名、分页状态与操作按钮在桌面和窄屏均无重叠;
- 桌面端源文本收起后宽度从 576px 降为 48px,预览区从 864px 扩展
到 1392px;窄屏折叠条为 44px
- 快速预览输入第 2 页后目标页顶端对齐 iframe 视口,滚动回首页和第二页
时输入框分别同步为 1 和 2
- 页码输入 0 和 99 时分别限制到 1 和总页数;
- 精确预览输入第 2 页后,目标页与桌面滚动容器顶部误差为 0;
- 快速预览改用外层滚动容器后,右侧容器边界为 1440px,纸张边界约为
1400px,滚动条稳定贴在容器最右侧而非纸张边缘;
- 快速预览输入第 1 页后目标页与外层可视区域顶部误差为 0,外层滚动到
第 2 页时页码输入框同步更新为 2;
- 快速预览在 50% 和 150% 下的可滚动高度分别随内容显示高度缩放,
iframe 自身保持无滚动;切换精确/快速模式后仍使用同一右侧滚动条;
- 桌面端收起源文本后,预览滚动容器从 48px 延伸至窗口右边缘,纸张仍
在扩展后的预览区域内居中;
- 窄屏精确预览自动使用主窗口滚动,并正确跳转到第 2 页;
- 页码控件在窄屏独占首行,缩放控件在下一行铺满,没有与预览模式或主题
选择器重叠;
- 当前默认 Markdown 和主题资源加载正常,快速预览生成 2 页;
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘; - 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建; - 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
- 本轮浏览器回归期间控制台无警告或错误; - 本轮浏览器回归期间控制台无警告或错误;
@@ -262,13 +709,46 @@ git diff --check
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、 - 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
`themeVariables``style``classDef` 的 Mermaid 样例在快速预览 `themeVariables``style``classDef` 的 Mermaid 样例在快速预览
与精确预览中均成功渲染; 与精确预览中均成功渲染;
- 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG - 在 Windows 150% 系统显示缩放的笔记本内屏上,65 页附件的快速预览
生成 64 页,67 个 Mermaid 全部使用静态 SVG
- 同一附件的精确预览和最终 PDF 均为 65 页; - 同一附件的精确预览和最终 PDF 均为 65 页;
- 切换到 Windows 100% 系统显示缩放的外接显示器后,快速预览与精确预览
的分页结果基本一致,进一步排除了 Chromium 版本差异这一判断方向;
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误; - 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
- PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放; - PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放;
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致; - Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。 - 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
ECharts 第二阶段浏览器与 PDF 验证结果:
- 默认示例生成 10 个 ECharts SVG,包含原柱状图和九种新增系列,错误
占位数为 0,浏览器控制台无警告或错误;
- 快速预览生成 6 页,第 2~6 页各放置两张完整图表,所有图表边界均位于
当前页内容区内,没有跨页切割;
- 精确预览同样生成 6 页,逐页跳转正常;Chord、Funnel、Gauge 和
PictorialBar 的 PDF Canvas 视觉检查通过;
- 使用默认导出配置调用 PDF API 生成 4 页 PDF,响应中的 ECharts 和
Mermaid 错误数均为 0
- PDF 中九种新增图表的标题均可搜索;将全部页面渲染为 PNG 后,Tree、
Treemap、Sunburst、Graph、Sankey、Chord、Funnel、Gauge 和
PictorialBar 均显示完整,未发现裁切、重叠、黑块或空白图表。
- 修正 Treemap 示例后再次检查快速与精确预览:底部下钻面包屑已隐藏,
五个叶节点边界清晰;SVG 中父级和叶节点共 7 个标签均处于可见状态。
- 进一步确认此前 Treemap 标签虽存在于 SVG,但被冻结在入场动画初始帧,
`fill-opacity` 约为 `0.000014`,肉眼近似完全透明;全局关闭动画后,
快速预览和精确 PDF 中的 2 个父级及 5 个叶节点标签均清晰可见。
2026-07-27 已重新构建并启动本地发布候选镜像:
- 使用完整多阶段 `deploy/Dockerfile` 构建
`yixiong/md-to-pdf:v0.3.0`,镜像大小约 612 MB
- Compose 强制重建后容器健康检查通过,服务继续监听
`http://localhost:8080`
- 首页返回 200`POST /api/render` 的 ECharts 冒烟请求返回
`code``echarts` 功能标识和 `md-echarts` 安全占位;
- 容器内 `@md-to-pdf/markdown-echarts` 工作区包可正常加载;
- 当前镜像已通过发布前手动验收,并作为 `v0.3.0` 发布镜像保留运行。
此前主题阶段已使用浏览器插件完成视觉验证: 此前主题阶段已使用浏览器插件完成视觉验证:
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md` - 导入并打开 `tmp/数据中台项目周报_2026_W30.md`
@@ -295,7 +775,105 @@ git diff --check
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过; - 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。 - 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080` `v0.3.0` 发布验收完成后,已停止并删除本项目 Compose 容器及网络以释放
本地资源;`yixiong/md-to-pdf:v0.3.0` 镜像继续保留。
2026-07-27 已构建并启动 `v0.3.1` 发布候选:
- 使用完整多阶段 Dockerfile 构建 `yixiong/md-to-pdf:v0.3.1`,镜像
大小约 612 MB
- Compose 容器使用该镜像运行并通过健康检查,服务监听
`http://localhost:8080`
- 容器首页返回 200,默认 Markdown 解析出 10 个 ECharts 围栏,
错误占位和渲染警告均为 0
- 容器生成的默认配置 PDF 为 4 页、约 521 KiBECharts 和 Mermaid
渲染错误数均为 0
- ECharts 已编译进按需加载的前端浏览器 Bundle,生产运行时不需要保留
独立的 `echarts` Node.js 包;
- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。
2026-07-27 已完成 `v0.4.0` 最终发布候选验证:
- 生成本地镜像 `yixiong/md-to-pdf:v0.4.0`,镜像内容 ID 为
`sha256:1bf4a136e9b822fa0925070cd522ea2adbad8b388d8e039d6509024d466666b3`
- Compose 强制替换为 v0.4.0 后健康检查通过,首页返回 200,健康接口
返回 `ok`,主题接口返回 4 个 `bundled` 主题;
- 容器 Chromium 生成中文、表格、KaTeX、代码和 Mermaid 验收 PDF
HTTP 200、1 页、147,374 字节,Mermaid/ECharts 错误数均为 0
- PDF 渲染为 PNG 后检查通过,中文、公式、表格、代码、图表、页边距和
页码均正常,无裁切、重叠、黑块或空白;
- Compose 网页真实加载后标题旁显示 `v0.4.0`,主题选择器包含 4 个内置
主题,多页预览和图表正常,浏览器控制台无错误;
- Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB,
当前 Authenticode 状态为未签名。
2026-07-27 已完成 `v0.4.1` 图片资源发布候选验证:
- 用户提供的 `tmp/数据工程规划.md` 成功解析 2 张本地 PNG,生成约
5.0 MB 内嵌 HTML,图片自然宽度均为 8192px,零资源告警。
- Web 真实选择 Markdown 与 `.assets` 目录后生成 4 页快速预览,两张
本地图片均完整位于各自页面内容区。
- Wikimedia SVG、NASA JPEG 和 Unsplash JPEG 三种公网图片均完成下载、
格式识别、Data URL 内嵌和自然尺寸解码。
- 1200×1800 超高网络图在扣除标题高度后缩放;最终图片约 977px 高,
图加标题约 1001px 高,完整位于同一页且未复制、裁切或跨页。
- 默认示例的快速预览和精确预览均为 7 页,两个图片标题可见;容器
Playwright PDF 的精确预览同样为 7 页。
- Electron 目录版显示 `v0.4.1`,使用 Electron 内置 Chromium 成功渲染
两张默认网络图片;Windows 主程序仍为 `md-to-pdf.exe`
- 最终 Squirrel 安装包为
`Markdown PDF 导出器-0.4.1 Setup.exe`144,166,400 字节,SHA-256
`FC6687494255EE21224B65C1CA2D520316D568CCE8EB99A608A0DEF160611F4E`
Authenticode 状态为未签名。
- 本地镜像 `yixiong/md-to-pdf:v0.4.1` 内容 ID 为
`sha256:1ded8a8a11ecb8b69eaa116bb9129dd4ec039e9918b047169fe5f8add2af3a80`
Compose 容器健康运行在 `http://localhost:8080`
2026-07-27 已完成 `v0.4.2` 桌面维护版本验证:
- 仅重新构建桌面端及其内嵌 Web 静态资源,没有重新构建或替换
Web/Compose 发布镜像。
- Windows 目录版视觉检查通过:默认原生菜单栏已经移除,应用内容直接
位于系统标题栏下方,版本徽标显示 `v0.4.2`
- Windows 主程序继续使用英文文件名 `md-to-pdf.exe`
- Squirrel 安装包为 `Markdown PDF 导出器-0.4.2 Setup.exe`
144,166,400 字节,SHA-256 为
`59DA57522C99EB6B16A9591AFDEC349B785BF9B76950504B314273520152D41B`
Authenticode 状态为未签名。
2026-07-27 已完成 `v0.4.3` 标准安装向导验证:
- NSIS 中文辅助安装向导正确显示安装范围和安装目录选择,版本显示为
`0.4.3`
- 当前用户安装成功,安装目录为
`%LOCALAPPDATA%\Programs\md-to-pdf`,卸载项版本为 `0.4.3`
- 桌面和开始菜单快捷方式均存在,实际目标均为安装目录中的
`md-to-pdf.exe`;从开始菜单快捷方式启动成功。
- 静默卸载返回 0,安装目录、桌面快捷方式和开始菜单目录全部清理;
随后重新安装成功,最终本机保持已安装状态。
- 正式安装包为 `md-to-pdf-0.4.3-x86_64-Setup.exe`
103,346,230 字节,SHA-256 为
`C027B4AC604889C7E7E00BA3CE08A12616312BC9250268775B1B7B578A7C4817`
Authenticode 状态为未签名。
- 免安装包为 `md-to-pdf-0.4.3-x86_64.zip`143,784,617 字节,
SHA-256 为
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`
2026-07-27 已完成 `v0.4.4` 桌面语言包精简验证:
- 目录版只包含 `zh-CN.pak``en-US.pak`,语言资源从 55 个、
48,911,653 字节降为 2 个、1,137,843 字节。
- 目录版总大小从 378,084,454 字节降为 330,310,644 字节,减少
47,773,810 字节;Chromium 图形与软件渲染后备组件均保持完整。
- Windows 主程序版本为 `0.4.4`,目录版成功启动并保持应用、GPU、
渲染器等 4 个 Electron 进程正常运行。
- 正式安装包为 `md-to-pdf-0.4.4-x86_64-Setup.exe`94,991,759 字节,
SHA-256 为
`0827FD87A8EEC8B034DF738909CB6A20231245B507AE4A0BAA579AFA03A942DE`
Authenticode 状态为未签名。
- 免安装包为 `md-to-pdf-0.4.4-x86_64.zip`132,470,241 字节,
SHA-256 为
`D6CDB8DAE30676BAB6B2A037567164476AD294588B102A6FBBE9458ABCE5CCC0`
## 5. 当前注意事项 ## 5. 当前注意事项
@@ -304,14 +882,23 @@ git diff --check
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和 - `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
渲染错误状态。 渲染错误状态。
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。 - `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。 - `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。 - `packages/preview-engine/src/paged-document-runtime.ts` 是连续预览、
- `apps/web/src/mermaid-page-fit.ts` 负责超高 Mermaid 的单页等比例适配 快速预览和 PDF 共用的媒体渲染、资源等待和 Paged.js 运行时
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。 - `packages/preview-engine/src/continuous-preview.ts`
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理 `incremental-pagination.ts` 分别负责无分页 DOM 更新和后缀分页缓存
- `packages/preview-engine/src/paged-preview.ts` 负责分页协议、共享文档
CSS、页眉页码和 Typora 代码围栏结构兼容。
- Mermaid、ECharts、图片适配、媒体回填及跨页表格实现均位于
`packages/preview-engine/src/`,不得在 Web 或 Desktop 复制第二套。
- `apps/web/src/PrecisePdfPreview.tsx``pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。 - `apps/web/src/PrecisePdfPreview.tsx``pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
- `apps/web/src/document-link.ts``pdf-annotation-links.ts` 负责 Web
链接动作和精确 PDF 注释层适配。
- `apps/web/src/PreviewPageControl.tsx``preview-page.ts` 负责当前页输入、
页码边界、滚动页识别和快速跳转。
- `apps/web/src/preview-zoom.ts``PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。 - `apps/web/src/preview-zoom.ts``PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
- `packages/markdown-echarts` 负责 ECharts YAML 协议、验证、安全过滤、
Markdown-it 插件、浏览器 SVG 渲染和分页样式。
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。 - `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。 - `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。 - `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
@@ -319,23 +906,40 @@ git diff --check
- `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的 - `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的
共享模型。 共享模型。
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。 - `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。 - `packages/application` 负责共享 Markdown 渲染和主题应用服务;
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交 `apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。 - `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。 多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现 组装、协议和生命周期
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。 - `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/` 只用于外挂自定义主题;四套发布主题已经内置,无需
重复挂载。历史副本保存在 `.local/theme-backups`,均被 Git 忽略。
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
未来改变分发范围前必须重新完成许可证审查。
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
目录读取;远程图片仅保留有界内存缓存。
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
导出,不依赖 Fastify 或本地 HTTP Server。
- `v0.5.0` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
Compose 健康检查,当前 Compose 容器保持运行。
- Desktop `package``make` 必须经过 `build:embedded-web`;禁止直接
调用 electron-builder 复制未经本轮编译的 `apps/web/dist`
## 6. 推荐接手顺序 ## 6. 推荐接手顺序
### 阶段一:资源与安全 ### 阶段一:v0.5.x 稳定性与性能观测
- Markdown 文件夹或 ZIP - 为增量分页补充超长文档性能基线,记录稳定前缀页数和后缀重算耗时
- 相对图片解析 - 覆盖 Windows 100%、125% 和 150% 显示缩放的快速/精确分页差异
- 每请求独立临时目录 - 扩展 Desktop 多窗口、文件外部变化和链接打开的真实端到端回归
- 路径穿越和符号链接越界防护; - 持续检查连续预览中媒体宽高、单滚动容器和资源释放。
- 文件数量、单文件大小、总大小和超时限制;
- 请求结束可靠清理。
### 阶段二:导出配置扩展 ### 阶段二:导出配置扩展
@@ -346,7 +950,7 @@ git diff --check
## 7. 首版验收目标 ## 7. 首版验收目标
- 可以选择或粘贴 Markdown - 可以选择或粘贴 Markdown
- 网页正确预览常用 Markdown、公式Mermaid - 网页正确预览常用 Markdown、公式Mermaid 和 ECharts
- 可以下载真实 PDF - 可以下载真实 PDF
- PDF 文本可搜索; - PDF 文本可搜索;
- 预览与 PDF 基本一致; - 预览与 PDF 基本一致;
+7 -3
View File
@@ -13,7 +13,7 @@ themes/<theme-id>/ 随项目分发的内置主题
.local/themes/<theme-id>/ 仅供当前设备使用的本地主题 .local/themes/<theme-id>/ 仅供当前设备使用的本地主题
``` ```
- 内置主题的 `bundled` 必须为 `true`,并具有允许项目分发的明确许可证 - 内置主题的 `bundled` 必须为 `true`,并记录来源、许可证或内部使用边界
- 本地主题的 `bundled` 必须为 `false`,整个 `.local/` 目录已被 Git 忽略。 - 本地主题的 `bundled` 必须为 `false`,整个 `.local/` 目录已被 Git 忽略。
- 主题目录名必须与清单中的 `id` 完全一致。 - 主题目录名必须与清单中的 `id` 完全一致。
@@ -262,11 +262,15 @@ npm run theme:import-typora -- --replace
.local/theme-backups/ .local/theme-backups/
``` ```
Typora 当前没有为这些文件提供允许项目再分发的明确许可证,因此导入结果只能保存在 `.local`,不得提交到 Git、打包进容器或随项目发布。 Typora 当前没有为这些文件提供允许公开再分发的明确许可证。普通导入结果
仍默认保存在 `.local`。本项目从 v0.4.0 起按公司内部私有项目决策,将
GitHub、Pixyll 和 Whitey 三套主题复制为内置资源,并在
`themes/INTERNAL_THEME_NOTICE.md` 记录来源和内部使用边界;如未来公开、
商用或向公司外部分发,必须重新完成许可证审查并替换或取得授权。
## 10. 提交内置主题前检查 ## 10. 提交内置主题前检查
- 许可证明确允许再分发,并在清单和说明中记录。 - 许可证明确允许目标范围内使用,或已在清单和内部说明中记录限制
- 主题目录不包含用户文档、临时文件或无关资源。 - 主题目录不包含用户文档、临时文件或无关资源。
- `npm test` 通过。 - `npm test` 通过。
- `npm run typecheck` 通过。 - `npm run typecheck` 通过。
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title description">
<title id="title">Markdown PDF Logo</title>
<desc id="description">青绿色折角文档与 M 形透明负空间组成的标志</desc>
<path
fill="#32B8AE"
fill-rule="evenodd"
d="
M 262 64
H 650
V 198
Q 650 254 706 254
H 840
V 850
Q 840 928 762 928
H 262
Q 184 928 184 850
V 142
Q 184 64 262 64
Z
M 282 792
V 498
Q 282 462 318 462
Q 334 462 350 478
L 512 640
L 674 478
Q 690 462 706 462
Q 742 462 742 498
V 792
H 650
V 626
L 548 728
Q 512 764 476 728
L 374 626
V 792
Z
"
/>
<path
fill="#FFD166"
d="
M 678 76
Q 694 76 710 92
L 812 194
Q 828 210 828 226
H 716
Q 678 226 678 188
Z
"
/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

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