Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925b0d1485 | ||
|
|
ac54ce46be |
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
|||||||
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
|
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
|
||||||
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
|
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
|
||||||
- 支持主题清单与 CSS 主题扩展;
|
- 支持主题清单与 CSS 主题扩展;
|
||||||
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX、Mermaid 和 ECharts;
|
||||||
|
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
|
||||||
- 使用 Docker Compose 在内网部署;
|
- 使用 Docker Compose 在内网部署;
|
||||||
- 文档只在当前请求中临时处理,不建立文档历史数据库。
|
- 文档只在当前请求中临时处理,不建立文档历史数据库。
|
||||||
|
|
||||||
@@ -75,15 +76,18 @@ flowchart LR
|
|||||||
|
|
||||||
- 前端:React、TypeScript、Vite。
|
- 前端:React、TypeScript、Vite。
|
||||||
- 后端:Node.js、TypeScript、Fastify。
|
- 后端:Node.js、TypeScript、Fastify。
|
||||||
|
- 桌面端:Electron、electron-builder、NSIS。
|
||||||
|
- 应用服务:`packages/application`。
|
||||||
- 共享模型:`packages/core`。
|
- 共享模型:`packages/core`。
|
||||||
- Markdown 渲染:`packages/renderer`。
|
- Markdown 渲染:`packages/renderer`。
|
||||||
- PDF 引擎:计划使用 Playwright Chromium。
|
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
||||||
|
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
||||||
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。
|
||||||
- 元数据:gray-matter。
|
- 元数据:gray-matter。
|
||||||
- 安全过滤:sanitize-html。
|
- 安全过滤:sanitize-html。
|
||||||
- 代码高亮:highlight.js。
|
- 代码高亮:highlight.js。
|
||||||
- 数学公式:KaTeX。
|
- 数学公式:KaTeX。
|
||||||
- 图表:Mermaid。
|
- 图表:Mermaid、ECharts。
|
||||||
- 校验:Zod。
|
- 校验:Zod。
|
||||||
- 测试:Vitest。
|
- 测试:Vitest。
|
||||||
- 部署:Docker、Docker Compose。
|
- 部署:Docker、Docker Compose。
|
||||||
@@ -92,12 +96,14 @@ flowchart LR
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
apps/web/ 前端编辑、配置与预览界面
|
apps/web/ 前端编辑、配置与预览界面
|
||||||
apps/server/ HTTP API、主题读取及未来 PDF 服务
|
apps/server/ HTTP API、主题读取和 Playwright PDF 服务
|
||||||
|
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
|
||||||
|
packages/application/ 共享应用服务、主题注册和图片资源处理
|
||||||
packages/core/ 导出配置和主题清单等共享模型
|
packages/core/ 导出配置和主题清单等共享模型
|
||||||
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
||||||
themes/ 内置主题及未来可安装主题
|
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
||||||
docker/ 容器化配置
|
themes/ 内置主题
|
||||||
tests/ 跨包和端到端测试
|
deploy/ Docker、Compose、Nginx 和部署文档
|
||||||
docs/ 进度、架构和部署文档
|
docs/ 进度、架构和部署文档
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -186,6 +192,11 @@ chore: 初始化项目骨架
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 不修改或重写已有提交,除非用户明确要求。
|
- 不修改或重写已有提交,除非用户明确要求。
|
||||||
|
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
|
||||||
|
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
|
||||||
|
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
|
||||||
|
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
|
||||||
|
记录本版本更新,不得使用只有版本号的标签说明。
|
||||||
|
|
||||||
## 9. 当前交接入口
|
## 9. 当前交接入口
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,90 @@
|
|||||||
# Markdown PDF 导出器
|
# Markdown PDF 导出器
|
||||||
|
|
||||||
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。
|
一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF
|
||||||
|
导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和
|
||||||
|
分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。
|
||||||
|
|
||||||
## 当前状态
|
当前版本:`v0.4.5`。
|
||||||
|
|
||||||
已经实现:
|
## v0.4.5 主要能力
|
||||||
|
|
||||||
- React Markdown 编辑与本地文件读取;
|
- 新建、打开和保存 Markdown;新文档可直接导出 PDF;
|
||||||
- Fastify 预览接口;
|
- Windows 注册 `.md`、`.markdown` 文件关联,系统打开文件时自动加载;
|
||||||
- 共享导出配置模型;
|
- 桌面窗口位置、尺寸和最大化状态延迟持久化,并在多显示器变化后自动
|
||||||
- 动态主题清单、CSS 和静态资源接口;
|
修正到可见工作区;
|
||||||
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
- “打开 Markdown”保留为独立主按钮,新建、保存和主题目录收纳在“更多”
|
||||||
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
菜单;
|
||||||
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
|
- 桌面端提供可直接放置自定义主题的本地目录;
|
||||||
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
|
- 图片、Mermaid 和 ECharts 采用串行媒体分页:每个媒体块至多重排一次,
|
||||||
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
|
严格保持文档顺序;
|
||||||
- 快速预览与精确预览共用的 50%~400% 显示缩放;
|
- 媒体先按内容限宽计算基准高度。当上一页空白接近媒体所需空间时,允许
|
||||||
- 内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题;
|
将媒体内容等比例缩小回填;图片标题或图表标题保持原字号且与媒体成块;
|
||||||
- Web 素材目录上传、桌面同目录相对图片和受限公网图片下载;
|
- 示例文档覆盖横图、竖图、Base64 图片、Mermaid、ECharts 以及不同尺寸
|
||||||
- 图片标题、整块分页及超高图片单页等比例适配;
|
的分页边界。
|
||||||
- 无本地 Server 的 Electron 桌面端与 Windows 安装包;
|
|
||||||
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
|
## Web 与桌面端
|
||||||
|
|
||||||
|
| 能力 | Web / Docker | Windows Desktop |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新建 Markdown | 支持 | 支持 |
|
||||||
|
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||||
|
| 保存 Markdown | 浏览器下载 | 原生另存为 |
|
||||||
|
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||||
|
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||||
|
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||||
|
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||||
|
|
||||||
|
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
||||||
|
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
||||||
|
|
||||||
|
## 渲染架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
||||||
|
B --> C["统一 #write HTML"]
|
||||||
|
D["主题 CSS 与导出配置"] --> C
|
||||||
|
C --> E["共享分页运行时"]
|
||||||
|
E --> F["Web 快速预览"]
|
||||||
|
E --> G["Playwright Chromium"]
|
||||||
|
E --> H["Electron Chromium"]
|
||||||
|
G --> I["Web PDF"]
|
||||||
|
H --> J["Desktop PDF"]
|
||||||
|
```
|
||||||
|
|
||||||
|
快速预览适合编辑反馈;精确预览直接展示服务端生成的 PDF,是 Web 最终
|
||||||
|
分页的权威结果。桌面 PDF 复用相同分页载荷和运行时。
|
||||||
|
|
||||||
|
## 项目目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
|
||||||
|
server/ Fastify API、Playwright PDF 与容器运行入口
|
||||||
|
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
|
||||||
|
packages/
|
||||||
|
core/ 跨端文档、导出配置和主题协议
|
||||||
|
renderer/ Markdown 到安全 #write HTML 的渲染管线
|
||||||
|
application/ 共享渲染、主题注册和图片资源应用服务
|
||||||
|
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
|
||||||
|
themes/ 内置主题与主题清单
|
||||||
|
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
||||||
|
docs/ 项目进度与主题开发说明
|
||||||
|
scripts/ 主题导入等维护脚本
|
||||||
|
logos/ 应用图标源文件
|
||||||
|
.local/themes/ 本地开发和 Compose 自定义主题目录
|
||||||
|
```
|
||||||
|
|
||||||
|
包依赖保持单向:`core` 提供基础协议;`markdown-echarts` 提供独立图表
|
||||||
|
能力;`renderer` 消费两者生成安全 HTML;`application` 组合渲染器、主题
|
||||||
|
和资源用例;三个 `apps/*` 只负责各自平台的界面、传输与 PDF 适配。
|
||||||
|
|
||||||
|
各包的目录和用法:
|
||||||
|
|
||||||
|
- [application](packages/application/README.md)
|
||||||
|
- [core](packages/core/README.md)
|
||||||
|
- [renderer](packages/renderer/README.md)
|
||||||
|
- [markdown-echarts](packages/markdown-echarts/README.md)
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -33,118 +97,67 @@ npm run dev
|
|||||||
|
|
||||||
默认地址:
|
默认地址:
|
||||||
|
|
||||||
- 前端:http://localhost:5173
|
- Web:http://localhost:5173
|
||||||
- 后端:http://localhost:3001
|
- API:http://localhost:3001
|
||||||
- 健康检查:http://localhost:3001/api/health
|
- 健康检查:http://localhost:3001/api/health
|
||||||
|
|
||||||
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。若 Markdown 使用
|
桌面端开发:
|
||||||
相对图片路径,再选择对应的素材目录即可;文件与图片只进入当前渲染请求,
|
|
||||||
不写入数据库。桌面端使用原生“打开 Markdown”对话框,并自动从 Markdown
|
|
||||||
所在目录安全解析相对图片。
|
|
||||||
|
|
||||||
独立一行的 `` 会显示图片标题。图片与标题作为一个整体
|
```powershell
|
||||||
参与分页;超出单页内容区时,图片会在预留标题高度后等比例缩放,不跨页
|
npm run desktop:dev
|
||||||
切割。远程图片会先由应用服务受限下载并内嵌,失败时显示可诊断占位。
|
|
||||||
|
|
||||||
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
|
|
||||||
|
|
||||||
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
|
|
||||||
|
|
||||||
## Mermaid 配置与样式
|
|
||||||
|
|
||||||
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
|
|
||||||
|
|
||||||
- 布局:Dagre、ELK;
|
|
||||||
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
|
|
||||||
- 外观:Classic、Hand-drawn、Neo;
|
|
||||||
- 字体:任意本地 CSS 字体族。
|
|
||||||
|
|
||||||
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
layout: elk
|
|
||||||
theme: base
|
|
||||||
look: handDrawn
|
|
||||||
fontFamily: Microsoft YaHei
|
|
||||||
themeVariables:
|
|
||||||
primaryColor: "#dbeafe"
|
|
||||||
primaryBorderColor: "#2563eb"
|
|
||||||
flowchart:
|
|
||||||
wrappingWidth: 360
|
|
||||||
---
|
|
||||||
flowchart TB
|
|
||||||
classDef important fill:#fee2e2,stroke:#dc2626
|
|
||||||
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
|
|
||||||
style A fill:#dcfce7,stroke:#16a34a
|
|
||||||
class B important
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
|
桌面端目录包和 Windows 安装包:
|
||||||
|
|
||||||
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
|
```powershell
|
||||||
|
npm run desktop:package
|
||||||
### Mermaid 长标签
|
npm run make -w @md-to-pdf/desktop
|
||||||
|
|
||||||
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
|
|
||||||
|
|
||||||
需要自动换行时可以使用 Mermaid Markdown String:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`:
|
产物位于 `apps/desktop/out/v0.4.5/`。正式安装包命名为
|
||||||
|
`md-to-pdf-0.4.5-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
||||||
|
未配置代码签名。
|
||||||
|
|
||||||
````markdown
|
## 文档与媒体
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["较长节点"] --> B["普通节点"]
|
|
||||||
classDef compact font-size:12px;
|
|
||||||
class A compact;
|
|
||||||
```
|
|
||||||
````
|
|
||||||
|
|
||||||
## 内置与本地 Typora 主题
|
Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、
|
||||||
|
KaTeX、Mermaid 和安全的 ECharts YAML 围栏。
|
||||||
|
|
||||||
公司内部版本已经内置 GitHub、Pixyll 和 Whitey 三套适合打印的白色
|
独立一行的 `` 会生成带标题的图片块。分页引擎将图片、
|
||||||
Typora 默认主题。如需从本机 Typora 安装目录刷新本地开发副本,可以运行:
|
Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行
|
||||||
|
计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时
|
||||||
|
只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免
|
||||||
|
跨页、重复重排和后续媒体顺序变化。
|
||||||
|
|
||||||
|
ECharts 围栏语法、支持系列和安全边界详见
|
||||||
|
[markdown-echarts 包文档](packages/markdown-echarts/README.md)。
|
||||||
|
|
||||||
|
## 主题
|
||||||
|
|
||||||
|
仓库内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题。主题由
|
||||||
|
版本化 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||||
|
|
||||||
|
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
||||||
|
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
|
||||||
|
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
|
||||||
|
|
||||||
|
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
|
||||||
|
刷新本地开发副本:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run theme:import-typora
|
npm run theme:import-typora
|
||||||
```
|
```
|
||||||
|
|
||||||
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。
|
## Docker Compose 部署
|
||||||
|
|
||||||
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run theme:import-typora -- --replace
|
|
||||||
```
|
|
||||||
|
|
||||||
旧副本会移动到 `.local\theme-backups`。
|
|
||||||
|
|
||||||
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
|
|
||||||
|
|
||||||
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
|
|
||||||
|
|
||||||
## Docker Desktop 部署
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f deploy\compose.yaml up --build -d
|
docker compose -f deploy\compose.yaml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
|
默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
|
||||||
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
|
配置详见 [AIO 容器部署说明](deploy/README.md)。
|
||||||
[AIO 容器部署说明](deploy/README.md)。
|
|
||||||
|
|
||||||
## 测试与构建
|
## 验证
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm test
|
npm test
|
||||||
@@ -153,8 +166,18 @@ npm run build
|
|||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
## 隐私原则
|
涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、
|
||||||
|
Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端
|
||||||
|
验证,并检查 PDF 页面渲染结果。
|
||||||
|
|
||||||
文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
## 安全与隐私
|
||||||
相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程
|
|
||||||
地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。
|
- 不建立 Markdown、图片或 PDF 历史数据库;
|
||||||
|
- 文档和资源只在当前请求或当前桌面会话中处理;
|
||||||
|
- Markdown 原始 HTML 不执行,输出经过白名单过滤;
|
||||||
|
- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML;
|
||||||
|
- 本地资源阻止路径穿越和符号链接越界;
|
||||||
|
- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时;
|
||||||
|
- PDF 页面阻止未授权外部网络访问。
|
||||||
|
|
||||||
|
当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。
|
||||||
|
|||||||
+19
-6
@@ -1,9 +1,19 @@
|
|||||||
# Desktop 桌面端
|
# Desktop 桌面端
|
||||||
|
|
||||||
`apps/desktop` 是 `v0.4.0` 新增的 Electron 桌面应用。当前已经完成
|
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
|
||||||
Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择、
|
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
|
||||||
同目录图片解析、隐藏分页窗口、Electron Chromium PDF 输出、原生 PDF
|
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
|
||||||
另存为和 Windows 安装包验证。
|
安装包。
|
||||||
|
|
||||||
|
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||||
|
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||||
|
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||||
|
双击文件或使用“打开方式”会复用已有应用窗口并加载文档。
|
||||||
|
|
||||||
|
“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开
|
||||||
|
自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”,
|
||||||
|
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
|
||||||
|
自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -27,8 +37,8 @@ npm run make -w @md-to-pdf/desktop
|
|||||||
```
|
```
|
||||||
|
|
||||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
`apps/desktop/out/v0.4.3/`。该目录被 Git 忽略。公司内部分发以
|
`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以
|
||||||
`md-to-pdf-0.4.3-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
提示。
|
提示。
|
||||||
|
|
||||||
@@ -47,6 +57,9 @@ NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安
|
|||||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||||
`mdpdf://bundle/` 自定义协议加载。
|
`mdpdf://bundle/` 自定义协议加载。
|
||||||
|
|
||||||
|
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||||
|
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||||
|
|||||||
@@ -1,4 +1,36 @@
|
|||||||
!macro customInstall
|
!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 导出器"
|
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
|
||||||
CreateShortCut \
|
CreateShortCut \
|
||||||
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
|
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
|
||||||
@@ -11,10 +43,22 @@
|
|||||||
"" \
|
"" \
|
||||||
"$INSTDIR\md-to-pdf.exe"
|
"$INSTDIR\md-to-pdf.exe"
|
||||||
StrCpy $launchLink "$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
|
!macroend
|
||||||
|
|
||||||
!macro customUnInstall
|
!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"
|
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
|
||||||
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
|
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
|
||||||
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
|
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
|
||||||
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
!macroend
|
!macroend
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module.exports = {
|
|||||||
productName: "Markdown PDF 导出器",
|
productName: "Markdown PDF 导出器",
|
||||||
copyright: "Copyright © YIXIONG Tech.ltd",
|
copyright: "Copyright © YIXIONG Tech.ltd",
|
||||||
asar: true,
|
asar: true,
|
||||||
|
electronLanguages: ["zh-CN", "en-US"],
|
||||||
directories: {
|
directories: {
|
||||||
output: `out/v${version}`
|
output: `out/v${version}`
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.4.3",
|
"version": "0.4.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"productName": "Markdown PDF 导出器",
|
"productName": "Markdown PDF 导出器",
|
||||||
"description": "Markdown PDF 导出器桌面端",
|
"description": "Markdown PDF 导出器桌面端",
|
||||||
|
|||||||
@@ -2,16 +2,40 @@ import { contextBridge, ipcRenderer } from "electron";
|
|||||||
import type { PagedDocumentPayload } from "@md-to-pdf/core";
|
import type { PagedDocumentPayload } from "@md-to-pdf/core";
|
||||||
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
DESKTOP_GET_THEME_CSS,
|
DESKTOP_GET_THEME_CSS,
|
||||||
DESKTOP_GENERATE_PDF,
|
DESKTOP_GENERATE_PDF,
|
||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
DESKTOP_OPEN_MARKDOWN,
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
|
DESKTOP_REFRESH_THEMES,
|
||||||
DESKTOP_RENDER_MARKDOWN,
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
DESKTOP_SAVE_PDF
|
DESKTOP_SAVE_PDF
|
||||||
} from "./channels.js";
|
} from "./channels.js";
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||||
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
|
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
|
||||||
|
consumePendingMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
|
||||||
|
discardPendingMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
|
||||||
|
onMarkdownOpened: (listener: () => void) => {
|
||||||
|
const handleOpened = () => listener();
|
||||||
|
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
|
},
|
||||||
|
startNewMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
||||||
|
saveMarkdown: (fileName: string, markdown: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
||||||
|
openThemeDirectory: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
|
||||||
|
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
|
||||||
renderMarkdown: (input: MarkdownRenderRequest) =>
|
renderMarkdown: (input: MarkdownRenderRequest) =>
|
||||||
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
|
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
|
||||||
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
|
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ export const DESKTOP_RENDER_MARKDOWN =
|
|||||||
"md-to-pdf:desktop:render-markdown";
|
"md-to-pdf:desktop:render-markdown";
|
||||||
export const DESKTOP_OPEN_MARKDOWN =
|
export const DESKTOP_OPEN_MARKDOWN =
|
||||||
"md-to-pdf:desktop:open-markdown";
|
"md-to-pdf:desktop:open-markdown";
|
||||||
|
export const DESKTOP_CONSUME_PENDING_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:consume-pending-markdown";
|
||||||
|
export const DESKTOP_DISCARD_PENDING_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:discard-pending-markdown";
|
||||||
|
export const DESKTOP_MARKDOWN_OPENED =
|
||||||
|
"md-to-pdf:desktop:markdown-opened";
|
||||||
|
export const DESKTOP_START_NEW_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:start-new-markdown";
|
||||||
|
export const DESKTOP_SAVE_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:save-markdown";
|
||||||
|
export const DESKTOP_OPEN_THEME_DIRECTORY =
|
||||||
|
"md-to-pdf:desktop:open-theme-directory";
|
||||||
|
export const DESKTOP_REFRESH_THEMES =
|
||||||
|
"md-to-pdf:desktop:refresh-themes";
|
||||||
export const DESKTOP_LIST_THEMES =
|
export const DESKTOP_LIST_THEMES =
|
||||||
"md-to-pdf:desktop:list-themes";
|
"md-to-pdf:desktop:list-themes";
|
||||||
export const DESKTOP_GET_THEME_CSS =
|
export const DESKTOP_GET_THEME_CSS =
|
||||||
|
|||||||
+322
-17
@@ -6,23 +6,37 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
net,
|
net,
|
||||||
protocol,
|
protocol,
|
||||||
|
screen,
|
||||||
session,
|
session,
|
||||||
|
shell,
|
||||||
type IpcMainInvokeEvent
|
type IpcMainInvokeEvent
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
import {
|
||||||
|
access,
|
||||||
|
mkdir,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
createApplicationService,
|
createApplicationService,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH,
|
||||||
type ApplicationService
|
type ApplicationService
|
||||||
} from "@md-to-pdf/application";
|
} from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
DESKTOP_GET_THEME_CSS,
|
DESKTOP_GET_THEME_CSS,
|
||||||
DESKTOP_GENERATE_PDF,
|
DESKTOP_GENERATE_PDF,
|
||||||
DESKTOP_LIST_THEMES,
|
DESKTOP_LIST_THEMES,
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
DESKTOP_OPEN_MARKDOWN,
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
|
DESKTOP_REFRESH_THEMES,
|
||||||
DESKTOP_RENDER_MARKDOWN,
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
DESKTOP_SAVE_PDF
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_PDF,
|
||||||
|
DESKTOP_START_NEW_MARKDOWN
|
||||||
} from "./channels.js";
|
} from "./channels.js";
|
||||||
import {
|
import {
|
||||||
parseMarkdownRenderRequest,
|
parseMarkdownRenderRequest,
|
||||||
@@ -34,11 +48,36 @@ import {
|
|||||||
ElectronPdfGenerator
|
ElectronPdfGenerator
|
||||||
} from "./electron-pdf-generator.js";
|
} from "./electron-pdf-generator.js";
|
||||||
import { parsePagedDocumentPayload } from "./pdf-contract.js";
|
import { parsePagedDocumentPayload } from "./pdf-contract.js";
|
||||||
|
import {
|
||||||
|
findMarkdownFileArgument,
|
||||||
|
isMarkdownFilePath,
|
||||||
|
readMarkdownDocument,
|
||||||
|
type OpenedMarkdownDocument
|
||||||
|
} from "./markdown-file.js";
|
||||||
|
import {
|
||||||
|
loadWindowState,
|
||||||
|
resolveWindowBounds,
|
||||||
|
saveWindowState,
|
||||||
|
type WindowState
|
||||||
|
} from "./window-state.js";
|
||||||
|
|
||||||
const APP_SCHEME = "mdpdf";
|
const APP_SCHEME = "mdpdf";
|
||||||
const APP_HOST = "bundle";
|
const APP_HOST = "bundle";
|
||||||
const THEME_HOST = "theme";
|
const THEME_HOST = "theme";
|
||||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
|
||||||
|
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
|
||||||
|
let applicationController:
|
||||||
|
| {
|
||||||
|
window: BrowserWindow;
|
||||||
|
openMarkdownFile(filePath: string): Promise<void>;
|
||||||
|
flushWindowState(): Promise<void>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||||
|
process.argv,
|
||||||
|
process.cwd()
|
||||||
|
);
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
@@ -91,15 +130,44 @@ function encodeThemeAssetPath(assetPath: string) {
|
|||||||
.join("/");
|
.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusApplicationWindow() {
|
||||||
|
const window = applicationController?.window;
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.isMinimized()) {
|
||||||
|
window.restore();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchExternalMarkdown(filePath: string) {
|
||||||
|
if (!applicationController) {
|
||||||
|
pendingExternalMarkdownPath = filePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void applicationController.openMarkdownFile(filePath).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
focusApplicationWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopThemeDirectory() {
|
||||||
|
return app.isPackaged
|
||||||
|
? path.join(app.getPath("userData"), "themes")
|
||||||
|
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||||
|
}
|
||||||
|
|
||||||
function createDesktopApplicationService() {
|
function createDesktopApplicationService() {
|
||||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||||
return createApplicationService({
|
return createApplicationService({
|
||||||
bundledRoot: app.isPackaged
|
bundledRoot: app.isPackaged
|
||||||
? path.join(process.resourcesPath, "themes")
|
? path.join(process.resourcesPath, "themes")
|
||||||
: path.join(projectRoot, "themes"),
|
: path.join(projectRoot, "themes"),
|
||||||
localRoot: app.isPackaged
|
localRoot: getDesktopThemeDirectory(),
|
||||||
? path.join(app.getPath("userData"), "themes")
|
|
||||||
: path.join(projectRoot, ".local", "themes"),
|
|
||||||
createAssetUrl: (themeId, assetPath) =>
|
createAssetUrl: (themeId, assetPath) =>
|
||||||
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
||||||
onWarning: (message) => console.warn(message)
|
onWarning: (message) => console.warn(message)
|
||||||
@@ -204,14 +272,26 @@ async function createApplication(
|
|||||||
"/preview-frame.html?target=pdf",
|
"/preview-frame.html?target=pdf",
|
||||||
applicationUrl
|
applicationUrl
|
||||||
).href;
|
).href;
|
||||||
|
const windowStatePath = path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"window-state.json"
|
||||||
|
);
|
||||||
|
const savedWindowState = await loadWindowState(windowStatePath);
|
||||||
|
const primaryWorkArea = screen.getPrimaryDisplay().workArea;
|
||||||
|
const initialBounds = resolveWindowBounds(
|
||||||
|
savedWindowState?.bounds,
|
||||||
|
screen.getAllDisplays().map((display) => display.workArea),
|
||||||
|
primaryWorkArea,
|
||||||
|
DEFAULT_WINDOW_SIZE,
|
||||||
|
MINIMUM_WINDOW_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
||||||
icon: getApplicationIcon(),
|
icon: getApplicationIcon(),
|
||||||
width: 1440,
|
...initialBounds,
|
||||||
height: 960,
|
minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width),
|
||||||
minWidth: 1024,
|
minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height),
|
||||||
minHeight: 720,
|
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -221,6 +301,55 @@ async function createApplication(
|
|||||||
preload: path.join(currentDirectory, "app-preload.cjs")
|
preload: path.join(currentDirectory, "app-preload.cjs")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let saveWindowStateTimer: NodeJS.Timeout | undefined;
|
||||||
|
let pendingWindowStateWrite = Promise.resolve();
|
||||||
|
let latestWindowState: WindowState = {
|
||||||
|
version: 1,
|
||||||
|
bounds: window.getNormalBounds(),
|
||||||
|
maximized: window.isMaximized()
|
||||||
|
};
|
||||||
|
const captureWindowState = () => {
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
latestWindowState = {
|
||||||
|
version: 1,
|
||||||
|
bounds: window.getNormalBounds(),
|
||||||
|
maximized: window.isMaximized()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const persistWindowState = () => {
|
||||||
|
const state = structuredClone(latestWindowState);
|
||||||
|
pendingWindowStateWrite = pendingWindowStateWrite
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => saveWindowState(windowStatePath, state))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn("无法保存窗口状态", error);
|
||||||
|
});
|
||||||
|
return pendingWindowStateWrite;
|
||||||
|
};
|
||||||
|
const scheduleWindowStateSave = () => {
|
||||||
|
captureWindowState();
|
||||||
|
if (saveWindowStateTimer) {
|
||||||
|
clearTimeout(saveWindowStateTimer);
|
||||||
|
}
|
||||||
|
saveWindowStateTimer = setTimeout(() => {
|
||||||
|
saveWindowStateTimer = undefined;
|
||||||
|
void persistWindowState();
|
||||||
|
}, 2_000);
|
||||||
|
};
|
||||||
|
const flushWindowState = async () => {
|
||||||
|
if (saveWindowStateTimer) {
|
||||||
|
clearTimeout(saveWindowStateTimer);
|
||||||
|
saveWindowStateTimer = undefined;
|
||||||
|
}
|
||||||
|
captureWindowState();
|
||||||
|
await persistWindowState();
|
||||||
|
};
|
||||||
|
window.on("move", scheduleWindowStateSave);
|
||||||
|
window.on("resize", scheduleWindowStateSave);
|
||||||
|
window.on("maximize", scheduleWindowStateSave);
|
||||||
|
window.on("unmaximize", scheduleWindowStateSave);
|
||||||
lockDownWindow(window, new URL(applicationUrl).origin);
|
lockDownWindow(window, new URL(applicationUrl).origin);
|
||||||
|
|
||||||
const pdfGenerator = new ElectronPdfGenerator({
|
const pdfGenerator = new ElectronPdfGenerator({
|
||||||
@@ -233,6 +362,24 @@ async function createApplication(
|
|||||||
new URL(event.senderFrame.url).origin ===
|
new URL(event.senderFrame.url).origin ===
|
||||||
new URL(applicationUrl).origin;
|
new URL(applicationUrl).origin;
|
||||||
let documentRoot: string | undefined;
|
let documentRoot: string | undefined;
|
||||||
|
let currentMarkdownPath: string | undefined;
|
||||||
|
let pendingOpenedMarkdown:
|
||||||
|
| {
|
||||||
|
document: OpenedMarkdownDocument;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const queueOpenedMarkdown = async (filePath: string) => {
|
||||||
|
const opened = await readMarkdownDocument(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
|
pendingOpenedMarkdown = {
|
||||||
|
document: opened,
|
||||||
|
filePath
|
||||||
|
};
|
||||||
|
window.webContents.send(DESKTOP_MARKDOWN_OPENED);
|
||||||
|
};
|
||||||
|
|
||||||
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
|
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
|
||||||
if (!validateSender(event)) {
|
if (!validateSender(event)) {
|
||||||
@@ -242,20 +389,116 @@ async function createApplication(
|
|||||||
title: "打开 Markdown",
|
title: "打开 Markdown",
|
||||||
properties: ["openFile"],
|
properties: ["openFile"],
|
||||||
filters: [
|
filters: [
|
||||||
{ name: "Markdown 文件", extensions: ["md", "markdown"] },
|
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
|
||||||
{ name: "文本文件", extensions: ["txt"] }
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const filePath = selection.filePaths[0];
|
const filePath = selection.filePaths[0];
|
||||||
if (selection.canceled || !filePath) {
|
if (selection.canceled || !filePath) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const content = await readFile(filePath, "utf8");
|
const opened = await readMarkdownDocument(
|
||||||
|
filePath,
|
||||||
|
MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
);
|
||||||
documentRoot = path.dirname(filePath);
|
documentRoot = path.dirname(filePath);
|
||||||
return {
|
currentMarkdownPath = filePath;
|
||||||
markdown: content,
|
return opened;
|
||||||
fileName: path.basename(filePath)
|
});
|
||||||
};
|
ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
const pending = pendingOpenedMarkdown;
|
||||||
|
pendingOpenedMarkdown = undefined;
|
||||||
|
if (!pending) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
currentMarkdownPath = pending.filePath;
|
||||||
|
documentRoot = path.dirname(pending.filePath);
|
||||||
|
return pending.document;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
pendingOpenedMarkdown = undefined;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
currentMarkdownPath = undefined;
|
||||||
|
documentRoot = undefined;
|
||||||
|
pendingOpenedMarkdown = undefined;
|
||||||
|
});
|
||||||
|
ipcMain.handle(
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
unsafeFileName: unknown,
|
||||||
|
unsafeMarkdown: unknown
|
||||||
|
) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面文件请求");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof unsafeFileName !== "string" ||
|
||||||
|
unsafeFileName.length === 0 ||
|
||||||
|
unsafeFileName.length > 500 ||
|
||||||
|
typeof unsafeMarkdown !== "string" ||
|
||||||
|
unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH
|
||||||
|
) {
|
||||||
|
throw new Error("Markdown 保存参数无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPath = currentMarkdownPath;
|
||||||
|
if (!targetPath) {
|
||||||
|
const suggestedName = /\.(?:md|markdown)$/iu.test(
|
||||||
|
path.basename(unsafeFileName)
|
||||||
|
)
|
||||||
|
? path.basename(unsafeFileName)
|
||||||
|
: `${path.basename(unsafeFileName)}.md`;
|
||||||
|
const selection = await dialog.showSaveDialog(window, {
|
||||||
|
title: "保存 Markdown",
|
||||||
|
defaultPath: path.join(
|
||||||
|
app.getPath("documents"),
|
||||||
|
suggestedName
|
||||||
|
),
|
||||||
|
filters: [
|
||||||
|
{ name: "Markdown 文件", extensions: ["md", "markdown"] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if (selection.canceled || !selection.filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath)
|
||||||
|
? selection.filePath
|
||||||
|
: `${selection.filePath}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(targetPath, unsafeMarkdown, "utf8");
|
||||||
|
currentMarkdownPath = targetPath;
|
||||||
|
documentRoot = path.dirname(targetPath);
|
||||||
|
return { fileName: path.basename(targetPath) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面主题请求");
|
||||||
|
}
|
||||||
|
const themeDirectory = getDesktopThemeDirectory();
|
||||||
|
await mkdir(themeDirectory, { recursive: true });
|
||||||
|
const errorMessage = await shell.openPath(themeDirectory);
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(`无法打开自定义主题目录:${errorMessage}`);
|
||||||
|
}
|
||||||
|
return themeDirectory;
|
||||||
|
});
|
||||||
|
ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => {
|
||||||
|
if (!validateSender(event)) {
|
||||||
|
throw new Error("拒绝未授权的桌面主题请求");
|
||||||
|
}
|
||||||
|
applicationService.invalidateThemes();
|
||||||
});
|
});
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
DESKTOP_RENDER_MARKDOWN,
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
@@ -343,9 +586,23 @@ async function createApplication(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
window.once("ready-to-show", () => window.show());
|
window.once("ready-to-show", () => {
|
||||||
|
if (savedWindowState?.maximized) {
|
||||||
|
window.maximize();
|
||||||
|
}
|
||||||
|
window.show();
|
||||||
|
});
|
||||||
|
window.on("close", () => {
|
||||||
|
void flushWindowState();
|
||||||
|
});
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
|
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN);
|
||||||
|
ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY);
|
||||||
|
ipcMain.removeHandler(DESKTOP_REFRESH_THEMES);
|
||||||
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
|
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
|
||||||
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
|
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
|
||||||
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
|
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
|
||||||
@@ -354,8 +611,46 @@ async function createApplication(
|
|||||||
void pdfGenerator.close();
|
void pdfGenerator.close();
|
||||||
});
|
});
|
||||||
await window.loadURL(applicationUrl);
|
await window.loadURL(applicationUrl);
|
||||||
|
return {
|
||||||
|
window,
|
||||||
|
openMarkdownFile: queueOpenedMarkdown,
|
||||||
|
flushWindowState
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
let windowStateReadyToQuit = false;
|
||||||
|
|
||||||
|
if (!hasSingleInstanceLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", (_event, arguments_, workingDirectory) => {
|
||||||
|
const filePath = findMarkdownFileArgument(
|
||||||
|
arguments_,
|
||||||
|
workingDirectory
|
||||||
|
);
|
||||||
|
if (filePath) {
|
||||||
|
dispatchExternalMarkdown(filePath);
|
||||||
|
} else {
|
||||||
|
focusApplicationWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("open-file", (event, filePath) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isMarkdownFilePath(filePath)) {
|
||||||
|
dispatchExternalMarkdown(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("before-quit", (event) => {
|
||||||
|
if (windowStateReadyToQuit || !applicationController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void applicationController.flushWindowState().finally(() => {
|
||||||
|
windowStateReadyToQuit = true;
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
});
|
||||||
app
|
app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -363,14 +658,24 @@ app
|
|||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||||
}
|
}
|
||||||
|
await mkdir(getDesktopThemeDirectory(), { recursive: true });
|
||||||
const applicationService = createDesktopApplicationService();
|
const applicationService = createDesktopApplicationService();
|
||||||
await registerApplicationProtocol(applicationService);
|
await registerApplicationProtocol(applicationService);
|
||||||
|
applicationController =
|
||||||
await createApplication(applicationService);
|
await createApplication(applicationService);
|
||||||
|
const startupMarkdownPath = pendingExternalMarkdownPath;
|
||||||
|
pendingExternalMarkdownPath = undefined;
|
||||||
|
if (startupMarkdownPath) {
|
||||||
|
await applicationController.openMarkdownFile(
|
||||||
|
startupMarkdownPath
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.error("桌面应用启动失败", error);
|
console.error("桌面应用启动失败", error);
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||||
|
|
||||||
|
export interface OpenedMarkdownDocument {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarkdownFilePath(filePath: string) {
|
||||||
|
return markdownExtensions.has(path.extname(filePath).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMarkdownFileArgument(
|
||||||
|
arguments_: string[],
|
||||||
|
workingDirectory: string
|
||||||
|
) {
|
||||||
|
for (const argument of arguments_) {
|
||||||
|
if (!argument || argument.startsWith("-")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = path.isAbsolute(argument)
|
||||||
|
? path.normalize(argument)
|
||||||
|
: path.resolve(workingDirectory, argument);
|
||||||
|
if (isMarkdownFilePath(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMarkdownDocument(
|
||||||
|
filePath: string,
|
||||||
|
maximumLength: number
|
||||||
|
): Promise<OpenedMarkdownDocument> {
|
||||||
|
if (!isMarkdownFilePath(filePath)) {
|
||||||
|
throw new Error("只允许打开 .md 或 .markdown 文件");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
|
||||||
|
throw new Error("Markdown 长度限制无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStats = await stat(filePath);
|
||||||
|
if (!fileStats.isFile()) {
|
||||||
|
throw new Error("Markdown 路径不是文件");
|
||||||
|
}
|
||||||
|
if (fileStats.size > maximumLength * 4) {
|
||||||
|
throw new Error("Markdown 文件过大");
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await readFile(filePath, "utf8");
|
||||||
|
if (markdown.length > maximumLength) {
|
||||||
|
throw new Error("Markdown 文件过大");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
fileName: path.basename(filePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface WindowBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowState {
|
||||||
|
version: 1;
|
||||||
|
bounds: WindowBounds;
|
||||||
|
maximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWindowBounds(value: unknown): WindowBounds | undefined {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const bounds = value as Partial<WindowBounds>;
|
||||||
|
if (
|
||||||
|
!isFiniteNumber(bounds.x) ||
|
||||||
|
!isFiniteNumber(bounds.y) ||
|
||||||
|
!isFiniteNumber(bounds.width) ||
|
||||||
|
!isFiniteNumber(bounds.height) ||
|
||||||
|
bounds.width <= 0 ||
|
||||||
|
bounds.height <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: Math.round(bounds.x),
|
||||||
|
y: Math.round(bounds.y),
|
||||||
|
width: Math.round(bounds.width),
|
||||||
|
height: Math.round(bounds.height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWindowState(value: unknown): WindowState | undefined {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const state = value as Partial<WindowState>;
|
||||||
|
const bounds = parseWindowBounds(state.bounds);
|
||||||
|
if (
|
||||||
|
state.version !== WINDOW_STATE_VERSION ||
|
||||||
|
!bounds ||
|
||||||
|
typeof state.maximized !== "boolean"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: WINDOW_STATE_VERSION,
|
||||||
|
bounds,
|
||||||
|
maximized: state.maximized
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectionArea(left: WindowBounds, right: WindowBounds) {
|
||||||
|
const width = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(left.x + left.width, right.x + right.width) -
|
||||||
|
Math.max(left.x, right.x)
|
||||||
|
);
|
||||||
|
const height = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(left.y + left.height, right.y + right.height) -
|
||||||
|
Math.max(left.y, right.y)
|
||||||
|
);
|
||||||
|
return width * height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(1, Math.round(size.width)),
|
||||||
|
workArea.width
|
||||||
|
);
|
||||||
|
const height = Math.min(
|
||||||
|
Math.max(1, Math.round(size.height)),
|
||||||
|
workArea.height
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x: Math.round(workArea.x + (workArea.width - width) / 2),
|
||||||
|
y: Math.round(workArea.y + (workArea.height - height) / 2),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWindowBounds(
|
||||||
|
savedBounds: WindowBounds | undefined,
|
||||||
|
workAreas: WindowBounds[],
|
||||||
|
primaryWorkArea: WindowBounds,
|
||||||
|
defaultSize: WindowSize,
|
||||||
|
minimumSize: WindowSize
|
||||||
|
): WindowBounds {
|
||||||
|
if (!savedBounds) {
|
||||||
|
return centerBounds(defaultSize, primaryWorkArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedArea = primaryWorkArea;
|
||||||
|
let selectedIntersection = 0;
|
||||||
|
for (const workArea of workAreas) {
|
||||||
|
const area = intersectionArea(savedBounds, workArea);
|
||||||
|
if (area > selectedIntersection) {
|
||||||
|
selectedArea = workArea;
|
||||||
|
selectedIntersection = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIntersection === 0) {
|
||||||
|
return centerBounds(
|
||||||
|
{
|
||||||
|
width: Math.min(
|
||||||
|
Math.max(savedBounds.width, minimumSize.width),
|
||||||
|
primaryWorkArea.width
|
||||||
|
),
|
||||||
|
height: Math.min(
|
||||||
|
Math.max(savedBounds.height, minimumSize.height),
|
||||||
|
primaryWorkArea.height
|
||||||
|
)
|
||||||
|
},
|
||||||
|
primaryWorkArea
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(savedBounds.width, minimumSize.width),
|
||||||
|
selectedArea.width
|
||||||
|
);
|
||||||
|
const height = Math.min(
|
||||||
|
Math.max(savedBounds.height, minimumSize.height),
|
||||||
|
selectedArea.height
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x: clamp(
|
||||||
|
savedBounds.x,
|
||||||
|
selectedArea.x,
|
||||||
|
selectedArea.x + selectedArea.width - width
|
||||||
|
),
|
||||||
|
y: clamp(
|
||||||
|
savedBounds.y,
|
||||||
|
selectedArea.y,
|
||||||
|
selectedArea.y + selectedArea.height - height
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWindowState(filePath: string) {
|
||||||
|
try {
|
||||||
|
return parseWindowState(
|
||||||
|
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWindowState(
|
||||||
|
filePath: string,
|
||||||
|
state: WindowState
|
||||||
|
) {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
temporaryPath,
|
||||||
|
`${JSON.stringify(state, null, 2)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await rename(temporaryPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
findMarkdownFileArgument,
|
||||||
|
isMarkdownFilePath,
|
||||||
|
readMarkdownDocument
|
||||||
|
} from "../src/markdown-file.js";
|
||||||
|
|
||||||
|
describe("桌面 Markdown 文件参数", () => {
|
||||||
|
it("识别两种 Markdown 扩展名且忽略大小写", () => {
|
||||||
|
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
|
||||||
|
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
|
||||||
|
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("从启动参数中提取绝对 Markdown 路径", () => {
|
||||||
|
expect(
|
||||||
|
findMarkdownFileArgument(
|
||||||
|
[
|
||||||
|
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
|
||||||
|
"--original-process-start-time=1",
|
||||||
|
"D:\\文档\\项目周报.md"
|
||||||
|
],
|
||||||
|
"C:\\Windows"
|
||||||
|
)
|
||||||
|
).toBe(path.normalize("D:\\文档\\项目周报.md"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根据第二实例的工作目录解析相对路径", () => {
|
||||||
|
expect(
|
||||||
|
findMarkdownFileArgument(
|
||||||
|
["md-to-pdf.exe", "docs\\readme.markdown"],
|
||||||
|
"D:\\项目"
|
||||||
|
)
|
||||||
|
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略非 Markdown 文件及 Chromium 参数", () => {
|
||||||
|
expect(
|
||||||
|
findMarkdownFileArgument(
|
||||||
|
["md-to-pdf.exe", "--inspect", "notes.txt"],
|
||||||
|
"C:\\项目"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("统一读取 Markdown 并限制解码后的字符数", async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-markdown-")
|
||||||
|
);
|
||||||
|
const filePath = path.join(directory, "示例.md");
|
||||||
|
try {
|
||||||
|
await writeFile(filePath, "# 示例", "utf8");
|
||||||
|
await expect(
|
||||||
|
readMarkdownDocument(filePath, 20)
|
||||||
|
).resolves.toEqual({
|
||||||
|
markdown: "# 示例",
|
||||||
|
fileName: "示例.md"
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
readMarkdownDocument(filePath, 2)
|
||||||
|
).rejects.toThrow("Markdown 文件过大");
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝非 Markdown 扩展名", async () => {
|
||||||
|
await expect(
|
||||||
|
readMarkdownDocument("C:\\docs\\说明.txt", 100)
|
||||||
|
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+293
-380
@@ -49,12 +49,15 @@ import {
|
|||||||
} from "./preview-zoom";
|
} from "./preview-zoom";
|
||||||
import { useMarkdownRender } from "./use-markdown-render";
|
import { useMarkdownRender } from "./use-markdown-render";
|
||||||
import {
|
import {
|
||||||
type MarkdownImageResource
|
createMarkdownFileName,
|
||||||
} from "./application-backend";
|
downloadMarkdown,
|
||||||
|
ensureMarkdownFileName
|
||||||
|
} from "./document-file";
|
||||||
import {
|
import {
|
||||||
type ThemeSummary,
|
type ThemeSummary,
|
||||||
useThemeResources
|
useThemeResources
|
||||||
} from "./use-theme-resources";
|
} from "./use-theme-resources";
|
||||||
|
import { sampleMarkdown } from "./sample-markdown";
|
||||||
|
|
||||||
const PrecisePdfPreview = lazy(async () => {
|
const PrecisePdfPreview = lazy(async () => {
|
||||||
const module = await import("./PrecisePdfPreview");
|
const module = await import("./PrecisePdfPreview");
|
||||||
@@ -62,336 +65,20 @@ const PrecisePdfPreview = lazy(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type PreviewMode = "quick" | "precise";
|
type PreviewMode = "quick" | "precise";
|
||||||
const imageFilePattern = /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
|
type DocumentKind = "sample" | "new" | "file";
|
||||||
|
const noImageResources: never[] = [];
|
||||||
function bytesToBase64(bytes: Uint8Array) {
|
|
||||||
const parts: string[] = [];
|
|
||||||
const chunkSize = 32_768;
|
|
||||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
||||||
parts.push(
|
|
||||||
String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return window.btoa(parts.join(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readImageResources(files: FileList) {
|
|
||||||
const images = Array.from(files).filter((file) =>
|
|
||||||
imageFilePattern.test(file.name)
|
|
||||||
);
|
|
||||||
return Promise.all(
|
|
||||||
images.map(async (file): Promise<MarkdownImageResource> => ({
|
|
||||||
path: file.webkitRelativePath || file.name,
|
|
||||||
data: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleMarkdown = `---
|
|
||||||
title: Markdown PDF 示例
|
|
||||||
author: 内网文档团队
|
|
||||||
keywords:
|
|
||||||
- Markdown
|
|
||||||
- PDF
|
|
||||||
---
|
|
||||||
|
|
||||||
# Markdown PDF 示例
|
|
||||||
|
|
||||||
这是一份使用 **Typora 风格主题** 渲染的预览文档。
|
|
||||||
|
|
||||||
## 常用内容
|
|
||||||
|
|
||||||
- [x] Markdown 实时预览
|
|
||||||
- [x] 表格与任务列表
|
|
||||||
- [x] 数学公式、Mermaid 与 ECharts
|
|
||||||
- [ ] Chromium PDF 导出
|
|
||||||
|
|
||||||
| 功能 | 当前状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| HTML 预览 | 已实现 |
|
|
||||||
| PDF 下载 | 下一阶段 |
|
|
||||||
|
|
||||||
行内公式:$E = mc^2$
|
|
||||||
|
|
||||||
## 网络图片
|
|
||||||
|
|
||||||
### 中等尺寸
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 超高尺寸
|
|
||||||
|
|
||||||
下图用于验证图片高度超过单页内容区时,会等比例缩放并作为完整块放置,
|
|
||||||
不会跨页切割。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
\`\`\`mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["Markdown"] --> B["统一 HTML"]
|
|
||||||
B --> C["主题 CSS"]
|
|
||||||
C --> D["网页预览"]
|
|
||||||
C --> E["PDF 导出"]
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## ECharts 图表
|
|
||||||
|
|
||||||
### 基础柱状图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 各模块文档数量
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
xAxis:
|
|
||||||
type: category
|
|
||||||
data: [研发, 产品, 设计, 运营]
|
|
||||||
yAxis:
|
|
||||||
type: value
|
|
||||||
series:
|
|
||||||
- type: bar
|
|
||||||
data: [28, 18, 15, 22]
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Tree 树图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 产品能力树
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: tree
|
|
||||||
orient: LR
|
|
||||||
label:
|
|
||||||
position: left
|
|
||||||
data:
|
|
||||||
- name: 文档平台
|
|
||||||
children:
|
|
||||||
- name: Markdown
|
|
||||||
children:
|
|
||||||
- name: Mermaid
|
|
||||||
- name: ECharts
|
|
||||||
- name: PDF
|
|
||||||
children:
|
|
||||||
- name: 快速预览
|
|
||||||
- name: 精确预览
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Treemap 矩形树图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 模块工作量占比
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: treemap
|
|
||||||
roam: false
|
|
||||||
nodeClick: false
|
|
||||||
breadcrumb:
|
|
||||||
show: false
|
|
||||||
label:
|
|
||||||
show: true
|
|
||||||
formatter: "{b}"
|
|
||||||
color: "#1f2937"
|
|
||||||
fontWeight: bold
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
|
||||||
borderRadius: 3
|
|
||||||
padding: [3, 6]
|
|
||||||
itemStyle:
|
|
||||||
borderColor: "#ffffff"
|
|
||||||
borderWidth: 2
|
|
||||||
gapWidth: 2
|
|
||||||
levels:
|
|
||||||
- itemStyle:
|
|
||||||
borderWidth: 0
|
|
||||||
gapWidth: 2
|
|
||||||
- upperLabel:
|
|
||||||
show: true
|
|
||||||
height: 28
|
|
||||||
color: "#1f2937"
|
|
||||||
fontWeight: bold
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
|
||||||
padding: [3, 6]
|
|
||||||
itemStyle:
|
|
||||||
borderColor: "#ffffff"
|
|
||||||
borderWidth: 2
|
|
||||||
gapWidth: 2
|
|
||||||
- label:
|
|
||||||
show: true
|
|
||||||
formatter: "{b}"
|
|
||||||
color: "#1f2937"
|
|
||||||
fontWeight: bold
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
|
||||||
borderRadius: 3
|
|
||||||
padding: [3, 6]
|
|
||||||
itemStyle:
|
|
||||||
borderColor: "#ffffff"
|
|
||||||
borderWidth: 2
|
|
||||||
gapWidth: 2
|
|
||||||
data:
|
|
||||||
- name: 渲染
|
|
||||||
children:
|
|
||||||
- { name: Markdown, value: 32 }
|
|
||||||
- { name: ECharts, value: 24 }
|
|
||||||
- { name: Mermaid, value: 18 }
|
|
||||||
- name: 导出
|
|
||||||
children:
|
|
||||||
- { name: PDF, value: 28 }
|
|
||||||
- { name: 主题, value: 12 }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Sunburst 旭日图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 文档内容层级
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: sunburst
|
|
||||||
radius: [15%, 90%]
|
|
||||||
data:
|
|
||||||
- name: 技术文档
|
|
||||||
children:
|
|
||||||
- { name: 架构, value: 18 }
|
|
||||||
- { name: 接口, value: 24 }
|
|
||||||
- name: 项目文档
|
|
||||||
children:
|
|
||||||
- { name: 周报, value: 16 }
|
|
||||||
- { name: 计划, value: 12 }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Graph 关系图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 渲染链路关系
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: graph
|
|
||||||
layout: circular
|
|
||||||
roam: false
|
|
||||||
label:
|
|
||||||
show: true
|
|
||||||
data:
|
|
||||||
- { name: Markdown, symbolSize: 42 }
|
|
||||||
- { name: HTML, symbolSize: 38 }
|
|
||||||
- { name: 预览, symbolSize: 34 }
|
|
||||||
- { name: PDF, symbolSize: 34 }
|
|
||||||
links:
|
|
||||||
- { source: Markdown, target: HTML }
|
|
||||||
- { source: HTML, target: 预览 }
|
|
||||||
- { source: HTML, target: PDF }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Sankey 桑基图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 文档流转量
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: sankey
|
|
||||||
data:
|
|
||||||
- { name: Markdown }
|
|
||||||
- { name: HTML }
|
|
||||||
- { name: 快速预览 }
|
|
||||||
- { name: 精确预览 }
|
|
||||||
- { name: PDF }
|
|
||||||
links:
|
|
||||||
- { source: Markdown, target: HTML, value: 100 }
|
|
||||||
- { source: HTML, target: 快速预览, value: 55 }
|
|
||||||
- { source: HTML, target: 精确预览, value: 25 }
|
|
||||||
- { source: HTML, target: PDF, value: 20 }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Chord 和弦图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 团队协作关系
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: chord
|
|
||||||
label:
|
|
||||||
show: true
|
|
||||||
data:
|
|
||||||
- { name: 研发 }
|
|
||||||
- { name: 产品 }
|
|
||||||
- { name: 设计 }
|
|
||||||
- { name: 运营 }
|
|
||||||
links:
|
|
||||||
- { source: 研发, target: 产品, value: 12 }
|
|
||||||
- { source: 产品, target: 设计, value: 8 }
|
|
||||||
- { source: 设计, target: 运营, value: 6 }
|
|
||||||
- { source: 运营, target: 研发, value: 4 }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Funnel 漏斗图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 文档发布转化
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: funnel
|
|
||||||
sort: descending
|
|
||||||
label:
|
|
||||||
formatter: "{b}: {c}"
|
|
||||||
data:
|
|
||||||
- { name: 草稿, value: 100 }
|
|
||||||
- { name: 评审, value: 76 }
|
|
||||||
- { name: 定稿, value: 58 }
|
|
||||||
- { name: 发布, value: 45 }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Gauge 仪表盘
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 发布完成率
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
series:
|
|
||||||
- type: gauge
|
|
||||||
min: 0
|
|
||||||
max: 100
|
|
||||||
progress:
|
|
||||||
show: true
|
|
||||||
detail:
|
|
||||||
formatter: "{value}%"
|
|
||||||
data:
|
|
||||||
- { name: 完成率, value: 78 }
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### PictorialBar 象形柱图
|
|
||||||
|
|
||||||
\`\`\`echarts
|
|
||||||
caption: 各渠道文档数量
|
|
||||||
height: 58mm
|
|
||||||
option:
|
|
||||||
xAxis:
|
|
||||||
type: category
|
|
||||||
data: [知识库, 周报, 手册, 方案]
|
|
||||||
yAxis:
|
|
||||||
type: value
|
|
||||||
series:
|
|
||||||
- type: pictorialBar
|
|
||||||
symbol: "path://M0,10 L5,0 L10,10 Z"
|
|
||||||
symbolRepeat: true
|
|
||||||
symbolSize: [14, 10]
|
|
||||||
data: [12, 20, 16, 24]
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||||||
const [fileName, setFileName] = useState("示例文档.md");
|
const [fileName, setFileName] = useState("示例文档.md");
|
||||||
const [imageResources, setImageResources] = useState<
|
const [documentKind, setDocumentKind] =
|
||||||
MarkdownImageResource[]
|
useState<DocumentKind>("sample");
|
||||||
>([]);
|
const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown);
|
||||||
const [exportConfig, setExportConfig] =
|
const [exportConfig, setExportConfig] =
|
||||||
useState<ExportConfig>(loadExportConfig);
|
useState<ExportConfig>(loadExportConfig);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||||
|
const [themeRefreshKey, setThemeRefreshKey] = useState(0);
|
||||||
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
|
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
|
||||||
const [status, setStatus] = useState("正在准备预览…");
|
const [status, setStatus] = useState("正在准备预览…");
|
||||||
const [appError, setAppError] = useState("");
|
const [appError, setAppError] = useState("");
|
||||||
@@ -412,6 +99,8 @@ export function App() {
|
|||||||
total: 0
|
total: 0
|
||||||
});
|
});
|
||||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const markdownFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const previewScrollRef = useRef<HTMLDivElement>(null);
|
const previewScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const previewFrameRef = useRef<HTMLIFrameElement>(null);
|
const previewFrameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const previewRequestIdRef = useRef(0);
|
const previewRequestIdRef = useRef(0);
|
||||||
@@ -421,9 +110,93 @@ export function App() {
|
|||||||
const scrollSyncOriginRef = useRef<
|
const scrollSyncOriginRef = useRef<
|
||||||
"editor" | "preview" | undefined
|
"editor" | "preview" | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const documentDirty = markdown !== savedMarkdown;
|
||||||
|
const documentDirtyRef = useRef(documentDirty);
|
||||||
|
const themeRefreshTargetRef = useRef<number | undefined>(undefined);
|
||||||
|
documentDirtyRef.current = documentDirty;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bridge = window.mdToPdfDesktop;
|
||||||
|
if (!bridge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
const consumeOpenedMarkdown = async () => {
|
||||||
|
if (
|
||||||
|
documentDirtyRef.current &&
|
||||||
|
!window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await bridge.discardPendingMarkdown();
|
||||||
|
} catch (reason) {
|
||||||
|
if (active) {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "无法丢弃待打开的 Markdown 文件"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const opened = await bridge.consumePendingMarkdown();
|
||||||
|
if (!active || !opened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAppError("");
|
||||||
|
setMarkdown(opened.markdown);
|
||||||
|
setFileName(opened.fileName);
|
||||||
|
setDocumentKind("file");
|
||||||
|
setSavedMarkdown(opened.markdown);
|
||||||
|
setStatus("Markdown 与同目录图片素材已载入");
|
||||||
|
} catch (reason) {
|
||||||
|
if (active) {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "无法打开 Markdown 文件"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeListener = bridge.onMarkdownOpened(() => {
|
||||||
|
void consumeOpenedMarkdown();
|
||||||
|
});
|
||||||
|
void consumeOpenedMarkdown();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
removeListener();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!moreMenuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof Node &&
|
||||||
|
!moreMenuRef.current?.contains(event.target)
|
||||||
|
) {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [moreMenuOpen]);
|
||||||
|
|
||||||
const handleThemesLoaded = useCallback(
|
const handleThemesLoaded = useCallback(
|
||||||
(availableThemes: ThemeSummary[]) => {
|
(availableThemes: ThemeSummary[], loadedRefreshKey: number) => {
|
||||||
setExportConfig((currentConfig) => {
|
setExportConfig((currentConfig) => {
|
||||||
if (
|
if (
|
||||||
availableThemes.some(
|
availableThemes.some(
|
||||||
@@ -440,6 +213,10 @@ export function App() {
|
|||||||
? { ...currentConfig, themeId: preferredTheme.id }
|
? { ...currentConfig, themeId: preferredTheme.id }
|
||||||
: currentConfig;
|
: currentConfig;
|
||||||
});
|
});
|
||||||
|
if (themeRefreshTargetRef.current === loadedRefreshKey) {
|
||||||
|
themeRefreshTargetRef.current = undefined;
|
||||||
|
setStatus("主题清单已刷新");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -450,7 +227,7 @@ export function App() {
|
|||||||
const { error: renderError, result } = useMarkdownRender(
|
const { error: renderError, result } = useMarkdownRender(
|
||||||
markdown,
|
markdown,
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
imageResources,
|
noImageResources,
|
||||||
{
|
{
|
||||||
onRenderStart: handleRenderStart,
|
onRenderStart: handleRenderStart,
|
||||||
onStatusChange: setStatus
|
onStatusChange: setStatus
|
||||||
@@ -460,7 +237,11 @@ export function App() {
|
|||||||
error: themeError,
|
error: themeError,
|
||||||
themeCss,
|
themeCss,
|
||||||
themes
|
themes
|
||||||
} = useThemeResources(exportConfig.themeId, handleThemesLoaded);
|
} = useThemeResources(
|
||||||
|
exportConfig.themeId,
|
||||||
|
handleThemesLoaded,
|
||||||
|
themeRefreshKey
|
||||||
|
);
|
||||||
const error =
|
const error =
|
||||||
renderError ||
|
renderError ||
|
||||||
themeError ||
|
themeError ||
|
||||||
@@ -471,6 +252,13 @@ export function App() {
|
|||||||
pdfError;
|
pdfError;
|
||||||
const themeId = exportConfig.themeId;
|
const themeId = exportConfig.themeId;
|
||||||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||||||
|
const effectiveFileName =
|
||||||
|
documentKind === "new" || !fileName
|
||||||
|
? createMarkdownFileName(markdown)
|
||||||
|
: fileName;
|
||||||
|
const documentDisplayName =
|
||||||
|
documentKind === "new" ? "新建文档" : fileName;
|
||||||
|
const showUnsavedState = documentKind === "new" || documentDirty;
|
||||||
const paperDimensions = getPaperDimensionsMm(
|
const paperDimensions = getPaperDimensionsMm(
|
||||||
exportConfig.paper.format,
|
exportConfig.paper.format,
|
||||||
exportConfig.paper.orientation
|
exportConfig.paper.orientation
|
||||||
@@ -501,22 +289,22 @@ export function App() {
|
|||||||
result && themeCss
|
result && themeCss
|
||||||
? createPagedDocumentPayload({
|
? createPagedDocumentPayload({
|
||||||
document: result,
|
document: result,
|
||||||
fileName,
|
fileName: effectiveFileName,
|
||||||
themeCss,
|
themeCss,
|
||||||
exportConfig
|
exportConfig
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
[exportConfig, fileName, result, themeCss]
|
[effectiveFileName, exportConfig, result, themeCss]
|
||||||
);
|
);
|
||||||
const pdfRequest = useMemo<PdfExportRequest>(
|
const pdfRequest = useMemo<PdfExportRequest>(
|
||||||
() => ({
|
() => ({
|
||||||
markdown,
|
markdown,
|
||||||
fileName,
|
fileName: effectiveFileName,
|
||||||
language: "zh-CN",
|
language: "zh-CN",
|
||||||
resources: imageResources,
|
resources: noImageResources,
|
||||||
exportConfig
|
exportConfig
|
||||||
}),
|
}),
|
||||||
[exportConfig, fileName, imageResources, markdown]
|
[effectiveFileName, exportConfig, markdown]
|
||||||
);
|
);
|
||||||
const pdfCacheKey = useMemo(
|
const pdfCacheKey = useMemo(
|
||||||
() => createPdfExportCacheKey(pdfRequest),
|
() => createPdfExportCacheKey(pdfRequest),
|
||||||
@@ -848,6 +636,24 @@ export function App() {
|
|||||||
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
|
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function confirmDiscardChanges() {
|
||||||
|
return (
|
||||||
|
!documentDirty ||
|
||||||
|
window.confirm("当前文档有未保存修改,是否放弃这些修改?")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenMarkdown() {
|
||||||
|
if (!confirmDiscardChanges()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.mdToPdfDesktop) {
|
||||||
|
void handleDesktopOpenMarkdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markdownFileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -856,10 +662,12 @@ export function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setAppError("");
|
setAppError("");
|
||||||
setMarkdown(await file.text());
|
const content = await file.text();
|
||||||
|
setMarkdown(content);
|
||||||
setFileName(file.name);
|
setFileName(file.name);
|
||||||
setImageResources([]);
|
setDocumentKind("file");
|
||||||
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
|
setSavedMarkdown(content);
|
||||||
|
setStatus("Markdown 已载入;Web 端不读取本地相对图片");
|
||||||
} catch {
|
} catch {
|
||||||
setAppError("无法读取所选 Markdown 文件");
|
setAppError("无法读取所选 Markdown 文件");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -867,25 +675,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAssetDirectoryChange(
|
|
||||||
event: ChangeEvent<HTMLInputElement>
|
|
||||||
) {
|
|
||||||
const files = event.target.files;
|
|
||||||
if (!files?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setAppError("");
|
|
||||||
const resources = await readImageResources(files);
|
|
||||||
setImageResources(resources);
|
|
||||||
setStatus(`已载入 ${resources.length} 个图片素材`);
|
|
||||||
} catch {
|
|
||||||
setAppError("无法读取所选图片素材目录");
|
|
||||||
} finally {
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDesktopOpenMarkdown() {
|
async function handleDesktopOpenMarkdown() {
|
||||||
try {
|
try {
|
||||||
setAppError("");
|
setAppError("");
|
||||||
@@ -895,7 +684,8 @@ export function App() {
|
|||||||
}
|
}
|
||||||
setMarkdown(opened.markdown);
|
setMarkdown(opened.markdown);
|
||||||
setFileName(opened.fileName);
|
setFileName(opened.fileName);
|
||||||
setImageResources([]);
|
setDocumentKind("file");
|
||||||
|
setSavedMarkdown(opened.markdown);
|
||||||
setStatus("Markdown 与同目录图片素材已载入");
|
setStatus("Markdown 与同目录图片素材已载入");
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setAppError(
|
setAppError(
|
||||||
@@ -904,6 +694,91 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleNewMarkdown() {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
if (!confirmDiscardChanges()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setAppError("");
|
||||||
|
await window.mdToPdfDesktop?.startNewMarkdown();
|
||||||
|
setMarkdown("");
|
||||||
|
setFileName("");
|
||||||
|
setDocumentKind("new");
|
||||||
|
setSavedMarkdown("");
|
||||||
|
setStatus("新建文档尚未保存,可直接编辑或导出 PDF");
|
||||||
|
window.requestAnimationFrame(() => editorRef.current?.focus());
|
||||||
|
} catch (reason) {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error ? reason.message : "无法新建 Markdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveMarkdown() {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
const suggestedName = ensureMarkdownFileName(fileName, markdown);
|
||||||
|
try {
|
||||||
|
setAppError("");
|
||||||
|
const bridge = window.mdToPdfDesktop;
|
||||||
|
let savedFileName = suggestedName;
|
||||||
|
if (bridge) {
|
||||||
|
const saved = await bridge.saveMarkdown(suggestedName, markdown);
|
||||||
|
if (!saved) {
|
||||||
|
setStatus("已取消 Markdown 保存");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savedFileName = saved.fileName;
|
||||||
|
} else {
|
||||||
|
downloadMarkdown(markdown, suggestedName);
|
||||||
|
}
|
||||||
|
setFileName(savedFileName);
|
||||||
|
setDocumentKind("file");
|
||||||
|
setSavedMarkdown(markdown);
|
||||||
|
setStatus(`Markdown 已保存:${savedFileName}`);
|
||||||
|
} catch (reason) {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error ? reason.message : "Markdown 保存失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenThemeDirectory() {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
try {
|
||||||
|
setAppError("");
|
||||||
|
const themeDirectory =
|
||||||
|
await window.mdToPdfDesktop?.openThemeDirectory();
|
||||||
|
if (themeDirectory) {
|
||||||
|
setStatus(`自定义主题目录:${themeDirectory}`);
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "无法打开自定义主题目录"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshThemes() {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
try {
|
||||||
|
setAppError("");
|
||||||
|
await window.mdToPdfDesktop?.refreshThemes();
|
||||||
|
setStatus("正在刷新主题清单…");
|
||||||
|
setThemeRefreshKey((key) => {
|
||||||
|
const nextKey = key + 1;
|
||||||
|
themeRefreshTargetRef.current = nextKey;
|
||||||
|
return nextKey;
|
||||||
|
});
|
||||||
|
} catch (reason) {
|
||||||
|
setAppError(
|
||||||
|
reason instanceof Error ? reason.message : "无法刷新主题清单"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function generatePdf(download: boolean) {
|
async function generatePdf(download: boolean) {
|
||||||
if (exportingPdf) {
|
if (exportingPdf) {
|
||||||
return;
|
return;
|
||||||
@@ -993,7 +868,14 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="document-summary">
|
<div className="document-summary">
|
||||||
<span className="panel-kicker">源文件</span>
|
<span className="panel-kicker">源文件</span>
|
||||||
<strong title={fileName}>{fileName}</strong>
|
<div className="document-name-row">
|
||||||
|
<strong title={documentDisplayName}>
|
||||||
|
{documentDisplayName}
|
||||||
|
</strong>
|
||||||
|
{showUnsavedState ? (
|
||||||
|
<span className="unsaved-badge">未保存</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`render-status${error ? " is-error" : ""}`}
|
className={`render-status${error ? " is-error" : ""}`}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
@@ -1004,36 +886,67 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
{window.mdToPdfDesktop ? (
|
<button type="button" onClick={handleOpenMarkdown}>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleDesktopOpenMarkdown()}
|
|
||||||
>
|
|
||||||
打开 Markdown
|
打开 Markdown
|
||||||
</button>
|
</button>
|
||||||
) : (
|
<div className="more-menu" ref={moreMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-controls="document-actions-menu"
|
||||||
|
aria-expanded={moreMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => setMoreMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
更多 ▾
|
||||||
|
</button>
|
||||||
|
{moreMenuOpen ? (
|
||||||
|
<div
|
||||||
|
id="document-actions-menu"
|
||||||
|
className="more-menu-popover"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void handleNewMarkdown()}
|
||||||
|
>
|
||||||
|
新建 Markdown
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void handleSaveMarkdown()}
|
||||||
|
>
|
||||||
|
保存 Markdown
|
||||||
|
</button>
|
||||||
|
{window.mdToPdfDesktop ? (
|
||||||
<>
|
<>
|
||||||
<label className="file-button">
|
<button
|
||||||
选择 Markdown
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void handleOpenThemeDirectory()}
|
||||||
|
>
|
||||||
|
打开自定义主题目录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void handleRefreshThemes()}
|
||||||
|
>
|
||||||
|
刷新主题
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
|
ref={markdownFileInputRef}
|
||||||
|
hidden
|
||||||
type="file"
|
type="file"
|
||||||
accept=".md,.markdown,text/markdown,text/plain"
|
accept=".md,.markdown,text/markdown"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label className="file-button">
|
|
||||||
添加素材目录
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
// React 尚未声明 Chromium 的目录选择属性。
|
|
||||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
|
||||||
onChange={handleAssetDirectoryChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||||
导出设置
|
导出设置
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,6 +30,58 @@ export interface SvgDiagramFitOptions {
|
|||||||
|
|
||||||
const PAGE_FIT_EPSILON_PX = 1;
|
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(
|
export function parseSvgViewBox(
|
||||||
value: string | null
|
value: string | null
|
||||||
): DiagramDimensions | undefined {
|
): DiagramDimensions | undefined {
|
||||||
@@ -144,9 +196,17 @@ export function fitSvgDiagramsToPage(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availablePageContent =
|
const resolvedPageContent =
|
||||||
options.resolvePageContent?.(container, pageContent) ??
|
options.resolvePageContent?.(container, pageContent) ??
|
||||||
pageContent;
|
pageContent;
|
||||||
|
const availablePageContent = {
|
||||||
|
width: resolvedPageContent.width,
|
||||||
|
height: Math.max(
|
||||||
|
0,
|
||||||
|
resolvedPageContent.height -
|
||||||
|
getPrecedingMediaTitleHeight(container)
|
||||||
|
)
|
||||||
|
};
|
||||||
const fit = calculateDiagramPageFit(
|
const fit = calculateDiagramPageFit(
|
||||||
intrinsic,
|
intrinsic,
|
||||||
availablePageContent
|
availablePageContent
|
||||||
@@ -157,6 +217,13 @@ export function fitSvgDiagramsToPage(
|
|||||||
|
|
||||||
container.dataset.pageHeightFitted = "true";
|
container.dataset.pageHeightFitted = "true";
|
||||||
container.style.setProperty("margin-block", "0", "important");
|
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")) {
|
if (!svg.getAttribute("viewBox")) {
|
||||||
svg.setAttribute(
|
svg.setAttribute(
|
||||||
"viewBox",
|
"viewBox",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { ExportConfig } from "@md-to-pdf/core";
|
import type { ExportConfig } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
calculateDiagramPageFit,
|
calculateDiagramPageFit,
|
||||||
getPageContentDimensions
|
ensurePageBreakAfter,
|
||||||
|
getPageContentDimensions,
|
||||||
|
getPrecedingMediaTitleHeight
|
||||||
} from "./diagram-page-fit";
|
} from "./diagram-page-fit";
|
||||||
|
|
||||||
function getImageBlock(image: HTMLImageElement) {
|
function getImageBlock(image: HTMLImageElement) {
|
||||||
@@ -23,11 +25,30 @@ function getImageBlock(image: HTMLImageElement) {
|
|||||||
return undefined;
|
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(
|
export function fitDocumentImagesToPage(
|
||||||
root: ParentNode,
|
root: ParentNode,
|
||||||
config: ExportConfig
|
config: ExportConfig,
|
||||||
|
options: { prepareBlocks?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const pageContent = getPageContentDimensions(config);
|
const pageContent = getPageContentDimensions(config);
|
||||||
|
if (options.prepareBlocks !== false) {
|
||||||
|
prepareDocumentImageBlocks(root);
|
||||||
|
}
|
||||||
const images = Array.from(
|
const images = Array.from(
|
||||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||||
);
|
);
|
||||||
@@ -42,11 +63,17 @@ export function fitDocumentImagesToPage(
|
|||||||
const imageHeight = image.getBoundingClientRect().height;
|
const imageHeight = image.getBoundingClientRect().height;
|
||||||
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
||||||
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
||||||
|
const titleHeight = block
|
||||||
|
? getPrecedingMediaTitleHeight(block)
|
||||||
|
: 0;
|
||||||
const fit = calculateDiagramPageFit(
|
const fit = calculateDiagramPageFit(
|
||||||
{ width, height },
|
{ width, height },
|
||||||
{
|
{
|
||||||
width: pageContent.width,
|
width: pageContent.width,
|
||||||
height: Math.max(0, pageContent.height - nonImageHeight)
|
height: Math.max(
|
||||||
|
0,
|
||||||
|
pageContent.height - nonImageHeight - titleHeight
|
||||||
|
)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!fit.scaled) {
|
if (!fit.scaled) {
|
||||||
@@ -55,6 +82,13 @@ export function fitDocumentImagesToPage(
|
|||||||
if (block?.classList.contains("md-document-image-block")) {
|
if (block?.classList.contains("md-document-image-block")) {
|
||||||
block.dataset.pageHeightFitted = "true";
|
block.dataset.pageHeightFitted = "true";
|
||||||
block.style.setProperty("margin-block", "0", "important");
|
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.dataset.pageHeightFitted = "true";
|
||||||
image.style.setProperty("display", "block");
|
image.style.setProperty("display", "block");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,7 +7,16 @@ import type {
|
|||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { Previewer } from "pagedjs";
|
import { Previewer } from "pagedjs";
|
||||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||||
import { fitDocumentImagesToPage } from "./document-image-fit";
|
import {
|
||||||
|
fitDocumentImagesToPage,
|
||||||
|
prepareDocumentImageBlocks
|
||||||
|
} from "./document-image-fit";
|
||||||
|
import {
|
||||||
|
applyMediaBackfillCandidates,
|
||||||
|
findMediaBackfillCandidates,
|
||||||
|
getMediaBackfillIdsInDocumentOrder,
|
||||||
|
prepareMediaBackfillBlocks
|
||||||
|
} from "./media-page-backfill";
|
||||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||||
import {
|
import {
|
||||||
fitOversizedMermaidToPage,
|
fitOversizedMermaidToPage,
|
||||||
@@ -232,6 +241,31 @@ export class PagedDocumentRuntime {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async replaceEChartsSvgWithImages(container: ParentNode) {
|
||||||
|
const { freezeEChartsSvg } = await this.loadECharts();
|
||||||
|
const figures = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
".md-echarts:not(.md-echarts-error)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const figure of figures) {
|
||||||
|
const host = figure.querySelector<HTMLElement>(
|
||||||
|
".md-echarts-host"
|
||||||
|
);
|
||||||
|
const svg = host?.querySelector<SVGSVGElement>("svg");
|
||||||
|
if (!host || !svg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
host.getAttribute("aria-label") || "ECharts 图表";
|
||||||
|
host.replaceChildren(
|
||||||
|
freezeEChartsSvg(host, svg, "svg-image", label)
|
||||||
|
);
|
||||||
|
figure.dataset.echartsRendered = "svg-image";
|
||||||
|
}
|
||||||
|
await waitForImages(container);
|
||||||
|
}
|
||||||
|
|
||||||
private async renderMermaid(
|
private async renderMermaid(
|
||||||
container: DocumentFragment,
|
container: DocumentFragment,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload
|
||||||
@@ -346,9 +380,12 @@ export class PagedDocumentRuntime {
|
|||||||
);
|
);
|
||||||
echartsMs = performance.now() - echartsStartedAt;
|
echartsMs = performance.now() - echartsStartedAt;
|
||||||
await waitForImages(measurement.host);
|
await waitForImages(measurement.host);
|
||||||
|
prepareDocumentImageBlocks(measurement.host);
|
||||||
|
prepareMediaBackfillBlocks(measurement.host);
|
||||||
fitDocumentImagesToPage(
|
fitDocumentImagesToPage(
|
||||||
measurement.host,
|
measurement.host,
|
||||||
payload.exportConfig
|
payload.exportConfig,
|
||||||
|
{ prepareBlocks: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const echartsFitStartedAt = performance.now();
|
const echartsFitStartedAt = performance.now();
|
||||||
@@ -356,6 +393,7 @@ export class PagedDocumentRuntime {
|
|||||||
measurement.host,
|
measurement.host,
|
||||||
payload.exportConfig
|
payload.exportConfig
|
||||||
);
|
);
|
||||||
|
await this.replaceEChartsSvgWithImages(measurement.host);
|
||||||
echartsFitMs =
|
echartsFitMs =
|
||||||
performance.now() - echartsFitStartedAt;
|
performance.now() - echartsFitStartedAt;
|
||||||
|
|
||||||
@@ -388,11 +426,10 @@ export class PagedDocumentRuntime {
|
|||||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
||||||
|
|
||||||
const paginationStartedAt = performance.now();
|
const paginationStartedAt = performance.now();
|
||||||
const previewer = new Previewer();
|
const repaginationSource = content.cloneNode(
|
||||||
this.activePreviewer = previewer;
|
true
|
||||||
const flow = await previewer.preview(
|
) as DocumentFragment;
|
||||||
content,
|
const stylesheets = [
|
||||||
[
|
|
||||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||||
stylesheet(documentRef, highlightCss, "highlight"),
|
stylesheet(documentRef, highlightCss, "highlight"),
|
||||||
stylesheet(documentRef, katexCss, "katex"),
|
stylesheet(documentRef, katexCss, "katex"),
|
||||||
@@ -408,9 +445,55 @@ export class PagedDocumentRuntime {
|
|||||||
buildPagedMediaCss(payload.exportConfig, payload),
|
buildPagedMediaCss(payload.exportConfig, payload),
|
||||||
"paged-media"
|
"paged-media"
|
||||||
)
|
)
|
||||||
],
|
];
|
||||||
|
let previewer = new Previewer();
|
||||||
|
this.activePreviewer = previewer;
|
||||||
|
let flow = await previewer.preview(
|
||||||
|
content,
|
||||||
|
stylesheets,
|
||||||
this.root
|
this.root
|
||||||
);
|
);
|
||||||
|
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
||||||
|
repaginationSource
|
||||||
|
);
|
||||||
|
let mediaCandidates = new Map(
|
||||||
|
findMediaBackfillCandidates(this.root).map((candidate) => [
|
||||||
|
candidate.id,
|
||||||
|
candidate
|
||||||
|
])
|
||||||
|
);
|
||||||
|
for (const mediaId of mediaIds) {
|
||||||
|
if (!shouldContinue()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const candidate = mediaCandidates.get(mediaId);
|
||||||
|
if (
|
||||||
|
!candidate ||
|
||||||
|
applyMediaBackfillCandidates(repaginationSource, [
|
||||||
|
candidate
|
||||||
|
]) === 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewer.chunker.destroy();
|
||||||
|
previewer.polisher.destroy();
|
||||||
|
previewer = new Previewer();
|
||||||
|
this.activePreviewer = previewer;
|
||||||
|
flow = await previewer.preview(
|
||||||
|
repaginationSource.cloneNode(true) as DocumentFragment,
|
||||||
|
stylesheets,
|
||||||
|
this.root
|
||||||
|
);
|
||||||
|
mediaCandidates = new Map(
|
||||||
|
findMediaBackfillCandidates(this.root).map(
|
||||||
|
(nextCandidate) => [
|
||||||
|
nextCandidate.id,
|
||||||
|
nextCandidate
|
||||||
|
]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
const paginationMs = performance.now() - paginationStartedAt;
|
const paginationMs = performance.now() - paginationStartedAt;
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
if (!shouldContinue()) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
`;
|
||||||
+48
-11
@@ -24,8 +24,7 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button {
|
||||||
.file-button {
|
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border: 1px solid #b8c2bd;
|
border: 1px solid #b8c2bd;
|
||||||
@@ -102,13 +101,31 @@ button:disabled {
|
|||||||
border-left: 1px solid #d4dad7;
|
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;
|
overflow: hidden;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.document-summary .render-status {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -138,17 +155,37 @@ h1 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-button {
|
.more-menu {
|
||||||
display: inline-flex;
|
position: relative;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-button input {
|
.more-menu-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
z-index: 30;
|
||||||
height: 1px;
|
top: calc(100% + 8px);
|
||||||
overflow: hidden;
|
right: 0;
|
||||||
clip: rect(0 0 0 0);
|
display: flex;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #c8d1cc;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-menu-popover button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-menu-popover button:hover,
|
||||||
|
.more-menu-popover button:focus-visible {
|
||||||
|
background: #edf3f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-layout {
|
.editor-layout {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export type { ThemeSummary } from "./application-backend";
|
|||||||
|
|
||||||
export function useThemeResources(
|
export function useThemeResources(
|
||||||
themeId: string,
|
themeId: string,
|
||||||
onThemesLoaded: (themes: ThemeSummary[]) => void
|
onThemesLoaded: (
|
||||||
|
themes: ThemeSummary[],
|
||||||
|
refreshKey: number
|
||||||
|
) => void,
|
||||||
|
refreshKey = 0
|
||||||
) {
|
) {
|
||||||
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
const [themes, setThemes] = useState<ThemeSummary[]>([]);
|
||||||
const [themeCss, setThemeCss] = useState("");
|
const [themeCss, setThemeCss] = useState("");
|
||||||
@@ -23,7 +27,7 @@ export function useThemeResources(
|
|||||||
.then(({ themes: availableThemes }) => {
|
.then(({ themes: availableThemes }) => {
|
||||||
setThemes(availableThemes);
|
setThemes(availableThemes);
|
||||||
setCatalogError("");
|
setCatalogError("");
|
||||||
onThemesLoaded(availableThemes);
|
onThemesLoaded(availableThemes, refreshKey);
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -34,7 +38,7 @@ export function useThemeResources(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [onThemesLoaded]);
|
}, [onThemesLoaded, refreshKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -52,7 +56,7 @@ export function useThemeResources(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [themeId]);
|
}, [refreshKey, themeId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: catalogError || cssError,
|
error: catalogError || cssError,
|
||||||
|
|||||||
Vendored
+16
@@ -24,6 +24,22 @@ declare global {
|
|||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>;
|
>;
|
||||||
|
consumePendingMarkdown(): Promise<
|
||||||
|
| {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>;
|
||||||
|
discardPendingMarkdown(): Promise<void>;
|
||||||
|
onMarkdownOpened(listener: () => void): () => void;
|
||||||
|
startNewMarkdown(): Promise<void>;
|
||||||
|
saveMarkdown(
|
||||||
|
fileName: string,
|
||||||
|
markdown: string
|
||||||
|
): Promise<{ fileName: string } | undefined>;
|
||||||
|
openThemeDirectory(): Promise<string>;
|
||||||
|
refreshThemes(): Promise<void>;
|
||||||
renderMarkdown(input: {
|
renderMarkdown(input: {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
|
|
||||||
describe("应用版本", () => {
|
describe("应用版本", () => {
|
||||||
it("从统一构建版本生成标题徽标", () => {
|
it("从统一构建版本生成标题徽标", () => {
|
||||||
expect(APP_VERSION).toBe("0.4.3");
|
expect(APP_VERSION).toBe("0.4.5");
|
||||||
expect(APP_VERSION_LABEL).toBe("v0.4.3");
|
expect(APP_VERSION_LABEL).toBe("v0.4.5");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ describe("ECharts 单页高度适配", () => {
|
|||||||
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
|
<h2 style="margin: 10px 0">大型图表</h2>
|
||||||
<figure class="md-echarts">
|
<figure class="md-echarts">
|
||||||
<div class="md-echarts-host" style="height: 2000px">
|
<div class="md-echarts-host" style="height: 2000px">
|
||||||
<svg width="1000" height="2000"></svg>
|
<svg width="1000" height="2000"></svg>
|
||||||
@@ -15,22 +16,43 @@ describe("ECharts 单页高度适配", () => {
|
|||||||
</figure>
|
</figure>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
fitOversizedEChartsToPage(root, defaultExportConfig);
|
|
||||||
|
|
||||||
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||||
|
const title = root.querySelector<HTMLElement>("h2");
|
||||||
const host = root.querySelector<HTMLElement>(
|
const host = root.querySelector<HTMLElement>(
|
||||||
".md-echarts-host"
|
".md-echarts-host"
|
||||||
);
|
);
|
||||||
const svg = root.querySelector<SVGSVGElement>("svg");
|
const svg = root.querySelector<SVGSVGElement>("svg");
|
||||||
|
if (title) {
|
||||||
|
title.getBoundingClientRect = () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 700,
|
||||||
|
bottom: 40,
|
||||||
|
width: 700,
|
||||||
|
height: 40,
|
||||||
|
toJSON: () => ({})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||||
|
|
||||||
expect(figure?.dataset.pageHeightFitted).toBe("true");
|
expect(figure?.dataset.pageHeightFitted).toBe("true");
|
||||||
|
expect(title?.style.breakAfter).toBe("avoid-page");
|
||||||
|
expect(figure?.style.breakAfter).toBe("page");
|
||||||
|
expect(figure?.style.pageBreakAfter).toBe("always");
|
||||||
|
expect(figure?.nextElementSibling).toHaveProperty(
|
||||||
|
"dataset.mediaPageBreak",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
|
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
|
||||||
1000.5748,
|
960.5748,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
expect(host?.style.aspectRatio).toBe("");
|
expect(host?.style.aspectRatio).toBe("");
|
||||||
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
|
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
|
||||||
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
|
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
|
||||||
1000.5748,
|
960.5748,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyMediaBackfillCandidates,
|
||||||
|
calculateMediaBackfillScale,
|
||||||
|
findMediaBackfillCandidates,
|
||||||
|
getMediaBackfillIdsInDocumentOrder,
|
||||||
|
prepareMediaBackfillBlocks
|
||||||
|
} from "../src/media-page-backfill";
|
||||||
|
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit";
|
||||||
|
|
||||||
|
function rect(width: number, height: number) {
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: width,
|
||||||
|
bottom: height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
toJSON: () => ({})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionedRect(
|
||||||
|
top: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...rect(width, height),
|
||||||
|
y: top,
|
||||||
|
top,
|
||||||
|
bottom: top + height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("媒体分页空白回填", () => {
|
||||||
|
it("全页适配时保留前置标题高度并避免标题孤行", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<h2 style="margin: 10px 0 12px">媒体标题</h2>
|
||||||
|
<div class="mermaid"></div>
|
||||||
|
`;
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>(".mermaid")!;
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
|
||||||
|
expect(getPrecedingMediaTitleHeight(block)).toBe(40);
|
||||||
|
expect(title.style.getPropertyValue("break-after")).toBe(
|
||||||
|
"avoid-page"
|
||||||
|
);
|
||||||
|
expect(title.style.getPropertyPriority("break-after")).toBe(
|
||||||
|
"important"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按限宽后的基准高度计算额外缩放率", () => {
|
||||||
|
expect(
|
||||||
|
calculateMediaBackfillScale({
|
||||||
|
remainingHeight: 420,
|
||||||
|
fixedHeight: 30,
|
||||||
|
baselineVisualHeight: 430
|
||||||
|
})
|
||||||
|
).toBeCloseTo(388 / 430, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缩小超过阈值或无需缩小时不回填", () => {
|
||||||
|
expect(
|
||||||
|
calculateMediaBackfillScale({
|
||||||
|
remainingHeight: 300,
|
||||||
|
fixedHeight: 30,
|
||||||
|
baselineVisualHeight: 430
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
calculateMediaBackfillScale({
|
||||||
|
remainingHeight: 500,
|
||||||
|
fixedHeight: 30,
|
||||||
|
baselineVisualHeight: 430
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("记录图片限宽后的实际基准尺寸", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure class="md-document-image-block">
|
||||||
|
<img class="md-document-image">
|
||||||
|
<figcaption>标题</figcaption>
|
||||||
|
</figure>
|
||||||
|
`;
|
||||||
|
const image = root.querySelector("img")!;
|
||||||
|
image.getBoundingClientRect = () => rect(640, 480);
|
||||||
|
|
||||||
|
expect(prepareMediaBackfillBlocks(root)).toBe(1);
|
||||||
|
const figure = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
expect(figure.dataset.mediaBackfillKind).toBe("image");
|
||||||
|
expect(figure.dataset.mediaBaselineWidth).toBe("640");
|
||||||
|
expect(figure.dataset.mediaBaselineHeight).toBe("480");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保持媒体元素在源文档中的串行处理顺序", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<figure data-media-backfill-id="media-2"></figure>
|
||||||
|
<div><figure data-media-backfill-id="media-1"></figure></div>
|
||||||
|
<figure></figure>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([
|
||||||
|
"media-2",
|
||||||
|
"media-1"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根据上一页剩余区域生成一次回填候选", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<div
|
||||||
|
class="pagedjs_page_content_flow"
|
||||||
|
data-ref="previous-content"
|
||||||
|
>上一页内容</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<h2 data-ref="media-title">图片标题</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const previousFlow =
|
||||||
|
pageContents[0]!.firstElementChild as HTMLElement;
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||||
|
pageContents[0]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
previousFlow.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 550);
|
||||||
|
pageContents[1]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
block.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 330);
|
||||||
|
image.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 300);
|
||||||
|
|
||||||
|
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||||
|
{ id: "media-1", scale: 0.92 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显式分页的媒体块不会回填到上一页", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<h2 data-ref="media-title" style="break-before: page">
|
||||||
|
图片标题
|
||||||
|
</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-ref="media-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||||
|
pageContents[0]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
pageContents[1]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
block.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 330);
|
||||||
|
image.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 300);
|
||||||
|
|
||||||
|
expect(findMediaBackfillCandidates(root)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略媒体之前其他内容的显式分页规则", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<div data-ref="previous-content">上一页内容</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pagedjs_page">
|
||||||
|
<div class="pagedjs_page_content">
|
||||||
|
<h1 data-ref="earlier-heading" style="break-before: page">
|
||||||
|
本页章节
|
||||||
|
</h1>
|
||||||
|
<h2 data-ref="media-title">图片标题</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-ref="media-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||||
|
".pagedjs_page_content"
|
||||||
|
);
|
||||||
|
const previousFlow = root.querySelector<HTMLElement>(
|
||||||
|
"[data-ref='previous-content']"
|
||||||
|
)!;
|
||||||
|
const earlierHeading = root.querySelector<HTMLElement>("h1")!;
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const block = root.querySelector<HTMLElement>("figure")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||||
|
pageContents[0]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
previousFlow.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 550);
|
||||||
|
pageContents[1]!.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 700, 1_000);
|
||||||
|
earlierHeading.getBoundingClientRect = () =>
|
||||||
|
positionedRect(100, 600, 40);
|
||||||
|
title.getBoundingClientRect = () =>
|
||||||
|
positionedRect(150, 600, 40);
|
||||||
|
block.getBoundingClientRect = () =>
|
||||||
|
positionedRect(200, 600, 330);
|
||||||
|
image.getBoundingClientRect = () =>
|
||||||
|
positionedRect(200, 600, 300);
|
||||||
|
|
||||||
|
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||||
|
{ id: "media-1", scale: 0.92 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将候选缩放应用到图片和 ECharts 主体", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.innerHTML = `
|
||||||
|
<h2>图片标题保持原尺寸</h2>
|
||||||
|
<figure
|
||||||
|
class="md-document-image-block"
|
||||||
|
data-media-backfill-id="media-1"
|
||||||
|
data-media-backfill-kind="image"
|
||||||
|
data-media-baseline-width="800"
|
||||||
|
data-media-baseline-height="600"
|
||||||
|
>
|
||||||
|
<img class="md-document-image">
|
||||||
|
</figure>
|
||||||
|
<figure
|
||||||
|
class="md-echarts"
|
||||||
|
data-media-backfill-id="media-2"
|
||||||
|
data-media-backfill-kind="echarts"
|
||||||
|
data-media-baseline-width="700"
|
||||||
|
data-media-baseline-height="500"
|
||||||
|
>
|
||||||
|
<div class="md-echarts-host"><svg></svg></div>
|
||||||
|
</figure>
|
||||||
|
<div
|
||||||
|
class="mermaid"
|
||||||
|
data-media-backfill-id="media-3"
|
||||||
|
data-media-backfill-kind="mermaid"
|
||||||
|
data-media-baseline-width="600"
|
||||||
|
data-media-baseline-height="400"
|
||||||
|
>
|
||||||
|
<svg></svg>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
applyMediaBackfillCandidates(root, [
|
||||||
|
{ id: "media-1", scale: 0.9 },
|
||||||
|
{ id: "media-2", scale: 0.88 },
|
||||||
|
{ id: "media-3", scale: 0.9 }
|
||||||
|
])
|
||||||
|
).toBe(3);
|
||||||
|
const title = root.querySelector<HTMLElement>("h2")!;
|
||||||
|
const image = root.querySelector<HTMLImageElement>(
|
||||||
|
"img.md-document-image"
|
||||||
|
)!;
|
||||||
|
const host = root.querySelector<HTMLElement>(".md-echarts-host")!;
|
||||||
|
const svg = root.querySelector<SVGSVGElement>("svg")!;
|
||||||
|
const mermaidSvg = root.querySelector<SVGSVGElement>(
|
||||||
|
".mermaid svg"
|
||||||
|
)!;
|
||||||
|
expect(title.getAttribute("style")).toBeNull();
|
||||||
|
expect(image.style.width).toBe("720px");
|
||||||
|
expect(image.style.height).toBe("540px");
|
||||||
|
expect(host.style.width).toBe("616px");
|
||||||
|
expect(host.style.height).toBe("440px");
|
||||||
|
expect(svg.style.width).toBe("616px");
|
||||||
|
expect(svg.style.height).toBe("440px");
|
||||||
|
expect(mermaidSvg.style.width).toBe("540px");
|
||||||
|
expect(mermaidSvg.style.height).toBe("360px");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("## 串行媒体块");
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-3
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
|
|||||||
可以直接指定版本化镜像名称构建:
|
可以直接指定版本化镜像名称构建:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
|
|||||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
|
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5'
|
||||||
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
|
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
|
||||||
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
|
||||||
docker compose -f deploy\compose.yaml build
|
docker compose -f deploy\compose.yaml build
|
||||||
```
|
```
|
||||||
|
|
||||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.1 应用代码和生产
|
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.5 应用代码和生产
|
||||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||||
|
|
||||||
## 主题挂载
|
## 主题挂载
|
||||||
|
|||||||
+113
-10
@@ -1,6 +1,6 @@
|
|||||||
# Markdown PDF 导出器进度
|
# Markdown PDF 导出器进度
|
||||||
|
|
||||||
最后更新:2026-07-27
|
最后更新:2026-07-28
|
||||||
|
|
||||||
## 1. 当前概况
|
## 1. 当前概况
|
||||||
|
|
||||||
@@ -41,6 +41,14 @@ Web/Compose 发布仍保持 `v0.4.1`。
|
|||||||
`v0.4.3` 将桌面发行包迁移为标准 NSIS 安装向导,并使用全英文、无空格、
|
`v0.4.3` 将桌面发行包迁移为标准 NSIS 安装向导,并使用全英文、无空格、
|
||||||
包含版本与平台的安装包文件名;Web/Compose 发布仍保持 `v0.4.1`。
|
包含版本与平台的安装包文件名;Web/Compose 发布仍保持 `v0.4.1`。
|
||||||
|
|
||||||
|
`v0.4.4` 仅精简 Electron 桌面发行包的语言资源,保留简体中文和英文;
|
||||||
|
Web/Compose 发布仍保持 `v0.4.1`。
|
||||||
|
|
||||||
|
`v0.4.5` 同步更新 Web 与 Desktop:新增新建/保存 Markdown、桌面文件
|
||||||
|
关联、窗口状态恢复、自定义主题目录和收纳式“更多”菜单;Web 明确取消
|
||||||
|
本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序
|
||||||
|
串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -426,9 +434,45 @@ Web/Compose 发布仍保持 `v0.4.1`。
|
|||||||
- 安装包和校验文件作为 Gitea `v0.4.3` Release 附件发布,不写入 Git
|
- 安装包和校验文件作为 Gitea `v0.4.3` Release 附件发布,不写入 Git
|
||||||
历史。
|
历史。
|
||||||
|
|
||||||
|
### 3.17 v0.4.4 桌面语言包精简
|
||||||
|
|
||||||
|
- Electron 发行包只保留 `zh-CN` 和 `en-US` 两种 Chromium 语言资源。
|
||||||
|
- 不裁剪 ICU、ANGLE、Direct3D、Dawn、SwiftShader 或其他 Chromium
|
||||||
|
运行时组件,保证桌面预览与 PDF 导出在不同图形环境下的可靠性。
|
||||||
|
- 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持
|
||||||
|
`v0.4.1`。
|
||||||
|
|
||||||
|
### 3.18 v0.4.5 文档工作流与媒体分页
|
||||||
|
|
||||||
|
- Web 与 Desktop 均支持新建 Markdown;未保存时使用“未命名文档”状态,
|
||||||
|
不显示伪造源文件名,并允许直接导出 PDF。
|
||||||
|
- Web 保存使用浏览器下载;Desktop 使用原生另存为。默认文件名优先取
|
||||||
|
第一个一级标题,没有一级标题时取首行可见文本,并清理 Windows 非法
|
||||||
|
字符、保留名和末尾空格或句点。
|
||||||
|
- “打开 Markdown”继续作为顶部独立强操作;新建、保存及桌面自定义主题
|
||||||
|
目录收纳到“更多”菜单,Web 不再显示本地素材目录入口。
|
||||||
|
- Windows 安装包注册 `.md` 和 `.markdown` 文件关联;系统打开文件会
|
||||||
|
转交给现有单实例窗口并自动加载。
|
||||||
|
- Desktop 监听窗口位置、尺寸和最大化变化,延迟写入完整状态;退出前
|
||||||
|
刷新待写状态。下次启动按当前显示器工作区限制尺寸并修正坐标,避免
|
||||||
|
多显示器断开后窗口落在屏幕外。
|
||||||
|
- Desktop 会建立自定义主题目录,“更多”菜单可直接在资源管理器打开;
|
||||||
|
目录允许留空,放入合法主题后由共享主题注册器读取。
|
||||||
|
- 图片、Mermaid 和 ECharts 统一视为带标题的媒体块。分页按 DOM 文档
|
||||||
|
顺序串行处理,每个媒体元素只允许一次回填或缩放重排。
|
||||||
|
- 图片基准高度按原尺寸先收缩到内容限宽后计算;媒体块只有在上一页空白
|
||||||
|
与所需空间比例达到阈值时才尝试回填。缩放只作用于图片或图表内容,
|
||||||
|
标题保持原字号和自然高度。
|
||||||
|
- ECharts 在分页前冻结为 SVG 图片,三类媒体共享回填决策与边界检查;
|
||||||
|
无法安全回填时仍移至下一页,保证顺序、不跨页且不覆盖后续内容。
|
||||||
|
- 默认示例仅保留一张网络图片,并新增有效 Base64 横图、不同尺寸图片、
|
||||||
|
Mermaid 和 ECharts,用于观察媒体分页、标题配对和渲染效果。
|
||||||
|
- 根 README、Desktop README、四个工作区包 README、进度文档和发布
|
||||||
|
规范随版本同步更新。
|
||||||
|
|
||||||
## 4. 已执行验证
|
## 4. 已执行验证
|
||||||
|
|
||||||
2026-07-27 在当前完整工作区成功执行:
|
2026-07-28 在当前完整工作区成功执行:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
npm test
|
npm test
|
||||||
@@ -441,16 +485,45 @@ git diff --check
|
|||||||
|
|
||||||
- ECharts 围栏测试:36 项通过;
|
- ECharts 围栏测试:36 项通过;
|
||||||
- 共享配置测试:9 项通过;
|
- 共享配置测试:9 项通过;
|
||||||
- 渲染器测试:10 项通过;
|
- 渲染器测试:12 项通过;
|
||||||
- 应用服务测试:5 项通过;
|
- 应用服务测试:9 项通过;
|
||||||
- 前端测试:67 项通过;
|
- 前端测试:87 项通过;
|
||||||
- 后端测试:29 项通过;
|
- 后端测试:29 项通过;
|
||||||
- 桌面端测试:7 项通过;
|
- 桌面端测试:20 项通过;
|
||||||
- 全项目共 171 项测试通过;
|
- 全项目共 202 项测试通过;
|
||||||
- 全项目类型检查通过;
|
- 全项目类型检查通过;
|
||||||
- 生产构建通过;
|
- 生产构建通过;
|
||||||
- `git diff --check` 通过。
|
- `git diff --check` 通过。
|
||||||
|
|
||||||
|
`v0.4.5` 双端与媒体分页验收结果:
|
||||||
|
|
||||||
|
- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID
|
||||||
|
为 `sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`;
|
||||||
|
Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`。
|
||||||
|
- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF,
|
||||||
|
Mermaid 与 ECharts 错误数均为 0。
|
||||||
|
- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只
|
||||||
|
出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。
|
||||||
|
- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、
|
||||||
|
928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。
|
||||||
|
- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过;
|
||||||
|
`output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。
|
||||||
|
- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题
|
||||||
|
目录能够打开且可识别已有主题。
|
||||||
|
- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸
|
||||||
|
测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。
|
||||||
|
- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`,
|
||||||
|
94,998,489 字节,SHA-256 为
|
||||||
|
`9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`。
|
||||||
|
- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`,132,477,245 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`。
|
||||||
|
- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN` 和 `en-US` 语言包,
|
||||||
|
Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。
|
||||||
|
- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、
|
||||||
|
桌面与开始菜单快捷方式、`.md` 和 `.markdown` 打开注册均验证通过,
|
||||||
|
安装版应用启动正常。
|
||||||
|
|
||||||
Electron 桌面端第一阶段验证结果:
|
Electron 桌面端第一阶段验证结果:
|
||||||
|
|
||||||
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
|
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
|
||||||
@@ -648,9 +721,29 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
SHA-256 为
|
SHA-256 为
|
||||||
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`。
|
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`。
|
||||||
|
|
||||||
|
2026-07-27 已完成 `v0.4.4` 桌面语言包精简验证:
|
||||||
|
|
||||||
|
- 目录版只包含 `zh-CN.pak` 和 `en-US.pak`,语言资源从 55 个、
|
||||||
|
48,911,653 字节降为 2 个、1,137,843 字节。
|
||||||
|
- 目录版总大小从 378,084,454 字节降为 330,310,644 字节,减少
|
||||||
|
47,773,810 字节;Chromium 图形与软件渲染后备组件均保持完整。
|
||||||
|
- Windows 主程序版本为 `0.4.4`,目录版成功启动并保持应用、GPU、
|
||||||
|
渲染器等 4 个 Electron 进程正常运行。
|
||||||
|
- 正式安装包为 `md-to-pdf-0.4.4-x86_64-Setup.exe`,94,991,759 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`0827FD87A8EEC8B034DF738909CB6A20231245B507AE4A0BAA579AFA03A942DE`,
|
||||||
|
Authenticode 状态为未签名。
|
||||||
|
- 免安装包为 `md-to-pdf-0.4.4-x86_64.zip`,132,470,241 字节,
|
||||||
|
SHA-256 为
|
||||||
|
`D6CDB8DAE30676BAB6B2A037567164476AD294588B102A6FBBE9458ABCE5CCC0`。
|
||||||
|
|
||||||
## 5. 当前注意事项
|
## 5. 当前注意事项
|
||||||
|
|
||||||
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
||||||
|
- 已确认分页预览 iframe 内点击超链接会在 iframe 内执行导航,可能将当前
|
||||||
|
预览替换为空白页。该问题不阻塞 `v0.4.5`,计划在 `v0.4.6` 统一处理:
|
||||||
|
区分文档内锚点与外部链接、阻止危险协议,并为外部链接使用受控的新窗口
|
||||||
|
或系统浏览器打开策略。
|
||||||
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
|
- `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。
|
||||||
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
|
- `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和
|
||||||
渲染错误状态。
|
渲染错误状态。
|
||||||
@@ -693,12 +786,22 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
目录读取;远程图片仅保留有界内存缓存。
|
目录读取;远程图片仅保留有界内存缓存。
|
||||||
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
||||||
导出,不依赖 Fastify 或本地 HTTP Server。
|
导出,不依赖 Fastify 或本地 HTTP Server。
|
||||||
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
|
- `v0.4.5` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和
|
||||||
当前 Compose 容器保持运行。
|
Compose 健康检查,当前 Compose 容器保持运行。
|
||||||
|
|
||||||
## 6. 推荐接手顺序
|
## 6. 推荐接手顺序
|
||||||
|
|
||||||
### 阶段一:导出配置扩展
|
### 阶段一:v0.4.6 超链接导航
|
||||||
|
|
||||||
|
- 在快速预览 iframe 中统一拦截链接点击,避免外部页面替换分页预览;
|
||||||
|
- 文档内 `#anchor` 保持在当前预览中定位,并兼容分页后的标题锚点;
|
||||||
|
- `http:`、`https:`、`mailto:` 等允许协议通过受控新窗口或桌面系统
|
||||||
|
浏览器打开;
|
||||||
|
- 拒绝 `javascript:`、`file:`、未知协议及未授权本地资源;
|
||||||
|
- Web 与 Desktop 共用链接分类规则,各平台只实现打开动作适配;
|
||||||
|
- 增加快速预览、精确预览和 Desktop 的链接行为测试。
|
||||||
|
|
||||||
|
### 阶段二:导出配置扩展
|
||||||
|
|
||||||
- Front Matter 元数据覆盖;
|
- Front Matter 元数据覆盖;
|
||||||
- 浏览器本地配置预设;
|
- 浏览器本地配置预设;
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.4.3",
|
"version": "0.4.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.4.3",
|
"version": "0.4.5",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "@md-to-pdf/desktop",
|
"name": "@md-to-pdf/desktop",
|
||||||
"version": "0.4.3",
|
"version": "0.4.5",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@md-to-pdf/application": "0.4.1",
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
@@ -3486,7 +3486,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -5296,6 +5295,7 @@
|
|||||||
"integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==",
|
"integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"builder-util": "26.15.3",
|
"builder-util": "26.15.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "md-to-pdf",
|
"name": "md-to-pdf",
|
||||||
"version": "0.4.3",
|
"version": "0.4.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# @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
|
||||||
|
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 所在目录为边界解析相对资源。
|
||||||
|
|
||||||
|
## 开发与验证
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# @md-to-pdf/core
|
||||||
|
|
||||||
|
跨 Web、Server、Desktop 和渲染器共享的纯数据模型与校验规则。该包不
|
||||||
|
依赖浏览器、Electron 或 Fastify,负责保证各运行端使用同一份协议。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
document.ts Markdown 文档、分页载荷、结果与耗时模型
|
||||||
|
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
|
||||||
|
theme.ts 主题清单、主题能力与 CSS 载荷模型
|
||||||
|
index.ts 公共导出入口
|
||||||
|
tests/
|
||||||
|
document.test.ts
|
||||||
|
export-config.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig,
|
||||||
|
exportConfigSchema
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
const config = exportConfigSchema.parse(defaultExportConfig);
|
||||||
|
const payload = createPagedDocumentPayload({
|
||||||
|
document,
|
||||||
|
fileName: "example.md",
|
||||||
|
themeCss,
|
||||||
|
exportConfig: config
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
新增跨端字段时,应先在这里定义类型、默认值、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
|
||||||
|
```
|
||||||
@@ -6,6 +6,24 @@
|
|||||||
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
|
||||||
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```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 测试
|
||||||
|
```
|
||||||
|
|
||||||
## 基本语法
|
## 基本语法
|
||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
@@ -289,6 +307,15 @@ Chromium `page.pdf()`。
|
|||||||
3. 注册该系列的语义验证器;
|
3. 注册该系列的语义验证器;
|
||||||
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
|
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)
|
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# @md-to-pdf/renderer
|
||||||
|
|
||||||
|
将 Markdown 转换为统一、安全的 `#write` HTML 文档。Web 预览、服务端
|
||||||
|
PDF 和桌面端 PDF 均通过应用服务复用本包,不维护第二套 Markdown 解析
|
||||||
|
逻辑。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
render-markdown.ts Front Matter、Markdown 扩展、安全过滤与元数据
|
||||||
|
index.ts 公共导出入口
|
||||||
|
tests/
|
||||||
|
render-markdown.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持能力
|
||||||
|
|
||||||
|
- 标题锚点、表格、任务列表、脚注和代码高亮;
|
||||||
|
- KaTeX 数学公式;
|
||||||
|
- Mermaid 安全占位;
|
||||||
|
- `@md-to-pdf/markdown-echarts` ECharts YAML 围栏;
|
||||||
|
- 图片标题 DOM 与资源引用收集;
|
||||||
|
- `sanitize-html` 白名单过滤和统一 `<article id="write">` 输出。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderMarkdown } from "@md-to-pdf/renderer";
|
||||||
|
|
||||||
|
const result = renderMarkdown("# 示例", {
|
||||||
|
language: "zh-CN"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
实际应用优先通过 `@md-to-pdf/application` 调用,以便同时完成主题和图片
|
||||||
|
资源处理。
|
||||||
|
|
||||||
|
## 开发与验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev -w @md-to-pdf/renderer
|
||||||
|
npm run test -w @md-to-pdf/renderer
|
||||||
|
npm run typecheck -w @md-to-pdf/renderer
|
||||||
|
npm run build -w @md-to-pdf/renderer
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user