Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e6559c2c | ||
|
|
58087d0c7e | ||
|
|
925b0d1485 | ||
|
|
ac54ce46be |
@@ -3,12 +3,14 @@ dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
.conda_env
|
||||
.local/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
.serena/
|
||||
tmp/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
||||
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
|
||||
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
|
||||
- 支持主题清单与 CSS 主题扩展;
|
||||
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
||||
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX、Mermaid 和 ECharts;
|
||||
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
|
||||
- 使用 Docker Compose 在内网部署;
|
||||
- 文档只在当前请求中临时处理,不建立文档历史数据库。
|
||||
|
||||
@@ -75,15 +76,19 @@ flowchart LR
|
||||
|
||||
- 前端:React、TypeScript、Vite。
|
||||
- 后端:Node.js、TypeScript、Fastify。
|
||||
- 桌面端:Electron、electron-builder、NSIS。
|
||||
- 应用服务:`packages/application`。
|
||||
- 共享模型:`packages/core`。
|
||||
- Markdown 渲染:`packages/renderer`。
|
||||
- PDF 引擎:计划使用 Playwright Chromium。
|
||||
- 连续预览与分页引擎:`packages/preview-engine`。
|
||||
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
||||
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
||||
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
||||
- 元数据:gray-matter。
|
||||
- 安全过滤:sanitize-html。
|
||||
- 代码高亮:highlight.js。
|
||||
- 数学公式:KaTeX。
|
||||
- 图表:Mermaid。
|
||||
- 图表:Mermaid、ECharts。
|
||||
- 校验:Zod。
|
||||
- 测试:Vitest。
|
||||
- 部署:Docker、Docker Compose。
|
||||
@@ -92,12 +97,15 @@ flowchart LR
|
||||
|
||||
```text
|
||||
apps/web/ 前端编辑、配置与预览界面
|
||||
apps/server/ HTTP API、主题读取及未来 PDF 服务
|
||||
apps/server/ HTTP API、主题读取和 Playwright PDF 服务
|
||||
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
|
||||
packages/application/ 共享应用服务、主题注册和图片资源处理
|
||||
packages/core/ 导出配置和主题清单等共享模型
|
||||
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
||||
themes/ 内置主题及未来可安装主题
|
||||
docker/ 容器化配置
|
||||
tests/ 跨包和端到端测试
|
||||
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
||||
packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||
themes/ 内置主题
|
||||
deploy/ Docker、Compose、Nginx 和部署文档
|
||||
docs/ 进度、架构和部署文档
|
||||
```
|
||||
|
||||
@@ -186,6 +194,11 @@ chore: 初始化项目骨架
|
||||
```
|
||||
|
||||
- 不修改或重写已有提交,除非用户明确要求。
|
||||
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
|
||||
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
|
||||
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
|
||||
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
|
||||
记录本版本更新,不得使用只有版本号的标签说明。
|
||||
|
||||
## 9. 当前交接入口
|
||||
|
||||
|
||||
@@ -1,26 +1,101 @@
|
||||
# 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 编辑与本地文件读取;
|
||||
- Fastify 预览接口;
|
||||
- 共享导出配置模型;
|
||||
- 动态主题清单、CSS 和静态资源接口;
|
||||
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
||||
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
||||
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
|
||||
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
|
||||
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
|
||||
- 快速预览与精确预览共用的 50%~400% 显示缩放;
|
||||
- 内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题;
|
||||
- Web 素材目录上传、桌面同目录相对图片和受限公网图片下载;
|
||||
- 图片标题、整块分页及超高图片单页等比例适配;
|
||||
- 无本地 Server 的 Electron 桌面端与 Windows 安装包;
|
||||
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
|
||||
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
|
||||
以及 3 套正式文档主题和 3 套标书主题;
|
||||
- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
|
||||
16mm 默认页边距,选择正式主题时自动采用其规范参数;
|
||||
- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
|
||||
签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
|
||||
- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
|
||||
字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
|
||||
同时保留 `8px` 左侧内容留白;
|
||||
- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown;
|
||||
Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
|
||||
- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
|
||||
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
|
||||
Web 端隐藏不适用的“另存为”。
|
||||
|
||||
## Web 与桌面端
|
||||
|
||||
| 能力 | Web / Docker | Windows Desktop |
|
||||
| --- | --- | --- |
|
||||
| 新建 Markdown | 支持 | 支持 |
|
||||
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
|
||||
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
||||
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
||||
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
|
||||
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
|
||||
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
|
||||
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||
|
||||
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
||||
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
||||
|
||||
## 渲染架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
||||
B --> C["统一 #write HTML"]
|
||||
D["主题 CSS 与导出配置"] --> C
|
||||
C --> E["共享 Preview Engine"]
|
||||
E --> F["连续预览"]
|
||||
E --> G["快速分页预览"]
|
||||
E --> H["Playwright Chromium"]
|
||||
E --> I["Electron Chromium"]
|
||||
H --> J["Web PDF"]
|
||||
I --> K["Desktop PDF"]
|
||||
```
|
||||
|
||||
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
|
||||
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
|
||||
载荷和运行时。
|
||||
|
||||
## 项目目录
|
||||
|
||||
```text
|
||||
apps/
|
||||
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
|
||||
server/ Fastify API、Playwright PDF 与容器运行入口
|
||||
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
|
||||
packages/
|
||||
core/ 跨端文档、导出配置和主题协议
|
||||
renderer/ Markdown 到安全 #write HTML 的渲染管线
|
||||
application/ 共享渲染、主题注册和图片资源应用服务
|
||||
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
|
||||
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||
themes/ 内置主题与主题清单
|
||||
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
||||
docs/ 项目进度与主题开发说明
|
||||
scripts/ 主题导入等维护脚本
|
||||
logos/ 应用图标源文件
|
||||
.local/themes/ 本地开发和 Compose 自定义主题目录
|
||||
```
|
||||
|
||||
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
|
||||
提供独立图表能力;`renderer` 消费两者生成安全 HTML;`application`
|
||||
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
|
||||
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
|
||||
|
||||
各包的目录和用法:
|
||||
|
||||
- [application](packages/application/README.md)
|
||||
- [core](packages/core/README.md)
|
||||
- [renderer](packages/renderer/README.md)
|
||||
- [markdown-echarts](packages/markdown-echarts/README.md)
|
||||
- [preview-engine](packages/preview-engine/README.md)
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -33,118 +108,69 @@ npm run dev
|
||||
|
||||
默认地址:
|
||||
|
||||
- 前端:http://localhost:5173
|
||||
- 后端:http://localhost:3001
|
||||
- Web:http://localhost:5173
|
||||
- API:http://localhost:3001
|
||||
- 健康检查:http://localhost:3001/api/health
|
||||
|
||||
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。若 Markdown 使用
|
||||
相对图片路径,再选择对应的素材目录即可;文件与图片只进入当前渲染请求,
|
||||
不写入数据库。桌面端使用原生“打开 Markdown”对话框,并自动从 Markdown
|
||||
所在目录安全解析相对图片。
|
||||
桌面端开发:
|
||||
|
||||
独立一行的 `` 会显示图片标题。图片与标题作为一个整体
|
||||
参与分页;超出单页内容区时,图片会在预留标题高度后等比例缩放,不跨页
|
||||
切割。远程图片会先由应用服务受限下载并内嵌,失败时显示可诊断占位。
|
||||
|
||||
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
|
||||
|
||||
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
|
||||
|
||||
## Mermaid 配置与样式
|
||||
|
||||
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
|
||||
|
||||
- 布局:Dagre、ELK;
|
||||
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
|
||||
- 外观:Classic、Hand-drawn、Neo;
|
||||
- 字体:任意本地 CSS 字体族。
|
||||
|
||||
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
layout: elk
|
||||
theme: base
|
||||
look: handDrawn
|
||||
fontFamily: Microsoft YaHei
|
||||
themeVariables:
|
||||
primaryColor: "#dbeafe"
|
||||
primaryBorderColor: "#2563eb"
|
||||
flowchart:
|
||||
wrappingWidth: 360
|
||||
---
|
||||
flowchart TB
|
||||
classDef important fill:#fee2e2,stroke:#dc2626
|
||||
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
|
||||
style A fill:#dcfce7,stroke:#16a34a
|
||||
class B important
|
||||
```powershell
|
||||
npm run desktop:dev
|
||||
```
|
||||
````
|
||||
|
||||
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
|
||||
桌面端目录包和 Windows 安装包:
|
||||
|
||||
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
|
||||
|
||||
### Mermaid 长标签
|
||||
|
||||
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
|
||||
|
||||
需要自动换行时可以使用 Mermaid Markdown String:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
|
||||
```powershell
|
||||
npm run desktop:package
|
||||
npm run make -w @md-to-pdf/desktop
|
||||
```
|
||||
````
|
||||
|
||||
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`:
|
||||
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,不能
|
||||
复用陈旧的 `apps/web/dist`。产物位于 `apps/desktop/out/v0.5.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 围栏。
|
||||
|
||||
公司内部版本已经内置 GitHub、Pixyll 和 Whitey 三套适合打印的白色
|
||||
Typora 默认主题。如需从本机 Typora 安装目录刷新本地开发副本,可以运行:
|
||||
独立一行的 `` 会生成带标题的图片块。分页引擎将图片、
|
||||
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
|
||||
npm run theme:import-typora
|
||||
```
|
||||
|
||||
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。
|
||||
|
||||
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
|
||||
|
||||
```powershell
|
||||
npm run theme:import-typora -- --replace
|
||||
```
|
||||
|
||||
旧副本会移动到 `.local\theme-backups`。
|
||||
|
||||
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
|
||||
|
||||
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
|
||||
|
||||
## Docker Desktop 部署
|
||||
## Docker Compose 部署
|
||||
|
||||
```powershell
|
||||
docker compose -f deploy\compose.yaml up --build -d
|
||||
```
|
||||
|
||||
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
|
||||
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
|
||||
[AIO 容器部署说明](deploy/README.md)。
|
||||
默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
|
||||
配置详见 [AIO 容器部署说明](deploy/README.md)。
|
||||
|
||||
## 测试与构建
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
@@ -153,8 +179,18 @@ npm run build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 隐私原则
|
||||
涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、
|
||||
Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端
|
||||
验证,并检查 PDF 页面渲染结果。
|
||||
|
||||
文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
||||
相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程
|
||||
地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。
|
||||
## 安全与隐私
|
||||
|
||||
- 不建立 Markdown、图片或 PDF 历史数据库;
|
||||
- 文档和资源只在当前请求或当前桌面会话中处理;
|
||||
- Markdown 原始 HTML 不执行,输出经过白名单过滤;
|
||||
- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML;
|
||||
- 本地资源阻止路径穿越和符号链接越界;
|
||||
- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时;
|
||||
- PDF 页面阻止未授权外部网络访问。
|
||||
|
||||
当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。
|
||||
|
||||
+38
-6
@@ -1,9 +1,24 @@
|
||||
# Desktop 桌面端
|
||||
|
||||
`apps/desktop` 是 `v0.4.0` 新增的 Electron 桌面应用。当前已经完成
|
||||
Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择、
|
||||
同目录图片解析、隐藏分页窗口、Electron Chromium PDF 输出、原生 PDF
|
||||
另存为和 Windows 安装包验证。
|
||||
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
|
||||
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
|
||||
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
|
||||
安装包。
|
||||
|
||||
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
|
||||
一个窗口,其他文件可以同时使用多个窗口。
|
||||
|
||||
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
||||
重新加载,避免阅读或导出磁盘旧版本。
|
||||
|
||||
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
|
||||
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
|
||||
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
|
||||
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
|
||||
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -26,9 +41,13 @@ 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.4.3/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.4.3-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
`apps/desktop/out/v0.5.1/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.5.1-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
@@ -47,6 +66,19 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
|
||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||
`mdpdf://bundle/` 自定义协议加载。
|
||||
|
||||
## 链接与窗口行为
|
||||
|
||||
- 当前文档 `#anchor` 在当前预览中定位;
|
||||
- HTTP/HTTPS 链接交给系统默认浏览器;
|
||||
- `mailto:` 等系统协议交给操作系统处理;
|
||||
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
|
||||
- Markdown 文件会提示在当前窗口或新窗口打开;
|
||||
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
|
||||
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
|
||||
|
||||
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
!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" \
|
||||
@@ -11,10 +43,22 @@
|
||||
"" \
|
||||
"$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
|
||||
|
||||
@@ -11,6 +11,7 @@ module.exports = {
|
||||
productName: "Markdown PDF 导出器",
|
||||
copyright: "Copyright © YIXIONG Tech.ltd",
|
||||
asar: true,
|
||||
electronLanguages: ["zh-CN", "en-US"],
|
||||
directories: {
|
||||
output: `out/v${version}`
|
||||
},
|
||||
@@ -29,6 +30,11 @@ module.exports = {
|
||||
to: "themes",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: "../../samples",
|
||||
to: "samples",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: windowsIcon,
|
||||
to: "app.ico"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"productName": "Markdown PDF 导出器",
|
||||
"description": "Markdown PDF 导出器桌面端",
|
||||
@@ -9,12 +9,13 @@
|
||||
"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 && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||
"make": "npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||
"package": "npm run build:embedded-web && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||
"make": "npm run build: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"
|
||||
},
|
||||
|
||||
@@ -2,16 +2,73 @@ 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),
|
||||
|
||||
@@ -2,6 +2,32 @@ 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 =
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
+112
-205
@@ -1,44 +1,36 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
net,
|
||||
protocol,
|
||||
session,
|
||||
type IpcMainInvokeEvent
|
||||
session
|
||||
} from "electron";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
createApplicationService,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import { parseThemeResourceUrl } from "./application-contract.js";
|
||||
import {
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_OPEN_MARKDOWN,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF
|
||||
} from "./channels.js";
|
||||
DesktopApplicationController
|
||||
} from "./desktop-application-controller.js";
|
||||
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
parseThemeId,
|
||||
parseThemeResourceUrl
|
||||
} from "./application-contract.js";
|
||||
import {
|
||||
DESKTOP_PDF_PARTITION,
|
||||
ElectronPdfGenerator
|
||||
} from "./electron-pdf-generator.js";
|
||||
import { parsePagedDocumentPayload } from "./pdf-contract.js";
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath
|
||||
} from "./markdown-file.js";
|
||||
|
||||
const APP_SCHEME = "mdpdf";
|
||||
const APP_HOST = "bundle";
|
||||
const THEME_HOST = "theme";
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
let applicationController: DesktopApplicationController | undefined;
|
||||
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||
process.argv,
|
||||
process.cwd()
|
||||
);
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -91,15 +83,31 @@ function encodeThemeAssetPath(assetPath: string) {
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function dispatchExternalMarkdown(filePath: string) {
|
||||
if (!applicationController) {
|
||||
pendingExternalMarkdownPath = filePath;
|
||||
return;
|
||||
}
|
||||
void applicationController.openExternalMarkdown(filePath).catch(
|
||||
(error: unknown) => {
|
||||
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getDesktopThemeDirectory() {
|
||||
return app.isPackaged
|
||||
? path.join(app.getPath("userData"), "themes")
|
||||
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||
}
|
||||
|
||||
function createDesktopApplicationService() {
|
||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||
return createApplicationService({
|
||||
bundledRoot: app.isPackaged
|
||||
? path.join(process.resourcesPath, "themes")
|
||||
: path.join(projectRoot, "themes"),
|
||||
localRoot: app.isPackaged
|
||||
? path.join(app.getPath("userData"), "themes")
|
||||
: path.join(projectRoot, ".local", "themes"),
|
||||
localRoot: getDesktopThemeDirectory(),
|
||||
createAssetUrl: (themeId, assetPath) =>
|
||||
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
||||
onWarning: (message) => console.warn(message)
|
||||
@@ -158,12 +166,10 @@ async function registerApplicationProtocol(
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const target = path.resolve(webRoot, relativePath);
|
||||
if (!isContainedPath(webRoot, target)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(target);
|
||||
} catch {
|
||||
@@ -180,198 +186,99 @@ async function registerApplicationProtocol(
|
||||
]);
|
||||
}
|
||||
|
||||
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("will-navigate", (event, targetUrl) => {
|
||||
if (new URL(targetUrl).origin !== allowedOrigin) {
|
||||
event.preventDefault();
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
let windowStateReadyToQuit = false;
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", (_event, arguments_, workingDirectory) => {
|
||||
const filePath = findMarkdownFileArgument(
|
||||
arguments_,
|
||||
workingDirectory
|
||||
);
|
||||
if (filePath) {
|
||||
dispatchExternalMarkdown(filePath);
|
||||
} else if (!applicationController?.focusMostRecentWindow()) {
|
||||
void applicationController?.createWindow();
|
||||
}
|
||||
});
|
||||
const applicationSession = window.webContents.session;
|
||||
applicationSession.setPermissionCheckHandler(() => false);
|
||||
applicationSession.setPermissionRequestHandler(
|
||||
(_webContents, _permission, callback) => callback(false)
|
||||
);
|
||||
}
|
||||
|
||||
async function createApplication(
|
||||
applicationService: ApplicationService
|
||||
) {
|
||||
const developmentUrl = getCommandLineValue("web-url");
|
||||
const applicationUrl =
|
||||
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
||||
const renderUrl = new URL(
|
||||
"/preview-frame.html?target=pdf",
|
||||
applicationUrl
|
||||
).href;
|
||||
|
||||
const window = new BrowserWindow({
|
||||
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
||||
icon: getApplicationIcon(),
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1024,
|
||||
minHeight: 720,
|
||||
autoHideMenuBar: true,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: path.join(currentDirectory, "app-preload.cjs")
|
||||
app.on("open-file", (event, filePath) => {
|
||||
event.preventDefault();
|
||||
if (isMarkdownFilePath(filePath)) {
|
||||
dispatchExternalMarkdown(filePath);
|
||||
}
|
||||
});
|
||||
lockDownWindow(window, new URL(applicationUrl).origin);
|
||||
|
||||
const pdfGenerator = new ElectronPdfGenerator({
|
||||
renderUrl,
|
||||
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
|
||||
});
|
||||
const validateSender = (event: IpcMainInvokeEvent) =>
|
||||
event.sender === window.webContents &&
|
||||
event.senderFrame !== null &&
|
||||
new URL(event.senderFrame.url).origin ===
|
||||
new URL(applicationUrl).origin;
|
||||
let documentRoot: string | undefined;
|
||||
|
||||
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面文件请求");
|
||||
app.on("activate", () => {
|
||||
if (!applicationController?.focusMostRecentWindow()) {
|
||||
void applicationController?.createWindow();
|
||||
}
|
||||
const selection = await dialog.showOpenDialog(window, {
|
||||
title: "打开 Markdown",
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "Markdown 文件", extensions: ["md", "markdown"] },
|
||||
{ name: "文本文件", extensions: ["txt"] }
|
||||
]
|
||||
});
|
||||
app.on("before-quit", (event) => {
|
||||
if (windowStateReadyToQuit || !applicationController) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void applicationController.flushWindowState().finally(() => {
|
||||
windowStateReadyToQuit = true;
|
||||
app.quit();
|
||||
setTimeout(() => {
|
||||
windowStateReadyToQuit = false;
|
||||
}, 1_000);
|
||||
});
|
||||
const filePath = selection.filePaths[0];
|
||||
if (selection.canceled || !filePath) {
|
||||
return undefined;
|
||||
}
|
||||
const content = await readFile(filePath, "utf8");
|
||||
documentRoot = path.dirname(filePath);
|
||||
return {
|
||||
markdown: content,
|
||||
fileName: path.basename(filePath)
|
||||
};
|
||||
});
|
||||
ipcMain.handle(
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
async (event, unsafeRequest: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面渲染请求");
|
||||
}
|
||||
return applicationService.render(
|
||||
parseMarkdownRenderRequest(unsafeRequest),
|
||||
documentRoot ? { localRoot: documentRoot } : {}
|
||||
);
|
||||
}
|
||||
);
|
||||
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面主题请求");
|
||||
}
|
||||
return applicationService.listThemes();
|
||||
app.on("will-quit", () => {
|
||||
void applicationController?.closeResources();
|
||||
});
|
||||
ipcMain.handle(
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
async (event, unsafeThemeId: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面主题请求");
|
||||
}
|
||||
const css = await applicationService.getThemeCss(
|
||||
parseThemeId(unsafeThemeId)
|
||||
);
|
||||
if (css === undefined) {
|
||||
throw new Error("未找到指定主题");
|
||||
}
|
||||
return css;
|
||||
}
|
||||
);
|
||||
ipcMain.handle(
|
||||
DESKTOP_GENERATE_PDF,
|
||||
async (event, unsafePayload: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面 PDF 请求");
|
||||
}
|
||||
const payload = parsePagedDocumentPayload(unsafePayload);
|
||||
const result = await pdfGenerator.generate(payload);
|
||||
return {
|
||||
pdf: result.pdf,
|
||||
pageCount: result.pageCount,
|
||||
echartsErrors: result.echartsErrors,
|
||||
mermaidErrors: result.mermaidErrors
|
||||
};
|
||||
}
|
||||
);
|
||||
ipcMain.handle(
|
||||
DESKTOP_SAVE_PDF,
|
||||
async (
|
||||
event,
|
||||
unsafeFileName: unknown,
|
||||
unsafePdf: unknown
|
||||
) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面 PDF 保存请求");
|
||||
}
|
||||
if (
|
||||
typeof unsafeFileName !== "string" ||
|
||||
unsafeFileName.length === 0 ||
|
||||
unsafeFileName.length > 500 ||
|
||||
!(unsafePdf instanceof Uint8Array) ||
|
||||
unsafePdf.byteLength === 0 ||
|
||||
unsafePdf.byteLength > 200 * 1024 * 1024
|
||||
) {
|
||||
throw new Error("PDF 保存参数无效");
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
Menu.setApplicationMenu(null);
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||
}
|
||||
const themeDirectory = getDesktopThemeDirectory();
|
||||
await mkdir(themeDirectory, { recursive: true });
|
||||
const applicationService = createDesktopApplicationService();
|
||||
await registerApplicationProtocol(applicationService);
|
||||
|
||||
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
|
||||
? path.basename(unsafeFileName)
|
||||
: `${path.basename(unsafeFileName)}.pdf`;
|
||||
const selection = await dialog.showSaveDialog(window, {
|
||||
title: "导出 PDF",
|
||||
defaultPath: path.join(app.getPath("documents"), suggestedName),
|
||||
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
|
||||
const developmentUrl = getCommandLineValue("web-url");
|
||||
const applicationUrl =
|
||||
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
||||
applicationController = new DesktopApplicationController({
|
||||
applicationService,
|
||||
applicationUrl,
|
||||
renderUrl: new URL(
|
||||
"/preview-frame.html?target=pdf",
|
||||
applicationUrl
|
||||
).href,
|
||||
preloadPath: path.join(
|
||||
currentDirectory,
|
||||
"app-preload.cjs"
|
||||
),
|
||||
pdfPreloadPath: path.join(
|
||||
currentDirectory,
|
||||
"pdf-preload.cjs"
|
||||
),
|
||||
iconPath: getApplicationIcon(),
|
||||
themeDirectory,
|
||||
windowStatePath: path.join(
|
||||
app.getPath("userData"),
|
||||
"window-state.json"
|
||||
)
|
||||
});
|
||||
if (selection.canceled || !selection.filePath) {
|
||||
return false;
|
||||
}
|
||||
await writeFile(selection.filePath, unsafePdf);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
await applicationController.initialize();
|
||||
|
||||
window.once("ready-to-show", () => window.show());
|
||||
window.on("closed", () => {
|
||||
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
|
||||
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
|
||||
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
|
||||
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
|
||||
void pdfGenerator.close();
|
||||
});
|
||||
await window.loadURL(applicationUrl);
|
||||
const startupMarkdownPath = pendingExternalMarkdownPath;
|
||||
pendingExternalMarkdownPath = undefined;
|
||||
await applicationController.createWindow(startupMarkdownPath);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("桌面应用启动失败", error);
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
Menu.setApplicationMenu(null);
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||
}
|
||||
const applicationService = createDesktopApplicationService();
|
||||
await registerApplicationProtocol(applicationService);
|
||||
await createApplication(applicationService);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("桌面应用启动失败", error);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||
|
||||
export interface OpenedMarkdownDocument {
|
||||
markdown: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface MarkdownFileSnapshot {
|
||||
document: OpenedMarkdownDocument;
|
||||
filePath: string;
|
||||
documentKey: string;
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
export function createMarkdownContentHash(markdown: string) {
|
||||
return createHash("sha256").update(markdown, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function createMarkdownDocumentKey(filePath: string) {
|
||||
const normalized = path.normalize(filePath);
|
||||
return process.platform === "win32"
|
||||
? normalized.toLocaleLowerCase("en-US")
|
||||
: normalized;
|
||||
}
|
||||
|
||||
export function isMarkdownFilePath(filePath: string) {
|
||||
return markdownExtensions.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
export function findMarkdownFileArgument(
|
||||
arguments_: string[],
|
||||
workingDirectory: string
|
||||
) {
|
||||
for (const argument of arguments_) {
|
||||
if (!argument || argument.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.isAbsolute(argument)
|
||||
? path.normalize(argument)
|
||||
: path.resolve(workingDirectory, argument);
|
||||
if (isMarkdownFilePath(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readMarkdownDocument(
|
||||
filePath: string,
|
||||
maximumLength: number
|
||||
): Promise<OpenedMarkdownDocument> {
|
||||
if (!isMarkdownFilePath(filePath)) {
|
||||
throw new Error("只允许打开 .md 或 .markdown 文件");
|
||||
}
|
||||
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
|
||||
throw new Error("Markdown 长度限制无效");
|
||||
}
|
||||
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) {
|
||||
throw new Error("Markdown 路径不是文件");
|
||||
}
|
||||
if (fileStats.size > maximumLength * 4) {
|
||||
throw new Error("Markdown 文件过大");
|
||||
}
|
||||
|
||||
const markdown = await readFile(filePath, "utf8");
|
||||
if (markdown.length > maximumLength) {
|
||||
throw new Error("Markdown 文件过大");
|
||||
}
|
||||
return {
|
||||
markdown,
|
||||
fileName: path.basename(filePath)
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMarkdownFileSnapshot(
|
||||
filePath: string,
|
||||
maximumLength: number
|
||||
): Promise<MarkdownFileSnapshot> {
|
||||
const canonicalPath = await realpath(filePath);
|
||||
const document = await readMarkdownDocument(
|
||||
canonicalPath,
|
||||
maximumLength
|
||||
);
|
||||
return {
|
||||
document,
|
||||
filePath: canonicalPath,
|
||||
documentKey: createMarkdownDocumentKey(canonicalPath),
|
||||
contentHash: createMarkdownContentHash(document.markdown)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface WindowCloseState {
|
||||
dirty: boolean;
|
||||
allowClose: boolean;
|
||||
closeRequestPending: boolean;
|
||||
}
|
||||
|
||||
export type WindowCloseDecision = "allow" | "request" | "wait";
|
||||
|
||||
export function getWindowCloseDecision(
|
||||
state: WindowCloseState
|
||||
): WindowCloseDecision {
|
||||
if (!state.dirty || state.allowClose) {
|
||||
return "allow";
|
||||
}
|
||||
return state.closeRequestPending ? "wait" : "request";
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WindowState {
|
||||
version: 1;
|
||||
bounds: WindowBounds;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
export interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const WINDOW_STATE_VERSION = 1;
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function parseWindowBounds(value: unknown): WindowBounds | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const bounds = value as Partial<WindowBounds>;
|
||||
if (
|
||||
!isFiniteNumber(bounds.x) ||
|
||||
!isFiniteNumber(bounds.y) ||
|
||||
!isFiniteNumber(bounds.width) ||
|
||||
!isFiniteNumber(bounds.height) ||
|
||||
bounds.width <= 0 ||
|
||||
bounds.height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.round(bounds.width),
|
||||
height: Math.round(bounds.height)
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWindowState(value: unknown): WindowState | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const state = value as Partial<WindowState>;
|
||||
const bounds = parseWindowBounds(state.bounds);
|
||||
if (
|
||||
state.version !== WINDOW_STATE_VERSION ||
|
||||
!bounds ||
|
||||
typeof state.maximized !== "boolean"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: WINDOW_STATE_VERSION,
|
||||
bounds,
|
||||
maximized: state.maximized
|
||||
};
|
||||
}
|
||||
|
||||
function intersectionArea(left: WindowBounds, right: WindowBounds) {
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(left.x + left.width, right.x + right.width) -
|
||||
Math.max(left.x, right.x)
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(left.y + left.height, right.y + right.height) -
|
||||
Math.max(left.y, right.y)
|
||||
);
|
||||
return width * height;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
|
||||
const width = Math.min(
|
||||
Math.max(1, Math.round(size.width)),
|
||||
workArea.width
|
||||
);
|
||||
const height = Math.min(
|
||||
Math.max(1, Math.round(size.height)),
|
||||
workArea.height
|
||||
);
|
||||
return {
|
||||
x: Math.round(workArea.x + (workArea.width - width) / 2),
|
||||
y: Math.round(workArea.y + (workArea.height - height) / 2),
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWindowBounds(
|
||||
savedBounds: WindowBounds | undefined,
|
||||
workAreas: WindowBounds[],
|
||||
primaryWorkArea: WindowBounds,
|
||||
defaultSize: WindowSize,
|
||||
minimumSize: WindowSize
|
||||
): WindowBounds {
|
||||
if (!savedBounds) {
|
||||
return centerBounds(defaultSize, primaryWorkArea);
|
||||
}
|
||||
|
||||
let selectedArea = primaryWorkArea;
|
||||
let selectedIntersection = 0;
|
||||
for (const workArea of workAreas) {
|
||||
const area = intersectionArea(savedBounds, workArea);
|
||||
if (area > selectedIntersection) {
|
||||
selectedArea = workArea;
|
||||
selectedIntersection = area;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIntersection === 0) {
|
||||
return centerBounds(
|
||||
{
|
||||
width: Math.min(
|
||||
Math.max(savedBounds.width, minimumSize.width),
|
||||
primaryWorkArea.width
|
||||
),
|
||||
height: Math.min(
|
||||
Math.max(savedBounds.height, minimumSize.height),
|
||||
primaryWorkArea.height
|
||||
)
|
||||
},
|
||||
primaryWorkArea
|
||||
);
|
||||
}
|
||||
|
||||
const width = Math.min(
|
||||
Math.max(savedBounds.width, minimumSize.width),
|
||||
selectedArea.width
|
||||
);
|
||||
const height = Math.min(
|
||||
Math.max(savedBounds.height, minimumSize.height),
|
||||
selectedArea.height
|
||||
);
|
||||
return {
|
||||
x: clamp(
|
||||
savedBounds.x,
|
||||
selectedArea.x,
|
||||
selectedArea.x + selectedArea.width - width
|
||||
),
|
||||
y: clamp(
|
||||
savedBounds.y,
|
||||
selectedArea.y,
|
||||
selectedArea.y + selectedArea.height - height
|
||||
),
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWindowState(filePath: string) {
|
||||
try {
|
||||
return parseWindowState(
|
||||
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWindowState(
|
||||
filePath: string,
|
||||
state: WindowState
|
||||
) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
await rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
|
||||
|
||||
describe("桌面文档链接动作", () => {
|
||||
const markdownPath = path.resolve("D:/文档/章节/当前.md");
|
||||
|
||||
it("保留锚点并把网络和自定义协议交给系统", () => {
|
||||
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
|
||||
type: "anchor",
|
||||
href: "#标题"
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("//example.com/path")
|
||||
).toEqual({
|
||||
type: "external",
|
||||
href: "https://example.com/path"
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("mailto:user@example.com")
|
||||
).toEqual({
|
||||
type: "external",
|
||||
href: "mailto:user@example.com"
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
|
||||
).toEqual({
|
||||
type: "external",
|
||||
href: "obsidian://open?vault=docs"
|
||||
});
|
||||
});
|
||||
|
||||
it("允许相对路径越过 Markdown 目录", () => {
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(
|
||||
"../../共享/说明.pdf#page=2",
|
||||
markdownPath
|
||||
)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.resolve(
|
||||
path.dirname(markdownPath),
|
||||
"../../共享/说明.pdf"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
it("解析绝对路径、file URI 和 URL 编码", () => {
|
||||
const absolutePath = path.resolve("C:/资料/项目说明.md");
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.normalize(absolutePath)
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(
|
||||
pathToFileURL(absolutePath).href,
|
||||
markdownPath
|
||||
)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.normalize(absolutePath)
|
||||
});
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction(
|
||||
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
|
||||
markdownPath
|
||||
)
|
||||
).toEqual({
|
||||
type: "local",
|
||||
filePath: path.resolve(
|
||||
path.dirname(markdownPath),
|
||||
"../共享/说明.pdf"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝危险链接和无文件基准的相对路径", () => {
|
||||
expect(
|
||||
resolveDesktopDocumentLinkAction("javascript:alert(1)")
|
||||
).toEqual({ type: "ignore" });
|
||||
expect(() =>
|
||||
resolveDesktopDocumentLinkAction("../说明.pdf")
|
||||
).toThrow("尚未保存");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMarkdownContentHash,
|
||||
createMarkdownDocumentKey,
|
||||
findMarkdownFileArgument,
|
||||
isMarkdownFilePath,
|
||||
readMarkdownDocument,
|
||||
readMarkdownFileSnapshot
|
||||
} from "../src/markdown-file.js";
|
||||
|
||||
describe("桌面 Markdown 文件参数", () => {
|
||||
it("识别两种 Markdown 扩展名且忽略大小写", () => {
|
||||
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
|
||||
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
|
||||
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("从启动参数中提取绝对 Markdown 路径", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
[
|
||||
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
|
||||
"--original-process-start-time=1",
|
||||
"D:\\文档\\项目周报.md"
|
||||
],
|
||||
"C:\\Windows"
|
||||
)
|
||||
).toBe(path.normalize("D:\\文档\\项目周报.md"));
|
||||
});
|
||||
|
||||
it("根据第二实例的工作目录解析相对路径", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
["md-to-pdf.exe", "docs\\readme.markdown"],
|
||||
"D:\\项目"
|
||||
)
|
||||
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
|
||||
});
|
||||
|
||||
it("忽略非 Markdown 文件及 Chromium 参数", () => {
|
||||
expect(
|
||||
findMarkdownFileArgument(
|
||||
["md-to-pdf.exe", "--inspect", "notes.txt"],
|
||||
"C:\\项目"
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("统一读取 Markdown 并限制解码后的字符数", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-markdown-")
|
||||
);
|
||||
const filePath = path.join(directory, "示例.md");
|
||||
try {
|
||||
await writeFile(filePath, "# 示例", "utf8");
|
||||
await expect(
|
||||
readMarkdownDocument(filePath, 20)
|
||||
).resolves.toEqual({
|
||||
markdown: "# 示例",
|
||||
fileName: "示例.md"
|
||||
});
|
||||
await expect(
|
||||
readMarkdownDocument(filePath, 2)
|
||||
).rejects.toThrow("Markdown 文件过大");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("拒绝非 Markdown 扩展名", async () => {
|
||||
await expect(
|
||||
readMarkdownDocument("C:\\docs\\说明.txt", 100)
|
||||
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
|
||||
});
|
||||
|
||||
it("使用真实路径和内容哈希创建文件快照", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
|
||||
);
|
||||
const filePath = path.join(directory, "快照.md");
|
||||
try {
|
||||
await writeFile(filePath, "# 第一版", "utf8");
|
||||
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
||||
|
||||
expect(snapshot.document).toEqual({
|
||||
markdown: "# 第一版",
|
||||
fileName: "快照.md"
|
||||
});
|
||||
expect(snapshot.filePath).toBe(filePath);
|
||||
expect(snapshot.documentKey).toBe(
|
||||
createMarkdownDocumentKey(filePath)
|
||||
);
|
||||
expect(snapshot.contentHash).toBe(
|
||||
createMarkdownContentHash("# 第一版")
|
||||
);
|
||||
|
||||
await writeFile(filePath, "# 第二版", "utf8");
|
||||
const changed = await readMarkdownFileSnapshot(filePath, 100);
|
||||
expect(changed.contentHash).not.toBe(snapshot.contentHash);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadWindowState,
|
||||
parseWindowState,
|
||||
resolveWindowBounds,
|
||||
saveWindowState
|
||||
} from "../src/window-state.js";
|
||||
|
||||
const primaryWorkArea = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1040
|
||||
};
|
||||
const defaultSize = { width: 1440, height: 960 };
|
||||
const minimumSize = { width: 1024, height: 720 };
|
||||
|
||||
describe("桌面窗口状态", () => {
|
||||
it("校验并规范化持久化状态", () => {
|
||||
expect(
|
||||
parseWindowState({
|
||||
version: 1,
|
||||
bounds: {
|
||||
x: 120.4,
|
||||
y: 80.6,
|
||||
width: 1280.2,
|
||||
height: 800.8
|
||||
},
|
||||
maximized: true
|
||||
})
|
||||
).toEqual({
|
||||
version: 1,
|
||||
bounds: {
|
||||
x: 120,
|
||||
y: 81,
|
||||
width: 1280,
|
||||
height: 801
|
||||
},
|
||||
maximized: true
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝损坏或不受支持的状态", () => {
|
||||
expect(parseWindowState(undefined)).toBeUndefined();
|
||||
expect(
|
||||
parseWindowState({
|
||||
version: 2,
|
||||
bounds: primaryWorkArea,
|
||||
maximized: false
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseWindowState({
|
||||
version: 1,
|
||||
bounds: { x: 0, y: 0, width: -1, height: 720 },
|
||||
maximized: false
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("首次启动时在主显示器居中", () => {
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
undefined,
|
||||
[primaryWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 240,
|
||||
y: 40,
|
||||
width: 1440,
|
||||
height: 960
|
||||
});
|
||||
});
|
||||
|
||||
it("将部分溢出的窗口完整收回原显示器", () => {
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
{ x: 1700, y: 900, width: 1280, height: 800 },
|
||||
[primaryWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 640,
|
||||
y: 240,
|
||||
width: 1280,
|
||||
height: 800
|
||||
});
|
||||
});
|
||||
|
||||
it("显示器移除后在主显示器居中恢复", () => {
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
{ x: 3000, y: 120, width: 1200, height: 780 },
|
||||
[primaryWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 360,
|
||||
y: 130,
|
||||
width: 1200,
|
||||
height: 780
|
||||
});
|
||||
});
|
||||
|
||||
it("窗口大于工作区时缩小到可完整展示", () => {
|
||||
const compactWorkArea = {
|
||||
x: 1920,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 680
|
||||
};
|
||||
expect(
|
||||
resolveWindowBounds(
|
||||
{ x: 2000, y: -100, width: 1800, height: 1000 },
|
||||
[primaryWorkArea, compactWorkArea],
|
||||
primaryWorkArea,
|
||||
defaultSize,
|
||||
minimumSize
|
||||
)
|
||||
).toEqual({
|
||||
x: 1920,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 680
|
||||
});
|
||||
});
|
||||
|
||||
it("通过临时文件原子保存并可重新载入", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-window-")
|
||||
);
|
||||
const filePath = path.join(directory, "window-state.json");
|
||||
const state = {
|
||||
version: 1 as const,
|
||||
bounds: { x: 10, y: 20, width: 1200, height: 800 },
|
||||
maximized: false
|
||||
};
|
||||
try {
|
||||
await saveWindowState(filePath, state);
|
||||
await expect(loadWindowState(filePath)).resolves.toEqual(state);
|
||||
const updatedState = {
|
||||
...state,
|
||||
bounds: { x: 30, y: 40, width: 1280, height: 900 },
|
||||
maximized: true
|
||||
};
|
||||
await saveWindowState(filePath, updatedState);
|
||||
await expect(loadWindowState(filePath)).resolves.toEqual(
|
||||
updatedState
|
||||
);
|
||||
expect(await readdir(directory)).toEqual(["window-state.json"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
<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:;"
|
||||
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
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"@md-to-pdf/preview-engine": "0.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"mermaid": "11.16.0",
|
||||
"pagedjs": "^0.4.3",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
|
||||
+914
-415
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
type DocumentActionDialogProps =
|
||||
| {
|
||||
type: "new-location";
|
||||
busy?: false;
|
||||
onCurrentWindow: () => void;
|
||||
onNewWindow: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
| {
|
||||
type: "unsaved";
|
||||
busy: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function DocumentActionDialog(
|
||||
props: DocumentActionDialogProps
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !props.busy) {
|
||||
event.preventDefault();
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [props]);
|
||||
|
||||
const isNewLocation = props.type === "new-location";
|
||||
return (
|
||||
<div className="document-dialog-layer">
|
||||
<div className="document-dialog-backdrop" />
|
||||
<section
|
||||
className="document-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="document-dialog-title"
|
||||
aria-describedby="document-dialog-description"
|
||||
>
|
||||
<span className="panel-kicker">文档操作</span>
|
||||
<h2 id="document-dialog-title">
|
||||
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
|
||||
</h2>
|
||||
<p id="document-dialog-description">
|
||||
{isNewLocation
|
||||
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
|
||||
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
|
||||
</p>
|
||||
<div className="document-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{isNewLocation ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewWindow}
|
||||
>
|
||||
新窗口新建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-primary"
|
||||
autoFocus
|
||||
onClick={props.onCurrentWindow}
|
||||
>
|
||||
当前窗口新建
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.busy}
|
||||
onClick={props.onDiscard}
|
||||
>
|
||||
不保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="is-primary"
|
||||
disabled={props.busy}
|
||||
autoFocus
|
||||
onClick={props.onSave}
|
||||
>
|
||||
{props.busy ? "正在保存…" : "保存"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
defaultExportConfig,
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
paperFormatLabels,
|
||||
@@ -12,9 +13,11 @@ import {
|
||||
type MermaidExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ThemeSummary } from "./application-backend";
|
||||
|
||||
interface ExportSettingsDrawerProps {
|
||||
config: ExportConfig;
|
||||
theme: ThemeSummary | undefined;
|
||||
onChange: (config: ExportConfig) => void;
|
||||
onClose: () => void;
|
||||
onReset: () => void;
|
||||
@@ -24,6 +27,9 @@ interface LengthInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
maximum: number;
|
||||
disabled?: boolean;
|
||||
precision?: number;
|
||||
step?: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
||||
"page-total": "1 / 10",
|
||||
"chinese-page-total": "第 1 页 / 共 10 页",
|
||||
"dash-page": "- 1 -",
|
||||
"official-page": "— 1 —(标准公文)",
|
||||
custom: "自定义模板"
|
||||
};
|
||||
|
||||
@@ -98,6 +105,9 @@ function LengthInput({
|
||||
label,
|
||||
value,
|
||||
maximum,
|
||||
disabled = false,
|
||||
precision = 1,
|
||||
step = 0.1,
|
||||
onChange
|
||||
}: LengthInputProps) {
|
||||
const [draft, setDraft] = useState(
|
||||
@@ -115,7 +125,8 @@ function LengthInput({
|
||||
return;
|
||||
}
|
||||
const normalized = Math.min(Math.max(number, 0), maximum);
|
||||
const rounded = Math.round(normalized * 10) / 10;
|
||||
const factor = 10 ** precision;
|
||||
const rounded = Math.round(normalized * factor) / factor;
|
||||
setDraft(String(rounded));
|
||||
onChange(`${rounded}mm`);
|
||||
}
|
||||
@@ -128,8 +139,9 @@ function LengthInput({
|
||||
type="number"
|
||||
min="0"
|
||||
max={maximum}
|
||||
step="0.1"
|
||||
step={step}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -144,6 +156,57 @@ function LengthInput({
|
||||
);
|
||||
}
|
||||
|
||||
function PageDecorationFontInput({
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
function commit() {
|
||||
const normalized = draft.trim();
|
||||
if (
|
||||
!normalized ||
|
||||
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
|
||||
) {
|
||||
setDraft(value);
|
||||
return;
|
||||
}
|
||||
setDraft(normalized);
|
||||
onChange(normalized);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="setting-field stacked">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={300}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidFontInput({
|
||||
value,
|
||||
onChange
|
||||
@@ -208,6 +271,7 @@ function updateHeaderSlot(
|
||||
|
||||
export function ExportSettingsDrawer({
|
||||
config,
|
||||
theme,
|
||||
onChange,
|
||||
onClose,
|
||||
onReset
|
||||
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
|
||||
value: string
|
||||
) {
|
||||
updatePaper({
|
||||
marginMode: "custom",
|
||||
margins: {
|
||||
...margins,
|
||||
[side]: value
|
||||
@@ -266,9 +331,22 @@ export function ExportSettingsDrawer({
|
||||
});
|
||||
}
|
||||
|
||||
function followThemeMargins() {
|
||||
updatePaper({
|
||||
marginMode: "theme",
|
||||
margins: theme?.pageDefaults?.margins ?? {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateHeader(header: Partial<HeaderConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom",
|
||||
header: {
|
||||
...config.header,
|
||||
...header
|
||||
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
|
||||
function updateFooter(footer: Partial<FooterConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom",
|
||||
footer: {
|
||||
...config.footer,
|
||||
...footer
|
||||
@@ -286,6 +365,19 @@ export function ExportSettingsDrawer({
|
||||
});
|
||||
}
|
||||
|
||||
function followThemePageDecorations() {
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "theme",
|
||||
header: structuredClone(
|
||||
theme?.pageDefaults?.header ?? defaultExportConfig.header
|
||||
),
|
||||
footer: structuredClone(
|
||||
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
||||
onChange({
|
||||
...config,
|
||||
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>页边距</h3>
|
||||
<fieldset className="segmented-control margin-mode-control">
|
||||
<legend>页边距来源</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="margin-mode"
|
||||
checked={config.paper.marginMode === "theme"}
|
||||
onChange={followThemeMargins}
|
||||
/>
|
||||
跟随主题
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="margin-mode"
|
||||
checked={config.paper.marginMode === "custom"}
|
||||
onChange={() => updatePaper({ marginMode: "custom" })}
|
||||
/>
|
||||
自定义
|
||||
</label>
|
||||
</fieldset>
|
||||
<p className="setting-hint margin-source-hint">
|
||||
{config.paper.marginMode === "theme"
|
||||
? theme?.pageDefaults
|
||||
? `当前采用“${theme.name}”推荐值`
|
||||
: "当前主题未设置推荐值,采用全局默认 16mm"
|
||||
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
|
||||
</p>
|
||||
<div className="margin-grid">
|
||||
<LengthInput
|
||||
label="上"
|
||||
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
|
||||
onChange={(value) => updateMargin("left", value)}
|
||||
/>
|
||||
</div>
|
||||
{config.paper.marginMode === "custom" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action margin-reset-action"
|
||||
onClick={followThemeMargins}
|
||||
>
|
||||
恢复主题推荐值
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>页眉与页码来源</h3>
|
||||
<fieldset className="segmented-control margin-mode-control">
|
||||
<legend>页面装饰来源</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="page-decorations-mode"
|
||||
checked={config.pageDecorationsMode === "theme"}
|
||||
onChange={followThemePageDecorations}
|
||||
/>
|
||||
跟随主题
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="page-decorations-mode"
|
||||
checked={config.pageDecorationsMode === "custom"}
|
||||
onChange={() =>
|
||||
onChange({
|
||||
...config,
|
||||
pageDecorationsMode: "custom"
|
||||
})
|
||||
}
|
||||
/>
|
||||
自定义
|
||||
</label>
|
||||
</fieldset>
|
||||
<p className="setting-hint margin-source-hint">
|
||||
{config.pageDecorationsMode === "theme"
|
||||
? theme?.pageDefaults?.header ||
|
||||
theme?.pageDefaults?.footer
|
||||
? `当前采用“${theme.name}”推荐的页眉与页码`
|
||||
: "当前主题未设置推荐值,采用全局默认配置"
|
||||
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
|
||||
</p>
|
||||
{config.pageDecorationsMode === "custom" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action margin-reset-action"
|
||||
onClick={followThemePageDecorations}
|
||||
>
|
||||
恢复主题推荐值
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="setting-section-title">
|
||||
<h3>页眉</h3>
|
||||
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
|
||||
<p className="setting-hint">
|
||||
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
||||
</p>
|
||||
<PageDecorationFontInput
|
||||
label="页眉字体"
|
||||
value={config.header.fontFamily}
|
||||
disabled={!config.header.enabled}
|
||||
onChange={(fontFamily) => updateHeader({ fontFamily })}
|
||||
/>
|
||||
<LengthInput
|
||||
label="页眉字号"
|
||||
value={config.header.fontSize}
|
||||
maximum={20}
|
||||
disabled={!config.header.enabled}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
onChange={(fontSize) => updateHeader({ fontSize })}
|
||||
/>
|
||||
{(["left", "center", "right"] as const).map((slot) => {
|
||||
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
||||
return (
|
||||
@@ -567,6 +758,21 @@ export function ExportSettingsDrawer({
|
||||
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
||||
</label>
|
||||
</div>
|
||||
<PageDecorationFontInput
|
||||
label="页码字体"
|
||||
value={config.footer.fontFamily}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(fontFamily) => updateFooter({ fontFamily })}
|
||||
/>
|
||||
<LengthInput
|
||||
label="页码字号"
|
||||
value={config.footer.fontSize}
|
||||
maximum={20}
|
||||
disabled={!config.footer.enabled}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
onChange={(fontSize) => updateFooter({ fontSize })}
|
||||
/>
|
||||
<label className="setting-field">
|
||||
<span>样式</span>
|
||||
<select
|
||||
@@ -616,6 +822,7 @@ export function ExportSettingsDrawer({
|
||||
<option value="left">左侧</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右侧</option>
|
||||
<option value="outer">外侧(奇数页右、偶数页左)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="setting-field">
|
||||
@@ -636,6 +843,19 @@ export function ExportSettingsDrawer({
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-control divider-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.footer.showOnFirstPage}
|
||||
disabled={!config.footer.enabled}
|
||||
onChange={(event) =>
|
||||
updateFooter({
|
||||
showOnFirstPage: event.target.checked
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>首页显示页码</span>
|
||||
</label>
|
||||
<label className="switch-control divider-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
AnnotationLayer,
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
RenderingCancelledException,
|
||||
@@ -20,10 +21,15 @@ import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions
|
||||
} from "./pdf-page-geometry";
|
||||
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
|
||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
type PdfJsAnnotationLinkService = Parameters<
|
||||
AnnotationLayer["render"]
|
||||
>[0]["linkService"];
|
||||
|
||||
interface PdfPageDescriptor {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
@@ -33,12 +39,16 @@ interface PdfPageDescriptor {
|
||||
export interface PrecisePdfPreviewProps {
|
||||
blob: Blob;
|
||||
zoomPercent: number;
|
||||
onExternalLink?: (href: string) => void;
|
||||
onNavigateToPage?: (pageNumber: number) => void;
|
||||
onPageCount?: (pageCount: number) => void;
|
||||
}
|
||||
|
||||
interface PrecisePdfPageProps {
|
||||
document: PDFDocumentProxy;
|
||||
descriptor: PdfPageDescriptor;
|
||||
onExternalLink: (href: string) => void;
|
||||
onNavigateToPage: (pageNumber: number) => void;
|
||||
renderQueue: PdfPageRenderQueue;
|
||||
zoomPercent: number;
|
||||
}
|
||||
@@ -92,12 +102,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
function PrecisePdfPage({
|
||||
document,
|
||||
descriptor,
|
||||
onExternalLink,
|
||||
onNavigateToPage,
|
||||
renderQueue,
|
||||
zoomPercent
|
||||
}: PrecisePdfPageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||
const annotationLayerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayWidth, setDisplayWidth] = useState(0);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [renderState, setRenderState] = useState<
|
||||
@@ -158,11 +171,13 @@ function PrecisePdfPage({
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const textContainer = textLayerRef.current;
|
||||
const annotationContainer = annotationLayerRef.current;
|
||||
if (
|
||||
!nearViewport ||
|
||||
displayWidth <= 0 ||
|
||||
!canvas ||
|
||||
!textContainer
|
||||
!textContainer ||
|
||||
!annotationContainer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -171,6 +186,7 @@ function PrecisePdfPage({
|
||||
let page: PDFPageProxy | undefined;
|
||||
let renderTask: RenderTask | undefined;
|
||||
let textLayer: TextLayer | undefined;
|
||||
let annotationLayer: AnnotationLayer | undefined;
|
||||
|
||||
setRenderError("");
|
||||
setRenderState("queued");
|
||||
@@ -212,6 +228,7 @@ function PrecisePdfPage({
|
||||
canvas.style.width = `${displayViewport.width}px`;
|
||||
canvas.style.height = `${displayViewport.height}px`;
|
||||
textContainer.replaceChildren();
|
||||
annotationContainer.replaceChildren();
|
||||
|
||||
renderTask = page.render({
|
||||
canvas,
|
||||
@@ -229,7 +246,43 @@ function PrecisePdfPage({
|
||||
textContentSource: page.streamTextContent(),
|
||||
viewport: displayViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
const annotations = await page.getAnnotations({
|
||||
intent: "display"
|
||||
});
|
||||
const linkService = createPdfAnnotationLinkService(
|
||||
document,
|
||||
descriptor.pageNumber,
|
||||
{
|
||||
onExternalLink,
|
||||
onNavigateToPage
|
||||
}
|
||||
);
|
||||
annotationLayer = new AnnotationLayer({
|
||||
div: annotationContainer,
|
||||
accessibilityManager: undefined,
|
||||
annotationCanvasMap: undefined,
|
||||
annotationEditorUIManager: undefined,
|
||||
page,
|
||||
viewport: displayViewport,
|
||||
structTreeLayer: undefined,
|
||||
commentManager: undefined,
|
||||
linkService,
|
||||
annotationStorage: document.annotationStorage
|
||||
});
|
||||
await Promise.all([
|
||||
textLayer.render(),
|
||||
annotationLayer.render({
|
||||
viewport: displayViewport,
|
||||
div: annotationContainer,
|
||||
annotations,
|
||||
page,
|
||||
linkService:
|
||||
linkService as unknown as PdfJsAnnotationLinkService,
|
||||
annotationStorage: document.annotationStorage,
|
||||
renderForms: false,
|
||||
enableScripting: false
|
||||
})
|
||||
]);
|
||||
} catch (reason: unknown) {
|
||||
if (
|
||||
disposed ||
|
||||
@@ -251,10 +304,12 @@ function PrecisePdfPage({
|
||||
cancelQueuedRender();
|
||||
renderTask?.cancel();
|
||||
textLayer?.cancel();
|
||||
annotationLayer?.destroy();
|
||||
page?.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
textContainer.replaceChildren();
|
||||
annotationContainer.replaceChildren();
|
||||
setRenderState("idle");
|
||||
};
|
||||
}, [
|
||||
@@ -264,6 +319,8 @@ function PrecisePdfPage({
|
||||
displayWidth,
|
||||
document,
|
||||
nearViewport,
|
||||
onExternalLink,
|
||||
onNavigateToPage,
|
||||
renderQueue
|
||||
]);
|
||||
|
||||
@@ -282,6 +339,10 @@ function PrecisePdfPage({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={textLayerRef} className="textLayer" />
|
||||
<div
|
||||
ref={annotationLayerRef}
|
||||
className="annotationLayer"
|
||||
/>
|
||||
{renderState !== "rendered" ? (
|
||||
<span className="precise-pdf-page-placeholder">
|
||||
{renderState === "rendering"
|
||||
@@ -299,6 +360,8 @@ function PrecisePdfPage({
|
||||
export function PrecisePdfPreview({
|
||||
blob,
|
||||
zoomPercent,
|
||||
onExternalLink,
|
||||
onNavigateToPage,
|
||||
onPageCount
|
||||
}: PrecisePdfPreviewProps) {
|
||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||
@@ -308,6 +371,19 @@ export function PrecisePdfPreview({
|
||||
() => new PdfPageRenderQueue(1),
|
||||
[document]
|
||||
);
|
||||
const externalLinkRef = useRef(onExternalLink);
|
||||
const navigateToPageRef = useRef(onNavigateToPage);
|
||||
externalLinkRef.current = onExternalLink;
|
||||
navigateToPageRef.current = onNavigateToPage;
|
||||
const handleExternalLink = useCallback((href: string) => {
|
||||
externalLinkRef.current?.(href);
|
||||
}, []);
|
||||
const handleNavigateToPage = useCallback(
|
||||
(pageNumber: number) => {
|
||||
navigateToPageRef.current?.(pageNumber);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||
|
||||
@@ -386,6 +462,8 @@ export function PrecisePdfPreview({
|
||||
key={page.pageNumber}
|
||||
document={document}
|
||||
descriptor={page}
|
||||
onExternalLink={handleExternalLink}
|
||||
onNavigateToPage={handleNavigateToPage}
|
||||
renderQueue={renderQueue}
|
||||
zoomPercent={zoomPercent}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const sampleModules = import.meta.glob<string>(
|
||||
"../../../samples/themes/*.md",
|
||||
{
|
||||
eager: true,
|
||||
query: "?raw",
|
||||
import: "default"
|
||||
}
|
||||
);
|
||||
|
||||
export interface BuiltinThemeSample {
|
||||
themeId: string;
|
||||
fileName: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function getFileName(modulePath: string) {
|
||||
return modulePath.split("/").at(-1) ?? "";
|
||||
}
|
||||
|
||||
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
|
||||
sampleModules
|
||||
)
|
||||
.map(([modulePath, markdown]) => {
|
||||
const fileName = getFileName(modulePath);
|
||||
return {
|
||||
themeId: fileName.replace(/\.md$/i, ""),
|
||||
fileName,
|
||||
markdown
|
||||
};
|
||||
})
|
||||
.sort((first, second) =>
|
||||
first.themeId.localeCompare(second.themeId, "en")
|
||||
);
|
||||
|
||||
const builtinThemeSampleMap = new Map(
|
||||
builtinThemeSamples.map((sample) => [sample.themeId, sample])
|
||||
);
|
||||
|
||||
export function getBuiltinThemeSample(themeId: string) {
|
||||
return builtinThemeSampleMap.get(themeId);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const tutorialModules = import.meta.glob<string>(
|
||||
"../../../samples/tutorials/*.md",
|
||||
{
|
||||
eager: true,
|
||||
query: "?raw",
|
||||
import: "default"
|
||||
}
|
||||
);
|
||||
|
||||
export interface BuiltinTutorial {
|
||||
id: string;
|
||||
name: string;
|
||||
fileName: string;
|
||||
themeId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
const tutorialDefinitions = [
|
||||
{
|
||||
id: "echarts-tutorial",
|
||||
name: "ECharts 图表教程",
|
||||
themeId: "typora-github"
|
||||
},
|
||||
{
|
||||
id: "formal-document-tutorial",
|
||||
name: "公文主题使用教程",
|
||||
themeId: "typora-github"
|
||||
}
|
||||
] as const;
|
||||
|
||||
const tutorialMarkdownById = new Map(
|
||||
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
|
||||
const fileName = modulePath.split("/").at(-1) ?? "";
|
||||
return [fileName.replace(/\.md$/i, ""), markdown];
|
||||
})
|
||||
);
|
||||
|
||||
export const builtinTutorials: BuiltinTutorial[] =
|
||||
tutorialDefinitions.map((definition) => {
|
||||
const markdown = tutorialMarkdownById.get(definition.id);
|
||||
if (!markdown) {
|
||||
throw new Error(`缺少内置教程:${definition.id}.md`);
|
||||
}
|
||||
return {
|
||||
...definition,
|
||||
fileName: `${definition.id}.md`,
|
||||
markdown
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
const fallbackBaseName = "未命名文档";
|
||||
const windowsReservedName =
|
||||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
|
||||
|
||||
function stripMarkdownFormatting(value: string) {
|
||||
return value
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
|
||||
.replace(/<\/?[A-Za-z][^>]*>/gu, " ")
|
||||
.replace(/[`*_~]/gu, "")
|
||||
.replace(/^\s{0,3}(?:>\s*|[-+*]\s+|\d+[.)]\s+)/u, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function legalizeMarkdownBaseName(value: string) {
|
||||
const normalized = stripMarkdownFormatting(value)
|
||||
.normalize("NFKC")
|
||||
.replace(/[^\p{L}\p{N}\s_-]+/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.replace(/[ .]+$/u, "");
|
||||
const shortened = Array.from(normalized).slice(0, 100).join("").trim();
|
||||
if (!shortened) {
|
||||
return fallbackBaseName;
|
||||
}
|
||||
return windowsReservedName.test(shortened)
|
||||
? `文档 ${shortened}`
|
||||
: shortened;
|
||||
}
|
||||
|
||||
export function deriveMarkdownBaseName(markdown: string) {
|
||||
const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u);
|
||||
let inFrontMatter = lines[0]?.trim() === "---";
|
||||
let inFence = false;
|
||||
let fenceMarker = "";
|
||||
let fenceLength = 0;
|
||||
let firstBodyLine = "";
|
||||
|
||||
for (let index = inFrontMatter ? 1 : 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
const trimmed = line.trim();
|
||||
if (inFrontMatter) {
|
||||
if (trimmed === "---" || trimmed === "...") {
|
||||
inFrontMatter = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
const closingFence = trimmed.match(/^(`+|~+)\s*$/u)?.[1];
|
||||
if (
|
||||
closingFence?.[0] === fenceMarker &&
|
||||
closingFence.length >= fenceLength
|
||||
) {
|
||||
inFence = false;
|
||||
fenceMarker = "";
|
||||
fenceLength = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fence = trimmed.match(/^(`{3,}|~{3,})/u)?.[1];
|
||||
if (fence) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceMarker = fence[0] ?? "";
|
||||
fenceLength = fence.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inFence || !trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u)?.[1];
|
||||
if (heading) {
|
||||
return legalizeMarkdownBaseName(heading);
|
||||
}
|
||||
if (!firstBodyLine) {
|
||||
firstBodyLine = line;
|
||||
}
|
||||
}
|
||||
|
||||
return legalizeMarkdownBaseName(firstBodyLine);
|
||||
}
|
||||
|
||||
export function createMarkdownFileName(markdown: string) {
|
||||
return `${deriveMarkdownBaseName(markdown)}.md`;
|
||||
}
|
||||
|
||||
export function ensureMarkdownFileName(fileName: string, markdown: string) {
|
||||
const trimmed = fileName.trim();
|
||||
if (!trimmed) {
|
||||
return createMarkdownFileName(markdown);
|
||||
}
|
||||
return /\.(?:md|markdown)$/iu.test(trimmed)
|
||||
? trimmed
|
||||
: `${trimmed}.md`;
|
||||
}
|
||||
|
||||
export function downloadMarkdown(markdown: string, fileName: string) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([markdown], { type: "text/markdown;charset=utf-8" })
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||
|
||||
export type WebDocumentLinkAction =
|
||||
| {
|
||||
type: "anchor";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "open";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "ignore";
|
||||
};
|
||||
|
||||
export function resolveWebDocumentLinkAction(
|
||||
href: string
|
||||
): WebDocumentLinkAction {
|
||||
const link = classifyDocumentLink(href);
|
||||
if (link.kind === "anchor") {
|
||||
return {
|
||||
type: "anchor",
|
||||
href: link.normalizedHref
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
link.kind === "network" ||
|
||||
(link.kind === "protocol" &&
|
||||
(link.scheme === "mailto" || link.scheme === "tel"))
|
||||
) {
|
||||
return {
|
||||
type: "open",
|
||||
href: link.normalizedHref
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "ignore" };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type DocumentShortcut =
|
||||
| "new"
|
||||
| "save"
|
||||
| "save-as"
|
||||
| "close";
|
||||
|
||||
export interface DocumentShortcutEvent {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
isComposing: boolean;
|
||||
repeat: boolean;
|
||||
}
|
||||
|
||||
export function getDocumentShortcut(
|
||||
event: DocumentShortcutEvent,
|
||||
desktop: boolean
|
||||
): DocumentShortcut | undefined {
|
||||
if (
|
||||
event.isComposing ||
|
||||
event.repeat ||
|
||||
event.altKey ||
|
||||
(!event.ctrlKey && !event.metaKey)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (event.shiftKey) {
|
||||
return desktop && key === "s" ? "save-as" : undefined;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "s":
|
||||
return "save";
|
||||
case "n":
|
||||
return desktop ? "new" : undefined;
|
||||
case "w":
|
||||
return desktop ? "close" : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+121
-14
@@ -4,8 +4,10 @@ import {
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
||||
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
|
||||
export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
|
||||
"md-to-pdf.export-config.v3";
|
||||
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
|
||||
"md-to-pdf.export-config.v2";
|
||||
|
||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
|
||||
return structuredClone(defaultExportConfig);
|
||||
}
|
||||
|
||||
function pickLegacyPageDecorations(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
if ("left" in source) {
|
||||
return {
|
||||
enabled: source.enabled,
|
||||
height: source.height,
|
||||
showDivider: source.showDivider,
|
||||
fontSize: source.fontSize,
|
||||
color: source.color,
|
||||
left: source.left,
|
||||
center: source.center,
|
||||
right: source.right
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: source.enabled,
|
||||
height: source.height,
|
||||
showDivider: source.showDivider,
|
||||
fontSize: source.fontSize,
|
||||
color: source.color,
|
||||
alignment: source.alignment,
|
||||
format: source.format,
|
||||
template: source.template,
|
||||
startFrom: source.startFrom
|
||||
};
|
||||
}
|
||||
|
||||
function usesLegacyPageDecorationDefaults(
|
||||
header: unknown,
|
||||
footer: unknown
|
||||
) {
|
||||
return (
|
||||
JSON.stringify(pickLegacyPageDecorations(header)) ===
|
||||
JSON.stringify(
|
||||
pickLegacyPageDecorations(defaultExportConfig.header)
|
||||
) &&
|
||||
JSON.stringify(pickLegacyPageDecorations(footer)) ===
|
||||
JSON.stringify(
|
||||
pickLegacyPageDecorations(defaultExportConfig.footer)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function migrateStoredExportConfig(value: unknown): unknown {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("version" in value) ||
|
||||
!("paper" in value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
value.version !== 2 &&
|
||||
value.version !== 3 &&
|
||||
value.version !== defaultExportConfig.version
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
const hasPageDecorationsMode =
|
||||
typeof source.pageDecorationsMode === "string";
|
||||
|
||||
const paper =
|
||||
typeof value.paper === "object" && value.paper !== null
|
||||
? value.paper
|
||||
: {};
|
||||
const margins =
|
||||
"margins" in paper &&
|
||||
typeof paper.margins === "object" &&
|
||||
paper.margins !== null
|
||||
? paper.margins
|
||||
: {};
|
||||
const usesLegacyDefaults =
|
||||
JSON.stringify(margins) ===
|
||||
JSON.stringify(defaultExportConfig.paper.margins);
|
||||
const hasMarginMode =
|
||||
typeof (paper as Record<string, unknown>).marginMode === "string";
|
||||
|
||||
return {
|
||||
...value,
|
||||
...(value.version === 2 || value.version === 3
|
||||
? { version: defaultExportConfig.version }
|
||||
: {}),
|
||||
...(value.version === 2
|
||||
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
|
||||
: {}),
|
||||
...(!hasPageDecorationsMode
|
||||
? {
|
||||
pageDecorationsMode: usesLegacyPageDecorationDefaults(
|
||||
source.header,
|
||||
source.footer
|
||||
)
|
||||
? "theme"
|
||||
: "custom"
|
||||
}
|
||||
: {}),
|
||||
paper: {
|
||||
...paper,
|
||||
...(!hasMarginMode
|
||||
? {
|
||||
marginMode: usesLegacyDefaults ? "theme" : "custom"
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
if (!rawValue) {
|
||||
return cloneDefaultExportConfig();
|
||||
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(rawValue);
|
||||
const migratedValue =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"version" in value &&
|
||||
value.version === 2
|
||||
? {
|
||||
...value,
|
||||
version: defaultExportConfig.version,
|
||||
mermaid: structuredClone(defaultExportConfig.mermaid)
|
||||
}
|
||||
: value;
|
||||
const migratedValue = migrateStoredExportConfig(value);
|
||||
const parsed = exportConfigSchema.safeParse(migratedValue);
|
||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||
} catch {
|
||||
@@ -47,7 +152,9 @@ export function loadExportConfig(
|
||||
return parseStoredExportConfig(currentValue);
|
||||
}
|
||||
|
||||
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
|
||||
const legacyValue =
|
||||
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
|
||||
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
|
||||
const config = parseStoredExportConfig(legacyValue);
|
||||
if (legacyValue) {
|
||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||
import type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { Previewer } from "pagedjs";
|
||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||
import { fitDocumentImagesToPage } from "./document-image-fit";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import {
|
||||
fitOversizedMermaidToPage,
|
||||
getPageContentDimensions
|
||||
} from "./mermaid-page-fit";
|
||||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||||
import { enablePrintMediaForPreview } from "./preview-styles";
|
||||
import "./paged-table-handler";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
documentBaseCss,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import type { PagedRenderTarget } from "./paged-render-target";
|
||||
|
||||
export type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface PagedDocumentRenderOptions {
|
||||
target: PagedRenderTarget;
|
||||
shouldContinue?: () => boolean;
|
||||
mermaidOutput?: MermaidOutputMode;
|
||||
}
|
||||
|
||||
function waitForImages(container: ParentNode) {
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
return Promise.all(
|
||||
images.map(async (image) => {
|
||||
if (image.complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await image.decode();
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => {
|
||||
image.addEventListener("load", () => resolve(), { once: true });
|
||||
image.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function stylesheet(
|
||||
documentRef: Document,
|
||||
css: string,
|
||||
name: string
|
||||
) {
|
||||
return {
|
||||
[`${documentRef.location.href}#${name}`]: css
|
||||
};
|
||||
}
|
||||
|
||||
function mountMeasurementContainer(
|
||||
documentRef: Document,
|
||||
content: DocumentFragment,
|
||||
payload: PagedPreviewPayload,
|
||||
target: PagedRenderTarget
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(payload.exportConfig);
|
||||
const style = documentRef.createElement("style");
|
||||
const themeCss =
|
||||
target === "preview"
|
||||
? enablePrintMediaForPreview(payload.themeCss)
|
||||
: payload.themeCss;
|
||||
style.dataset.pagedMeasurementStyle = "true";
|
||||
style.textContent = [
|
||||
documentBaseCss,
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss,
|
||||
themeCss,
|
||||
documentGeometryCss
|
||||
].join("\n");
|
||||
|
||||
const host = documentRef.createElement("div");
|
||||
host.dataset.pagedMeasurementHost = "true";
|
||||
host.style.cssText = [
|
||||
"position: fixed",
|
||||
"left: -100000px",
|
||||
"top: 0",
|
||||
`width: ${pageContent.width}px`,
|
||||
"height: auto",
|
||||
"opacity: 0",
|
||||
"pointer-events: none",
|
||||
"contain: layout style paint"
|
||||
].join(";");
|
||||
|
||||
documentRef.head.append(style);
|
||||
host.append(content);
|
||||
documentRef.body.append(host);
|
||||
|
||||
return {
|
||||
host,
|
||||
restore() {
|
||||
content.append(...Array.from(host.childNodes));
|
||||
host.remove();
|
||||
style.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyPageNumbers(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload,
|
||||
totalPages: number
|
||||
) {
|
||||
if (!payload.exportConfig.footer.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alignment = payload.exportConfig.footer.alignment;
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
|
||||
for (const [pageIndex, page] of pages.entries()) {
|
||||
const content = page.querySelector<HTMLElement>(
|
||||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
||||
);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
content.textContent = formatPageNumber(
|
||||
payload.exportConfig.footer,
|
||||
pageIndex,
|
||||
totalPages
|
||||
);
|
||||
content.setAttribute("data-page-number-rendered", "true");
|
||||
}
|
||||
}
|
||||
|
||||
function removeTrailingPdfPageBreak(container: ParentNode) {
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
pages
|
||||
.at(-1)
|
||||
?.style.setProperty("break-after", "auto", "important");
|
||||
}
|
||||
|
||||
export class PagedDocumentRuntime {
|
||||
private activePreviewer: Previewer | undefined;
|
||||
private mermaidRenderSequence = 0;
|
||||
private mermaidPromise:
|
||||
| Promise<(typeof import("mermaid"))["default"]>
|
||||
| undefined;
|
||||
private echartsPromise:
|
||||
| Promise<
|
||||
typeof import("@md-to-pdf/markdown-echarts/browser")
|
||||
>
|
||||
| undefined;
|
||||
|
||||
constructor(private readonly root: HTMLElement) {}
|
||||
|
||||
destroy() {
|
||||
if (!this.activePreviewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activePreviewer.chunker.destroy();
|
||||
this.activePreviewer.polisher.destroy();
|
||||
this.activePreviewer = undefined;
|
||||
}
|
||||
|
||||
private loadMermaid() {
|
||||
if (!this.mermaidPromise) {
|
||||
this.mermaidPromise = Promise.all([
|
||||
import("mermaid"),
|
||||
import("@mermaid-js/layout-elk")
|
||||
]).then(
|
||||
([{ default: mermaid }, { default: elkLayouts }]) => {
|
||||
mermaid.registerLayoutLoaders(elkLayouts);
|
||||
return mermaid;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.mermaidPromise;
|
||||
}
|
||||
|
||||
private loadECharts() {
|
||||
if (!this.echartsPromise) {
|
||||
this.echartsPromise = import(
|
||||
"@md-to-pdf/markdown-echarts/browser"
|
||||
);
|
||||
}
|
||||
return this.echartsPromise;
|
||||
}
|
||||
|
||||
private async renderECharts(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("echarts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { renderEChartsBlocks } = await this.loadECharts();
|
||||
const outcomes = await renderEChartsBlocks(container, {
|
||||
outputMode: "inline-svg",
|
||||
concurrency: 2,
|
||||
timeoutMs: 5_000
|
||||
});
|
||||
|
||||
return outcomes.flatMap((outcome, index) =>
|
||||
outcome.success
|
||||
? []
|
||||
: [
|
||||
`图表 ${index + 1}:${
|
||||
outcome.error?.message ?? "未知错误"
|
||||
}`
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private async renderMermaid(
|
||||
container: DocumentFragment,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("mermaid")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
".mermaid[data-mermaid-pending]"
|
||||
)
|
||||
);
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mermaid = await this.loadMermaid();
|
||||
mermaid.initialize(
|
||||
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
||||
);
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
nodes.map((node) => node.textContent ?? ""),
|
||||
async (definition) => {
|
||||
this.mermaidRenderSequence += 1;
|
||||
return mermaid.render(
|
||||
`mermaid-paged-document-${this.mermaidRenderSequence}`,
|
||||
definition
|
||||
);
|
||||
}
|
||||
);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, outcome] of outcomes.entries()) {
|
||||
const node = nodes[index];
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
node.removeAttribute("data-mermaid-pending");
|
||||
if (outcome.success) {
|
||||
node.innerHTML = outcome.svg;
|
||||
outcome.bindFunctions?.(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message =
|
||||
outcome.error instanceof Error
|
||||
? outcome.error.message
|
||||
: "未知错误";
|
||||
node.classList.add("mermaid-error");
|
||||
node.textContent =
|
||||
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
||||
errors.push(`图表 ${index + 1}:${message}`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async render(
|
||||
payload: PagedPreviewPayload,
|
||||
options: PagedDocumentRenderOptions
|
||||
): Promise<PagedDocumentRenderResult | undefined> {
|
||||
const totalStartedAt = performance.now();
|
||||
const shouldContinue = options.shouldContinue ?? (() => true);
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const setupStartedAt = performance.now();
|
||||
const documentRef = this.root.ownerDocument;
|
||||
this.destroy();
|
||||
this.root.replaceChildren();
|
||||
documentRef.documentElement.dataset.renderTarget = options.target;
|
||||
documentRef.documentElement.lang =
|
||||
payload.metadata.language || "zh-CN";
|
||||
documentRef.title =
|
||||
payload.metadata.title || "Markdown 分页文档";
|
||||
|
||||
const template = documentRef.createElement("template");
|
||||
template.innerHTML = payload.articleHtml;
|
||||
const content = template.content;
|
||||
const setupMs = performance.now() - setupStartedAt;
|
||||
|
||||
const mermaidStartedAt = performance.now();
|
||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
||||
|
||||
let echartsErrors: string[] = [];
|
||||
let echartsMs = 0;
|
||||
let echartsFitMs = 0;
|
||||
let mermaidFitMs = 0;
|
||||
let mermaidConversionMs = 0;
|
||||
if (
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg") ||
|
||||
content.querySelector("img.md-document-image")
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
documentRef,
|
||||
content,
|
||||
payload,
|
||||
options.target
|
||||
);
|
||||
try {
|
||||
await documentRef.fonts.ready;
|
||||
|
||||
const echartsStartedAt = performance.now();
|
||||
echartsErrors = await this.renderECharts(
|
||||
measurement.host,
|
||||
payload
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
fitDocumentImagesToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
echartsFitMs =
|
||||
performance.now() - echartsFitStartedAt;
|
||||
|
||||
const mermaidFitStartedAt = performance.now();
|
||||
fitOversizedMermaidToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||||
|
||||
const mermaidConversionStartedAt = performance.now();
|
||||
if (options.mermaidOutput === "svg-image") {
|
||||
replaceMermaidSvgWithImages(measurement.host);
|
||||
await waitForImages(measurement.host);
|
||||
}
|
||||
mermaidConversionMs =
|
||||
performance.now() - mermaidConversionStartedAt;
|
||||
} finally {
|
||||
measurement.restore();
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resourceWaitStartedAt = performance.now();
|
||||
await documentRef.fonts.ready;
|
||||
await waitForImages(content);
|
||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
||||
|
||||
const paginationStartedAt = performance.now();
|
||||
const previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
const flow = await previewer.preview(
|
||||
content,
|
||||
[
|
||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||
stylesheet(documentRef, highlightCss, "highlight"),
|
||||
stylesheet(documentRef, katexCss, "katex"),
|
||||
stylesheet(documentRef, echartsCss, "echarts"),
|
||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
documentGeometryCss,
|
||||
"document-geometry"
|
||||
),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
buildPagedMediaCss(payload.exportConfig, payload),
|
||||
"paged-media"
|
||||
)
|
||||
],
|
||||
this.root
|
||||
);
|
||||
const paginationMs = performance.now() - paginationStartedAt;
|
||||
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const finalizeStartedAt = performance.now();
|
||||
applyPageNumbers(this.root, payload, flow.total);
|
||||
if (options.target === "pdf") {
|
||||
removeTrailingPdfPageBreak(this.root);
|
||||
}
|
||||
const finalizeMs = performance.now() - finalizeStartedAt;
|
||||
|
||||
return {
|
||||
pageCount: flow.total,
|
||||
echartsErrors,
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
echartsMs,
|
||||
echartsFitMs,
|
||||
mermaidMs,
|
||||
mermaidFitMs,
|
||||
mermaidConversionMs,
|
||||
resourceWaitMs,
|
||||
paginationMs,
|
||||
finalizeMs,
|
||||
totalMs: performance.now() - totalStartedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mdToPdfRender?: (
|
||||
payload: PagedPreviewPayload,
|
||||
target?: PagedRenderTarget
|
||||
) => Promise<PagedDocumentRenderResult>;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { PagedDocumentRuntime } from "./paged-document-runtime";
|
||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||
import {
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
PagedDocumentRuntime,
|
||||
resolveMermaidOutputMode,
|
||||
resolvePagedRenderTarget,
|
||||
type PagedPreviewFrameMessage,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
||||
} from "@md-to-pdf/preview-engine";
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import {
|
||||
beginPreviewFrameRender,
|
||||
completePreviewFrameRender
|
||||
@@ -23,7 +26,11 @@ function getPreviewRoot() {
|
||||
const previewRoot = getPreviewRoot();
|
||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot, {
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss
|
||||
});
|
||||
document.documentElement.dataset.renderTarget = renderTarget;
|
||||
|
||||
let latestRequestId = 0;
|
||||
@@ -33,9 +40,47 @@ function post(message: PagedPreviewFrameMessage) {
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
}
|
||||
|
||||
function measurePreviewContentHeight() {
|
||||
const rootRect = previewRoot.getBoundingClientRect();
|
||||
const documentRect =
|
||||
document.documentElement.getBoundingClientRect();
|
||||
return Math.ceil(
|
||||
Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
previewRoot.scrollHeight,
|
||||
rootRect.bottom - documentRect.top
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLAnchorElement>("a[href]")
|
||||
: undefined;
|
||||
const href = target?.getAttribute("href")?.trim();
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "link",
|
||||
href
|
||||
});
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
async function renderPagedPreview(
|
||||
requestId: number,
|
||||
payload: PagedPreviewPayload
|
||||
payload: PagedPreviewPayload,
|
||||
layout: "paged" | "continuous"
|
||||
) {
|
||||
if (requestId !== latestRequestId) {
|
||||
return;
|
||||
@@ -45,15 +90,21 @@ async function renderPagedPreview(
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendering",
|
||||
requestId
|
||||
requestId,
|
||||
layout
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
const result =
|
||||
layout === "continuous"
|
||||
? await runtime.renderContinuous(payload, {
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
})
|
||||
: await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
@@ -62,7 +113,8 @@ async function renderPagedPreview(
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||
layout,
|
||||
contentHeight: measurePreviewContentHeight(),
|
||||
...result
|
||||
});
|
||||
} finally {
|
||||
@@ -121,12 +173,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
}
|
||||
|
||||
latestRequestId = Math.max(latestRequestId, event.data.requestId);
|
||||
const { requestId, payload } = event.data;
|
||||
const { requestId, layout, payload } = event.data;
|
||||
renderQueue = renderQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
try {
|
||||
await renderPagedPreview(requestId, payload);
|
||||
await renderPagedPreview(requestId, payload, layout);
|
||||
} catch (reason: unknown) {
|
||||
if (requestId !== latestRequestId) {
|
||||
return;
|
||||
|
||||
Vendored
-41
@@ -1,41 +0,0 @@
|
||||
declare module "pagedjs" {
|
||||
export interface PagedFlow {
|
||||
total: number;
|
||||
pages: HTMLElement[];
|
||||
performance: number;
|
||||
size: {
|
||||
width: { value: number; unit: string };
|
||||
height: { value: number; unit: string };
|
||||
format?: string;
|
||||
orientation?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PagedStylesheet = string | Record<string, string>;
|
||||
|
||||
export class Previewer {
|
||||
chunker: {
|
||||
destroy(): void;
|
||||
};
|
||||
polisher: {
|
||||
destroy(): void;
|
||||
};
|
||||
preview(
|
||||
content?: HTMLElement | DocumentFragment | string,
|
||||
stylesheets?: PagedStylesheet[],
|
||||
renderTo?: HTMLElement | string
|
||||
): Promise<PagedFlow>;
|
||||
}
|
||||
|
||||
export class Handler {
|
||||
constructor(
|
||||
chunker: unknown,
|
||||
polisher: unknown,
|
||||
caller: unknown
|
||||
);
|
||||
}
|
||||
|
||||
export function registerHandlers(
|
||||
...handlers: Array<typeof Handler>
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { decodeLocalDocumentLinkFromPdf } from "@md-to-pdf/core";
|
||||
|
||||
export type PdfDestination = string | unknown[];
|
||||
|
||||
export interface PdfAnnotationLinkCallbacks {
|
||||
onExternalLink: (href: string) => void;
|
||||
onNavigateToPage: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
export interface PdfAnnotationLinkService {
|
||||
externalLinkEnabled: boolean;
|
||||
addLinkAttributes(
|
||||
link: HTMLAnchorElement,
|
||||
url: string,
|
||||
newWindow?: boolean
|
||||
): void;
|
||||
getDestinationHash(destination: PdfDestination): string;
|
||||
getAnchorUrl(anchor: string): string;
|
||||
goToDestination(destination: PdfDestination): Promise<void>;
|
||||
executeNamedAction(action: string): void;
|
||||
executeSetOCGState(action: object): Promise<void>;
|
||||
getAttachmentContent(id: string): Promise<undefined>;
|
||||
}
|
||||
|
||||
export async function resolvePdfDestinationPage(
|
||||
document: Pick<
|
||||
PDFDocumentProxy,
|
||||
"getDestination" | "getPageIndex" | "numPages"
|
||||
>,
|
||||
destination: PdfDestination
|
||||
) {
|
||||
const explicitDestination =
|
||||
typeof destination === "string"
|
||||
? await document.getDestination(destination)
|
||||
: destination;
|
||||
if (
|
||||
!Array.isArray(explicitDestination) ||
|
||||
explicitDestination.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pageReference = explicitDestination[0];
|
||||
let pageNumber: number;
|
||||
if (
|
||||
typeof pageReference === "number" &&
|
||||
Number.isInteger(pageReference)
|
||||
) {
|
||||
pageNumber = pageReference + 1;
|
||||
} else if (
|
||||
pageReference &&
|
||||
typeof pageReference === "object"
|
||||
) {
|
||||
pageNumber =
|
||||
(await document.getPageIndex(
|
||||
pageReference as Parameters<
|
||||
PDFDocumentProxy["getPageIndex"]
|
||||
>[0]
|
||||
)) + 1;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pageNumber >= 1 && pageNumber <= document.numPages
|
||||
? pageNumber
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolvePdfNamedActionPage(
|
||||
action: string,
|
||||
currentPage: number,
|
||||
pageCount: number
|
||||
) {
|
||||
switch (action) {
|
||||
case "FirstPage":
|
||||
return 1;
|
||||
case "LastPage":
|
||||
return pageCount;
|
||||
case "NextPage":
|
||||
case "PageDown":
|
||||
return Math.min(pageCount, currentPage + 1);
|
||||
case "PrevPage":
|
||||
case "PageUp":
|
||||
return Math.max(1, currentPage - 1);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPdfAnnotationLinkService(
|
||||
document: Pick<
|
||||
PDFDocumentProxy,
|
||||
"getDestination" | "getPageIndex" | "numPages"
|
||||
>,
|
||||
currentPage: number,
|
||||
callbacks: PdfAnnotationLinkCallbacks
|
||||
): PdfAnnotationLinkService {
|
||||
return {
|
||||
externalLinkEnabled: true,
|
||||
addLinkAttributes(link, url) {
|
||||
const resolvedUrl =
|
||||
decodeLocalDocumentLinkFromPdf(url) ?? url;
|
||||
link.href = "#";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.title ||= resolvedUrl;
|
||||
link.dataset.pdfExternalHref = resolvedUrl;
|
||||
link.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callbacks.onExternalLink(resolvedUrl);
|
||||
return false;
|
||||
};
|
||||
},
|
||||
getDestinationHash() {
|
||||
return "#pdf-destination";
|
||||
},
|
||||
getAnchorUrl() {
|
||||
return "#pdf-action";
|
||||
},
|
||||
async goToDestination(destination) {
|
||||
try {
|
||||
const pageNumber = await resolvePdfDestinationPage(
|
||||
document,
|
||||
destination
|
||||
);
|
||||
if (pageNumber !== undefined) {
|
||||
callbacks.onNavigateToPage(pageNumber);
|
||||
}
|
||||
} catch {
|
||||
// 损坏或无法解析的 PDF 目标保持无行为。
|
||||
}
|
||||
},
|
||||
executeNamedAction(action) {
|
||||
const pageNumber = resolvePdfNamedActionPage(
|
||||
action,
|
||||
currentPage,
|
||||
document.numPages
|
||||
);
|
||||
if (pageNumber !== undefined) {
|
||||
callbacks.onNavigateToPage(pageNumber);
|
||||
}
|
||||
},
|
||||
async executeSetOCGState() {
|
||||
// 精确预览不改变 PDF 可选内容组状态。
|
||||
},
|
||||
async getAttachmentContent() {
|
||||
// 精确预览不下载或执行 PDF 内嵌附件。
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
const wideImageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAABkAAAAOEAQMAAADDg2/hAAAABlBMVEVPRuUOpntYb4NEAAAB+klEQVR42u3bAQEAMAiAMPuXvjm4WwMCMAMAAAAAAAAAAAAAAHHvE0KECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEi5G4IAAAAAAAAAAAAAADUuRWECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiREg9BAAAAAAAAAAAAAAA6twKQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESKkHgIAAAAAAAAAAAAAAHVuBSFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRUg8BAAAAAAAAAAAAAIA6t4IQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECKmHAAAAAAAAAAAAAABAnVtBiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoTUQwAAAAAAAAAAAAAAoM6tIESIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFC6iEAAAAAAAAAAAAAABC3woXq+yBn0OwAAAAASUVORK5CYII=";
|
||||
const portraitImageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAA4QAAAcIAQMAAABevZ3IAAAABlBMVEUPdm5e6tS6V5BUAAAB3ElEQVR42u3NMQ0AAAgDsPk3DQ52cpDWQBMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACo5prRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0fhoXJUddU5DUXZIAAAAASUVORK5CYII=";
|
||||
const boundaryImageBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAABLAAAANwAQMAAAAFoAG2AAAABlBMVEXCQQz7vyQwzQKmAAACUElEQVR42u3aoREAQAgDQZr8JqnyDXhkxOJwqzNXb67nMv7CwsLCwsLCwsLCwsLCwsLCwsI6sbI4+2NhYWFhYWFhYWFhYWFhYWFhYd1Y1kAsLCwsLCwsLCwsLCwsLCwsLP2WkRILCwsLCwsLCwsLCwsLCwsLS79lpMTCwsLCwsLCwsLCwsLCwsLC0m9hYWFhYWFhYWFhYWFhYWFhYem3rIFYWFhYWFhYWFhYWFhYWFhYWPotIyUWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFpd+yBmJhYWFhYWFhYWFhYWFhYWFh6beMlFhYWFhYWFhYWFhYWFhYWFhY+i0sLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhaWfstIiYWFhYWFhYWFhYWFhYWFhYWl3zJSYmFhYWFhYWFhYWFhYWFhYWHpt7CwsLCwsLCwsLCwsLCwsLCw9FvWQCwsLCwsLCwsLCwsLCwsLCws/ZaREgsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFhaXfwsLCwsLCwsLCwsLCwsLCwsLSb1kDsbCwsLCwsLCwsLCwsLCwsLD0W0ZKLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLC0u/ZaTEwsLCwsLCwsLCwsLCwsLCwtJvGSmxsLCwsLCwsLCwsLCwsLCwsPRbWFhYWFhYWFiRrA8Rs81BfKlBmwAAAABJRU5ErkJggg==";
|
||||
|
||||
export const sampleMarkdown = `---
|
||||
title: Markdown PDF 媒体分页示例
|
||||
author: 内网文档团队
|
||||
keywords:
|
||||
- Markdown
|
||||
- PDF
|
||||
- 媒体分页
|
||||
---
|
||||
|
||||
# Markdown PDF 媒体分页示例
|
||||
|
||||
这份文档集中展示常用排版、不同尺寸图片、Mermaid 与 ECharts 的渲染和分页效果。
|
||||
|
||||
## 基础排版
|
||||
|
||||
- [x] Markdown 实时预览
|
||||
- [x] 图片、Mermaid 与 ECharts
|
||||
- [x] 快速预览和精确 PDF 预览
|
||||
- [ ] 根据实际文档继续扩展主题
|
||||
|
||||
| 能力 | 示例用途 |
|
||||
| --- | --- |
|
||||
| 表格与任务列表 | 验证主题基础样式 |
|
||||
| 数学公式 | 验证字体和基线 |
|
||||
| 媒体块 | 验证限宽、缩放与分页 |
|
||||
|
||||
行内公式:$E = mc^2$。
|
||||
|
||||
> 预览与 PDF 共用相同的渲染结果、主题 CSS 和分页结构。
|
||||
|
||||
## 网络图片
|
||||
|
||||
下面只保留一张网络图片,用于验证远程资源获取与离线失败提示。
|
||||
|
||||

|
||||
|
||||
## Base64 图片块
|
||||
|
||||
### 横向图片:限宽基准
|
||||
|
||||
1600 × 900 的横图会先按正文宽度等比例收缩,收缩后的高度才是分页算法使用的基准高度。
|
||||
|
||||
![Base64 横向图片 1600×900][base64-wide]
|
||||
|
||||
### 边界图片:上一页空白回填
|
||||
|
||||
这一段用于形成稳定的分页边界。分页算法会保留标题、图注和间距的原始尺寸,只计算图片主体还能使用的高度。如果主体只需轻微缩小,并且缩放率仍在允许范围内,就尝试回填到上一页空白区域。
|
||||
|
||||
为了覆盖长文档场景,这里继续补充少量正文,使媒体块靠近默认 A4 页面底部。媒体元素必须按照源文档顺序串行处理,前一个媒体完成重排以后,后一个媒体才基于新的版面重新计算。
|
||||
|
||||
![Base64 边界图片 1200×880][base64-boundary]
|
||||
|
||||
### 纵向图片:单页高度适配
|
||||
|
||||
900 × 1800 的纵图在限宽后仍可能超过单页内容区,此时应继续等比例缩小并完整放在一页内。
|
||||
|
||||
![Base64 纵向图片 900×1800][base64-portrait]
|
||||
|
||||
## Mermaid 尺寸示例
|
||||
|
||||
### 横向流程图:限宽
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["Markdown"] --> B["统一 HTML"]
|
||||
B --> C["主题 CSS"]
|
||||
C --> D["网页预览"]
|
||||
C --> E["PDF 导出"]
|
||||
\`\`\`
|
||||
|
||||
### 纵向流程图:高度和分页
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TB
|
||||
A["读取文档"] --> B["解析 Front Matter"]
|
||||
B --> C["渲染 Markdown"]
|
||||
C --> D["渲染 Mermaid"]
|
||||
D --> E["渲染 ECharts"]
|
||||
E --> F["等待图片与字体"]
|
||||
F --> G["首次分页"]
|
||||
G --> H["串行检查媒体块"]
|
||||
H --> I["必要时重新分页"]
|
||||
I --> J["生成 PDF"]
|
||||
\`\`\`
|
||||
|
||||
## ECharts 尺寸示例
|
||||
|
||||
### 紧凑图表:40mm
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 紧凑柱状图(40mm)
|
||||
height: 40mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [研发, 产品, 设计, 运营]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [28, 18, 15, 22]
|
||||
\`\`\`
|
||||
|
||||
### 标准图表:58mm
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 产品能力树(58mm)
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: tree
|
||||
orient: LR
|
||||
label:
|
||||
position: left
|
||||
data:
|
||||
- name: 文档平台
|
||||
children:
|
||||
- name: Markdown
|
||||
children:
|
||||
- name: Mermaid
|
||||
- name: ECharts
|
||||
- name: PDF
|
||||
children:
|
||||
- name: 快速预览
|
||||
- name: 精确预览
|
||||
\`\`\`
|
||||
|
||||
大型图表的 caption 保持原尺寸,仅缩放图表主体。
|
||||
|
||||
### 大型图表:85mm
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 模块工作量占比(85mm)
|
||||
height: 85mm
|
||||
option:
|
||||
series:
|
||||
- type: treemap
|
||||
roam: false
|
||||
nodeClick: false
|
||||
breadcrumb:
|
||||
show: false
|
||||
label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
data:
|
||||
- name: 渲染
|
||||
children:
|
||||
- { name: Markdown, value: 32 }
|
||||
- { name: ECharts, value: 24 }
|
||||
- { name: Mermaid, value: 18 }
|
||||
- name: 导出
|
||||
children:
|
||||
- { name: PDF, value: 28 }
|
||||
- { name: 主题, value: 12 }
|
||||
\`\`\`
|
||||
|
||||
## 串行媒体块
|
||||
|
||||
下面连续放置图片、Mermaid 和 ECharts,用于观察长文档中多个媒体元素是否始终按源文档顺序计算。
|
||||
|
||||
![Base64 串行边界图片][base64-boundary]
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["图片完成"] --> B["Mermaid 计算"]
|
||||
B --> C["ECharts 计算"]
|
||||
\`\`\`
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 串行媒体收尾图(58mm)
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: pie
|
||||
radius: [35%, 70%]
|
||||
data:
|
||||
- { name: 图片, value: 36 }
|
||||
- { name: Mermaid, value: 28 }
|
||||
- { name: ECharts, value: 36 }
|
||||
\`\`\`
|
||||
|
||||
> 所有媒体仅在额外缩放不超过阈值时回填;否则保持完整媒体块并移动到下一页。
|
||||
|
||||
[base64-wide]: data:image/png;base64,${wideImageBase64}
|
||||
[base64-portrait]: data:image/png;base64,${portraitImageBase64}
|
||||
[base64-boundary]: data:image/png;base64,${boundaryImageBase64}
|
||||
`;
|
||||
+247
-11
@@ -24,8 +24,7 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.file-button {
|
||||
button {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #b8c2bd;
|
||||
@@ -102,13 +101,31 @@ button:disabled {
|
||||
border-left: 1px solid #d4dad7;
|
||||
}
|
||||
|
||||
.document-summary > strong {
|
||||
.document-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-name-row > strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unsaved-badge {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #d9bd86;
|
||||
border-radius: 999px;
|
||||
background: #fff7e7;
|
||||
color: #8a5a13;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.document-summary .render-status {
|
||||
max-width: 100%;
|
||||
margin-top: 3px;
|
||||
@@ -138,17 +155,103 @@ h1 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
.more-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
.more-menu-popover {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
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;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
margin: 5px 7px;
|
||||
background: #e0e6e3;
|
||||
}
|
||||
|
||||
.submenu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.more-menu-submenu {
|
||||
position: absolute;
|
||||
z-index: 31;
|
||||
top: -7px;
|
||||
right: calc(100% + 8px);
|
||||
width: 250px;
|
||||
max-height: min(70vh, 560px);
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #c8d1cc;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
|
||||
}
|
||||
|
||||
.sample-menu-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sample-menu-group + .sample-menu-group {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
border-top: 1px solid #e3e8e5;
|
||||
}
|
||||
|
||||
.sample-menu-group-label {
|
||||
padding: 6px 11px 3px;
|
||||
color: #718078;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
@@ -546,7 +649,8 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas,
|
||||
.precise-pdf-page .textLayer {
|
||||
.precise-pdf-page .textLayer,
|
||||
.precise-pdf-page .annotationLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
@@ -580,6 +684,45 @@ textarea:focus {
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
.precise-pdf-page .annotationLayer {
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
color-scheme: only light;
|
||||
pointer-events: none;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page .annotationLayer section {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
text-align: initial;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page
|
||||
.annotationLayer
|
||||
.linkAnnotation
|
||||
> a {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.precise-pdf-page
|
||||
.annotationLayer
|
||||
.linkAnnotation
|
||||
> a:hover,
|
||||
.precise-pdf-page
|
||||
.annotationLayer
|
||||
.linkAnnotation
|
||||
> a:focus-visible {
|
||||
background: rgb(31 111 83 / 14%);
|
||||
outline: 1px solid rgb(31 111 83 / 45%);
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer :is(span, br) {
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
@@ -672,6 +815,59 @@ textarea:focus {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.document-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.document-dialog-backdrop {
|
||||
position: absolute;
|
||||
background: rgb(24 35 30 / 46%);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.document-dialog {
|
||||
position: relative;
|
||||
width: min(480px, 100%);
|
||||
padding: 24px;
|
||||
border: 1px solid #c9d2cd;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgb(20 34 28 / 28%);
|
||||
}
|
||||
|
||||
.document-dialog h2 {
|
||||
margin: 4px 0 10px;
|
||||
color: #25332d;
|
||||
font-family: Georgia, "Songti SC", serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.document-dialog p {
|
||||
margin: 0;
|
||||
color: #5d6a64;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.document-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.document-dialog-actions .is-primary {
|
||||
border-color: #326b54;
|
||||
background: #326b54;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -850,6 +1046,19 @@ textarea:focus {
|
||||
gap: 8px 18px;
|
||||
}
|
||||
|
||||
.margin-mode-control {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.margin-source-hint {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.margin-reset-action {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.margin-grid .setting-field {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -1056,4 +1265,31 @@ textarea:focus {
|
||||
.settings-drawer {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,11 @@ export type { ThemeSummary } from "./application-backend";
|
||||
|
||||
export function useThemeResources(
|
||||
themeId: string,
|
||||
onThemesLoaded: (themes: ThemeSummary[]) => void
|
||||
onThemesLoaded: (
|
||||
themes: ThemeSummary[],
|
||||
refreshKey: number
|
||||
) => void,
|
||||
refreshKey = 0
|
||||
) {
|
||||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||
const [themeCss, setThemeCss] = useState("");
|
||||
@@ -23,7 +27,7 @@ export function useThemeResources(
|
||||
.then(({ themes: availableThemes }) => {
|
||||
setThemes(availableThemes);
|
||||
setCatalogError("");
|
||||
onThemesLoaded(availableThemes);
|
||||
onThemesLoaded(availableThemes, refreshKey);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -34,7 +38,7 @@ export function useThemeResources(
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [onThemesLoaded]);
|
||||
}, [onThemesLoaded, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -52,7 +56,7 @@ export function useThemeResources(
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [themeId]);
|
||||
}, [refreshKey, themeId]);
|
||||
|
||||
return {
|
||||
error: catalogError || cssError,
|
||||
|
||||
Vendored
+32
-1
@@ -3,7 +3,9 @@
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult,
|
||||
RenderedMarkdownDocument
|
||||
RenderedMarkdownDocument,
|
||||
ThemeCategory,
|
||||
DocumentProfileName
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
declare global {
|
||||
@@ -24,6 +26,33 @@ declare global {
|
||||
}
|
||||
| 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;
|
||||
@@ -38,6 +67,8 @@ declare global {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
category: ThemeCategory;
|
||||
compatibleProfiles: DocumentProfileName[];
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}>;
|
||||
|
||||
@@ -1,12 +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.4.3");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.3");
|
||||
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'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMarkdownFileName,
|
||||
deriveMarkdownBaseName,
|
||||
ensureMarkdownFileName,
|
||||
legalizeMarkdownBaseName
|
||||
} from "../src/document-file";
|
||||
|
||||
describe("Markdown 默认文件名", () => {
|
||||
it("优先使用 Front Matter 与代码围栏之外的一级标题", () => {
|
||||
expect(
|
||||
deriveMarkdownBaseName(`---
|
||||
title: 元数据标题
|
||||
---
|
||||
第一行正文
|
||||
|
||||
\`\`\`markdown
|
||||
# 围栏标题
|
||||
\`\`\`
|
||||
|
||||
# 2026 年研发周报:第 31 周`)
|
||||
).toBe("2026 年研发周报 第 31 周");
|
||||
});
|
||||
|
||||
it("没有一级标题时使用第一行非空正文", () => {
|
||||
expect(
|
||||
createMarkdownFileName(
|
||||
"\n\n> **本周完成** [API 重构](https://example.com)\n后续内容"
|
||||
)
|
||||
).toBe("本周完成 API 重构.md");
|
||||
});
|
||||
|
||||
it("四字符围栏不会被较短的同类围栏提前关闭", () => {
|
||||
expect(
|
||||
deriveMarkdownBaseName(`\`\`\`\`markdown
|
||||
# 围栏内标题
|
||||
\`\`\`
|
||||
# 仍在围栏内
|
||||
\`\`\`\`
|
||||
# 正文标题`)
|
||||
).toBe("正文标题");
|
||||
});
|
||||
|
||||
it("清理非法字符并规避 Windows 保留名称", () => {
|
||||
expect(legalizeMarkdownBaseName('季度总结:<测试> / "版本"')).toBe(
|
||||
"季度总结 测试 版本"
|
||||
);
|
||||
expect(legalizeMarkdownBaseName("CON")).toBe("文档 CON");
|
||||
});
|
||||
|
||||
it("空文档使用回退文件名并限制名称长度", () => {
|
||||
expect(createMarkdownFileName(" \n")).toBe("未命名文档.md");
|
||||
expect(legalizeMarkdownBaseName("文".repeat(150))).toHaveLength(100);
|
||||
});
|
||||
|
||||
it("保留已有 Markdown 扩展名", () => {
|
||||
expect(ensureMarkdownFileName("报告.markdown", "# 报告")).toBe(
|
||||
"报告.markdown"
|
||||
);
|
||||
expect(ensureMarkdownFileName("报告", "# 报告")).toBe("报告.md");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWebDocumentLinkAction } from "../src/document-link";
|
||||
|
||||
describe("Web 文档链接行为", () => {
|
||||
it("保留文档锚点", () => {
|
||||
expect(resolveWebDocumentLinkAction("#section")).toEqual({
|
||||
type: "anchor",
|
||||
href: "#section"
|
||||
});
|
||||
});
|
||||
|
||||
it("在外部窗口打开 HTTP 链接和浏览器协议", () => {
|
||||
expect(
|
||||
resolveWebDocumentLinkAction("//example.com/path")
|
||||
).toEqual({
|
||||
type: "open",
|
||||
href: "https://example.com/path"
|
||||
});
|
||||
expect(
|
||||
resolveWebDocumentLinkAction("mailto:a@example.com")
|
||||
).toEqual({
|
||||
type: "open",
|
||||
href: "mailto:a@example.com"
|
||||
});
|
||||
expect(resolveWebDocumentLinkAction("tel:+8612345")).toEqual({
|
||||
type: "open",
|
||||
href: "tel:+8612345"
|
||||
});
|
||||
});
|
||||
|
||||
it("忽略本地、未知和危险地址", () => {
|
||||
for (const href of [
|
||||
"../docs/a.md",
|
||||
"C:/docs/a.md",
|
||||
"file:///C:/docs/a.md",
|
||||
"obsidian://open?vault=x",
|
||||
"javascript:alert(1)"
|
||||
]) {
|
||||
expect(resolveWebDocumentLinkAction(href)).toEqual({
|
||||
type: "ignore"
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
|
||||
loadExportConfig,
|
||||
parseStoredExportConfig,
|
||||
saveExportConfig
|
||||
@@ -10,14 +11,15 @@ import {
|
||||
|
||||
function createMemoryStorage(
|
||||
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>();
|
||||
if (initialValue) {
|
||||
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||
}
|
||||
if (legacyValue) {
|
||||
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
||||
values.set(legacyKey, legacyValue);
|
||||
}
|
||||
return {
|
||||
getItem(key: string) {
|
||||
@@ -52,26 +54,106 @@ describe("导出设置缓存", () => {
|
||||
).toEqual(defaultExportConfig);
|
||||
});
|
||||
|
||||
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
||||
it("将 v2 缓存迁移为 v4 并让默认页边距跟随主题", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 2,
|
||||
mermaid: undefined,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: undefined,
|
||||
format: "A5"
|
||||
}
|
||||
};
|
||||
const storage = createMemoryStorage(
|
||||
null,
|
||||
JSON.stringify(legacyConfig)
|
||||
JSON.stringify(legacyConfig),
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
|
||||
);
|
||||
|
||||
const migrated = loadExportConfig(storage);
|
||||
expect(migrated.version).toBe(3);
|
||||
expect(migrated.version).toBe(4);
|
||||
expect(migrated.paper.format).toBe("A5");
|
||||
expect(migrated.paper.marginMode).toBe("theme");
|
||||
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("保存并恢复经过校验的配置", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("## 串行媒体块");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/application/package.json packages/application/package.json
|
||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY packages/preview-engine/package.json packages/preview-engine/package.json
|
||||
COPY packages/renderer/package.json packages/renderer/package.json
|
||||
RUN npm ci
|
||||
|
||||
@@ -20,10 +21,12 @@ COPY tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
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
|
||||
|
||||
@@ -38,6 +41,7 @@ COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/application/package.json packages/application/package.json
|
||||
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY packages/preview-engine/package.json packages/preview-engine/package.json
|
||||
COPY packages/renderer/package.json packages/renderer/package.json
|
||||
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
|
||||
|
||||
@@ -67,6 +71,7 @@ COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
|
||||
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
|
||||
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
|
||||
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
|
||||
COPY --chown=pwuser:pwuser packages/preview-engine/package.json packages/preview-engine/package.json
|
||||
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
|
||||
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
@@ -83,7 +88,9 @@ COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/preview-engine/dist ./packages/preview-engine/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
|
||||
COPY --chown=pwuser:pwuser --from=build /app/samples ./samples
|
||||
COPY --chown=pwuser:pwuser themes ./themes
|
||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --chown=pwuser:pwuser deploy/entrypoint.sh /app/deploy/entrypoint.sh
|
||||
|
||||
+7
-7
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
|
||||
可以直接指定版本化镜像名称构建:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.1'
|
||||
docker compose -f deploy\compose.yaml build
|
||||
```
|
||||
|
||||
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
|
||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
|
||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.5.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.4.1 应用代码和生产
|
||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.5.1 应用代码和生产
|
||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||
|
||||
## 主题挂载
|
||||
@@ -69,9 +69,9 @@ docker compose -f deploy\compose.yaml up -d
|
||||
目录下每套主题都应包含 `theme.json`。主题规范详见
|
||||
[`docs/THEMES.md`](../docs/THEMES.md)。
|
||||
|
||||
从 v0.4.0 起,公司内部私有版本已内置 GitHub、Pixyll 和 Whitey 三套
|
||||
Typora 默认主题;挂载目录仍可用于本机扩展主题。内置主题与挂载主题 ID
|
||||
重复时以内置版本为准。
|
||||
当前内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||
3 套标书主题;挂载目录只用于额外的本机扩展主题,无需重复
|
||||
放置内置主题。内置主题与挂载主题 ID 重复时以内置版本为准。
|
||||
|
||||
## 运行参数
|
||||
|
||||
|
||||
+264
-21
@@ -1,6 +1,6 @@
|
||||
# Markdown PDF 导出器进度
|
||||
|
||||
最后更新:2026-07-27
|
||||
最后更新:2026-07-29
|
||||
|
||||
## 1. 当前概况
|
||||
|
||||
@@ -41,6 +41,26 @@ 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.1 项目骨架
|
||||
@@ -426,9 +446,144 @@ Web/Compose 发布仍保持 `v0.4.1`。
|
||||
- 安装包和校验文件作为 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 Engine;Markdown 解析仍由 `packages/renderer` 的 markdown-it
|
||||
管线负责,应用服务继续统一主题与图片资源。
|
||||
- 新增“连续”预览模式,不创建纸张和分页节点,适合高频编辑;快速预览
|
||||
保留分页反馈,精确预览与最终 PDF 继续作为权威结果。
|
||||
- 增量分页根据源块稳定身份识别最早修改位置,复用此前稳定分页前缀,并
|
||||
串行重算受影响后缀;最终 PDF 不依赖客户端缓存。
|
||||
- Web 只处理当前文档锚点和 HTTP/HTTPS 外链,外链使用浏览器新窗口;
|
||||
本地路径及不支持协议不会替换预览 iframe。
|
||||
- Desktop 支持 HTTP/HTTPS、系统协议、相对或绝对本地路径及 `file:`
|
||||
URI;Markdown 链接提示在当前窗口或新窗口打开。
|
||||
- Desktop 改为多窗口,并按规范化文件路径保持同一文件单例;窗口聚焦时
|
||||
比较磁盘修改时间,检测到外部变化后提示是否重新加载。
|
||||
- 主窗口关闭后会选取仍存活的最新窗口作为状态持久化主窗口;窗口位置、
|
||||
尺寸和最大化状态仍按约 2 秒防抖写入并在启动时修正到可见工作区。
|
||||
- Core 新增跨端文档链接分类与 PDF 本地链接编码协议;精确 PDF 使用
|
||||
`mdpdf.local.invalid` 暂存本地链接,PDF.js 注释层解码后交给平台。
|
||||
- 内置主题名称统一为 Typora Github、Typora Pixyll、Typora whitey 和
|
||||
Typora Clean,默认主题为 Typora Github;删除强制覆盖主题链接样式的
|
||||
`!important`,保留主题自身设计。
|
||||
- markdown-it 普通围栏输出 `pre.md-fences` 和可选 `lang`;Preview
|
||||
Engine 仅重置内部语义 `code` 的重复行内盒模型,代码块与行内代码均
|
||||
保持主题预期,Mermaid/ECharts 专用围栏不受影响。
|
||||
- 根级 `build:web-runtime` 统一内嵌 Web 的六个工作区构建顺序;Desktop
|
||||
`package` 和 `make` 强制先调用该脚本,防止安装包复制陈旧
|
||||
`apps/web/dist`。
|
||||
|
||||
### 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. 已执行验证
|
||||
|
||||
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
|
||||
npm test
|
||||
@@ -440,17 +595,73 @@ git diff --check
|
||||
结果:
|
||||
|
||||
- ECharts 围栏测试:36 项通过;
|
||||
- 共享配置测试:9 项通过;
|
||||
- 渲染器测试:10 项通过;
|
||||
- 应用服务测试:5 项通过;
|
||||
- 前端测试:67 项通过;
|
||||
- Core 共享协议测试:15 项通过;
|
||||
- 渲染器测试:14 项通过;
|
||||
- 应用服务测试:11 项通过;
|
||||
- Preview Engine 测试:53 项通过;
|
||||
- 前端测试:54 项通过;
|
||||
- 后端测试:29 项通过;
|
||||
- 桌面端测试:7 项通过;
|
||||
- 全项目共 171 项测试通过;
|
||||
- 桌面端测试:26 项通过;
|
||||
- 全项目共 238 项测试通过;
|
||||
- 全项目类型检查通过;
|
||||
- 生产构建通过;
|
||||
- `git diff --check` 通过。
|
||||
|
||||
`v0.5.0` 双端与发行验收结果:
|
||||
|
||||
- 正式镜像 `yixiong/md-to-pdf:v0.5.0` 内容 ID 为
|
||||
`sha256:13a0955f3629e4e96d7af9d38230ead5ff6b8b70be2b35d5f8da9fdeb1f66474`,
|
||||
大小 640,294,504 字节;Compose 使用该内容 ID 健康运行在
|
||||
`http://localhost:8080`。
|
||||
- Web 快速、连续和精确预览完成真实浏览器验证:连续模式只有外层滚动,
|
||||
ECharts 使用完整内容宽度和配置高度;快速预览后缀编辑保留稳定前缀。
|
||||
- Web 外链样式、受控新窗口和本地链接禁用通过;精确 PDF 注释层保留
|
||||
HTTP/HTTPS 与锚点,并能将 `.invalid` 地址解码为原始本地路径。
|
||||
- Desktop 多窗口、同一文件单例、主窗口关闭后的状态接管、最大化防抖
|
||||
写入和重启恢复通过;本机安装版已升级至 v0.5.0。
|
||||
- 围栏代码块在 Typora Github 中显示完整外框且内部不再逐行出现行内
|
||||
代码方框;Web 与最终 Desktop 安装版均由用户确认,ECharts 围栏未受
|
||||
普通代码块兼容样式影响。
|
||||
- 正式 NSIS 为 `md-to-pdf-0.5.0-x86_64-Setup.exe`,95,004,759
|
||||
字节,SHA-256 为
|
||||
`60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92`。
|
||||
- 正式 ZIP 为 `md-to-pdf-0.5.0-x86_64.zip`,132,485,574 字节,
|
||||
SHA-256 为
|
||||
`D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8`。
|
||||
- 安装版内嵌 `paged-preview` Bundle 与本地最新 Web Bundle 的
|
||||
SHA-256 均为
|
||||
`BD29762A2E3CF1A2AAC4DD055D623B42C754DF46A3E773DD99B01CDFDAE36CE5`;
|
||||
`.md`、`.markdown` OpenWithProgids 注册正常。
|
||||
|
||||
`v0.4.5` 双端与媒体分页验收结果:
|
||||
|
||||
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
|
||||
为 `sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`;
|
||||
Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`。
|
||||
- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF,
|
||||
Mermaid 与 ECharts 错误数均为 0。
|
||||
- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只
|
||||
出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。
|
||||
- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、
|
||||
928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。
|
||||
- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过;
|
||||
`output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。
|
||||
- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题
|
||||
目录能够打开且可识别已有主题。
|
||||
- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸
|
||||
测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。
|
||||
- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`,
|
||||
94,998,489 字节,SHA-256 为
|
||||
`9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`。
|
||||
- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`,132,477,245 字节,
|
||||
SHA-256 为
|
||||
`97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`。
|
||||
- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN` 和 `en-US` 语言包,
|
||||
Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。
|
||||
- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、
|
||||
桌面与开始菜单快捷方式、`.md` 和 `.markdown` 打开注册均验证通过,
|
||||
安装版应用启动正常。
|
||||
|
||||
Electron 桌面端第一阶段验证结果:
|
||||
|
||||
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
|
||||
@@ -648,6 +859,22 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
SHA-256 为
|
||||
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`。
|
||||
|
||||
2026-07-27 已完成 `v0.4.4` 桌面语言包精简验证:
|
||||
|
||||
- 目录版只包含 `zh-CN.pak` 和 `en-US.pak`,语言资源从 55 个、
|
||||
48,911,653 字节降为 2 个、1,137,843 字节。
|
||||
- 目录版总大小从 378,084,454 字节降为 330,310,644 字节,减少
|
||||
47,773,810 字节;Chromium 图形与软件渲染后备组件均保持完整。
|
||||
- Windows 主程序版本为 `0.4.4`,目录版成功启动并保持应用、GPU、
|
||||
渲染器等 4 个 Electron 进程正常运行。
|
||||
- 正式安装包为 `md-to-pdf-0.4.4-x86_64-Setup.exe`,94,991,759 字节,
|
||||
SHA-256 为
|
||||
`0827FD87A8EEC8B034DF738909CB6A20231245B507AE4A0BAA579AFA03A942DE`,
|
||||
Authenticode 状态为未签名。
|
||||
- 免安装包为 `md-to-pdf-0.4.4-x86_64.zip`,132,470,241 字节,
|
||||
SHA-256 为
|
||||
`D6CDB8DAE30676BAB6B2A037567164476AD294588B102A6FBBE9458ABCE5CCC0`。
|
||||
|
||||
## 5. 当前注意事项
|
||||
|
||||
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
||||
@@ -655,13 +882,18 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
|
||||
渲染错误状态。
|
||||
- `apps/web/src/use-theme-resources.ts` 负责主题清单、主题 CSS 和请求取消。
|
||||
- `apps/web/src/paged-document-runtime.ts` 是快速预览和 PDF 共用的 Mermaid、资源等待和 Paged.js 分页运行时。
|
||||
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 消息协议和运行目标选择。
|
||||
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
|
||||
- `apps/web/src/mermaid-page-fit.ts` 负责超高 Mermaid 的单页等比例适配。
|
||||
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
|
||||
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
|
||||
- `packages/preview-engine/src/paged-document-runtime.ts` 是连续预览、
|
||||
快速预览和 PDF 共用的媒体渲染、资源等待和 Paged.js 运行时。
|
||||
- `packages/preview-engine/src/continuous-preview.ts` 与
|
||||
`incremental-pagination.ts` 分别负责无分页 DOM 更新和后缀分页缓存。
|
||||
- `packages/preview-engine/src/paged-preview.ts` 负责分页协议、共享文档
|
||||
CSS、页眉页码和 Typora 代码围栏结构兼容。
|
||||
- Mermaid、ECharts、图片适配、媒体回填及跨页表格实现均位于
|
||||
`packages/preview-engine/src/`,不得在 Web 或 Desktop 复制第二套。
|
||||
- `apps/web/src/PrecisePdfPreview.tsx` 和 `pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
|
||||
- `apps/web/src/document-link.ts` 与 `pdf-annotation-links.ts` 负责 Web
|
||||
链接动作和精确 PDF 注释层适配。
|
||||
- `apps/web/src/PreviewPageControl.tsx` 和 `preview-page.ts` 负责当前页输入、
|
||||
页码边界、滚动页识别和快速跳转。
|
||||
- `apps/web/src/preview-zoom.ts` 和 `PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
|
||||
@@ -676,15 +908,17 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
|
||||
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
||||
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
||||
- `apps/desktop/src/main.ts` 负责 Electron 生命周期、自定义应用协议、
|
||||
应用窗口、IPC 校验和原生 PDF 保存。
|
||||
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
|
||||
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
|
||||
组装、协议和生命周期。
|
||||
- `apps/desktop/src/desktop-document-link.ts` 负责本地链接解析和
|
||||
Markdown 目标识别。
|
||||
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
|
||||
调度、网络限制、打印和窗口复用。
|
||||
- `apps/desktop/src/app-preload.ts` 与 `pdf-preload.ts` 分别定义应用
|
||||
窗口和 PDF 运行窗口的最小 IPC 能力。
|
||||
- `.local/themes/typora-*` 仍可作为用户本机副本;v0.4.0 的三套发布版本
|
||||
已复制到 `themes/` 并标记为内置主题。
|
||||
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
|
||||
- `.local/themes/` 只用于外挂自定义主题;四套发布主题已经内置,无需
|
||||
重复挂载。历史副本保存在 `.local/theme-backups`,均被 Git 忽略。
|
||||
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
|
||||
未来改变分发范围前必须重新完成许可证审查。
|
||||
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
||||
@@ -693,12 +927,21 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
目录读取;远程图片仅保留有界内存缓存。
|
||||
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
||||
导出,不依赖 Fastify 或本地 HTTP Server。
|
||||
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
|
||||
当前 Compose 容器保持运行。
|
||||
- `v0.5.0` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
|
||||
Compose 健康检查,当前 Compose 容器保持运行。
|
||||
- Desktop `package` 和 `make` 必须经过 `build:embedded-web`;禁止直接
|
||||
调用 electron-builder 复制未经本轮编译的 `apps/web/dist`。
|
||||
|
||||
## 6. 推荐接手顺序
|
||||
|
||||
### 阶段一:导出配置扩展
|
||||
### 阶段一:v0.5.x 稳定性与性能观测
|
||||
|
||||
- 为增量分页补充超长文档性能基线,记录稳定前缀页数和后缀重算耗时;
|
||||
- 覆盖 Windows 100%、125% 和 150% 显示缩放的快速/精确分页差异;
|
||||
- 扩展 Desktop 多窗口、文件外部变化和链接打开的真实端到端回归;
|
||||
- 持续检查连续预览中媒体宽高、单滚动容器和资源释放。
|
||||
|
||||
### 阶段二:导出配置扩展
|
||||
|
||||
- Front Matter 元数据覆盖;
|
||||
- 浏览器本地配置预设;
|
||||
|
||||
Generated
+25
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.1",
|
||||
"devDependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@types/node": "^24.10.1",
|
||||
@@ -539,11 +539,9 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"@md-to-pdf/preview-engine": "0.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"mermaid": "11.16.0",
|
||||
"pagedjs": "^0.4.3",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
@@ -1956,6 +1954,10 @@
|
||||
"resolved": "packages/markdown-echarts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@md-to-pdf/preview-engine": {
|
||||
"resolved": "packages/preview-engine",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@md-to-pdf/renderer": {
|
||||
"resolved": "packages/renderer",
|
||||
"link": true
|
||||
@@ -3486,7 +3488,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -5296,6 +5297,7 @@
|
||||
"integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.15.3",
|
||||
"builder-util": "26.15.3",
|
||||
@@ -5505,7 +5507,6 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
@@ -5516,7 +5517,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
@@ -9558,7 +9558,6 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
@@ -9568,7 +9567,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"packages/application": {
|
||||
@@ -9612,6 +9610,22 @@
|
||||
"markdown-it": "^14.3.0"
|
||||
}
|
||||
},
|
||||
"packages/preview-engine": {
|
||||
"name": "@md-to-pdf/preview-engine",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"echarts": "6.1.0",
|
||||
"mermaid": "11.16.0",
|
||||
"pagedjs": "^0.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"packages/renderer": {
|
||||
"name": "@md-to-pdf/renderer",
|
||||
"version": "0.1.0",
|
||||
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
||||
"workspaces": [
|
||||
@@ -8,20 +8,22 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && concurrently -k -n markdown-echarts,core,renderer,application,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||
"build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web",
|
||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
||||
"dev:application": "npm run dev -w @md-to-pdf/application",
|
||||
"dev:preview-engine": "npm run dev -w @md-to-pdf/preview-engine",
|
||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
||||
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||
"desktop:package": "npm run build -w @md-to-pdf/web && npm run package -w @md-to-pdf/desktop",
|
||||
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||
"desktop:package": "npm run package -w @md-to-pdf/desktop",
|
||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# @md-to-pdf/application
|
||||
|
||||
Web Server 与 Electron Desktop 共用的应用服务层,组合
|
||||
`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、
|
||||
主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和
|
||||
平台能力适配。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
application-service.ts 渲染、主题和资源用例入口
|
||||
image-resources.ts 本地、Base64 与受限远程图片处理
|
||||
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
||||
index.ts 公共导出入口
|
||||
tests/
|
||||
application-service.test.ts
|
||||
bundled-themes.test.ts
|
||||
image-resources.test.ts
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import { createApplicationService } from "@md-to-pdf/application";
|
||||
|
||||
const service = createApplicationService({
|
||||
bundledRoot,
|
||||
localRoot
|
||||
});
|
||||
|
||||
const themes = await service.listThemes();
|
||||
const document = await service.render(request);
|
||||
```
|
||||
|
||||
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
|
||||
服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录,
|
||||
Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
||||
|
||||
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
||||
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```powershell
|
||||
npm run dev -w @md-to-pdf/application
|
||||
npm run test -w @md-to-pdf/application
|
||||
npm run typecheck -w @md-to-pdf/application
|
||||
npm run build -w @md-to-pdf/application
|
||||
```
|
||||
@@ -104,6 +104,9 @@ export function createApplicationService(
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
category: manifest.category,
|
||||
compatibleProfiles: manifest.compatibleProfiles,
|
||||
pageDefaults: manifest.pageDefaults,
|
||||
bundled: manifest.bundled,
|
||||
source
|
||||
}))
|
||||
|
||||
@@ -2,6 +2,7 @@ import { lookup } from "node:dns/promises";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
|
||||
|
||||
export const MAXIMUM_IMAGE_COUNT = 50;
|
||||
@@ -22,6 +23,7 @@ export interface MarkdownImageResource {
|
||||
|
||||
export interface ImageResolutionContext {
|
||||
localRoot?: string;
|
||||
allowUnrestrictedLocalFiles?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedMarkdownImages {
|
||||
@@ -313,6 +315,46 @@ async function loadLocalImage(root: string, source: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function decodeUnrestrictedLocalPath(source: string) {
|
||||
if (/^file:/iu.test(source)) {
|
||||
try {
|
||||
return fileURLToPath(new URL(source));
|
||||
} catch {
|
||||
throw new Error("file: 图片路径格式无效");
|
||||
}
|
||||
}
|
||||
const pathOnly = source.split(/[?#]/u, 1)[0] ?? "";
|
||||
try {
|
||||
return decodeURIComponent(pathOnly);
|
||||
} catch {
|
||||
throw new Error("图片路径 URL 编码无效");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUnrestrictedLocalImage(
|
||||
root: string,
|
||||
source: string
|
||||
) {
|
||||
const decoded = decodeUnrestrictedLocalPath(source);
|
||||
const target =
|
||||
path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)
|
||||
? path.normalize(decoded)
|
||||
: path.resolve(root, decoded);
|
||||
const realTarget = await realpath(target);
|
||||
const targetStat = await stat(realTarget);
|
||||
if (!targetStat.isFile()) {
|
||||
throw new Error("图片资源不是文件");
|
||||
}
|
||||
if (targetStat.size > MAXIMUM_IMAGE_BYTES) {
|
||||
throw new Error("本地图片超过单文件大小限制");
|
||||
}
|
||||
const content = await readFile(realTarget);
|
||||
return {
|
||||
content,
|
||||
contentType: detectImageContentType(content)
|
||||
};
|
||||
}
|
||||
|
||||
function parseUploadedResources(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return new Map<string, LoadedImage>();
|
||||
@@ -413,12 +455,26 @@ export function createImageResourceResolver(
|
||||
if (/^https?:\/\//iu.test(source)) {
|
||||
image = await loadRemote(source);
|
||||
} else {
|
||||
const normalized = normalizeDocumentAssetPath(source);
|
||||
const uploadedImage = uploaded.get(normalized);
|
||||
let normalized: string | undefined;
|
||||
try {
|
||||
normalized = normalizeDocumentAssetPath(source);
|
||||
} catch (error) {
|
||||
if (!context.allowUnrestrictedLocalFiles) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const uploadedImage = normalized
|
||||
? uploaded.get(normalized)
|
||||
: undefined;
|
||||
if (uploadedImage) {
|
||||
image = uploadedImage;
|
||||
} else if (context.localRoot) {
|
||||
image = await loadLocalImage(context.localRoot, source);
|
||||
image = context.allowUnrestrictedLocalFiles
|
||||
? await loadUnrestrictedLocalImage(
|
||||
context.localRoot,
|
||||
source
|
||||
)
|
||||
: await loadLocalImage(context.localRoot, source);
|
||||
} else {
|
||||
throw new Error("未提供对应素材目录");
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ const assetContentTypes: Record<string, string> = {
|
||||
const cssImportPattern =
|
||||
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
|
||||
const maximumCssImportDepth = 8;
|
||||
const sharedThemeAssetId = "_shared";
|
||||
const sharedThemeAssetScheme = "theme-shared:";
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
@@ -110,7 +112,7 @@ async function readThemeRoot(
|
||||
|
||||
const records: ThemeRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith("_")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -165,7 +167,8 @@ function prepareCssSegment(
|
||||
css: string,
|
||||
themeId: string,
|
||||
cssDirectory: string,
|
||||
createAssetUrl: (themeId: string, assetPath: string) => string
|
||||
createAssetUrl: (themeId: string, assetPath: string) => string,
|
||||
allowSharedAssets: boolean
|
||||
) {
|
||||
return css.replace(
|
||||
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
||||
@@ -174,6 +177,18 @@ function prepareCssSegment(
|
||||
if (value.startsWith("data:") || value.startsWith("#")) {
|
||||
return match;
|
||||
}
|
||||
if (value.startsWith(sharedThemeAssetScheme)) {
|
||||
if (!allowSharedAssets) {
|
||||
throw new Error("本地主题不能引用内置共享资源");
|
||||
}
|
||||
const sharedPath = normalizeCssPath(
|
||||
value.slice(sharedThemeAssetScheme.length)
|
||||
);
|
||||
if (!sharedPath || sharedPath === ".") {
|
||||
throw new Error("内置共享资源路径不能为空");
|
||||
}
|
||||
return `url("${createAssetUrl(sharedThemeAssetId, sharedPath)}")`;
|
||||
}
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
@@ -250,7 +265,8 @@ async function loadThemeCssFile(
|
||||
css.slice(previousEnd, matchStart),
|
||||
theme.manifest.id,
|
||||
cssDirectory,
|
||||
createAssetUrl
|
||||
createAssetUrl,
|
||||
theme.source === "bundled"
|
||||
);
|
||||
|
||||
const importedPath = readImportedPath(match);
|
||||
@@ -273,7 +289,8 @@ async function loadThemeCssFile(
|
||||
remainder,
|
||||
theme.manifest.id,
|
||||
cssDirectory,
|
||||
createAssetUrl
|
||||
createAssetUrl,
|
||||
theme.source === "bundled"
|
||||
);
|
||||
if (/@import\b/i.test(result)) {
|
||||
throw new Error("主题包含不支持的 CSS @import 语法");
|
||||
@@ -378,6 +395,20 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
}
|
||||
|
||||
async function getAsset(themeId: string, assetPath: string) {
|
||||
if (themeId === sharedThemeAssetId) {
|
||||
const extension = extname(assetPath).toLowerCase();
|
||||
const contentType = assetContentTypes[extension];
|
||||
if (!contentType) {
|
||||
throw new Error("不支持的主题资源类型");
|
||||
}
|
||||
const sharedRoot = resolve(options.bundledRoot, sharedThemeAssetId);
|
||||
const path = await resolveThemeFile(sharedRoot, assetPath);
|
||||
return {
|
||||
contentType,
|
||||
content: await readFile(path)
|
||||
};
|
||||
}
|
||||
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
|
||||
@@ -37,7 +37,13 @@ async function createThemeFixture() {
|
||||
const bundledRoot = join(temporaryDirectory, "bundled");
|
||||
const localRoot = join(temporaryDirectory, "local");
|
||||
const themeRoot = join(bundledRoot, "test-theme");
|
||||
const sharedFontRoot = join(
|
||||
bundledRoot,
|
||||
"_shared",
|
||||
"official-fonts"
|
||||
);
|
||||
await mkdir(join(themeRoot, "fonts"), { recursive: true });
|
||||
await mkdir(sharedFontRoot, { recursive: true });
|
||||
await mkdir(localRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(themeRoot, "theme.json"),
|
||||
@@ -53,6 +59,14 @@ async function createThemeFixture() {
|
||||
domPreset: "typora",
|
||||
defaultFontSize: "16px",
|
||||
supportedFeatures: ["code", "table"],
|
||||
pageDefaults: {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
}
|
||||
},
|
||||
bundled: true
|
||||
}),
|
||||
"utf8"
|
||||
@@ -64,6 +78,10 @@ async function createThemeFixture() {
|
||||
" font-family: Test;",
|
||||
" src: url('./fonts/test.woff2') format('woff2');",
|
||||
"}",
|
||||
"@font-face {",
|
||||
" font-family: SharedTest;",
|
||||
" src: url('theme-shared:official-fonts/shared.woff2') format('woff2');",
|
||||
"}",
|
||||
"#write { font-family: Test; }"
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
@@ -72,6 +90,10 @@ async function createThemeFixture() {
|
||||
join(themeRoot, "fonts", "test.woff2"),
|
||||
Buffer.from([0, 1, 2, 3])
|
||||
);
|
||||
await writeFile(
|
||||
join(sharedFontRoot, "shared.woff2"),
|
||||
Buffer.from([4, 5, 6, 7])
|
||||
);
|
||||
return { bundledRoot, localRoot };
|
||||
}
|
||||
|
||||
@@ -147,6 +169,16 @@ describe("共享应用服务", () => {
|
||||
themes: [
|
||||
expect.objectContaining({
|
||||
id: "test-theme",
|
||||
category: "general",
|
||||
compatibleProfiles: [],
|
||||
pageDefaults: {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
}
|
||||
},
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
})
|
||||
@@ -157,6 +189,11 @@ describe("共享应用服务", () => {
|
||||
).resolves.toContain(
|
||||
"mdpdf://theme/test-theme/fonts/test.woff2"
|
||||
);
|
||||
await expect(
|
||||
service.getThemeCss("test-theme")
|
||||
).resolves.toContain(
|
||||
"mdpdf://theme/_shared/official-fonts/shared.woff2"
|
||||
);
|
||||
});
|
||||
|
||||
it("安全读取主题二进制资源", async () => {
|
||||
@@ -172,6 +209,18 @@ describe("共享应用服务", () => {
|
||||
await expect(
|
||||
service.getThemeAsset("test-theme", "../test.woff2")
|
||||
).rejects.toThrow("主题资源路径不安全");
|
||||
await expect(
|
||||
service.getThemeAsset(
|
||||
"_shared",
|
||||
"official-fonts/shared.woff2"
|
||||
)
|
||||
).resolves.toEqual({
|
||||
contentType: "font/woff2",
|
||||
content: Buffer.from([4, 5, 6, 7])
|
||||
});
|
||||
await expect(
|
||||
service.getThemeAsset("_shared", "../shared.woff2")
|
||||
).rejects.toThrow("主题资源路径不安全");
|
||||
});
|
||||
|
||||
it("内置主题优先于同 ID 的旧本地副本", async () => {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const bundledThemeNames = {
|
||||
"typora-github": "Typora Github",
|
||||
"typora-pixyll": "Typora Pixyll",
|
||||
"typora-whitey": "Typora whitey",
|
||||
"typora-like": "Typora Clean",
|
||||
"gov-red-standard": "政企红头·标准文件",
|
||||
"gov-red-letter": "政企红头·标准信函",
|
||||
"enterprise-red-simple": "政企红头·企业简版",
|
||||
"red-briefing": "政企红头·纪要简报",
|
||||
"formal-report": "政企正式·工作报告",
|
||||
"formal-regulation": "政企正式·规章制度",
|
||||
"formal-feasibility": "政企正式·可研报告",
|
||||
"tender-classic": "标书正式·经典黑白",
|
||||
"tender-business-blue": "标书正式·商务蓝",
|
||||
"tender-blind": "标书正式·技术暗标"
|
||||
} as const;
|
||||
|
||||
describe("内置主题清单", () => {
|
||||
it("每个内置主题都有同名 Markdown 示例", async () => {
|
||||
const samplesRoot = fileURLToPath(
|
||||
new URL("../../../samples/themes/", import.meta.url)
|
||||
);
|
||||
const sampleFiles = (await readdir(samplesRoot))
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.sort();
|
||||
const expectedFiles = Object.keys(bundledThemeNames)
|
||||
.map((id) => `${id}.md`)
|
||||
.sort();
|
||||
|
||||
expect(sampleFiles).toEqual(expectedFiles);
|
||||
expect(sampleFiles).not.toContain("_shared.md");
|
||||
for (const fileName of sampleFiles) {
|
||||
const markdown = await readFile(
|
||||
`${samplesRoot}${fileName}`,
|
||||
"utf8"
|
||||
);
|
||||
expect(markdown.length).toBeGreaterThan(300);
|
||||
expect(markdown).toMatch(/^---[\s\S]+---/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("提供可随桌面发行包分发的内置教程", async () => {
|
||||
const tutorialsRoot = fileURLToPath(
|
||||
new URL("../../../samples/tutorials/", import.meta.url)
|
||||
);
|
||||
const tutorialFiles = (await readdir(tutorialsRoot))
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.sort();
|
||||
|
||||
expect(tutorialFiles).toEqual([
|
||||
"echarts-tutorial.md",
|
||||
"formal-document-tutorial.md"
|
||||
]);
|
||||
for (const fileName of tutorialFiles) {
|
||||
const markdown = await readFile(
|
||||
`${tutorialsRoot}${fileName}`,
|
||||
"utf8"
|
||||
);
|
||||
expect(markdown.startsWith("---")).toBe(true);
|
||||
expect(markdown.length).toBeGreaterThan(1_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("使用稳定主题 ID 和面向用户的显示名称", async () => {
|
||||
const themesRoot = fileURLToPath(
|
||||
new URL("../../../themes/", import.meta.url)
|
||||
);
|
||||
|
||||
for (const [id, name] of Object.entries(bundledThemeNames)) {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(
|
||||
`${themesRoot}${id}/theme.json`,
|
||||
"utf8"
|
||||
)
|
||||
) as { id?: unknown; name?: unknown };
|
||||
|
||||
expect(manifest.id).toBe(id);
|
||||
expect(manifest.name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("为红头主题声明分类与兼容的结构化文档", async () => {
|
||||
const themesRoot = fileURLToPath(
|
||||
new URL("../../../themes/", import.meta.url)
|
||||
);
|
||||
const expectedProfiles = {
|
||||
"gov-red-standard": "official",
|
||||
"gov-red-letter": "official",
|
||||
"enterprise-red-simple": "official",
|
||||
"red-briefing": "briefing"
|
||||
} as const;
|
||||
|
||||
for (const [id, profile] of Object.entries(expectedProfiles)) {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
|
||||
) as {
|
||||
category?: unknown;
|
||||
compatibleProfiles?: unknown;
|
||||
};
|
||||
|
||||
expect(manifest.category).toBe("red-letter");
|
||||
expect(manifest.compatibleProfiles).toContain(profile);
|
||||
}
|
||||
});
|
||||
|
||||
it("标准信函主题提供首页版头和末页底部红色双线", async () => {
|
||||
const [themeCss, printCss] = await Promise.all([
|
||||
readFile(
|
||||
new URL(
|
||||
"../../../themes/gov-red-letter/theme.css",
|
||||
import.meta.url
|
||||
),
|
||||
"utf8"
|
||||
),
|
||||
readFile(
|
||||
new URL(
|
||||
"../../../themes/gov-red-letter/print.css",
|
||||
import.meta.url
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
]);
|
||||
|
||||
expect(themeCss).toContain(".doc-masthead-official::after");
|
||||
expect(printCss).toContain(
|
||||
".pagedjs_pages > .pagedjs_page:last-child .pagedjs_pagebox::after"
|
||||
);
|
||||
expect(printCss).toContain("bottom: 20mm");
|
||||
});
|
||||
|
||||
it("为正式文档与标书主题声明正确分类", async () => {
|
||||
const themesRoot = fileURLToPath(
|
||||
new URL("../../../themes/", import.meta.url)
|
||||
);
|
||||
const expectedThemes = {
|
||||
"formal-report": {
|
||||
category: "formal",
|
||||
profile: undefined
|
||||
},
|
||||
"formal-regulation": {
|
||||
category: "formal",
|
||||
profile: undefined
|
||||
},
|
||||
"formal-feasibility": {
|
||||
category: "formal",
|
||||
profile: "project-report"
|
||||
},
|
||||
"tender-classic": {
|
||||
category: "tender",
|
||||
profile: "tender"
|
||||
},
|
||||
"tender-business-blue": {
|
||||
category: "tender",
|
||||
profile: "tender"
|
||||
},
|
||||
"tender-blind": {
|
||||
category: "tender",
|
||||
profile: "tender"
|
||||
}
|
||||
} as const;
|
||||
|
||||
for (const [id, expected] of Object.entries(expectedThemes)) {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
|
||||
) as {
|
||||
category?: unknown;
|
||||
compatibleProfiles?: unknown[];
|
||||
};
|
||||
|
||||
expect(manifest.category).toBe(expected.category);
|
||||
if (expected.profile) {
|
||||
expect(manifest.compatibleProfiles).toContain(
|
||||
expected.profile
|
||||
);
|
||||
} else {
|
||||
expect(manifest.compatibleProfiles).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("技术暗标主题隐藏结构化身份字段", async () => {
|
||||
const css = await readFile(
|
||||
new URL(
|
||||
"../../../themes/tender-blind/theme.css",
|
||||
import.meta.url
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(css).toMatch(
|
||||
/\.doc-cover-bidder,[\s\S]*\.doc-cover-representative\s*\{[\s\S]*display:\s*none\s*!important;/u
|
||||
);
|
||||
});
|
||||
|
||||
it("正式主题声明符合用途的推荐页边距", async () => {
|
||||
const themesRoot = fileURLToPath(
|
||||
new URL("../../../themes/", import.meta.url)
|
||||
);
|
||||
const expectedMargins = {
|
||||
"gov-red-standard": ["37mm", "26mm", "35mm", "28mm"],
|
||||
"gov-red-letter": ["37mm", "26mm", "35mm", "28mm"],
|
||||
"enterprise-red-simple": ["25mm", "25mm", "25mm", "28mm"],
|
||||
"red-briefing": ["25mm", "25mm", "25mm", "25mm"],
|
||||
"formal-report": ["37mm", "26mm", "35mm", "28mm"],
|
||||
"formal-regulation": ["37mm", "26mm", "35mm", "28mm"],
|
||||
"formal-feasibility": ["25mm", "20mm", "25mm", "30mm"],
|
||||
"tender-classic": ["25mm", "25mm", "25mm", "30mm"],
|
||||
"tender-business-blue": ["25mm", "25mm", "25mm", "30mm"],
|
||||
"tender-blind": ["25.4mm", "25.4mm", "25.4mm", "31.7mm"]
|
||||
} as const;
|
||||
|
||||
for (const [id, expected] of Object.entries(expectedMargins)) {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
|
||||
) as {
|
||||
pageDefaults?: {
|
||||
margins?: Record<"top" | "right" | "bottom" | "left", string>;
|
||||
};
|
||||
};
|
||||
const margins = manifest.pageDefaults?.margins;
|
||||
|
||||
expect([
|
||||
margins?.top,
|
||||
margins?.right,
|
||||
margins?.bottom,
|
||||
margins?.left
|
||||
]).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("红头机关名称不继承正文首行缩进", async () => {
|
||||
const themesRoot = fileURLToPath(
|
||||
new URL("../../../themes/", import.meta.url)
|
||||
);
|
||||
|
||||
for (const id of [
|
||||
"gov-red-standard",
|
||||
"gov-red-letter",
|
||||
"enterprise-red-simple"
|
||||
]) {
|
||||
const css = await readFile(
|
||||
`${themesRoot}${id}/theme.css`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(css).toMatch(
|
||||
/#write \.doc-issuer\s*\{[^}]*text-align:\s*center;[^}]*text-indent:\s*0;/su
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createImageResourceResolver,
|
||||
@@ -59,4 +63,41 @@ describe("Markdown 图片资源安全", () => {
|
||||
);
|
||||
expect(failed.warnings[0]).toContain("网络不可用");
|
||||
});
|
||||
|
||||
it("仅在显式授权时允许桌面读取上级、绝对及 file URI 图片", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "md-to-pdf-local-images-")
|
||||
);
|
||||
const documentRoot = path.join(directory, "docs");
|
||||
const imagePath = path.join(directory, "共享图片.png");
|
||||
try {
|
||||
await writeFile(imagePath, png);
|
||||
const resolve = createImageResourceResolver();
|
||||
const sources = [
|
||||
"../共享图片.png",
|
||||
imagePath,
|
||||
pathToFileURL(imagePath).href
|
||||
];
|
||||
const markdown = sources
|
||||
.map((source) => `})`)
|
||||
.join("\n");
|
||||
|
||||
const restricted = await resolve(markdown, undefined, {
|
||||
localRoot: documentRoot
|
||||
});
|
||||
expect(restricted.warnings).toHaveLength(3);
|
||||
|
||||
const unrestricted = await resolve(markdown, undefined, {
|
||||
localRoot: documentRoot,
|
||||
allowUnrestrictedLocalFiles: true
|
||||
});
|
||||
expect(unrestricted.warnings).toEqual([]);
|
||||
expect(unrestricted.sources).toHaveLength(3);
|
||||
for (const dataUrl of unrestricted.sources.values()) {
|
||||
expect(dataUrl).toMatch(/^data:image\/png;base64,/u);
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# @md-to-pdf/core
|
||||
|
||||
跨 Web、Server、Desktop 和渲染器共享的纯数据模型与校验规则。该包不
|
||||
依赖浏览器、Electron 或 Fastify,负责保证各运行端使用同一份协议。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
document.ts Markdown 文档、分页载荷、结果与耗时模型
|
||||
document-link.ts 跨端链接分类与 PDF 本地链接编码协议
|
||||
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
|
||||
theme.ts 主题清单、主题能力与 CSS 载荷模型
|
||||
index.ts 公共导出入口
|
||||
tests/
|
||||
document.test.ts
|
||||
document-link.test.ts
|
||||
export-config.test.ts
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyDocumentLink,
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig,
|
||||
exportConfigSchema
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
const link = classifyDocumentLink("../docs/example.md");
|
||||
const config = exportConfigSchema.parse(defaultExportConfig);
|
||||
const payload = createPagedDocumentPayload({
|
||||
document,
|
||||
fileName: "example.md",
|
||||
themeCss,
|
||||
exportConfig: config
|
||||
});
|
||||
```
|
||||
|
||||
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
|
||||
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
|
||||
Markdown 窗口。精确 PDF 使用保留的 `.invalid` 地址暂存本地链接,
|
||||
PDF.js 注释层展示时再安全解码,避免 Chromium 将相对路径误转成站点 URL。
|
||||
|
||||
新增跨端字段时,应先在这里定义类型、默认值、Zod 校验和兼容迁移,再由
|
||||
各适配层消费,避免 Web 与 Desktop 分别维护协议。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```powershell
|
||||
npm run dev -w @md-to-pdf/core
|
||||
npm run test -w @md-to-pdf/core
|
||||
npm run typecheck -w @md-to-pdf/core
|
||||
npm run build -w @md-to-pdf/core
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
export type DocumentLinkKind =
|
||||
| "anchor"
|
||||
| "network"
|
||||
| "protocol"
|
||||
| "local"
|
||||
| "unsafe"
|
||||
| "invalid";
|
||||
|
||||
export interface ClassifiedDocumentLink {
|
||||
kind: DocumentLinkKind;
|
||||
href: string;
|
||||
normalizedHref: string;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
const schemePattern = /^([a-z][a-z\d+.-]*):/iu;
|
||||
const windowsAbsolutePathPattern = /^[a-z]:[\\/]/iu;
|
||||
const windowsUncPathPattern = /^\\\\[^\\]/u;
|
||||
const unsafeSchemePattern = /^(?:data|javascript|vbscript)$/u;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/u;
|
||||
const pdfLocalLinkOrigin = "https://mdpdf.local.invalid";
|
||||
const pdfLocalLinkPath = "/document-link";
|
||||
|
||||
function result(
|
||||
kind: DocumentLinkKind,
|
||||
href: string,
|
||||
normalizedHref = href,
|
||||
scheme?: string
|
||||
): ClassifiedDocumentLink {
|
||||
return {
|
||||
kind,
|
||||
href,
|
||||
normalizedHref,
|
||||
...(scheme ? { scheme } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyDocumentLink(
|
||||
rawHref: string
|
||||
): ClassifiedDocumentLink {
|
||||
const href = rawHref.trim();
|
||||
if (!href || controlCharacterPattern.test(href)) {
|
||||
return result("invalid", href);
|
||||
}
|
||||
|
||||
if (href.startsWith("#")) {
|
||||
return result("anchor", href);
|
||||
}
|
||||
|
||||
if (
|
||||
windowsAbsolutePathPattern.test(href) ||
|
||||
windowsUncPathPattern.test(href)
|
||||
) {
|
||||
return result("local", href);
|
||||
}
|
||||
|
||||
if (href.startsWith("//")) {
|
||||
return result("network", href, `https:${href}`, "https");
|
||||
}
|
||||
|
||||
const schemeMatch = schemePattern.exec(href);
|
||||
if (!schemeMatch) {
|
||||
return result("local", href);
|
||||
}
|
||||
|
||||
const scheme = schemeMatch[1]?.toLowerCase() ?? "";
|
||||
if (unsafeSchemePattern.test(scheme)) {
|
||||
return result("unsafe", href, href, scheme);
|
||||
}
|
||||
|
||||
if (scheme === "http" || scheme === "https") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
if (!url.hostname) {
|
||||
return result("invalid", href, href, scheme);
|
||||
}
|
||||
return result("network", href, href, scheme);
|
||||
} catch {
|
||||
return result("invalid", href, href, scheme);
|
||||
}
|
||||
}
|
||||
|
||||
if (scheme === "file") {
|
||||
return result("local", href, href, scheme);
|
||||
}
|
||||
|
||||
return result("protocol", href, href, scheme);
|
||||
}
|
||||
|
||||
export function isSafeDocumentLink(rawHref: string) {
|
||||
const { kind } = classifyDocumentLink(rawHref);
|
||||
return kind !== "unsafe" && kind !== "invalid";
|
||||
}
|
||||
|
||||
export function encodeLocalDocumentLinkForPdf(rawHref: string) {
|
||||
const link = classifyDocumentLink(rawHref);
|
||||
if (link.kind !== "local") {
|
||||
return undefined;
|
||||
}
|
||||
const encoded = new URL(pdfLocalLinkPath, pdfLocalLinkOrigin);
|
||||
encoded.searchParams.set("href", link.href);
|
||||
return encoded.href;
|
||||
}
|
||||
|
||||
export function decodeLocalDocumentLinkFromPdf(rawHref: string) {
|
||||
let encoded: URL;
|
||||
try {
|
||||
encoded = new URL(rawHref);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
encoded.origin !== pdfLocalLinkOrigin ||
|
||||
encoded.pathname !== pdfLocalLinkPath
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const href = encoded.searchParams.get("href");
|
||||
if (!href || classifyDocumentLink(href).kind !== "local") {
|
||||
return undefined;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const scalarTextSchema = (maximumLength: number) =>
|
||||
z.preprocess(
|
||||
(value) => {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return String(value).trim();
|
||||
}
|
||||
return value;
|
||||
},
|
||||
z.string().min(1).max(maximumLength)
|
||||
);
|
||||
|
||||
const shortTextSchema = scalarTextSchema(120);
|
||||
const longTextSchema = scalarTextSchema(500);
|
||||
const textListSchema = z.preprocess(
|
||||
(value) =>
|
||||
typeof value === "string"
|
||||
? value
|
||||
.split(/[,,]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: value,
|
||||
z.array(shortTextSchema).max(30)
|
||||
);
|
||||
|
||||
export const documentProfileNameSchema = z.enum([
|
||||
"official",
|
||||
"briefing",
|
||||
"project-report",
|
||||
"tender"
|
||||
]);
|
||||
|
||||
const officialDocumentProfileSchema = z
|
||||
.object({
|
||||
profile: z.literal("official"),
|
||||
issuer: shortTextSchema.optional(),
|
||||
number: shortTextSchema.optional(),
|
||||
secrecy: shortTextSchema.optional(),
|
||||
urgency: shortTextSchema.optional(),
|
||||
signatory: shortTextSchema.optional(),
|
||||
date: shortTextSchema.optional(),
|
||||
copyTo: textListSchema.optional(),
|
||||
printingOffice: shortTextSchema.optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const briefingDocumentProfileSchema = z
|
||||
.object({
|
||||
profile: z.literal("briefing"),
|
||||
masthead: shortTextSchema.optional(),
|
||||
issue: shortTextSchema.optional(),
|
||||
publisher: shortTextSchema.optional(),
|
||||
signatory: shortTextSchema.optional(),
|
||||
date: shortTextSchema.optional(),
|
||||
contact: longTextSchema.optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const projectReportDocumentProfileSchema = z
|
||||
.object({
|
||||
profile: z.literal("project-report"),
|
||||
projectName: longTextSchema.optional(),
|
||||
documentType: shortTextSchema.optional(),
|
||||
owner: longTextSchema.optional(),
|
||||
preparedBy: longTextSchema.optional(),
|
||||
version: shortTextSchema.optional(),
|
||||
date: shortTextSchema.optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const tenderDocumentProfileSchema = z
|
||||
.object({
|
||||
profile: z.literal("tender"),
|
||||
copyMark: shortTextSchema.optional(),
|
||||
projectName: longTextSchema.optional(),
|
||||
projectNumber: shortTextSchema.optional(),
|
||||
volume: shortTextSchema.optional(),
|
||||
bidder: longTextSchema.optional(),
|
||||
representative: shortTextSchema.optional(),
|
||||
date: shortTextSchema.optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const documentProfileSchema = z.discriminatedUnion("profile", [
|
||||
officialDocumentProfileSchema,
|
||||
briefingDocumentProfileSchema,
|
||||
projectReportDocumentProfileSchema,
|
||||
tenderDocumentProfileSchema
|
||||
]);
|
||||
|
||||
export type DocumentProfileName = z.infer<
|
||||
typeof documentProfileNameSchema
|
||||
>;
|
||||
export type DocumentProfile = z.infer<typeof documentProfileSchema>;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExportConfig } from "./export-config.js";
|
||||
import type { DocumentProfile } from "./document-profile.js";
|
||||
import type { ThemeFeature } from "./theme.js";
|
||||
|
||||
export interface MarkdownDocumentMetadata {
|
||||
@@ -7,6 +8,7 @@ export interface MarkdownDocumentMetadata {
|
||||
subject: string;
|
||||
keywords: string[];
|
||||
language: string;
|
||||
document?: DocumentProfile;
|
||||
}
|
||||
|
||||
export interface RenderedMarkdownDocument {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const EXPORT_CONFIG_VERSION = 3;
|
||||
export const EXPORT_CONFIG_VERSION = 4;
|
||||
export const CSS_PIXELS_PER_INCH = 96;
|
||||
export const PDF_POINTS_PER_INCH = 72;
|
||||
export const MILLIMETERS_PER_INCH = 25.4;
|
||||
@@ -86,23 +86,39 @@ export function millimetersToPdfPoints(value: number) {
|
||||
return (value * PDF_POINTS_PER_INCH) / MILLIMETERS_PER_INCH;
|
||||
}
|
||||
|
||||
const marginSchema = z.object({
|
||||
export const pageMarginsSchema = z.object({
|
||||
top: lengthSchema,
|
||||
right: lengthSchema,
|
||||
bottom: lengthSchema,
|
||||
left: lengthSchema
|
||||
});
|
||||
|
||||
const headerSlotSchema = z.object({
|
||||
export const marginModeSchema = z.enum(["theme", "custom"]);
|
||||
export const pageDecorationsModeSchema = z.enum(["theme", "custom"]);
|
||||
|
||||
export const headerSlotSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
content: z.string().max(500)
|
||||
});
|
||||
|
||||
const headerSchema = z.object({
|
||||
const pageDecorationFontFamilySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(300)
|
||||
.regex(
|
||||
/^[\p{L}\p{N}\s,"'-]+$/u,
|
||||
"页眉页码字体只能包含字体名称、引号和逗号"
|
||||
);
|
||||
|
||||
export const headerSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
height: lengthSchema,
|
||||
showDivider: z.boolean(),
|
||||
fontSize: lengthSchema,
|
||||
fontFamily: pageDecorationFontFamilySchema.default(
|
||||
'"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
),
|
||||
color: z.string(),
|
||||
left: headerSlotSchema,
|
||||
center: headerSlotSchema,
|
||||
@@ -114,26 +130,39 @@ export const pageNumberFormatSchema = z.enum([
|
||||
"page-total",
|
||||
"chinese-page-total",
|
||||
"dash-page",
|
||||
"official-page",
|
||||
"custom"
|
||||
]);
|
||||
|
||||
const footerSchema = z.object({
|
||||
export const footerAlignmentSchema = z.enum([
|
||||
"left",
|
||||
"center",
|
||||
"right",
|
||||
"outer"
|
||||
]);
|
||||
|
||||
export const footerSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
height: lengthSchema,
|
||||
showDivider: z.boolean(),
|
||||
fontSize: lengthSchema,
|
||||
fontFamily: pageDecorationFontFamilySchema.default(
|
||||
'"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
),
|
||||
color: z.string(),
|
||||
alignment: z.enum(["left", "center", "right"]),
|
||||
alignment: footerAlignmentSchema,
|
||||
format: pageNumberFormatSchema,
|
||||
template: z.string().max(500).optional(),
|
||||
startFrom: z.number().int().positive()
|
||||
startFrom: z.number().int().positive(),
|
||||
showOnFirstPage: z.boolean().default(true)
|
||||
});
|
||||
|
||||
const paperSchema = z
|
||||
.object({
|
||||
format: z.enum(supportedPaperFormats),
|
||||
orientation: z.enum(["portrait", "landscape"]),
|
||||
margins: marginSchema
|
||||
marginMode: marginModeSchema,
|
||||
margins: pageMarginsSchema
|
||||
})
|
||||
.superRefine((paper, context) => {
|
||||
const sourceDimensions = paperDimensionsMm[paper.format];
|
||||
@@ -178,6 +207,7 @@ export const exportConfigSchema = z.object({
|
||||
version: z.literal(EXPORT_CONFIG_VERSION),
|
||||
name: z.string().min(1).max(100),
|
||||
themeId: z.string().min(1),
|
||||
pageDecorationsMode: pageDecorationsModeSchema.default("theme"),
|
||||
mermaid: mermaidConfigSchema,
|
||||
paper: paperSchema,
|
||||
header: headerSchema,
|
||||
@@ -200,9 +230,30 @@ export type ExportConfig = z.infer<typeof exportConfigSchema>;
|
||||
export type MermaidExportConfig = ExportConfig["mermaid"];
|
||||
export type PaperFormat = ExportConfig["paper"]["format"];
|
||||
export type PaperOrientation = ExportConfig["paper"]["orientation"];
|
||||
export type PageMargins = ExportConfig["paper"]["margins"];
|
||||
export type MarginMode = ExportConfig["paper"]["marginMode"];
|
||||
export type PageDecorationsMode = ExportConfig["pageDecorationsMode"];
|
||||
export type HeaderConfig = ExportConfig["header"];
|
||||
export type FooterConfig = ExportConfig["footer"];
|
||||
|
||||
export const defaultPageMargins: PageMargins = {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
};
|
||||
|
||||
export function resolvePageMargins(
|
||||
paper: Pick<ExportConfig["paper"], "marginMode" | "margins">,
|
||||
themeMargins?: PageMargins
|
||||
): PageMargins {
|
||||
const source =
|
||||
paper.marginMode === "theme"
|
||||
? themeMargins ?? defaultPageMargins
|
||||
: paper.margins;
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
export function getPaperDimensionsMm(
|
||||
format: PaperFormat,
|
||||
orientation: PaperOrientation
|
||||
@@ -222,6 +273,7 @@ export const defaultExportConfig: ExportConfig = {
|
||||
version: EXPORT_CONFIG_VERSION,
|
||||
name: "默认 A4 文档",
|
||||
themeId: "typora-github",
|
||||
pageDecorationsMode: "theme",
|
||||
mermaid: {
|
||||
layout: "dagre",
|
||||
theme: "default",
|
||||
@@ -231,18 +283,15 @@ export const defaultExportConfig: ExportConfig = {
|
||||
paper: {
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
margins: {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
}
|
||||
marginMode: "theme",
|
||||
margins: { ...defaultPageMargins }
|
||||
},
|
||||
header: {
|
||||
enabled: false,
|
||||
height: "8mm",
|
||||
showDivider: false,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
||||
color: "#6b7280",
|
||||
left: disabledHeaderSlot,
|
||||
center: disabledHeaderSlot,
|
||||
@@ -253,10 +302,12 @@ export const defaultExportConfig: ExportConfig = {
|
||||
height: "8mm",
|
||||
showDivider: false,
|
||||
fontSize: "3mm",
|
||||
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
||||
color: "#6b7280",
|
||||
alignment: "center",
|
||||
format: "page-total",
|
||||
startFrom: 1
|
||||
startFrom: 1,
|
||||
showOnFirstPage: true
|
||||
},
|
||||
metadata: {
|
||||
title: "",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./document.js";
|
||||
export * from "./document-profile.js";
|
||||
export * from "./document-link.js";
|
||||
export * from "./export-config.js";
|
||||
export * from "./theme.js";
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { documentProfileNameSchema } from "./document-profile.js";
|
||||
import {
|
||||
footerSchema,
|
||||
headerSchema,
|
||||
pageMarginsSchema
|
||||
} from "./export-config.js";
|
||||
|
||||
export const THEME_MANIFEST_VERSION = 1;
|
||||
|
||||
@@ -12,6 +18,13 @@ export const themeFeatureSchema = z.enum([
|
||||
"echarts"
|
||||
]);
|
||||
|
||||
export const themeCategorySchema = z.enum([
|
||||
"general",
|
||||
"red-letter",
|
||||
"formal",
|
||||
"tender"
|
||||
]);
|
||||
|
||||
export const themeManifestSchema = z.object({
|
||||
manifestVersion: z.literal(THEME_MANIFEST_VERSION),
|
||||
id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
@@ -26,8 +39,20 @@ export const themeManifestSchema = z.object({
|
||||
domPreset: z.enum(["typora", "github", "generic"]),
|
||||
defaultFontSize: z.string(),
|
||||
supportedFeatures: z.array(themeFeatureSchema),
|
||||
category: themeCategorySchema.default("general"),
|
||||
compatibleProfiles: z
|
||||
.array(documentProfileNameSchema)
|
||||
.default([]),
|
||||
pageDefaults: z
|
||||
.object({
|
||||
margins: pageMarginsSchema,
|
||||
header: headerSchema.optional(),
|
||||
footer: footerSchema.optional()
|
||||
})
|
||||
.optional(),
|
||||
bundled: z.boolean()
|
||||
});
|
||||
|
||||
export type ThemeManifest = z.infer<typeof themeManifestSchema>;
|
||||
export type ThemeFeature = z.infer<typeof themeFeatureSchema>;
|
||||
export type ThemeCategory = z.infer<typeof themeCategorySchema>;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyDocumentLink,
|
||||
decodeLocalDocumentLinkFromPdf,
|
||||
encodeLocalDocumentLinkForPdf,
|
||||
isSafeDocumentLink
|
||||
} from "../src/document-link.js";
|
||||
|
||||
describe("文档链接分类", () => {
|
||||
it("识别文档锚点和网络链接", () => {
|
||||
expect(classifyDocumentLink("#章节一").kind).toBe("anchor");
|
||||
expect(
|
||||
classifyDocumentLink("https://example.com/a").kind
|
||||
).toBe("network");
|
||||
expect(
|
||||
classifyDocumentLink("//example.com/a").normalizedHref
|
||||
).toBe("https://example.com/a");
|
||||
});
|
||||
|
||||
it("识别浏览器协议处理器和未知协议", () => {
|
||||
expect(classifyDocumentLink("mailto:a@example.com")).toMatchObject({
|
||||
kind: "protocol",
|
||||
scheme: "mailto"
|
||||
});
|
||||
expect(classifyDocumentLink("tel:+8612345")).toMatchObject({
|
||||
kind: "protocol",
|
||||
scheme: "tel"
|
||||
});
|
||||
expect(classifyDocumentLink("obsidian://open?vault=x")).toMatchObject({
|
||||
kind: "protocol",
|
||||
scheme: "obsidian"
|
||||
});
|
||||
});
|
||||
|
||||
it("识别跨目录、绝对路径、UNC 和 file URI", () => {
|
||||
for (const href of [
|
||||
"../docs/a.md",
|
||||
"/opt/docs/a.md",
|
||||
"C:/docs/a.md",
|
||||
"D:\\docs\\a.md",
|
||||
"\\\\server\\share\\a.md",
|
||||
"file:///C:/docs/a.md"
|
||||
]) {
|
||||
expect(classifyDocumentLink(href).kind).toBe("local");
|
||||
}
|
||||
});
|
||||
|
||||
it("拒绝危险协议、控制字符和无效网络地址", () => {
|
||||
for (const href of [
|
||||
"javascript:alert(1)",
|
||||
"vbscript:msgbox(1)",
|
||||
"data:text/html;base64,WA=="
|
||||
]) {
|
||||
expect(classifyDocumentLink(href).kind).toBe("unsafe");
|
||||
expect(isSafeDocumentLink(href)).toBe(false);
|
||||
}
|
||||
expect(classifyDocumentLink("https://[invalid").kind).toBe(
|
||||
"invalid"
|
||||
);
|
||||
expect(classifyDocumentLink("a\u0000b").kind).toBe("invalid");
|
||||
});
|
||||
|
||||
it("为 PDF 注释编码并还原本地链接", () => {
|
||||
for (const href of [
|
||||
"../docs/说明.md#章节",
|
||||
"C:/docs/a.md",
|
||||
"file:///D:/docs/a.md"
|
||||
]) {
|
||||
const encoded = encodeLocalDocumentLinkForPdf(href);
|
||||
expect(encoded).toMatch(
|
||||
/^https:\/\/mdpdf\.local\.invalid\/document-link\?/u
|
||||
);
|
||||
expect(decodeLocalDocumentLinkFromPdf(encoded ?? "")).toBe(
|
||||
href
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("不编码网络链接且拒绝伪造的 PDF 本地链接", () => {
|
||||
expect(
|
||||
encodeLocalDocumentLinkForPdf("https://example.com")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
decodeLocalDocumentLinkFromPdf(
|
||||
"https://mdpdf.local.invalid/document-link?href=https%3A%2F%2Fexample.com"
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
decodeLocalDocumentLinkFromPdf(
|
||||
"https://example.com/document-link?href=..%2Fa.md"
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
millimetersToCssPixels,
|
||||
millimetersToPdfPoints,
|
||||
paperDimensionsMm,
|
||||
resolvePageMargins,
|
||||
supportedMermaidLayouts,
|
||||
supportedMermaidLooks,
|
||||
supportedMermaidThemes,
|
||||
@@ -32,9 +33,12 @@ describe("导出配置", () => {
|
||||
});
|
||||
|
||||
it("使用 A4 和 16mm 作为默认纸张配置", () => {
|
||||
expect(defaultExportConfig.themeId).toBe("typora-github");
|
||||
expect(defaultExportConfig.pageDecorationsMode).toBe("theme");
|
||||
expect(defaultExportConfig.paper).toEqual({
|
||||
format: "A4",
|
||||
orientation: "portrait",
|
||||
marginMode: "theme",
|
||||
margins: {
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
@@ -45,6 +49,103 @@ describe("导出配置", () => {
|
||||
expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true);
|
||||
});
|
||||
|
||||
it("提供可由主题推荐的页眉和页码配置", () => {
|
||||
expect(defaultExportConfig.header).toMatchObject({
|
||||
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||
});
|
||||
expect(defaultExportConfig.footer).toMatchObject({
|
||||
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
||||
alignment: "center",
|
||||
format: "page-total",
|
||||
showOnFirstPage: true
|
||||
});
|
||||
|
||||
const parsedLegacyV4 = exportConfigSchema.parse({
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: undefined,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
fontFamily: undefined
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
fontFamily: undefined,
|
||||
showOnFirstPage: undefined
|
||||
}
|
||||
});
|
||||
|
||||
expect(parsedLegacyV4.pageDecorationsMode).toBe("theme");
|
||||
expect(parsedLegacyV4.header.fontFamily).toContain("Microsoft YaHei");
|
||||
expect(parsedLegacyV4.footer.fontFamily).toContain("Microsoft YaHei");
|
||||
expect(parsedLegacyV4.footer.showOnFirstPage).toBe(true);
|
||||
});
|
||||
|
||||
it("支持公文页码格式和奇偶页外侧对齐", () => {
|
||||
expect(
|
||||
exportConfigSchema.safeParse({
|
||||
...defaultExportConfig,
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||
fontSize: "4.94mm",
|
||||
color: "#000000",
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
showOnFirstPage: false
|
||||
}
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝可能逃逸 CSS 字体声明的页眉页码字体", () => {
|
||||
expect(
|
||||
exportConfigSchema.safeParse({
|
||||
...defaultExportConfig,
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
fontFamily: 'serif; color: red'
|
||||
}
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("按主题、自定义和全局默认顺序解析页边距", () => {
|
||||
const themeMargins = {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
};
|
||||
expect(resolvePageMargins(defaultExportConfig.paper, themeMargins)).toEqual(
|
||||
themeMargins
|
||||
);
|
||||
expect(resolvePageMargins(defaultExportConfig.paper)).toEqual({
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
});
|
||||
expect(
|
||||
resolvePageMargins(
|
||||
{
|
||||
marginMode: "custom",
|
||||
margins: {
|
||||
top: "10mm",
|
||||
right: "11mm",
|
||||
bottom: "12mm",
|
||||
left: "13mm"
|
||||
}
|
||||
},
|
||||
themeMargins
|
||||
)
|
||||
).toEqual({
|
||||
top: "10mm",
|
||||
right: "11mm",
|
||||
bottom: "12mm",
|
||||
left: "13mm"
|
||||
});
|
||||
});
|
||||
|
||||
it("提供 Mermaid 官方布局、主题、外观和字体默认值", () => {
|
||||
expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]);
|
||||
expect(supportedMermaidLooks).toEqual([
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { themeManifestSchema } from "../src/theme.js";
|
||||
|
||||
describe("主题清单", () => {
|
||||
it("允许主题推荐页边距、页眉和页码配置", () => {
|
||||
const parsed = themeManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: "official-test",
|
||||
name: "公文测试主题",
|
||||
version: "0.5.1",
|
||||
description: "测试主题推荐页面配置。",
|
||||
author: "md-to-pdf contributors",
|
||||
license: "项目自有",
|
||||
entry: "theme.css",
|
||||
domPreset: "generic",
|
||||
defaultFontSize: "21.333px",
|
||||
supportedFeatures: ["table"],
|
||||
category: "red-letter",
|
||||
compatibleProfiles: ["official"],
|
||||
pageDefaults: {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
},
|
||||
header: {
|
||||
enabled: false,
|
||||
height: "8mm",
|
||||
showDivider: false,
|
||||
fontSize: "4.94mm",
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||
color: "#000000",
|
||||
left: { enabled: false, content: "" },
|
||||
center: { enabled: false, content: "" },
|
||||
right: { enabled: false, content: "" }
|
||||
},
|
||||
footer: {
|
||||
enabled: true,
|
||||
height: "7mm",
|
||||
showDivider: false,
|
||||
fontSize: "4.94mm",
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||
color: "#000000",
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
startFrom: 1,
|
||||
showOnFirstPage: false
|
||||
}
|
||||
},
|
||||
bundled: true
|
||||
});
|
||||
|
||||
expect(parsed.pageDefaults?.footer).toMatchObject({
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
showOnFirstPage: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,26 @@
|
||||
|
||||
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
||||
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
||||
本项目由 `@md-to-pdf/renderer` 接入围栏插件,再由
|
||||
`@md-to-pdf/preview-engine` 统一完成连续预览、分页适配和 SVG 冻结。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
markdown-it-plugin.ts ECharts YAML 围栏与错误占位
|
||||
parse-yaml.ts 受限 YAML 解析
|
||||
protocol.ts 围栏协议与基础校验
|
||||
security-policy.ts URL、函数、原型和规模安全策略
|
||||
semantic-validator.ts 系列数据与坐标系语义校验
|
||||
dom-contract.ts 宿主与浏览器运行时共用的 DOM 协议
|
||||
browser.ts 浏览器入口
|
||||
browser/ ECharts 按需加载、渲染和 SVG 冻结
|
||||
index.ts Markdown 侧公共导出
|
||||
styles/
|
||||
echarts.css 图表、标题和错误占位样式
|
||||
tests/ 解析、安全、语义、插件和真实 SVG 测试
|
||||
```
|
||||
|
||||
## 基本语法
|
||||
|
||||
@@ -263,6 +283,8 @@ Chromium `page.pdf()`。
|
||||
|
||||
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
|
||||
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
|
||||
ECharts 围栏使用专用 `figure.md-echarts` DOM,不会套用普通代码围栏的
|
||||
`pre.md-fences` 样式。
|
||||
|
||||
## 安全边界
|
||||
|
||||
@@ -289,6 +311,15 @@ Chromium `page.pdf()`。
|
||||
3. 注册该系列的语义验证器;
|
||||
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```powershell
|
||||
npm run dev -w @md-to-pdf/markdown-echarts
|
||||
npm run test -w @md-to-pdf/markdown-echarts
|
||||
npm run typecheck -w @md-to-pdf/markdown-echarts
|
||||
npm run build -w @md-to-pdf/markdown-echarts
|
||||
```
|
||||
|
||||
参考:
|
||||
|
||||
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# @md-to-pdf/preview-engine
|
||||
|
||||
Markdown PDF 导出器的共享预览与分页引擎。Web 连续/快速预览、服务端
|
||||
Playwright PDF 和 Electron PDF 均通过同一套运行时完成媒体渲染、尺寸
|
||||
适配与 Paged.js 分页。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts 公共导出入口
|
||||
├── paged-document-runtime.ts 连续与分页渲染主流程
|
||||
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
|
||||
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
|
||||
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
|
||||
├── paged-table-handler.ts 跨页表格表头处理
|
||||
├── media-page-backfill.ts 图片与图表按文档顺序回填
|
||||
├── document-image-fit.ts Markdown 图片单页适配
|
||||
├── mermaid-*.ts Mermaid 配置、渲染与尺寸适配
|
||||
├── echarts-page-fit.ts ECharts 单页适配
|
||||
├── diagram-page-fit.ts 图像类元素的共享几何计算
|
||||
├── pdf-document-links.ts PDF 本地链接暂存编码
|
||||
├── preview-styles.ts 预览/打印媒体样式转换
|
||||
└── paged-render-target.ts Preview 与 PDF 运行目标
|
||||
tests/ 引擎单元测试
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
宿主负责提供渲染根节点和三份第三方样式,公共引擎不依赖 Vite 的
|
||||
`?inline` 导入语法:
|
||||
|
||||
```ts
|
||||
import { PagedDocumentRuntime } from "@md-to-pdf/preview-engine";
|
||||
|
||||
const runtime = new PagedDocumentRuntime(root, {
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss
|
||||
});
|
||||
|
||||
const result = await runtime.render(payload, {
|
||||
target: "preview",
|
||||
mermaidOutput: "svg-image"
|
||||
});
|
||||
|
||||
const continuous = await runtime.renderContinuous(payload);
|
||||
```
|
||||
|
||||
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
|
||||
纸张尺寸、页边距、页眉页脚及 Markdown 功能标识都随载荷传入。
|
||||
|
||||
## 连续预览与增量分页
|
||||
|
||||
- `renderContinuous()` 不创建纸张和分页节点,只更新统一 `#write` DOM;
|
||||
- 连续模式仍等待并渲染 Mermaid、ECharts、图片和字体;
|
||||
- 快速分页为源块生成稳定身份,编辑后保留修改点之前的分页结果;
|
||||
- 重新计算从最早受影响位置开始,后续页面串行生成;
|
||||
- 缓存不参与 Playwright/Electron 的最终 PDF 权威输出。
|
||||
|
||||
围栏代码块遵循 Typora DOM 约定:外层 `pre.md-fences` 负责主题样式,
|
||||
内部语义 `<code>` 会清除重复的行内代码背景、边框、内边距和字号缩放,
|
||||
不使用 `!important`,自定义主题仍可用更具体规则覆盖。
|
||||
|
||||
## 媒体分页
|
||||
|
||||
图片、Mermaid 和 ECharts 必须与紧邻标题作为同一媒体块。引擎按 DOM
|
||||
文档顺序串行处理,每个媒体元素至多回填或缩放一次。图片先按内容限宽
|
||||
得到基准高度;上一页空白与媒体需求接近时,才尝试只缩放媒体主体回填,
|
||||
标题始终保持自然尺寸。
|
||||
|
||||
## PDF 链接
|
||||
|
||||
`preparePdfDocumentLinks()` 仅在 PDF 目标中将本地链接编码为
|
||||
`https://mdpdf.local.invalid/...`。PDF.js 注释层通过 core 解码后交给
|
||||
Web/Desktop 平台处理,HTTP/HTTPS 和文档内锚点保持原始 PDF 行为。
|
||||
|
||||
## 设计边界
|
||||
|
||||
- 本包不解析 Markdown;语义 HTML 由 `@md-to-pdf/renderer` 生成。
|
||||
- 本包不读取文件、不发 HTTP 请求,也不决定 Web 或 Electron 平台行为。
|
||||
- 第三方展示样式由宿主注入,便于浏览器、Playwright 和 Electron 复用。
|
||||
- 媒体块按文档顺序串行判断回填,每个媒体元素至多触发一次重排。
|
||||
- 连续与分页模式共享媒体渲染、链接和样式契约,不维护两套引擎。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
npm run test -w @md-to-pdf/preview-engine
|
||||
npm run typecheck -w @md-to-pdf/preview-engine
|
||||
npm run build -w @md-to-pdf/preview-engine
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@md-to-pdf/preview-engine",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"echarts": "6.1.0",
|
||||
"mermaid": "11.16.0",
|
||||
"pagedjs": "^0.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.11.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
const PREPARED_ATTRIBUTE = "data-code-pagination-prepared";
|
||||
const CHUNK_CLASS = "md-code-pagination-chunk";
|
||||
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
|
||||
const LINE_GROUP_CLASS = "md-code-line-group";
|
||||
const LINE_CLASS = "md-code-line";
|
||||
|
||||
function splitNodeIntoLines(node: Node): Node[][] {
|
||||
const documentRef = node.ownerDocument;
|
||||
if (!documentRef) {
|
||||
throw new Error("代码块分页节点缺少 ownerDocument");
|
||||
}
|
||||
if (node.nodeType === node.TEXT_NODE) {
|
||||
return (node.textContent ?? "").split("\n").map((text) => [
|
||||
documentRef.createTextNode(text)
|
||||
]);
|
||||
}
|
||||
|
||||
if (node.nodeType !== node.ELEMENT_NODE) {
|
||||
return [[node.cloneNode(true)]];
|
||||
}
|
||||
|
||||
const lineChildren = splitNodesIntoLines(
|
||||
Array.from(node.childNodes)
|
||||
);
|
||||
return lineChildren.map((children) => {
|
||||
const clone = node.cloneNode(false) as Element;
|
||||
clone.append(...children);
|
||||
return [clone];
|
||||
});
|
||||
}
|
||||
|
||||
function splitNodesIntoLines(nodes: Node[]): Node[][] {
|
||||
const lines: Node[][] = [[]];
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeLines = splitNodeIntoLines(node);
|
||||
lines.at(-1)?.push(...(nodeLines[0] ?? []));
|
||||
for (const line of nodeLines.slice(1)) {
|
||||
lines.push([...line]);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function lineHasContent(nodes: Node[]) {
|
||||
return nodes.some((node) => (node.textContent ?? "").length > 0);
|
||||
}
|
||||
|
||||
function createLineGroups(
|
||||
documentRef: Document,
|
||||
lines: Node[][]
|
||||
) {
|
||||
const groups: HTMLElement[] = [];
|
||||
let lineIndex = 0;
|
||||
|
||||
while (lineIndex < lines.length) {
|
||||
const remaining = lines.length - lineIndex;
|
||||
const groupSize =
|
||||
remaining === 1 ? 1 : remaining === 3 ? 3 : 2;
|
||||
const group = documentRef.createElement("span");
|
||||
group.className = LINE_GROUP_CLASS;
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < groupSize && lineIndex < lines.length;
|
||||
offset += 1, lineIndex += 1
|
||||
) {
|
||||
const line = documentRef.createElement("span");
|
||||
line.className = LINE_CLASS;
|
||||
line.append(...(lines[lineIndex] ?? []));
|
||||
group.append(line);
|
||||
}
|
||||
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function prepareCodeBlockPagination(root: ParentNode) {
|
||||
const codeBlocks = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
`pre.md-fences:not([${PREPARED_ATTRIBUTE}])`
|
||||
)
|
||||
);
|
||||
|
||||
for (const codeBlock of codeBlocks) {
|
||||
const code = codeBlock.querySelector<HTMLElement>(
|
||||
":scope > code"
|
||||
);
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceEndsWithNewline =
|
||||
code.textContent?.endsWith("\n") ?? false;
|
||||
const lines = splitNodesIntoLines(Array.from(code.childNodes));
|
||||
if (
|
||||
sourceEndsWithNewline &&
|
||||
lines.length > 1 &&
|
||||
!lineHasContent(lines.at(-1) ?? [])
|
||||
) {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
const groups = createLineGroups(code.ownerDocument, lines);
|
||||
const chunks = groups.map((group, index) => {
|
||||
const chunk = code.ownerDocument.createElement("div");
|
||||
for (const attribute of Array.from(codeBlock.attributes)) {
|
||||
chunk.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
chunk.classList.add(CHUNK_CLASS);
|
||||
chunk.setAttribute(PREPARED_ATTRIBUTE, "true");
|
||||
chunk.setAttribute(
|
||||
CHUNK_POSITION_ATTRIBUTE,
|
||||
groups.length === 1
|
||||
? "only"
|
||||
: index === 0
|
||||
? "first"
|
||||
: index === groups.length - 1
|
||||
? "last"
|
||||
: "middle"
|
||||
);
|
||||
|
||||
const chunkCode = code.cloneNode(false) as HTMLElement;
|
||||
chunkCode.append(group);
|
||||
chunk.append(chunkCode);
|
||||
return chunk;
|
||||
});
|
||||
|
||||
codeBlock.replaceWith(...chunks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export function findCommonPrefixLength(
|
||||
previous: readonly string[],
|
||||
next: readonly string[]
|
||||
) {
|
||||
const maximum = Math.min(previous.length, next.length);
|
||||
let index = 0;
|
||||
while (index < maximum && previous[index] === next[index]) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function createNodeSignature(node: Node) {
|
||||
if (node instanceof Element) {
|
||||
return `element:${node.outerHTML}`;
|
||||
}
|
||||
return `node:${node.nodeType}:${node.textContent ?? ""}`;
|
||||
}
|
||||
|
||||
export function mountContinuousRenderStage(
|
||||
root: HTMLElement,
|
||||
articleTemplate: HTMLElement,
|
||||
content: DocumentFragment
|
||||
) {
|
||||
const article = articleTemplate.cloneNode(false) as HTMLElement;
|
||||
article.dataset.continuousRenderStage = "true";
|
||||
article.append(content);
|
||||
root.append(article);
|
||||
|
||||
return {
|
||||
article,
|
||||
takeContent() {
|
||||
const rendered = article.ownerDocument.createDocumentFragment();
|
||||
rendered.append(...Array.from(article.childNodes));
|
||||
return rendered;
|
||||
},
|
||||
dispose() {
|
||||
article.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,58 @@ export interface SvgDiagramFitOptions {
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
function parsePixelValue(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function getPrecedingMediaTitleHeight(
|
||||
element: HTMLElement
|
||||
) {
|
||||
const title = element.previousElementSibling;
|
||||
if (
|
||||
!(title instanceof HTMLElement) ||
|
||||
!/^H[1-6]$/.test(title.tagName)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const style = getComputedStyle(title);
|
||||
title.style.setProperty("break-after", "avoid-page", "important");
|
||||
title.style.setProperty(
|
||||
"page-break-after",
|
||||
"avoid",
|
||||
"important"
|
||||
);
|
||||
return (
|
||||
title.getBoundingClientRect().height +
|
||||
parsePixelValue(style.marginTop) +
|
||||
parsePixelValue(style.marginBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function ensurePageBreakAfter(element: HTMLElement) {
|
||||
const existingMarker = element.nextElementSibling;
|
||||
if (
|
||||
existingMarker instanceof HTMLElement &&
|
||||
existingMarker.dataset.mediaPageBreak === "true"
|
||||
) {
|
||||
return existingMarker;
|
||||
}
|
||||
const marker = element.ownerDocument.createElement("div");
|
||||
marker.dataset.mediaPageBreak = "true";
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
marker.style.setProperty("height", "0", "important");
|
||||
marker.style.setProperty("break-before", "page", "important");
|
||||
marker.style.setProperty(
|
||||
"page-break-before",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
element.after(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): DiagramDimensions | undefined {
|
||||
@@ -144,9 +196,17 @@ export function fitSvgDiagramsToPage(
|
||||
continue;
|
||||
}
|
||||
|
||||
const availablePageContent =
|
||||
const resolvedPageContent =
|
||||
options.resolvePageContent?.(container, pageContent) ??
|
||||
pageContent;
|
||||
const availablePageContent = {
|
||||
width: resolvedPageContent.width,
|
||||
height: Math.max(
|
||||
0,
|
||||
resolvedPageContent.height -
|
||||
getPrecedingMediaTitleHeight(container)
|
||||
)
|
||||
};
|
||||
const fit = calculateDiagramPageFit(
|
||||
intrinsic,
|
||||
availablePageContent
|
||||
@@ -157,6 +217,13 @@ export function fitSvgDiagramsToPage(
|
||||
|
||||
container.dataset.pageHeightFitted = "true";
|
||||
container.style.setProperty("margin-block", "0", "important");
|
||||
container.style.setProperty("break-after", "page", "important");
|
||||
container.style.setProperty(
|
||||
"page-break-after",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
ensurePageBreakAfter(container);
|
||||
if (!svg.getAttribute("viewBox")) {
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
+38
-4
@@ -1,8 +1,10 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
calculateDiagramPageFit,
|
||||
getPageContentDimensions
|
||||
} from "./diagram-page-fit";
|
||||
ensurePageBreakAfter,
|
||||
getPageContentDimensions,
|
||||
getPrecedingMediaTitleHeight
|
||||
} from "./diagram-page-fit.js";
|
||||
|
||||
function getImageBlock(image: HTMLImageElement) {
|
||||
const parent = image.parentElement;
|
||||
@@ -23,11 +25,30 @@ function getImageBlock(image: HTMLImageElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function prepareDocumentImageBlocks(root: ParentNode) {
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
let prepared = 0;
|
||||
|
||||
for (const image of images) {
|
||||
if (getImageBlock(image)) {
|
||||
prepared += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function fitDocumentImagesToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig
|
||||
config: ExportConfig,
|
||||
options: { prepareBlocks?: boolean } = {}
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
if (options.prepareBlocks !== false) {
|
||||
prepareDocumentImageBlocks(root);
|
||||
}
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
@@ -42,11 +63,17 @@ export function fitDocumentImagesToPage(
|
||||
const imageHeight = image.getBoundingClientRect().height;
|
||||
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
||||
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
||||
const titleHeight = block
|
||||
? getPrecedingMediaTitleHeight(block)
|
||||
: 0;
|
||||
const fit = calculateDiagramPageFit(
|
||||
{ width, height },
|
||||
{
|
||||
width: pageContent.width,
|
||||
height: Math.max(0, pageContent.height - nonImageHeight)
|
||||
height: Math.max(
|
||||
0,
|
||||
pageContent.height - nonImageHeight - titleHeight
|
||||
)
|
||||
}
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
@@ -55,6 +82,13 @@ export function fitDocumentImagesToPage(
|
||||
if (block?.classList.contains("md-document-image-block")) {
|
||||
block.dataset.pageHeightFitted = "true";
|
||||
block.style.setProperty("margin-block", "0", "important");
|
||||
block.style.setProperty("break-after", "page", "important");
|
||||
block.style.setProperty(
|
||||
"page-break-after",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
ensurePageBreakAfter(block);
|
||||
}
|
||||
image.dataset.pageHeightFitted = "true";
|
||||
image.style.setProperty("display", "block");
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit.js";
|
||||
|
||||
export function fitOversizedEChartsToPage(
|
||||
container: ParentNode,
|
||||
@@ -0,0 +1,109 @@
|
||||
export interface PagedBreakTokenLike {
|
||||
node?: Node;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PagedPageLike {
|
||||
element: HTMLElement;
|
||||
startToken?: PagedBreakTokenLike;
|
||||
}
|
||||
|
||||
export interface IncrementalPaginationPlan {
|
||||
prefixLength: number;
|
||||
invalidationPageIndex: number;
|
||||
startToken: PagedBreakTokenLike | undefined;
|
||||
}
|
||||
|
||||
function getNodeReference(node: Node | undefined) {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
if (node instanceof HTMLElement && node.dataset.ref) {
|
||||
return node.dataset.ref;
|
||||
}
|
||||
return node.parentElement?.dataset.ref;
|
||||
}
|
||||
|
||||
function pageContainsReference(
|
||||
page: PagedPageLike,
|
||||
reference: string
|
||||
) {
|
||||
return Array.from(
|
||||
page.element.querySelectorAll<HTMLElement>("[data-ref]")
|
||||
).some((element) => element.dataset.ref === reference);
|
||||
}
|
||||
|
||||
export function findSourceNodePageIndex(
|
||||
pages: readonly PagedPageLike[],
|
||||
sourceNode: Node | undefined
|
||||
) {
|
||||
const reference = getNodeReference(sourceNode);
|
||||
if (!reference) {
|
||||
return undefined;
|
||||
}
|
||||
const pageIndex = pages.findIndex((page) =>
|
||||
pageContainsReference(page, reference)
|
||||
);
|
||||
return pageIndex >= 0 ? pageIndex : undefined;
|
||||
}
|
||||
|
||||
export function createIncrementalPaginationPlan(
|
||||
prefixLength: number,
|
||||
previousNodeCount: number,
|
||||
sourceNodes: readonly Node[],
|
||||
pages: readonly PagedPageLike[]
|
||||
): IncrementalPaginationPlan | undefined {
|
||||
if (
|
||||
prefixLength <= 0 ||
|
||||
pages.length === 0 ||
|
||||
prefixLength > sourceNodes.length ||
|
||||
prefixLength > previousNodeCount
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const boundaryNode =
|
||||
sourceNodes[prefixLength] ??
|
||||
sourceNodes[prefixLength - 1];
|
||||
const boundaryPageIndex =
|
||||
findSourceNodePageIndex(pages, boundaryNode);
|
||||
if (boundaryPageIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 媒体块允许回填到上一页,因此变化边界必须额外向前回退一页。
|
||||
const invalidationPageIndex = Math.max(
|
||||
0,
|
||||
boundaryPageIndex - 1
|
||||
);
|
||||
return {
|
||||
prefixLength,
|
||||
invalidationPageIndex,
|
||||
startToken: pages[invalidationPageIndex]?.startToken
|
||||
};
|
||||
}
|
||||
|
||||
export function assignPagedSourceReferences(
|
||||
root: Node,
|
||||
createReference: () => string
|
||||
) {
|
||||
const elements: Element[] = [];
|
||||
if (root instanceof Element) {
|
||||
elements.push(root);
|
||||
}
|
||||
const queryRoot = root as Node & {
|
||||
querySelectorAll?: ParentNode["querySelectorAll"];
|
||||
};
|
||||
if (typeof queryRoot.querySelectorAll === "function") {
|
||||
elements.push(...Array.from(queryRoot.querySelectorAll("*")));
|
||||
}
|
||||
|
||||
for (const element of elements) {
|
||||
if (!element.hasAttribute("data-ref")) {
|
||||
element.setAttribute("data-ref", createReference());
|
||||
}
|
||||
if (element.id) {
|
||||
element.setAttribute("data-id", element.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export * from "./diagram-page-fit.js";
|
||||
export * from "./continuous-preview.js";
|
||||
export * from "./document-image-fit.js";
|
||||
export * from "./echarts-page-fit.js";
|
||||
export * from "./incremental-pagination.js";
|
||||
export * from "./media-page-backfill.js";
|
||||
export * from "./mermaid-config.js";
|
||||
export * from "./mermaid-page-fit.js";
|
||||
export * from "./mermaid-renderer.js";
|
||||
export * from "./mermaid-static-image.js";
|
||||
export * from "./paged-break-token.js";
|
||||
export * from "./paged-document-runtime.js";
|
||||
export * from "./paged-preview.js";
|
||||
export * from "./paged-render-target.js";
|
||||
export * from "./pdf-document-links.js";
|
||||
export * from "./preview-styles.js";
|
||||
@@ -0,0 +1,397 @@
|
||||
export type MediaBackfillKind = "image" | "mermaid" | "echarts";
|
||||
|
||||
export interface MediaBackfillGeometry {
|
||||
remainingHeight: number;
|
||||
fixedHeight: number;
|
||||
baselineVisualHeight: number;
|
||||
}
|
||||
|
||||
export interface MediaBackfillCandidate {
|
||||
id: string;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface MediaBackfillOptions {
|
||||
minimumScale?: number;
|
||||
safetyGapPx?: number;
|
||||
}
|
||||
|
||||
const MEDIA_BLOCK_SELECTOR = [
|
||||
".md-document-image-block",
|
||||
".mermaid:not(.mermaid-error)",
|
||||
".md-echarts:not(.md-echarts-error)"
|
||||
].join(", ");
|
||||
const DEFAULT_MINIMUM_SCALE = 0.88;
|
||||
const DEFAULT_SAFETY_GAP_PX = 2;
|
||||
const SCALE_EPSILON = 0.001;
|
||||
|
||||
function finitePositive(value: string | undefined) {
|
||||
const parsed = Number.parseFloat(value ?? "");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function getMediaKind(block: Element): MediaBackfillKind | undefined {
|
||||
if (block.classList.contains("md-document-image-block")) {
|
||||
return "image";
|
||||
}
|
||||
if (block.classList.contains("mermaid")) {
|
||||
return "mermaid";
|
||||
}
|
||||
if (block.classList.contains("md-echarts")) {
|
||||
return "echarts";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMediaVisual(
|
||||
block: Element,
|
||||
kind: MediaBackfillKind
|
||||
): HTMLElement | SVGSVGElement | undefined {
|
||||
if (kind === "image") {
|
||||
return (
|
||||
block.querySelector<HTMLImageElement>("img.md-document-image") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
if (kind === "echarts") {
|
||||
return (
|
||||
block.querySelector<HTMLElement>(".md-echarts-host") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
return (
|
||||
block.querySelector<SVGSVGElement>("svg") ??
|
||||
block.querySelector<HTMLImageElement>("img") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function parsePixelValue(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getOuterBottom(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.bottom +
|
||||
parsePixelValue(getComputedStyle(element).marginBottom)
|
||||
);
|
||||
}
|
||||
|
||||
function getOuterTop(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top -
|
||||
parsePixelValue(getComputedStyle(element).marginTop)
|
||||
);
|
||||
}
|
||||
|
||||
function getMediaGroupTop(
|
||||
block: HTMLElement,
|
||||
pageContentTop: number
|
||||
) {
|
||||
const title = block.previousElementSibling;
|
||||
if (
|
||||
title instanceof HTMLElement &&
|
||||
/^H[1-6]$/.test(title.tagName)
|
||||
) {
|
||||
return Math.max(
|
||||
pageContentTop,
|
||||
getOuterTop(title)
|
||||
);
|
||||
}
|
||||
return Math.max(
|
||||
pageContentTop,
|
||||
getOuterTop(block)
|
||||
);
|
||||
}
|
||||
|
||||
function getLastContentBottom(pageContent: HTMLElement) {
|
||||
const contentRect = pageContent.getBoundingClientRect();
|
||||
const leafElements = Array.from(
|
||||
pageContent.querySelectorAll<HTMLElement>("[data-ref]")
|
||||
).filter(
|
||||
(element) => !element.querySelector("[data-ref]")
|
||||
);
|
||||
let bottom = contentRect.top;
|
||||
|
||||
for (const element of leafElements) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
rect.bottom <= contentRect.top ||
|
||||
rect.top >= contentRect.bottom
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
bottom = Math.max(
|
||||
bottom,
|
||||
Math.min(getOuterBottom(element), contentRect.bottom)
|
||||
);
|
||||
}
|
||||
return bottom;
|
||||
}
|
||||
|
||||
function hasForcedBreakBefore(block: HTMLElement) {
|
||||
const title = block.previousElementSibling;
|
||||
const elements = [
|
||||
block,
|
||||
title instanceof HTMLElement && /^H[1-6]$/.test(title.tagName)
|
||||
? title
|
||||
: undefined
|
||||
].filter((element): element is HTMLElement => Boolean(element));
|
||||
return elements.some((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
style.breakBefore === "page" ||
|
||||
style.pageBreakBefore === "always"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMediaBackfillScale(
|
||||
geometry: MediaBackfillGeometry,
|
||||
options: MediaBackfillOptions = {}
|
||||
) {
|
||||
const minimumScale =
|
||||
options.minimumScale ?? DEFAULT_MINIMUM_SCALE;
|
||||
const safetyGapPx =
|
||||
options.safetyGapPx ?? DEFAULT_SAFETY_GAP_PX;
|
||||
if (
|
||||
!Number.isFinite(geometry.remainingHeight) ||
|
||||
!Number.isFinite(geometry.fixedHeight) ||
|
||||
!Number.isFinite(geometry.baselineVisualHeight) ||
|
||||
geometry.remainingHeight <= 0 ||
|
||||
geometry.fixedHeight < 0 ||
|
||||
geometry.baselineVisualHeight <= 0 ||
|
||||
!Number.isFinite(minimumScale) ||
|
||||
minimumScale <= 0 ||
|
||||
minimumScale >= 1 ||
|
||||
!Number.isFinite(safetyGapPx) ||
|
||||
safetyGapPx < 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const availableVisualHeight =
|
||||
geometry.remainingHeight -
|
||||
geometry.fixedHeight -
|
||||
safetyGapPx;
|
||||
const scale =
|
||||
availableVisualHeight / geometry.baselineVisualHeight;
|
||||
if (
|
||||
scale < minimumScale ||
|
||||
scale >= 1 - SCALE_EPSILON
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
export function prepareMediaBackfillBlocks(root: ParentNode) {
|
||||
const blocks = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(MEDIA_BLOCK_SELECTOR)
|
||||
);
|
||||
let prepared = 0;
|
||||
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const kind = getMediaKind(block);
|
||||
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||
const rect = visual?.getBoundingClientRect();
|
||||
if (
|
||||
!kind ||
|
||||
!visual ||
|
||||
!rect ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
block.dataset.mediaBackfillId = `media-${index + 1}`;
|
||||
block.dataset.mediaBackfillKind = kind;
|
||||
block.dataset.mediaBaselineWidth = String(rect.width);
|
||||
block.dataset.mediaBaselineHeight = String(rect.height);
|
||||
prepared += 1;
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function getMediaBackfillIdsInDocumentOrder(
|
||||
root: ParentNode
|
||||
) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).flatMap((block) =>
|
||||
block.dataset.mediaBackfillId
|
||||
? [block.dataset.mediaBackfillId]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
export function findMediaBackfillCandidates(
|
||||
root: ParentNode,
|
||||
options: MediaBackfillOptions = {}
|
||||
) {
|
||||
const pages = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
const candidates: MediaBackfillCandidate[] = [];
|
||||
const candidateIds = new Set<string>();
|
||||
|
||||
for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) {
|
||||
const previousPage = pages[pageIndex - 1];
|
||||
const currentPage = pages[pageIndex];
|
||||
const previousContent =
|
||||
previousPage?.querySelector<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const currentContent =
|
||||
currentPage?.querySelector<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
if (!previousContent || !currentContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaBlocks = Array.from(
|
||||
currentContent.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).sort(
|
||||
(left, right) =>
|
||||
left.getBoundingClientRect().top -
|
||||
right.getBoundingClientRect().top
|
||||
);
|
||||
const block = mediaBlocks[0];
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = getMediaKind(block);
|
||||
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||
const id = block.dataset.mediaBackfillId;
|
||||
const baselineVisualHeight = finitePositive(
|
||||
block.dataset.mediaBaselineHeight
|
||||
);
|
||||
if (!kind || !visual || !id || !baselineVisualHeight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasForcedBreakBefore(block)) {
|
||||
continue;
|
||||
}
|
||||
const currentContentRect = currentContent.getBoundingClientRect();
|
||||
const visualHeight = visual.getBoundingClientRect().height;
|
||||
const mediaGroupTop = getMediaGroupTop(
|
||||
block,
|
||||
currentContentRect.top
|
||||
);
|
||||
const fixedHeight = Math.max(
|
||||
0,
|
||||
getOuterBottom(block) -
|
||||
mediaGroupTop -
|
||||
visualHeight
|
||||
);
|
||||
|
||||
const previousContentRect =
|
||||
previousContent.getBoundingClientRect();
|
||||
const previousFlowBottom =
|
||||
getLastContentBottom(previousContent);
|
||||
const remainingHeight = Math.max(
|
||||
0,
|
||||
previousContentRect.bottom - previousFlowBottom
|
||||
);
|
||||
const scale = calculateMediaBackfillScale(
|
||||
{
|
||||
remainingHeight,
|
||||
fixedHeight,
|
||||
baselineVisualHeight
|
||||
},
|
||||
options
|
||||
);
|
||||
if (scale === undefined || candidateIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
candidateIds.add(id);
|
||||
candidates.push({ id, scale });
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function setVisualSize(
|
||||
visual: HTMLElement | SVGSVGElement,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
visual.style.setProperty("display", "block");
|
||||
visual.style.setProperty("width", `${width}px`, "important");
|
||||
visual.style.setProperty("height", `${height}px`, "important");
|
||||
visual.style.setProperty("max-width", "100%", "important");
|
||||
visual.style.setProperty("max-height", `${height}px`, "important");
|
||||
visual.style.setProperty("margin-inline", "auto", "important");
|
||||
}
|
||||
|
||||
export function applyMediaBackfillCandidates(
|
||||
source: ParentNode,
|
||||
candidates: MediaBackfillCandidate[]
|
||||
) {
|
||||
let applied = 0;
|
||||
for (const candidate of candidates) {
|
||||
const block = Array.from(
|
||||
source.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).find(
|
||||
(element) =>
|
||||
element.dataset.mediaBackfillId === candidate.id
|
||||
);
|
||||
const kind = block ? getMediaKind(block) : undefined;
|
||||
const baselineWidth = finitePositive(
|
||||
block?.dataset.mediaBaselineWidth
|
||||
);
|
||||
const baselineHeight = finitePositive(
|
||||
block?.dataset.mediaBaselineHeight
|
||||
);
|
||||
const visual =
|
||||
block && kind ? getMediaVisual(block, kind) : undefined;
|
||||
if (
|
||||
!block ||
|
||||
!kind ||
|
||||
!visual ||
|
||||
!baselineWidth ||
|
||||
!baselineHeight ||
|
||||
!Number.isFinite(candidate.scale) ||
|
||||
candidate.scale <= 0 ||
|
||||
candidate.scale >= 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const width = baselineWidth * candidate.scale;
|
||||
const height = baselineHeight * candidate.scale;
|
||||
block.dataset.mediaBackfilled = "true";
|
||||
block.dataset.mediaBackfillScale = String(candidate.scale);
|
||||
setVisualSize(visual, width, height);
|
||||
|
||||
if (kind === "image") {
|
||||
visual.style.setProperty("object-fit", "contain");
|
||||
} else if (kind === "echarts") {
|
||||
const svg = block.querySelector<SVGSVGElement>(
|
||||
".md-echarts-host svg"
|
||||
);
|
||||
if (svg) {
|
||||
setVisualSize(svg, width, height);
|
||||
}
|
||||
visual.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseSvgViewBox,
|
||||
type DiagramDimensions,
|
||||
type DiagramPageFit
|
||||
} from "./diagram-page-fit";
|
||||
} from "./diagram-page-fit.js";
|
||||
|
||||
export type MermaidDimensions = DiagramDimensions;
|
||||
export type MermaidPageFit = DiagramPageFit;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ export interface PagedPreviewRenderRequest {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "render";
|
||||
requestId: number;
|
||||
layout: "paged" | "continuous";
|
||||
payload: PagedPreviewPayload;
|
||||
}
|
||||
|
||||
@@ -21,15 +22,22 @@ export type PagedPreviewFrameMessage =
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "ready";
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "link";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "rendering";
|
||||
requestId: number;
|
||||
layout: "paged" | "continuous";
|
||||
}
|
||||
| {
|
||||
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
|
||||
type: "rendered";
|
||||
requestId: number;
|
||||
layout: "paged" | "continuous";
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
echartsErrors: string[];
|
||||
@@ -67,6 +75,35 @@ svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#write pre.md-fences,
|
||||
#write .md-code-pagination-chunk {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#write pre.md-fences > code,
|
||||
#write .md-code-pagination-chunk > code {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
padding-left: 8px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
white-space: inherit;
|
||||
overflow-wrap: inherit;
|
||||
word-break: inherit;
|
||||
}
|
||||
|
||||
.md-document-image-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
@@ -133,6 +170,60 @@ body,
|
||||
}
|
||||
`;
|
||||
|
||||
export const documentInteractionCss = `
|
||||
:where(#write a[href]) {
|
||||
color: var(--md-link-color, var(--md-accent, #0969da));
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.16em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:where(#write a[href]:hover) {
|
||||
color: var(--md-link-hover-color, #0550ae);
|
||||
}
|
||||
|
||||
:where(#write a[href]:focus-visible) {
|
||||
border-radius: 2px;
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const continuousDocumentGeometryCss = `
|
||||
html,
|
||||
body,
|
||||
#preview-root {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
#preview-root {
|
||||
padding: 34px 0;
|
||||
}
|
||||
|
||||
#write {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 16mm !important;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 16px rgb(34 43 38 / 16%);
|
||||
}
|
||||
|
||||
#write[data-continuous-render-stage="true"] {
|
||||
position: fixed !important;
|
||||
inset: 0 auto auto 0 !important;
|
||||
z-index: -1 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
function cssString(value: string) {
|
||||
return JSON.stringify(value)
|
||||
.replace(/\u2028/g, "\\2028 ")
|
||||
@@ -174,6 +265,9 @@ export function formatPageNumber(
|
||||
if (config.format === "dash-page") {
|
||||
return `- ${page} -`;
|
||||
}
|
||||
if (config.format === "official-page") {
|
||||
return `— ${page} —`;
|
||||
}
|
||||
|
||||
return (config.template || "${page} / ${pages}")
|
||||
.replace(/\$\{page\}/g, String(page))
|
||||
@@ -186,6 +280,7 @@ function marginBox(
|
||||
content: string,
|
||||
options: {
|
||||
color: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
height: string;
|
||||
showDivider: boolean;
|
||||
@@ -204,7 +299,7 @@ function marginBox(
|
||||
content: ${content};
|
||||
height: ${options.height};
|
||||
color: ${options.color};
|
||||
font-family: inherit;
|
||||
font-family: ${options.fontFamily};
|
||||
font-size: ${options.fontSize};
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
@@ -224,6 +319,7 @@ function buildHeaderCss(
|
||||
|
||||
const options = {
|
||||
color: config.header.color,
|
||||
fontFamily: config.header.fontFamily,
|
||||
fontSize: config.header.fontSize,
|
||||
height: config.header.height,
|
||||
showDivider: config.header.showDivider
|
||||
@@ -252,6 +348,7 @@ function buildFooterCss(config: ExportConfig) {
|
||||
|
||||
const options = {
|
||||
color: config.footer.color,
|
||||
fontFamily: config.footer.fontFamily,
|
||||
fontSize: config.footer.fontSize,
|
||||
height: config.footer.height,
|
||||
showDivider: config.footer.showDivider
|
||||
@@ -271,6 +368,23 @@ function buildFooterCss(config: ExportConfig) {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function resolvePageNumberAlignment(
|
||||
config: ExportConfig["footer"],
|
||||
pageIndex: number
|
||||
): "left" | "center" | "right" {
|
||||
if (config.alignment !== "outer") {
|
||||
return config.alignment;
|
||||
}
|
||||
return pageIndex % 2 === 0 ? "right" : "left";
|
||||
}
|
||||
|
||||
export function shouldRenderPageNumber(
|
||||
config: ExportConfig["footer"],
|
||||
pageIndex: number
|
||||
) {
|
||||
return config.showOnFirstPage || pageIndex !== 0;
|
||||
}
|
||||
|
||||
export function buildPagedMediaCss(
|
||||
config: ExportConfig,
|
||||
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
|
||||
@@ -322,6 +436,47 @@ ${buildFooterCss(config)}
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
#write .md-code-pagination-chunk {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
#write .md-code-pagination-chunk > code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#write .md-code-pagination-chunk:not(
|
||||
[data-code-pagination-position="first"]
|
||||
):not([data-code-pagination-position="only"]) {
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
border-top: 0 !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
border-top-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
#write .md-code-pagination-chunk:not(
|
||||
[data-code-pagination-position="last"]
|
||||
):not([data-code-pagination-position="only"]) {
|
||||
margin-bottom: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
border-bottom: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
#write .md-code-line-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#write .md-code-line {
|
||||
display: block;
|
||||
min-height: 1lh;
|
||||
white-space: inherit;
|
||||
overflow-wrap: inherit;
|
||||
word-break: inherit;
|
||||
}
|
||||
|
||||
#write table[data-empty-split-table="true"] {
|
||||
display: none;
|
||||
}
|
||||
@@ -398,12 +553,14 @@ html[data-render-target="pdf"] .pagedjs_page {
|
||||
|
||||
export function createPagedPreviewRenderRequest(
|
||||
requestId: number,
|
||||
payload: PagedPreviewPayload
|
||||
payload: PagedPreviewPayload,
|
||||
layout: "paged" | "continuous" = "paged"
|
||||
): PagedPreviewRenderRequest {
|
||||
return {
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "render",
|
||||
requestId,
|
||||
layout,
|
||||
payload
|
||||
};
|
||||
}
|
||||
@@ -420,6 +577,8 @@ export function isPagedPreviewRenderRequest(
|
||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
||||
candidate.type === "render" &&
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
(candidate.layout === "paged" ||
|
||||
candidate.layout === "continuous") &&
|
||||
Boolean(candidate.payload) &&
|
||||
typeof candidate.payload?.articleHtml === "string" &&
|
||||
typeof candidate.payload?.themeCss === "string"
|
||||
@@ -436,24 +595,54 @@ export function isPagedPreviewFrameMessage(
|
||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||
if (
|
||||
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||
!["ready", "rendering", "rendered", "error"].includes(
|
||||
!["ready", "link", "rendering", "rendered", "error"].includes(
|
||||
candidate.type ?? ""
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type !== "rendered") {
|
||||
if (candidate.type === "ready") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate.type === "link") {
|
||||
return (
|
||||
typeof candidate.href === "string" &&
|
||||
candidate.href.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type === "rendering") {
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
(candidate.layout === "paged" ||
|
||||
candidate.layout === "continuous")
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type === "error") {
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
typeof candidate.message === "string"
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.type !== "rendered") {
|
||||
return false;
|
||||
}
|
||||
const rendered = candidate as Partial<
|
||||
Extract<PagedPreviewFrameMessage, { type: "rendered" }>
|
||||
>;
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
Number.isInteger(candidate.pageCount) &&
|
||||
typeof candidate.contentHeight === "number" &&
|
||||
Number.isFinite(candidate.contentHeight) &&
|
||||
candidate.contentHeight > 0 &&
|
||||
Array.isArray(candidate.echartsErrors) &&
|
||||
Array.isArray(candidate.mermaidErrors)
|
||||
Number.isInteger(rendered.requestId) &&
|
||||
(rendered.layout === "paged" ||
|
||||
rendered.layout === "continuous") &&
|
||||
Number.isInteger(rendered.pageCount) &&
|
||||
typeof rendered.contentHeight === "number" &&
|
||||
Number.isFinite(rendered.contentHeight) &&
|
||||
rendered.contentHeight > 0 &&
|
||||
Array.isArray(rendered.echartsErrors) &&
|
||||
Array.isArray(rendered.mermaidErrors)
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Handler, registerHandlers } from "pagedjs";
|
||||
import { resolveBreakTokenElement } from "./paged-break-token";
|
||||
import { resolveBreakTokenElement } from "./paged-break-token.js";
|
||||
|
||||
interface PagedBreakToken {
|
||||
node?: Node;
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
declare module "pagedjs" {
|
||||
export interface PagedBreakToken {
|
||||
node?: Node;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PagedPage {
|
||||
element: HTMLElement;
|
||||
startToken?: PagedBreakToken;
|
||||
endToken?: PagedBreakToken;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export interface PagedFlow {
|
||||
total: number;
|
||||
pages: PagedPage[];
|
||||
performance: number;
|
||||
size: {
|
||||
width: { value: number; unit: string };
|
||||
height: { value: number; unit: string };
|
||||
format?: string;
|
||||
orientation?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PagedStylesheet = string | Record<string, string>;
|
||||
|
||||
export class Previewer {
|
||||
chunker: {
|
||||
source: DocumentFragment;
|
||||
pages: PagedPage[];
|
||||
total: number;
|
||||
rendered: boolean;
|
||||
pagesArea: HTMLElement;
|
||||
hooks: {
|
||||
afterParsed: {
|
||||
trigger(
|
||||
parsed: DocumentFragment,
|
||||
chunker: unknown
|
||||
): Promise<void>;
|
||||
};
|
||||
afterRendered: {
|
||||
trigger(
|
||||
pages: PagedPage[],
|
||||
chunker: unknown
|
||||
): Promise<void>;
|
||||
};
|
||||
};
|
||||
loadFonts(): Promise<void>;
|
||||
removePages(fromIndex?: number): void;
|
||||
render(
|
||||
parsed: DocumentFragment,
|
||||
startAt?: PagedBreakToken
|
||||
): Promise<{ done: boolean; canceled?: boolean }>;
|
||||
destroy(): void;
|
||||
};
|
||||
polisher: {
|
||||
destroy(): void;
|
||||
};
|
||||
preview(
|
||||
content?: HTMLElement | DocumentFragment | string,
|
||||
stylesheets?: PagedStylesheet[],
|
||||
renderTo?: HTMLElement | string
|
||||
): Promise<PagedFlow>;
|
||||
}
|
||||
|
||||
export class Handler {
|
||||
constructor(
|
||||
chunker: unknown,
|
||||
polisher: unknown,
|
||||
caller: unknown
|
||||
);
|
||||
}
|
||||
|
||||
export function registerHandlers(
|
||||
...handlers: Array<typeof Handler>
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
|
||||
|
||||
export function preparePdfDocumentLinks(root: ParentNode) {
|
||||
let rewritten = 0;
|
||||
for (const link of root.querySelectorAll<HTMLAnchorElement>(
|
||||
"a[href]"
|
||||
)) {
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) {
|
||||
continue;
|
||||
}
|
||||
const encoded = encodeLocalDocumentLinkForPdf(href);
|
||||
if (!encoded) {
|
||||
continue;
|
||||
}
|
||||
link.setAttribute("href", encoded);
|
||||
rewritten += 1;
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareCodeBlockPagination } from "../src/code-block-pagination.js";
|
||||
|
||||
function createRoot(codeHtml: string) {
|
||||
const root = document.createElement("article");
|
||||
root.innerHTML = `<pre class="md-fences"><code>${codeHtml}</code></pre>`;
|
||||
return root;
|
||||
}
|
||||
|
||||
function getGroups(root: ParentNode) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(".md-code-line-group")
|
||||
);
|
||||
}
|
||||
|
||||
function getLines(root: ParentNode) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(".md-code-line")
|
||||
);
|
||||
}
|
||||
|
||||
function getChunks(root: ParentNode) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(".md-code-pagination-chunk")
|
||||
);
|
||||
}
|
||||
|
||||
describe("代码块分页预处理", () => {
|
||||
it("偶数行按两行分组", () => {
|
||||
const root = createRoot("line-1\nline-2\nline-3\nline-4\n");
|
||||
|
||||
prepareCodeBlockPagination(root);
|
||||
|
||||
expect(getGroups(root).map((group) => group.children.length)).toEqual([
|
||||
2,
|
||||
2
|
||||
]);
|
||||
expect(getLines(root).map((line) => line.textContent)).toEqual([
|
||||
"line-1",
|
||||
"line-2",
|
||||
"line-3",
|
||||
"line-4"
|
||||
]);
|
||||
expect(
|
||||
getChunks(root).map((chunk) =>
|
||||
chunk.getAttribute("data-code-pagination-position")
|
||||
)
|
||||
).toEqual(["first", "last"]);
|
||||
});
|
||||
|
||||
it("奇数行将最后三行合组并保留单行代码块", () => {
|
||||
const oddRoot = createRoot(
|
||||
"line-1\nline-2\nline-3\nline-4\nline-5\n"
|
||||
);
|
||||
const singleRoot = createRoot("only-line\n");
|
||||
|
||||
prepareCodeBlockPagination(oddRoot);
|
||||
prepareCodeBlockPagination(singleRoot);
|
||||
|
||||
expect(
|
||||
getGroups(oddRoot).map((group) => group.children.length)
|
||||
).toEqual([2, 3]);
|
||||
expect(
|
||||
getGroups(singleRoot).map((group) => group.children.length)
|
||||
).toEqual([1]);
|
||||
expect(
|
||||
getChunks(singleRoot)[0]?.getAttribute(
|
||||
"data-code-pagination-position"
|
||||
)
|
||||
).toBe("only");
|
||||
});
|
||||
|
||||
it("跨行克隆语法高亮结构并保留空行", () => {
|
||||
const root = createRoot(
|
||||
'<span class="hljs-string">first\nsecond</span>\n\n' +
|
||||
'<span class="hljs-keyword">return</span> value;\n'
|
||||
);
|
||||
|
||||
prepareCodeBlockPagination(root);
|
||||
|
||||
const lines = getLines(root);
|
||||
expect(lines.map((line) => line.textContent)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
"",
|
||||
"return value;"
|
||||
]);
|
||||
expect(
|
||||
lines[0]?.querySelector(".hljs-string")?.textContent
|
||||
).toBe("first");
|
||||
expect(
|
||||
lines[1]?.querySelector(".hljs-string")?.textContent
|
||||
).toBe("second");
|
||||
expect(
|
||||
lines[3]?.querySelector(".hljs-keyword")?.textContent
|
||||
).toBe("return");
|
||||
});
|
||||
|
||||
it("重复调用不会再次包装已经准备的代码块", () => {
|
||||
const root = createRoot("line-1\nline-2\n");
|
||||
|
||||
prepareCodeBlockPagination(root);
|
||||
const firstHtml = root.innerHTML;
|
||||
prepareCodeBlockPagination(root);
|
||||
|
||||
expect(root.innerHTML).toBe(firstHtml);
|
||||
expect(root.querySelector("pre.md-fences")).toBeNull();
|
||||
const paginationChunks = getChunks(root);
|
||||
expect(paginationChunks).toHaveLength(1);
|
||||
expect(
|
||||
paginationChunks[0]?.classList.contains("md-fences")
|
||||
).toBe(true);
|
||||
expect(
|
||||
paginationChunks[0]?.getAttribute(
|
||||
"data-code-pagination-prepared"
|
||||
)
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("复制代码元素属性并把每个双行组拆成同级分页片段", () => {
|
||||
const root = document.createElement("article");
|
||||
root.innerHTML =
|
||||
'<pre class="md-fences custom" data-source="demo">' +
|
||||
'<code class="hljs language-js" data-language="js">' +
|
||||
"line-1\nline-2\nline-3\nline-4\n" +
|
||||
"</code></pre>";
|
||||
|
||||
prepareCodeBlockPagination(root);
|
||||
|
||||
const chunks = getChunks(root);
|
||||
expect(chunks).toHaveLength(2);
|
||||
expect(Array.from(root.children)).toEqual(chunks);
|
||||
expect(chunks[0]?.classList.contains("custom")).toBe(true);
|
||||
expect(chunks[1]?.getAttribute("data-source")).toBe("demo");
|
||||
expect(
|
||||
chunks[1]?.querySelector("code")?.getAttribute("data-language")
|
||||
).toBe("js");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user