Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc30f8cf5 | ||
|
|
7533226366 | ||
|
|
c4df499680 | ||
|
|
effeca7ca8 | ||
|
|
55614fafed | ||
|
|
64445322eb | ||
|
|
01b06abc2c | ||
|
|
f93abf064f | ||
|
|
2c5c1bd317 | ||
|
|
b275c671fc | ||
|
|
9dee7e8605 | ||
|
|
6a6972ae48 | ||
|
|
a094dabf02 | ||
|
|
f0fff7f726 | ||
|
|
28c88faabb | ||
|
|
842c3f1782 | ||
|
|
3e89e3faff | ||
|
|
8f4d16a9b7 | ||
|
|
5c82b3df7f | ||
|
|
14f470454c | ||
|
|
3a8d2c1c2e | ||
|
|
9a2aa3c341 | ||
|
|
58f87cc19f | ||
|
|
f9f5fccfc9 | ||
|
|
3081c02887 | ||
|
|
7f2c169405 | ||
|
|
dc026835b7 | ||
|
|
6f62cfebd6 | ||
|
|
ae9fde3ac6 | ||
|
|
9f96d51922 | ||
|
|
c76454be9d | ||
|
|
e5da699ea3 | ||
|
|
10bc62cee4 | ||
|
|
f3847f3e4b | ||
|
|
6e40374200 | ||
|
|
459a079f3b | ||
|
|
7f72258126 | ||
|
|
d2acc9c5ce | ||
|
|
6086762b4f | ||
|
|
6fed666f44 | ||
|
|
e7c688df6e | ||
|
|
aff2830c28 | ||
|
|
f2dfc6ef72 | ||
|
|
bf43433d38 | ||
|
|
fa159d916f | ||
|
|
fe5d67fb6d | ||
|
|
7831bc23f0 | ||
|
|
f7d4efeafc | ||
|
|
fb3ddada39 | ||
|
|
81d9099eae | ||
|
|
83e6559c2c | ||
|
|
58087d0c7e | ||
|
|
925b0d1485 | ||
|
|
ac54ce46be | ||
|
|
06407650e0 | ||
|
|
8d91d08809 | ||
|
|
32a37293d0 | ||
|
|
06b30d084e | ||
|
|
4d60741f5a |
@@ -0,0 +1,19 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.local
|
||||||
|
.vscode
|
||||||
|
apps/desktop
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
out
|
||||||
|
output/*
|
||||||
|
!output/font-packs/
|
||||||
|
output/font-packs/*
|
||||||
|
!output/font-packs/root/
|
||||||
|
!output/font-packs/root/**
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
tmp
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -1 +1,7 @@
|
|||||||
deploy/entrypoint.sh text eol=lf
|
deploy/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
|
# 正式字体资产保存在仓库固定目录,但二进制内容交由 Git LFS 管理。
|
||||||
|
font-packs/assets/**/*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
font-packs/assets/**/*.otf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
font-packs/assets/**/*.woff filter=lfs diff=lfs merge=lfs -text
|
||||||
|
font-packs/assets/**/*.woff2 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.conda_env
|
||||||
.local/
|
.local/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.serena/
|
||||||
tmp/
|
tmp/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
|
||||||
|
# 构建、门禁与正式发版产物只保留在本机,不进入 Git。
|
||||||
output/
|
output/
|
||||||
|
/apps/desktop/out/
|
||||||
|
/apps/desktop/.runtime/
|
||||||
|
/release/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -13,34 +13,28 @@
|
|||||||
|
|
||||||
## 2. 会话启动检查
|
## 2. 会话启动检查
|
||||||
|
|
||||||
新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先运行:
|
新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先确认仓库根目录。不得假设固定绝对路径或盘符——协作环境可能是 Windows、macOS 或 Linux,克隆位置因机器而异。可用如下只读方式确认(按当前 Shell 语法改写等价命令):
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-Location
|
|
||||||
```
|
|
||||||
|
|
||||||
确认目录为:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
C:\Projects\md-to-pdf
|
git rev-parse --show-toplevel
|
||||||
```
|
```
|
||||||
|
|
||||||
随后依次执行只读检查:
|
随后依次执行只读检查:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
git status --short
|
git status --short
|
||||||
git log --oneline -5
|
git log --oneline -5
|
||||||
Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
|
读取 docs/PROGRESS.md(显式使用 UTF-8 编码)
|
||||||
```
|
```
|
||||||
|
|
||||||
当前工作区可能包含用户或上一个会话留下的未提交修改。禁止使用 `git reset --hard`、`git checkout --`、`git clean` 等方式丢弃修改,除非用户明确要求。
|
当前工作区可能包含用户或上一个会话留下的未提交修改。禁止使用 `git reset --hard`、`git checkout --`、`git clean` 等方式丢弃修改,除非用户明确要求。
|
||||||
|
|
||||||
## 3. Shell 与文件操作
|
## 3. Shell 与文件操作
|
||||||
|
|
||||||
- 当前默认 Shell 为 PowerShell,使用 PowerShell 语法。
|
- 不假设固定 Shell 类型(PowerShell、bash、zsh 等均可能);命令语法跟随当前会话实际使用的 Shell,禁止把某一种 Shell 的专属语法当作硬性要求写入产物、脚本或文档。
|
||||||
- 不要通过绝对路径 `cd` 前缀拼接命令;工具支持时优先使用 `workdir`。
|
- 不要通过绝对路径 `cd` 前缀拼接命令;工具支持时优先使用 `workdir`。
|
||||||
- 子目录操作使用相对路径或直接设置 `workdir`。
|
- 子目录操作使用相对路径或直接设置 `workdir`。
|
||||||
- PowerShell 读取、写入、追加或替换文本时显式指定 UTF-8 编码。
|
- 读取、写入、追加或替换文本文件时显式指定 UTF-8 编码。
|
||||||
- 本地文件修改优先使用补丁工具,避免使用不透明的大段覆盖命令。
|
- 本地文件修改优先使用补丁工具,避免使用不透明的大段覆盖命令。
|
||||||
- 不执行破坏性删除;如确需删除,先确认精确目标并向用户说明。
|
- 不执行破坏性删除;如确需删除,先确认精确目标并向用户说明。
|
||||||
|
|
||||||
@@ -54,7 +48,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 +70,19 @@ flowchart LR
|
|||||||
|
|
||||||
- 前端:React、TypeScript、Vite。
|
- 前端:React、TypeScript、Vite。
|
||||||
- 后端:Node.js、TypeScript、Fastify。
|
- 后端:Node.js、TypeScript、Fastify。
|
||||||
|
- 桌面端:Electron、electron-builder、NSIS。
|
||||||
|
- 应用服务:`packages/application`。
|
||||||
- 共享模型:`packages/core`。
|
- 共享模型:`packages/core`。
|
||||||
- Markdown 渲染:`packages/renderer`。
|
- Markdown 渲染:`packages/renderer`。
|
||||||
- PDF 引擎:计划使用 Playwright Chromium。
|
- 连续预览与分页引擎:`packages/preview-engine`。
|
||||||
|
- ECharts 协议与运行时:`packages/markdown-echarts`。
|
||||||
|
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
|
||||||
- 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 +91,15 @@ flowchart LR
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
apps/web/ 前端编辑、配置与预览界面
|
apps/web/ 前端编辑、配置与预览界面
|
||||||
apps/server/ HTTP API、主题读取及未来 PDF 服务
|
apps/server/ HTTP API、主题读取和 Playwright PDF 服务
|
||||||
|
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
|
||||||
|
packages/application/ 共享应用服务、主题注册和图片资源处理
|
||||||
packages/core/ 导出配置和主题清单等共享模型
|
packages/core/ 导出配置和主题清单等共享模型
|
||||||
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
packages/renderer/ Markdown 到安全 HTML 的渲染管线
|
||||||
themes/ 内置主题及未来可安装主题
|
packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
|
||||||
docker/ 容器化配置
|
packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||||
tests/ 跨包和端到端测试
|
themes/ 内置主题
|
||||||
|
deploy/ Docker、Compose、Nginx 和部署文档
|
||||||
docs/ 进度、架构和部署文档
|
docs/ 进度、架构和部署文档
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,19 +144,19 @@ docs/ 进度、架构和部署文档
|
|||||||
|
|
||||||
安装依赖:
|
安装依赖:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
本地开发:
|
本地开发:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
完整验证:
|
完整验证:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
npm test
|
npm test
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run build
|
npm run build
|
||||||
@@ -186,6 +188,11 @@ chore: 初始化项目骨架
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 不修改或重写已有提交,除非用户明确要求。
|
- 不修改或重写已有提交,除非用户明确要求。
|
||||||
|
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
|
||||||
|
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
|
||||||
|
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
|
||||||
|
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
|
||||||
|
记录本版本更新,不得使用只有版本号的标签说明。
|
||||||
|
|
||||||
## 9. 当前交接入口
|
## 9. 当前交接入口
|
||||||
|
|
||||||
|
|||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
# 变更日志
|
||||||
|
|
||||||
|
本文档记录 MorphDoc(原 Markdown PDF 导出器)的重要版本变更。
|
||||||
|
|
||||||
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
|
||||||
|
[Semantic Versioning](https://semver.org/lang/zh-CN/)。发布时以对应版本章节作为
|
||||||
|
Gitea Release 页面正文的基础;构建产物、校验和及详细验收记录可继续写入
|
||||||
|
`docs/releases/v<version>.md`。
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复 macOS 桌面应用未声明 `.md`/`.markdown` 文件关联的问题:`electron-builder`
|
||||||
|
的 `mac` 配置块新增 `fileAssociations`,写入 Info.plist 的
|
||||||
|
`CFBundleDocumentTypes`,Finder"打开方式"中现在能正确列出 MorphDoc;主进程
|
||||||
|
既有的 `open-file` 事件处理无需改动。
|
||||||
|
|
||||||
|
## [0.6.4] - 2026-08-26
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 桌面端新增 macOS 打包能力:`electron-builder` 增加独立 `mac` 配置块,产出
|
||||||
|
`dmg`,按 arm64、x64 分别单独构建,不产出 universal 包(内置完整
|
||||||
|
Chromium/Electron 与固定版本 Pandoc,合并双架构会让单包体积翻倍)。
|
||||||
|
- Pandoc 运行时清单新增 `darwin/arm64`、`darwin/x64` 两个官方发行项及独立
|
||||||
|
许可证文件来源(macOS 官方 zip 本身不随附许可证文本);桌面 Pandoc 候选
|
||||||
|
路径与 `prepare-pandoc-runtime.mjs` 均已泛化为按 `--platform`/`--arch`
|
||||||
|
参数选取目标,不再只认 Windows x64。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复 `apps/desktop/scripts/prepare-pandoc-runtime.mjs` 解压内置 Pandoc 时
|
||||||
|
未保留 Unix 可执行权限位的问题(`unzipSync` 不保留压缩包内的权限信息),
|
||||||
|
macOS/Linux 上解压出的二进制此前无法直接执行。
|
||||||
|
- 修复根 `package.json` 中 `build:web-runtime`、`dev`、`desktop:dev` 三个
|
||||||
|
脚本的构建顺序错误:`@md-to-pdf/application` 依赖
|
||||||
|
`@md-to-pdf/preview-engine`,但被排在其构建之前,在没有历史 `dist/`
|
||||||
|
残留的全新环境(如全新克隆 + 全新 `npm install`)中会导致类型解析失败。
|
||||||
|
- 修正 `AGENTS.md` 中硬编码的 Windows 绝对路径与强制 PowerShell 语法,改为
|
||||||
|
按当前 Shell 与仓库实际位置自适应。
|
||||||
|
- 修正测试用例 `packages/docx-engine/tests/pandoc-runtime.test.ts` 中硬编码
|
||||||
|
反斜杠路径分隔符的断言,避免该测试仅能在 Windows 宿主上通过。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `docx-engine` 包 15 个测试文件、93 项测试通过;类型检查通过。
|
||||||
|
- 本机 macOS(Apple Silicon)实测:`npm run package:mac:arm64` 全链路成功,
|
||||||
|
内置 darwin-arm64 版 Pandoc 3.9.0.2 完成下载、哈希校验与真实 DOCX 冒烟
|
||||||
|
转换;未打包应用(`--dir`)可正常启动,主进程/渲染进程/GPU 进程/网络
|
||||||
|
服务进程均稳定运行,AppleScript 可正常触发退出。实际截图确认窗口、
|
||||||
|
macOS 原生菜单栏、Markdown 编辑器工具栏、中文字体与实时预览(标题、
|
||||||
|
任务列表、表格、行内公式、引用块)均渲染正常。
|
||||||
|
- 补跑跳过真实语料依赖的全部工作区测试与全项目类型检查、生产构建均
|
||||||
|
通过;`test:docx-real-world-corpus` 与 `test:docx-release-gate-suites`
|
||||||
|
依赖 Git 忽略的真实客户文档语料(`tmp/`),本机没有该目录,这两步
|
||||||
|
在当前环境下无法验证,留待接入真实语料的机器上执行。
|
||||||
|
|
||||||
|
### 兼容与部署
|
||||||
|
|
||||||
|
- 本版本仅完成开发基础设施与验证,未构建正式发行文件:没有产出真实签名
|
||||||
|
的 dmg、没有重新构建 Windows 安装包,也没有重新构建 Docker 镜像;正式
|
||||||
|
macOS 发行产物、Windows/Docker 同步验证留待后续门禁阶段完成后一并发布。
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-08-26
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复真实长文档中表格分页后的列宽、偏移和行样式丢失,冻结逻辑表格列轨并将
|
||||||
|
打印几何稳定传递到 Paged.js 分片。
|
||||||
|
- 修复 Markdown 表格中 `<br>` 变体、连续换行、实体和行内代码的 DOCX 翻译,
|
||||||
|
避免换行逃逸到目标单元格段落之外。
|
||||||
|
- 修复全 JSON 围栏缩进、表格末行对齐、代码高亮和行内代码连续性。
|
||||||
|
- 改进 PDF 文本流排序和数值范围归一化,减少窄字符重叠、跨视觉行破折号及重复
|
||||||
|
表头映射造成的视觉比较误报。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 合成基线与长庆真实文档两套严格门禁为 `280/280`,M4N 复杂表格文档为
|
||||||
|
`140/140`。
|
||||||
|
- 健康数据长文档残余误报为 `6/115`(`5.22%`),均核查为重复表头自动对齐或
|
||||||
|
取样错配,基础设施错误为 0。
|
||||||
|
- 全项目测试、类型检查、生产构建、正式 Docker、内置字体 NSIS/ZIP 和离线发行
|
||||||
|
归集通过。
|
||||||
|
|
||||||
|
### 发行与兼容性
|
||||||
|
|
||||||
|
- Desktop 安装器、免安装 ZIP 与 Docker 镜像统一为 0.6.2。
|
||||||
|
- Docker 继续内置固定 Chromium、Pandoc 3.9.0.2 和受校验字体包;Desktop
|
||||||
|
继续直接内置 Serif、Sans、Mono 字体,无需安装系统字体。
|
||||||
|
- Windows 安装器尚未进行 Authenticode 签名,仅适用于公司内部发布。
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-08-04
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 重构通用 CSS 到 WordprocessingML 翻译链,统一字体、字号、行距、段距、缩进、
|
||||||
|
边框、底纹、表格、引用和代码样式。
|
||||||
|
- 修复字体实际嵌入、中文标点字距、精确行距、打印媒体环境和 Markdown 行内代码
|
||||||
|
连续性问题。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 建立 14 套主题 × 两个方向 × 五组页边距的 140 场景视觉矩阵,`140/140`
|
||||||
|
全部通过。
|
||||||
|
- 四套独立封面执行 Chromium、Word、WPS 整页严格门禁,正文执行与物理分页无关
|
||||||
|
的语义块门禁。
|
||||||
|
- 源码服务、Docker Web API 和实际安装 Desktop 三条生产 DOCX 导出链通过。
|
||||||
|
|
||||||
|
### 发行与兼容性
|
||||||
|
|
||||||
|
- Serif、Sans、Mono 三套字体直接内置于 Desktop 安装器和免安装 ZIP,不再发布
|
||||||
|
独立字体安装器或字体 ZIP。
|
||||||
|
- Docker 内置固定 Chromium、Pandoc 3.9.0.2 和三套正式字体包。
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-08-02
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增原生可编辑 DOCX 导出,支持 Microsoft Word 与 WPS,并保留纸张、页边距、
|
||||||
|
字体、段落、标题、代码块、表格、媒体、封面、页眉页脚和页码配置。
|
||||||
|
- 建立通用主题到 DOCX 样式翻译引擎,覆盖 14 套内置主题。
|
||||||
|
- 新增可扩展 Markdown 工具栏,支持标题、强调、代码、引用、列表、任务列表、
|
||||||
|
分隔线及 M×N 表格。
|
||||||
|
- PDF 与 DOCX 统一收口至“导出”菜单,并提供生成进度提示。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 产品更名为“墨呈”,英文名 MorphDoc,并保留原 NSIS 安装身份以支持覆盖升级。
|
||||||
|
- Desktop 内置固定 Pandoc 3.9.0.2;Docker 内置 Chromium、Pandoc 和正式字体,
|
||||||
|
支持完全离线部署。
|
||||||
|
- 正式字体资产改由 Git LFS 管理,发行产物统一归集到 `release/v<version>/`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复精确预览放大后的右侧留白、保存与导出默认路径、Desktop DOCX 媒体捕获
|
||||||
|
超时及品牌迁移后的安装目录问题。
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-07-29
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增 4 套红头、3 套正式文档和 3 套标书主题,内置主题总数增至 14 套。
|
||||||
|
- 支持主题推荐页边距、页面装饰、页眉页脚、页码和结构化公文、报告及标书
|
||||||
|
Front Matter。
|
||||||
|
- 内置 Fandol 中文字体、两份教程和 14 份主题示例。
|
||||||
|
- Desktop 增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复红头标题居中、正式文档字体、代码块长内容越界和大型代码块跨页留白。
|
||||||
|
- Docker Web 镜像补齐 `samples` 目录。
|
||||||
|
|
||||||
|
### 兼容性
|
||||||
|
|
||||||
|
- 未声明主题推荐设置的主题继续使用原有 16mm 默认页边距。
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-07-28
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与
|
||||||
|
Electron PDF 渲染链路。
|
||||||
|
- 新增连续预览和修改位置之后的增量分页,支持稳定前缀复用。
|
||||||
|
- Desktop 支持多窗口、同文件单例、本地路径与 Markdown 链接,以及聚焦时检测
|
||||||
|
外部文件变化。
|
||||||
|
- Web 统一处理文档锚点和 HTTP/HTTPS 外链。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 内置主题统一为 Typora Github、Typora Pixyll、Typora whitey 和 Typora Clean。
|
||||||
|
- Windows 安装包统一使用 NSIS,并提供免安装 ZIP。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块样式和发行包
|
||||||
|
复用陈旧 Web 构建产物的问题。
|
||||||
|
|
||||||
|
## [0.4.5] - 2026-07-28
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- Web 与 Desktop 支持新建和保存 Markdown;Desktop 增加 `.md`、`.markdown`
|
||||||
|
文件关联、单实例系统打开、自定义主题目录和窗口状态恢复。
|
||||||
|
- 新文档可按一级标题或首行生成合法文件名,并直接导出 PDF。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 图片、Mermaid 和 ECharts 改为按文档顺序串行执行媒体分页回填,每个媒体元素
|
||||||
|
至多重排一次,并保持标题原尺寸。
|
||||||
|
- Web 取消本地素材目录入口。
|
||||||
|
|
||||||
|
## [0.4.4] - 2026-07-27
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- Desktop 发行包仅保留简体中文和英文语言资源,显著缩减目录版、安装包和 ZIP
|
||||||
|
体积,同时保留 Chromium 图形与软件渲染后备组件。
|
||||||
|
|
||||||
|
## [0.4.3] - 2026-07-27
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- Windows Desktop 从 Squirrel 迁移到标准 NSIS 安装向导,支持安装范围和安装目录
|
||||||
|
选择。
|
||||||
|
- 安装包改用英文、无空格、包含版本与平台的文件名,并继续提供免安装 ZIP。
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-07-27
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 移除 Windows Desktop 原生应用菜单栏,使应用内容直接位于系统标题栏下方。
|
||||||
|
- 此版本仅更新 Desktop,Web 与 Docker Compose 继续保持 0.4.1。
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-07-27
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 支持 Web 素材目录、Desktop 同目录资源和受限公网图片下载。
|
||||||
|
- 支持图片标题、图片整块分页以及超高图片在单页内容区内自适应缩放。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修复本地与网络图片的资源解析、自然尺寸解码和 PDF 分页边界问题。
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-07-27
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 首次发布 Electron Windows Desktop,复用与 Web 相同的渲染和 PDF 核心。
|
||||||
|
- 支持桌面 Markdown 文件选择、原生保存和离线 PDF 导出。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 固定桌面端英文可执行文件名,避免中文名称影响安装和自动化链路。
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-07-27
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 扩展 ECharts 第二阶段图表协议和示例覆盖,完善更多系列、坐标轴及组合图表。
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-07-27
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 新增 ECharts YAML 围栏、安全校验、浏览器 SVG 渲染和 PDF 输出。
|
||||||
|
- 增强编辑区折叠、页码导航和快速预览外层滚动交互。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- ECharts 媒体块参与统一不可跨页处理,避免图表被分页裁断。
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-07-27
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 完善 Mermaid 配置、安全渲染、资源等待和预览缩放。
|
||||||
|
- 增强长文档分页、滚动同步和预览/PDF 边界处理。
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 收敛 Web 预览和 Chromium PDF 的共享渲染抽象,减少链路分叉。
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-26
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 实现 Markdown 安全渲染核心、网页实时预览、本地主题兼容和导出设置。
|
||||||
|
- 支持真实分页预览、固定 Chromium PDF 导出和 PDF 精确预览。
|
||||||
|
- 支持长文档分页、页码导航、滚动同步以及 AIO Docker 容器部署。
|
||||||
|
|
||||||
|
[Unreleased]: https://gitea.rk-health.com/yixiong/MorphDoc/compare/v0.6.4...main
|
||||||
|
[0.6.4]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.4
|
||||||
|
[0.6.2]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.2
|
||||||
|
[0.6.1]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.1
|
||||||
|
[0.6.0]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.0
|
||||||
|
[0.5.1]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.5.1
|
||||||
|
[0.5.0]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.5.0
|
||||||
|
[0.4.5]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.5
|
||||||
|
[0.4.4]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.4.4
|
||||||
|
[0.4.3]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.4.3
|
||||||
|
[0.4.2]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.2
|
||||||
|
[0.4.1]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.1
|
||||||
|
[0.4.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.0
|
||||||
|
[0.3.1]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.3.1
|
||||||
|
[0.3.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.3.0
|
||||||
|
[0.2.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.2.0
|
||||||
|
[0.1.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.1.0
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
YIXIONG Tech.ltd 公司内部专属软件许可证
|
||||||
|
版本 1.0,2026 年
|
||||||
|
|
||||||
|
版权所有 © 2026 YIXIONG Tech.ltd。保留所有权利。
|
||||||
|
|
||||||
|
一、适用范围
|
||||||
|
|
||||||
|
本许可证适用于“墨呈(MorphDoc)”项目中由 YIXIONG Tech.ltd 拥有
|
||||||
|
著作权的源代码、原创主题、原创文档、构建脚本和其他原创材料(以下简称
|
||||||
|
“本软件”)。
|
||||||
|
|
||||||
|
本软件包含或调用的第三方程序、依赖、字体、主题和其他材料不受本许可证
|
||||||
|
重新许可,继续适用其各自的许可证、授权条件和使用边界。
|
||||||
|
|
||||||
|
二、授权对象
|
||||||
|
|
||||||
|
“内部授权用户”是指经 YIXIONG Tech.ltd 明确授权,并且属于以下范围的
|
||||||
|
个人或组织:
|
||||||
|
|
||||||
|
1. YIXIONG Tech.ltd 的员工;
|
||||||
|
2. YIXIONG Tech.ltd 直接或间接控制的组织;
|
||||||
|
3. 因内部项目需要而获得明确书面授权的承包商、供应商或合作人员。
|
||||||
|
|
||||||
|
授权在相关人员不再属于上述范围、授权被撤销或合作关系终止时结束,但
|
||||||
|
依法或书面约定需要继续履行的保密、知识产权和责任条款不因此失效。
|
||||||
|
|
||||||
|
三、允许的行为
|
||||||
|
|
||||||
|
在遵守本许可证、公司制度和适用法律的前提下,内部授权用户可以:
|
||||||
|
|
||||||
|
1. 为公司内部业务、测试、研发、评估和运维目的运行本软件;
|
||||||
|
2. 阅读、复制和修改本软件源代码;
|
||||||
|
3. 在公司控制或明确授权的设备、服务器、容器和内部网络中部署本软件;
|
||||||
|
4. 在内部授权用户之间分发源代码、Docker 镜像、安装包、压缩包和测试
|
||||||
|
产物;
|
||||||
|
5. 为内部使用目的制作和分发修改版本;
|
||||||
|
6. 使用本软件处理合法取得的 Markdown、图片、主题和其他文档资源。
|
||||||
|
|
||||||
|
四、禁止的行为
|
||||||
|
|
||||||
|
未经 YIXIONG Tech.ltd 事先明确书面许可,任何人不得:
|
||||||
|
|
||||||
|
1. 向公司外部或公开网络发布、上传、分发、提供下载或公开展示本软件的
|
||||||
|
源代码、二进制、镜像、安装包或实质性部分;
|
||||||
|
2. 出售、出租、转授权、托管经营或以本软件向公司外部提供商业服务;
|
||||||
|
3. 将本软件或修改版本置于开源许可证、公共领域声明或其他与本许可证
|
||||||
|
冲突的授权条件下;
|
||||||
|
4. 删除或隐瞒版权、许可证、来源、第三方声明或其他权利标识;
|
||||||
|
5. 超出第三方材料自身许可证或授权边界使用、复制或分发第三方材料;
|
||||||
|
6. 使用本软件侵犯他人知识产权、隐私、商业秘密或其他合法权益;
|
||||||
|
7. 规避访问控制、安全限制、资源隔离或审计措施。
|
||||||
|
|
||||||
|
五、修改与贡献
|
||||||
|
|
||||||
|
内部授权用户为公司项目提交的修改、补丁、文档和其他贡献,按照其劳动、
|
||||||
|
合作、保密、知识产权归属协议及公司制度处理。本许可证本身不改变已经
|
||||||
|
存在的职务成果、委托开发或合作开发权利归属。
|
||||||
|
|
||||||
|
未经 YIXIONG Tech.ltd 明确书面决定,内部修改或贡献不会使本软件自动
|
||||||
|
转为开源软件,也不会产生向公司外部公开源代码的义务。
|
||||||
|
|
||||||
|
六、用户文档与导出结果
|
||||||
|
|
||||||
|
用户输入的 Markdown、图片、数据和其他内容,其权利归原权利人所有。
|
||||||
|
使用本软件生成的 PDF、DOCX、图片或其他导出结果,不会仅因使用本软件而
|
||||||
|
自动受本许可证约束。
|
||||||
|
|
||||||
|
导出结果中嵌入的字体、图片、模板、主题或其他第三方材料,仍可能受其
|
||||||
|
各自许可证或授权条件约束,使用者应自行确保拥有相应权利。
|
||||||
|
|
||||||
|
七、第三方材料
|
||||||
|
|
||||||
|
本软件的第三方许可证与特殊分发边界记录在 `THIRD_PARTY_NOTICES.md`
|
||||||
|
以及相关资源目录的许可证文件中。若本许可证与第三方材料的有效许可证
|
||||||
|
发生冲突,对该第三方材料以其自身许可证为准。
|
||||||
|
|
||||||
|
八、未来公开许可
|
||||||
|
|
||||||
|
YIXIONG Tech.ltd 可以在未来自行决定以 Apache License 2.0、其他开源
|
||||||
|
许可证或其他授权方式发布本软件的某个版本。任何未来公开许可仅自明确
|
||||||
|
发布之日起适用于指定版本,不追溯授予当前内部版本,也不自动覆盖第三方
|
||||||
|
材料。
|
||||||
|
|
||||||
|
在公开许可正式发布前,本软件不是面向公众的开源软件。源代码可在公司
|
||||||
|
内部查看和修改,不等于授予公众使用、复制、修改或分发权。
|
||||||
|
|
||||||
|
九、免责声明
|
||||||
|
|
||||||
|
在适用法律允许的最大范围内,本软件按“现状”提供,不附带任何明示或
|
||||||
|
默示保证,包括但不限于适销性、特定用途适用性、无侵权、持续可用或数据
|
||||||
|
完整性保证。
|
||||||
|
|
||||||
|
十、责任限制
|
||||||
|
|
||||||
|
在适用法律允许的最大范围内,YIXIONG Tech.ltd 及贡献者不对因使用、
|
||||||
|
无法使用、修改、部署或分发本软件导致的任何间接、附带、特殊、惩罚性或
|
||||||
|
后果性损失承担责任。公司内部另有有效合同、制度或书面约定的,从其约定。
|
||||||
|
|
||||||
|
十一、终止
|
||||||
|
|
||||||
|
违反本许可证的授权自动终止。YIXIONG Tech.ltd 有权要求停止使用、删除
|
||||||
|
未经授权的副本,并依据适用法律、合同、保密协议和公司制度追究责任。
|
||||||
|
|
||||||
|
十二、其他
|
||||||
|
|
||||||
|
本许可证未明确授予的权利均由 YIXIONG Tech.ltd 保留。对本许可证的任何
|
||||||
|
例外、补充或变更必须由 YIXIONG Tech.ltd 以明确书面形式作出。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
墨呈(MorphDoc)
|
||||||
|
Copyright 2026 YIXIONG Tech.ltd
|
||||||
|
|
||||||
|
This product includes software developed by YIXIONG Tech.ltd and
|
||||||
|
contributors.
|
||||||
|
|
||||||
|
The original project code and documentation are currently licensed only for
|
||||||
|
authorized internal use under the proprietary LICENSE in this repository.
|
||||||
|
Third-party components and assets remain under their respective licenses.
|
||||||
|
See THIRD_PARTY_NOTICES.md.
|
||||||
@@ -1,25 +1,115 @@
|
|||||||
# Markdown PDF 导出器
|
# 墨呈
|
||||||
|
|
||||||
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。
|
> 一稿多式,即写即呈
|
||||||
|
|
||||||
## 当前状态
|
面向结构化 Markdown 的主题化文档创作与发布工具,同时提供 Web 与
|
||||||
|
Windows 桌面端。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题
|
||||||
|
CSS 和分页运行时,DOCX 使用同源语义文档、主题令牌和可编辑 OOXML。
|
||||||
|
|
||||||
已经实现:
|
当前版本:`v0.6.0`。
|
||||||
|
|
||||||
- React Markdown 编辑与本地文件读取;
|
## v0.6.0 核心更新
|
||||||
- Fastify 预览接口;
|
|
||||||
- 共享导出配置模型;
|
|
||||||
- 动态主题清单、CSS 和静态资源接口;
|
|
||||||
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
|
||||||
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
|
||||||
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
|
|
||||||
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
|
|
||||||
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
|
|
||||||
- 快速预览与精确预览共用的 50%~400% 显示缩放;
|
|
||||||
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
|
|
||||||
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
|
|
||||||
|
|
||||||
本地相对资源文件处理将在后续阶段实现。
|
- 新增可编辑 DOCX 导出,使用固定 Pandoc、动态 Word 样式、原生分节、
|
||||||
|
页眉页脚、页码和高分辨率 PNG 媒体管线;
|
||||||
|
- 主题 CSS 通过通用语义槽位翻译为 Word 样式,支持 14 套内置主题、
|
||||||
|
公文结构、独立封面、代码、表格、图片、Mermaid 与 ECharts;
|
||||||
|
- 新增 CodeMirror 6 Markdown 工具栏,支持标题、行内格式、引用、列表、
|
||||||
|
代码块、分隔线和尺寸表格;
|
||||||
|
- 产品品牌升级为“墨呈”,Windows 主程序和发行产物使用英文名
|
||||||
|
`MorphDoc`。
|
||||||
|
|
||||||
|
## v0.5.1 主题与公文能力
|
||||||
|
|
||||||
|
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
|
||||||
|
以及 3 套正式文档主题和 3 套标书主题;
|
||||||
|
- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
|
||||||
|
16mm 默认页边距,选择正式主题时自动采用其规范参数;
|
||||||
|
- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
|
||||||
|
签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
|
||||||
|
- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
|
||||||
|
字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
|
||||||
|
同时保留 `8px` 左侧内容留白;
|
||||||
|
- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown;
|
||||||
|
Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
|
||||||
|
- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
|
||||||
|
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
|
||||||
|
Web 端隐藏不适用的“另存为”。
|
||||||
|
|
||||||
|
## Web 与桌面端
|
||||||
|
|
||||||
|
| 能力 | Web / Docker | Windows Desktop |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新建 Markdown | 支持 | 支持 |
|
||||||
|
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
|
||||||
|
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
|
||||||
|
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
|
||||||
|
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
|
||||||
|
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
|
||||||
|
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
|
||||||
|
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
|
||||||
|
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
|
||||||
|
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
|
||||||
|
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
|
||||||
|
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
|
||||||
|
| DOCX 引擎 | 固定版本 Pandoc | 内置固定版本 Pandoc |
|
||||||
|
|
||||||
|
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
|
||||||
|
Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。
|
||||||
|
|
||||||
|
## 渲染架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
|
||||||
|
B --> C["统一 #write HTML"]
|
||||||
|
D["主题 CSS 与导出配置"] --> C
|
||||||
|
C --> E["共享 Preview Engine"]
|
||||||
|
E --> F["连续预览"]
|
||||||
|
E --> G["快速分页预览"]
|
||||||
|
E --> H["Playwright Chromium"]
|
||||||
|
E --> I["Electron Chromium"]
|
||||||
|
H --> J["Web PDF"]
|
||||||
|
I --> K["Desktop PDF"]
|
||||||
|
```
|
||||||
|
|
||||||
|
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
|
||||||
|
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
|
||||||
|
载荷和运行时。
|
||||||
|
|
||||||
|
## 项目目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
|
||||||
|
server/ Fastify API、Playwright PDF 与容器运行入口
|
||||||
|
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
|
||||||
|
packages/
|
||||||
|
core/ 跨端文档、导出配置和主题协议
|
||||||
|
renderer/ Markdown 到安全 #write HTML 的渲染管线
|
||||||
|
application/ 共享渲染、主题注册和图片资源应用服务
|
||||||
|
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
|
||||||
|
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
|
||||||
|
themes/ 内置主题与主题清单
|
||||||
|
deploy/ Dockerfile、Compose、Nginx 和部署文档
|
||||||
|
docs/ 项目进度与主题开发说明
|
||||||
|
scripts/ 主题导入等维护脚本
|
||||||
|
logos/ 应用图标源文件
|
||||||
|
.local/themes/ 本地开发和 Compose 自定义主题目录
|
||||||
|
```
|
||||||
|
|
||||||
|
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
|
||||||
|
提供独立图表能力;`renderer` 消费两者生成安全 HTML;`application`
|
||||||
|
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
|
||||||
|
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
|
||||||
|
|
||||||
|
各包的目录和用法:
|
||||||
|
|
||||||
|
- [application](packages/application/README.md)
|
||||||
|
- [core](packages/core/README.md)
|
||||||
|
- [renderer](packages/renderer/README.md)
|
||||||
|
- [markdown-echarts](packages/markdown-echarts/README.md)
|
||||||
|
- [preview-engine](packages/preview-engine/README.md)
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -32,110 +122,70 @@ 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` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
|
桌面端开发:
|
||||||
|
|
||||||
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
|
```powershell
|
||||||
|
npm run desktop:dev
|
||||||
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
|
|
||||||
|
|
||||||
## Mermaid 配置与样式
|
|
||||||
|
|
||||||
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
|
|
||||||
|
|
||||||
- 布局:Dagre、ELK;
|
|
||||||
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
|
|
||||||
- 外观:Classic、Hand-drawn、Neo;
|
|
||||||
- 字体:任意本地 CSS 字体族。
|
|
||||||
|
|
||||||
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
layout: elk
|
|
||||||
theme: base
|
|
||||||
look: handDrawn
|
|
||||||
fontFamily: Microsoft YaHei
|
|
||||||
themeVariables:
|
|
||||||
primaryColor: "#dbeafe"
|
|
||||||
primaryBorderColor: "#2563eb"
|
|
||||||
flowchart:
|
|
||||||
wrappingWidth: 360
|
|
||||||
---
|
|
||||||
flowchart TB
|
|
||||||
classDef important fill:#fee2e2,stroke:#dc2626
|
|
||||||
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
|
|
||||||
style A fill:#dcfce7,stroke:#16a34a
|
|
||||||
class B important
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。
|
桌面端目录包和 Windows 安装包:
|
||||||
|
|
||||||
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。
|
```powershell
|
||||||
|
npm run desktop:package
|
||||||
### Mermaid 长标签
|
npm run make -w @md-to-pdf/desktop
|
||||||
|
|
||||||
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
|
|
||||||
|
|
||||||
需要自动换行时可以使用 Mermaid Markdown String:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`:
|
桌面 `package` 和 `make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并
|
||||||
|
校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的
|
||||||
|
`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`。
|
||||||
|
正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
|
||||||
|
未配置代码签名。
|
||||||
|
|
||||||
````markdown
|
## 文档与媒体
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["较长节点"] --> B["普通节点"]
|
|
||||||
classDef compact font-size:12px;
|
|
||||||
class A compact;
|
|
||||||
```
|
|
||||||
````
|
|
||||||
|
|
||||||
## 本地导入 Typora 默认主题
|
Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、
|
||||||
|
KaTeX、Mermaid 和安全的 ECharts YAML 围栏。
|
||||||
|
|
||||||
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录:
|
独立一行的 `` 会生成带标题的图片块。分页引擎将图片、
|
||||||
|
Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行
|
||||||
|
计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时
|
||||||
|
只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免
|
||||||
|
跨页、重复重排和后续媒体顺序变化。
|
||||||
|
|
||||||
|
ECharts 围栏语法、支持系列和安全边界详见
|
||||||
|
[markdown-echarts 包文档](packages/markdown-echarts/README.md)。
|
||||||
|
|
||||||
|
## 主题
|
||||||
|
|
||||||
|
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
|
||||||
|
3 套标书主题,默认使用 GitHub。主题由版本化
|
||||||
|
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
|
||||||
|
|
||||||
|
- 本地开发与 Compose:将主题目录放入 `.local/themes/`;
|
||||||
|
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
|
||||||
|
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
|
||||||
|
|
||||||
|
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
|
||||||
|
刷新本地开发副本:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run theme:import-typora
|
npm run theme:import-typora
|
||||||
```
|
```
|
||||||
|
|
||||||
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。
|
## Docker Compose 部署
|
||||||
|
|
||||||
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm run theme:import-typora -- --replace
|
|
||||||
```
|
|
||||||
|
|
||||||
旧副本会移动到 `.local\theme-backups`。
|
|
||||||
|
|
||||||
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
|
|
||||||
|
|
||||||
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
|
|
||||||
|
|
||||||
## Docker Desktop 部署
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f deploy\compose.yaml up --build -d
|
docker compose -f deploy\compose.yaml up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes`
|
默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
|
||||||
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见
|
配置详见 [AIO 容器部署说明](deploy/README.md)。
|
||||||
[AIO 容器部署说明](deploy/README.md)。
|
|
||||||
|
|
||||||
## 测试与构建
|
## 验证
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm test
|
npm test
|
||||||
@@ -144,7 +194,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)。
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 第三方许可证与公开发布说明
|
||||||
|
|
||||||
|
根目录 `LICENSE` 是当前公司内部专属许可证,适用于本项目原创代码、原创
|
||||||
|
主题和原创文档,不会替换第三方组件、字体、主题或工具各自的许可证。
|
||||||
|
|
||||||
|
Apache License 2.0 仅是未来公开 GitHub 版本的候选许可证。当前仓库没有
|
||||||
|
通过 Apache License 2.0 向公司外部授予项目原创内容的使用或分发权。
|
||||||
|
|
||||||
|
本文件是发行审查入口,不替代各第三方项目附带的完整许可证。正式发行前
|
||||||
|
还应根据最终生产依赖生成完整的软件物料清单和许可证清单。
|
||||||
|
|
||||||
|
## Pandoc
|
||||||
|
|
||||||
|
`v0.6.0` 将未经修改的 Pandoc 作为独立可执行程序用于 DOCX 转换。
|
||||||
|
|
||||||
|
- 项目:Pandoc
|
||||||
|
- 版权:John MacFarlane 及贡献者
|
||||||
|
- 许可证:GNU General Public License,version 2 or later
|
||||||
|
- 交互方式:独立子进程、命令行参数和临时文档文件
|
||||||
|
- 项目地址:<https://pandoc.org/>
|
||||||
|
- 源码地址:<https://github.com/jgm/pandoc>
|
||||||
|
|
||||||
|
项目不链接、导入或修改 Pandoc 程序代码。发行包必须保留 Pandoc 的
|
||||||
|
`COPYING`、`COPYRIGHT` 和版本信息,并在相同版本的发行位置提供对应源码
|
||||||
|
获取方式。具体版本、校验值和分发规则见
|
||||||
|
[`docs/PANDOC_DISTRIBUTION.md`](docs/PANDOC_DISTRIBUTION.md)。
|
||||||
|
|
||||||
|
## Fandol 字体
|
||||||
|
|
||||||
|
`themes/_shared/official-fonts/` 包含 Fandol 中文字体的 WOFF2 转换版本。
|
||||||
|
Fandol 字体采用 GNU GPL,并附带字体例外条款。完整许可证、上游说明、
|
||||||
|
文件哈希和来源信息保存在该目录:
|
||||||
|
|
||||||
|
- [`LICENSE.txt`](themes/_shared/official-fonts/LICENSE.txt)
|
||||||
|
- [`README.md`](themes/_shared/official-fonts/README.md)
|
||||||
|
- [`UPSTREAM-README.txt`](themes/_shared/official-fonts/UPSTREAM-README.txt)
|
||||||
|
|
||||||
|
使用字体生成的 PDF、图片或 DOCX 不会仅因为使用该字体而自动受 GPL
|
||||||
|
约束。重新分发字体时必须保留上述许可证和来源文件。
|
||||||
|
|
||||||
|
## npm、Electron 与 Chromium 组件
|
||||||
|
|
||||||
|
项目直接或间接使用 MIT、Apache-2.0、BSD、ISC、EPL-2.0、MPL-2.0、
|
||||||
|
CC-BY-4.0 等许可证的软件包。`package-lock.json` 固定具体依赖版本;
|
||||||
|
Electron 和 Chromium 发行目录还包含其自身第三方许可证文件。
|
||||||
|
|
||||||
|
构建产物不得删除 Electron、Chromium 或其他依赖已经附带的许可证文件。
|
||||||
|
正式发布阶段应基于生产依赖和浏览器 Bundle 生成最终第三方清单,而不能
|
||||||
|
仅以根目录项目许可证代替。
|
||||||
|
|
||||||
|
## 仅限公司内部使用的 Typora 资源
|
||||||
|
|
||||||
|
以下目录来自公司内部安装的 Typora 1.14.7,目前没有确认可用于公开再
|
||||||
|
分发的完整授权:
|
||||||
|
|
||||||
|
```text
|
||||||
|
themes/typora-github/
|
||||||
|
themes/typora-pixyll/
|
||||||
|
themes/typora-whitey/
|
||||||
|
```
|
||||||
|
|
||||||
|
它们不受本项目内部专属许可证重新授权。即使其中部分样式可追溯到 MIT
|
||||||
|
上游,也不能据此推定 Typora 修改版本整体可以公开分发。
|
||||||
|
|
||||||
|
当前项目属于公司内部源码开放和内部部署阶段。根据现有内部使用决策,
|
||||||
|
公司内部的源码仓库、Web/Docker 部署、Desktop 安装包、ZIP 和测试产物
|
||||||
|
可以继续携带这三套主题,但不得向公司外部或公开网络发布、销售或授权。
|
||||||
|
接收内部发行物的人员也不能依据根目录项目许可证对这三套主题进行公司外
|
||||||
|
再分发。
|
||||||
|
|
||||||
|
在将项目发布到公开 GitHub 仓库前,必须完成以下方案之一:
|
||||||
|
|
||||||
|
1. 取得明确的公开分发授权;
|
||||||
|
2. 使用许可证明确的上游资源重新实现并完成来源审查;
|
||||||
|
3. 删除这些目录,并从准备公开的 Git 历史中清除其内容。
|
||||||
|
|
||||||
|
仅在后续提交中删除目录仍会使旧版本存在于 Git 历史。若不能取得授权,
|
||||||
|
公开发布应使用经过清理的历史或从合规快照创建新的公开仓库。上述门禁
|
||||||
|
只针对公司外部和公开网络发行,不阻止当前公司内部源码共享、部署和
|
||||||
|
安装包分发。
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Desktop 桌面端
|
||||||
|
|
||||||
|
`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
|
||||||
|
preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
|
||||||
|
本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
|
||||||
|
安装包。
|
||||||
|
|
||||||
|
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
|
||||||
|
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
|
||||||
|
落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联,
|
||||||
|
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
|
||||||
|
一个窗口,其他文件可以同时使用多个窗口。
|
||||||
|
|
||||||
|
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
|
||||||
|
重新加载,避免阅读或导出磁盘旧版本。
|
||||||
|
|
||||||
|
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
|
||||||
|
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
|
||||||
|
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
|
||||||
|
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
|
||||||
|
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
从仓库根目录运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run desktop:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
桌面端不启动 Fastify,也不占用本地 HTTP 端口。React 界面通过受限 IPC
|
||||||
|
调用 `packages/application` 中的 Markdown 渲染和主题服务,PDF 由
|
||||||
|
Electron 自带 Chromium 生成。Web 与 Docker 继续通过 Fastify HTTP
|
||||||
|
适配器使用同一套应用服务。
|
||||||
|
|
||||||
|
## 构建与打包
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build -w @md-to-pdf/desktop
|
||||||
|
npm run desktop:package
|
||||||
|
npm run make -w @md-to-pdf/desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
`package` 和 `make` 会先调用根级 `build:web-runtime`,完整重建
|
||||||
|
Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web,
|
||||||
|
再按固定清单下载或复用 Pandoc `3.9.0.2` 缓存、校验官方 ZIP 的
|
||||||
|
SHA-256,并执行版本与最小 DOCX 冒烟,最后才调用 electron-builder。
|
||||||
|
不得绕过该链路直接调用 electron-builder。
|
||||||
|
|
||||||
|
Pandoc 构建缓存位于被 Git 忽略的 `apps/desktop/.runtime/`。发行目录只
|
||||||
|
包含 `pandoc.exe`、`COPYING.rtf`、`COPYRIGHT.txt` 和生成的审计清单,
|
||||||
|
不会携带官方 MSI、手册、下载缓存或用户级 Pandoc 配置。已准备运行时可用
|
||||||
|
以下命令离线复验:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run verify:pandoc-runtime -w @md-to-pdf/desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||||
|
`apps/desktop/out/v0.6.4/`。该目录被 Git 忽略。公司内部分发以
|
||||||
|
`MorphDoc-0.6.4-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||||
|
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||||
|
提示。
|
||||||
|
|
||||||
|
Serif、Sans、Mono 三套正式字体随 NSIS 安装包和便携 ZIP 直接进入
|
||||||
|
`resources/font-packs`,由应用私有加载,不写入 Windows 系统字体目录。
|
||||||
|
安装向导不再提供字体选择页,正式发行也不附带独立字体安装器或字体 ZIP。
|
||||||
|
|
||||||
|
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
|
||||||
|
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
|
||||||
|
发布,不提交进 Git 历史。
|
||||||
|
|
||||||
|
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
|
||||||
|
“运行”操作直接启动 `MorphDoc.exe`,不依赖快捷方式文件。
|
||||||
|
|
||||||
|
界面、快捷方式与卸载项使用中文名“墨呈”,窗口标题为
|
||||||
|
“墨呈 · MorphDoc”,默认安装目录和 Windows 主程序分别为 `MorphDoc`
|
||||||
|
和 `MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保
|
||||||
|
v0.5.1 覆盖升级沿用原卸载身份;v0.6.0 使用独立的安装位置注册表键,
|
||||||
|
升级时由旧版卸载器清理 `md-to-pdf` 目录,再安装到 `MorphDoc` 目录,
|
||||||
|
避免产品改名后继续继承旧目录名。
|
||||||
|
|
||||||
|
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium;
|
||||||
|
Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。
|
||||||
|
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||||
|
`mdpdf://bundle/` 自定义协议加载。
|
||||||
|
|
||||||
|
## 链接与窗口行为
|
||||||
|
|
||||||
|
- 当前文档 `#anchor` 在当前预览中定位;
|
||||||
|
- HTTP/HTTPS 链接交给系统默认浏览器;
|
||||||
|
- `mailto:` 等系统协议交给操作系统处理;
|
||||||
|
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
|
||||||
|
- Markdown 文件会提示在当前窗口或新窗口打开;
|
||||||
|
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
|
||||||
|
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
|
||||||
|
|
||||||
|
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||||
|
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||||
|
- 启用 Context Isolation 和 Chromium Sandbox;
|
||||||
|
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
|
||||||
|
的窄接口;
|
||||||
|
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
|
||||||
|
链接越界;
|
||||||
|
- 主进程校验 IPC 发送者和分页载荷;
|
||||||
|
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
|
||||||
|
- 应用页面使用自定义协议,不使用 `file://`。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
!define MORPHDOC_DISPLAY_NAME "墨呈"
|
||||||
|
!define MORPHDOC_EXECUTABLE "MorphDoc.exe"
|
||||||
|
!define MORPHDOC_LEGACY_DISPLAY_NAME "Markdown PDF 导出器"
|
||||||
|
!define MORPHDOC_LEGACY_EXECUTABLE "md-to-pdf.exe"
|
||||||
|
!define MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY "Software\${APP_GUID}"
|
||||||
|
|
||||||
|
; 保持 APP_GUID 与卸载身份不变,但不再沿用旧产品名对应的安装目录。
|
||||||
|
; electron-builder 会从 INSTALL_REGISTRY_KEY 读取历史 InstallLocation;使用
|
||||||
|
; 独立键可让 v0.6.0 默认进入 MorphDoc 目录,同时仍由相同卸载身份识别旧版。
|
||||||
|
!define INSTALL_REGISTRY_KEY "Software\${APP_GUID}.MorphDoc"
|
||||||
|
|
||||||
|
!macro customInstall
|
||||||
|
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown" \
|
||||||
|
"" \
|
||||||
|
"Markdown 文档"
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
|
||||||
|
"" \
|
||||||
|
'"$INSTDIR\${MORPHDOC_EXECUTABLE}",0'
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
|
||||||
|
"" \
|
||||||
|
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\.md\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown" \
|
||||||
|
""
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\.markdown\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown" \
|
||||||
|
""
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\shell\open\command" \
|
||||||
|
"" \
|
||||||
|
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
|
||||||
|
".md" \
|
||||||
|
""
|
||||||
|
WriteRegStr SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
|
||||||
|
".markdown" \
|
||||||
|
""
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
|
||||||
|
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
|
||||||
|
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
|
||||||
|
CreateDirectory "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
|
||||||
|
CreateShortCut \
|
||||||
|
"$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk" \
|
||||||
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
|
||||||
|
"" \
|
||||||
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||||
|
CreateShortCut \
|
||||||
|
"$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk" \
|
||||||
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
|
||||||
|
"" \
|
||||||
|
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||||
|
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
|
||||||
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro customUnInstall
|
||||||
|
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
|
||||||
|
DeleteRegValue SHCTX \
|
||||||
|
"Software\Classes\.md\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown"
|
||||||
|
DeleteRegValue SHCTX \
|
||||||
|
"Software\Classes\.markdown\OpenWithProgids" \
|
||||||
|
"MarkdownPdfExporter.Markdown"
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}"
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
|
||||||
|
DeleteRegKey SHCTX \
|
||||||
|
"Software\Classes\MarkdownPdfExporter.Markdown"
|
||||||
|
Delete "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
|
||||||
|
Delete "$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk"
|
||||||
|
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
|
||||||
|
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
|
||||||
|
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
|
||||||
|
!macroend
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
const { version } = require("./package.json");
|
||||||
|
const brand = require("../../product.json");
|
||||||
|
const pandocRuntime = require(
|
||||||
|
"../../packages/docx-engine/src/pandoc-runtime.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
const windowsIcon = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../logos/desktop/windows/app.ico"
|
||||||
|
);
|
||||||
|
const macIcon = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../logos/desktop/macos/app.icns"
|
||||||
|
);
|
||||||
|
const nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
|
||||||
|
"build/installer.nsh";
|
||||||
|
|
||||||
|
// macOS 每次只构建一个架构的 dmg(arm64 或 x64),不产出 universal 包——
|
||||||
|
// 项目内置完整 Chromium/Electron 运行时和固定版本 Pandoc,合并双架构会让单个
|
||||||
|
// 安装包体积翻倍。目标架构通过环境变量显式声明,用于挑选对应的内置 Pandoc
|
||||||
|
// 资源目录;不依赖 electron-builder 的 `${arch}` 路径宏(该宏在 extraResources
|
||||||
|
// 场景下有已知的可靠性问题)。
|
||||||
|
const macTargetArch = process.env.MD_TO_PDF_MAC_TARGET_ARCH?.trim() || "";
|
||||||
|
if (macTargetArch && macTargetArch !== "arm64" && macTargetArch !== "x64") {
|
||||||
|
throw new Error(
|
||||||
|
`未知 macOS 目标架构:${macTargetArch}(仅支持 arm64 或 x64)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appId: brand.appId,
|
||||||
|
productName: brand.englishName,
|
||||||
|
copyright: "Copyright © YIXIONG Tech.ltd",
|
||||||
|
asar: true,
|
||||||
|
electronLanguages: ["zh-CN", "en-US"],
|
||||||
|
directories: {
|
||||||
|
output: `out/v${version}`
|
||||||
|
},
|
||||||
|
files: [
|
||||||
|
"dist/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
extraResources: [
|
||||||
|
{
|
||||||
|
from: "../web/dist",
|
||||||
|
to: "dist",
|
||||||
|
filter: ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "../../themes",
|
||||||
|
to: "themes",
|
||||||
|
filter: ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "../../samples",
|
||||||
|
to: "samples",
|
||||||
|
filter: ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "../../output/font-packs/root",
|
||||||
|
to: "font-packs",
|
||||||
|
filter: ["**/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: windowsIcon,
|
||||||
|
to: "app.ico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: macIcon,
|
||||||
|
to: "app.icns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
win: {
|
||||||
|
target: [
|
||||||
|
{
|
||||||
|
target: "nsis",
|
||||||
|
arch: ["x64"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "zip",
|
||||||
|
arch: ["x64"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
icon: windowsIcon,
|
||||||
|
executableName: brand.executableName,
|
||||||
|
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`,
|
||||||
|
extraResources: [
|
||||||
|
{
|
||||||
|
from: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||||
|
to: `pandoc/${pandocRuntime.version}/windows-x86_64`,
|
||||||
|
filter: [
|
||||||
|
"pandoc.exe",
|
||||||
|
"COPYING.rtf",
|
||||||
|
"COPYRIGHT.txt",
|
||||||
|
"runtime-manifest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
nsis: {
|
||||||
|
oneClick: false,
|
||||||
|
allowToChangeInstallationDirectory: true,
|
||||||
|
perMachine: false,
|
||||||
|
allowElevation: true,
|
||||||
|
createDesktopShortcut: false,
|
||||||
|
createStartMenuShortcut: false,
|
||||||
|
shortcutName: brand.displayName,
|
||||||
|
uninstallDisplayName: brand.displayName,
|
||||||
|
installerIcon: windowsIcon,
|
||||||
|
uninstallerIcon: windowsIcon,
|
||||||
|
installerLanguages: ["zh_CN"],
|
||||||
|
include: nsisInclude,
|
||||||
|
artifactName: `${brand.artifactPrefix}-${version}-x86_64-Setup.\${ext}`
|
||||||
|
},
|
||||||
|
mac: {
|
||||||
|
category: "public.app-category.productivity",
|
||||||
|
icon: macIcon,
|
||||||
|
executableName: brand.executableName,
|
||||||
|
// 项目当前没有 Apple Developer 证书,显式声明不签名,与 Windows 侧
|
||||||
|
// 一贯记录在案的"未签名"状态保持一致;后续如需公证再补齐。
|
||||||
|
identity: null,
|
||||||
|
target: [
|
||||||
|
{
|
||||||
|
target: "dmg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// 声明 .md/.markdown 文件关联,写入 Info.plist 的 CFBundleDocumentTypes,
|
||||||
|
// 否则 Finder 的"打开方式"里不会出现本应用;与 Windows 侧的 ProgID 注册
|
||||||
|
// 是对等能力,主进程已有的 open-file 事件处理无需改动。
|
||||||
|
fileAssociations: [
|
||||||
|
{
|
||||||
|
ext: ["md", "markdown"],
|
||||||
|
name: "Markdown",
|
||||||
|
description: "Markdown 文档",
|
||||||
|
role: "Editor",
|
||||||
|
icon: macIcon
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extraResources: macTargetArch
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
from: `.runtime/pandoc/${pandocRuntime.version}/darwin-${macTargetArch}`,
|
||||||
|
to: `pandoc/${pandocRuntime.version}/darwin-${macTargetArch}`,
|
||||||
|
filter: [
|
||||||
|
"pandoc",
|
||||||
|
"COPYING.md",
|
||||||
|
"COPYRIGHT",
|
||||||
|
"runtime-manifest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
},
|
||||||
|
dmg: {
|
||||||
|
title: brand.displayName,
|
||||||
|
artifactName: `${brand.artifactPrefix}-${version}-\${arch}.\${ext}`
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@md-to-pdf/desktop",
|
||||||
|
"version": "0.6.4",
|
||||||
|
"private": true,
|
||||||
|
"productName": "MorphDoc",
|
||||||
|
"description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
|
||||||
|
"author": "YIXIONG Tech.ltd",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||||
|
"build:embedded-web": "npm --prefix ../.. run build:web-runtime",
|
||||||
|
"prepare:pandoc": "node scripts/prepare-pandoc-runtime.mjs",
|
||||||
|
"prepare:pandoc:mac-arm64": "node scripts/prepare-pandoc-runtime.mjs --platform=darwin --arch=arm64",
|
||||||
|
"prepare:pandoc:mac-x64": "node scripts/prepare-pandoc-runtime.mjs --platform=darwin --arch=x64",
|
||||||
|
"verify:pandoc-runtime": "node scripts/prepare-pandoc-runtime.mjs --check",
|
||||||
|
"build:main": "node scripts/build-main.mjs",
|
||||||
|
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||||
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
|
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||||
|
"package": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||||
|
"package:mac:arm64": "npm run build:embedded-web && npm run prepare:pandoc:mac-arm64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=arm64 electron-builder --dir --config electron-builder.config.cjs --mac --arm64",
|
||||||
|
"package:mac:x64": "npm run build:embedded-web && npm run prepare:pandoc:mac-x64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=x64 electron-builder --dir --config electron-builder.config.cjs --mac --x64",
|
||||||
|
"make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||||
|
"make:mac:arm64": "npm run build:embedded-web && npm run prepare:pandoc:mac-arm64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=arm64 electron-builder --mac dmg --config electron-builder.config.cjs --arm64",
|
||||||
|
"make:mac:x64": "npm run build:embedded-web && npm run prepare:pandoc:mac-x64 && npm run build && MD_TO_PDF_MAC_TARGET_ARCH=x64 electron-builder --mac dmg --config electron-builder.config.cjs --x64",
|
||||||
|
"make:mac": "npm run make:mac:arm64 && npm run make:mac:x64",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||||
|
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@md-to-pdf/application": "0.4.1",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"electron": "43.2.0",
|
||||||
|
"electron-builder": "26.15.3",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"wait-on": "^9.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { build } from "esbuild";
|
||||||
|
import { copyFile, readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const productBrand = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
fileURLToPath(new URL("../../../product.json", import.meta.url)),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ["src/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
platform: "node",
|
||||||
|
format: "esm",
|
||||||
|
external: ["electron"],
|
||||||
|
outfile: "dist/main.js",
|
||||||
|
define: {
|
||||||
|
__APP_ID__: JSON.stringify(productBrand.appId),
|
||||||
|
__APP_ENGLISH_NAME__: JSON.stringify(productBrand.englishName),
|
||||||
|
__APP_DISPLAY_NAME__: JSON.stringify(productBrand.displayName),
|
||||||
|
__APP_WINDOW_TITLE__: JSON.stringify(productBrand.windowTitle),
|
||||||
|
__APP_LEGACY_DISPLAY_NAME__: JSON.stringify(
|
||||||
|
productBrand.legacyDisplayName
|
||||||
|
)
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
js: [
|
||||||
|
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
|
||||||
|
'import { dirname as __mdToPdfDirname } from "node:path";',
|
||||||
|
'import { fileURLToPath as __mdToPdfFileURLToPath } from "node:url";',
|
||||||
|
"const require = __mdToPdfCreateRequire(import.meta.url);",
|
||||||
|
"const __dirname = __mdToPdfDirname(__mdToPdfFileURLToPath(import.meta.url));"
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await copyFile(
|
||||||
|
fileURLToPath(
|
||||||
|
new URL(
|
||||||
|
"../../../packages/docx-engine/assets/docx-media-filter.lua",
|
||||||
|
import.meta.url
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fileURLToPath(
|
||||||
|
new URL("../dist/docx-media-filter.lua", import.meta.url)
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface MinimalPandocArtifact {
|
||||||
|
executableRelativePath: string;
|
||||||
|
licenseFiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(content: Uint8Array): string;
|
||||||
|
|
||||||
|
export function extractRequiredFiles(
|
||||||
|
archive: Uint8Array,
|
||||||
|
artifact: MinimalPandocArtifact
|
||||||
|
): Map<string, Uint8Array>;
|
||||||
|
|
||||||
|
export function verifyPandocVersion(
|
||||||
|
executablePath: string,
|
||||||
|
expectedVersion: string
|
||||||
|
): void;
|
||||||
|
|
||||||
|
export function preparePandocRuntime(options?: {
|
||||||
|
checkOnly?: boolean;
|
||||||
|
}): Promise<string>;
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { unzipSync } from "fflate";
|
||||||
|
|
||||||
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||||
|
const desktopRoot = path.join(projectRoot, "apps", "desktop");
|
||||||
|
const manifestPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"packages",
|
||||||
|
"docx-engine",
|
||||||
|
"src",
|
||||||
|
"pandoc-runtime.json"
|
||||||
|
);
|
||||||
|
const maximumArchiveBytes = 64 * 1024 * 1024;
|
||||||
|
const maximumExtractedBytes = 256 * 1024 * 1024;
|
||||||
|
const downloadTimeoutMs = 120_000;
|
||||||
|
|
||||||
|
function sha256(content) {
|
||||||
|
return createHash("sha256").update(content).digest("hex").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedBaseName(fileName) {
|
||||||
|
return fileName.replaceAll("\\", "/").split("/").filter(Boolean).at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath) {
|
||||||
|
try {
|
||||||
|
await access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformDirectoryName(platform, architecture) {
|
||||||
|
if (platform === "win32" && architecture === "x64") {
|
||||||
|
return "windows-x86_64";
|
||||||
|
}
|
||||||
|
if (platform === "darwin" && (architecture === "arm64" || architecture === "x64")) {
|
||||||
|
return `darwin-${architecture}`;
|
||||||
|
}
|
||||||
|
throw new Error(`不支持的 Pandoc 桌面内置目标:${platform}/${architecture}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档下载得到的、需要从压缩包内部提取的文件名(不含通过 licenseSource 单独下载的许可证文件)。 */
|
||||||
|
function archiveRequiredNames(artifact) {
|
||||||
|
const licenseSource = artifact.licenseSource ?? {};
|
||||||
|
return [
|
||||||
|
normalizedBaseName(artifact.executableRelativePath),
|
||||||
|
...artifact.licenseFiles
|
||||||
|
.filter((fileName) => !licenseSource[fileName])
|
||||||
|
.map(normalizedBaseName)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRuntimeManifest({ platform, architecture }) {
|
||||||
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||||
|
const artifact = manifest.artifacts?.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.platform === platform && candidate.architecture === architecture
|
||||||
|
);
|
||||||
|
if (!artifact) {
|
||||||
|
throw new Error(`Pandoc 清单缺少 ${platform}/${architecture} 发行项`);
|
||||||
|
}
|
||||||
|
if (artifact.archiveType !== "zip") {
|
||||||
|
throw new Error(`Pandoc 清单要求 ${platform}/${architecture} 为 ZIP 发行项`);
|
||||||
|
}
|
||||||
|
const requiredNames = [
|
||||||
|
normalizedBaseName(artifact.executableRelativePath),
|
||||||
|
...artifact.licenseFiles.map(normalizedBaseName)
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
requiredNames.some(
|
||||||
|
(fileName) =>
|
||||||
|
!fileName ||
|
||||||
|
!Number.isInteger(artifact.files?.[fileName]?.bytes) ||
|
||||||
|
!/^[A-F0-9]{64}$/u.test(artifact.files?.[fileName]?.sha256 ?? "")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("Pandoc 清单缺少最小运行时文件哈希或长度");
|
||||||
|
}
|
||||||
|
return { manifest, artifact };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadArchive(downloadUrl, destination) {
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(downloadTimeoutMs)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Pandoc 下载失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const declaredLength = Number(response.headers.get("content-length"));
|
||||||
|
if (
|
||||||
|
Number.isFinite(declaredLength) &&
|
||||||
|
declaredLength > maximumArchiveBytes
|
||||||
|
) {
|
||||||
|
throw new Error("Pandoc 归档超过允许的下载大小");
|
||||||
|
}
|
||||||
|
const content = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (content.byteLength > maximumArchiveBytes) {
|
||||||
|
throw new Error("Pandoc 归档超过允许的下载大小");
|
||||||
|
}
|
||||||
|
await writeFile(destination, content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadLicenseSourceFile(downloadUrl) {
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(downloadTimeoutMs)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Pandoc 许可证文件下载失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const content = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (content.byteLength > maximumExtractedBytes) {
|
||||||
|
throw new Error("Pandoc 许可证文件超过允许的下载大小");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部分平台(如 macOS)的官方归档不随附许可证文件,需要按清单声明的
|
||||||
|
* 独立来源单独下载;返回的文件与归档内提取的文件合并后一并校验哈希。
|
||||||
|
*/
|
||||||
|
async function loadLicenseSourceFiles(artifact) {
|
||||||
|
const files = new Map();
|
||||||
|
for (const [fileName, source] of Object.entries(artifact.licenseSource ?? {})) {
|
||||||
|
files.set(fileName, await downloadLicenseSourceFile(source.downloadUrl));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVerifiedArchive(artifact, archivePath) {
|
||||||
|
if (await fileExists(archivePath)) {
|
||||||
|
const cached = new Uint8Array(await readFile(archivePath));
|
||||||
|
if (sha256(cached) === artifact.sha256) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
await rm(archivePath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialPath = `${archivePath}.${process.pid}.partial`;
|
||||||
|
await rm(partialPath, { force: true });
|
||||||
|
try {
|
||||||
|
const downloaded = await downloadArchive(
|
||||||
|
artifact.downloadUrl,
|
||||||
|
partialPath
|
||||||
|
);
|
||||||
|
const actualHash = sha256(downloaded);
|
||||||
|
if (actualHash !== artifact.sha256) {
|
||||||
|
throw new Error(
|
||||||
|
`Pandoc 归档 SHA-256 不匹配:期望 ${artifact.sha256},实际 ${actualHash}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await rename(partialPath, archivePath);
|
||||||
|
return downloaded;
|
||||||
|
} finally {
|
||||||
|
await rm(partialPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRequiredFiles(archive, artifact) {
|
||||||
|
const requiredNames = new Set(archiveRequiredNames(artifact));
|
||||||
|
let selectedBytes = 0;
|
||||||
|
const entries = unzipSync(archive, {
|
||||||
|
filter(file) {
|
||||||
|
const selected = requiredNames.has(normalizedBaseName(file.name));
|
||||||
|
if (selected) {
|
||||||
|
selectedBytes += file.originalSize;
|
||||||
|
if (selectedBytes > maximumExtractedBytes) {
|
||||||
|
throw new Error("Pandoc 最小运行时超过允许的解压大小");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const files = new Map();
|
||||||
|
for (const [entryName, content] of Object.entries(entries)) {
|
||||||
|
const baseName = normalizedBaseName(entryName);
|
||||||
|
if (!baseName || !requiredNames.has(baseName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (files.has(baseName)) {
|
||||||
|
throw new Error(`Pandoc 归档包含重复文件:${baseName}`);
|
||||||
|
}
|
||||||
|
files.set(baseName, content);
|
||||||
|
}
|
||||||
|
for (const requiredName of requiredNames) {
|
||||||
|
if (!files.has(requiredName)) {
|
||||||
|
throw new Error(`Pandoc 归档缺少必须文件:${requiredName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPandocVersion(executablePath, expectedVersion) {
|
||||||
|
const result = spawnSync(executablePath, ["--version"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 * 1024,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(`内置 Pandoc 无法执行:${result.error?.message ?? result.stderr}`);
|
||||||
|
}
|
||||||
|
const firstLine = result.stdout.split(/\r?\n/u)[0]?.trim();
|
||||||
|
if (firstLine !== `pandoc ${expectedVersion}`) {
|
||||||
|
throw new Error(
|
||||||
|
`内置 Pandoc 版本不匹配:期望 pandoc ${expectedVersion},实际 ${firstLine ?? "未知"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smokeTestPandoc(executablePath) {
|
||||||
|
const smokeRoot = path.join(
|
||||||
|
tmpdir(),
|
||||||
|
`morphdoc-pandoc-smoke-${process.pid}-${Date.now()}`
|
||||||
|
);
|
||||||
|
const dataDirectory = path.join(smokeRoot, "data");
|
||||||
|
const outputPath = path.join(smokeRoot, "smoke.docx");
|
||||||
|
await mkdir(dataDirectory, { recursive: true });
|
||||||
|
try {
|
||||||
|
const result = spawnSync(
|
||||||
|
executablePath,
|
||||||
|
[
|
||||||
|
"--from=gfm",
|
||||||
|
"--to=docx",
|
||||||
|
`--data-dir=${dataDirectory}`,
|
||||||
|
`--output=${outputPath}`
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
input: "# 墨呈\n\nPandoc 运行时冒烟测试。\n",
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
windowsHide: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(`内置 Pandoc DOCX 冒烟失败:${result.error?.message ?? result.stderr}`);
|
||||||
|
}
|
||||||
|
const output = await readFile(outputPath);
|
||||||
|
if (output.byteLength < 1024 || output[0] !== 0x50 || output[1] !== 0x4b) {
|
||||||
|
throw new Error("内置 Pandoc 生成的 DOCX 不是有效 ZIP 包");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(smokeRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPreparedRuntime(targetDirectory, manifest, artifact) {
|
||||||
|
const auditPath = path.join(targetDirectory, "runtime-manifest.json");
|
||||||
|
if (!(await fileExists(auditPath))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const audit = JSON.parse(await readFile(auditPath, "utf8"));
|
||||||
|
if (
|
||||||
|
audit.version !== manifest.version ||
|
||||||
|
audit.archiveSha256 !== artifact.sha256 ||
|
||||||
|
audit.downloadUrl !== artifact.downloadUrl
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedFiles = [
|
||||||
|
normalizedBaseName(artifact.executableRelativePath),
|
||||||
|
...artifact.licenseFiles.map(normalizedBaseName)
|
||||||
|
];
|
||||||
|
for (const fileName of expectedFiles) {
|
||||||
|
const content = await readFile(path.join(targetDirectory, fileName));
|
||||||
|
const expected = artifact.files[fileName];
|
||||||
|
if (
|
||||||
|
!expected ||
|
||||||
|
content.byteLength !== expected.bytes ||
|
||||||
|
sha256(content) !== expected.sha256 ||
|
||||||
|
audit.files?.[fileName]?.sha256 !== expected.sha256
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const executablePath = path.join(
|
||||||
|
targetDirectory,
|
||||||
|
normalizedBaseName(artifact.executableRelativePath)
|
||||||
|
);
|
||||||
|
verifyPandocVersion(executablePath, manifest.version);
|
||||||
|
await smokeTestPandoc(executablePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preparePandocRuntime({
|
||||||
|
checkOnly = false,
|
||||||
|
platform = process.platform,
|
||||||
|
architecture = process.arch
|
||||||
|
} = {}) {
|
||||||
|
const { manifest, artifact } = await readRuntimeManifest({ platform, architecture });
|
||||||
|
const platformDirectory = platformDirectoryName(platform, architecture);
|
||||||
|
const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc");
|
||||||
|
const targetDirectory = path.join(
|
||||||
|
runtimeRoot,
|
||||||
|
manifest.version,
|
||||||
|
platformDirectory
|
||||||
|
);
|
||||||
|
if (await verifyPreparedRuntime(targetDirectory, manifest, artifact)) {
|
||||||
|
console.log(`Pandoc ${manifest.version} 最小运行时已通过校验`);
|
||||||
|
return targetDirectory;
|
||||||
|
}
|
||||||
|
if (checkOnly) {
|
||||||
|
throw new Error("Pandoc 最小运行时缺失或未通过完整性校验");
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadDirectory = path.join(runtimeRoot, "downloads");
|
||||||
|
await mkdir(downloadDirectory, { recursive: true });
|
||||||
|
const archiveName = new URL(artifact.downloadUrl).pathname.split("/").at(-1);
|
||||||
|
if (!archiveName) {
|
||||||
|
throw new Error("Pandoc 下载地址缺少归档文件名");
|
||||||
|
}
|
||||||
|
const archivePath = path.join(downloadDirectory, archiveName);
|
||||||
|
const archive = await loadVerifiedArchive(artifact, archivePath);
|
||||||
|
const files = new Map([
|
||||||
|
...extractRequiredFiles(archive, artifact),
|
||||||
|
...await loadLicenseSourceFiles(artifact)
|
||||||
|
]);
|
||||||
|
const temporaryDirectory = path.join(
|
||||||
|
runtimeRoot,
|
||||||
|
manifest.version,
|
||||||
|
`.${platformDirectory}-${process.pid}-${Date.now()}`
|
||||||
|
);
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
await mkdir(temporaryDirectory, { recursive: true });
|
||||||
|
try {
|
||||||
|
const auditFiles = {};
|
||||||
|
for (const [fileName, content] of files) {
|
||||||
|
const expected = artifact.files[fileName];
|
||||||
|
const actualHash = sha256(content);
|
||||||
|
if (
|
||||||
|
!expected ||
|
||||||
|
content.byteLength !== expected.bytes ||
|
||||||
|
actualHash !== expected.sha256
|
||||||
|
) {
|
||||||
|
throw new Error(`Pandoc 文件完整性校验失败:${fileName}`);
|
||||||
|
}
|
||||||
|
await writeFile(path.join(temporaryDirectory, fileName), content);
|
||||||
|
auditFiles[fileName] = {
|
||||||
|
bytes: content.byteLength,
|
||||||
|
sha256: actualHash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const executablePath = path.join(
|
||||||
|
temporaryDirectory,
|
||||||
|
normalizedBaseName(artifact.executableRelativePath)
|
||||||
|
);
|
||||||
|
if (platform !== "win32") {
|
||||||
|
// unzipSync 不保留压缩包内的可执行权限位,POSIX 平台需要手动补上。
|
||||||
|
await chmod(executablePath, 0o755);
|
||||||
|
}
|
||||||
|
verifyPandocVersion(executablePath, manifest.version);
|
||||||
|
await smokeTestPandoc(executablePath);
|
||||||
|
await writeFile(
|
||||||
|
path.join(temporaryDirectory, "runtime-manifest.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
version: manifest.version,
|
||||||
|
platform,
|
||||||
|
architecture,
|
||||||
|
license: manifest.license,
|
||||||
|
projectUrl: manifest.projectUrl,
|
||||||
|
sourceArchiveUrl: manifest.sourceArchiveUrl,
|
||||||
|
downloadUrl: artifact.downloadUrl,
|
||||||
|
archiveSha256: artifact.sha256,
|
||||||
|
files: auditFiles
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await rm(targetDirectory, { recursive: true, force: true });
|
||||||
|
await rename(temporaryDirectory, targetDirectory);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
console.log(`Pandoc ${manifest.version} 最小运行时已准备:${targetDirectory}`);
|
||||||
|
return targetDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCliFlag(name) {
|
||||||
|
const prefix = `--${name}=`;
|
||||||
|
const match = process.argv.find((argument) => argument.startsWith(prefix));
|
||||||
|
return match ? match.slice(prefix.length) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectInvocation =
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
if (isDirectInvocation) {
|
||||||
|
preparePandocRuntime({
|
||||||
|
checkOnly: process.argv.includes("--check"),
|
||||||
|
platform: readCliFlag("platform") ?? process.platform,
|
||||||
|
architecture: readCliFlag("arch") ?? process.arch
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
extractRequiredFiles,
|
||||||
|
preparePandocRuntime,
|
||||||
|
sha256,
|
||||||
|
verifyPandocVersion
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
void import("./verify-docx-theme-styles.mjs").catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
const { app } = require("electron");
|
||||||
|
if (app.isReady()) {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createApplicationService } from "@md-to-pdf/application";
|
||||||
|
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
|
createDocxThemeStyleCaptureScript,
|
||||||
|
createDocxThemeStyleFingerprint,
|
||||||
|
normalizeDocxThemeMappingConfig,
|
||||||
|
parseDocxThemeStyleRuntimeCapture,
|
||||||
|
resolveDocxThemeTokens
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import { app, BrowserWindow } from "electron";
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryDirectory = path.resolve(directory, "../../..");
|
||||||
|
const outputDirectory = path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"output",
|
||||||
|
"docx-theme-styles"
|
||||||
|
);
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = createApplicationService({
|
||||||
|
bundledRoot: path.join(repositoryDirectory, "themes"),
|
||||||
|
localRoot: path.join(repositoryDirectory, ".local", "themes")
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url ?? "/", "http://localhost");
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"content-type": "text/html; charset=utf-8"
|
||||||
|
});
|
||||||
|
response.end("<!doctype html><html><body></body></html>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = url.pathname.match(
|
||||||
|
/^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const themeId = decodeURIComponent(match[1]);
|
||||||
|
const assetPath = match[2]
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => decodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
const asset = await application.getThemeAsset(
|
||||||
|
themeId,
|
||||||
|
assetPath
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"content-type": asset.contentType,
|
||||||
|
"x-content-type-options": "nosniff"
|
||||||
|
});
|
||||||
|
response.end(asset.content);
|
||||||
|
} catch (error) {
|
||||||
|
response.writeHead(500);
|
||||||
|
response.end(
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.whenReady();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("无法获取 Electron DOCX 主题样式验收地址");
|
||||||
|
}
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}/`;
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
focusable: false,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
paintWhenInitiallyHidden: true,
|
||||||
|
webPreferences: {
|
||||||
|
backgroundThrottling: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.loadURL(baseUrl);
|
||||||
|
window.webContents.debugger.attach("1.3");
|
||||||
|
await window.webContents.debugger.sendCommand(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "print" }
|
||||||
|
);
|
||||||
|
const { themes } = await application.listThemes();
|
||||||
|
const bundledThemes = themes
|
||||||
|
.filter((theme) => theme.source === "bundled")
|
||||||
|
.sort((first, second) =>
|
||||||
|
first.id.localeCompare(second.id, "en")
|
||||||
|
);
|
||||||
|
const snapshots = [];
|
||||||
|
const tokenSets = [];
|
||||||
|
for (const theme of bundledThemes) {
|
||||||
|
console.error(`[Electron DOCX theme styles] capturing ${theme.id}`);
|
||||||
|
const prepared = await application.prepareDocxExport({
|
||||||
|
markdown: `# ${theme.name}`,
|
||||||
|
fileName: `${theme.id}.md`,
|
||||||
|
language: "zh-CN",
|
||||||
|
exportConfig: {
|
||||||
|
...defaultExportConfig,
|
||||||
|
themeId: theme.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const themeCss = prepared.theme.css;
|
||||||
|
const manifest = prepared.theme.manifest;
|
||||||
|
const themeFingerprint =
|
||||||
|
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
||||||
|
const request = {
|
||||||
|
themeId: theme.id,
|
||||||
|
themeFingerprint,
|
||||||
|
themeCss,
|
||||||
|
baseUrl
|
||||||
|
};
|
||||||
|
const capture = await window.webContents.executeJavaScript(
|
||||||
|
createDocxThemeStyleCaptureScript(request),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const snapshot = parseDocxThemeStyleRuntimeCapture(
|
||||||
|
request,
|
||||||
|
capture
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
`主题 ${theme.id} 的槽位数量不正确`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
snapshot.slots.every((slot) => slot.matched),
|
||||||
|
`主题 ${theme.id} 存在未命中槽位`
|
||||||
|
);
|
||||||
|
snapshots.push(snapshot);
|
||||||
|
tokenSets.push(
|
||||||
|
resolveDocxThemeTokens({
|
||||||
|
snapshot,
|
||||||
|
config: normalizeDocxThemeMappingConfig(manifest)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
snapshots.length === 14,
|
||||||
|
`内置主题数量应为 14,实际为 ${snapshots.length}`
|
||||||
|
);
|
||||||
|
const invalidDiagnostics = tokenSets.flatMap((tokens) =>
|
||||||
|
tokens.diagnostics.filter(
|
||||||
|
(diagnostic) =>
|
||||||
|
diagnostic.severity === "error" ||
|
||||||
|
diagnostic.code === "css-value-invalid"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
invalidDiagnostics.length === 0,
|
||||||
|
`DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断`
|
||||||
|
);
|
||||||
|
const playwrightReport = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(outputDirectory, "snapshots.json"),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const playwrightTokensByTheme = new Map(
|
||||||
|
playwrightReport.tokenSets.map((tokens) => [
|
||||||
|
tokens.themeId,
|
||||||
|
tokens
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const crossEngineMismatches = [];
|
||||||
|
for (const electronTokens of tokenSets) {
|
||||||
|
const playwrightTokens = playwrightTokensByTheme.get(
|
||||||
|
electronTokens.themeId
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
playwrightTokens,
|
||||||
|
`Playwright 报告缺少主题 ${electronTokens.themeId}`
|
||||||
|
);
|
||||||
|
for (const electronSlot of electronTokens.slots) {
|
||||||
|
const playwrightSlot = playwrightTokens.slots.find(
|
||||||
|
(slot) => slot.slot === electronSlot.slot
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
JSON.stringify(playwrightSlot) !==
|
||||||
|
JSON.stringify(electronSlot)
|
||||||
|
) {
|
||||||
|
crossEngineMismatches.push(
|
||||||
|
`${electronTokens.themeId}:${electronSlot.slot}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
crossEngineMismatches.length === 0,
|
||||||
|
`Playwright/Electron 令牌不一致:${crossEngineMismatches.join("、")}`
|
||||||
|
);
|
||||||
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outputDirectory, "electron-snapshots.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
electronVersion: process.versions.electron,
|
||||||
|
chromiumVersion: process.versions.chrome,
|
||||||
|
themeCount: snapshots.length,
|
||||||
|
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
crossEngineTokenMismatches:
|
||||||
|
crossEngineMismatches.length,
|
||||||
|
snapshots,
|
||||||
|
tokenSets
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
electronVersion: process.versions.electron,
|
||||||
|
chromiumVersion: process.versions.chrome,
|
||||||
|
themes: snapshots.length,
|
||||||
|
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
totalSlots:
|
||||||
|
snapshots.length * DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
tokenSets: tokenSets.length,
|
||||||
|
crossEngineTokenMismatches:
|
||||||
|
crossEngineMismatches.length,
|
||||||
|
diagnostics: tokenSets.reduce(
|
||||||
|
(count, tokens) => count + tokens.diagnostics.length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (
|
||||||
|
!window.isDestroyed() &&
|
||||||
|
window.webContents.debugger.isAttached()
|
||||||
|
) {
|
||||||
|
window.webContents.debugger.detach();
|
||||||
|
}
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import type {
|
||||||
|
DocxExportRequestInput,
|
||||||
|
PagedDocumentPayload
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DESKTOP_CONSUME_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_CREATE_NEW_WINDOW,
|
||||||
|
DESKTOP_DISCARD_PENDING_MARKDOWN,
|
||||||
|
DESKTOP_GET_THEME_CSS,
|
||||||
|
DESKTOP_GET_DOCX_CAPABILITY,
|
||||||
|
DESKTOP_EXPORT_DOCX,
|
||||||
|
DESKTOP_GENERATE_PDF,
|
||||||
|
DESKTOP_LIST_THEMES,
|
||||||
|
DESKTOP_MARKDOWN_OPENED,
|
||||||
|
DESKTOP_OPEN_MARKDOWN,
|
||||||
|
DESKTOP_OPEN_DOCUMENT_LINK,
|
||||||
|
DESKTOP_OPEN_THEME_DIRECTORY,
|
||||||
|
DESKTOP_REFRESH_THEMES,
|
||||||
|
DESKTOP_RENDER_MARKDOWN,
|
||||||
|
DESKTOP_RESOLVE_WINDOW_CLOSE,
|
||||||
|
DESKTOP_SAVE_MARKDOWN,
|
||||||
|
DESKTOP_SAVE_MARKDOWN_AS,
|
||||||
|
DESKTOP_SET_DOCUMENT_DIRTY,
|
||||||
|
DESKTOP_START_NEW_MARKDOWN,
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||||
|
DESKTOP_SAVE_PDF
|
||||||
|
} from "./channels.js";
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||||
|
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
|
||||||
|
consumePendingMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN),
|
||||||
|
discardPendingMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN),
|
||||||
|
onMarkdownOpened: (
|
||||||
|
listener: (reason: "open" | "reload" | "replace") => void
|
||||||
|
) => {
|
||||||
|
const handleOpened = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
reason: "open" | "reload" | "replace"
|
||||||
|
) => listener(reason);
|
||||||
|
ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened);
|
||||||
|
},
|
||||||
|
startNewMarkdown: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN),
|
||||||
|
createNewWindow: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_CREATE_NEW_WINDOW),
|
||||||
|
resolveWindowClose: (close: boolean) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_RESOLVE_WINDOW_CLOSE, close),
|
||||||
|
onWindowCloseRequested: (listener: () => void) => {
|
||||||
|
const handleCloseRequested = () => listener();
|
||||||
|
ipcRenderer.on(
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||||
|
handleCloseRequested
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
DESKTOP_WINDOW_CLOSE_REQUESTED,
|
||||||
|
handleCloseRequested
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setDocumentDirty: (dirty: boolean) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SET_DOCUMENT_DIRTY, dirty),
|
||||||
|
openDocumentLink: (href: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_OPEN_DOCUMENT_LINK, href),
|
||||||
|
saveMarkdown: (fileName: string, markdown: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown),
|
||||||
|
saveMarkdownAs: (fileName: string, markdown: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN_AS, fileName, markdown),
|
||||||
|
openThemeDirectory: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY),
|
||||||
|
refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES),
|
||||||
|
renderMarkdown: (input: MarkdownRenderRequest) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
|
||||||
|
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
|
||||||
|
getThemeCss: (themeId: string) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_GET_THEME_CSS, themeId),
|
||||||
|
generatePdf: (payload: PagedDocumentPayload) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
||||||
|
savePdf: (fileName: string, pdf: Uint8Array) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf),
|
||||||
|
getDocxCapability: () =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY),
|
||||||
|
exportDocx: (request: DocxExportRequestInput) =>
|
||||||
|
ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request)
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarkdownRenderRequest(
|
||||||
|
value: unknown
|
||||||
|
): MarkdownRenderRequest {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
markdown: value.markdown,
|
||||||
|
language: value.language,
|
||||||
|
resources: value.resources
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseThemeId(value: unknown) {
|
||||||
|
if (
|
||||||
|
typeof value !== "string" ||
|
||||||
|
!/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(value)
|
||||||
|
) {
|
||||||
|
throw new Error("主题 ID 无效");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseThemeResourceUrl(requestUrl: string) {
|
||||||
|
const url = new URL(requestUrl);
|
||||||
|
if (url.protocol !== "mdpdf:" || url.host !== "theme") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments: string[];
|
||||||
|
try {
|
||||||
|
segments = url.pathname
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => decodeURIComponent(segment));
|
||||||
|
} catch {
|
||||||
|
throw new Error("主题资源地址无效");
|
||||||
|
}
|
||||||
|
const [unsafeThemeId, ...assetSegments] = segments;
|
||||||
|
if (!unsafeThemeId || assetSegments.length === 0) {
|
||||||
|
throw new Error("主题资源地址无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeId: parseThemeId(unsafeThemeId),
|
||||||
|
assetPath: assetSegments.join("/")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFontPackResourceUrl(requestUrl: string) {
|
||||||
|
const url = new URL(requestUrl);
|
||||||
|
if (url.protocol !== "mdpdf:" || url.host !== "font-pack") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let segments: string[];
|
||||||
|
try {
|
||||||
|
segments = url.pathname
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => decodeURIComponent(segment));
|
||||||
|
} catch {
|
||||||
|
throw new Error("字体包资源地址无效");
|
||||||
|
}
|
||||||
|
const [packId, packVersion, faceId, kind, ...extra] = segments;
|
||||||
|
if (
|
||||||
|
!packId ||
|
||||||
|
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(packId) ||
|
||||||
|
!packVersion ||
|
||||||
|
!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(packVersion) ||
|
||||||
|
!faceId ||
|
||||||
|
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(faceId) ||
|
||||||
|
kind !== "web" ||
|
||||||
|
extra.length > 0
|
||||||
|
) {
|
||||||
|
throw new Error("字体包资源地址无效");
|
||||||
|
}
|
||||||
|
return { packId, packVersion, faceId, kind } as const;
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare const __APP_ID__: string;
|
||||||
|
declare const __APP_ENGLISH_NAME__: string;
|
||||||
|
declare const __APP_DISPLAY_NAME__: string;
|
||||||
|
declare const __APP_WINDOW_TITLE__: string;
|
||||||
|
declare const __APP_LEGACY_DISPLAY_NAME__: string;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const DESKTOP_RENDER_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:render-markdown";
|
||||||
|
export const DESKTOP_OPEN_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:open-markdown";
|
||||||
|
export const DESKTOP_CONSUME_PENDING_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:consume-pending-markdown";
|
||||||
|
export const DESKTOP_DISCARD_PENDING_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:discard-pending-markdown";
|
||||||
|
export const DESKTOP_MARKDOWN_OPENED =
|
||||||
|
"md-to-pdf:desktop:markdown-opened";
|
||||||
|
export const DESKTOP_SET_DOCUMENT_DIRTY =
|
||||||
|
"md-to-pdf:desktop:set-document-dirty";
|
||||||
|
export const DESKTOP_OPEN_DOCUMENT_LINK =
|
||||||
|
"md-to-pdf:desktop:open-document-link";
|
||||||
|
export const DESKTOP_START_NEW_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:start-new-markdown";
|
||||||
|
export const DESKTOP_CREATE_NEW_WINDOW =
|
||||||
|
"md-to-pdf:desktop:create-new-window";
|
||||||
|
export const DESKTOP_WINDOW_CLOSE_REQUESTED =
|
||||||
|
"md-to-pdf:desktop:window-close-requested";
|
||||||
|
export const DESKTOP_RESOLVE_WINDOW_CLOSE =
|
||||||
|
"md-to-pdf:desktop:resolve-window-close";
|
||||||
|
export const DESKTOP_SAVE_MARKDOWN =
|
||||||
|
"md-to-pdf:desktop:save-markdown";
|
||||||
|
export const DESKTOP_SAVE_MARKDOWN_AS =
|
||||||
|
"md-to-pdf:desktop:save-markdown-as";
|
||||||
|
export const DESKTOP_OPEN_THEME_DIRECTORY =
|
||||||
|
"md-to-pdf:desktop:open-theme-directory";
|
||||||
|
export const DESKTOP_REFRESH_THEMES =
|
||||||
|
"md-to-pdf:desktop:refresh-themes";
|
||||||
|
export const DESKTOP_LIST_THEMES =
|
||||||
|
"md-to-pdf:desktop:list-themes";
|
||||||
|
export const DESKTOP_GET_THEME_CSS =
|
||||||
|
"md-to-pdf:desktop:get-theme-css";
|
||||||
|
export const DESKTOP_GENERATE_PDF =
|
||||||
|
"md-to-pdf:desktop:generate-pdf";
|
||||||
|
export const DESKTOP_SAVE_PDF =
|
||||||
|
"md-to-pdf:desktop:save-pdf";
|
||||||
|
export const DESKTOP_GET_DOCX_CAPABILITY =
|
||||||
|
"md-to-pdf:desktop:get-docx-capability";
|
||||||
|
export const DESKTOP_EXPORT_DOCX =
|
||||||
|
"md-to-pdf:desktop:export-docx";
|
||||||
|
export const PDF_RUNTIME_READY =
|
||||||
|
"md-to-pdf:desktop-pdf:ready";
|
||||||
|
export const PDF_RUNTIME_RENDER =
|
||||||
|
"md-to-pdf:desktop-pdf:render";
|
||||||
|
export const PDF_RUNTIME_COMPLETE =
|
||||||
|
"md-to-pdf:desktop-pdf:complete";
|
||||||
|
export const PDF_RUNTIME_FAIL =
|
||||||
|
"md-to-pdf:desktop-pdf:fail";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export type DesktopDocumentLinkAction =
|
||||||
|
| {
|
||||||
|
type: "anchor";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "external";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "local";
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ignore";
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeLocalPath(href: string) {
|
||||||
|
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(pathOnly);
|
||||||
|
} catch {
|
||||||
|
throw new Error("本地链接的 URL 编码无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDesktopDocumentLinkAction(
|
||||||
|
href: string,
|
||||||
|
markdownFilePath?: string
|
||||||
|
): DesktopDocumentLinkAction {
|
||||||
|
const link = classifyDocumentLink(href);
|
||||||
|
if (link.kind === "anchor") {
|
||||||
|
return { type: "anchor", href: link.normalizedHref };
|
||||||
|
}
|
||||||
|
if (link.kind === "network" || link.kind === "protocol") {
|
||||||
|
return { type: "external", href: link.normalizedHref };
|
||||||
|
}
|
||||||
|
if (link.kind !== "local") {
|
||||||
|
return { type: "ignore" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.scheme === "file") {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
type: "local",
|
||||||
|
filePath: path.normalize(fileURLToPath(new URL(link.href)))
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new Error("file: 链接格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeLocalPath(link.href);
|
||||||
|
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
|
||||||
|
return { type: "local", filePath: path.normalize(decoded) };
|
||||||
|
}
|
||||||
|
if (!markdownFilePath) {
|
||||||
|
throw new Error("当前文档尚未保存,无法解析相对本地链接");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "local",
|
||||||
|
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
export function resolveDesktopDocxTargetPath(selectedPath: string) {
|
||||||
|
return selectedPath.toLowerCase().endsWith(".docx")
|
||||||
|
? selectedPath
|
||||||
|
: `${selectedPath}.docx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDesktopDocx(
|
||||||
|
selectedPath: string,
|
||||||
|
content: Uint8Array
|
||||||
|
) {
|
||||||
|
if (!selectedPath || content.byteLength === 0) {
|
||||||
|
throw new Error("DOCX 保存参数无效");
|
||||||
|
}
|
||||||
|
const targetPath = resolveDesktopDocxTargetPath(selectedPath);
|
||||||
|
await writeFile(targetPath, content);
|
||||||
|
return {
|
||||||
|
targetPath,
|
||||||
|
fileName: path.basename(targetPath)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
ApplicationRequestError,
|
||||||
|
DocxExportServiceError
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import type {
|
||||||
|
DocxCapability,
|
||||||
|
DocxExportDiagnostics,
|
||||||
|
DocxExportErrorCode,
|
||||||
|
DocxGenerationTimings
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { docxExportErrorCodeSchema } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface DesktopDocxExportSuccess {
|
||||||
|
ok: true;
|
||||||
|
saved: boolean;
|
||||||
|
fileName: string;
|
||||||
|
diagnostics: DocxExportDiagnostics;
|
||||||
|
timings: DocxGenerationTimings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopDocxExportFailure {
|
||||||
|
ok: false;
|
||||||
|
error: DocxExportErrorCode;
|
||||||
|
message: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopDocxExportOutcome =
|
||||||
|
| DesktopDocxExportSuccess
|
||||||
|
| DesktopDocxExportFailure;
|
||||||
|
|
||||||
|
export type DesktopDocxCapability = DocxCapability;
|
||||||
|
|
||||||
|
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
|
||||||
|
const cause = error.cause;
|
||||||
|
if (!(cause instanceof Error)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return /^桌面 DOCX (?:媒体布局超过 \d+ms|第 \d+\/\d+ 个媒体((?:image|mermaid|echarts))PNG 捕获超过 \d+ms)$/u.test(
|
||||||
|
cause.message
|
||||||
|
)
|
||||||
|
? cause.message
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDesktopDocxExportFailure(
|
||||||
|
error: unknown
|
||||||
|
): DesktopDocxExportFailure {
|
||||||
|
if (error instanceof ApplicationRequestError) {
|
||||||
|
const parsedCode = docxExportErrorCodeSchema.safeParse(
|
||||||
|
error.code
|
||||||
|
);
|
||||||
|
if (!parsedCode.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "DOCX_GENERATION_FAILED",
|
||||||
|
message: "DOCX 导出失败",
|
||||||
|
retryable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: parsedCode.data,
|
||||||
|
message: error.message,
|
||||||
|
retryable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof DocxExportServiceError) {
|
||||||
|
const detail = readSafeDesktopMediaDetail(error);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error.code,
|
||||||
|
message: detail ? `${error.message}:${detail}` : error.message,
|
||||||
|
retryable: error.retryable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "DOCX_GENERATION_FAILED",
|
||||||
|
message: "DOCX 导出失败",
|
||||||
|
retryable: false
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { WebContents } from "electron";
|
||||||
|
import type {
|
||||||
|
DocxMediaCaptureAdapter,
|
||||||
|
DocxMediaCaptureRequest
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
interface CaptureScreenshotResult {
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronDocxMediaCaptureTimeouts {
|
||||||
|
renderMs: number;
|
||||||
|
screenshotMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
|
||||||
|
renderMs: 30_000,
|
||||||
|
screenshotMs: 20_000
|
||||||
|
};
|
||||||
|
|
||||||
|
function withTimeout<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
const abort = () =>
|
||||||
|
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => finish(() => reject(new Error(message))),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operation.then(
|
||||||
|
(value) => finish(() => resolve(value)),
|
||||||
|
(error) => finish(() => reject(error))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenderScript(request: DocxMediaCaptureRequest) {
|
||||||
|
const serialized = JSON.stringify(request);
|
||||||
|
return `(async () => {
|
||||||
|
const request = ${serialized};
|
||||||
|
const renderer = window.__mdToPdfRenderDocxMedia;
|
||||||
|
if (!renderer) {
|
||||||
|
throw new Error("DOCX 媒体渲染运行时不可用");
|
||||||
|
}
|
||||||
|
return renderer(request.payload, request.dimensions);
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents: WebContents,
|
||||||
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
||||||
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const ownsDebugger = !webContents.debugger.isAttached();
|
||||||
|
if (ownsDebugger) {
|
||||||
|
webContents.debugger.attach("1.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", {
|
||||||
|
media: "print"
|
||||||
|
});
|
||||||
|
const plan = (await withTimeout(
|
||||||
|
webContents.executeJavaScript(createRenderScript(request), true),
|
||||||
|
timeouts.renderMs,
|
||||||
|
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||||
|
signal
|
||||||
|
)) as DocxMediaCapturePlan;
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const captures = [];
|
||||||
|
for (const target of plan.targets) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const result = (await withTimeout(
|
||||||
|
webContents.debugger.sendCommand(
|
||||||
|
"Page.captureScreenshot",
|
||||||
|
{
|
||||||
|
format: "png",
|
||||||
|
fromSurface: true,
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
clip: {
|
||||||
|
x: target.captureX,
|
||||||
|
y: target.captureY,
|
||||||
|
width: target.captureWidthPx,
|
||||||
|
height: target.captureHeightPx,
|
||||||
|
scale: target.rasterScale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
timeouts.screenshotMs,
|
||||||
|
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind})PNG 捕获超过 ${timeouts.screenshotMs}ms`,
|
||||||
|
signal
|
||||||
|
)) as CaptureScreenshotResult;
|
||||||
|
captures.push({
|
||||||
|
id: target.id,
|
||||||
|
png: Buffer.from(result.data, "base64")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return { plan, captures };
|
||||||
|
} finally {
|
||||||
|
if (webContents.debugger.isAttached()) {
|
||||||
|
await webContents.debugger
|
||||||
|
.sendCommand("Emulation.setEmulatedMedia", { media: "screen" })
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||||
|
webContents.debugger.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ElectronDocxMediaCaptureAdapter
|
||||||
|
implements DocxMediaCaptureAdapter
|
||||||
|
{
|
||||||
|
constructor(private readonly webContents: WebContents) {}
|
||||||
|
|
||||||
|
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||||
|
return captureDocxMediaWithElectronWebContents(
|
||||||
|
this.webContents,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { BrowserWindow } from "electron";
|
||||||
|
import type {
|
||||||
|
DocxMediaCaptureAdapter,
|
||||||
|
DocxMediaCaptureRequest
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import type {
|
||||||
|
DocxThemeStyleCaptureAdapter,
|
||||||
|
DocxThemeStyleCaptureRequest
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
|
||||||
|
import { captureDocxThemeStyleWithElectronWebContents } from "./electron-docx-theme-style.js";
|
||||||
|
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
|
||||||
|
|
||||||
|
export const DESKTOP_DOCX_PARTITION =
|
||||||
|
"md-to-pdf-desktop-docx";
|
||||||
|
|
||||||
|
export interface ElectronDocxMediaEngineOptions {
|
||||||
|
renderUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withActiveDocxCaptureSurface<T>(
|
||||||
|
window: Pick<
|
||||||
|
BrowserWindow,
|
||||||
|
"showInactive" | "hide" | "isDestroyed"
|
||||||
|
>,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
settleMs = 50
|
||||||
|
) {
|
||||||
|
window.showInactive();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ElectronDocxMediaEngine
|
||||||
|
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||||
|
{
|
||||||
|
readonly themeStyleBaseUrl: string;
|
||||||
|
private readonly renderUrl: string;
|
||||||
|
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||||
|
private queue = Promise.resolve();
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(options: ElectronDocxMediaEngineOptions) {
|
||||||
|
this.renderUrl = options.renderUrl;
|
||||||
|
this.themeStyleBaseUrl = this.renderUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(
|
||||||
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
|
||||||
|
}
|
||||||
|
const operation = this.queue.then(() =>
|
||||||
|
this.captureNow(request, signal)
|
||||||
|
);
|
||||||
|
this.queue = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureThemeStyle(
|
||||||
|
request: DocxThemeStyleCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
|
||||||
|
}
|
||||||
|
const operation = this.queue.then(() =>
|
||||||
|
this.captureThemeStyleNow(request, signal)
|
||||||
|
);
|
||||||
|
this.queue = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.closed = true;
|
||||||
|
await this.queue;
|
||||||
|
const windowPromise = this.windowPromise;
|
||||||
|
this.windowPromise = undefined;
|
||||||
|
if (!windowPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const window = await windowPromise;
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 创建失败时没有需要关闭的窗口。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async captureNow(
|
||||||
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const window = await this.getWindow();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const abort = () => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
try {
|
||||||
|
return await withActiveDocxCaptureSurface(
|
||||||
|
window,
|
||||||
|
() =>
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
|
window.webContents,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async captureThemeStyleNow(
|
||||||
|
request: DocxThemeStyleCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (
|
||||||
|
new URL(request.baseUrl).origin !==
|
||||||
|
new URL(this.renderUrl).origin
|
||||||
|
) {
|
||||||
|
throw new Error("桌面 DOCX 主题资源地址必须与渲染地址同源");
|
||||||
|
}
|
||||||
|
const window = await this.getWindow();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const abort = () => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
try {
|
||||||
|
return await captureDocxThemeStyleWithElectronWebContents(
|
||||||
|
window.webContents,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWindow() {
|
||||||
|
if (!this.windowPromise) {
|
||||||
|
this.windowPromise = this.createWindow().catch((error) => {
|
||||||
|
this.windowPromise = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.windowPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWindow() {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
focusable: false,
|
||||||
|
opacity: 0,
|
||||||
|
skipTaskbar: true,
|
||||||
|
x: -32_000,
|
||||||
|
y: -32_000,
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
paintWhenInitiallyHidden: true,
|
||||||
|
webPreferences: {
|
||||||
|
backgroundThrottling: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
partition: DESKTOP_DOCX_PARTITION
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||||
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
|
const target = new URL(url);
|
||||||
|
const renderTarget = new URL(this.renderUrl);
|
||||||
|
if (
|
||||||
|
target.origin !== renderTarget.origin ||
|
||||||
|
target.pathname !== renderTarget.pathname
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.webContents.on("destroyed", () => {
|
||||||
|
this.windowPromise = undefined;
|
||||||
|
});
|
||||||
|
window.webContents.session.webRequest.onBeforeRequest(
|
||||||
|
{ urls: ["*://*/*"] },
|
||||||
|
(details, callback) => {
|
||||||
|
callback({
|
||||||
|
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.loadURL(this.renderUrl);
|
||||||
|
await window.webContents.executeJavaScript(`
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (document.documentElement.dataset.runtimeReady === "true") {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
reject(new Error("桌面 DOCX 媒体运行时启动超时"));
|
||||||
|
}, 10000);
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (document.documentElement.dataset.runtimeReady === "true") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
observer.disconnect();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-runtime-ready"]
|
||||||
|
});
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
window.webContents.setZoomFactor(1);
|
||||||
|
return window;
|
||||||
|
} catch (error) {
|
||||||
|
window.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
createDocxThemeStyleCaptureScript,
|
||||||
|
parseDocxThemeStyleRuntimeCapture,
|
||||||
|
type DocxThemeStyleCaptureRequest,
|
||||||
|
type DocxThemeStyleSnapshot
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import type { WebContents } from "electron";
|
||||||
|
|
||||||
|
export async function captureDocxThemeStyleWithElectronWebContents(
|
||||||
|
webContents: WebContents,
|
||||||
|
request: DocxThemeStyleCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<DocxThemeStyleSnapshot> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const ownsDebugger = !webContents.debugger.isAttached();
|
||||||
|
if (ownsDebugger) {
|
||||||
|
webContents.debugger.attach("1.3");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await webContents.debugger.sendCommand(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "print" }
|
||||||
|
);
|
||||||
|
const capture = await webContents.executeJavaScript(
|
||||||
|
createDocxThemeStyleCaptureScript(request),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return parseDocxThemeStyleRuntimeCapture(request, capture);
|
||||||
|
} finally {
|
||||||
|
if (
|
||||||
|
!webContents.isDestroyed() &&
|
||||||
|
webContents.debugger.isAttached()
|
||||||
|
) {
|
||||||
|
await webContents.debugger
|
||||||
|
.sendCommand("Emulation.setEmulatedMedia", {
|
||||||
|
media: "screen"
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||||
|
webContents.debugger.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import {
|
||||||
|
BrowserWindow,
|
||||||
|
ipcMain,
|
||||||
|
type IpcMainEvent
|
||||||
|
} from "electron";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import type {
|
||||||
|
PagedDocumentPayload,
|
||||||
|
PagedDocumentRenderResult
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
PDF_RUNTIME_COMPLETE,
|
||||||
|
PDF_RUNTIME_FAIL,
|
||||||
|
PDF_RUNTIME_READY,
|
||||||
|
PDF_RUNTIME_RENDER
|
||||||
|
} from "./channels.js";
|
||||||
|
import {
|
||||||
|
createElectronPdfPrintOptions,
|
||||||
|
isAllowedPdfRuntimeUrl
|
||||||
|
} from "./pdf-contract.js";
|
||||||
|
|
||||||
|
export const DESKTOP_PDF_PARTITION =
|
||||||
|
"md-to-pdf-desktop-pdf";
|
||||||
|
|
||||||
|
export interface ElectronPdfGenerationResult {
|
||||||
|
pdf: Buffer;
|
||||||
|
pageCount: number;
|
||||||
|
echartsErrors: string[];
|
||||||
|
mermaidErrors: string[];
|
||||||
|
timings: {
|
||||||
|
documentRenderMs: number;
|
||||||
|
pdfPrintMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronPdfGeneratorOptions {
|
||||||
|
renderUrl: string;
|
||||||
|
preloadPath: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRender {
|
||||||
|
requestId: number;
|
||||||
|
resolve: (result: PagedDocumentRenderResult) => void;
|
||||||
|
reject: (reason: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWithTimeout<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(
|
||||||
|
() => reject(new Error(message)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ElectronPdfGenerator {
|
||||||
|
private readonly renderUrl: string;
|
||||||
|
private readonly preloadPath: string;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||||
|
private renderWindow: BrowserWindow | undefined;
|
||||||
|
private pendingRender: PendingRender | undefined;
|
||||||
|
private nextRequestId = 1;
|
||||||
|
private queue = Promise.resolve();
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(options: ElectronPdfGeneratorOptions) {
|
||||||
|
this.renderUrl = options.renderUrl;
|
||||||
|
this.preloadPath = options.preloadPath;
|
||||||
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
||||||
|
|
||||||
|
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
|
||||||
|
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(payload: PagedDocumentPayload) {
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = this.queue.then(() => this.generateNow(payload));
|
||||||
|
this.queue = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.closed = true;
|
||||||
|
ipcMain.removeListener(
|
||||||
|
PDF_RUNTIME_COMPLETE,
|
||||||
|
this.handleRenderComplete
|
||||||
|
);
|
||||||
|
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||||
|
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||||
|
this.pendingRender = undefined;
|
||||||
|
|
||||||
|
const windowPromise = this.windowPromise;
|
||||||
|
this.windowPromise = undefined;
|
||||||
|
this.renderWindow = undefined;
|
||||||
|
if (!windowPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const window = await windowPromise;
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 创建失败时没有需要关闭的窗口。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateNow(payload: PagedDocumentPayload) {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const window = await this.getWindow();
|
||||||
|
const documentRenderStartedAt = performance.now();
|
||||||
|
const renderResult = await this.requestRender(window, payload);
|
||||||
|
const documentRenderMs =
|
||||||
|
performance.now() - documentRenderStartedAt;
|
||||||
|
await window.webContents.executeJavaScript(
|
||||||
|
"new Promise((resolve) => setTimeout(resolve, 0))"
|
||||||
|
);
|
||||||
|
|
||||||
|
const pdfPrintStartedAt = performance.now();
|
||||||
|
window.showInactive();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
let pdf: Buffer;
|
||||||
|
try {
|
||||||
|
pdf = await waitWithTimeout(
|
||||||
|
window.webContents.printToPDF(
|
||||||
|
createElectronPdfPrintOptions(payload, renderResult)
|
||||||
|
),
|
||||||
|
this.timeoutMs,
|
||||||
|
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pdf,
|
||||||
|
pageCount: renderResult.pageCount,
|
||||||
|
echartsErrors: renderResult.echartsErrors,
|
||||||
|
mermaidErrors: renderResult.mermaidErrors,
|
||||||
|
timings: {
|
||||||
|
documentRenderMs,
|
||||||
|
pdfPrintMs,
|
||||||
|
totalMs: performance.now() - startedAt
|
||||||
|
}
|
||||||
|
} satisfies ElectronPdfGenerationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWindow() {
|
||||||
|
if (!this.windowPromise) {
|
||||||
|
this.windowPromise = this.createWindow().catch((error) => {
|
||||||
|
this.windowPromise = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.windowPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWindow() {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
focusable: false,
|
||||||
|
opacity: 0,
|
||||||
|
skipTaskbar: true,
|
||||||
|
x: -32_000,
|
||||||
|
y: -32_000,
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
paintWhenInitiallyHidden: true,
|
||||||
|
webPreferences: {
|
||||||
|
backgroundThrottling: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
partition: DESKTOP_PDF_PARTITION,
|
||||||
|
preload: this.preloadPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||||
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
|
const target = new URL(url);
|
||||||
|
const renderTarget = new URL(this.renderUrl);
|
||||||
|
if (
|
||||||
|
target.origin !== renderTarget.origin ||
|
||||||
|
target.pathname !== renderTarget.pathname
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.webContents.on("destroyed", () => {
|
||||||
|
if (this.windowPromise) {
|
||||||
|
this.windowPromise = undefined;
|
||||||
|
}
|
||||||
|
this.renderWindow = undefined;
|
||||||
|
this.pendingRender?.reject(
|
||||||
|
new Error("桌面 PDF 渲染窗口已关闭")
|
||||||
|
);
|
||||||
|
this.pendingRender = undefined;
|
||||||
|
});
|
||||||
|
window.webContents.session.webRequest.onBeforeRequest(
|
||||||
|
{ urls: ["*://*/*"] },
|
||||||
|
(details, callback) => {
|
||||||
|
callback({
|
||||||
|
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const ready = new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("桌面 PDF 分页运行时启动超时"));
|
||||||
|
}, this.timeoutMs);
|
||||||
|
const handleReady = (event: IpcMainEvent) => {
|
||||||
|
if (event.sender !== window.webContents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
|
||||||
|
};
|
||||||
|
ipcMain.on(PDF_RUNTIME_READY, handleReady);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([window.loadURL(this.renderUrl), ready]);
|
||||||
|
window.webContents.setZoomFactor(1);
|
||||||
|
this.renderWindow = window;
|
||||||
|
return window;
|
||||||
|
} catch (error) {
|
||||||
|
window.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestRender(
|
||||||
|
window: BrowserWindow,
|
||||||
|
payload: PagedDocumentPayload
|
||||||
|
) {
|
||||||
|
if (this.pendingRender) {
|
||||||
|
throw new Error("桌面 PDF 渲染器状态异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = this.nextRequestId++;
|
||||||
|
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (this.pendingRender?.requestId === requestId) {
|
||||||
|
this.pendingRender = undefined;
|
||||||
|
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.timeoutMs);
|
||||||
|
|
||||||
|
this.pendingRender = {
|
||||||
|
requestId,
|
||||||
|
resolve: (result) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(result);
|
||||||
|
},
|
||||||
|
reject: (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleRenderComplete = (
|
||||||
|
event: IpcMainEvent,
|
||||||
|
requestId: number,
|
||||||
|
result: PagedDocumentRenderResult
|
||||||
|
) => {
|
||||||
|
const pending = this.pendingRender;
|
||||||
|
if (
|
||||||
|
!pending ||
|
||||||
|
pending.requestId !== requestId ||
|
||||||
|
event.sender !== this.renderWindow?.webContents
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingRender = undefined;
|
||||||
|
pending.resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleRenderFailure = (
|
||||||
|
event: IpcMainEvent,
|
||||||
|
requestId: number,
|
||||||
|
message: string
|
||||||
|
) => {
|
||||||
|
const pending = this.pendingRender;
|
||||||
|
if (
|
||||||
|
!pending ||
|
||||||
|
pending.requestId !== requestId ||
|
||||||
|
event.sender !== this.renderWindow?.webContents
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingRender = undefined;
|
||||||
|
pending.reject(
|
||||||
|
new Error(
|
||||||
|
typeof message === "string" ? message : "桌面 PDF 分页失败"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
Menu,
|
||||||
|
net,
|
||||||
|
protocol,
|
||||||
|
session
|
||||||
|
} from "electron";
|
||||||
|
import { access, mkdir } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createApplicationService,
|
||||||
|
type ApplicationService
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
parseFontPackResourceUrl,
|
||||||
|
parseThemeResourceUrl
|
||||||
|
} from "./application-contract.js";
|
||||||
|
import {
|
||||||
|
DesktopApplicationController
|
||||||
|
} from "./desktop-application-controller.js";
|
||||||
|
import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
|
||||||
|
import { DESKTOP_DOCX_PARTITION } from "./electron-docx-media-engine.js";
|
||||||
|
import {
|
||||||
|
findMarkdownFileArgument,
|
||||||
|
isMarkdownFilePath
|
||||||
|
} from "./markdown-file.js";
|
||||||
|
import { migrateLegacyUserData } from "./user-data-migration.js";
|
||||||
|
|
||||||
|
const APP_SCHEME = "mdpdf";
|
||||||
|
const APP_HOST = "bundle";
|
||||||
|
const THEME_HOST = "theme";
|
||||||
|
const FONT_PACK_HOST = "font-pack";
|
||||||
|
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
let applicationController: DesktopApplicationController | undefined;
|
||||||
|
let pendingExternalMarkdownPath = findMarkdownFileArgument(
|
||||||
|
process.argv,
|
||||||
|
process.cwd()
|
||||||
|
);
|
||||||
|
|
||||||
|
function configurePackagedUserDataDirectory() {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const appDataDirectory = app.getPath("appData");
|
||||||
|
const targetDirectory = path.join(
|
||||||
|
appDataDirectory,
|
||||||
|
__APP_ENGLISH_NAME__
|
||||||
|
);
|
||||||
|
const targetAlreadyExists = existsSync(targetDirectory);
|
||||||
|
app.setPath("userData", targetDirectory);
|
||||||
|
if (targetAlreadyExists) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return migrateLegacyUserData(
|
||||||
|
[path.join(appDataDirectory, __APP_LEGACY_DISPLAY_NAME__)],
|
||||||
|
targetDirectory
|
||||||
|
).then(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDataMigration = configurePackagedUserDataDirectory();
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: APP_SCHEME,
|
||||||
|
privileges: {
|
||||||
|
standard: true,
|
||||||
|
secure: true,
|
||||||
|
supportFetchAPI: true,
|
||||||
|
stream: true,
|
||||||
|
codeCache: true,
|
||||||
|
corsEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getCommandLineValue(name: string) {
|
||||||
|
const prefix = `--${name}=`;
|
||||||
|
return process.argv
|
||||||
|
.find((argument) => argument.startsWith(prefix))
|
||||||
|
?.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebRoot() {
|
||||||
|
return app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "dist")
|
||||||
|
: path.resolve(currentDirectory, "../../web/dist");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApplicationIcon() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return path.join(
|
||||||
|
process.resourcesPath,
|
||||||
|
process.platform === "darwin" ? "app.icns" : "app.ico"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||||
|
return path.join(
|
||||||
|
projectRoot,
|
||||||
|
"logos",
|
||||||
|
"desktop",
|
||||||
|
process.platform === "darwin" ? "macos" : "windows",
|
||||||
|
process.platform === "darwin" ? "app.icns" : "app.ico"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeThemeAssetPath(assetPath: string) {
|
||||||
|
return assetPath
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchExternalMarkdown(filePath: string) {
|
||||||
|
if (!applicationController) {
|
||||||
|
pendingExternalMarkdownPath = filePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void applicationController.openExternalMarkdown(filePath).catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
console.warn("无法打开系统传入的 Markdown 文件", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopThemeDirectory() {
|
||||||
|
return app.isPackaged
|
||||||
|
? path.join(app.getPath("userData"), "themes")
|
||||||
|
: path.resolve(currentDirectory, "../../..", ".local", "themes");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopUserFontPackDirectory() {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return path.resolve(
|
||||||
|
currentDirectory,
|
||||||
|
"../../..",
|
||||||
|
".local",
|
||||||
|
"font-packs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const localApplicationData = process.env.LOCALAPPDATA?.trim();
|
||||||
|
return localApplicationData
|
||||||
|
? path.join(localApplicationData, "md-to-pdf", "font-packs")
|
||||||
|
: path.join(app.getPath("userData"), "font-packs");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDesktopApplicationService() {
|
||||||
|
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||||
|
const fontPackRoots = app.isPackaged
|
||||||
|
? [
|
||||||
|
path.join(process.resourcesPath, "font-packs"),
|
||||||
|
getDesktopUserFontPackDirectory()
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
path.join(projectRoot, "output", "font-packs", "root"),
|
||||||
|
getDesktopUserFontPackDirectory()
|
||||||
|
];
|
||||||
|
return createApplicationService({
|
||||||
|
bundledRoot: app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "themes")
|
||||||
|
: path.join(projectRoot, "themes"),
|
||||||
|
localRoot: getDesktopThemeDirectory(),
|
||||||
|
createAssetUrl: (themeId, assetPath) =>
|
||||||
|
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
||||||
|
fontPacks: {
|
||||||
|
roots: fontPackRoots,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
createAssetUrl: (
|
||||||
|
packId,
|
||||||
|
version,
|
||||||
|
faceId,
|
||||||
|
kind,
|
||||||
|
sha256
|
||||||
|
) =>
|
||||||
|
`${APP_SCHEME}://${FONT_PACK_HOST}/${encodeURIComponent(
|
||||||
|
packId
|
||||||
|
)}/${encodeURIComponent(version)}/${encodeURIComponent(
|
||||||
|
faceId
|
||||||
|
)}/${kind}?v=${sha256}`
|
||||||
|
},
|
||||||
|
onWarning: (message) => console.warn(message)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContainedPath(root: string, target: string) {
|
||||||
|
const relative = path.relative(root, target);
|
||||||
|
return (
|
||||||
|
relative !== ".." &&
|
||||||
|
!relative.startsWith(`..${path.sep}`) &&
|
||||||
|
!path.isAbsolute(relative)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerApplicationProtocol(
|
||||||
|
applicationService: ApplicationService
|
||||||
|
) {
|
||||||
|
const webRoot = getWebRoot();
|
||||||
|
const handleRequest = async (request: Request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.host === THEME_HOST) {
|
||||||
|
try {
|
||||||
|
const resource = parseThemeResourceUrl(request.url);
|
||||||
|
if (!resource) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const asset = await applicationService.getThemeAsset(
|
||||||
|
resource.themeId,
|
||||||
|
resource.assetPath
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(asset.content, {
|
||||||
|
headers: {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"cache-control": "public, max-age=300",
|
||||||
|
"content-type": asset.contentType,
|
||||||
|
"x-content-type-options": "nosniff"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.host === FONT_PACK_HOST) {
|
||||||
|
try {
|
||||||
|
const resource = parseFontPackResourceUrl(request.url);
|
||||||
|
if (!resource) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const asset = await applicationService.getFontPackAsset(
|
||||||
|
resource.packId,
|
||||||
|
resource.packVersion,
|
||||||
|
resource.faceId,
|
||||||
|
resource.kind
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(asset.content, {
|
||||||
|
headers: {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"cache-control": "public, max-age=31536000, immutable",
|
||||||
|
"content-type": asset.contentType,
|
||||||
|
etag: `\"${asset.sha256}\"`,
|
||||||
|
"x-content-type-options": "nosniff"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.host !== APP_HOST) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let relativePath: string;
|
||||||
|
try {
|
||||||
|
relativePath =
|
||||||
|
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
|
||||||
|
"index.html";
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
const target = path.resolve(webRoot, relativePath);
|
||||||
|
if (!isContainedPath(webRoot, target)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await access(target);
|
||||||
|
} catch {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
return net.fetch(pathToFileURL(target).href);
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
protocol.handle(APP_SCHEME, handleRequest),
|
||||||
|
session
|
||||||
|
.fromPartition(DESKTOP_PDF_PARTITION)
|
||||||
|
.protocol.handle(APP_SCHEME, handleRequest),
|
||||||
|
session
|
||||||
|
.fromPartition(DESKTOP_DOCX_PARTITION)
|
||||||
|
.protocol.handle(APP_SCHEME, handleRequest)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
let windowStateReadyToQuit = false;
|
||||||
|
|
||||||
|
if (!hasSingleInstanceLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", (_event, arguments_, workingDirectory) => {
|
||||||
|
const filePath = findMarkdownFileArgument(
|
||||||
|
arguments_,
|
||||||
|
workingDirectory
|
||||||
|
);
|
||||||
|
if (filePath) {
|
||||||
|
dispatchExternalMarkdown(filePath);
|
||||||
|
} else if (!applicationController?.focusMostRecentWindow()) {
|
||||||
|
void applicationController?.createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("open-file", (event, filePath) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isMarkdownFilePath(filePath)) {
|
||||||
|
dispatchExternalMarkdown(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (!applicationController?.focusMostRecentWindow()) {
|
||||||
|
void applicationController?.createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("before-quit", (event) => {
|
||||||
|
if (windowStateReadyToQuit || !applicationController) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void Promise.all([
|
||||||
|
applicationController.flushWindowState(),
|
||||||
|
applicationController.closeResources()
|
||||||
|
]).finally(() => {
|
||||||
|
windowStateReadyToQuit = true;
|
||||||
|
app.quit();
|
||||||
|
setTimeout(() => {
|
||||||
|
windowStateReadyToQuit = false;
|
||||||
|
}, 1_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app
|
||||||
|
.whenReady()
|
||||||
|
.then(async () => {
|
||||||
|
await userDataMigration;
|
||||||
|
Menu.setApplicationMenu(null);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
app.setAppUserModelId(__APP_ID__);
|
||||||
|
}
|
||||||
|
const themeDirectory = getDesktopThemeDirectory();
|
||||||
|
await mkdir(themeDirectory, { recursive: true });
|
||||||
|
const applicationService = createDesktopApplicationService();
|
||||||
|
await registerApplicationProtocol(applicationService);
|
||||||
|
|
||||||
|
const developmentUrl = getCommandLineValue("web-url");
|
||||||
|
const applicationUrl =
|
||||||
|
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
||||||
|
applicationController = new DesktopApplicationController({
|
||||||
|
applicationService,
|
||||||
|
applicationUrl,
|
||||||
|
renderUrl: new URL(
|
||||||
|
"/preview-frame.html?target=pdf",
|
||||||
|
applicationUrl
|
||||||
|
).href,
|
||||||
|
preloadPath: path.join(
|
||||||
|
currentDirectory,
|
||||||
|
"app-preload.cjs"
|
||||||
|
),
|
||||||
|
pdfPreloadPath: path.join(
|
||||||
|
currentDirectory,
|
||||||
|
"pdf-preload.cjs"
|
||||||
|
),
|
||||||
|
docxRenderUrl: new URL(
|
||||||
|
"/preview-frame.html?target=continuous",
|
||||||
|
applicationUrl
|
||||||
|
).href,
|
||||||
|
desktopResourcesPath: process.resourcesPath,
|
||||||
|
iconPath: getApplicationIcon(),
|
||||||
|
themeDirectory,
|
||||||
|
windowStatePath: path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"window-state.json"
|
||||||
|
),
|
||||||
|
saveLocationStatePath: path.join(
|
||||||
|
app.getPath("userData"),
|
||||||
|
"save-location.json"
|
||||||
|
),
|
||||||
|
documentsDirectory: app.getPath("documents")
|
||||||
|
});
|
||||||
|
await applicationController.initialize();
|
||||||
|
|
||||||
|
const startupMarkdownPath = pendingExternalMarkdownPath;
|
||||||
|
pendingExternalMarkdownPath = undefined;
|
||||||
|
await applicationController.createWindow(startupMarkdownPath);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("桌面应用启动失败", error);
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, realpath, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const markdownExtensions = new Set([".md", ".markdown"]);
|
||||||
|
|
||||||
|
export interface OpenedMarkdownDocument {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownFileSnapshot {
|
||||||
|
document: OpenedMarkdownDocument;
|
||||||
|
filePath: string;
|
||||||
|
documentKey: string;
|
||||||
|
contentHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownContentHash(markdown: string) {
|
||||||
|
return createHash("sha256").update(markdown, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownDocumentKey(filePath: string) {
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
|
return process.platform === "win32"
|
||||||
|
? normalized.toLocaleLowerCase("en-US")
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarkdownFilePath(filePath: string) {
|
||||||
|
return markdownExtensions.has(path.extname(filePath).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMarkdownFileArgument(
|
||||||
|
arguments_: string[],
|
||||||
|
workingDirectory: string
|
||||||
|
) {
|
||||||
|
for (const argument of arguments_) {
|
||||||
|
if (!argument || argument.startsWith("-")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = path.isAbsolute(argument)
|
||||||
|
? path.normalize(argument)
|
||||||
|
: path.resolve(workingDirectory, argument);
|
||||||
|
if (isMarkdownFilePath(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMarkdownDocument(
|
||||||
|
filePath: string,
|
||||||
|
maximumLength: number
|
||||||
|
): Promise<OpenedMarkdownDocument> {
|
||||||
|
if (!isMarkdownFilePath(filePath)) {
|
||||||
|
throw new Error("只允许打开 .md 或 .markdown 文件");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
|
||||||
|
throw new Error("Markdown 长度限制无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStats = await stat(filePath);
|
||||||
|
if (!fileStats.isFile()) {
|
||||||
|
throw new Error("Markdown 路径不是文件");
|
||||||
|
}
|
||||||
|
if (fileStats.size > maximumLength * 4) {
|
||||||
|
throw new Error("Markdown 文件过大");
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await readFile(filePath, "utf8");
|
||||||
|
if (markdown.length > maximumLength) {
|
||||||
|
throw new Error("Markdown 文件过大");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
fileName: path.basename(filePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMarkdownFileSnapshot(
|
||||||
|
filePath: string,
|
||||||
|
maximumLength: number
|
||||||
|
): Promise<MarkdownFileSnapshot> {
|
||||||
|
const canonicalPath = await realpath(filePath);
|
||||||
|
const document = await readMarkdownDocument(
|
||||||
|
canonicalPath,
|
||||||
|
maximumLength
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
filePath: canonicalPath,
|
||||||
|
documentKey: createMarkdownDocumentKey(canonicalPath),
|
||||||
|
contentHash: createMarkdownContentHash(document.markdown)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
exportConfigSchema,
|
||||||
|
semanticDocumentModelSchema,
|
||||||
|
themeFeatureSchema,
|
||||||
|
type PagedDocumentPayload,
|
||||||
|
type PagedDocumentRenderResult
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
|
||||||
|
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
maximum: number
|
||||||
|
) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string" || value.length > maximum) {
|
||||||
|
throw new Error(`${key} 无效`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(value: unknown, key: string, maximum: number) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length > maximum ||
|
||||||
|
value.some((item) => typeof item !== "string")
|
||||||
|
) {
|
||||||
|
throw new Error(`${key} 无效`);
|
||||||
|
}
|
||||||
|
return value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePagedDocumentPayload(
|
||||||
|
value: unknown
|
||||||
|
): PagedDocumentPayload {
|
||||||
|
if (!isRecord(value) || !isRecord(value.metadata)) {
|
||||||
|
throw new Error("PDF 分页载荷无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = value.metadata;
|
||||||
|
const features = readStringArray(value.features, "features", 20);
|
||||||
|
const parsedFeatures = features.map((feature) =>
|
||||||
|
themeFeatureSchema.parse(feature)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
articleHtml: readString(
|
||||||
|
value,
|
||||||
|
"articleHtml",
|
||||||
|
MAX_ARTICLE_HTML_LENGTH
|
||||||
|
),
|
||||||
|
fileName: readString(value, "fileName", 500),
|
||||||
|
metadata: {
|
||||||
|
title: readString(metadata, "title", 500),
|
||||||
|
author: readString(metadata, "author", 500),
|
||||||
|
subject: readString(metadata, "subject", 1_000),
|
||||||
|
keywords: readStringArray(
|
||||||
|
metadata.keywords,
|
||||||
|
"metadata.keywords",
|
||||||
|
100
|
||||||
|
),
|
||||||
|
language: readString(metadata, "language", 50)
|
||||||
|
},
|
||||||
|
semanticDocument: semanticDocumentModelSchema.parse(
|
||||||
|
value.semanticDocument
|
||||||
|
),
|
||||||
|
features: parsedFeatures,
|
||||||
|
themeCss: readString(
|
||||||
|
value,
|
||||||
|
"themeCss",
|
||||||
|
MAX_THEME_CSS_LENGTH
|
||||||
|
),
|
||||||
|
exportConfig: exportConfigSchema.parse(value.exportConfig)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronPdfPrintOptions {
|
||||||
|
displayHeaderFooter: false;
|
||||||
|
margins: {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
pageRanges: string;
|
||||||
|
preferCSSPageSize: true;
|
||||||
|
printBackground: boolean;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createElectronPdfPrintOptions(
|
||||||
|
payload: PagedDocumentPayload,
|
||||||
|
renderResult: PagedDocumentRenderResult
|
||||||
|
): ElectronPdfPrintOptions {
|
||||||
|
return {
|
||||||
|
displayHeaderFooter: false,
|
||||||
|
margins: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0
|
||||||
|
},
|
||||||
|
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
printBackground: payload.exportConfig.print.background,
|
||||||
|
scale: payload.exportConfig.print.scale
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedPdfRuntimeUrl(
|
||||||
|
requestUrl: string,
|
||||||
|
renderUrl: string
|
||||||
|
) {
|
||||||
|
const requested = new URL(requestUrl);
|
||||||
|
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
requested.protocol === "mdpdf:" &&
|
||||||
|
["theme", "font-pack"].includes(requested.host)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return requested.origin === new URL(renderUrl).origin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
contextBridge,
|
||||||
|
ipcRenderer,
|
||||||
|
type IpcRendererEvent
|
||||||
|
} from "electron";
|
||||||
|
import type {
|
||||||
|
PagedDocumentPayload,
|
||||||
|
PagedDocumentRenderResult
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
PDF_RUNTIME_COMPLETE,
|
||||||
|
PDF_RUNTIME_FAIL,
|
||||||
|
PDF_RUNTIME_READY,
|
||||||
|
PDF_RUNTIME_RENDER
|
||||||
|
} from "./channels.js";
|
||||||
|
|
||||||
|
type RenderListener = (
|
||||||
|
requestId: number,
|
||||||
|
payload: PagedDocumentPayload
|
||||||
|
) => Promise<void> | void;
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
|
||||||
|
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
|
||||||
|
onRender: (listener: RenderListener) => {
|
||||||
|
const handler = (
|
||||||
|
_event: IpcRendererEvent,
|
||||||
|
requestId: number,
|
||||||
|
payload: PagedDocumentPayload
|
||||||
|
) => {
|
||||||
|
void listener(requestId, payload);
|
||||||
|
};
|
||||||
|
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
|
||||||
|
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
|
||||||
|
},
|
||||||
|
complete: (
|
||||||
|
requestId: number,
|
||||||
|
result: PagedDocumentRenderResult
|
||||||
|
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
|
||||||
|
fail: (requestId: number, message: string) =>
|
||||||
|
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SAVE_LOCATION_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
export interface SaveLocationState {
|
||||||
|
version: 1;
|
||||||
|
directory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveDefaultSavePathOptions {
|
||||||
|
sourceFilePath?: string;
|
||||||
|
lastSaveDirectory?: string;
|
||||||
|
documentsDirectory: string;
|
||||||
|
suggestedFileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSaveLocationState(
|
||||||
|
value: unknown
|
||||||
|
): SaveLocationState | undefined {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const state = value as Partial<SaveLocationState>;
|
||||||
|
if (
|
||||||
|
state.version !== SAVE_LOCATION_STATE_VERSION ||
|
||||||
|
typeof state.directory !== "string" ||
|
||||||
|
!path.isAbsolute(state.directory)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: SAVE_LOCATION_STATE_VERSION,
|
||||||
|
directory: path.resolve(state.directory)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAvailableDirectory(directory: string | undefined) {
|
||||||
|
if (!directory || !path.isAbsolute(directory)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await stat(directory)).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSuggestedFileName(value: string) {
|
||||||
|
const fileName = path.basename(value.trim());
|
||||||
|
if (!fileName || fileName === ".") {
|
||||||
|
throw new Error("默认保存文件名无效");
|
||||||
|
}
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveDefaultSavePath(
|
||||||
|
options: ResolveDefaultSavePathOptions
|
||||||
|
) {
|
||||||
|
const fileName = assertSuggestedFileName(
|
||||||
|
options.suggestedFileName
|
||||||
|
);
|
||||||
|
const sourceDirectory = options.sourceFilePath
|
||||||
|
? path.dirname(path.resolve(options.sourceFilePath))
|
||||||
|
: undefined;
|
||||||
|
if (await isAvailableDirectory(sourceDirectory)) {
|
||||||
|
return path.join(sourceDirectory!, fileName);
|
||||||
|
}
|
||||||
|
if (await isAvailableDirectory(options.lastSaveDirectory)) {
|
||||||
|
return path.join(options.lastSaveDirectory!, fileName);
|
||||||
|
}
|
||||||
|
return path.join(
|
||||||
|
path.resolve(options.documentsDirectory),
|
||||||
|
fileName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSaveLocationState(filePath: string) {
|
||||||
|
try {
|
||||||
|
return parseSaveLocationState(
|
||||||
|
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSaveLocationState(
|
||||||
|
filePath: string,
|
||||||
|
directory: string
|
||||||
|
) {
|
||||||
|
const state = parseSaveLocationState({
|
||||||
|
version: SAVE_LOCATION_STATE_VERSION,
|
||||||
|
directory
|
||||||
|
});
|
||||||
|
if (!state) {
|
||||||
|
throw new Error("最近保存目录无效");
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveLocationStore {
|
||||||
|
readonly #statePath: string;
|
||||||
|
readonly #documentsDirectory: string;
|
||||||
|
#lastSaveDirectory: string | undefined;
|
||||||
|
#pendingWrite = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(statePath: string, documentsDirectory: string) {
|
||||||
|
this.#statePath = statePath;
|
||||||
|
this.#documentsDirectory = documentsDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this.#lastSaveDirectory = (
|
||||||
|
await loadSaveLocationState(this.#statePath)
|
||||||
|
)?.directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDefaultPath(
|
||||||
|
sourceFilePath: string | undefined,
|
||||||
|
suggestedFileName: string
|
||||||
|
) {
|
||||||
|
return resolveDefaultSavePath({
|
||||||
|
documentsDirectory: this.#documentsDirectory,
|
||||||
|
suggestedFileName,
|
||||||
|
...(sourceFilePath ? { sourceFilePath } : {}),
|
||||||
|
...(this.#lastSaveDirectory
|
||||||
|
? { lastSaveDirectory: this.#lastSaveDirectory }
|
||||||
|
: {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordSuccessfulFile(filePath: string) {
|
||||||
|
const directory = path.dirname(path.resolve(filePath));
|
||||||
|
this.#lastSaveDirectory = directory;
|
||||||
|
const write = this.#pendingWrite
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() =>
|
||||||
|
saveSaveLocationState(this.#statePath, directory)
|
||||||
|
);
|
||||||
|
this.#pendingWrite = write.then(() => undefined);
|
||||||
|
await write;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { cp, lstat, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const MIGRATED_ENTRIES = [
|
||||||
|
"window-state.json",
|
||||||
|
"save-location.json",
|
||||||
|
"themes",
|
||||||
|
"Local Storage",
|
||||||
|
"Session Storage",
|
||||||
|
"IndexedDB"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function readOrdinaryStatus(target: string) {
|
||||||
|
try {
|
||||||
|
const status = await lstat(target);
|
||||||
|
return status.isSymbolicLink() ? undefined : status;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyUserDataMigrationResult {
|
||||||
|
migrated: boolean;
|
||||||
|
sourceDirectory?: string;
|
||||||
|
copiedEntries: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateLegacyUserData(
|
||||||
|
legacyDirectories: readonly string[],
|
||||||
|
targetDirectory: string
|
||||||
|
): Promise<LegacyUserDataMigrationResult> {
|
||||||
|
if (await readOrdinaryStatus(targetDirectory)) {
|
||||||
|
return { migrated: false, copiedEntries: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const legacyDirectory of legacyDirectories) {
|
||||||
|
if (path.resolve(legacyDirectory) === path.resolve(targetDirectory)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const legacyStatus = await readOrdinaryStatus(legacyDirectory);
|
||||||
|
if (!legacyStatus?.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(targetDirectory, { recursive: true });
|
||||||
|
const copiedEntries: string[] = [];
|
||||||
|
for (const entry of MIGRATED_ENTRIES) {
|
||||||
|
const source = path.join(legacyDirectory, entry);
|
||||||
|
if (!(await readOrdinaryStatus(source))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await cp(source, path.join(targetDirectory, entry), {
|
||||||
|
recursive: true,
|
||||||
|
errorOnExist: true,
|
||||||
|
force: false
|
||||||
|
});
|
||||||
|
copiedEntries.push(entry);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
migrated: true,
|
||||||
|
sourceDirectory: legacyDirectory,
|
||||||
|
copiedEntries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { migrated: false, copiedEntries: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface WindowCloseState {
|
||||||
|
dirty: boolean;
|
||||||
|
allowClose: boolean;
|
||||||
|
closeRequestPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WindowCloseDecision = "allow" | "request" | "wait";
|
||||||
|
|
||||||
|
export function getWindowCloseDecision(
|
||||||
|
state: WindowCloseState
|
||||||
|
): WindowCloseDecision {
|
||||||
|
if (!state.dirty || state.allowClose) {
|
||||||
|
return "allow";
|
||||||
|
}
|
||||||
|
return state.closeRequestPending ? "wait" : "request";
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface WindowBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowState {
|
||||||
|
version: 1;
|
||||||
|
bounds: WindowBounds;
|
||||||
|
maximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOW_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWindowBounds(value: unknown): WindowBounds | undefined {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const bounds = value as Partial<WindowBounds>;
|
||||||
|
if (
|
||||||
|
!isFiniteNumber(bounds.x) ||
|
||||||
|
!isFiniteNumber(bounds.y) ||
|
||||||
|
!isFiniteNumber(bounds.width) ||
|
||||||
|
!isFiniteNumber(bounds.height) ||
|
||||||
|
bounds.width <= 0 ||
|
||||||
|
bounds.height <= 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: Math.round(bounds.x),
|
||||||
|
y: Math.round(bounds.y),
|
||||||
|
width: Math.round(bounds.width),
|
||||||
|
height: Math.round(bounds.height)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWindowState(value: unknown): WindowState | undefined {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const state = value as Partial<WindowState>;
|
||||||
|
const bounds = parseWindowBounds(state.bounds);
|
||||||
|
if (
|
||||||
|
state.version !== WINDOW_STATE_VERSION ||
|
||||||
|
!bounds ||
|
||||||
|
typeof state.maximized !== "boolean"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: WINDOW_STATE_VERSION,
|
||||||
|
bounds,
|
||||||
|
maximized: state.maximized
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectionArea(left: WindowBounds, right: WindowBounds) {
|
||||||
|
const width = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(left.x + left.width, right.x + right.width) -
|
||||||
|
Math.max(left.x, right.x)
|
||||||
|
);
|
||||||
|
const height = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(left.y + left.height, right.y + right.height) -
|
||||||
|
Math.max(left.y, right.y)
|
||||||
|
);
|
||||||
|
return width * height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(1, Math.round(size.width)),
|
||||||
|
workArea.width
|
||||||
|
);
|
||||||
|
const height = Math.min(
|
||||||
|
Math.max(1, Math.round(size.height)),
|
||||||
|
workArea.height
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x: Math.round(workArea.x + (workArea.width - width) / 2),
|
||||||
|
y: Math.round(workArea.y + (workArea.height - height) / 2),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWindowBounds(
|
||||||
|
savedBounds: WindowBounds | undefined,
|
||||||
|
workAreas: WindowBounds[],
|
||||||
|
primaryWorkArea: WindowBounds,
|
||||||
|
defaultSize: WindowSize,
|
||||||
|
minimumSize: WindowSize
|
||||||
|
): WindowBounds {
|
||||||
|
if (!savedBounds) {
|
||||||
|
return centerBounds(defaultSize, primaryWorkArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedArea = primaryWorkArea;
|
||||||
|
let selectedIntersection = 0;
|
||||||
|
for (const workArea of workAreas) {
|
||||||
|
const area = intersectionArea(savedBounds, workArea);
|
||||||
|
if (area > selectedIntersection) {
|
||||||
|
selectedArea = workArea;
|
||||||
|
selectedIntersection = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIntersection === 0) {
|
||||||
|
return centerBounds(
|
||||||
|
{
|
||||||
|
width: Math.min(
|
||||||
|
Math.max(savedBounds.width, minimumSize.width),
|
||||||
|
primaryWorkArea.width
|
||||||
|
),
|
||||||
|
height: Math.min(
|
||||||
|
Math.max(savedBounds.height, minimumSize.height),
|
||||||
|
primaryWorkArea.height
|
||||||
|
)
|
||||||
|
},
|
||||||
|
primaryWorkArea
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(savedBounds.width, minimumSize.width),
|
||||||
|
selectedArea.width
|
||||||
|
);
|
||||||
|
const height = Math.min(
|
||||||
|
Math.max(savedBounds.height, minimumSize.height),
|
||||||
|
selectedArea.height
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x: clamp(
|
||||||
|
savedBounds.x,
|
||||||
|
selectedArea.x,
|
||||||
|
selectedArea.x + selectedArea.width - width
|
||||||
|
),
|
||||||
|
y: clamp(
|
||||||
|
savedBounds.y,
|
||||||
|
selectedArea.y,
|
||||||
|
selectedArea.y + selectedArea.height - height
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWindowState(filePath: string) {
|
||||||
|
try {
|
||||||
|
return parseWindowState(
|
||||||
|
JSON.parse(await readFile(filePath, "utf8")) as unknown
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWindowState(
|
||||||
|
filePath: string,
|
||||||
|
state: WindowState
|
||||||
|
) {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
temporaryPath,
|
||||||
|
`${JSON.stringify(state, null, 2)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await rename(temporaryPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
|
||||||
|
|
||||||
|
describe("桌面文档链接动作", () => {
|
||||||
|
const markdownPath = path.resolve("D:/文档/章节/当前.md");
|
||||||
|
|
||||||
|
it("保留锚点并把网络和自定义协议交给系统", () => {
|
||||||
|
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
|
||||||
|
type: "anchor",
|
||||||
|
href: "#标题"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("//example.com/path")
|
||||||
|
).toEqual({
|
||||||
|
type: "external",
|
||||||
|
href: "https://example.com/path"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("mailto:user@example.com")
|
||||||
|
).toEqual({
|
||||||
|
type: "external",
|
||||||
|
href: "mailto:user@example.com"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
|
||||||
|
).toEqual({
|
||||||
|
type: "external",
|
||||||
|
href: "obsidian://open?vault=docs"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("允许相对路径越过 Markdown 目录", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(
|
||||||
|
"../../共享/说明.pdf#page=2",
|
||||||
|
markdownPath
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.resolve(
|
||||||
|
path.dirname(markdownPath),
|
||||||
|
"../../共享/说明.pdf"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("解析绝对路径、file URI 和 URL 编码", () => {
|
||||||
|
const absolutePath = path.resolve("C:/资料/项目说明.md");
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.normalize(absolutePath)
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(
|
||||||
|
pathToFileURL(absolutePath).href,
|
||||||
|
markdownPath
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.normalize(absolutePath)
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction(
|
||||||
|
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
|
||||||
|
markdownPath
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
type: "local",
|
||||||
|
filePath: path.resolve(
|
||||||
|
path.dirname(markdownPath),
|
||||||
|
"../共享/说明.pdf"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝危险链接和无文件基准的相对路径", () => {
|
||||||
|
expect(
|
||||||
|
resolveDesktopDocumentLinkAction("javascript:alert(1)")
|
||||||
|
).toEqual({ type: "ignore" });
|
||||||
|
expect(() =>
|
||||||
|
resolveDesktopDocumentLinkAction("../说明.pdf")
|
||||||
|
).toThrow("尚未保存");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveDesktopDocxTargetPath,
|
||||||
|
saveDesktopDocx
|
||||||
|
} from "../src/desktop-docx-save.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTemporaryDirectory() {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-desktop-docx-save-")
|
||||||
|
);
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Desktop DOCX 原生保存", () => {
|
||||||
|
it("在用户未填写扩展名时补充 .docx 并原样落盘", async () => {
|
||||||
|
const directory = await createTemporaryDirectory();
|
||||||
|
const content = Uint8Array.of(80, 75, 3, 4, 1, 2, 3);
|
||||||
|
const selectedPath = path.join(directory, "桌面验收");
|
||||||
|
|
||||||
|
const saved = await saveDesktopDocx(selectedPath, content);
|
||||||
|
|
||||||
|
expect(saved).toEqual({
|
||||||
|
targetPath: `${selectedPath}.docx`,
|
||||||
|
fileName: "桌面验收.docx"
|
||||||
|
});
|
||||||
|
expect(await readFile(saved.targetPath)).toEqual(Buffer.from(content));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("大小写不敏感地保留已有 DOCX 扩展名", () => {
|
||||||
|
expect(resolveDesktopDocxTargetPath("报告.DOCX")).toBe(
|
||||||
|
"报告.DOCX"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝空路径或空内容", async () => {
|
||||||
|
await expect(
|
||||||
|
saveDesktopDocx("", Uint8Array.of(80, 75))
|
||||||
|
).rejects.toThrow("DOCX 保存参数无效");
|
||||||
|
await expect(
|
||||||
|
saveDesktopDocx("报告.docx", new Uint8Array())
|
||||||
|
).rejects.toThrow("DOCX 保存参数无效");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ApplicationRequestError,
|
||||||
|
DocxExportServiceError
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import { toDesktopDocxExportFailure } from "../src/docx-contract.js";
|
||||||
|
|
||||||
|
describe("Desktop DOCX IPC 契约", () => {
|
||||||
|
it("映射应用请求错误", () => {
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new ApplicationRequestError(
|
||||||
|
400,
|
||||||
|
"INVALID_EXPORT_CONFIG",
|
||||||
|
"导出配置无效"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "INVALID_EXPORT_CONFIG",
|
||||||
|
message: "导出配置无效",
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保留共享服务的重试语义", () => {
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new DocxExportServiceError(
|
||||||
|
429,
|
||||||
|
"DOCX_QUEUE_FULL",
|
||||||
|
"DOCX 生成队列已满",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "DOCX_QUEUE_FULL",
|
||||||
|
message: "DOCX 生成队列已满",
|
||||||
|
retryable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("仅透传格式受控的桌面媒体超时详情", () => {
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new DocxExportServiceError(
|
||||||
|
500,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出失败",
|
||||||
|
false,
|
||||||
|
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new DocxExportServiceError(
|
||||||
|
500,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出失败",
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
cause: new Error(
|
||||||
|
"桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
message:
|
||||||
|
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保留 Front Matter 解析错误", () => {
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new ApplicationRequestError(
|
||||||
|
400,
|
||||||
|
"INVALID_FRONT_MATTER",
|
||||||
|
"无法解析 Markdown Front Matter"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "INVALID_FRONT_MATTER",
|
||||||
|
message: "无法解析 Markdown Front Matter",
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("隐藏未知主进程错误细节", () => {
|
||||||
|
expect(
|
||||||
|
toDesktopDocxExportFailure(
|
||||||
|
new Error("C:\\secret\\pandoc.exe 启动失败")
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "DOCX_GENERATION_FAILED",
|
||||||
|
message: "DOCX 导出失败",
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { WebContents } from "electron";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig,
|
||||||
|
type DocxMediaCapturePlan
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { captureDocxMediaWithElectronWebContents } from "../src/electron-docx-media-capture.js";
|
||||||
|
|
||||||
|
const plan: DocxMediaCapturePlan = {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
id: "docx-media-1",
|
||||||
|
kind: "image",
|
||||||
|
ordinal: 1,
|
||||||
|
kindOrdinal: 1,
|
||||||
|
altText: "图片",
|
||||||
|
alignment: "center",
|
||||||
|
displayWidthPx: 200,
|
||||||
|
displayHeightPx: 100,
|
||||||
|
captureX: 12,
|
||||||
|
captureY: 24,
|
||||||
|
captureWidthPx: 200,
|
||||||
|
captureHeightPx: 100,
|
||||||
|
rasterScale: 3.125
|
||||||
|
}
|
||||||
|
],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
payload: createPagedDocumentPayload({
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"></article>',
|
||||||
|
bodyHtml: "",
|
||||||
|
metadata: {
|
||||||
|
title: "",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
semanticDocument: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
titlePolicy: {
|
||||||
|
metadataTitle: "emit",
|
||||||
|
firstBodyHeading: "keep"
|
||||||
|
},
|
||||||
|
regions: []
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
fileName: "test.md",
|
||||||
|
themeCss: "",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
}),
|
||||||
|
dimensions: {
|
||||||
|
contentWidthPx: 600,
|
||||||
|
contentHeightPx: 900
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Electron DOCX 媒体捕获", () => {
|
||||||
|
it("复用隐藏 Chromium 并在捕获后释放调试会话", async () => {
|
||||||
|
let attached = false;
|
||||||
|
const attach = vi.fn(() => {
|
||||||
|
attached = true;
|
||||||
|
});
|
||||||
|
const detach = vi.fn(() => {
|
||||||
|
attached = false;
|
||||||
|
});
|
||||||
|
const sendCommand = vi.fn(async () => ({
|
||||||
|
data: Buffer.from("png").toString("base64")
|
||||||
|
}));
|
||||||
|
const webContents = {
|
||||||
|
executeJavaScript: vi.fn(async () => plan),
|
||||||
|
debugger: {
|
||||||
|
isAttached: vi.fn(() => attached),
|
||||||
|
attach,
|
||||||
|
detach,
|
||||||
|
sendCommand
|
||||||
|
}
|
||||||
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
const result = await captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.captures[0]).toEqual({
|
||||||
|
id: "docx-media-1",
|
||||||
|
png: Buffer.from("png")
|
||||||
|
});
|
||||||
|
expect(attach).toHaveBeenCalledWith("1.3");
|
||||||
|
expect(sendCommand).toHaveBeenCalledWith(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "print" }
|
||||||
|
);
|
||||||
|
expect(sendCommand).toHaveBeenCalledWith(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "screen" }
|
||||||
|
);
|
||||||
|
expect(sendCommand).toHaveBeenCalledWith(
|
||||||
|
"Page.captureScreenshot",
|
||||||
|
expect.objectContaining({
|
||||||
|
format: "png",
|
||||||
|
clip: expect.objectContaining({ scale: 3.125 })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||||
|
let attached = false;
|
||||||
|
const webContents = {
|
||||||
|
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||||
|
debugger: {
|
||||||
|
isAttached: vi.fn(() => attached),
|
||||||
|
attach: vi.fn(() => {
|
||||||
|
attached = true;
|
||||||
|
}),
|
||||||
|
detach: vi.fn(() => {
|
||||||
|
attached = false;
|
||||||
|
}),
|
||||||
|
sendCommand: vi.fn(async () => undefined)
|
||||||
|
}
|
||||||
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents,
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
{ renderMs: 10, screenshotMs: 10 }
|
||||||
|
)
|
||||||
|
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
|
||||||
|
let attached = false;
|
||||||
|
const detach = vi.fn(() => {
|
||||||
|
attached = false;
|
||||||
|
});
|
||||||
|
const webContents = {
|
||||||
|
executeJavaScript: vi.fn(async () => plan),
|
||||||
|
debugger: {
|
||||||
|
isAttached: vi.fn(() => attached),
|
||||||
|
attach: vi.fn(() => {
|
||||||
|
attached = true;
|
||||||
|
}),
|
||||||
|
detach,
|
||||||
|
sendCommand: vi.fn((method: string) =>
|
||||||
|
method === "Page.captureScreenshot"
|
||||||
|
? new Promise(() => undefined)
|
||||||
|
: Promise.resolve(undefined)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} as unknown as WebContents;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
captureDocxMediaWithElectronWebContents(
|
||||||
|
webContents,
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
{ renderMs: 10, screenshotMs: 10 }
|
||||||
|
)
|
||||||
|
).rejects.toThrow(
|
||||||
|
"桌面 DOCX 第 1/1 个媒体(image)PNG 捕获超过 10ms"
|
||||||
|
);
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
let destroyed = false;
|
||||||
|
return {
|
||||||
|
showInactive: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
isDestroyed: vi.fn(() => destroyed),
|
||||||
|
destroy: () => {
|
||||||
|
destroyed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Electron DOCX 捕获合成表面", () => {
|
||||||
|
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
|
||||||
|
const window = createWindow();
|
||||||
|
const operation = vi.fn(async () => "captured");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withActiveDocxCaptureSurface(window, operation, 0)
|
||||||
|
).resolves.toBe("captured");
|
||||||
|
|
||||||
|
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||||
|
expect(operation).toHaveBeenCalledOnce();
|
||||||
|
expect(window.hide).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("窗口在失败期间销毁时不再隐藏", async () => {
|
||||||
|
const window = createWindow();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withActiveDocxCaptureSurface(
|
||||||
|
window,
|
||||||
|
async () => {
|
||||||
|
window.destroy();
|
||||||
|
throw new Error("capture failed");
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
|
).rejects.toThrow("capture failed");
|
||||||
|
|
||||||
|
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||||
|
expect(window.hide).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
|
type DocxComputedStyle,
|
||||||
|
type DocxThemeStyleCaptureRequest
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { captureDocxThemeStyleWithElectronWebContents } from "../src/electron-docx-theme-style.js";
|
||||||
|
|
||||||
|
const computed: DocxComputedStyle = {
|
||||||
|
fontFamily: "Arial",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "400",
|
||||||
|
fontStyle: "normal",
|
||||||
|
color: "rgb(0, 0, 0)",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "normal",
|
||||||
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
|
textIndent: "0px",
|
||||||
|
textDecorationLine: "none",
|
||||||
|
marginTop: "0px",
|
||||||
|
marginRight: "0px",
|
||||||
|
marginBottom: "0px",
|
||||||
|
marginLeft: "0px",
|
||||||
|
paddingTop: "0px",
|
||||||
|
paddingRight: "0px",
|
||||||
|
paddingBottom: "0px",
|
||||||
|
paddingLeft: "0px",
|
||||||
|
borderTop: "0px none rgb(0, 0, 0)",
|
||||||
|
borderRight: "0px none rgb(0, 0, 0)",
|
||||||
|
borderBottom: "0px none rgb(0, 0, 0)",
|
||||||
|
borderLeft: "0px none rgb(0, 0, 0)",
|
||||||
|
width: "640px",
|
||||||
|
maxWidth: "none",
|
||||||
|
minWidth: "0px",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
|
breakBefore: "auto",
|
||||||
|
breakAfter: "auto",
|
||||||
|
breakInside: "auto",
|
||||||
|
display: "block",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "normal",
|
||||||
|
justifyContent: "normal",
|
||||||
|
outline: "rgb(0, 0, 0) none 0px",
|
||||||
|
outlineOffset: "0px"
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: DocxThemeStyleCaptureRequest = {
|
||||||
|
themeId: "external-clean",
|
||||||
|
themeFingerprint: "b".repeat(64),
|
||||||
|
themeCss: "#write { color: #000; }",
|
||||||
|
baseUrl: "mdpdf://app/"
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Electron DOCX 主题样式采集", () => {
|
||||||
|
it("通过 CDP 切换打印媒体并在结束后恢复", async () => {
|
||||||
|
let attached = false;
|
||||||
|
const sendCommand = vi.fn(async () => undefined);
|
||||||
|
const webContents = {
|
||||||
|
debugger: {
|
||||||
|
isAttached: () => attached,
|
||||||
|
attach: vi.fn(() => {
|
||||||
|
attached = true;
|
||||||
|
}),
|
||||||
|
detach: vi.fn(() => {
|
||||||
|
attached = false;
|
||||||
|
}),
|
||||||
|
sendCommand
|
||||||
|
},
|
||||||
|
executeJavaScript: vi.fn(async () => ({
|
||||||
|
rootFontSizePx: 16,
|
||||||
|
viewport: {
|
||||||
|
widthPx: 794,
|
||||||
|
heightPx: 1123,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
},
|
||||||
|
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
|
||||||
|
slot,
|
||||||
|
matched: true,
|
||||||
|
computed
|
||||||
|
}))
|
||||||
|
})),
|
||||||
|
isDestroyed: () => false
|
||||||
|
};
|
||||||
|
const snapshot =
|
||||||
|
await captureDocxThemeStyleWithElectronWebContents(
|
||||||
|
webContents as never,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
expect(sendCommand).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "print" }
|
||||||
|
);
|
||||||
|
expect(sendCommand).toHaveBeenLastCalledWith(
|
||||||
|
"Emulation.setEmulatedMedia",
|
||||||
|
{ media: "screen" }
|
||||||
|
);
|
||||||
|
expect(webContents.debugger.detach).toHaveBeenCalledTimes(1);
|
||||||
|
expect(snapshot.slots).toHaveLength(
|
||||||
|
DOCX_STYLE_SLOT_NAMES.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createMarkdownContentHash,
|
||||||
|
createMarkdownDocumentKey,
|
||||||
|
findMarkdownFileArgument,
|
||||||
|
isMarkdownFilePath,
|
||||||
|
readMarkdownDocument,
|
||||||
|
readMarkdownFileSnapshot
|
||||||
|
} from "../src/markdown-file.js";
|
||||||
|
|
||||||
|
describe("桌面 Markdown 文件参数", () => {
|
||||||
|
it("识别两种 Markdown 扩展名且忽略大小写", () => {
|
||||||
|
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
|
||||||
|
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
|
||||||
|
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// findMarkdownFileArgument 依赖 Node 的 path 模块识别绝对路径,其行为
|
||||||
|
// 随宿主 OS 而定:Windows 风格盘符路径只有在 Windows 宿主上才会被
|
||||||
|
// 识别为绝对路径,这与真实部署一致(Windows 安装包只会在 Windows 上
|
||||||
|
// 收到 Windows 风格的启动参数),因此该用例仅在 Windows 宿主上有意义。
|
||||||
|
it.runIf(process.platform === "win32")("从启动参数中提取绝对 Markdown 路径", () => {
|
||||||
|
expect(
|
||||||
|
findMarkdownFileArgument(
|
||||||
|
[
|
||||||
|
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
|
||||||
|
"--original-process-start-time=1",
|
||||||
|
"D:\\文档\\项目周报.md"
|
||||||
|
],
|
||||||
|
"C:\\Windows"
|
||||||
|
)
|
||||||
|
).toBe(path.normalize("D:\\文档\\项目周报.md"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根据第二实例的工作目录解析相对路径", () => {
|
||||||
|
expect(
|
||||||
|
findMarkdownFileArgument(
|
||||||
|
["md-to-pdf.exe", "docs\\readme.markdown"],
|
||||||
|
"D:\\项目"
|
||||||
|
)
|
||||||
|
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("忽略非 Markdown 文件及 Chromium 参数", () => {
|
||||||
|
expect(
|
||||||
|
findMarkdownFileArgument(
|
||||||
|
["md-to-pdf.exe", "--inspect", "notes.txt"],
|
||||||
|
"C:\\项目"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("统一读取 Markdown 并限制解码后的字符数", async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-markdown-")
|
||||||
|
);
|
||||||
|
const filePath = path.join(directory, "示例.md");
|
||||||
|
try {
|
||||||
|
await writeFile(filePath, "# 示例", "utf8");
|
||||||
|
await expect(
|
||||||
|
readMarkdownDocument(filePath, 20)
|
||||||
|
).resolves.toEqual({
|
||||||
|
markdown: "# 示例",
|
||||||
|
fileName: "示例.md"
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
readMarkdownDocument(filePath, 2)
|
||||||
|
).rejects.toThrow("Markdown 文件过大");
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝非 Markdown 扩展名", async () => {
|
||||||
|
await expect(
|
||||||
|
readMarkdownDocument("C:\\docs\\说明.txt", 100)
|
||||||
|
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("使用真实路径和内容哈希创建文件快照", async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
|
||||||
|
);
|
||||||
|
const filePath = path.join(directory, "快照.md");
|
||||||
|
try {
|
||||||
|
await writeFile(filePath, "# 第一版", "utf8");
|
||||||
|
// readMarkdownFileSnapshot 内部会 realpath 解析符号链接(例如 macOS
|
||||||
|
// 的 /var -> /private/var),返回的是规范化路径,因此断言也需要对
|
||||||
|
// 同一路径做 realpath,而不是直接比较 mkdtemp 拼接出的原始路径。
|
||||||
|
const canonicalFilePath = await realpath(filePath);
|
||||||
|
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
|
||||||
|
|
||||||
|
expect(snapshot.document).toEqual({
|
||||||
|
markdown: "# 第一版",
|
||||||
|
fileName: "快照.md"
|
||||||
|
});
|
||||||
|
expect(snapshot.filePath).toBe(canonicalFilePath);
|
||||||
|
expect(snapshot.documentKey).toBe(
|
||||||
|
createMarkdownDocumentKey(canonicalFilePath)
|
||||||
|
);
|
||||||
|
expect(snapshot.contentHash).toBe(
|
||||||
|
createMarkdownContentHash("# 第一版")
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(filePath, "# 第二版", "utf8");
|
||||||
|
const changed = await readMarkdownFileSnapshot(filePath, 100);
|
||||||
|
expect(changed.contentHash).not.toBe(snapshot.contentHash);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { strToU8, zipSync } from "fflate";
|
||||||
|
import {
|
||||||
|
extractRequiredFiles,
|
||||||
|
sha256
|
||||||
|
} from "../scripts/prepare-pandoc-runtime.mjs";
|
||||||
|
|
||||||
|
const artifact = {
|
||||||
|
executableRelativePath: "pandoc.exe",
|
||||||
|
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"]
|
||||||
|
};
|
||||||
|
|
||||||
|
function archive(files: Record<string, string>) {
|
||||||
|
return zipSync(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(files).map(([name, content]) => [
|
||||||
|
name,
|
||||||
|
strToU8(content)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Desktop Pandoc 最小运行时构建", () => {
|
||||||
|
it("仅提取可执行文件和两份许可证文件", () => {
|
||||||
|
const files = extractRequiredFiles(
|
||||||
|
archive({
|
||||||
|
"pandoc-3.9.0.2/pandoc.exe": "executable",
|
||||||
|
"pandoc-3.9.0.2/COPYING.rtf": "license",
|
||||||
|
"pandoc-3.9.0.2/COPYRIGHT.txt": "copyright",
|
||||||
|
"pandoc-3.9.0.2/MANUAL.html": "manual"
|
||||||
|
}),
|
||||||
|
artifact
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...files.keys()].sort()).toEqual([
|
||||||
|
"COPYING.rtf",
|
||||||
|
"COPYRIGHT.txt",
|
||||||
|
"pandoc.exe"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝缺失许可证文件的归档", () => {
|
||||||
|
expect(() =>
|
||||||
|
extractRequiredFiles(
|
||||||
|
archive({
|
||||||
|
"pandoc.exe": "executable",
|
||||||
|
"COPYING.rtf": "license"
|
||||||
|
}),
|
||||||
|
artifact
|
||||||
|
)
|
||||||
|
).toThrow("COPYRIGHT.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝用不同目录伪造的重复文件", () => {
|
||||||
|
expect(() =>
|
||||||
|
extractRequiredFiles(
|
||||||
|
archive({
|
||||||
|
"official/pandoc.exe": "official",
|
||||||
|
"shadow/pandoc.exe": "shadow",
|
||||||
|
"official/COPYING.rtf": "license",
|
||||||
|
"official/COPYRIGHT.txt": "copyright"
|
||||||
|
}),
|
||||||
|
artifact
|
||||||
|
)
|
||||||
|
).toThrow("重复文件:pandoc.exe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("使用大写十六进制输出稳定 SHA-256", () => {
|
||||||
|
const content = strToU8("MorphDoc");
|
||||||
|
expect(sha256(content)).toBe(
|
||||||
|
createHash("sha256").update(content).digest("hex").toUpperCase()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
createElectronPdfPrintOptions,
|
||||||
|
isAllowedPdfRuntimeUrl,
|
||||||
|
parsePagedDocumentPayload
|
||||||
|
} from "../src/pdf-contract.js";
|
||||||
|
import {
|
||||||
|
parseMarkdownRenderRequest,
|
||||||
|
parseFontPackResourceUrl,
|
||||||
|
parseThemeId,
|
||||||
|
parseThemeResourceUrl
|
||||||
|
} from "../src/application-contract.js";
|
||||||
|
|
||||||
|
const payload = createPagedDocumentPayload({
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||||
|
bodyHtml: "<h1>测试</h1>",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
semanticDocument: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
titlePolicy: {
|
||||||
|
metadataTitle: "suppress",
|
||||||
|
firstBodyHeading: "keep"
|
||||||
|
},
|
||||||
|
regions: []
|
||||||
|
},
|
||||||
|
features: ["mermaid"],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
fileName: "测试.md",
|
||||||
|
themeCss: "#write { color: black; }",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("桌面 PDF 载荷", () => {
|
||||||
|
it("校验并保留合法的共享分页载荷", () => {
|
||||||
|
expect(parsePagedDocumentPayload(payload)).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝无效功能标识", () => {
|
||||||
|
expect(() =>
|
||||||
|
parsePagedDocumentPayload({
|
||||||
|
...payload,
|
||||||
|
features: ["remote-script"]
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("映射为与 Playwright 对齐的 Electron 打印参数", () => {
|
||||||
|
expect(
|
||||||
|
createElectronPdfPrintOptions(payload, {
|
||||||
|
pageCount: 3,
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: [],
|
||||||
|
timings: {
|
||||||
|
setupMs: 0,
|
||||||
|
echartsMs: 0,
|
||||||
|
echartsFitMs: 0,
|
||||||
|
mermaidMs: 0,
|
||||||
|
mermaidFitMs: 0,
|
||||||
|
mermaidConversionMs: 0,
|
||||||
|
resourceWaitMs: 0,
|
||||||
|
paginationMs: 0,
|
||||||
|
finalizeMs: 0,
|
||||||
|
totalMs: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
displayHeaderFooter: false,
|
||||||
|
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||||
|
pageRanges: "1-3",
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
printBackground: true,
|
||||||
|
scale: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("桌面 PDF 网络边界", () => {
|
||||||
|
const renderUrl =
|
||||||
|
"http://localhost:5173/preview-frame.html?target=pdf";
|
||||||
|
|
||||||
|
it("允许同源和内嵌资源", () => {
|
||||||
|
expect(
|
||||||
|
isAllowedPdfRuntimeUrl(
|
||||||
|
"http://localhost:5173/assets/runtime.js",
|
||||||
|
renderUrl
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl))
|
||||||
|
.toBe(true);
|
||||||
|
expect(
|
||||||
|
isAllowedPdfRuntimeUrl(
|
||||||
|
"mdpdf://theme/typora-like/font.woff2",
|
||||||
|
renderUrl
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAllowedPdfRuntimeUrl(
|
||||||
|
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
|
||||||
|
renderUrl
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝外部网络资源", () => {
|
||||||
|
expect(
|
||||||
|
isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("桌面应用服务 IPC 载荷", () => {
|
||||||
|
it("只提取 Markdown 渲染所需字段", () => {
|
||||||
|
expect(
|
||||||
|
parseMarkdownRenderRequest({
|
||||||
|
markdown: "# 测试",
|
||||||
|
language: "zh-CN",
|
||||||
|
ignored: true
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
markdown: "# 测试",
|
||||||
|
language: "zh-CN"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("校验主题 ID 与主题资源地址", () => {
|
||||||
|
expect(parseThemeId("typora-like")).toBe("typora-like");
|
||||||
|
expect(
|
||||||
|
parseThemeResourceUrl(
|
||||||
|
"mdpdf://theme/typora-like/fonts/test.woff2"
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
themeId: "typora-like",
|
||||||
|
assetPath: "fonts/test.woff2"
|
||||||
|
});
|
||||||
|
expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效");
|
||||||
|
expect(
|
||||||
|
parseThemeResourceUrl("mdpdf://bundle/index.html")
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只接受字体包 Web 资源地址", () => {
|
||||||
|
expect(
|
||||||
|
parseFontPackResourceUrl(
|
||||||
|
"mdpdf://font-pack/official-cjk/1.0.0/serif-regular/web?v=abc"
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
packId: "official-cjk",
|
||||||
|
packVersion: "1.0.0",
|
||||||
|
faceId: "serif-regular",
|
||||||
|
kind: "web"
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
parseFontPackResourceUrl(
|
||||||
|
"mdpdf://font-pack/official-cjk/1.0.0/serif-regular/docx"
|
||||||
|
)
|
||||||
|
).toThrow("字体包资源地址无效");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
interface PackageManifest {
|
||||||
|
version?: string;
|
||||||
|
productName?: string;
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductBrand {
|
||||||
|
appId: string;
|
||||||
|
englishName: string;
|
||||||
|
displayName: string;
|
||||||
|
windowTitle: string;
|
||||||
|
executableName: string;
|
||||||
|
artifactPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PandocRuntimeManifest {
|
||||||
|
version: string;
|
||||||
|
artifacts: Array<{
|
||||||
|
platform: string;
|
||||||
|
architecture: string;
|
||||||
|
sha256: string;
|
||||||
|
licenseFiles: string[];
|
||||||
|
files?: Record<string, { bytes: number; sha256: string }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||||
|
|
||||||
|
function readManifest<T = PackageManifest>(path: string): T {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("桌面发行构建链", () => {
|
||||||
|
it("打包前按依赖顺序完整重建内嵌 Web", () => {
|
||||||
|
const rootManifest = readManifest(`${projectRoot}/package.json`);
|
||||||
|
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
|
||||||
|
const webBuild = rootManifest.scripts?.["build:web-runtime"] ?? "";
|
||||||
|
const expectedWorkspaces = [
|
||||||
|
"@md-to-pdf/markdown-echarts",
|
||||||
|
"@md-to-pdf/core",
|
||||||
|
"@md-to-pdf/renderer",
|
||||||
|
"@md-to-pdf/preview-engine",
|
||||||
|
"@md-to-pdf/application",
|
||||||
|
"@md-to-pdf/web"
|
||||||
|
];
|
||||||
|
|
||||||
|
let previousIndex = -1;
|
||||||
|
for (const workspace of expectedWorkspaces) {
|
||||||
|
const workspaceIndex = webBuild.indexOf(workspace);
|
||||||
|
expect(workspaceIndex).toBeGreaterThan(previousIndex);
|
||||||
|
previousIndex = workspaceIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(desktopManifest.scripts?.["build:embedded-web"]).toBe(
|
||||||
|
"npm --prefix ../.. run build:web-runtime"
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.package).toMatch(
|
||||||
|
/^npm run build:embedded-web && /
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.make).toMatch(
|
||||||
|
/^npm run build:embedded-web && /
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.package).toContain(
|
||||||
|
"npm run prepare:pandoc"
|
||||||
|
);
|
||||||
|
expect(desktopManifest.scripts?.make).toContain(
|
||||||
|
"npm run prepare:pandoc"
|
||||||
|
);
|
||||||
|
expect(rootManifest.scripts?.["desktop:package"]).toBe(
|
||||||
|
"npm run build:font-pack && npm run package -w @md-to-pdf/desktop"
|
||||||
|
);
|
||||||
|
expect(rootManifest.scripts?.["desktop:make"]).toBe(
|
||||||
|
"npm run build:font-pack && node scripts/build-desktop-release.mjs"
|
||||||
|
);
|
||||||
|
expect(rootManifest.scripts?.["desktop:make:with-font-pack"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将固定 Pandoc 最小运行时作为 Desktop 独立资源打包", () => {
|
||||||
|
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
|
||||||
|
const runtimeManifest = readManifest<PandocRuntimeManifest>(
|
||||||
|
`${projectRoot}/packages/docx-engine/src/pandoc-runtime.json`
|
||||||
|
);
|
||||||
|
const builderConfig = readFileSync(
|
||||||
|
`${desktopRoot}/electron-builder.config.cjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const prepareScript = readFileSync(
|
||||||
|
`${desktopRoot}/scripts/prepare-pandoc-runtime.mjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const windowsArtifact = runtimeManifest.artifacts.find(
|
||||||
|
(artifact) =>
|
||||||
|
artifact.platform === "win32" && artifact.architecture === "x64"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runtimeManifest.version).toBe("3.9.0.2");
|
||||||
|
expect(windowsArtifact).toMatchObject({
|
||||||
|
sha256:
|
||||||
|
"C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A",
|
||||||
|
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"],
|
||||||
|
files: {
|
||||||
|
"pandoc.exe": {
|
||||||
|
bytes: 231056136,
|
||||||
|
sha256:
|
||||||
|
"E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(desktopManifest.scripts?.["prepare:pandoc"]).toBe(
|
||||||
|
"node scripts/prepare-pandoc-runtime.mjs"
|
||||||
|
);
|
||||||
|
expect(builderConfig).toContain(
|
||||||
|
"`.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`"
|
||||||
|
);
|
||||||
|
expect(builderConfig).toContain('"pandoc.exe"');
|
||||||
|
expect(builderConfig).toContain('"COPYING.rtf"');
|
||||||
|
expect(builderConfig).toContain('"COPYRIGHT.txt"');
|
||||||
|
expect(prepareScript).toContain("sha256(downloaded)");
|
||||||
|
expect(prepareScript).toContain("smokeTestPandoc");
|
||||||
|
expect(prepareScript).toContain('process.argv.includes("--check")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将内置主题示例作为独立资源复制到发行目录", () => {
|
||||||
|
const builderConfig = readFileSync(
|
||||||
|
`${desktopRoot}/electron-builder.config.cjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(builderConfig).toContain('from: "../../samples"');
|
||||||
|
expect(builderConfig).toContain('to: "samples"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("保持安装身份并从统一配置生成 MorphDoc 发行名称", () => {
|
||||||
|
const brand = readManifest<ProductBrand>(
|
||||||
|
`${projectRoot}/product.json`
|
||||||
|
);
|
||||||
|
const desktopManifest = readManifest(
|
||||||
|
`${desktopRoot}/package.json`
|
||||||
|
);
|
||||||
|
const builderConfig = readFileSync(
|
||||||
|
`${desktopRoot}/electron-builder.config.cjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const installerInclude = readFileSync(
|
||||||
|
`${desktopRoot}/build/installer.nsh`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(brand).toMatchObject({
|
||||||
|
appId: "com.md-to-pdf.desktop",
|
||||||
|
englishName: "MorphDoc",
|
||||||
|
displayName: "墨呈",
|
||||||
|
windowTitle: "墨呈 · MorphDoc",
|
||||||
|
executableName: "MorphDoc",
|
||||||
|
artifactPrefix: "MorphDoc"
|
||||||
|
});
|
||||||
|
expect(desktopManifest).toMatchObject({
|
||||||
|
version: "0.6.4",
|
||||||
|
productName: "MorphDoc"
|
||||||
|
});
|
||||||
|
expect(builderConfig).toContain("appId: brand.appId");
|
||||||
|
expect(builderConfig).toContain("productName: brand.englishName");
|
||||||
|
expect(builderConfig).toContain("executableName: brand.executableName");
|
||||||
|
expect(builderConfig).toContain("shortcutName: brand.displayName");
|
||||||
|
expect(installerInclude).toContain(
|
||||||
|
'!define MORPHDOC_EXECUTABLE "MorphDoc.exe"'
|
||||||
|
);
|
||||||
|
expect(installerInclude).toContain(
|
||||||
|
'!define MORPHDOC_DISPLAY_NAME "墨呈"'
|
||||||
|
);
|
||||||
|
expect(installerInclude).toContain(
|
||||||
|
'!define INSTALL_REGISTRY_KEY "Software\\${APP_GUID}.MorphDoc"'
|
||||||
|
);
|
||||||
|
expect(installerInclude).toContain(
|
||||||
|
'DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"'
|
||||||
|
);
|
||||||
|
expect(installerInclude).toContain(
|
||||||
|
"Software\\Classes\\Applications\\${MORPHDOC_LEGACY_EXECUTABLE}"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在安装版与便携版中强制内置完整字体包集合", () => {
|
||||||
|
const builderConfig = readFileSync(
|
||||||
|
`${desktopRoot}/electron-builder.config.cjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const desktopMain = readFileSync(
|
||||||
|
`${desktopRoot}/src/main.ts`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const bundledBuild = readFileSync(
|
||||||
|
`${projectRoot}/scripts/build-desktop-release.mjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const installerInclude = readFileSync(
|
||||||
|
`${desktopRoot}/build/installer.nsh`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(builderConfig).toContain('../../output/font-packs/root');
|
||||||
|
expect(builderConfig).toContain('to: "font-packs"');
|
||||||
|
expect(desktopMain).toContain(
|
||||||
|
'path.join(process.resourcesPath, "font-packs")'
|
||||||
|
);
|
||||||
|
expect(desktopMain).toContain("getDesktopUserFontPackDirectory()");
|
||||||
|
expect(bundledBuild).not.toContain("createDesktopCompanionInclude");
|
||||||
|
expect(bundledBuild).toContain("已生成字体直接内置");
|
||||||
|
expect(installerInclude).not.toContain("FONT_PACK_COMPANION");
|
||||||
|
expect(installerInclude).not.toContain("FontPackCompanionPage");
|
||||||
|
expect(installerInclude).not.toContain("/WITHFONTPACK=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
|
||||||
|
const buildScript = readFileSync(
|
||||||
|
`${desktopRoot}/scripts/build-main.mjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildScript).toContain(
|
||||||
|
"packages/docx-engine/assets/docx-media-filter.lua"
|
||||||
|
);
|
||||||
|
expect(buildScript).toContain("dist/docx-media-filter.lua");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("为 ESM 主进程中的 CommonJS 字体转换依赖保留 __dirname", () => {
|
||||||
|
const buildScript = readFileSync(
|
||||||
|
`${desktopRoot}/scripts/build-main.mjs`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildScript).toContain(
|
||||||
|
'dirname as __mdToPdfDirname'
|
||||||
|
);
|
||||||
|
expect(buildScript).toContain(
|
||||||
|
'fileURLToPath as __mdToPdfFileURLToPath'
|
||||||
|
);
|
||||||
|
expect(buildScript).toContain(
|
||||||
|
"const __dirname = __mdToPdfDirname(__mdToPdfFileURLToPath(import.meta.url));"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
|
||||||
|
const dockerfile = readFileSync(
|
||||||
|
`${projectRoot}/deploy/Dockerfile`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const samplesBuildCopyIndex = dockerfile.indexOf(
|
||||||
|
"COPY samples ./samples"
|
||||||
|
);
|
||||||
|
const webBuildIndex = dockerfile.indexOf(
|
||||||
|
"npm run build -w @md-to-pdf/web"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(samplesBuildCopyIndex).toBeGreaterThan(-1);
|
||||||
|
expect(webBuildIndex).toBeGreaterThan(samplesBuildCopyIndex);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"COPY --chown=pwuser:pwuser --from=build /app/samples ./samples"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
SaveLocationStore,
|
||||||
|
loadSaveLocationState,
|
||||||
|
parseSaveLocationState,
|
||||||
|
resolveDefaultSavePath,
|
||||||
|
saveSaveLocationState
|
||||||
|
} from "../src/save-location-state.js";
|
||||||
|
|
||||||
|
let temporaryDirectory: string | undefined;
|
||||||
|
|
||||||
|
async function createTemporaryDirectory() {
|
||||||
|
temporaryDirectory = await mkdtemp(
|
||||||
|
path.join(tmpdir(), "md-to-pdf-save-location-")
|
||||||
|
);
|
||||||
|
return temporaryDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (temporaryDirectory) {
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
temporaryDirectory = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("桌面默认保存位置", () => {
|
||||||
|
it("只接受版本匹配的绝对目录状态", () => {
|
||||||
|
expect(
|
||||||
|
parseSaveLocationState({
|
||||||
|
version: 1,
|
||||||
|
directory: path.resolve("C:/Documents")
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
version: 1,
|
||||||
|
directory: path.resolve("C:/Documents")
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseSaveLocationState({ version: 2, directory: "C:/Documents" })
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
parseSaveLocationState({ version: 1, directory: "relative" })
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("优先使用有效的源文件目录", async () => {
|
||||||
|
const root = await createTemporaryDirectory();
|
||||||
|
const sourceDirectory = path.join(root, "source");
|
||||||
|
const historyDirectory = path.join(root, "history");
|
||||||
|
const documentsDirectory = path.join(root, "documents");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(sourceDirectory),
|
||||||
|
mkdir(historyDirectory),
|
||||||
|
mkdir(documentsDirectory)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveDefaultSavePath({
|
||||||
|
sourceFilePath: path.join(sourceDirectory, "source.md"),
|
||||||
|
lastSaveDirectory: historyDirectory,
|
||||||
|
documentsDirectory,
|
||||||
|
suggestedFileName: "report.pdf"
|
||||||
|
})
|
||||||
|
).resolves.toBe(path.join(sourceDirectory, "report.pdf"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("源目录不可用时使用最近成功目录", async () => {
|
||||||
|
const root = await createTemporaryDirectory();
|
||||||
|
const historyDirectory = path.join(root, "history");
|
||||||
|
const documentsDirectory = path.join(root, "documents");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(historyDirectory),
|
||||||
|
mkdir(documentsDirectory)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveDefaultSavePath({
|
||||||
|
sourceFilePath: path.join(root, "missing", "source.md"),
|
||||||
|
lastSaveDirectory: historyDirectory,
|
||||||
|
documentsDirectory,
|
||||||
|
suggestedFileName: "report.docx"
|
||||||
|
})
|
||||||
|
).resolves.toBe(path.join(historyDirectory, "report.docx"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("源目录和历史目录均不可用时回退到文档目录", async () => {
|
||||||
|
const root = await createTemporaryDirectory();
|
||||||
|
const documentsDirectory = path.join(root, "documents");
|
||||||
|
await mkdir(documentsDirectory);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveDefaultSavePath({
|
||||||
|
lastSaveDirectory: path.join(root, "missing"),
|
||||||
|
documentsDirectory,
|
||||||
|
suggestedFileName: "draft.md"
|
||||||
|
})
|
||||||
|
).resolves.toBe(path.join(documentsDirectory, "draft.md"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("以原子 JSON 状态持久化并恢复最近目录", async () => {
|
||||||
|
const root = await createTemporaryDirectory();
|
||||||
|
const directory = path.join(root, "saved");
|
||||||
|
const statePath = path.join(root, "state", "save-location.json");
|
||||||
|
await mkdir(directory);
|
||||||
|
|
||||||
|
await saveSaveLocationState(statePath, directory);
|
||||||
|
|
||||||
|
await expect(loadSaveLocationState(statePath)).resolves.toEqual({
|
||||||
|
version: 1,
|
||||||
|
directory
|
||||||
|
});
|
||||||
|
expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({
|
||||||
|
version: 1,
|
||||||
|
directory
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("跨 Store 实例恢复最近成功文件目录", async () => {
|
||||||
|
const root = await createTemporaryDirectory();
|
||||||
|
const savedDirectory = path.join(root, "saved");
|
||||||
|
const documentsDirectory = path.join(root, "documents");
|
||||||
|
const statePath = path.join(root, "save-location.json");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(savedDirectory),
|
||||||
|
mkdir(documentsDirectory)
|
||||||
|
]);
|
||||||
|
const first = new SaveLocationStore(
|
||||||
|
statePath,
|
||||||
|
documentsDirectory
|
||||||
|
);
|
||||||
|
await first.initialize();
|
||||||
|
await first.recordSuccessfulFile(
|
||||||
|
path.join(savedDirectory, "first.pdf")
|
||||||
|
);
|
||||||
|
|
||||||
|
const restored = new SaveLocationStore(
|
||||||
|
statePath,
|
||||||
|
documentsDirectory
|
||||||
|
);
|
||||||
|
await restored.initialize();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
restored.resolveDefaultPath(undefined, "second.docx")
|
||||||
|
).resolves.toBe(path.join(savedDirectory, "second.docx"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { migrateLegacyUserData } from "../src/user-data-migration.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRoot() {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "morphdoc-migration-"));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("品牌升级用户数据迁移", () => {
|
||||||
|
it("首次启动时复制状态、主题和浏览器本地存储但保留旧目录", async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const legacy = path.join(root, "Markdown PDF 导出器");
|
||||||
|
const target = path.join(root, "MorphDoc");
|
||||||
|
await mkdir(path.join(legacy, "themes", "custom"), {
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
await mkdir(path.join(legacy, "Local Storage"), {
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(legacy, "window-state.json"),
|
||||||
|
'{"width":1280}',
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
path.join(legacy, "themes", "custom", "theme.css"),
|
||||||
|
"#write{}",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await migrateLegacyUserData([legacy], target);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
migrated: true,
|
||||||
|
sourceDirectory: legacy,
|
||||||
|
copiedEntries: ["window-state.json", "themes", "Local Storage"]
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await readFile(path.join(target, "window-state.json"), "utf8")
|
||||||
|
).toBe('{"width":1280}');
|
||||||
|
expect(
|
||||||
|
await readFile(
|
||||||
|
path.join(target, "themes", "custom", "theme.css"),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
).toBe("#write{}");
|
||||||
|
expect(
|
||||||
|
await readFile(path.join(legacy, "window-state.json"), "utf8")
|
||||||
|
).toBe('{"width":1280}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("目标目录已经存在时不覆盖新版本数据", async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const legacy = path.join(root, "Markdown PDF 导出器");
|
||||||
|
const target = path.join(root, "MorphDoc");
|
||||||
|
await mkdir(legacy, { recursive: true });
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
await writeFile(path.join(legacy, "window-state.json"), "legacy", "utf8");
|
||||||
|
await writeFile(path.join(target, "window-state.json"), "current", "utf8");
|
||||||
|
|
||||||
|
expect(await migrateLegacyUserData([legacy], target)).toEqual({
|
||||||
|
migrated: false,
|
||||||
|
copiedEntries: []
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await readFile(path.join(target, "window-state.json"), "utf8")
|
||||||
|
).toBe("current");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getWindowCloseDecision } from "../src/window-close-policy.js";
|
||||||
|
|
||||||
|
describe("桌面窗口关闭策略", () => {
|
||||||
|
it("干净文档或已授权窗口可以直接关闭", () => {
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: false,
|
||||||
|
allowClose: false,
|
||||||
|
closeRequestPending: false
|
||||||
|
})
|
||||||
|
).toBe("allow");
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: true,
|
||||||
|
allowClose: true,
|
||||||
|
closeRequestPending: false
|
||||||
|
})
|
||||||
|
).toBe("allow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未保存文档只发出一次关闭确认请求", () => {
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: true,
|
||||||
|
allowClose: false,
|
||||||
|
closeRequestPending: false
|
||||||
|
})
|
||||||
|
).toBe("request");
|
||||||
|
expect(
|
||||||
|
getWindowCloseDecision({
|
||||||
|
dirty: true,
|
||||||
|
allowClose: false,
|
||||||
|
closeRequestPending: true
|
||||||
|
})
|
||||||
|
).toBe("wait");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
loadWindowState,
|
||||||
|
parseWindowState,
|
||||||
|
resolveWindowBounds,
|
||||||
|
saveWindowState
|
||||||
|
} from "../src/window-state.js";
|
||||||
|
|
||||||
|
const primaryWorkArea = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1040
|
||||||
|
};
|
||||||
|
const defaultSize = { width: 1440, height: 960 };
|
||||||
|
const minimumSize = { width: 1024, height: 720 };
|
||||||
|
|
||||||
|
describe("桌面窗口状态", () => {
|
||||||
|
it("校验并规范化持久化状态", () => {
|
||||||
|
expect(
|
||||||
|
parseWindowState({
|
||||||
|
version: 1,
|
||||||
|
bounds: {
|
||||||
|
x: 120.4,
|
||||||
|
y: 80.6,
|
||||||
|
width: 1280.2,
|
||||||
|
height: 800.8
|
||||||
|
},
|
||||||
|
maximized: true
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
version: 1,
|
||||||
|
bounds: {
|
||||||
|
x: 120,
|
||||||
|
y: 81,
|
||||||
|
width: 1280,
|
||||||
|
height: 801
|
||||||
|
},
|
||||||
|
maximized: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝损坏或不受支持的状态", () => {
|
||||||
|
expect(parseWindowState(undefined)).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
parseWindowState({
|
||||||
|
version: 2,
|
||||||
|
bounds: primaryWorkArea,
|
||||||
|
maximized: false
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
parseWindowState({
|
||||||
|
version: 1,
|
||||||
|
bounds: { x: 0, y: 0, width: -1, height: 720 },
|
||||||
|
maximized: false
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("首次启动时在主显示器居中", () => {
|
||||||
|
expect(
|
||||||
|
resolveWindowBounds(
|
||||||
|
undefined,
|
||||||
|
[primaryWorkArea],
|
||||||
|
primaryWorkArea,
|
||||||
|
defaultSize,
|
||||||
|
minimumSize
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
x: 240,
|
||||||
|
y: 40,
|
||||||
|
width: 1440,
|
||||||
|
height: 960
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将部分溢出的窗口完整收回原显示器", () => {
|
||||||
|
expect(
|
||||||
|
resolveWindowBounds(
|
||||||
|
{ x: 1700, y: 900, width: 1280, height: 800 },
|
||||||
|
[primaryWorkArea],
|
||||||
|
primaryWorkArea,
|
||||||
|
defaultSize,
|
||||||
|
minimumSize
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
x: 640,
|
||||||
|
y: 240,
|
||||||
|
width: 1280,
|
||||||
|
height: 800
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显示器移除后在主显示器居中恢复", () => {
|
||||||
|
expect(
|
||||||
|
resolveWindowBounds(
|
||||||
|
{ x: 3000, y: 120, width: 1200, height: 780 },
|
||||||
|
[primaryWorkArea],
|
||||||
|
primaryWorkArea,
|
||||||
|
defaultSize,
|
||||||
|
minimumSize
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
x: 360,
|
||||||
|
y: 130,
|
||||||
|
width: 1200,
|
||||||
|
height: 780
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("窗口大于工作区时缩小到可完整展示", () => {
|
||||||
|
const compactWorkArea = {
|
||||||
|
x: 1920,
|
||||||
|
y: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 680
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
resolveWindowBounds(
|
||||||
|
{ x: 2000, y: -100, width: 1800, height: 1000 },
|
||||||
|
[primaryWorkArea, compactWorkArea],
|
||||||
|
primaryWorkArea,
|
||||||
|
defaultSize,
|
||||||
|
minimumSize
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
x: 1920,
|
||||||
|
y: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 680
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过临时文件原子保存并可重新载入", async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "md-to-pdf-window-")
|
||||||
|
);
|
||||||
|
const filePath = path.join(directory, "window-state.json");
|
||||||
|
const state = {
|
||||||
|
version: 1 as const,
|
||||||
|
bounds: { x: 10, y: 20, width: 1200, height: 800 },
|
||||||
|
maximized: false
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await saveWindowState(filePath, state);
|
||||||
|
await expect(loadWindowState(filePath)).resolves.toEqual(state);
|
||||||
|
const updatedState = {
|
||||||
|
...state,
|
||||||
|
bounds: { x: 30, y: 40, width: 1280, height: 900 },
|
||||||
|
maximized: true
|
||||||
|
};
|
||||||
|
await saveWindowState(filePath, updatedState);
|
||||||
|
await expect(loadWindowState(filePath)).resolves.toEqual(
|
||||||
|
updatedState
|
||||||
|
);
|
||||||
|
expect(await readdir(directory)).toEqual(["window-state.json"]);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
"tests"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -8,11 +8,18 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||||
|
"verify:docx-http": "node scripts/verify-docx-http.mjs",
|
||||||
|
"verify:docx-r4-matrix": "node scripts/verify-docx-r4-matrix.mjs",
|
||||||
|
"verify:docx-theme-styles": "node scripts/verify-docx-theme-styles.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@md-to-pdf/application": "0.4.1",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-theme-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0",
|
"@md-to-pdf/renderer": "0.1.0",
|
||||||
|
"fflate": "0.8.3",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"playwright": "1.62.0"
|
"playwright": "1.62.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
DOCX_MIME_TYPE,
|
||||||
|
defaultExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { inspectDocxAcceptance } from "@md-to-pdf/docx-engine";
|
||||||
|
import { DOCX_STYLE_SLOTS } from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import { buildApp } from "../dist/app.js";
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryDirectory = path.resolve(directory, "../../..");
|
||||||
|
const outputDirectory = path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"output",
|
||||||
|
"docx-acceptance"
|
||||||
|
);
|
||||||
|
const temporaryPrefix = "md-to-pdf-docx-";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryDirectories() {
|
||||||
|
return new Set(
|
||||||
|
fs
|
||||||
|
.readdirSync(os.tmpdir(), { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.isDirectory() && entry.name.startsWith(temporaryPrefix)
|
||||||
|
)
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = `---
|
||||||
|
title: Server HTTP DOCX 验收
|
||||||
|
author: 墨呈研发组
|
||||||
|
---
|
||||||
|
|
||||||
|
# HTTP 导出保持可编辑
|
||||||
|
|
||||||
|
这是一段由真实 Fastify 路由和 Pandoc 生成的可编辑中文正文,包含
|
||||||
|
[验收链接](https://example.invalid/server-docx)。
|
||||||
|
|
||||||
|
1. 原生编号第一项
|
||||||
|
2. 原生编号第二项
|
||||||
|
|
||||||
|
| HTTP 门禁 | 预期 |
|
||||||
|
| --- | --- |
|
||||||
|
| MIME | DOCX |
|
||||||
|
| 内容 | 可编辑 |
|
||||||
|
|
||||||
|
脚注必须保留。[^server-footnote]
|
||||||
|
|
||||||
|
公式 $a^2+b^2=c^2$ 必须使用 OMML。
|
||||||
|
|
||||||
|
[^server-footnote]: Server HTTP 验收脚注。
|
||||||
|
`;
|
||||||
|
const exportConfig = {
|
||||||
|
...defaultExportConfig,
|
||||||
|
themeId: "typora-like",
|
||||||
|
pageDecorationsMode: "custom",
|
||||||
|
paper: {
|
||||||
|
...defaultExportConfig.paper,
|
||||||
|
marginMode: "custom",
|
||||||
|
margins: {
|
||||||
|
top: "16mm",
|
||||||
|
right: "16mm",
|
||||||
|
bottom: "16mm",
|
||||||
|
left: "16mm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const computedStyle = {
|
||||||
|
fontFamily: '"Microsoft YaHei", sans-serif',
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "400",
|
||||||
|
fontStyle: "normal",
|
||||||
|
color: "rgb(34, 34, 34)",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||||
|
lineHeight: "24px",
|
||||||
|
letterSpacing: "normal",
|
||||||
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
|
textIndent: "0px",
|
||||||
|
textDecorationLine: "none",
|
||||||
|
marginTop: "0px",
|
||||||
|
marginRight: "0px",
|
||||||
|
marginBottom: "8px",
|
||||||
|
marginLeft: "0px",
|
||||||
|
paddingTop: "0px",
|
||||||
|
paddingRight: "0px",
|
||||||
|
paddingBottom: "0px",
|
||||||
|
paddingLeft: "0px",
|
||||||
|
borderTop: "0px none rgb(34, 34, 34)",
|
||||||
|
borderRight: "0px none rgb(34, 34, 34)",
|
||||||
|
borderBottom: "0px none rgb(34, 34, 34)",
|
||||||
|
borderLeft: "0px none rgb(34, 34, 34)",
|
||||||
|
width: "640px",
|
||||||
|
maxWidth: "none",
|
||||||
|
minWidth: "0px",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
|
breakBefore: "auto",
|
||||||
|
breakAfter: "auto",
|
||||||
|
breakInside: "auto",
|
||||||
|
display: "block",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "normal",
|
||||||
|
justifyContent: "normal",
|
||||||
|
outline: "0px none rgb(34, 34, 34)",
|
||||||
|
outlineOffset: "0px"
|
||||||
|
};
|
||||||
|
const mediaAdapter = {
|
||||||
|
themeStyleBaseUrl:
|
||||||
|
"http://localhost:5173/preview-frame.html",
|
||||||
|
async captureThemeStyle(request) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
themeId: request.themeId,
|
||||||
|
themeFingerprint: request.themeFingerprint,
|
||||||
|
viewport: {
|
||||||
|
widthPx: 794,
|
||||||
|
heightPx: 1123,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
},
|
||||||
|
rootFontSizePx: 16,
|
||||||
|
slots: DOCX_STYLE_SLOTS.map(({ name }) => ({
|
||||||
|
slot: name,
|
||||||
|
matched: true,
|
||||||
|
computed: computedStyle
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async capture() {
|
||||||
|
return {
|
||||||
|
plan: {
|
||||||
|
targets: [],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
},
|
||||||
|
captures: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
};
|
||||||
|
const pdfGenerator = {
|
||||||
|
async generate() {
|
||||||
|
throw new Error("DOCX HTTP 验收不得调用 PDF");
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
};
|
||||||
|
const beforeTemporaryDirectories = temporaryDirectories();
|
||||||
|
const app = buildApp({
|
||||||
|
logger: { level: "error" },
|
||||||
|
pdfGenerator,
|
||||||
|
docxMediaAdapter: mediaAdapter
|
||||||
|
});
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
const capabilityResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/docx/capability"
|
||||||
|
});
|
||||||
|
assert(capabilityResponse.statusCode === 200, "DOCX capability 请求失败");
|
||||||
|
assert(
|
||||||
|
capabilityResponse.json().status === "available",
|
||||||
|
`DOCX capability 不可用:${capabilityResponse.body}`
|
||||||
|
);
|
||||||
|
|
||||||
|
response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/docx",
|
||||||
|
payload: {
|
||||||
|
markdown,
|
||||||
|
fileName: "目录/Server HTTP 验收.md",
|
||||||
|
language: "zh-CN",
|
||||||
|
resources: [],
|
||||||
|
exportConfig
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
response.statusCode === 200,
|
||||||
|
`DOCX HTTP 返回 ${response.statusCode}:${response.body}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
response.headers["content-type"]?.includes(DOCX_MIME_TYPE),
|
||||||
|
`DOCX MIME 无效:${response.headers["content-type"]}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
response.headers["content-disposition"]?.includes(
|
||||||
|
"Server%20HTTP%20%E9%AA%8C%E6%94%B6.docx"
|
||||||
|
),
|
||||||
|
`DOCX 文件名无效:${response.headers["content-disposition"]}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
response.headers["cache-control"] === "no-store",
|
||||||
|
"DOCX HTTP 响应必须禁用缓存"
|
||||||
|
);
|
||||||
|
for (const timing of [
|
||||||
|
"runtime-probe",
|
||||||
|
"prepare",
|
||||||
|
"media",
|
||||||
|
"reference",
|
||||||
|
"pandoc",
|
||||||
|
"validation",
|
||||||
|
"total"
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
response.headers["server-timing"]?.includes(`${timing};dur=`),
|
||||||
|
`Server-Timing 缺少 ${timing}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
response.headers["x-docx-warning-count"] === "0",
|
||||||
|
"无媒体 HTTP 验收不应产生 DOCX 警告"
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = inspectDocxAcceptance(response.rawPayload, {
|
||||||
|
id: "server-http",
|
||||||
|
page: {
|
||||||
|
widthTwips: 11906,
|
||||||
|
heightTwips: 16838,
|
||||||
|
orientation: "portrait",
|
||||||
|
marginsTwips: {
|
||||||
|
top: 907,
|
||||||
|
right: 907,
|
||||||
|
bottom: 907,
|
||||||
|
left: 907
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requiredText: [
|
||||||
|
"真实 Fastify 路由",
|
||||||
|
"原生编号第一项",
|
||||||
|
"HTTP 门禁"
|
||||||
|
],
|
||||||
|
requiredFootnoteText: ["Server HTTP 验收脚注"],
|
||||||
|
minimumParagraphs: 8,
|
||||||
|
minimumTextRuns: 12,
|
||||||
|
minimumTables: 1,
|
||||||
|
minimumNumberedParagraphs: 2,
|
||||||
|
minimumHyperlinks: 1,
|
||||||
|
minimumFootnoteReferences: 1,
|
||||||
|
minimumMathObjects: 1,
|
||||||
|
requiredStyleIds: [
|
||||||
|
"Normal",
|
||||||
|
"Heading1",
|
||||||
|
"SourceCode",
|
||||||
|
"Table",
|
||||||
|
"Caption"
|
||||||
|
],
|
||||||
|
requirePageField: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterTemporaryDirectories = temporaryDirectories();
|
||||||
|
const leakedTemporaryDirectories = [...afterTemporaryDirectories].filter(
|
||||||
|
(name) => !beforeTemporaryDirectories.has(name)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
leakedTemporaryDirectories.length === 0,
|
||||||
|
`DOCX HTTP 遗留临时目录:${leakedTemporaryDirectories.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
const outputPath = path.join(outputDirectory, "server-http.docx");
|
||||||
|
const reportPath = path.join(
|
||||||
|
outputDirectory,
|
||||||
|
"server-http-report.json"
|
||||||
|
);
|
||||||
|
fs.writeFileSync(outputPath, response.rawPayload);
|
||||||
|
const result = {
|
||||||
|
outputFile: path.relative(repositoryDirectory, outputPath),
|
||||||
|
bytes: response.rawPayload.byteLength,
|
||||||
|
headers: {
|
||||||
|
contentType: response.headers["content-type"],
|
||||||
|
contentDisposition: response.headers["content-disposition"],
|
||||||
|
cacheControl: response.headers["cache-control"],
|
||||||
|
serverTiming: response.headers["server-timing"],
|
||||||
|
warningCount: response.headers["x-docx-warning-count"],
|
||||||
|
echartsErrorCount: response.headers["x-echarts-error-count"],
|
||||||
|
mermaidErrorCount: response.headers["x-mermaid-error-count"]
|
||||||
|
},
|
||||||
|
leakedTemporaryDirectories,
|
||||||
|
acceptance: report
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
reportPath,
|
||||||
|
`${JSON.stringify(result, null, 2)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
@@ -0,0 +1,923 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
|
||||||
|
import { unzipSync } from "fflate";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { buildApp } from "../dist/app.js";
|
||||||
|
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
|
||||||
|
import { createPdfGenerator } from "../dist/pdf-engine.js";
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryDirectory = path.resolve(directory, "../../..");
|
||||||
|
const appVersion = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8")
|
||||||
|
).version;
|
||||||
|
const themesDirectory = path.join(repositoryDirectory, "themes");
|
||||||
|
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
|
||||||
|
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
|
||||||
|
const outputDirectory = path.resolve(
|
||||||
|
process.env.MD_TO_PDF_R4_OUTPUT_DIR?.trim() ||
|
||||||
|
path.join(repositoryDirectory, "output", "docx-r4")
|
||||||
|
);
|
||||||
|
const pdfDirectory = path.join(outputDirectory, "chromium-pdf");
|
||||||
|
const docxDirectory = path.join(outputDirectory, "docx");
|
||||||
|
const standardReportPath = path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"output",
|
||||||
|
"docx-theme-matrix",
|
||||||
|
"theme-matrix-report.json"
|
||||||
|
);
|
||||||
|
const fontPackRoot = path.resolve(
|
||||||
|
process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() ||
|
||||||
|
path.join(repositoryDirectory, "output", "font-packs", "root")
|
||||||
|
);
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function writeArtifactWithBusyFallback(filePath, content) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, content);
|
||||||
|
return filePath;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "EBUSY") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const extension = path.extname(filePath);
|
||||||
|
const fallbackPath = path.join(
|
||||||
|
path.dirname(filePath),
|
||||||
|
`${path.basename(filePath, extension)}-attempt-${randomUUID()}${extension}`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(fallbackPath, content);
|
||||||
|
process.stderr.write(
|
||||||
|
`[DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件:${path.basename(fallbackPath)}\n`
|
||||||
|
);
|
||||||
|
return fallbackPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineCodeContinuityProbes = [
|
||||||
|
{
|
||||||
|
id: "paragraph",
|
||||||
|
code: "mdtp_ic_p",
|
||||||
|
text: "段落前mdtp_ic_p段落后"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list-item",
|
||||||
|
code: "mdtp_ic_l",
|
||||||
|
text: "列表前mdtp_ic_l列表后"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "table-cell",
|
||||||
|
code: "mdtp_ic_c",
|
||||||
|
text: "单元格前mdtp_ic_c单元格后"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const inlineCodeContinuityMarkdown = `
|
||||||
|
|
||||||
|
## 行内代码连续性门禁
|
||||||
|
|
||||||
|
段落前\`mdtp_ic_p\`段落后
|
||||||
|
|
||||||
|
- 列表前\`mdtp_ic_l\`列表后
|
||||||
|
|
||||||
|
| 门禁 | 内容 | 邻接单元格 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
|
||||||
|
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
|
||||||
|
|
||||||
|
| 场景 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
|
||||||
|
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
|
||||||
|
| 叁式 | 连甲<br><br>连乙 |
|
||||||
|
| 肆式 | <br>边界<br> |
|
||||||
|
| 伍式 | 转甲\\<br>转乙 <br> 转丙 |
|
||||||
|
| 陆式 | 属甲<br class="unsafe">属乙 |
|
||||||
|
| 柒式 | 码甲\`<br>\`码乙 |
|
||||||
|
|
||||||
|
表格外保留 外一<br>外二 原文。
|
||||||
|
|
||||||
|
\`\`\`html
|
||||||
|
围一<br>围二
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
|
||||||
|
const docxBreakParagraphProbes = [
|
||||||
|
{
|
||||||
|
id: "basic",
|
||||||
|
marker: "标甲",
|
||||||
|
expectedText: "标甲标乙标丙标丁",
|
||||||
|
hardBreakCount: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "case-insensitive",
|
||||||
|
marker: "大甲",
|
||||||
|
expectedText: "大甲大乙大丙大丁",
|
||||||
|
hardBreakCount: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multiple",
|
||||||
|
marker: "连甲",
|
||||||
|
expectedText: "连甲连乙",
|
||||||
|
hardBreakCount: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "boundary",
|
||||||
|
marker: "边界",
|
||||||
|
expectedText: "边界",
|
||||||
|
hardBreakCount: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "adjacent-a",
|
||||||
|
marker: "邻乙",
|
||||||
|
expectedText: "邻甲邻乙",
|
||||||
|
hardBreakCount: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "adjacent-b",
|
||||||
|
marker: "旁乙",
|
||||||
|
expectedText: "旁甲旁乙",
|
||||||
|
hardBreakCount: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "escaped-and-entity",
|
||||||
|
marker: "转甲",
|
||||||
|
expectedText: "转甲<br>转乙 <br> 转丙",
|
||||||
|
hardBreakCount: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "attribute",
|
||||||
|
marker: "属甲",
|
||||||
|
expectedText: "属甲<br class=\"unsafe\">属乙",
|
||||||
|
hardBreakCount: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inline-code",
|
||||||
|
marker: "码甲",
|
||||||
|
expectedText: "码甲<br>码乙",
|
||||||
|
hardBreakCount: 0,
|
||||||
|
requiredCharacterStyle: "VerbatimChar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outside-table",
|
||||||
|
marker: "外一",
|
||||||
|
expectedText: "表格外保留 外一<br>外二 原文。",
|
||||||
|
hardBreakCount: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fenced-code",
|
||||||
|
marker: "围一",
|
||||||
|
expectedText: "围一<br>围二",
|
||||||
|
hardBreakCount: 0,
|
||||||
|
requiredParagraphStyle: "SourceCode"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const contentTypes = new Map([
|
||||||
|
[".css", "text/css; charset=utf-8"],
|
||||||
|
[".html", "text/html; charset=utf-8"],
|
||||||
|
[".js", "text/javascript; charset=utf-8"],
|
||||||
|
[".mjs", "text/javascript; charset=utf-8"],
|
||||||
|
[".png", "image/png"],
|
||||||
|
[".svg", "image/svg+xml"],
|
||||||
|
[".ttf", "font/ttf"],
|
||||||
|
[".woff", "font/woff"],
|
||||||
|
[".woff2", "font/woff2"]
|
||||||
|
]);
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestModifiedAt(targetPath) {
|
||||||
|
const stat = fs.statSync(targetPath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
return stat.mtimeMs;
|
||||||
|
}
|
||||||
|
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
|
||||||
|
(newest, entry) =>
|
||||||
|
Math.max(
|
||||||
|
newest,
|
||||||
|
newestModifiedAt(path.join(targetPath, entry.name))
|
||||||
|
),
|
||||||
|
stat.mtimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFreshArtifact(label, sourcePaths, artifactPath) {
|
||||||
|
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
|
||||||
|
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
|
||||||
|
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
|
||||||
|
assert(
|
||||||
|
artifactModifiedAt + 1_500 >= newestSource,
|
||||||
|
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(content) {
|
||||||
|
return createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExportConfigOverride() {
|
||||||
|
const source = process.env.MD_TO_PDF_R4_EXPORT_CONFIG_JSON?.trim();
|
||||||
|
if (!source) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(source);
|
||||||
|
assert(
|
||||||
|
parsed && typeof parsed === "object" && !Array.isArray(parsed),
|
||||||
|
"R4 导出配置覆盖必须是 JSON 对象"
|
||||||
|
);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeExportConfig(base, override) {
|
||||||
|
if (!override) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...override,
|
||||||
|
mermaid: { ...base.mermaid, ...override.mermaid },
|
||||||
|
paper: {
|
||||||
|
...base.paper,
|
||||||
|
...override.paper,
|
||||||
|
margins: { ...base.paper.margins, ...override.paper?.margins }
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
...base.header,
|
||||||
|
...override.header,
|
||||||
|
left: { ...base.header.left, ...override.header?.left },
|
||||||
|
center: { ...base.header.center, ...override.header?.center },
|
||||||
|
right: { ...base.header.right, ...override.header?.right }
|
||||||
|
},
|
||||||
|
footer: { ...base.footer, ...override.footer },
|
||||||
|
metadata: { ...base.metadata, ...override.metadata },
|
||||||
|
print: { ...base.print, ...override.print }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readThemes() {
|
||||||
|
return fs
|
||||||
|
.readdirSync(themesDirectory, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.isDirectory() &&
|
||||||
|
fs.existsSync(path.join(themesDirectory, entry.name, "theme.json"))
|
||||||
|
)
|
||||||
|
.map((entry) =>
|
||||||
|
themeManifestSchema.parse(
|
||||||
|
readJson(path.join(themesDirectory, entry.name, "theme.json"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sort((first, second) => first.id.localeCompare(second.id, "en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
const port =
|
||||||
|
typeof address === "object" && address ? address.port : undefined;
|
||||||
|
server.close((error) =>
|
||||||
|
error || !port
|
||||||
|
? reject(error ?? new Error("无法分配 R4 验收端口"))
|
||||||
|
: resolve(port)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWebFile(relativePath) {
|
||||||
|
const resolved = path.resolve(webDirectory, relativePath);
|
||||||
|
const relative = path.relative(webDirectory, resolved);
|
||||||
|
assert(
|
||||||
|
relative !== ".." &&
|
||||||
|
!relative.startsWith(`..${path.sep}`) &&
|
||||||
|
!path.isAbsolute(relative),
|
||||||
|
`Web 资源路径越界:${relativePath}`
|
||||||
|
);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerWebRuntime(app) {
|
||||||
|
app.get("/preview-frame.html", async (_request, reply) => {
|
||||||
|
const content = fs.readFileSync(resolveWebFile("preview-frame.html"));
|
||||||
|
return reply.type("text/html; charset=utf-8").send(content);
|
||||||
|
});
|
||||||
|
app.get("/assets/*", async (request, reply) => {
|
||||||
|
const relativePath = String(request.params["*"] ?? "");
|
||||||
|
const filePath = resolveWebFile(path.join("assets", relativePath));
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
assert(stat.isFile(), `Web 资源不是普通文件:${relativePath}`);
|
||||||
|
return reply
|
||||||
|
.type(
|
||||||
|
contentTypes.get(path.extname(filePath).toLowerCase()) ||
|
||||||
|
"application/octet-stream"
|
||||||
|
)
|
||||||
|
.send(fs.readFileSync(filePath));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectDocx(content) {
|
||||||
|
const entries = unzipSync(content);
|
||||||
|
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||||
|
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||||
|
const fontTableXml = decoder.decode(entries["word/fontTable.xml"]);
|
||||||
|
const fontParts = Object.keys(entries).filter((name) =>
|
||||||
|
name.startsWith("word/fonts/")
|
||||||
|
);
|
||||||
|
const fontNames = [
|
||||||
|
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
|
||||||
|
].map((match) => match[1]);
|
||||||
|
const embeddedFontNames = [
|
||||||
|
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"[\s\S]*?<\/w:font>/gu)
|
||||||
|
].flatMap((match) => /<w:embed(?:Regular|Bold|Italic|BoldItalic)\b/u.test(match[0])
|
||||||
|
? [match[1]]
|
||||||
|
: []);
|
||||||
|
const paragraphs = [
|
||||||
|
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
|
||||||
|
].map((match) => match[0]);
|
||||||
|
const authorParagraphCount = paragraphs.filter((paragraph) =>
|
||||||
|
/<w:pStyle\s+w:val="Author"\s*\/>/u.test(paragraph)
|
||||||
|
).length;
|
||||||
|
const officialIssueRows = paragraphs.filter((paragraph) =>
|
||||||
|
/<w:pStyle\s+w:val="MdOfficialIssueRow"\s*\/>/u.test(
|
||||||
|
paragraph
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const officialIssueRowsWithRightTab = officialIssueRows.filter(
|
||||||
|
(paragraph) =>
|
||||||
|
[...paragraph.matchAll(/<w:tab(?:\s[^>]*)?\s*\/>/gu)].some(
|
||||||
|
(match) =>
|
||||||
|
/\bw:val="right"/u.test(match[0]) &&
|
||||||
|
/\bw:pos="\d+"/u.test(match[0])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const officialIssueRowContentTabCount = officialIssueRows.reduce(
|
||||||
|
(count, paragraph) =>
|
||||||
|
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const inlineCodeContinuity = inlineCodeContinuityProbes.map((probe) => {
|
||||||
|
const matchingParagraphs = paragraphs.filter((paragraph) =>
|
||||||
|
paragraph.includes(probe.code)
|
||||||
|
);
|
||||||
|
const paragraph = matchingParagraphs[0] ?? "";
|
||||||
|
const text = [...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.join("");
|
||||||
|
const codeRunPattern = new RegExp(
|
||||||
|
`<w:r(?:\\s|>)[\\s\\S]*?<w:rStyle\\s+w:val="VerbatimChar"\\s*\\/>[\\s\\S]*?<w:t(?:\\s[^>]*)?>${probe.code}<\\/w:t>[\\s\\S]*?<\\/w:r>`,
|
||||||
|
"u"
|
||||||
|
);
|
||||||
|
const hardBreakCount =
|
||||||
|
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
|
||||||
|
return {
|
||||||
|
id: probe.id,
|
||||||
|
code: probe.code,
|
||||||
|
expectedText: probe.text,
|
||||||
|
actualText: text,
|
||||||
|
paragraphCount: matchingParagraphs.length,
|
||||||
|
hardBreakCount,
|
||||||
|
characterStylePresent: codeRunPattern.test(paragraph),
|
||||||
|
passed:
|
||||||
|
matchingParagraphs.length === 1 &&
|
||||||
|
text === probe.text &&
|
||||||
|
hardBreakCount === 0 &&
|
||||||
|
codeRunPattern.test(paragraph)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const breakProbes = docxBreakParagraphProbes.map((probe) => {
|
||||||
|
const matchingParagraphs = paragraphs.map((paragraph) => ({
|
||||||
|
paragraph,
|
||||||
|
actualText: [
|
||||||
|
...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)
|
||||||
|
].map((match) => match[1]).join("")
|
||||||
|
})).filter(({ actualText }) => actualText === probe.expectedText);
|
||||||
|
const paragraph = matchingParagraphs[0]?.paragraph ?? "";
|
||||||
|
const actualText = matchingParagraphs[0]?.actualText ?? "";
|
||||||
|
const hardBreakCount =
|
||||||
|
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
|
||||||
|
const runs = paragraph.match(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu) ?? [];
|
||||||
|
const characterStylePresent = !probe.requiredCharacterStyle ||
|
||||||
|
runs.some((run) =>
|
||||||
|
run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) &&
|
||||||
|
run.includes("<br>")
|
||||||
|
);
|
||||||
|
const paragraphStylePresent = !probe.requiredParagraphStyle ||
|
||||||
|
new RegExp(
|
||||||
|
`<w:pStyle\\s+w:val="${probe.requiredParagraphStyle}"\\s*\\/>`,
|
||||||
|
"u"
|
||||||
|
).test(paragraph);
|
||||||
|
return {
|
||||||
|
id: probe.id,
|
||||||
|
marker: probe.marker,
|
||||||
|
expectedText: probe.expectedText,
|
||||||
|
actualText,
|
||||||
|
expectedHardBreakCount: probe.hardBreakCount,
|
||||||
|
hardBreakCount,
|
||||||
|
paragraphCount: matchingParagraphs.length,
|
||||||
|
characterStylePresent,
|
||||||
|
paragraphStylePresent,
|
||||||
|
passed:
|
||||||
|
matchingParagraphs.length === 1 &&
|
||||||
|
actualText === probe.expectedText &&
|
||||||
|
hardBreakCount === probe.hardBreakCount &&
|
||||||
|
characterStylePresent &&
|
||||||
|
paragraphStylePresent
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const adjacentA = breakProbes.find((probe) => probe.id === "adjacent-a");
|
||||||
|
const adjacentB = breakProbes.find((probe) => probe.id === "adjacent-b");
|
||||||
|
const tableBreakSemantics = {
|
||||||
|
probes: breakProbes,
|
||||||
|
adjacentCellsIsolated:
|
||||||
|
adjacentA?.paragraphCount === 1 &&
|
||||||
|
adjacentB?.paragraphCount === 1 &&
|
||||||
|
adjacentA.actualText !== adjacentB.actualText,
|
||||||
|
passed:
|
||||||
|
breakProbes.every((probe) => probe.passed) &&
|
||||||
|
adjacentA?.paragraphCount === 1 &&
|
||||||
|
adjacentB?.paragraphCount === 1
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
bytes: content.byteLength,
|
||||||
|
sha256: sha256(content),
|
||||||
|
partCount: Object.keys(entries).length,
|
||||||
|
sectionCount: documentXml.match(/<w:sectPr(?:\s|>)/gu)?.length ?? 0,
|
||||||
|
embeddedFontPartCount: fontParts.length,
|
||||||
|
fontNames: [...new Set(fontNames)].sort((first, second) =>
|
||||||
|
first.localeCompare(second, "en")
|
||||||
|
),
|
||||||
|
embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) =>
|
||||||
|
first.localeCompare(second, "en")
|
||||||
|
),
|
||||||
|
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
|
||||||
|
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
|
||||||
|
internalMarkerCount:
|
||||||
|
documentXml.match(/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu)?.length ?? 0,
|
||||||
|
authorParagraphCount,
|
||||||
|
officialIssueRowCount: officialIssueRows.length,
|
||||||
|
officialIssueRowRightTabCount:
|
||||||
|
officialIssueRowsWithRightTab.length,
|
||||||
|
officialIssueRowContentTabCount,
|
||||||
|
inlineCodeContinuity,
|
||||||
|
inlineCodeContinuityPassed: inlineCodeContinuity.every(
|
||||||
|
(probe) => probe.passed
|
||||||
|
),
|
||||||
|
tableBreakSemantics,
|
||||||
|
requiredStylesPresent: [
|
||||||
|
"Normal",
|
||||||
|
"Heading1",
|
||||||
|
"SourceCode",
|
||||||
|
"Table",
|
||||||
|
"Caption"
|
||||||
|
].every((styleId) => stylesXml.includes(`w:styleId="${styleId}"`))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestArtifact(origin, route, payload, contentType) {
|
||||||
|
const response = await fetch(`${origin}${route}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const content = new Uint8Array(await response.arrayBuffer());
|
||||||
|
assert(
|
||||||
|
response.ok,
|
||||||
|
`${route} 返回 ${response.status}:${decoder.decode(content)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
response.headers.get("content-type")?.includes(contentType),
|
||||||
|
`${route} MIME 无效:${response.headers.get("content-type")}`
|
||||||
|
);
|
||||||
|
return { response, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(origin, route, payload) {
|
||||||
|
const response = await fetch(`${origin}${route}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
assert(response.ok, `${route} 返回 ${response.status}:${text}`);
|
||||||
|
assert(
|
||||||
|
response.headers.get("content-type")?.includes("application/json"),
|
||||||
|
`${route} MIME 无效:${response.headers.get("content-type")}`
|
||||||
|
);
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectHtmlBreakSemantics(articleHtml) {
|
||||||
|
const cellFor = (marker) => {
|
||||||
|
const cells = articleHtml.match(/<t[dh](?:\s[^>]*)?>[\s\S]*?<\/t[dh]>/gu) ?? [];
|
||||||
|
return cells.find((cell) => cell.includes(marker)) ?? "";
|
||||||
|
};
|
||||||
|
const fragmentFor = (start, end) => {
|
||||||
|
const startIndex = articleHtml.indexOf(start);
|
||||||
|
const endIndex = articleHtml.indexOf(end, startIndex + start.length);
|
||||||
|
return startIndex >= 0 && endIndex >= 0
|
||||||
|
? articleHtml.slice(startIndex, endIndex + end.length)
|
||||||
|
: "";
|
||||||
|
};
|
||||||
|
const probes = [
|
||||||
|
{
|
||||||
|
id: "basic",
|
||||||
|
passed: cellFor("标甲").includes(
|
||||||
|
"标甲<br />标乙<br />标丙<br />标丁"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "case-insensitive",
|
||||||
|
passed: cellFor("大甲").includes(
|
||||||
|
"大甲<br />大乙<br />大丙<br />大丁"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "multiple",
|
||||||
|
passed: cellFor("连甲").includes(
|
||||||
|
"连甲<br /><br />连乙"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "boundary",
|
||||||
|
passed: cellFor("边界").includes("<br />边界<br />")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "escaped-and-entity",
|
||||||
|
passed: cellFor("转甲").includes(
|
||||||
|
"转甲<br>转乙 <br> 转丙"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "attribute-literal",
|
||||||
|
passed: cellFor("属甲").includes(
|
||||||
|
"属甲<br class=\"unsafe\">属乙"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inline-code-literal",
|
||||||
|
passed: cellFor("码甲").includes(
|
||||||
|
"码甲<code><br></code>码乙"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outside-table-literal",
|
||||||
|
passed: fragmentFor("外一", "外二").includes(
|
||||||
|
"外一<br>外二"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fenced-code-literal",
|
||||||
|
passed: (() => {
|
||||||
|
const fragment = fragmentFor("围一", "围二");
|
||||||
|
return fragment.includes("<") && fragment.includes("br") &&
|
||||||
|
fragment.includes(">") && !/<br(?:\s|\/?>)/iu.test(fragment);
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const adjacentA = cellFor("邻甲");
|
||||||
|
const adjacentB = cellFor("旁甲");
|
||||||
|
const adjacentCellsIsolated =
|
||||||
|
adjacentA.includes("邻甲<br />邻乙") &&
|
||||||
|
adjacentB.includes("旁甲<br />旁乙") &&
|
||||||
|
!adjacentA.includes("旁甲") &&
|
||||||
|
!adjacentB.includes("邻甲");
|
||||||
|
return {
|
||||||
|
probes,
|
||||||
|
adjacentCellsIsolated,
|
||||||
|
passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseDiagnostics(response, kind) {
|
||||||
|
return {
|
||||||
|
serverTiming: response.headers.get("server-timing"),
|
||||||
|
pageCount:
|
||||||
|
kind === "pdf"
|
||||||
|
? Number(response.headers.get("x-pdf-page-count"))
|
||||||
|
: undefined,
|
||||||
|
warningCount:
|
||||||
|
kind === "docx"
|
||||||
|
? Number(response.headers.get("x-docx-warning-count"))
|
||||||
|
: undefined,
|
||||||
|
echartsErrorCount: Number(
|
||||||
|
response.headers.get("x-echarts-error-count")
|
||||||
|
),
|
||||||
|
mermaidErrorCount: Number(
|
||||||
|
response.headers.get("x-mermaid-error-count")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
|
||||||
|
assertFreshArtifact(
|
||||||
|
"Preview Engine",
|
||||||
|
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
|
||||||
|
path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"packages",
|
||||||
|
"preview-engine",
|
||||||
|
"dist",
|
||||||
|
"semantic-cover-fit.js"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assertFreshArtifact(
|
||||||
|
"DOCX Engine",
|
||||||
|
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
|
||||||
|
path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"packages",
|
||||||
|
"docx-engine",
|
||||||
|
"dist",
|
||||||
|
"document-structure-transform.js"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assertFreshArtifact(
|
||||||
|
"Web Preview Runtime",
|
||||||
|
[
|
||||||
|
path.join(repositoryDirectory, "apps", "web", "src"),
|
||||||
|
path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"packages",
|
||||||
|
"preview-engine",
|
||||||
|
"dist",
|
||||||
|
"semantic-cover-fit.js"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
path.join(webDirectory, "preview-frame.html")
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fs.statSync(fontPackRoot).isDirectory(),
|
||||||
|
`缺少有效字体包根目录:${fontPackRoot}`
|
||||||
|
);
|
||||||
|
const allThemes = readThemes();
|
||||||
|
assert(
|
||||||
|
allThemes.length === 14,
|
||||||
|
`内置主题数量应为 14,实际为 ${allThemes.length}`
|
||||||
|
);
|
||||||
|
const selectedThemeId =
|
||||||
|
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
|
||||||
|
const externalMarkdownPath =
|
||||||
|
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() || undefined;
|
||||||
|
const appendInlineCodeProbes =
|
||||||
|
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
|
||||||
|
const exportConfigOverride = readExportConfigOverride();
|
||||||
|
const themes = selectedThemeId
|
||||||
|
? allThemes.filter((theme) => theme.id === selectedThemeId)
|
||||||
|
: allThemes;
|
||||||
|
assert(
|
||||||
|
themes.length > 0,
|
||||||
|
`R4 验收主题不存在:${selectedThemeId}`
|
||||||
|
);
|
||||||
|
const standardReport = readJson(standardReportPath);
|
||||||
|
assert(
|
||||||
|
standardReport.themeCount === allThemes.length,
|
||||||
|
"标准 DOCX 主题矩阵与 R4 主题数量不一致"
|
||||||
|
);
|
||||||
|
const standardByTheme = new Map(
|
||||||
|
standardReport.results.map((result) => [result.id, result])
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.mkdirSync(pdfDirectory, { recursive: true });
|
||||||
|
fs.mkdirSync(docxDirectory, { recursive: true });
|
||||||
|
const port = await reservePort();
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
|
||||||
|
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
|
||||||
|
const captureDocxMedia = docxMediaAdapter.capture.bind(docxMediaAdapter);
|
||||||
|
let capturedDocxDocumentLayout;
|
||||||
|
docxMediaAdapter.capture = async (...args) => {
|
||||||
|
const output = await captureDocxMedia(...args);
|
||||||
|
capturedDocxDocumentLayout = structuredClone(
|
||||||
|
output.plan.documentLayout ?? { tables: [] }
|
||||||
|
);
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
const app = buildApp({
|
||||||
|
logger: { level: "error" },
|
||||||
|
pdfGenerator,
|
||||||
|
docxMediaAdapter,
|
||||||
|
fontPacks: {
|
||||||
|
roots: [fontPackRoot],
|
||||||
|
appVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
registerWebRuntime(app);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
await app.listen({ port, host: "127.0.0.1" });
|
||||||
|
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
|
||||||
|
const capability = await capabilityResponse.json();
|
||||||
|
assert(
|
||||||
|
capabilityResponse.ok &&
|
||||||
|
capability.status === "available" &&
|
||||||
|
capability.detectedVersion === "3.9.0.2",
|
||||||
|
`DOCX capability 无效:${JSON.stringify(capability)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const theme of themes) {
|
||||||
|
capturedDocxDocumentLayout = undefined;
|
||||||
|
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
|
||||||
|
const samplePath = externalMarkdownPath
|
||||||
|
? path.resolve(repositoryDirectory, externalMarkdownPath)
|
||||||
|
: path.join(samplesDirectory, `${theme.id}.md`);
|
||||||
|
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
|
||||||
|
const sourceMarkdown = fs.readFileSync(samplePath, "utf8");
|
||||||
|
const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`;
|
||||||
|
const sourceMetadata = matter(sourceMarkdown).data;
|
||||||
|
const exportConfig = mergeExportConfig({
|
||||||
|
...defaultExportConfig,
|
||||||
|
name: `${theme.name} R4 生产链验收`,
|
||||||
|
themeId: theme.id,
|
||||||
|
pageDecorationsMode: "theme",
|
||||||
|
header: structuredClone(
|
||||||
|
theme.pageDefaults?.header ?? defaultExportConfig.header
|
||||||
|
),
|
||||||
|
footer: structuredClone(
|
||||||
|
theme.pageDefaults?.footer ?? defaultExportConfig.footer
|
||||||
|
),
|
||||||
|
paper: {
|
||||||
|
...defaultExportConfig.paper,
|
||||||
|
format: "A4",
|
||||||
|
orientation: "portrait",
|
||||||
|
marginMode: "theme",
|
||||||
|
margins: structuredClone(
|
||||||
|
theme.pageDefaults?.margins ??
|
||||||
|
defaultExportConfig.paper.margins
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, exportConfigOverride);
|
||||||
|
const payload = {
|
||||||
|
markdown,
|
||||||
|
fileName: path.basename(samplePath),
|
||||||
|
language: "zh-CN",
|
||||||
|
resources: [],
|
||||||
|
exportConfig
|
||||||
|
};
|
||||||
|
const rendered = await requestJson(origin, "/api/render", payload);
|
||||||
|
const htmlBreakSemantics = appendInlineCodeProbes
|
||||||
|
? inspectHtmlBreakSemantics(rendered.articleHtml)
|
||||||
|
: undefined;
|
||||||
|
if (appendInlineCodeProbes) {
|
||||||
|
assert(
|
||||||
|
htmlBreakSemantics.passed,
|
||||||
|
`主题 ${theme.id} 的 Chromium br 语义门禁失败:${JSON.stringify(htmlBreakSemantics)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pdf = await requestArtifact(
|
||||||
|
origin,
|
||||||
|
"/api/pdf",
|
||||||
|
payload,
|
||||||
|
"application/pdf"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
decoder.decode(pdf.content.subarray(0, 5)) === "%PDF-",
|
||||||
|
`主题 ${theme.id} 的 PDF 签名无效`
|
||||||
|
);
|
||||||
|
const docx = await requestArtifact(
|
||||||
|
origin,
|
||||||
|
"/api/docx",
|
||||||
|
payload,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
);
|
||||||
|
const inspection = inspectDocx(docx.content);
|
||||||
|
assert(!inspection.hasAltChunk, `主题 ${theme.id} 包含 altChunk`);
|
||||||
|
assert(
|
||||||
|
inspection.internalMarkerCount === 0,
|
||||||
|
`主题 ${theme.id} 残留内部结构标记`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
inspection.requiredStylesPresent,
|
||||||
|
`主题 ${theme.id} 缺少标准 Word 样式`
|
||||||
|
);
|
||||||
|
if (appendInlineCodeProbes) {
|
||||||
|
assert(
|
||||||
|
inspection.inlineCodeContinuityPassed,
|
||||||
|
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
inspection.tableBreakSemantics.passed,
|
||||||
|
`主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const standard = standardByTheme.get(theme.id);
|
||||||
|
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
|
||||||
|
assert(
|
||||||
|
standard.inspection.structureCoverage === 1 &&
|
||||||
|
standard.inspection.titlePolicyPassed &&
|
||||||
|
standard.inspection.titleDeduplicationPassed,
|
||||||
|
`主题 ${theme.id} 的标准结构门禁未通过`
|
||||||
|
);
|
||||||
|
if (standard.inspection.profile) {
|
||||||
|
assert(
|
||||||
|
inspection.authorParagraphCount === 0,
|
||||||
|
`结构化主题 ${theme.id} 残留重复 Author 段落`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const expectsOfficialDualEndedRow = Boolean(
|
||||||
|
sourceMetadata.document?.profile === "official" &&
|
||||||
|
sourceMetadata.document.signatory
|
||||||
|
);
|
||||||
|
if (expectsOfficialDualEndedRow) {
|
||||||
|
assert(
|
||||||
|
inspection.officialIssueRowCount > 0 &&
|
||||||
|
inspection.officialIssueRowCount ===
|
||||||
|
inspection.officialIssueRowRightTabCount &&
|
||||||
|
inspection.officialIssueRowContentTabCount ===
|
||||||
|
inspection.officialIssueRowCount,
|
||||||
|
`主题 ${theme.id} 的公文双端行缺少右制表位或签发人分隔`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert(
|
||||||
|
inspection.officialIssueRowRightTabCount === 0 &&
|
||||||
|
inspection.officialIssueRowContentTabCount === 0,
|
||||||
|
`主题 ${theme.id} 的单端公文行包含无意义制表位`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
|
||||||
|
const preferredDocxPath = path.join(docxDirectory, `${theme.id}.docx`);
|
||||||
|
fs.writeFileSync(pdfPath, pdf.content);
|
||||||
|
const docxPath = writeArtifactWithBusyFallback(
|
||||||
|
preferredDocxPath,
|
||||||
|
docx.content
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
id: theme.id,
|
||||||
|
name: theme.name,
|
||||||
|
category: theme.category,
|
||||||
|
compatibleProfiles: theme.compatibleProfiles,
|
||||||
|
sample: path.relative(repositoryDirectory, samplePath),
|
||||||
|
exportConfig,
|
||||||
|
htmlBreakSemantics,
|
||||||
|
pdf: {
|
||||||
|
outputFile: path.relative(repositoryDirectory, pdfPath),
|
||||||
|
bytes: pdf.content.byteLength,
|
||||||
|
sha256: sha256(pdf.content),
|
||||||
|
diagnostics: responseDiagnostics(pdf.response, "pdf")
|
||||||
|
},
|
||||||
|
docx: {
|
||||||
|
outputFile: path.relative(repositoryDirectory, docxPath),
|
||||||
|
inspection,
|
||||||
|
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
|
||||||
|
diagnostics: responseDiagnostics(docx.response, "docx")
|
||||||
|
},
|
||||||
|
standardInspection: standard.inspection,
|
||||||
|
expectsOfficialDualEndedRow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhancedThemes = results.filter(
|
||||||
|
(result) => result.docx.inspection.optionalFontPackApplied
|
||||||
|
);
|
||||||
|
for (const result of results) {
|
||||||
|
assert(
|
||||||
|
result.pdf.diagnostics.pageCount > 0,
|
||||||
|
`主题 ${result.id} 的 PDF 页数无效`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.pdf.diagnostics.echartsErrorCount === 0 &&
|
||||||
|
result.pdf.diagnostics.mermaidErrorCount === 0,
|
||||||
|
`主题 ${result.id} 的 PDF 图表渲染失败`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.docx.diagnostics.echartsErrorCount === 0 &&
|
||||||
|
result.docx.diagnostics.mermaidErrorCount === 0,
|
||||||
|
`主题 ${result.id} 的 DOCX 图表渲染失败`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
origin,
|
||||||
|
themeCount: themes.length,
|
||||||
|
totalThemeCount: allThemes.length,
|
||||||
|
selectedThemeId,
|
||||||
|
fontPackRoot,
|
||||||
|
standardReport: path.relative(repositoryDirectory, standardReportPath),
|
||||||
|
outputDirectory: path.relative(repositoryDirectory, outputDirectory),
|
||||||
|
enhancedThemeIds: enhancedThemes.map((result) => result.id),
|
||||||
|
results
|
||||||
|
};
|
||||||
|
const reportPath = path.join(outputDirectory, "r4-matrix-report.json");
|
||||||
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createApplicationService } from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DOCX_PANDOC_VERSION,
|
||||||
|
defaultExportConfig
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
|
createDocxThemeStyleFingerprint,
|
||||||
|
normalizeDocxThemeMappingConfig,
|
||||||
|
resolveDocxThemeTokens
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import {
|
||||||
|
PandocRuntime,
|
||||||
|
createDynamicReferenceDocx,
|
||||||
|
readReferenceDocxPackage,
|
||||||
|
resolveTokenFonts
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repositoryDirectory = path.resolve(directory, "../../..");
|
||||||
|
const outputDirectory = path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"output",
|
||||||
|
"docx-theme-styles"
|
||||||
|
);
|
||||||
|
const referenceDirectory = path.join(
|
||||||
|
outputDirectory,
|
||||||
|
"references"
|
||||||
|
);
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleBlock(stylesXml, styleId) {
|
||||||
|
const escaped = styleId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||||
|
return stylesXml.match(
|
||||||
|
new RegExp(
|
||||||
|
`<w:style[^>]*w:styleId="${escaped}"[^>]*>[\\s\\S]*?<\\/w:style>`,
|
||||||
|
"u"
|
||||||
|
)
|
||||||
|
)?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectReference(reference, tokens) {
|
||||||
|
const entries = readReferenceDocxPackage(reference.content).entries;
|
||||||
|
const stylesXml = new TextDecoder().decode(
|
||||||
|
entries.get("word/styles.xml")
|
||||||
|
);
|
||||||
|
const fontTableXml = new TextDecoder().decode(
|
||||||
|
entries.get("word/fontTable.xml")
|
||||||
|
);
|
||||||
|
const normal = styleBlock(stylesXml, "Normal");
|
||||||
|
const paragraph = tokens.slots.find(
|
||||||
|
(slot) => slot.slot === "paragraph"
|
||||||
|
)?.style;
|
||||||
|
assert(normal, `主题 ${tokens.themeId} 缺少 Normal 样式`);
|
||||||
|
const fonts = resolveTokenFonts(paragraph, {
|
||||||
|
latin: "Arial",
|
||||||
|
eastAsia: "SimSun"
|
||||||
|
});
|
||||||
|
if (paragraph?.fontCandidates.length) {
|
||||||
|
assert(
|
||||||
|
normal.includes(`w:ascii="${fonts.latin}"`),
|
||||||
|
`主题 ${tokens.themeId} 的 Latin 正文字体未写入模板`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
normal.includes(`w:eastAsia="${fonts.eastAsia}"`),
|
||||||
|
`主题 ${tokens.themeId} 的东亚正文字体未写入模板`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fontTableXml.includes(`w:name="${fonts.eastAsia}"`),
|
||||||
|
`主题 ${tokens.themeId} 的东亚字体未写入字体表`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (paragraph?.fontSizePt !== undefined) {
|
||||||
|
assert(
|
||||||
|
normal.includes(
|
||||||
|
`w:sz w:val="${Math.round(paragraph.fontSizePt * 2)}"`
|
||||||
|
),
|
||||||
|
`主题 ${tokens.themeId} 的正文字号未写入模板`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const styleId of [
|
||||||
|
"Heading1",
|
||||||
|
"SourceCode",
|
||||||
|
"Table",
|
||||||
|
"MdOfficialTitle",
|
||||||
|
"MdBriefingTitle",
|
||||||
|
"MdProjectReportTitle",
|
||||||
|
"MdTenderTitle"
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
stylesXml.includes(`w:styleId="${styleId}"`),
|
||||||
|
`主题 ${tokens.themeId} 缺少样式 ${styleId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bytes: reference.content.byteLength,
|
||||||
|
templateFingerprint: reference.templateFingerprint,
|
||||||
|
cacheKey: reference.cacheKey,
|
||||||
|
stylePreset: reference.stylePreset,
|
||||||
|
partCount: reference.partCount,
|
||||||
|
paragraphFonts: fonts,
|
||||||
|
customStyleCount:
|
||||||
|
stylesXml.match(/w:styleId="Md[A-Za-z]+"/gu)?.length ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = createApplicationService({
|
||||||
|
bundledRoot: path.join(repositoryDirectory, "themes"),
|
||||||
|
localRoot: path.join(repositoryDirectory, ".local", "themes")
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url ?? "/", "http://localhost");
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"content-type": "text/html; charset=utf-8"
|
||||||
|
});
|
||||||
|
response.end("<!doctype html><html><body></body></html>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = url.pathname.match(
|
||||||
|
/^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const themeId = decodeURIComponent(match[1]);
|
||||||
|
const assetPath = match[2]
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => decodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
const asset = await application.getThemeAsset(
|
||||||
|
themeId,
|
||||||
|
assetPath
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"content-type": asset.contentType,
|
||||||
|
"x-content-type-options": "nosniff"
|
||||||
|
});
|
||||||
|
response.end(asset.content);
|
||||||
|
} catch (error) {
|
||||||
|
response.writeHead(500);
|
||||||
|
response.end(
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("无法获取 DOCX 主题样式验收地址");
|
||||||
|
}
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}/`;
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const runtime = new PandocRuntime(
|
||||||
|
process.env.DOCX_PANDOC_PATH?.trim()
|
||||||
|
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
const capability = await runtime.probe();
|
||||||
|
if (capability.capability.status !== "available") {
|
||||||
|
throw new Error(capability.capability.message);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
capability.capability.detectedVersion === DOCX_PANDOC_VERSION,
|
||||||
|
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
|
||||||
|
);
|
||||||
|
const baseline = await runtime.getDefaultReferenceDocx();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
locale: "zh-CN",
|
||||||
|
serviceWorkers: "block"
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
const { themes } = await application.listThemes();
|
||||||
|
const bundledThemes = themes
|
||||||
|
.filter((theme) => theme.source === "bundled")
|
||||||
|
.sort((first, second) =>
|
||||||
|
first.id.localeCompare(second.id, "en")
|
||||||
|
);
|
||||||
|
const snapshots = [];
|
||||||
|
const tokenSets = [];
|
||||||
|
const references = [];
|
||||||
|
for (const theme of bundledThemes) {
|
||||||
|
console.error(`[DOCX theme styles] capturing ${theme.id}`);
|
||||||
|
const prepared = await application.prepareDocxExport({
|
||||||
|
markdown: `# ${theme.name}`,
|
||||||
|
fileName: `${theme.id}.md`,
|
||||||
|
language: "zh-CN",
|
||||||
|
exportConfig: {
|
||||||
|
...defaultExportConfig,
|
||||||
|
themeId: theme.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const themeCss = prepared.theme.css;
|
||||||
|
const manifest = prepared.theme.manifest;
|
||||||
|
const themeFingerprint =
|
||||||
|
await createDocxThemeStyleFingerprint(theme.id, themeCss);
|
||||||
|
const snapshot =
|
||||||
|
await captureDocxThemeStyleWithPlaywrightPage(page, {
|
||||||
|
themeId: theme.id,
|
||||||
|
themeFingerprint,
|
||||||
|
themeCss,
|
||||||
|
baseUrl
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
`主题 ${theme.id} 的槽位数量不正确`
|
||||||
|
);
|
||||||
|
const missing = snapshot.slots.filter((slot) => !slot.matched);
|
||||||
|
assert(
|
||||||
|
missing.length === 0,
|
||||||
|
`主题 ${theme.id} 缺少槽位:${missing
|
||||||
|
.map((slot) => slot.slot)
|
||||||
|
.join("、")}`
|
||||||
|
);
|
||||||
|
snapshots.push(snapshot);
|
||||||
|
const tokens = resolveDocxThemeTokens({
|
||||||
|
snapshot,
|
||||||
|
config: normalizeDocxThemeMappingConfig(manifest)
|
||||||
|
});
|
||||||
|
tokenSets.push(tokens);
|
||||||
|
const reference = createDynamicReferenceDocx(baseline, {
|
||||||
|
exportConfig: prepared.request.exportConfig,
|
||||||
|
theme: manifest,
|
||||||
|
fileName: prepared.request.fileName,
|
||||||
|
metadata: prepared.document.metadata,
|
||||||
|
themeTokens: tokens
|
||||||
|
});
|
||||||
|
fs.mkdirSync(referenceDirectory, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(referenceDirectory, `${theme.id}.docx`),
|
||||||
|
reference.content
|
||||||
|
);
|
||||||
|
references.push({
|
||||||
|
themeId: theme.id,
|
||||||
|
...inspectReference(reference, tokens)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
snapshots.length === 14,
|
||||||
|
`内置主题数量应为 14,实际为 ${snapshots.length}`
|
||||||
|
);
|
||||||
|
const invalidDiagnostics = tokenSets.flatMap((tokens) =>
|
||||||
|
tokens.diagnostics.filter(
|
||||||
|
(diagnostic) =>
|
||||||
|
diagnostic.severity === "error" ||
|
||||||
|
diagnostic.code === "css-value-invalid"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
invalidDiagnostics.length === 0,
|
||||||
|
`DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断`
|
||||||
|
);
|
||||||
|
const paragraphStyles = new Set(
|
||||||
|
snapshots.map(
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.slots.find((slot) => slot.slot === "paragraph")
|
||||||
|
?.computed?.fontFamily
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
paragraphStyles.size > 1,
|
||||||
|
"主题 CSS 未产生可区分的正文字体计算结果"
|
||||||
|
);
|
||||||
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outputDirectory, "snapshots.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
chromiumVersion: browser.version(),
|
||||||
|
themeCount: snapshots.length,
|
||||||
|
slotCount: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
snapshots,
|
||||||
|
tokenSets,
|
||||||
|
pandocVersion: capability.capability.detectedVersion,
|
||||||
|
references
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
chromiumVersion: browser.version(),
|
||||||
|
themes: snapshots.length,
|
||||||
|
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
totalSlots:
|
||||||
|
snapshots.length * DOCX_STYLE_SLOT_NAMES.length,
|
||||||
|
tokenSets: tokenSets.length,
|
||||||
|
diagnostics: tokenSets.reduce(
|
||||||
|
(count, tokens) => count + tokens.diagnostics.length,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
pandocVersion: capability.capability.detectedVersion,
|
||||||
|
references: references.length
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
+287
-119
@@ -4,16 +4,31 @@ import { performance } from "node:perf_hooks";
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import {
|
import {
|
||||||
EXPORT_CONFIG_VERSION,
|
EXPORT_CONFIG_VERSION,
|
||||||
|
DOCX_MIME_TYPE,
|
||||||
createPagedDocumentPayload,
|
createPagedDocumentPayload,
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
exportConfigSchema,
|
exportConfigSchema,
|
||||||
supportedPaperFormats
|
supportedPaperFormats
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
MarkdownDocumentParseError,
|
ApplicationRequestError,
|
||||||
RENDERER_VERSION,
|
DocxExportService,
|
||||||
renderMarkdown
|
DocxExportServiceError,
|
||||||
} from "@md-to-pdf/renderer";
|
DocxThemeTokenService,
|
||||||
|
createApplicationService,
|
||||||
|
readDocxExportRuntimeLimits,
|
||||||
|
type ApplicationService,
|
||||||
|
type ApplicationServiceOptions
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
PandocDocxConverter,
|
||||||
|
PandocRuntime
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
|
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||||
|
import {
|
||||||
|
createDocxMediaEngine,
|
||||||
|
type ServerDocxMediaCaptureAdapter
|
||||||
|
} from "./docx-media-engine.js";
|
||||||
import {
|
import {
|
||||||
createPdfGenerator,
|
createPdfGenerator,
|
||||||
PdfEngineClosedError,
|
PdfEngineClosedError,
|
||||||
@@ -21,11 +36,11 @@ import {
|
|||||||
PdfRenderTimeoutError,
|
PdfRenderTimeoutError,
|
||||||
type PdfGenerator
|
type PdfGenerator
|
||||||
} from "./pdf-engine.js";
|
} from "./pdf-engine.js";
|
||||||
import { createThemeRegistry } from "./theme-registry.js";
|
|
||||||
|
|
||||||
interface RenderRequestBody {
|
interface RenderRequestBody {
|
||||||
markdown?: unknown;
|
markdown?: unknown;
|
||||||
language?: unknown;
|
language?: unknown;
|
||||||
|
resources?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PdfRequestBody extends RenderRequestBody {
|
interface PdfRequestBody extends RenderRequestBody {
|
||||||
@@ -34,66 +49,6 @@ interface PdfRequestBody extends RenderRequestBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||||
const maximumMarkdownLength = 1_500_000;
|
|
||||||
|
|
||||||
function validateMarkdownRequest(
|
|
||||||
body: RenderRequestBody | undefined
|
|
||||||
):
|
|
||||||
| { markdown: string; language?: string }
|
|
||||||
| { statusCode: 400 | 413; error: string; message: string } {
|
|
||||||
const { markdown, language } = body ?? {};
|
|
||||||
|
|
||||||
if (typeof markdown !== "string") {
|
|
||||||
return {
|
|
||||||
statusCode: 400,
|
|
||||||
error: "INVALID_MARKDOWN",
|
|
||||||
message: "markdown 必须是字符串"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (markdown.length > maximumMarkdownLength) {
|
|
||||||
return {
|
|
||||||
statusCode: 413,
|
|
||||||
error: "MARKDOWN_TOO_LARGE",
|
|
||||||
message: "Markdown 内容不能超过 1.5 MB"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (language !== undefined && typeof language !== "string") {
|
|
||||||
return {
|
|
||||||
statusCode: 400,
|
|
||||||
error: "INVALID_LANGUAGE",
|
|
||||||
message: "language 必须是字符串"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
markdown,
|
|
||||||
...(typeof language === "string" ? { language } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMarkdownRequest(
|
|
||||||
markdown: string,
|
|
||||||
language: string | undefined
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
success: true as const,
|
|
||||||
document: renderMarkdown(markdown, {
|
|
||||||
...(language ? { language } : {})
|
|
||||||
})
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof MarkdownDocumentParseError) {
|
|
||||||
return {
|
|
||||||
success: false as const,
|
|
||||||
error: error.code,
|
|
||||||
message: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeRfc5987(value: string) {
|
function encodeRfc5987(value: string) {
|
||||||
return encodeURIComponent(value).replace(
|
return encodeURIComponent(value).replace(
|
||||||
/[!'()*]/g,
|
/[!'()*]/g,
|
||||||
@@ -127,6 +82,14 @@ export interface BuildAppOptions {
|
|||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
pdfGenerator?: PdfGenerator;
|
pdfGenerator?: PdfGenerator;
|
||||||
prewarmPdfBrowser?: boolean;
|
prewarmPdfBrowser?: boolean;
|
||||||
|
prewarmDocxRuntime?: boolean;
|
||||||
|
applicationService?: ApplicationService;
|
||||||
|
docxExportService?: Pick<
|
||||||
|
DocxExportService,
|
||||||
|
"getCapability" | "generate" | "close"
|
||||||
|
>;
|
||||||
|
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
|
||||||
|
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function milliseconds(value: number) {
|
function milliseconds(value: number) {
|
||||||
@@ -164,19 +127,65 @@ function createPdfServerTiming(
|
|||||||
].join(", ");
|
].join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDocxServerTiming(
|
||||||
|
timings: Awaited<
|
||||||
|
ReturnType<DocxExportService["generate"]>
|
||||||
|
>["timings"]
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
`queue;dur=${milliseconds(timings.queueMs)}`,
|
||||||
|
`runtime-probe;dur=${milliseconds(timings.probeMs)}`,
|
||||||
|
`prepare;dur=${milliseconds(timings.prepareMs)}`,
|
||||||
|
`theme-style;dur=${milliseconds(timings.themeStyleMs)}`,
|
||||||
|
`media;dur=${milliseconds(timings.mediaMs)}`,
|
||||||
|
`reference;dur=${milliseconds(timings.referenceMs)}`,
|
||||||
|
`pandoc;dur=${milliseconds(timings.pandocMs)}`,
|
||||||
|
`validation;dur=${milliseconds(timings.validationMs)}`,
|
||||||
|
`total;dur=${milliseconds(timings.totalMs)}`
|
||||||
|
].join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocxContentDisposition(fileName: string) {
|
||||||
|
return (
|
||||||
|
`attachment; filename="document.docx"; ` +
|
||||||
|
`filename*=UTF-8''${encodeRfc5987(fileName)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildApp(options: BuildAppOptions = {}) {
|
export function buildApp(options: BuildAppOptions = {}) {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: options.logger ?? true,
|
logger: options.logger ?? true,
|
||||||
bodyLimit: 2 * 1024 * 1024
|
bodyLimit: 24 * 1024 * 1024
|
||||||
});
|
});
|
||||||
const themes = createThemeRegistry({
|
const applicationService =
|
||||||
|
options.applicationService ??
|
||||||
|
createApplicationService({
|
||||||
bundledRoot: resolve(projectRoot, "themes"),
|
bundledRoot: resolve(projectRoot, "themes"),
|
||||||
localRoot:
|
localRoot:
|
||||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||||
onWarning: (message) => app.log.warn(message)
|
onWarning: (message) => app.log.warn(message),
|
||||||
|
...(options.fontPacks ? { fontPacks: options.fontPacks } : {})
|
||||||
});
|
});
|
||||||
const pdfGenerator =
|
const pdfGenerator =
|
||||||
options.pdfGenerator ?? createPdfGenerator();
|
options.pdfGenerator ?? createPdfGenerator();
|
||||||
|
const pandocRuntime = options.docxExportService
|
||||||
|
? undefined
|
||||||
|
: new PandocRuntime();
|
||||||
|
const docxMediaAdapter =
|
||||||
|
options.docxMediaAdapter ?? createDocxMediaEngine();
|
||||||
|
const docxExportService =
|
||||||
|
options.docxExportService ??
|
||||||
|
new DocxExportService({
|
||||||
|
application: applicationService,
|
||||||
|
runtime: pandocRuntime!,
|
||||||
|
converter: new PandocDocxConverter({
|
||||||
|
runtime: pandocRuntime!
|
||||||
|
}),
|
||||||
|
themeTokens: new DocxThemeTokenService(
|
||||||
|
docxMediaAdapter
|
||||||
|
),
|
||||||
|
limits: readDocxExportRuntimeLimits()
|
||||||
|
});
|
||||||
|
|
||||||
if (options.prewarmPdfBrowser) {
|
if (options.prewarmPdfBrowser) {
|
||||||
app.addHook("onReady", async () => {
|
app.addHook("onReady", async () => {
|
||||||
@@ -188,8 +197,27 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.prewarmDocxRuntime) {
|
||||||
|
app.addHook("onReady", async () => {
|
||||||
|
try {
|
||||||
|
const capability =
|
||||||
|
await docxExportService.getCapability();
|
||||||
|
if (capability.status !== "available") {
|
||||||
|
app.log.warn(
|
||||||
|
{ capability },
|
||||||
|
"DOCX runtime unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ error }, "DOCX runtime probe failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
await pdfGenerator.close();
|
await pdfGenerator.close();
|
||||||
|
await docxExportService.close();
|
||||||
|
await docxMediaAdapter.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/health", async () => ({
|
app.get("/api/health", async () => ({
|
||||||
@@ -199,37 +227,39 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
rendererVersion: RENDERER_VERSION
|
rendererVersion: RENDERER_VERSION
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/capabilities", async () => ({
|
app.get("/api/capabilities", async () => {
|
||||||
|
const docx = await docxExportService.getCapability();
|
||||||
|
return {
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
supportedPaperFormats,
|
supportedPaperFormats,
|
||||||
|
docx,
|
||||||
implemented: [
|
implemented: [
|
||||||
"project-skeleton",
|
"project-skeleton",
|
||||||
"export-config",
|
"export-config",
|
||||||
"theme-manifest",
|
"theme-manifest",
|
||||||
"markdown-render",
|
"markdown-render",
|
||||||
"html-preview",
|
"html-preview",
|
||||||
"pdf-export"
|
"pdf-export",
|
||||||
|
"docx-export"
|
||||||
],
|
],
|
||||||
planned: []
|
planned: []
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/themes", async () => ({
|
app.get("/api/docx/capability", async () =>
|
||||||
themes: (await themes.list()).map(({ manifest, source }) => ({
|
docxExportService.getCapability()
|
||||||
id: manifest.id,
|
);
|
||||||
name: manifest.name,
|
|
||||||
version: manifest.version,
|
app.get("/api/themes", async () =>
|
||||||
description: manifest.description,
|
applicationService.listThemes()
|
||||||
bundled: manifest.bundled,
|
);
|
||||||
source
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.get<{ Params: { themeId: string } }>(
|
app.get<{ Params: { themeId: string } }>(
|
||||||
"/api/themes/:themeId/css",
|
"/api/themes/:themeId/css",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { themeId } = request.params;
|
const { themeId } = request.params;
|
||||||
|
|
||||||
const css = await themes.getCss(themeId);
|
const css = await applicationService.getThemeCss(themeId);
|
||||||
if (css === undefined) {
|
if (css === undefined) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
error: "THEME_NOT_FOUND",
|
error: "THEME_NOT_FOUND",
|
||||||
@@ -248,7 +278,7 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
"/api/themes/:themeId/assets/*",
|
"/api/themes/:themeId/assets/*",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const asset = await themes.getAsset(
|
const asset = await applicationService.getThemeAsset(
|
||||||
request.params.themeId,
|
request.params.themeId,
|
||||||
request.params["*"]
|
request.params["*"]
|
||||||
);
|
);
|
||||||
@@ -274,28 +304,58 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: {
|
||||||
|
packId: string;
|
||||||
|
packVersion: string;
|
||||||
|
faceId: string;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
"/api/font-packs/:packId/:packVersion/:faceId/web",
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const asset = await applicationService.getFontPackAsset(
|
||||||
|
request.params.packId,
|
||||||
|
request.params.packVersion,
|
||||||
|
request.params.faceId,
|
||||||
|
"web"
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: "FONT_PACK_ASSET_NOT_FOUND",
|
||||||
|
message: "未找到指定字体包资源"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
.header("content-type", asset.contentType)
|
||||||
|
.header("cache-control", "public, max-age=31536000, immutable")
|
||||||
|
.header("content-security-policy", "default-src 'none'; sandbox")
|
||||||
|
.header("x-content-type-options", "nosniff")
|
||||||
|
.header("etag", `\"${asset.sha256}\"`)
|
||||||
|
.send(asset.content);
|
||||||
|
} catch {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: "FONT_PACK_ASSET_NOT_FOUND",
|
||||||
|
message: "未找到指定字体包资源"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.post<{ Body: RenderRequestBody }>(
|
app.post<{ Body: RenderRequestBody }>(
|
||||||
"/api/render",
|
"/api/render",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const validated = validateMarkdownRequest(request.body);
|
try {
|
||||||
if ("statusCode" in validated) {
|
return await applicationService.render(request.body ?? {});
|
||||||
return reply.code(validated.statusCode).send({
|
} catch (error) {
|
||||||
error: validated.error,
|
if (error instanceof ApplicationRequestError) {
|
||||||
message: validated.message
|
return reply.code(error.statusCode).send({
|
||||||
|
error: error.code,
|
||||||
|
message: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
const rendered = renderMarkdownRequest(
|
|
||||||
validated.markdown,
|
|
||||||
validated.language
|
|
||||||
);
|
|
||||||
if (!rendered.success) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
error: rendered.error,
|
|
||||||
message: rendered.message
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return rendered.document;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,14 +363,6 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
"/api/pdf",
|
"/api/pdf",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const requestStartedAt = performance.now();
|
const requestStartedAt = performance.now();
|
||||||
const validated = validateMarkdownRequest(request.body);
|
|
||||||
if ("statusCode" in validated) {
|
|
||||||
return reply.code(validated.statusCode).send({
|
|
||||||
error: validated.error,
|
|
||||||
message: validated.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedConfig = exportConfigSchema.safeParse(
|
const parsedConfig = exportConfigSchema.safeParse(
|
||||||
request.body?.exportConfig
|
request.body?.exportConfig
|
||||||
);
|
);
|
||||||
@@ -333,7 +385,9 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const themeStartedAt = performance.now();
|
const themeStartedAt = performance.now();
|
||||||
const themeCss = await themes.getCss(parsedConfig.data.themeId);
|
const themeCss = await applicationService.getThemeCss(
|
||||||
|
parsedConfig.data.themeId
|
||||||
|
);
|
||||||
const themeMs = performance.now() - themeStartedAt;
|
const themeMs = performance.now() - themeStartedAt;
|
||||||
if (themeCss === undefined) {
|
if (themeCss === undefined) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -343,18 +397,19 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markdownStartedAt = performance.now();
|
const markdownStartedAt = performance.now();
|
||||||
const renderedResult = renderMarkdownRequest(
|
let rendered;
|
||||||
validated.markdown,
|
try {
|
||||||
validated.language
|
rendered = await applicationService.render(request.body ?? {});
|
||||||
);
|
} catch (error) {
|
||||||
const markdownMs = performance.now() - markdownStartedAt;
|
if (error instanceof ApplicationRequestError) {
|
||||||
if (!renderedResult.success) {
|
return reply.code(error.statusCode).send({
|
||||||
return reply.code(400).send({
|
error: error.code,
|
||||||
error: renderedResult.error,
|
message: error.message
|
||||||
message: renderedResult.message
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const rendered = renderedResult.document;
|
throw error;
|
||||||
|
}
|
||||||
|
const markdownMs = performance.now() - markdownStartedAt;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const generated = await pdfGenerator.generate(
|
const generated = await pdfGenerator.generate(
|
||||||
@@ -431,7 +486,16 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.error({ error }, "PDF generation failed");
|
request.log.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
errorMessage:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
errorStack:
|
||||||
|
error instanceof Error ? error.stack : undefined
|
||||||
|
},
|
||||||
|
"PDF generation failed"
|
||||||
|
);
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: "PDF_GENERATION_FAILED",
|
error: "PDF_GENERATION_FAILED",
|
||||||
message: "PDF 生成失败"
|
message: "PDF 生成失败"
|
||||||
@@ -440,5 +504,109 @@ export function buildApp(options: BuildAppOptions = {}) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: unknown }>("/api/docx", async (request, reply) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const abortRequest = () =>
|
||||||
|
abortController.abort(new Error("HTTP 请求已中断"));
|
||||||
|
request.raw.once("aborted", abortRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generated = await docxExportService.generate(
|
||||||
|
request.body ?? {},
|
||||||
|
{
|
||||||
|
mediaAdapter: docxMediaAdapter,
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
fileName: generated.fileName,
|
||||||
|
bytes: generated.docx.byteLength,
|
||||||
|
diagnostics: generated.diagnostics,
|
||||||
|
timings: generated.timings
|
||||||
|
},
|
||||||
|
"DOCX generated"
|
||||||
|
);
|
||||||
|
return reply
|
||||||
|
.header("content-type", DOCX_MIME_TYPE)
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
createDocxContentDisposition(generated.fileName)
|
||||||
|
)
|
||||||
|
.header("cache-control", "no-store")
|
||||||
|
.header(
|
||||||
|
"server-timing",
|
||||||
|
createDocxServerTiming(generated.timings)
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"x-docx-warning-count",
|
||||||
|
String(generated.diagnostics.warnings.length)
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"x-echarts-error-count",
|
||||||
|
String(generated.diagnostics.echartsErrors.length)
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"x-mermaid-error-count",
|
||||||
|
String(generated.diagnostics.mermaidErrors.length)
|
||||||
|
)
|
||||||
|
.send(
|
||||||
|
Buffer.from(
|
||||||
|
generated.docx.buffer,
|
||||||
|
generated.docx.byteOffset,
|
||||||
|
generated.docx.byteLength
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApplicationRequestError) {
|
||||||
|
return reply.code(error.statusCode).send({
|
||||||
|
error: error.code,
|
||||||
|
message: error.message,
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof DocxExportServiceError) {
|
||||||
|
if (error.statusCode >= 500) {
|
||||||
|
request.log.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
cause: error.cause,
|
||||||
|
causeMessage:
|
||||||
|
error.cause instanceof Error
|
||||||
|
? error.cause.message
|
||||||
|
: undefined,
|
||||||
|
causeStack:
|
||||||
|
error.cause instanceof Error
|
||||||
|
? error.cause.stack
|
||||||
|
: undefined,
|
||||||
|
causeDiagnostics:
|
||||||
|
error.cause instanceof Error &&
|
||||||
|
"diagnostics" in error.cause
|
||||||
|
? error.cause.diagnostics
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
"DOCX generation service failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error.retryable) {
|
||||||
|
reply.header("retry-after", "5");
|
||||||
|
}
|
||||||
|
return reply.code(error.statusCode).send({
|
||||||
|
error: error.code,
|
||||||
|
message: error.message,
|
||||||
|
retryable: error.retryable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request.log.error({ error }, "DOCX generation failed");
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: "DOCX_GENERATION_FAILED",
|
||||||
|
message: "DOCX 生成失败",
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
request.raw.removeListener("aborted", abortRequest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { chromium, type Browser, type BrowserContext } from "playwright";
|
||||||
|
import type {
|
||||||
|
DocxMediaCaptureAdapter,
|
||||||
|
DocxMediaCaptureRequest
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import type {
|
||||||
|
DocxThemeStyleCaptureAdapter,
|
||||||
|
DocxThemeStyleCaptureRequest
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import { captureDocxMediaWithPlaywrightPage } from "./playwright-docx-media-capture.js";
|
||||||
|
import { captureDocxThemeStyleWithPlaywrightPage } from "./playwright-docx-theme-style.js";
|
||||||
|
import { isAllowedPdfRequestUrl } from "./pdf-engine.js";
|
||||||
|
|
||||||
|
export interface ServerDocxMediaCaptureAdapter
|
||||||
|
extends DocxMediaCaptureAdapter,
|
||||||
|
DocxThemeStyleCaptureAdapter {
|
||||||
|
warmup?(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaywrightDocxMediaEngineOptions {
|
||||||
|
renderOrigin?: string;
|
||||||
|
launchBrowser?: () => Promise<Browser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRenderOrigin(value: string) {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new Error("DOCX 媒体渲染地址必须使用 HTTP 或 HTTPS");
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeContext(context: BrowserContext) {
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaywrightDocxMediaEngine
|
||||||
|
implements ServerDocxMediaCaptureAdapter
|
||||||
|
{
|
||||||
|
readonly themeStyleBaseUrl: string;
|
||||||
|
private readonly renderOrigin: string;
|
||||||
|
private readonly renderUrl: string;
|
||||||
|
private readonly launchBrowser: () => Promise<Browser>;
|
||||||
|
private browserPromise: Promise<Browser> | undefined;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(options: PlaywrightDocxMediaEngineOptions = {}) {
|
||||||
|
this.renderOrigin = normalizeRenderOrigin(
|
||||||
|
options.renderOrigin ??
|
||||||
|
process.env.PDF_RENDER_ORIGIN ??
|
||||||
|
"http://localhost:5173"
|
||||||
|
);
|
||||||
|
this.renderUrl = new URL(
|
||||||
|
"/preview-frame.html?target=continuous",
|
||||||
|
this.renderOrigin
|
||||||
|
).href;
|
||||||
|
this.themeStyleBaseUrl = this.renderUrl;
|
||||||
|
this.launchBrowser =
|
||||||
|
options.launchBrowser ??
|
||||||
|
(() =>
|
||||||
|
chromium.launch({
|
||||||
|
headless: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async warmup() {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error("DOCX 媒体服务已关闭");
|
||||||
|
}
|
||||||
|
await this.getBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
|
async capture(
|
||||||
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error("DOCX 媒体服务已关闭");
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
|
const browser = await this.getBrowser();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const context = await browser.newContext({
|
||||||
|
locale: request.payload.metadata.language || "zh-CN",
|
||||||
|
serviceWorkers: "block"
|
||||||
|
});
|
||||||
|
const abort = () => {
|
||||||
|
void closeContext(context);
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.route("**/*", async (route) => {
|
||||||
|
if (
|
||||||
|
isAllowedPdfRequestUrl(
|
||||||
|
route.request().url(),
|
||||||
|
this.renderOrigin
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.abort("blockedbyclient");
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(this.renderUrl, {
|
||||||
|
waitUntil: "domcontentloaded"
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.documentElement.dataset.runtimeReady === "true"
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return await captureDocxMediaWithPlaywrightPage(
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
await closeContext(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureThemeStyle(
|
||||||
|
request: DocxThemeStyleCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error("DOCX 媒体服务已关闭");
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (new URL(request.baseUrl).origin !== this.renderOrigin) {
|
||||||
|
throw new Error("DOCX 主题样式资源地址必须与渲染地址同源");
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await this.getBrowser();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const context = await browser.newContext({
|
||||||
|
locale: "zh-CN",
|
||||||
|
serviceWorkers: "block"
|
||||||
|
});
|
||||||
|
const abort = () => {
|
||||||
|
void closeContext(context);
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.route("**/*", async (route) => {
|
||||||
|
if (
|
||||||
|
isAllowedPdfRequestUrl(
|
||||||
|
route.request().url(),
|
||||||
|
this.renderOrigin
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.abort("blockedbyclient");
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(this.renderUrl, {
|
||||||
|
waitUntil: "domcontentloaded"
|
||||||
|
});
|
||||||
|
return await captureDocxThemeStyleWithPlaywrightPage(
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
await closeContext(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.closed = true;
|
||||||
|
const browserPromise = this.browserPromise;
|
||||||
|
this.browserPromise = undefined;
|
||||||
|
if (!browserPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const browser = await browserPromise;
|
||||||
|
await browser.close();
|
||||||
|
} catch {
|
||||||
|
// 浏览器启动失败时没有可关闭的实例。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBrowser() {
|
||||||
|
if (!this.browserPromise) {
|
||||||
|
const browserPromise = this.launchBrowser();
|
||||||
|
this.browserPromise = browserPromise;
|
||||||
|
void browserPromise
|
||||||
|
.then((browser) => {
|
||||||
|
browser.on("disconnected", () => {
|
||||||
|
if (this.browserPromise === browserPromise) {
|
||||||
|
this.browserPromise = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (this.browserPromise === browserPromise) {
|
||||||
|
this.browserPromise = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.browserPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocxMediaEngine(
|
||||||
|
options: PlaywrightDocxMediaEngineOptions = {}
|
||||||
|
) {
|
||||||
|
return new PlaywrightDocxMediaEngine(options);
|
||||||
|
}
|
||||||
@@ -2,8 +2,18 @@ import { buildApp } from "./app.js";
|
|||||||
|
|
||||||
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
|
||||||
const host = process.env.HOST ?? "0.0.0.0";
|
const host = process.env.HOST ?? "0.0.0.0";
|
||||||
|
const fontPackRoot = process.env.FONT_PACK_ROOT?.trim();
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
prewarmPdfBrowser: true
|
prewarmPdfBrowser: true,
|
||||||
|
prewarmDocxRuntime: true,
|
||||||
|
...(fontPackRoot
|
||||||
|
? {
|
||||||
|
fontPacks: {
|
||||||
|
roots: [fontPackRoot],
|
||||||
|
appVersion: process.env.APP_VERSION ?? "0.6.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
});
|
});
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
|
|||||||
timeoutMs: readIntegerEnvironment(
|
timeoutMs: readIntegerEnvironment(
|
||||||
environment,
|
environment,
|
||||||
"PDF_TIMEOUT_MS",
|
"PDF_TIMEOUT_MS",
|
||||||
60_000,
|
180_000,
|
||||||
1_000
|
1_000
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Page } from "playwright";
|
||||||
|
import type {
|
||||||
|
DocxMediaCaptureAdapter,
|
||||||
|
DocxMediaCaptureRequest
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
interface CaptureScreenshotResult {
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCapturePlan(
|
||||||
|
page: Page,
|
||||||
|
request: DocxMediaCaptureRequest
|
||||||
|
) {
|
||||||
|
return page.evaluate(
|
||||||
|
async ({ payload, dimensions }): Promise<DocxMediaCapturePlan> => {
|
||||||
|
const renderer = (
|
||||||
|
window as typeof window & {
|
||||||
|
__mdToPdfRenderDocxMedia?: (
|
||||||
|
renderPayload: typeof payload,
|
||||||
|
renderDimensions: typeof dimensions
|
||||||
|
) => Promise<DocxMediaCapturePlan>;
|
||||||
|
}
|
||||||
|
).__mdToPdfRenderDocxMedia;
|
||||||
|
if (!renderer) {
|
||||||
|
throw new Error("DOCX 媒体渲染运行时不可用");
|
||||||
|
}
|
||||||
|
return renderer(payload, dimensions);
|
||||||
|
},
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureDocxMediaWithPlaywrightPage(
|
||||||
|
page: Page,
|
||||||
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
await page.emulateMedia({ media: "print" });
|
||||||
|
const plan = await renderCapturePlan(page, request);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const session = await page.context().newCDPSession(page);
|
||||||
|
try {
|
||||||
|
const captures = [];
|
||||||
|
for (const target of plan.targets) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const result = (await session.send("Page.captureScreenshot", {
|
||||||
|
format: "png",
|
||||||
|
fromSurface: true,
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
clip: {
|
||||||
|
x: target.captureX,
|
||||||
|
y: target.captureY,
|
||||||
|
width: target.captureWidthPx,
|
||||||
|
height: target.captureHeightPx,
|
||||||
|
scale: target.rasterScale
|
||||||
|
}
|
||||||
|
})) as CaptureScreenshotResult;
|
||||||
|
captures.push({
|
||||||
|
id: target.id,
|
||||||
|
png: Buffer.from(result.data, "base64")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return { plan, captures };
|
||||||
|
} finally {
|
||||||
|
await session.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaywrightDocxMediaCaptureAdapter
|
||||||
|
implements DocxMediaCaptureAdapter
|
||||||
|
{
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||||
|
return captureDocxMediaWithPlaywrightPage(
|
||||||
|
this.page,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
createDocxThemeStyleCaptureScript,
|
||||||
|
parseDocxThemeStyleRuntimeCapture,
|
||||||
|
type DocxThemeStyleCaptureRequest,
|
||||||
|
type DocxThemeStyleSnapshot
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import type { Page } from "playwright";
|
||||||
|
|
||||||
|
export async function captureDocxThemeStyleWithPlaywrightPage(
|
||||||
|
page: Page,
|
||||||
|
request: DocxThemeStyleCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<DocxThemeStyleSnapshot> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
await page.emulateMedia({ media: "print" });
|
||||||
|
const capture = await page.evaluate(
|
||||||
|
createDocxThemeStyleCaptureScript(request)
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return parseDocxThemeStyleRuntimeCapture(request, capture);
|
||||||
|
}
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
import {
|
|
||||||
readdir,
|
|
||||||
readFile,
|
|
||||||
realpath,
|
|
||||||
stat
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import {
|
|
||||||
extname,
|
|
||||||
isAbsolute,
|
|
||||||
posix,
|
|
||||||
relative,
|
|
||||||
resolve,
|
|
||||||
sep
|
|
||||||
} from "node:path";
|
|
||||||
import {
|
|
||||||
themeManifestSchema,
|
|
||||||
type ThemeManifest
|
|
||||||
} from "@md-to-pdf/core";
|
|
||||||
|
|
||||||
export interface ThemeRecord {
|
|
||||||
manifest: ThemeManifest;
|
|
||||||
directory: string;
|
|
||||||
source: "bundled" | "local";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemeRegistryOptions {
|
|
||||||
bundledRoot: string;
|
|
||||||
localRoot: string;
|
|
||||||
onWarning?: (message: string) => void;
|
|
||||||
cacheTtlMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetContentTypes: Record<string, string> = {
|
|
||||||
".gif": "image/gif",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".png": "image/png",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".ttf": "font/ttf",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".woff": "font/woff",
|
|
||||||
".woff2": "font/woff2"
|
|
||||||
};
|
|
||||||
|
|
||||||
const cssImportPattern =
|
|
||||||
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
|
|
||||||
const maximumCssImportDepth = 8;
|
|
||||||
|
|
||||||
function isMissingFileError(error: unknown) {
|
|
||||||
return (
|
|
||||||
error instanceof Error &&
|
|
||||||
"code" in error &&
|
|
||||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInsideDirectory(directory: string, path: string) {
|
|
||||||
const pathFromDirectory = relative(directory, path);
|
|
||||||
return (
|
|
||||||
pathFromDirectory === "" ||
|
|
||||||
(!pathFromDirectory.startsWith(`..${sep}`) &&
|
|
||||||
pathFromDirectory !== ".." &&
|
|
||||||
!isAbsolute(pathFromDirectory))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveThemeFile(directory: string, relativePath: string) {
|
|
||||||
if (
|
|
||||||
isAbsolute(relativePath) ||
|
|
||||||
relativePath.includes("\0") ||
|
|
||||||
relativePath.split(/[\\/]/).includes("..")
|
|
||||||
) {
|
|
||||||
throw new Error("主题资源路径不安全");
|
|
||||||
}
|
|
||||||
|
|
||||||
const realDirectory = await realpath(directory);
|
|
||||||
const candidate = resolve(realDirectory, relativePath);
|
|
||||||
if (!isInsideDirectory(realDirectory, candidate)) {
|
|
||||||
throw new Error("主题资源路径越界");
|
|
||||||
}
|
|
||||||
|
|
||||||
const realCandidate = await realpath(candidate);
|
|
||||||
if (!isInsideDirectory(realDirectory, realCandidate)) {
|
|
||||||
throw new Error("主题资源符号链接越界");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileStat = await stat(realCandidate);
|
|
||||||
if (!fileStat.isFile()) {
|
|
||||||
throw new Error("主题资源不是文件");
|
|
||||||
}
|
|
||||||
|
|
||||||
return realCandidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readThemeRoot(
|
|
||||||
root: string,
|
|
||||||
source: ThemeRecord["source"],
|
|
||||||
onWarning?: (message: string) => void
|
|
||||||
): Promise<ThemeRecord[]> {
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = await readdir(root, { withFileTypes: true });
|
|
||||||
} catch (error) {
|
|
||||||
if (isMissingFileError(error) && source === "local") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const records: ThemeRecord[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = resolve(root, entry.name);
|
|
||||||
try {
|
|
||||||
const manifestPath = await resolveThemeFile(directory, "theme.json");
|
|
||||||
const manifest = themeManifestSchema.parse(
|
|
||||||
JSON.parse(await readFile(manifestPath, "utf8"))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (manifest.id !== entry.name) {
|
|
||||||
throw new Error("主题目录名必须与主题 ID 一致");
|
|
||||||
}
|
|
||||||
if (manifest.bundled !== (source === "bundled")) {
|
|
||||||
throw new Error("主题 bundled 标记与来源不一致");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest.base) {
|
|
||||||
await resolveThemeFile(directory, manifest.base);
|
|
||||||
}
|
|
||||||
await resolveThemeFile(directory, manifest.entry);
|
|
||||||
if (manifest.print) {
|
|
||||||
await resolveThemeFile(directory, manifest.print);
|
|
||||||
}
|
|
||||||
|
|
||||||
records.push({
|
|
||||||
manifest,
|
|
||||||
directory,
|
|
||||||
source
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (source === "local") {
|
|
||||||
onWarning?.(`已忽略无效本地主题 ${entry.name}:${message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error(`主题 ${entry.name} 无效:${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeAssetPath(path: string) {
|
|
||||||
return path
|
|
||||||
.split("/")
|
|
||||||
.map((segment) => encodeURIComponent(segment))
|
|
||||||
.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareCssSegment(
|
|
||||||
css: string,
|
|
||||||
themeId: string,
|
|
||||||
cssDirectory: string
|
|
||||||
) {
|
|
||||||
return css.replace(
|
|
||||||
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
|
||||||
(match, _quote: string, rawValue: string) => {
|
|
||||||
const value = rawValue.trim();
|
|
||||||
if (value.startsWith("data:") || value.startsWith("#")) {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
value.startsWith("/") ||
|
|
||||||
value.startsWith("//") ||
|
|
||||||
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
|
||||||
) {
|
|
||||||
throw new Error(`主题包含不允许的外部资源:${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
||||||
if (normalized.split("/").includes("..")) {
|
|
||||||
throw new Error(`主题资源路径不安全:${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetPath = posix.normalize(posix.join(cssDirectory, normalized));
|
|
||||||
if (
|
|
||||||
assetPath === ".." ||
|
|
||||||
assetPath.startsWith("../") ||
|
|
||||||
assetPath.startsWith("/")
|
|
||||||
) {
|
|
||||||
throw new Error(`主题资源路径越界:${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}")`;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readImportedPath(match: RegExpMatchArray) {
|
|
||||||
return match.slice(1).find((value): value is string => value !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCssPath(relativePath: string) {
|
|
||||||
const normalized = relativePath.replaceAll("\\", "/");
|
|
||||||
if (
|
|
||||||
normalized.startsWith("/") ||
|
|
||||||
normalized.split("/").includes("..") ||
|
|
||||||
/^[a-z][a-z0-9+.-]*:/i.test(normalized)
|
|
||||||
) {
|
|
||||||
throw new Error(`主题 CSS 路径不安全:${relativePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return posix.normalize(normalized.replace(/^\.\//, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadThemeCssFile(
|
|
||||||
theme: ThemeRecord,
|
|
||||||
relativePath: string,
|
|
||||||
importStack: string[] = []
|
|
||||||
): Promise<string> {
|
|
||||||
const normalizedPath = normalizeCssPath(relativePath);
|
|
||||||
if (importStack.includes(normalizedPath)) {
|
|
||||||
throw new Error(
|
|
||||||
`主题 CSS 存在循环引用:${[...importStack, normalizedPath].join(" -> ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (importStack.length >= maximumCssImportDepth) {
|
|
||||||
throw new Error(`主题 CSS 导入层级超过 ${maximumCssImportDepth} 层`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssPath = await resolveThemeFile(theme.directory, normalizedPath);
|
|
||||||
const css = (await readFile(cssPath, "utf8")).replace(
|
|
||||||
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
const cssDirectory = posix.dirname(normalizedPath);
|
|
||||||
const matches = [...css.matchAll(cssImportPattern)];
|
|
||||||
let result = "";
|
|
||||||
let previousEnd = 0;
|
|
||||||
|
|
||||||
for (const match of matches) {
|
|
||||||
const matchStart = match.index ?? 0;
|
|
||||||
result += prepareCssSegment(
|
|
||||||
css.slice(previousEnd, matchStart),
|
|
||||||
theme.manifest.id,
|
|
||||||
cssDirectory
|
|
||||||
);
|
|
||||||
|
|
||||||
const importedPath = readImportedPath(match);
|
|
||||||
if (!importedPath) {
|
|
||||||
throw new Error(`无法解析主题 CSS 导入:${match[0]}`);
|
|
||||||
}
|
|
||||||
const normalizedImport = normalizeCssPath(importedPath);
|
|
||||||
const importPath = posix.normalize(posix.join(cssDirectory, normalizedImport));
|
|
||||||
result += await loadThemeCssFile(theme, importPath, [
|
|
||||||
...importStack,
|
|
||||||
normalizedPath
|
|
||||||
]);
|
|
||||||
previousEnd = matchStart + match[0].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainder = css.slice(previousEnd);
|
|
||||||
result += prepareCssSegment(
|
|
||||||
remainder,
|
|
||||||
theme.manifest.id,
|
|
||||||
cssDirectory
|
|
||||||
);
|
|
||||||
if (/@import\b/i.test(result)) {
|
|
||||||
throw new Error("主题包含不支持的 CSS @import 语法");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
|
||||||
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
|
|
||||||
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
|
||||||
throw new Error("主题缓存有效期必须是非负有限数值");
|
|
||||||
}
|
|
||||||
let cachedThemes:
|
|
||||||
| {
|
|
||||||
expiresAt: number;
|
|
||||||
promise: Promise<ThemeRecord[]>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
async function scanThemes() {
|
|
||||||
const [bundledThemes, localThemes] = await Promise.all([
|
|
||||||
readThemeRoot(options.bundledRoot, "bundled"),
|
|
||||||
readThemeRoot(options.localRoot, "local", options.onWarning)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const themes = [...bundledThemes, ...localThemes];
|
|
||||||
const themeIds = new Set<string>();
|
|
||||||
for (const theme of themes) {
|
|
||||||
if (themeIds.has(theme.manifest.id)) {
|
|
||||||
throw new Error(`主题 ID 重复:${theme.manifest.id}`);
|
|
||||||
}
|
|
||||||
themeIds.add(theme.manifest.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return themes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function list() {
|
|
||||||
const now = Date.now();
|
|
||||||
if (cachedThemes && now < cachedThemes.expiresAt) {
|
|
||||||
return cachedThemes.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = scanThemes();
|
|
||||||
cachedThemes = {
|
|
||||||
expiresAt: now + cacheTtlMs,
|
|
||||||
promise
|
|
||||||
};
|
|
||||||
void promise.catch(() => {
|
|
||||||
if (cachedThemes?.promise === promise) {
|
|
||||||
cachedThemes = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidate() {
|
|
||||||
cachedThemes = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(themeId: string) {
|
|
||||||
return (await list()).find((theme) => theme.manifest.id === themeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCss(themeId: string) {
|
|
||||||
const theme = await get(themeId);
|
|
||||||
if (!theme) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cssFiles = [
|
|
||||||
...(theme.manifest.base ? [theme.manifest.base] : []),
|
|
||||||
theme.manifest.entry,
|
|
||||||
...(theme.manifest.print
|
|
||||||
? [theme.manifest.print]
|
|
||||||
: [])
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
await Promise.all(
|
|
||||||
cssFiles.map((path) => loadThemeCssFile(theme, path))
|
|
||||||
)
|
|
||||||
).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAsset(themeId: string, assetPath: string) {
|
|
||||||
const theme = await get(themeId);
|
|
||||||
if (!theme) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = extname(assetPath).toLowerCase();
|
|
||||||
const contentType = assetContentTypes[extension];
|
|
||||||
if (!contentType) {
|
|
||||||
throw new Error("不支持的主题资源类型");
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = await resolveThemeFile(theme.directory, assetPath);
|
|
||||||
return {
|
|
||||||
contentType,
|
|
||||||
content: await readFile(path)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get,
|
|
||||||
getAsset,
|
|
||||||
getCss,
|
|
||||||
invalidate,
|
|
||||||
list
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,13 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
createPdfContentDisposition,
|
createPdfContentDisposition,
|
||||||
createPdfFileName
|
createPdfFileName
|
||||||
} from "../src/app.js";
|
} from "../src/app.js";
|
||||||
import { createThemeRegistry } from "../src/theme-registry.js";
|
import { createThemeRegistry } from "@md-to-pdf/application";
|
||||||
import {
|
import {
|
||||||
PdfEngineOverloadedError,
|
PdfEngineOverloadedError,
|
||||||
type PdfGenerator
|
type PdfGenerator
|
||||||
@@ -39,6 +40,61 @@ function localThemeManifest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sha256(content: Uint8Array | string) {
|
||||||
|
return createHash("sha256").update(content).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServerFontPack(root: string) {
|
||||||
|
const directory = join(root, "official-cjk", "1.0.0");
|
||||||
|
const web = Buffer.from([31, 32, 33, 34]);
|
||||||
|
const docx = Buffer.from([41, 42, 43, 44, 45]);
|
||||||
|
const license = "SIL Open Font License 1.1";
|
||||||
|
await mkdir(join(directory, "fonts"), { recursive: true });
|
||||||
|
await writeFile(join(directory, "fonts", "serif.woff2"), web);
|
||||||
|
await writeFile(join(directory, "fonts", "serif.ttf"), docx);
|
||||||
|
await writeFile(join(directory, "OFL.txt"), license, "utf8");
|
||||||
|
await writeFile(
|
||||||
|
join(directory, "font-pack.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
manifestVersion: 1,
|
||||||
|
id: "official-cjk",
|
||||||
|
version: "1.0.0",
|
||||||
|
name: "政务中文字体包",
|
||||||
|
description: "Server 字体包资源测试",
|
||||||
|
license: "OFL-1.1",
|
||||||
|
licenseFile: "OFL.txt",
|
||||||
|
licenseSha256: sha256(license),
|
||||||
|
licenseBytes: Buffer.byteLength(license),
|
||||||
|
compatibility: {
|
||||||
|
minimumAppVersion: "0.6.0",
|
||||||
|
maximumAppVersionExclusive: "0.7.0"
|
||||||
|
},
|
||||||
|
faces: [
|
||||||
|
{
|
||||||
|
id: "serif-regular",
|
||||||
|
targets: ["FandolSong", "Mdpdf Fandol Song"],
|
||||||
|
weight: 400,
|
||||||
|
style: "normal",
|
||||||
|
web: {
|
||||||
|
path: "fonts/serif.woff2",
|
||||||
|
format: "woff2",
|
||||||
|
bytes: web.byteLength,
|
||||||
|
sha256: sha256(web)
|
||||||
|
},
|
||||||
|
docx: {
|
||||||
|
path: "fonts/serif.ttf",
|
||||||
|
format: "truetype",
|
||||||
|
bytes: docx.byteLength,
|
||||||
|
sha256: sha256(docx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
return { web };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app?.close();
|
await app?.close();
|
||||||
if (temporaryDirectory) {
|
if (temporaryDirectory) {
|
||||||
@@ -136,6 +192,43 @@ describe("预览 API", () => {
|
|||||||
expect(response.body).toContain("#write");
|
expect(response.body).toContain("#write");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("为主题 CSS 提供经过注册器验证的字体包 Web 资源", async () => {
|
||||||
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-font-pack-"));
|
||||||
|
const fontPack = await createServerFontPack(temporaryDirectory);
|
||||||
|
app = buildApp({
|
||||||
|
logger: false,
|
||||||
|
fontPacks: {
|
||||||
|
roots: [temporaryDirectory],
|
||||||
|
appVersion: "0.6.0"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cssResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/themes/gov-red-standard/css"
|
||||||
|
});
|
||||||
|
expect(cssResponse.statusCode).toBe(200);
|
||||||
|
expect(cssResponse.body).toContain("md-to-pdf-font-packs:official-cjk@1.0.0");
|
||||||
|
expect(cssResponse.body).toContain(
|
||||||
|
"/api/font-packs/official-cjk/1.0.0/serif-regular/web?v="
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/font-packs/official-cjk/1.0.0/serif-regular/web?v=${sha256(fontPack.web)}`
|
||||||
|
});
|
||||||
|
expect(assetResponse.statusCode).toBe(200);
|
||||||
|
expect(assetResponse.headers["content-type"]).toContain("font/woff2");
|
||||||
|
expect(assetResponse.headers["cache-control"]).toContain("immutable");
|
||||||
|
expect(assetResponse.rawPayload).toEqual(fontPack.web);
|
||||||
|
|
||||||
|
const missingResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/font-packs/official-cjk/2.0.0/serif-regular/web"
|
||||||
|
});
|
||||||
|
expect(missingResponse.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it("发现本地主题并安全提供相对字体资源", async () => {
|
it("发现本地主题并安全提供相对字体资源", async () => {
|
||||||
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
|
||||||
const themeDirectory = join(temporaryDirectory, "local-test");
|
const themeDirectory = join(temporaryDirectory, "local-test");
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
interface PackageManifest {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = fileURLToPath(
|
||||||
|
new URL("../../../", import.meta.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
function readProjectFile(relativePath: string) {
|
||||||
|
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Docker v0.6.4 部署契约", () => {
|
||||||
|
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
|
||||||
|
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||||
|
const workspaces = [
|
||||||
|
"semantic-document",
|
||||||
|
"docx-theme-engine",
|
||||||
|
"font-pack-registry",
|
||||||
|
"docx-engine"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
`COPY packages/${workspace}/package.json packages/${workspace}/package.json`
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
`npm run build -w @md-to-pdf/${workspace}`
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
`/app/packages/${workspace}/dist ./packages/${workspace}/dist`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"/app/packages/docx-engine/assets ./packages/docx-engine/assets"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain("COPY tsconfig.base.json product.json ./");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("固定校验 Pandoc 二进制、许可证、版权和最终版本", () => {
|
||||||
|
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||||
|
|
||||||
|
expect(dockerfile).toContain("ARG PANDOC_VERSION=3.9.0.2");
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
|
||||||
|
const dockerfile = readProjectFile("deploy/Dockerfile");
|
||||||
|
const compose = readProjectFile("deploy/compose.yaml");
|
||||||
|
const dockerignore = readProjectFile(".dockerignore");
|
||||||
|
const dockerfileIgnore = readProjectFile(
|
||||||
|
"deploy/Dockerfile.dockerignore"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(compose).toContain(
|
||||||
|
"FONT_PACK_ROOT: /app/.local/font-packs"
|
||||||
|
);
|
||||||
|
expect(compose).not.toContain("MD_TO_PDF_FONT_PACK_DIR");
|
||||||
|
expect(dockerfile).toContain(
|
||||||
|
"COPY --chown=pwuser:pwuser output/font-packs/root /app/.local/font-packs"
|
||||||
|
);
|
||||||
|
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
|
||||||
|
expect(compose).toContain(
|
||||||
|
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.4}"'
|
||||||
|
);
|
||||||
|
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
|
||||||
|
expect(dockerignore).toContain("!output/font-packs/root/**");
|
||||||
|
expect(dockerfileIgnore).toBe(dockerignore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("提供显式真实 Docker 字体包验收命令", () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readProjectFile("package.json")
|
||||||
|
) as PackageManifest;
|
||||||
|
|
||||||
|
expect(manifest.scripts?.["verify:docker-font-pack"]).toContain(
|
||||||
|
"deploy/verify-font-pack-compose.mjs"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
ApplicationRequestError,
|
||||||
|
DocxExportServiceError,
|
||||||
|
type DocxMediaCaptureAdapter
|
||||||
|
} from "@md-to-pdf/application";
|
||||||
|
import {
|
||||||
|
DOCX_MIME_TYPE,
|
||||||
|
defaultExportConfig,
|
||||||
|
type DocxCapability,
|
||||||
|
type DocxExportResult
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import type { PdfGenerator } from "../src/pdf-engine.js";
|
||||||
|
import type { ServerDocxMediaCaptureAdapter } from "../src/docx-media-engine.js";
|
||||||
|
|
||||||
|
let app: FastifyInstance | undefined;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close();
|
||||||
|
app = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableCapability: DocxCapability = {
|
||||||
|
format: "docx",
|
||||||
|
status: "available",
|
||||||
|
expectedVersion: "3.9.0.2",
|
||||||
|
detectedVersion: "3.9.0.2"
|
||||||
|
};
|
||||||
|
|
||||||
|
function generatedDocx(): DocxExportResult {
|
||||||
|
return {
|
||||||
|
docx: new Uint8Array([80, 75, 3, 4]),
|
||||||
|
fileName: "测试报告.docx",
|
||||||
|
diagnostics: {
|
||||||
|
warnings: ["字体可能被替换"],
|
||||||
|
echartsErrors: ["图表 1"],
|
||||||
|
mermaidErrors: []
|
||||||
|
},
|
||||||
|
timings: {
|
||||||
|
queueMs: 1,
|
||||||
|
probeMs: 2,
|
||||||
|
prepareMs: 3,
|
||||||
|
themeStyleMs: 4,
|
||||||
|
mediaMs: 5,
|
||||||
|
referenceMs: 6,
|
||||||
|
pandocMs: 7,
|
||||||
|
validationMs: 8,
|
||||||
|
totalMs: 36
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDocxService(
|
||||||
|
overrides: {
|
||||||
|
capability?: DocxCapability;
|
||||||
|
generate?: (request: unknown) => Promise<DocxExportResult>;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
getCapability: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
overrides.capability ?? availableCapability
|
||||||
|
),
|
||||||
|
generate: vi.fn(
|
||||||
|
overrides.generate ?? (async () => generatedDocx())
|
||||||
|
),
|
||||||
|
close: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMediaAdapter() {
|
||||||
|
return {
|
||||||
|
themeStyleBaseUrl:
|
||||||
|
"http://localhost:5173/preview-frame.html",
|
||||||
|
capture: vi.fn<
|
||||||
|
DocxMediaCaptureAdapter["capture"]
|
||||||
|
>(async () => ({
|
||||||
|
plan: {
|
||||||
|
targets: [],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
},
|
||||||
|
captures: []
|
||||||
|
})),
|
||||||
|
captureThemeStyle: vi.fn(async () => {
|
||||||
|
throw new Error("此测试使用注入的 DOCX 导出服务");
|
||||||
|
}),
|
||||||
|
close: vi.fn(async () => undefined)
|
||||||
|
} satisfies ServerDocxMediaCaptureAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPdfGenerator() {
|
||||||
|
return {
|
||||||
|
generate: vi.fn(),
|
||||||
|
close: vi.fn(async () => undefined)
|
||||||
|
} as unknown as PdfGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestApp(
|
||||||
|
service = createDocxService(),
|
||||||
|
mediaAdapter = createMediaAdapter()
|
||||||
|
) {
|
||||||
|
app = buildApp({
|
||||||
|
logger: false,
|
||||||
|
pdfGenerator: createPdfGenerator(),
|
||||||
|
docxExportService: service,
|
||||||
|
docxMediaAdapter: mediaAdapter
|
||||||
|
});
|
||||||
|
return { app, service, mediaAdapter };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DOCX HTTP API", () => {
|
||||||
|
it("在统一 capability 中报告 DOCX 运行时状态", async () => {
|
||||||
|
const { app: instance, service } = createTestApp();
|
||||||
|
|
||||||
|
const response = await instance.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/capabilities"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().docx).toEqual(availableCapability);
|
||||||
|
expect(response.json().implemented).toContain("docx-export");
|
||||||
|
expect(service.getCapability).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过独立端点返回不可用 capability", async () => {
|
||||||
|
const capability: DocxCapability = {
|
||||||
|
format: "docx",
|
||||||
|
status: "not-found",
|
||||||
|
expectedVersion: "3.9.0.2",
|
||||||
|
message: "未找到 Pandoc 运行时"
|
||||||
|
};
|
||||||
|
const { app: instance } = createTestApp(
|
||||||
|
createDocxService({ capability })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await instance.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/docx/capability"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual(capability);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("生成 DOCX 并返回安全文件名、诊断和耗时", async () => {
|
||||||
|
const { app: instance, service, mediaAdapter } =
|
||||||
|
createTestApp();
|
||||||
|
const payload = {
|
||||||
|
markdown: "# 测试报告",
|
||||||
|
fileName: "目录/测试报告.md",
|
||||||
|
language: "zh-CN",
|
||||||
|
resources: [],
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await instance.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/docx",
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers["content-type"]).toContain(
|
||||||
|
DOCX_MIME_TYPE
|
||||||
|
);
|
||||||
|
expect(response.headers["content-disposition"]).toContain(
|
||||||
|
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%8A%A5%E5%91%8A.docx"
|
||||||
|
);
|
||||||
|
expect(response.headers["cache-control"]).toBe("no-store");
|
||||||
|
expect(response.headers["x-docx-warning-count"]).toBe("1");
|
||||||
|
expect(response.headers["x-echarts-error-count"]).toBe("1");
|
||||||
|
expect(response.headers["x-mermaid-error-count"]).toBe("0");
|
||||||
|
expect(response.headers["server-timing"]).toContain(
|
||||||
|
"runtime-probe;dur=2.0"
|
||||||
|
);
|
||||||
|
expect(response.headers["server-timing"]).toContain(
|
||||||
|
"pandoc;dur=7.0"
|
||||||
|
);
|
||||||
|
expect(response.rawPayload).toEqual(
|
||||||
|
Buffer.from([80, 75, 3, 4])
|
||||||
|
);
|
||||||
|
expect(service.generate).toHaveBeenCalledWith(payload, {
|
||||||
|
mediaAdapter,
|
||||||
|
signal: expect.any(AbortSignal)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("映射应用请求错误", async () => {
|
||||||
|
const service = createDocxService({
|
||||||
|
generate: async () => {
|
||||||
|
throw new ApplicationRequestError(
|
||||||
|
400,
|
||||||
|
"INVALID_EXPORT_CONFIG",
|
||||||
|
"导出配置无效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { app: instance } = createTestApp(service);
|
||||||
|
|
||||||
|
const response = await instance.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/docx",
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: "INVALID_EXPORT_CONFIG",
|
||||||
|
message: "导出配置无效",
|
||||||
|
retryable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("映射可重试的队列错误", async () => {
|
||||||
|
const service = createDocxService({
|
||||||
|
generate: async () => {
|
||||||
|
throw new DocxExportServiceError(
|
||||||
|
429,
|
||||||
|
"DOCX_QUEUE_FULL",
|
||||||
|
"DOCX 生成队列已满",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { app: instance } = createTestApp(service);
|
||||||
|
|
||||||
|
const response = await instance.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/docx",
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(429);
|
||||||
|
expect(response.headers["retry-after"]).toBe("5");
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: "DOCX_QUEUE_FULL",
|
||||||
|
message: "DOCX 生成队列已满",
|
||||||
|
retryable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("关闭应用时按顺序释放共享服务与媒体浏览器", async () => {
|
||||||
|
const service = createDocxService();
|
||||||
|
const mediaAdapter = createMediaAdapter();
|
||||||
|
const { app: instance } = createTestApp(
|
||||||
|
service,
|
||||||
|
mediaAdapter
|
||||||
|
);
|
||||||
|
|
||||||
|
await instance.close();
|
||||||
|
|
||||||
|
expect(service.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mediaAdapter.close).toHaveBeenCalledTimes(1);
|
||||||
|
app = undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Browser } from "playwright";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig,
|
||||||
|
type DocxMediaCapturePlan
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { PlaywrightDocxMediaEngine } from "../src/docx-media-engine.js";
|
||||||
|
|
||||||
|
const emptyPlan: DocxMediaCapturePlan = {
|
||||||
|
targets: [],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
payload: createPagedDocumentPayload({
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"></article>',
|
||||||
|
bodyHtml: "",
|
||||||
|
metadata: {
|
||||||
|
title: "",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
fileName: "test.md",
|
||||||
|
themeCss: "",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
}),
|
||||||
|
dimensions: {
|
||||||
|
contentWidthPx: 600,
|
||||||
|
contentHeightPx: 900
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Server DOCX 媒体浏览器", () => {
|
||||||
|
it("使用请求级上下文并阻止非同源网络访问", async () => {
|
||||||
|
const continueRoute = vi.fn(async () => undefined);
|
||||||
|
const abortRoute = vi.fn(async () => undefined);
|
||||||
|
const detach = vi.fn(async () => undefined);
|
||||||
|
const page = {
|
||||||
|
goto: vi.fn(async () => undefined),
|
||||||
|
emulateMedia: vi.fn(async () => undefined),
|
||||||
|
waitForFunction: vi.fn(async () => undefined),
|
||||||
|
evaluate: vi.fn(async () => emptyPlan),
|
||||||
|
context: () => context
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
route: vi.fn(
|
||||||
|
async (
|
||||||
|
_pattern: string,
|
||||||
|
handler: (route: {
|
||||||
|
request(): { url(): string };
|
||||||
|
continue(): Promise<void>;
|
||||||
|
abort(reason: string): Promise<void>;
|
||||||
|
}) => Promise<void>
|
||||||
|
) => {
|
||||||
|
await handler({
|
||||||
|
request: () => ({
|
||||||
|
url: () => "http://renderer.test/assets/runtime.js"
|
||||||
|
}),
|
||||||
|
continue: continueRoute,
|
||||||
|
abort: abortRoute
|
||||||
|
});
|
||||||
|
await handler({
|
||||||
|
request: () => ({
|
||||||
|
url: () => "https://external.test/tracker.js"
|
||||||
|
}),
|
||||||
|
continue: continueRoute,
|
||||||
|
abort: abortRoute
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
newPage: vi.fn(async () => page),
|
||||||
|
newCDPSession: vi.fn(async () => ({
|
||||||
|
send: vi.fn(),
|
||||||
|
detach
|
||||||
|
})),
|
||||||
|
close: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
const browser = {
|
||||||
|
newContext: vi.fn(async () => context),
|
||||||
|
on: vi.fn(),
|
||||||
|
close: vi.fn(async () => undefined)
|
||||||
|
} as unknown as Browser;
|
||||||
|
const engine = new PlaywrightDocxMediaEngine({
|
||||||
|
renderOrigin: "http://renderer.test",
|
||||||
|
launchBrowser: vi.fn(async () => browser)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await engine.capture(request);
|
||||||
|
|
||||||
|
expect(result.plan).toEqual(emptyPlan);
|
||||||
|
expect(browser.newContext).toHaveBeenCalledWith({
|
||||||
|
locale: "zh-CN",
|
||||||
|
serviceWorkers: "block"
|
||||||
|
});
|
||||||
|
expect(page.goto).toHaveBeenCalledWith(
|
||||||
|
"http://renderer.test/preview-frame.html?target=continuous",
|
||||||
|
{ waitUntil: "domcontentloaded" }
|
||||||
|
);
|
||||||
|
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
|
||||||
|
expect(continueRoute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
expect(context.close).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await engine.close();
|
||||||
|
expect(browser.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("关闭后拒绝预热和媒体捕获", async () => {
|
||||||
|
const engine = new PlaywrightDocxMediaEngine({
|
||||||
|
renderOrigin: "http://renderer.test",
|
||||||
|
launchBrowser: vi.fn()
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.close();
|
||||||
|
|
||||||
|
await expect(engine.warmup()).rejects.toThrow(
|
||||||
|
"DOCX 媒体服务已关闭"
|
||||||
|
);
|
||||||
|
await expect(engine.capture(request)).rejects.toThrow(
|
||||||
|
"DOCX 媒体服务已关闭"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
|
|||||||
expect(readPdfEngineRuntimeLimits({})).toEqual({
|
expect(readPdfEngineRuntimeLimits({})).toEqual({
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
maxQueue: 8,
|
maxQueue: 8,
|
||||||
timeoutMs: 60_000
|
timeoutMs: 180_000
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Page } from "playwright";
|
||||||
|
import {
|
||||||
|
createPagedDocumentPayload,
|
||||||
|
defaultExportConfig,
|
||||||
|
type DocxMediaCapturePlan
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { captureDocxMediaWithPlaywrightPage } from "../src/playwright-docx-media-capture.js";
|
||||||
|
|
||||||
|
const plan: DocxMediaCapturePlan = {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
id: "docx-media-1",
|
||||||
|
kind: "echarts",
|
||||||
|
ordinal: 1,
|
||||||
|
kindOrdinal: 1,
|
||||||
|
altText: "图表",
|
||||||
|
alignment: "center",
|
||||||
|
displayWidthPx: 320,
|
||||||
|
displayHeightPx: 180,
|
||||||
|
captureX: 10,
|
||||||
|
captureY: 20,
|
||||||
|
captureWidthPx: 320,
|
||||||
|
captureHeightPx: 180,
|
||||||
|
rasterScale: 3.125
|
||||||
|
}
|
||||||
|
],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
payload: createPagedDocumentPayload({
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"></article>',
|
||||||
|
bodyHtml: "",
|
||||||
|
metadata: {
|
||||||
|
title: "",
|
||||||
|
author: "",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
fileName: "test.md",
|
||||||
|
themeCss: "",
|
||||||
|
exportConfig: defaultExportConfig
|
||||||
|
}),
|
||||||
|
dimensions: {
|
||||||
|
contentWidthPx: 600,
|
||||||
|
contentHeightPx: 900
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Playwright DOCX 媒体捕获", () => {
|
||||||
|
it("按运行时计划以目标倍率捕获 PNG", async () => {
|
||||||
|
const detach = vi.fn(async () => undefined);
|
||||||
|
const send = vi.fn(async () => ({
|
||||||
|
data: Buffer.from("png").toString("base64")
|
||||||
|
}));
|
||||||
|
const page = {
|
||||||
|
emulateMedia: vi.fn(async () => undefined),
|
||||||
|
evaluate: vi.fn(async () => plan),
|
||||||
|
context: () => ({
|
||||||
|
newCDPSession: vi.fn(async () => ({ send, detach }))
|
||||||
|
})
|
||||||
|
} as unknown as Page;
|
||||||
|
|
||||||
|
const result = await captureDocxMediaWithPlaywrightPage(
|
||||||
|
page,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.plan).toBe(plan);
|
||||||
|
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
|
||||||
|
expect(result.captures[0]).toEqual({
|
||||||
|
id: "docx-media-1",
|
||||||
|
png: Buffer.from("png")
|
||||||
|
});
|
||||||
|
expect(send).toHaveBeenCalledWith("Page.captureScreenshot", {
|
||||||
|
format: "png",
|
||||||
|
fromSurface: true,
|
||||||
|
captureBeyondViewport: true,
|
||||||
|
clip: {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
scale: 3.125
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
DOCX_STYLE_SLOT_NAMES,
|
||||||
|
type DocxComputedStyle,
|
||||||
|
type DocxThemeStyleCaptureRequest
|
||||||
|
} from "@md-to-pdf/docx-theme-engine";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { captureDocxThemeStyleWithPlaywrightPage } from "../src/playwright-docx-theme-style.js";
|
||||||
|
|
||||||
|
const computed: DocxComputedStyle = {
|
||||||
|
fontFamily: "Arial",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "400",
|
||||||
|
fontStyle: "normal",
|
||||||
|
color: "rgb(0, 0, 0)",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "normal",
|
||||||
|
textAlign: "start",
|
||||||
|
direction: "ltr",
|
||||||
|
textIndent: "0px",
|
||||||
|
textDecorationLine: "none",
|
||||||
|
marginTop: "0px",
|
||||||
|
marginRight: "0px",
|
||||||
|
marginBottom: "0px",
|
||||||
|
marginLeft: "0px",
|
||||||
|
paddingTop: "0px",
|
||||||
|
paddingRight: "0px",
|
||||||
|
paddingBottom: "0px",
|
||||||
|
paddingLeft: "0px",
|
||||||
|
borderTop: "0px none rgb(0, 0, 0)",
|
||||||
|
borderRight: "0px none rgb(0, 0, 0)",
|
||||||
|
borderBottom: "0px none rgb(0, 0, 0)",
|
||||||
|
borderLeft: "0px none rgb(0, 0, 0)",
|
||||||
|
width: "640px",
|
||||||
|
maxWidth: "none",
|
||||||
|
minWidth: "0px",
|
||||||
|
minHeight: "0px",
|
||||||
|
height: "24px",
|
||||||
|
breakBefore: "auto",
|
||||||
|
breakAfter: "auto",
|
||||||
|
breakInside: "auto",
|
||||||
|
display: "block",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "normal",
|
||||||
|
justifyContent: "normal",
|
||||||
|
outline: "rgb(0, 0, 0) none 0px",
|
||||||
|
outlineOffset: "0px"
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: DocxThemeStyleCaptureRequest = {
|
||||||
|
themeId: "external-clean",
|
||||||
|
themeFingerprint: "a".repeat(64),
|
||||||
|
themeCss: "#write { color: #000; }",
|
||||||
|
baseUrl: "http://localhost:3001/"
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Playwright DOCX 主题样式采集", () => {
|
||||||
|
it("使用打印媒体并校验浏览器返回的快照", async () => {
|
||||||
|
const page = {
|
||||||
|
emulateMedia: vi.fn(async () => undefined),
|
||||||
|
evaluate: vi.fn(async () => ({
|
||||||
|
rootFontSizePx: 16,
|
||||||
|
viewport: {
|
||||||
|
widthPx: 794,
|
||||||
|
heightPx: 1123,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
},
|
||||||
|
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
|
||||||
|
slot,
|
||||||
|
matched: true,
|
||||||
|
computed
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
const snapshot =
|
||||||
|
await captureDocxThemeStyleWithPlaywrightPage(
|
||||||
|
page as never,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
expect(page.emulateMedia).toHaveBeenCalledWith({
|
||||||
|
media: "print"
|
||||||
|
});
|
||||||
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(snapshot.themeId).toBe("external-clean");
|
||||||
|
expect(snapshot.slots).toHaveLength(
|
||||||
|
DOCX_STYLE_SLOT_NAMES.length
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-2
@@ -2,12 +2,24 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
href="/apple-touch-icon.png"
|
||||||
|
sizes="180x180"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#1f2937" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:; manifest-src 'self';"
|
||||||
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="可配置、可主题化的 Markdown PDF 导出工具"
|
content="面向结构化 Markdown 的主题化文档创作与发布工具"
|
||||||
/>
|
/>
|
||||||
<title>Markdown PDF 导出器</title>
|
<title>墨呈 · MorphDoc</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,13 +10,16 @@
|
|||||||
"typecheck": "tsc -b --pretty false"
|
"typecheck": "tsc -b --pretty false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "^6.10.4",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.1",
|
||||||
|
"@codemirror/state": "^6.7.1",
|
||||||
|
"@codemirror/view": "^6.43.7",
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||||
"@mermaid-js/layout-elk": "0.2.2",
|
"@md-to-pdf/preview-engine": "0.1.0",
|
||||||
|
"@uiw/react-codemirror": "^4.25.11",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.28",
|
"katex": "^0.16.28",
|
||||||
"mermaid": "11.16.0",
|
|
||||||
"pagedjs": "^0.4.3",
|
|
||||||
"pdfjs-dist": "6.1.200",
|
"pdfjs-dist": "6.1.200",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
|
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: mdpdf:; connect-src 'self';"
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+1174
-350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type DocumentActionDialogProps =
|
||||||
|
| {
|
||||||
|
type: "new-location";
|
||||||
|
busy?: false;
|
||||||
|
onCurrentWindow: () => void;
|
||||||
|
onNewWindow: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "unsaved";
|
||||||
|
busy: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentActionDialog(
|
||||||
|
props: DocumentActionDialogProps
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !props.busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
|
const isNewLocation = props.type === "new-location";
|
||||||
|
return (
|
||||||
|
<div className="document-dialog-layer">
|
||||||
|
<div className="document-dialog-backdrop" />
|
||||||
|
<section
|
||||||
|
className="document-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="document-dialog-title"
|
||||||
|
aria-describedby="document-dialog-description"
|
||||||
|
>
|
||||||
|
<span className="panel-kicker">文档操作</span>
|
||||||
|
<h2 id="document-dialog-title">
|
||||||
|
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
|
||||||
|
</h2>
|
||||||
|
<p id="document-dialog-description">
|
||||||
|
{isNewLocation
|
||||||
|
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
|
||||||
|
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
|
||||||
|
</p>
|
||||||
|
<div className="document-dialog-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onCancel}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
{isNewLocation ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onNewWindow}
|
||||||
|
>
|
||||||
|
新窗口新建
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="is-primary"
|
||||||
|
autoFocus
|
||||||
|
onClick={props.onCurrentWindow}
|
||||||
|
>
|
||||||
|
当前窗口新建
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={props.onDiscard}
|
||||||
|
>
|
||||||
|
不保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="is-primary"
|
||||||
|
disabled={props.busy}
|
||||||
|
autoFocus
|
||||||
|
onClick={props.onSave}
|
||||||
|
>
|
||||||
|
{props.busy ? "正在保存…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface ExportCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledReason?: string | undefined;
|
||||||
|
busy?: boolean;
|
||||||
|
onSelect(): void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportMenuProps {
|
||||||
|
commands: ExportCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportMenu({ commands }: ExportMenuProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof Node &&
|
||||||
|
!menuRef.current?.contains(event.target)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="export-menu more-menu" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-controls="export-actions-menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
导出 ▾
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
id="export-actions-menu"
|
||||||
|
className="export-menu-popover more-menu-popover"
|
||||||
|
role="menu"
|
||||||
|
aria-label="导出格式"
|
||||||
|
>
|
||||||
|
{commands.map((command) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-menu-command"
|
||||||
|
role="menuitem"
|
||||||
|
key={command.id}
|
||||||
|
disabled={command.disabled}
|
||||||
|
title={command.disabledReason}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void command.onSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>
|
||||||
|
{command.busy
|
||||||
|
? `正在生成 ${command.label}…`
|
||||||
|
: `导出 ${command.label}`}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{command.disabledReason ?? command.description}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
lengthToMillimeters,
|
lengthToMillimeters,
|
||||||
paperFormatLabels,
|
paperFormatLabels,
|
||||||
@@ -12,9 +13,11 @@ import {
|
|||||||
type MermaidExportConfig
|
type MermaidExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ThemeSummary } from "./application-backend";
|
||||||
|
|
||||||
interface ExportSettingsDrawerProps {
|
interface ExportSettingsDrawerProps {
|
||||||
config: ExportConfig;
|
config: ExportConfig;
|
||||||
|
theme: ThemeSummary | undefined;
|
||||||
onChange: (config: ExportConfig) => void;
|
onChange: (config: ExportConfig) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@@ -24,6 +27,9 @@ interface LengthInputProps {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
maximum: number;
|
maximum: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
precision?: number;
|
||||||
|
step?: number;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
|
|||||||
"page-total": "1 / 10",
|
"page-total": "1 / 10",
|
||||||
"chinese-page-total": "第 1 页 / 共 10 页",
|
"chinese-page-total": "第 1 页 / 共 10 页",
|
||||||
"dash-page": "- 1 -",
|
"dash-page": "- 1 -",
|
||||||
|
"official-page": "— 1 —(标准公文)",
|
||||||
custom: "自定义模板"
|
custom: "自定义模板"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +105,9 @@ function LengthInput({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
maximum,
|
maximum,
|
||||||
|
disabled = false,
|
||||||
|
precision = 1,
|
||||||
|
step = 0.1,
|
||||||
onChange
|
onChange
|
||||||
}: LengthInputProps) {
|
}: LengthInputProps) {
|
||||||
const [draft, setDraft] = useState(
|
const [draft, setDraft] = useState(
|
||||||
@@ -115,7 +125,8 @@ function LengthInput({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = Math.min(Math.max(number, 0), maximum);
|
const normalized = Math.min(Math.max(number, 0), maximum);
|
||||||
const rounded = Math.round(normalized * 10) / 10;
|
const factor = 10 ** precision;
|
||||||
|
const rounded = Math.round(normalized * factor) / factor;
|
||||||
setDraft(String(rounded));
|
setDraft(String(rounded));
|
||||||
onChange(`${rounded}mm`);
|
onChange(`${rounded}mm`);
|
||||||
}
|
}
|
||||||
@@ -128,8 +139,9 @@ function LengthInput({
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max={maximum}
|
max={maximum}
|
||||||
step="0.1"
|
step={step}
|
||||||
value={draft}
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -144,6 +156,57 @@ function LengthInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageDecorationFontInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const normalized = draft.trim();
|
||||||
|
if (
|
||||||
|
!normalized ||
|
||||||
|
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
|
||||||
|
) {
|
||||||
|
setDraft(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(normalized);
|
||||||
|
onChange(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="setting-field stacked">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={300}
|
||||||
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
|
||||||
|
onBlur={commit}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MermaidFontInput({
|
function MermaidFontInput({
|
||||||
value,
|
value,
|
||||||
onChange
|
onChange
|
||||||
@@ -208,6 +271,7 @@ function updateHeaderSlot(
|
|||||||
|
|
||||||
export function ExportSettingsDrawer({
|
export function ExportSettingsDrawer({
|
||||||
config,
|
config,
|
||||||
|
theme,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
onReset
|
onReset
|
||||||
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
|
|||||||
value: string
|
value: string
|
||||||
) {
|
) {
|
||||||
updatePaper({
|
updatePaper({
|
||||||
|
marginMode: "custom",
|
||||||
margins: {
|
margins: {
|
||||||
...margins,
|
...margins,
|
||||||
[side]: value
|
[side]: value
|
||||||
@@ -266,9 +331,22 @@ export function ExportSettingsDrawer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function followThemeMargins() {
|
||||||
|
updatePaper({
|
||||||
|
marginMode: "theme",
|
||||||
|
margins: theme?.pageDefaults?.margins ?? {
|
||||||
|
top: "16mm",
|
||||||
|
right: "16mm",
|
||||||
|
bottom: "16mm",
|
||||||
|
left: "16mm"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateHeader(header: Partial<HeaderConfig>) {
|
function updateHeader(header: Partial<HeaderConfig>) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
|
pageDecorationsMode: "custom",
|
||||||
header: {
|
header: {
|
||||||
...config.header,
|
...config.header,
|
||||||
...header
|
...header
|
||||||
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
|
|||||||
function updateFooter(footer: Partial<FooterConfig>) {
|
function updateFooter(footer: Partial<FooterConfig>) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
|
pageDecorationsMode: "custom",
|
||||||
footer: {
|
footer: {
|
||||||
...config.footer,
|
...config.footer,
|
||||||
...footer
|
...footer
|
||||||
@@ -286,6 +365,19 @@ export function ExportSettingsDrawer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function followThemePageDecorations() {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
pageDecorationsMode: "theme",
|
||||||
|
header: structuredClone(
|
||||||
|
theme?.pageDefaults?.header ?? defaultExportConfig.header
|
||||||
|
),
|
||||||
|
footer: structuredClone(
|
||||||
|
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
|
|||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h3>页边距</h3>
|
<h3>页边距</h3>
|
||||||
|
<fieldset className="segmented-control margin-mode-control">
|
||||||
|
<legend>页边距来源</legend>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="margin-mode"
|
||||||
|
checked={config.paper.marginMode === "theme"}
|
||||||
|
onChange={followThemeMargins}
|
||||||
|
/>
|
||||||
|
跟随主题
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="margin-mode"
|
||||||
|
checked={config.paper.marginMode === "custom"}
|
||||||
|
onChange={() => updatePaper({ marginMode: "custom" })}
|
||||||
|
/>
|
||||||
|
自定义
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p className="setting-hint margin-source-hint">
|
||||||
|
{config.paper.marginMode === "theme"
|
||||||
|
? theme?.pageDefaults
|
||||||
|
? `当前采用“${theme.name}”推荐值`
|
||||||
|
: "当前主题未设置推荐值,采用全局默认 16mm"
|
||||||
|
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
|
||||||
|
</p>
|
||||||
<div className="margin-grid">
|
<div className="margin-grid">
|
||||||
<LengthInput
|
<LengthInput
|
||||||
label="上"
|
label="上"
|
||||||
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
|
|||||||
onChange={(value) => updateMargin("left", value)}
|
onChange={(value) => updateMargin("left", value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{config.paper.marginMode === "custom" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action margin-reset-action"
|
||||||
|
onClick={followThemeMargins}
|
||||||
|
>
|
||||||
|
恢复主题推荐值
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>页眉与页码来源</h3>
|
||||||
|
<fieldset className="segmented-control margin-mode-control">
|
||||||
|
<legend>页面装饰来源</legend>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="page-decorations-mode"
|
||||||
|
checked={config.pageDecorationsMode === "theme"}
|
||||||
|
onChange={followThemePageDecorations}
|
||||||
|
/>
|
||||||
|
跟随主题
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="page-decorations-mode"
|
||||||
|
checked={config.pageDecorationsMode === "custom"}
|
||||||
|
onChange={() =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
pageDecorationsMode: "custom"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
自定义
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p className="setting-hint margin-source-hint">
|
||||||
|
{config.pageDecorationsMode === "theme"
|
||||||
|
? theme?.pageDefaults?.header ||
|
||||||
|
theme?.pageDefaults?.footer
|
||||||
|
? `当前采用“${theme.name}”推荐的页眉与页码`
|
||||||
|
: "当前主题未设置推荐值,采用全局默认配置"
|
||||||
|
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
|
||||||
|
</p>
|
||||||
|
{config.pageDecorationsMode === "custom" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action margin-reset-action"
|
||||||
|
onClick={followThemePageDecorations}
|
||||||
|
>
|
||||||
|
恢复主题推荐值
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<div className="setting-section-title">
|
<div className="setting-section-title">
|
||||||
<h3>页眉</h3>
|
<h3>页眉</h3>
|
||||||
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
|
|||||||
<p className="setting-hint">
|
<p className="setting-hint">
|
||||||
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
支持 {"${title}"}、{"${author}"} 和 {"${filename}"}。
|
||||||
</p>
|
</p>
|
||||||
|
<PageDecorationFontInput
|
||||||
|
label="页眉字体"
|
||||||
|
value={config.header.fontFamily}
|
||||||
|
disabled={!config.header.enabled}
|
||||||
|
onChange={(fontFamily) => updateHeader({ fontFamily })}
|
||||||
|
/>
|
||||||
|
<LengthInput
|
||||||
|
label="页眉字号"
|
||||||
|
value={config.header.fontSize}
|
||||||
|
maximum={20}
|
||||||
|
disabled={!config.header.enabled}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(fontSize) => updateHeader({ fontSize })}
|
||||||
|
/>
|
||||||
{(["left", "center", "right"] as const).map((slot) => {
|
{(["left", "center", "right"] as const).map((slot) => {
|
||||||
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
const labels = { left: "左侧", center: "中间", right: "右侧" };
|
||||||
return (
|
return (
|
||||||
@@ -540,6 +731,19 @@ export function ExportSettingsDrawer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<label className="switch-control divider-control">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.header.showOnFirstPage}
|
||||||
|
disabled={!config.header.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateHeader({
|
||||||
|
showOnFirstPage: event.target.checked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>正文首页显示页眉</span>
|
||||||
|
</label>
|
||||||
<label className="switch-control divider-control">
|
<label className="switch-control divider-control">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -567,6 +771,21 @@ export function ExportSettingsDrawer({
|
|||||||
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
<span>{config.footer.enabled ? "开启" : "关闭"}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<PageDecorationFontInput
|
||||||
|
label="页码字体"
|
||||||
|
value={config.footer.fontFamily}
|
||||||
|
disabled={!config.footer.enabled}
|
||||||
|
onChange={(fontFamily) => updateFooter({ fontFamily })}
|
||||||
|
/>
|
||||||
|
<LengthInput
|
||||||
|
label="页码字号"
|
||||||
|
value={config.footer.fontSize}
|
||||||
|
maximum={20}
|
||||||
|
disabled={!config.footer.enabled}
|
||||||
|
precision={2}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(fontSize) => updateFooter({ fontSize })}
|
||||||
|
/>
|
||||||
<label className="setting-field">
|
<label className="setting-field">
|
||||||
<span>样式</span>
|
<span>样式</span>
|
||||||
<select
|
<select
|
||||||
@@ -616,6 +835,7 @@ export function ExportSettingsDrawer({
|
|||||||
<option value="left">左侧</option>
|
<option value="left">左侧</option>
|
||||||
<option value="center">居中</option>
|
<option value="center">居中</option>
|
||||||
<option value="right">右侧</option>
|
<option value="right">右侧</option>
|
||||||
|
<option value="outer">外侧(奇数页右、偶数页左)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="setting-field">
|
<label className="setting-field">
|
||||||
@@ -636,6 +856,19 @@ export function ExportSettingsDrawer({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="switch-control divider-control">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.footer.showOnFirstPage}
|
||||||
|
disabled={!config.footer.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateFooter({
|
||||||
|
showOnFirstPage: event.target.checked
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>正文首页显示页码</span>
|
||||||
|
</label>
|
||||||
<label className="switch-control divider-control">
|
<label className="switch-control divider-control">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export interface ExportToastState {
|
||||||
|
kind: "progress" | "success" | "error";
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportToastProps {
|
||||||
|
toast: ExportToastState;
|
||||||
|
onDismiss(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportToast({
|
||||||
|
toast,
|
||||||
|
onDismiss
|
||||||
|
}: ExportToastProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast.kind !== "success") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(onDismiss, 3_500);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [onDismiss, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`export-toast is-${toast.kind}`}
|
||||||
|
role={toast.kind === "error" ? "alert" : "status"}
|
||||||
|
aria-live={toast.kind === "error" ? "assertive" : "polite"}
|
||||||
|
>
|
||||||
|
<div className="export-toast-content">
|
||||||
|
<strong>{toast.title}</strong>
|
||||||
|
{toast.message ? <span>{toast.message}</span> : null}
|
||||||
|
</div>
|
||||||
|
{toast.kind !== "progress" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-toast-close"
|
||||||
|
aria-label="关闭导出提示"
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{toast.kind === "progress" ? (
|
||||||
|
<div className="export-toast-progress" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef
|
||||||
|
} from "react";
|
||||||
|
import CodeMirror, {
|
||||||
|
type ReactCodeMirrorRef
|
||||||
|
} from "@uiw/react-codemirror";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
keymap,
|
||||||
|
type KeyBinding
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import {
|
||||||
|
executeMarkdownEditorCommand,
|
||||||
|
markdownEditorCommands
|
||||||
|
} from "./markdown-editor-commands";
|
||||||
|
|
||||||
|
export interface MarkdownEditorHandle {
|
||||||
|
focus: () => void;
|
||||||
|
getScrollElement: () => HTMLElement | null;
|
||||||
|
getView: () => EditorView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
value: string;
|
||||||
|
documentVersion: number;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onEditorReady?: (view: EditorView | null) => void;
|
||||||
|
onScroll?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorTheme = EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#f6f7f5",
|
||||||
|
color: "#26312d",
|
||||||
|
fontSize: "0.86rem"
|
||||||
|
},
|
||||||
|
"&.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
boxShadow: "inset 3px 0 #74a88f"
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily:
|
||||||
|
'"Cascadia Code", "SFMono-Regular", Consolas, monospace',
|
||||||
|
lineHeight: "1.72",
|
||||||
|
tabSize: "2",
|
||||||
|
overflow: "auto"
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
minHeight: "100%",
|
||||||
|
padding: "24px"
|
||||||
|
},
|
||||||
|
".cm-line": {
|
||||||
|
padding: "0"
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
border: "0",
|
||||||
|
backgroundColor: "#eef1ef",
|
||||||
|
color: "#8a9690"
|
||||||
|
},
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": {
|
||||||
|
backgroundColor: "rgb(116 168 143 / 9%)"
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||||
|
backgroundColor: "rgb(89 135 113 / 24%)"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MarkdownEditor = forwardRef<
|
||||||
|
MarkdownEditorHandle,
|
||||||
|
MarkdownEditorProps
|
||||||
|
>(function MarkdownEditor(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
documentVersion,
|
||||||
|
onChange,
|
||||||
|
onEditorReady,
|
||||||
|
onScroll
|
||||||
|
},
|
||||||
|
forwardedRef
|
||||||
|
) {
|
||||||
|
const codeMirrorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const shortcutBindings = useMemo(
|
||||||
|
() =>
|
||||||
|
markdownEditorCommands.flatMap<KeyBinding>((command) =>
|
||||||
|
command.shortcut
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: command.shortcut,
|
||||||
|
run: (view) =>
|
||||||
|
executeMarkdownEditorCommand(command.id, { view })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [markdown(), keymap.of(shortcutBindings)],
|
||||||
|
[shortcutBindings]
|
||||||
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
forwardedRef,
|
||||||
|
() => ({
|
||||||
|
focus: () => viewRef.current?.focus(),
|
||||||
|
getScrollElement: () => viewRef.current?.scrollDOM ?? null,
|
||||||
|
getView: () => viewRef.current
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
onEditorReady?.(null);
|
||||||
|
viewRef.current = null;
|
||||||
|
},
|
||||||
|
[documentVersion, onEditorReady]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="markdown-editor"
|
||||||
|
onScrollCapture={onScroll}
|
||||||
|
>
|
||||||
|
<CodeMirror
|
||||||
|
key={documentVersion}
|
||||||
|
ref={codeMirrorRef}
|
||||||
|
value={value}
|
||||||
|
height="100%"
|
||||||
|
basicSetup={{
|
||||||
|
autocompletion: false,
|
||||||
|
bracketMatching: true,
|
||||||
|
closeBrackets: true,
|
||||||
|
foldGutter: false,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
highlightActiveLineGutter: true,
|
||||||
|
lineNumbers: true,
|
||||||
|
searchKeymap: true
|
||||||
|
}}
|
||||||
|
extensions={extensions}
|
||||||
|
theme={editorTheme}
|
||||||
|
aria-label="Markdown 内容"
|
||||||
|
onChange={onChange}
|
||||||
|
onCreateEditor={(view) => {
|
||||||
|
viewRef.current = view;
|
||||||
|
onEditorReady?.(view);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
type KeyboardEvent,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import { insertMarkdownTable } from "./markdown-editor-commands";
|
||||||
|
|
||||||
|
interface MarkdownTableToolProps {
|
||||||
|
editorView: EditorView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximumColumns = 10;
|
||||||
|
const maximumRows = 10;
|
||||||
|
const minimumRows = 2;
|
||||||
|
|
||||||
|
interface TableSize {
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownTableTool({
|
||||||
|
editorView
|
||||||
|
}: MarkdownTableToolProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [size, setSize] = useState<TableSize>({
|
||||||
|
columns: 3,
|
||||||
|
rows: 3
|
||||||
|
});
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
function focusSize(nextSize: TableSize) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
rootRef.current
|
||||||
|
?.querySelector<HTMLButtonElement>(
|
||||||
|
`[data-table-size="${nextSize.columns}x${nextSize.rows}"]`
|
||||||
|
)
|
||||||
|
?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusSize(size);
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof Node &&
|
||||||
|
!rootRef.current?.contains(event.target)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorView) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [editorView]);
|
||||||
|
|
||||||
|
function moveSelection(
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
columnsDelta: number,
|
||||||
|
rowsDelta: number
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextSize = {
|
||||||
|
columns: clamp(
|
||||||
|
size.columns + columnsDelta,
|
||||||
|
1,
|
||||||
|
maximumColumns
|
||||||
|
),
|
||||||
|
rows: clamp(
|
||||||
|
size.rows + rowsDelta,
|
||||||
|
minimumRows,
|
||||||
|
maximumRows
|
||||||
|
)
|
||||||
|
};
|
||||||
|
setSize(nextSize);
|
||||||
|
focusSize(nextSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTable(selectedSize: TableSize) {
|
||||||
|
if (!editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertMarkdownTable(
|
||||||
|
editorView,
|
||||||
|
selectedSize.columns,
|
||||||
|
selectedSize.rows
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="markdown-table-tool" ref={rootRef}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className="markdown-toolbar-button"
|
||||||
|
aria-label="插入表格"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
title="插入表格"
|
||||||
|
disabled={!editorView}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
表格
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
className="markdown-table-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="选择表格尺寸"
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{size.columns} 列 × {size.rows} 行
|
||||||
|
</strong>
|
||||||
|
<span>首行为表头</span>
|
||||||
|
<div
|
||||||
|
className="markdown-table-grid"
|
||||||
|
role="grid"
|
||||||
|
aria-label="表格尺寸"
|
||||||
|
aria-rowcount={maximumRows - minimumRows + 1}
|
||||||
|
aria-colcount={maximumColumns}
|
||||||
|
>
|
||||||
|
{Array.from(
|
||||||
|
{
|
||||||
|
length:
|
||||||
|
maximumColumns *
|
||||||
|
(maximumRows - minimumRows + 1)
|
||||||
|
},
|
||||||
|
(_, index) => {
|
||||||
|
const columns = (index % maximumColumns) + 1;
|
||||||
|
const rows =
|
||||||
|
Math.floor(index / maximumColumns) + minimumRows;
|
||||||
|
const selected =
|
||||||
|
columns <= size.columns && rows <= size.rows;
|
||||||
|
const active =
|
||||||
|
columns === size.columns && rows === size.rows;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${columns}x${rows}`}
|
||||||
|
type="button"
|
||||||
|
role="gridcell"
|
||||||
|
className={
|
||||||
|
selected
|
||||||
|
? "markdown-table-cell is-selected"
|
||||||
|
: "markdown-table-cell"
|
||||||
|
}
|
||||||
|
data-table-size={`${columns}x${rows}`}
|
||||||
|
aria-label={`插入 ${columns} 列 × ${rows} 行表格`}
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
onPointerEnter={() =>
|
||||||
|
setSize({ columns, rows })
|
||||||
|
}
|
||||||
|
onFocus={() => setSize({ columns, rows })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
moveSelection(event, -1, 0);
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
moveSelection(event, 1, 0);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
moveSelection(event, 0, -1);
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
moveSelection(event, 0, 1);
|
||||||
|
} else if (
|
||||||
|
event.key === "Enter" ||
|
||||||
|
event.key === " "
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
insertTable({ columns, rows });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => insertTable({ columns, rows })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import {
|
||||||
|
executeMarkdownEditorCommand,
|
||||||
|
getMarkdownEditorCommand
|
||||||
|
} from "./markdown-editor-commands";
|
||||||
|
import { MarkdownTableTool } from "./MarkdownTableTool";
|
||||||
|
|
||||||
|
interface MarkdownToolbarProps {
|
||||||
|
editorView: EditorView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarGroups = [
|
||||||
|
["undo", "redo"],
|
||||||
|
["bold", "italic", "strikethrough", "inline-code"],
|
||||||
|
[
|
||||||
|
"code-block",
|
||||||
|
"quote",
|
||||||
|
"unordered-list",
|
||||||
|
"ordered-list",
|
||||||
|
"task-list"
|
||||||
|
],
|
||||||
|
["table", "horizontal-rule"]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function preserveEditorSelection(event: MouseEvent<HTMLElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownToolbar({
|
||||||
|
editorView
|
||||||
|
}: MarkdownToolbarProps) {
|
||||||
|
function execute(commandId: string) {
|
||||||
|
if (!editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeMarkdownEditorCommand(commandId, { view: editorView });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="markdown-toolbar"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Markdown 格式工具栏"
|
||||||
|
>
|
||||||
|
<label className="markdown-toolbar-heading">
|
||||||
|
<span className="sr-only">标题级别</span>
|
||||||
|
<select
|
||||||
|
aria-label="设置标题级别"
|
||||||
|
defaultValue=""
|
||||||
|
disabled={!editorView}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value) {
|
||||||
|
execute(event.target.value);
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">标题</option>
|
||||||
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
|
<option
|
||||||
|
key={index + 1}
|
||||||
|
value={`heading-${index + 1}`}
|
||||||
|
>
|
||||||
|
H{index + 1}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{toolbarGroups.map((group, groupIndex) => (
|
||||||
|
<div
|
||||||
|
className="markdown-toolbar-group"
|
||||||
|
key={group.join("-")}
|
||||||
|
aria-label={`工具组 ${groupIndex + 1}`}
|
||||||
|
>
|
||||||
|
{group.map((commandId) => {
|
||||||
|
if (commandId === "table") {
|
||||||
|
return (
|
||||||
|
<MarkdownTableTool
|
||||||
|
key={commandId}
|
||||||
|
editorView={editorView}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const command = getMarkdownEditorCommand(commandId);
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const disabled =
|
||||||
|
!editorView ||
|
||||||
|
(command.isEnabled?.({ view: editorView }) === false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={command.id}
|
||||||
|
type="button"
|
||||||
|
className={`markdown-toolbar-button command-${command.id}`}
|
||||||
|
aria-label={command.label}
|
||||||
|
title={
|
||||||
|
command.shortcut
|
||||||
|
? `${command.label}(${command.shortcut})`
|
||||||
|
: command.label
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={preserveEditorSelection}
|
||||||
|
onClick={() => execute(command.id)}
|
||||||
|
>
|
||||||
|
{command.toolbarLabel}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
AnnotationLayer,
|
||||||
getDocument,
|
getDocument,
|
||||||
GlobalWorkerOptions,
|
GlobalWorkerOptions,
|
||||||
RenderingCancelledException,
|
RenderingCancelledException,
|
||||||
@@ -18,12 +19,20 @@ import {
|
|||||||
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
import {
|
import {
|
||||||
getPdfCanvasRenderScale,
|
getPdfCanvasRenderScale,
|
||||||
getPdfPageCssDimensions
|
getPdfPageCssDimensions,
|
||||||
|
getPrecisePdfDocumentWidth
|
||||||
} from "./pdf-page-geometry";
|
} from "./pdf-page-geometry";
|
||||||
|
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
|
||||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||||
|
|
||||||
|
const PRECISE_PDF_INLINE_GUTTER = 30;
|
||||||
|
|
||||||
|
type PdfJsAnnotationLinkService = Parameters<
|
||||||
|
AnnotationLayer["render"]
|
||||||
|
>[0]["linkService"];
|
||||||
|
|
||||||
interface PdfPageDescriptor {
|
interface PdfPageDescriptor {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -33,12 +42,16 @@ interface PdfPageDescriptor {
|
|||||||
export interface PrecisePdfPreviewProps {
|
export interface PrecisePdfPreviewProps {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
zoomPercent: number;
|
zoomPercent: number;
|
||||||
|
onExternalLink?: (href: string) => void;
|
||||||
|
onNavigateToPage?: (pageNumber: number) => void;
|
||||||
onPageCount?: (pageCount: number) => void;
|
onPageCount?: (pageCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrecisePdfPageProps {
|
interface PrecisePdfPageProps {
|
||||||
document: PDFDocumentProxy;
|
document: PDFDocumentProxy;
|
||||||
descriptor: PdfPageDescriptor;
|
descriptor: PdfPageDescriptor;
|
||||||
|
onExternalLink: (href: string) => void;
|
||||||
|
onNavigateToPage: (pageNumber: number) => void;
|
||||||
renderQueue: PdfPageRenderQueue;
|
renderQueue: PdfPageRenderQueue;
|
||||||
zoomPercent: number;
|
zoomPercent: number;
|
||||||
}
|
}
|
||||||
@@ -92,12 +105,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
|||||||
function PrecisePdfPage({
|
function PrecisePdfPage({
|
||||||
document,
|
document,
|
||||||
descriptor,
|
descriptor,
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage,
|
||||||
renderQueue,
|
renderQueue,
|
||||||
zoomPercent
|
zoomPercent
|
||||||
}: PrecisePdfPageProps) {
|
}: PrecisePdfPageProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const annotationLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const [displayWidth, setDisplayWidth] = useState(0);
|
const [displayWidth, setDisplayWidth] = useState(0);
|
||||||
const [renderError, setRenderError] = useState("");
|
const [renderError, setRenderError] = useState("");
|
||||||
const [renderState, setRenderState] = useState<
|
const [renderState, setRenderState] = useState<
|
||||||
@@ -158,11 +174,13 @@ function PrecisePdfPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const textContainer = textLayerRef.current;
|
const textContainer = textLayerRef.current;
|
||||||
|
const annotationContainer = annotationLayerRef.current;
|
||||||
if (
|
if (
|
||||||
!nearViewport ||
|
!nearViewport ||
|
||||||
displayWidth <= 0 ||
|
displayWidth <= 0 ||
|
||||||
!canvas ||
|
!canvas ||
|
||||||
!textContainer
|
!textContainer ||
|
||||||
|
!annotationContainer
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,6 +189,7 @@ function PrecisePdfPage({
|
|||||||
let page: PDFPageProxy | undefined;
|
let page: PDFPageProxy | undefined;
|
||||||
let renderTask: RenderTask | undefined;
|
let renderTask: RenderTask | undefined;
|
||||||
let textLayer: TextLayer | undefined;
|
let textLayer: TextLayer | undefined;
|
||||||
|
let annotationLayer: AnnotationLayer | undefined;
|
||||||
|
|
||||||
setRenderError("");
|
setRenderError("");
|
||||||
setRenderState("queued");
|
setRenderState("queued");
|
||||||
@@ -212,6 +231,7 @@ function PrecisePdfPage({
|
|||||||
canvas.style.width = `${displayViewport.width}px`;
|
canvas.style.width = `${displayViewport.width}px`;
|
||||||
canvas.style.height = `${displayViewport.height}px`;
|
canvas.style.height = `${displayViewport.height}px`;
|
||||||
textContainer.replaceChildren();
|
textContainer.replaceChildren();
|
||||||
|
annotationContainer.replaceChildren();
|
||||||
|
|
||||||
renderTask = page.render({
|
renderTask = page.render({
|
||||||
canvas,
|
canvas,
|
||||||
@@ -229,7 +249,43 @@ function PrecisePdfPage({
|
|||||||
textContentSource: page.streamTextContent(),
|
textContentSource: page.streamTextContent(),
|
||||||
viewport: displayViewport
|
viewport: displayViewport
|
||||||
});
|
});
|
||||||
await textLayer.render();
|
const annotations = await page.getAnnotations({
|
||||||
|
intent: "display"
|
||||||
|
});
|
||||||
|
const linkService = createPdfAnnotationLinkService(
|
||||||
|
document,
|
||||||
|
descriptor.pageNumber,
|
||||||
|
{
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage
|
||||||
|
}
|
||||||
|
);
|
||||||
|
annotationLayer = new AnnotationLayer({
|
||||||
|
div: annotationContainer,
|
||||||
|
accessibilityManager: undefined,
|
||||||
|
annotationCanvasMap: undefined,
|
||||||
|
annotationEditorUIManager: undefined,
|
||||||
|
page,
|
||||||
|
viewport: displayViewport,
|
||||||
|
structTreeLayer: undefined,
|
||||||
|
commentManager: undefined,
|
||||||
|
linkService,
|
||||||
|
annotationStorage: document.annotationStorage
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
textLayer.render(),
|
||||||
|
annotationLayer.render({
|
||||||
|
viewport: displayViewport,
|
||||||
|
div: annotationContainer,
|
||||||
|
annotations,
|
||||||
|
page,
|
||||||
|
linkService:
|
||||||
|
linkService as unknown as PdfJsAnnotationLinkService,
|
||||||
|
annotationStorage: document.annotationStorage,
|
||||||
|
renderForms: false,
|
||||||
|
enableScripting: false
|
||||||
|
})
|
||||||
|
]);
|
||||||
} catch (reason: unknown) {
|
} catch (reason: unknown) {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
@@ -251,10 +307,12 @@ function PrecisePdfPage({
|
|||||||
cancelQueuedRender();
|
cancelQueuedRender();
|
||||||
renderTask?.cancel();
|
renderTask?.cancel();
|
||||||
textLayer?.cancel();
|
textLayer?.cancel();
|
||||||
|
annotationLayer?.destroy();
|
||||||
page?.cleanup();
|
page?.cleanup();
|
||||||
canvas.width = 0;
|
canvas.width = 0;
|
||||||
canvas.height = 0;
|
canvas.height = 0;
|
||||||
textContainer.replaceChildren();
|
textContainer.replaceChildren();
|
||||||
|
annotationContainer.replaceChildren();
|
||||||
setRenderState("idle");
|
setRenderState("idle");
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -264,6 +322,8 @@ function PrecisePdfPage({
|
|||||||
displayWidth,
|
displayWidth,
|
||||||
document,
|
document,
|
||||||
nearViewport,
|
nearViewport,
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage,
|
||||||
renderQueue
|
renderQueue
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -282,6 +342,10 @@ function PrecisePdfPage({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div ref={textLayerRef} className="textLayer" />
|
<div ref={textLayerRef} className="textLayer" />
|
||||||
|
<div
|
||||||
|
ref={annotationLayerRef}
|
||||||
|
className="annotationLayer"
|
||||||
|
/>
|
||||||
{renderState !== "rendered" ? (
|
{renderState !== "rendered" ? (
|
||||||
<span className="precise-pdf-page-placeholder">
|
<span className="precise-pdf-page-placeholder">
|
||||||
{renderState === "rendering"
|
{renderState === "rendering"
|
||||||
@@ -299,6 +363,8 @@ function PrecisePdfPage({
|
|||||||
export function PrecisePdfPreview({
|
export function PrecisePdfPreview({
|
||||||
blob,
|
blob,
|
||||||
zoomPercent,
|
zoomPercent,
|
||||||
|
onExternalLink,
|
||||||
|
onNavigateToPage,
|
||||||
onPageCount
|
onPageCount
|
||||||
}: PrecisePdfPreviewProps) {
|
}: PrecisePdfPreviewProps) {
|
||||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||||
@@ -308,6 +374,19 @@ export function PrecisePdfPreview({
|
|||||||
() => new PdfPageRenderQueue(1),
|
() => new PdfPageRenderQueue(1),
|
||||||
[document]
|
[document]
|
||||||
);
|
);
|
||||||
|
const externalLinkRef = useRef(onExternalLink);
|
||||||
|
const navigateToPageRef = useRef(onNavigateToPage);
|
||||||
|
externalLinkRef.current = onExternalLink;
|
||||||
|
navigateToPageRef.current = onNavigateToPage;
|
||||||
|
const handleExternalLink = useCallback((href: string) => {
|
||||||
|
externalLinkRef.current?.(href);
|
||||||
|
}, []);
|
||||||
|
const handleNavigateToPage = useCallback(
|
||||||
|
(pageNumber: number) => {
|
||||||
|
navigateToPageRef.current?.(pageNumber);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||||
|
|
||||||
@@ -379,13 +458,24 @@ export function PrecisePdfPreview({
|
|||||||
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const documentWidth = getPrecisePdfDocumentWidth(
|
||||||
|
pages.map((page) => page.width),
|
||||||
|
zoomPercent / 100,
|
||||||
|
PRECISE_PDF_INLINE_GUTTER
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="precise-pdf-document">
|
<div
|
||||||
|
className="precise-pdf-document"
|
||||||
|
style={{ width: `${documentWidth}px` }}
|
||||||
|
>
|
||||||
{pages.map((page) => (
|
{pages.map((page) => (
|
||||||
<PrecisePdfPage
|
<PrecisePdfPage
|
||||||
key={page.pageNumber}
|
key={page.pageNumber}
|
||||||
document={document}
|
document={document}
|
||||||
descriptor={page}
|
descriptor={page}
|
||||||
|
onExternalLink={handleExternalLink}
|
||||||
|
onNavigateToPage={handleNavigateToPage}
|
||||||
renderQueue={renderQueue}
|
renderQueue={renderQueue}
|
||||||
zoomPercent={zoomPercent}
|
zoomPercent={zoomPercent}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const APP_DISPLAY_NAME = __APP_DISPLAY_NAME__;
|
||||||
|
export const APP_WINDOW_TITLE = __APP_WINDOW_TITLE__;
|
||||||
|
export const APP_SLOGAN = __APP_SLOGAN__;
|
||||||
|
export const APP_DESCRIPTION = __APP_DESCRIPTION__;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const APP_VERSION = __APP_VERSION__;
|
||||||
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type {
|
||||||
|
DocumentProfileName,
|
||||||
|
FooterConfig,
|
||||||
|
HeaderConfig,
|
||||||
|
PageMargins,
|
||||||
|
RenderedMarkdownDocument,
|
||||||
|
ThemeCategory
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface ThemeSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
category: ThemeCategory;
|
||||||
|
compatibleProfiles: DocumentProfileName[];
|
||||||
|
pageDefaults?: {
|
||||||
|
margins: PageMargins;
|
||||||
|
header?: HeaderConfig;
|
||||||
|
footer?: FooterConfig;
|
||||||
|
};
|
||||||
|
bundled: boolean;
|
||||||
|
source: "bundled" | "local";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownRenderInput {
|
||||||
|
markdown: string;
|
||||||
|
language: string;
|
||||||
|
resources?: MarkdownImageResource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownImageResource {
|
||||||
|
path: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal?: AbortSignal) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(
|
||||||
|
response: Response,
|
||||||
|
fallback: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { message?: unknown };
|
||||||
|
return typeof payload.message === "string"
|
||||||
|
? payload.message
|
||||||
|
: fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderMarkdownDocument(
|
||||||
|
input: MarkdownRenderInput,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (window.mdToPdfDesktop) {
|
||||||
|
const result = await window.mdToPdfDesktop.renderMarkdown(input);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/render", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
...(signal ? { signal } : {})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, "渲染失败"));
|
||||||
|
}
|
||||||
|
return response.json() as Promise<RenderedMarkdownDocument>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listThemes(signal?: AbortSignal) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (window.mdToPdfDesktop) {
|
||||||
|
const result = await window.mdToPdfDesktop.listThemes();
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/themes", {
|
||||||
|
...(signal ? { signal } : {})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("无法加载主题清单");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThemeCss(
|
||||||
|
themeId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (window.mdToPdfDesktop) {
|
||||||
|
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/themes/${encodeURIComponent(themeId)}/css`,
|
||||||
|
{ ...(signal ? { signal } : {}) }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("无法加载主题");
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const sampleModules = import.meta.glob<string>(
|
||||||
|
"../../../samples/themes/*.md",
|
||||||
|
{
|
||||||
|
eager: true,
|
||||||
|
query: "?raw",
|
||||||
|
import: "default"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BuiltinThemeSample {
|
||||||
|
themeId: string;
|
||||||
|
fileName: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(modulePath: string) {
|
||||||
|
return modulePath.split("/").at(-1) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
|
||||||
|
sampleModules
|
||||||
|
)
|
||||||
|
.map(([modulePath, markdown]) => {
|
||||||
|
const fileName = getFileName(modulePath);
|
||||||
|
return {
|
||||||
|
themeId: fileName.replace(/\.md$/i, ""),
|
||||||
|
fileName,
|
||||||
|
markdown
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((first, second) =>
|
||||||
|
first.themeId.localeCompare(second.themeId, "en")
|
||||||
|
);
|
||||||
|
|
||||||
|
const builtinThemeSampleMap = new Map(
|
||||||
|
builtinThemeSamples.map((sample) => [sample.themeId, sample])
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getBuiltinThemeSample(themeId: string) {
|
||||||
|
return builtinThemeSampleMap.get(themeId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const tutorialModules = import.meta.glob<string>(
|
||||||
|
"../../../samples/tutorials/*.md",
|
||||||
|
{
|
||||||
|
eager: true,
|
||||||
|
query: "?raw",
|
||||||
|
import: "default"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BuiltinTutorial {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileName: string;
|
||||||
|
themeId: string;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tutorialDefinitions = [
|
||||||
|
{
|
||||||
|
id: "echarts-tutorial",
|
||||||
|
name: "ECharts 图表教程",
|
||||||
|
themeId: "typora-github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "formal-document-tutorial",
|
||||||
|
name: "公文主题使用教程",
|
||||||
|
themeId: "typora-github"
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const tutorialMarkdownById = new Map(
|
||||||
|
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
|
||||||
|
const fileName = modulePath.split("/").at(-1) ?? "";
|
||||||
|
return [fileName.replace(/\.md$/i, ""), markdown];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const builtinTutorials: BuiltinTutorial[] =
|
||||||
|
tutorialDefinitions.map((definition) => {
|
||||||
|
const markdown = tutorialMarkdownById.get(definition.id);
|
||||||
|
if (!markdown) {
|
||||||
|
throw new Error(`缺少内置教程:${definition.id}.md`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
fileName: `${definition.id}.md`,
|
||||||
|
markdown
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const fallbackBaseName = "未命名文档";
|
||||||
|
const windowsReservedName =
|
||||||
|
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
|
||||||
|
|
||||||
|
function stripMarkdownFormatting(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
|
||||||
|
.replace(/<\/?[A-Za-z][^>]*>/gu, " ")
|
||||||
|
.replace(/[`*_~]/gu, "")
|
||||||
|
.replace(/^\s{0,3}(?:>\s*|[-+*]\s+|\d+[.)]\s+)/u, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legalizeMarkdownBaseName(value: string) {
|
||||||
|
const normalized = stripMarkdownFormatting(value)
|
||||||
|
.normalize("NFKC")
|
||||||
|
.replace(/[^\p{L}\p{N}\s_-]+/gu, " ")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/[ .]+$/u, "");
|
||||||
|
const shortened = Array.from(normalized).slice(0, 100).join("").trim();
|
||||||
|
if (!shortened) {
|
||||||
|
return fallbackBaseName;
|
||||||
|
}
|
||||||
|
return windowsReservedName.test(shortened)
|
||||||
|
? `文档 ${shortened}`
|
||||||
|
: shortened;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveMarkdownBaseName(markdown: string) {
|
||||||
|
const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u);
|
||||||
|
let inFrontMatter = lines[0]?.trim() === "---";
|
||||||
|
let inFence = false;
|
||||||
|
let fenceMarker = "";
|
||||||
|
let fenceLength = 0;
|
||||||
|
let firstBodyLine = "";
|
||||||
|
|
||||||
|
for (let index = inFrontMatter ? 1 : 0; index < lines.length; index += 1) {
|
||||||
|
const line = lines[index] ?? "";
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (inFrontMatter) {
|
||||||
|
if (trimmed === "---" || trimmed === "...") {
|
||||||
|
inFrontMatter = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFence) {
|
||||||
|
const closingFence = trimmed.match(/^(`+|~+)\s*$/u)?.[1];
|
||||||
|
if (
|
||||||
|
closingFence?.[0] === fenceMarker &&
|
||||||
|
closingFence.length >= fenceLength
|
||||||
|
) {
|
||||||
|
inFence = false;
|
||||||
|
fenceMarker = "";
|
||||||
|
fenceLength = 0;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fence = trimmed.match(/^(`{3,}|~{3,})/u)?.[1];
|
||||||
|
if (fence) {
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
fenceMarker = fence[0] ?? "";
|
||||||
|
fenceLength = fence.length;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence || !trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u)?.[1];
|
||||||
|
if (heading) {
|
||||||
|
return legalizeMarkdownBaseName(heading);
|
||||||
|
}
|
||||||
|
if (!firstBodyLine) {
|
||||||
|
firstBodyLine = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return legalizeMarkdownBaseName(firstBodyLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownFileName(markdown: string) {
|
||||||
|
return `${deriveMarkdownBaseName(markdown)}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureMarkdownFileName(fileName: string, markdown: string) {
|
||||||
|
const trimmed = fileName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return createMarkdownFileName(markdown);
|
||||||
|
}
|
||||||
|
return /\.(?:md|markdown)$/iu.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: `${trimmed}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadMarkdown(markdown: string, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([markdown], { type: "text/markdown;charset=utf-8" })
|
||||||
|
);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export type WebDocumentLinkAction =
|
||||||
|
| {
|
||||||
|
type: "anchor";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "open";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ignore";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveWebDocumentLinkAction(
|
||||||
|
href: string
|
||||||
|
): WebDocumentLinkAction {
|
||||||
|
const link = classifyDocumentLink(href);
|
||||||
|
if (link.kind === "anchor") {
|
||||||
|
return {
|
||||||
|
type: "anchor",
|
||||||
|
href: link.normalizedHref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
link.kind === "network" ||
|
||||||
|
(link.kind === "protocol" &&
|
||||||
|
(link.scheme === "mailto" || link.scheme === "tel"))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "open",
|
||||||
|
href: link.normalizedHref
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "ignore" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type DocumentShortcut =
|
||||||
|
| "new"
|
||||||
|
| "save"
|
||||||
|
| "save-as"
|
||||||
|
| "close";
|
||||||
|
|
||||||
|
export interface DocumentShortcutEvent {
|
||||||
|
key: string;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
isComposing: boolean;
|
||||||
|
repeat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocumentShortcut(
|
||||||
|
event: DocumentShortcutEvent,
|
||||||
|
desktop: boolean
|
||||||
|
): DocumentShortcut | undefined {
|
||||||
|
if (
|
||||||
|
event.isComposing ||
|
||||||
|
event.repeat ||
|
||||||
|
event.altKey ||
|
||||||
|
(!event.ctrlKey && !event.metaKey)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (event.shiftKey) {
|
||||||
|
return desktop && key === "s" ? "save-as" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "s":
|
||||||
|
return "save";
|
||||||
|
case "n":
|
||||||
|
return desktop ? "new" : undefined;
|
||||||
|
case "w":
|
||||||
|
return desktop ? "close" : undefined;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
DOCX_MIME_TYPE,
|
||||||
|
type DocxCapability,
|
||||||
|
type DocxExportRequestInput
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { parseContentDispositionFileName } from "./pdf-export";
|
||||||
|
|
||||||
|
export interface DocxExportResult {
|
||||||
|
blob: Blob;
|
||||||
|
fileName: string;
|
||||||
|
warningCount: number;
|
||||||
|
echartsErrorCount: number;
|
||||||
|
mermaidErrorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopDocxExportResult {
|
||||||
|
saved: boolean;
|
||||||
|
fileName: string;
|
||||||
|
warningCount: number;
|
||||||
|
echartsErrorCount: number;
|
||||||
|
mermaidErrorCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumericHeader(value: string | null) {
|
||||||
|
if (value === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof payload.message === "string") {
|
||||||
|
return payload.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 错误响应使用通用提示。
|
||||||
|
}
|
||||||
|
return "DOCX 导出失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocxCapability(
|
||||||
|
fetcher: typeof fetch = fetch
|
||||||
|
): Promise<DocxCapability> {
|
||||||
|
const bridge = window.mdToPdfDesktop;
|
||||||
|
if (bridge) {
|
||||||
|
return bridge.getDocxCapability();
|
||||||
|
}
|
||||||
|
const response = await fetcher("/api/docx/capability");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("无法检测 DOCX 导出能力");
|
||||||
|
}
|
||||||
|
return (await response.json()) as DocxCapability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDocxExport(
|
||||||
|
request: DocxExportRequestInput,
|
||||||
|
fetcher: typeof fetch = fetch
|
||||||
|
): Promise<DocxExportResult> {
|
||||||
|
const response = await fetcher("/api/docx", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
const contentType =
|
||||||
|
response.headers.get("content-type")?.split(";")[0]?.trim();
|
||||||
|
if (contentType !== DOCX_MIME_TYPE) {
|
||||||
|
throw new Error("DOCX 服务返回了无效文件类型");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blob: await response.blob(),
|
||||||
|
fileName: parseContentDispositionFileName(
|
||||||
|
response.headers.get("content-disposition"),
|
||||||
|
"document.docx"
|
||||||
|
),
|
||||||
|
warningCount: parseNumericHeader(
|
||||||
|
response.headers.get("x-docx-warning-count")
|
||||||
|
),
|
||||||
|
echartsErrorCount: parseNumericHeader(
|
||||||
|
response.headers.get("x-echarts-error-count")
|
||||||
|
),
|
||||||
|
mermaidErrorCount: parseNumericHeader(
|
||||||
|
response.headers.get("x-mermaid-error-count")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDesktopDocxExport(
|
||||||
|
request: DocxExportRequestInput
|
||||||
|
): Promise<DesktopDocxExportResult> {
|
||||||
|
const bridge = window.mdToPdfDesktop;
|
||||||
|
if (!bridge) {
|
||||||
|
throw new Error("桌面 DOCX 引擎不可用");
|
||||||
|
}
|
||||||
|
const outcome = await bridge.exportDocx(request);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
throw new Error(outcome.message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
saved: outcome.saved,
|
||||||
|
fileName: outcome.fileName,
|
||||||
|
warningCount: outcome.diagnostics.warnings.length,
|
||||||
|
echartsErrorCount: outcome.diagnostics.echartsErrors.length,
|
||||||
|
mermaidErrorCount: outcome.diagnostics.mermaidErrors.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadDocx(result: DocxExportResult) {
|
||||||
|
const url = URL.createObjectURL(result.blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = result.fileName;
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocxDiagnosticsMessage(result: {
|
||||||
|
warningCount: number;
|
||||||
|
echartsErrorCount: number;
|
||||||
|
mermaidErrorCount: number;
|
||||||
|
}) {
|
||||||
|
const messages = [];
|
||||||
|
if (result.warningCount > 0) {
|
||||||
|
messages.push(`${result.warningCount} 条提示`);
|
||||||
|
}
|
||||||
|
if (result.echartsErrorCount > 0) {
|
||||||
|
messages.push(`${result.echartsErrorCount} 个 ECharts 图表失败`);
|
||||||
|
}
|
||||||
|
if (result.mermaidErrorCount > 0) {
|
||||||
|
messages.push(`${result.mermaidErrorCount} 个 Mermaid 图表失败`);
|
||||||
|
}
|
||||||
|
return messages.length > 0
|
||||||
|
? messages.join(",")
|
||||||
|
: "纸张、样式和可编辑结构已写入文档";
|
||||||
|
}
|
||||||
+121
-14
@@ -4,8 +4,10 @@ import {
|
|||||||
type ExportConfig
|
type ExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3";
|
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
|
||||||
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY =
|
export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
|
||||||
|
"md-to-pdf.export-config.v3";
|
||||||
|
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
|
||||||
"md-to-pdf.export-config.v2";
|
"md-to-pdf.export-config.v2";
|
||||||
|
|
||||||
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||||
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
|
|||||||
return structuredClone(defaultExportConfig);
|
return structuredClone(defaultExportConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickLegacyPageDecorations(value: unknown) {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
if ("left" in source) {
|
||||||
|
return {
|
||||||
|
enabled: source.enabled,
|
||||||
|
height: source.height,
|
||||||
|
showDivider: source.showDivider,
|
||||||
|
fontSize: source.fontSize,
|
||||||
|
color: source.color,
|
||||||
|
left: source.left,
|
||||||
|
center: source.center,
|
||||||
|
right: source.right
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: source.enabled,
|
||||||
|
height: source.height,
|
||||||
|
showDivider: source.showDivider,
|
||||||
|
fontSize: source.fontSize,
|
||||||
|
color: source.color,
|
||||||
|
alignment: source.alignment,
|
||||||
|
format: source.format,
|
||||||
|
template: source.template,
|
||||||
|
startFrom: source.startFrom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesLegacyPageDecorationDefaults(
|
||||||
|
header: unknown,
|
||||||
|
footer: unknown
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
JSON.stringify(pickLegacyPageDecorations(header)) ===
|
||||||
|
JSON.stringify(
|
||||||
|
pickLegacyPageDecorations(defaultExportConfig.header)
|
||||||
|
) &&
|
||||||
|
JSON.stringify(pickLegacyPageDecorations(footer)) ===
|
||||||
|
JSON.stringify(
|
||||||
|
pickLegacyPageDecorations(defaultExportConfig.footer)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateStoredExportConfig(value: unknown): unknown {
|
||||||
|
if (
|
||||||
|
typeof value !== "object" ||
|
||||||
|
value === null ||
|
||||||
|
!("version" in value) ||
|
||||||
|
!("paper" in value)
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.version !== 2 &&
|
||||||
|
value.version !== 3 &&
|
||||||
|
value.version !== defaultExportConfig.version
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
const hasPageDecorationsMode =
|
||||||
|
typeof source.pageDecorationsMode === "string";
|
||||||
|
|
||||||
|
const paper =
|
||||||
|
typeof value.paper === "object" && value.paper !== null
|
||||||
|
? value.paper
|
||||||
|
: {};
|
||||||
|
const margins =
|
||||||
|
"margins" in paper &&
|
||||||
|
typeof paper.margins === "object" &&
|
||||||
|
paper.margins !== null
|
||||||
|
? paper.margins
|
||||||
|
: {};
|
||||||
|
const usesLegacyDefaults =
|
||||||
|
JSON.stringify(margins) ===
|
||||||
|
JSON.stringify(defaultExportConfig.paper.margins);
|
||||||
|
const hasMarginMode =
|
||||||
|
typeof (paper as Record<string, unknown>).marginMode === "string";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
...(value.version === 2 || value.version === 3
|
||||||
|
? { version: defaultExportConfig.version }
|
||||||
|
: {}),
|
||||||
|
...(value.version === 2
|
||||||
|
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
|
||||||
|
: {}),
|
||||||
|
...(!hasPageDecorationsMode
|
||||||
|
? {
|
||||||
|
pageDecorationsMode: usesLegacyPageDecorationDefaults(
|
||||||
|
source.header,
|
||||||
|
source.footer
|
||||||
|
)
|
||||||
|
? "theme"
|
||||||
|
: "custom"
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
paper: {
|
||||||
|
...paper,
|
||||||
|
...(!hasMarginMode
|
||||||
|
? {
|
||||||
|
marginMode: usesLegacyDefaults ? "theme" : "custom"
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return cloneDefaultExportConfig();
|
return cloneDefaultExportConfig();
|
||||||
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const value: unknown = JSON.parse(rawValue);
|
const value: unknown = JSON.parse(rawValue);
|
||||||
const migratedValue =
|
const migratedValue = migrateStoredExportConfig(value);
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"version" in value &&
|
|
||||||
value.version === 2
|
|
||||||
? {
|
|
||||||
...value,
|
|
||||||
version: defaultExportConfig.version,
|
|
||||||
mermaid: structuredClone(defaultExportConfig.mermaid)
|
|
||||||
}
|
|
||||||
: value;
|
|
||||||
const parsed = exportConfigSchema.safeParse(migratedValue);
|
const parsed = exportConfigSchema.safeParse(migratedValue);
|
||||||
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
return parsed.success ? parsed.data : cloneDefaultExportConfig();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -47,7 +152,9 @@ export function loadExportConfig(
|
|||||||
return parseStoredExportConfig(currentValue);
|
return parseStoredExportConfig(currentValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY);
|
const legacyValue =
|
||||||
|
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
|
||||||
|
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
|
||||||
const config = parseStoredExportConfig(legacyValue);
|
const config = parseStoredExportConfig(legacyValue);
|
||||||
if (legacyValue) {
|
if (legacyValue) {
|
||||||
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export async function withHiddenWindowAnimationFrames<T>(
|
||||||
|
windowRef: Window,
|
||||||
|
operation: () => Promise<T>
|
||||||
|
) {
|
||||||
|
const requestAnimationFrame = windowRef.requestAnimationFrame;
|
||||||
|
windowRef.requestAnimationFrame = (callback) =>
|
||||||
|
windowRef.setTimeout(
|
||||||
|
() => callback(windowRef.performance.now()),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
windowRef.requestAnimationFrame = requestAnimationFrame;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { APP_WINDOW_TITLE } from "./app-brand";
|
||||||
|
import { APP_VERSION_LABEL } from "./app-version";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
document.title = `${APP_WINDOW_TITLE} ${APP_VERSION_LABEL}`;
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import {
|
||||||
|
EditorSelection,
|
||||||
|
type EditorState,
|
||||||
|
type SelectionRange
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
redo,
|
||||||
|
redoDepth,
|
||||||
|
undo,
|
||||||
|
undoDepth
|
||||||
|
} from "@codemirror/commands";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
export type MarkdownEditorCommandGroup =
|
||||||
|
| "history"
|
||||||
|
| "text"
|
||||||
|
| "block"
|
||||||
|
| "insert"
|
||||||
|
| "document";
|
||||||
|
|
||||||
|
export interface EditorCommandContext {
|
||||||
|
view: EditorView;
|
||||||
|
openPanel?: (panelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownEditorCommand {
|
||||||
|
id: string;
|
||||||
|
group: MarkdownEditorCommandGroup;
|
||||||
|
label: string;
|
||||||
|
toolbarLabel: string;
|
||||||
|
shortcut?: string;
|
||||||
|
isEnabled?: (context: EditorCommandContext) => boolean;
|
||||||
|
execute: (
|
||||||
|
context: EditorCommandContext
|
||||||
|
) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownTextEdit {
|
||||||
|
insert: string;
|
||||||
|
selectionFrom: number;
|
||||||
|
selectionTo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectionEditor = (
|
||||||
|
selectedText: string,
|
||||||
|
range: SelectionRange,
|
||||||
|
state: EditorState
|
||||||
|
) => MarkdownTextEdit;
|
||||||
|
|
||||||
|
export function wrapMarkdownSelection(
|
||||||
|
selectedText: string,
|
||||||
|
prefix: string,
|
||||||
|
suffix = prefix,
|
||||||
|
placeholder = "文本"
|
||||||
|
): MarkdownTextEdit {
|
||||||
|
if (
|
||||||
|
selectedText.length >= prefix.length + suffix.length &&
|
||||||
|
selectedText.startsWith(prefix) &&
|
||||||
|
selectedText.endsWith(suffix)
|
||||||
|
) {
|
||||||
|
const insert = selectedText.slice(
|
||||||
|
prefix.length,
|
||||||
|
selectedText.length - suffix.length
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
insert,
|
||||||
|
selectionFrom: 0,
|
||||||
|
selectionTo: insert.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = selectedText || placeholder;
|
||||||
|
return {
|
||||||
|
insert: `${prefix}${content}${suffix}`,
|
||||||
|
selectionFrom: prefix.length,
|
||||||
|
selectionTo: prefix.length + content.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNonBlankLines(
|
||||||
|
text: string,
|
||||||
|
mapper: (line: string, index: number) => string
|
||||||
|
) {
|
||||||
|
let contentLineIndex = 0;
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
if (!line.trim()) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
const mapped = mapper(line, contentLineIndex);
|
||||||
|
contentLineIndex += 1;
|
||||||
|
return mapped;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkdownLinePrefix =
|
||||||
|
| "quote"
|
||||||
|
| "unordered-list"
|
||||||
|
| "ordered-list"
|
||||||
|
| "task-list";
|
||||||
|
|
||||||
|
const linePrefixPatterns: Record<MarkdownLinePrefix, RegExp> = {
|
||||||
|
quote: /^>\s?/u,
|
||||||
|
"unordered-list": /^[-*+]\s+/u,
|
||||||
|
"ordered-list": /^\d+[.)]\s+/u,
|
||||||
|
"task-list": /^[-*+]\s+\[[ xX]\]\s+/u
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toggleMarkdownLinePrefix(
|
||||||
|
text: string,
|
||||||
|
kind: MarkdownLinePrefix
|
||||||
|
) {
|
||||||
|
const pattern = linePrefixPatterns[kind];
|
||||||
|
const nonBlankLines = text
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
const shouldRemove =
|
||||||
|
nonBlankLines.length > 0 &&
|
||||||
|
nonBlankLines.every((line) => pattern.test(line));
|
||||||
|
|
||||||
|
return mapNonBlankLines(text, (line, index) => {
|
||||||
|
if (shouldRemove) {
|
||||||
|
return line.replace(pattern, "");
|
||||||
|
}
|
||||||
|
if (kind === "quote") {
|
||||||
|
return `> ${line.replace(linePrefixPatterns.quote, "")}`;
|
||||||
|
}
|
||||||
|
if (kind === "ordered-list") {
|
||||||
|
return `${index + 1}. ${line.replace(
|
||||||
|
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
|
||||||
|
""
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
if (kind === "task-list") {
|
||||||
|
return `- [ ] ${line.replace(
|
||||||
|
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
|
||||||
|
""
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
return `- ${line.replace(
|
||||||
|
/^(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/u,
|
||||||
|
""
|
||||||
|
)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMarkdownHeading(text: string, level: number) {
|
||||||
|
const prefix = level > 0 ? `${"#".repeat(level)} ` : "";
|
||||||
|
return mapNonBlankLines(
|
||||||
|
text,
|
||||||
|
(line) => `${prefix}${line.replace(/^#{1,6}\s+/u, "")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarkdownTable(
|
||||||
|
columnCount: number,
|
||||||
|
rowCount: number
|
||||||
|
): MarkdownTextEdit {
|
||||||
|
const columns = Math.trunc(columnCount);
|
||||||
|
const rows = Math.trunc(rowCount);
|
||||||
|
if (columns < 1 || columns > 10 || rows < 2 || rows > 10) {
|
||||||
|
throw new RangeError("表格尺寸必须为 1~10 列、2~10 行");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerCells = Array.from(
|
||||||
|
{ length: columns },
|
||||||
|
(_, index) => `列 ${index + 1}`
|
||||||
|
);
|
||||||
|
const separatorCells = Array.from(
|
||||||
|
{ length: columns },
|
||||||
|
() => "---"
|
||||||
|
);
|
||||||
|
const contentRow = `| ${Array.from(
|
||||||
|
{ length: columns },
|
||||||
|
() => "内容"
|
||||||
|
).join(" | ")} |`;
|
||||||
|
const insert = [
|
||||||
|
`| ${headerCells.join(" | ")} |`,
|
||||||
|
`| ${separatorCells.join(" | ")} |`,
|
||||||
|
...Array.from({ length: rows - 1 }, () => contentRow)
|
||||||
|
].join("\n");
|
||||||
|
return {
|
||||||
|
insert,
|
||||||
|
selectionFrom: 2,
|
||||||
|
selectionTo: 5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSelections(
|
||||||
|
view: EditorView,
|
||||||
|
createEdit: SelectionEditor
|
||||||
|
) {
|
||||||
|
const transaction = view.state.changeByRange((range) => {
|
||||||
|
const selectedText = view.state.sliceDoc(range.from, range.to);
|
||||||
|
const edit = createEdit(selectedText, range, view.state);
|
||||||
|
return {
|
||||||
|
changes: {
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
insert: edit.insert
|
||||||
|
},
|
||||||
|
range: EditorSelection.range(
|
||||||
|
range.from + edit.selectionFrom,
|
||||||
|
range.from + edit.selectionTo
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
view.dispatch(transaction);
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSelectedLines(
|
||||||
|
view: EditorView,
|
||||||
|
transform: (text: string) => string
|
||||||
|
) {
|
||||||
|
const transaction = view.state.changeByRange((range) => {
|
||||||
|
const startLine = view.state.doc.lineAt(range.from);
|
||||||
|
const endProbe =
|
||||||
|
range.to > range.from &&
|
||||||
|
view.state.doc.lineAt(range.to).from === range.to
|
||||||
|
? range.to - 1
|
||||||
|
: range.to;
|
||||||
|
const endLine = view.state.doc.lineAt(endProbe);
|
||||||
|
const selectedLines = view.state.sliceDoc(
|
||||||
|
startLine.from,
|
||||||
|
endLine.to
|
||||||
|
);
|
||||||
|
const insert = transform(selectedLines);
|
||||||
|
return {
|
||||||
|
changes: {
|
||||||
|
from: startLine.from,
|
||||||
|
to: endLine.to,
|
||||||
|
insert
|
||||||
|
},
|
||||||
|
range: EditorSelection.range(
|
||||||
|
startLine.from,
|
||||||
|
startLine.from + insert.length
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
view.dispatch(transaction);
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMarkdownBlock(
|
||||||
|
block: string,
|
||||||
|
range?: SelectionRange,
|
||||||
|
state?: EditorState
|
||||||
|
): MarkdownTextEdit {
|
||||||
|
const before =
|
||||||
|
range && state ? state.sliceDoc(0, range.from) : "";
|
||||||
|
const after = range && state ? state.sliceDoc(range.to) : "";
|
||||||
|
const leading =
|
||||||
|
!before || before.endsWith("\n\n")
|
||||||
|
? ""
|
||||||
|
: before.endsWith("\n")
|
||||||
|
? "\n"
|
||||||
|
: "\n\n";
|
||||||
|
const trailing =
|
||||||
|
!after || after.startsWith("\n\n")
|
||||||
|
? ""
|
||||||
|
: after.startsWith("\n")
|
||||||
|
? "\n"
|
||||||
|
: "\n\n";
|
||||||
|
const insert = `${leading}${block}${trailing}`;
|
||||||
|
return {
|
||||||
|
insert,
|
||||||
|
selectionFrom: leading.length,
|
||||||
|
selectionTo: leading.length + block.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMarkdownTable(
|
||||||
|
view: EditorView,
|
||||||
|
columnCount: number,
|
||||||
|
rowCount: number
|
||||||
|
) {
|
||||||
|
replaceSelections(view, (_, range, state) => {
|
||||||
|
const table = createMarkdownTable(columnCount, rowCount);
|
||||||
|
const block = insertMarkdownBlock(
|
||||||
|
table.insert,
|
||||||
|
range,
|
||||||
|
state
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
insert: block.insert,
|
||||||
|
selectionFrom: block.selectionFrom + table.selectionFrom,
|
||||||
|
selectionTo: block.selectionFrom + table.selectionTo
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInlineCommand(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
toolbarLabel: string,
|
||||||
|
prefix: string,
|
||||||
|
suffix = prefix,
|
||||||
|
placeholder = "文本",
|
||||||
|
shortcut?: string
|
||||||
|
): MarkdownEditorCommand {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
group: "text",
|
||||||
|
label,
|
||||||
|
toolbarLabel,
|
||||||
|
...(shortcut ? { shortcut } : {}),
|
||||||
|
execute: ({ view }) =>
|
||||||
|
replaceSelections(view, (selectedText) =>
|
||||||
|
wrapMarkdownSelection(
|
||||||
|
selectedText,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
placeholder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLineCommand(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
toolbarLabel: string,
|
||||||
|
kind: MarkdownLinePrefix,
|
||||||
|
shortcut?: string
|
||||||
|
): MarkdownEditorCommand {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
group: "block",
|
||||||
|
label,
|
||||||
|
toolbarLabel,
|
||||||
|
...(shortcut ? { shortcut } : {}),
|
||||||
|
execute: ({ view }) =>
|
||||||
|
editSelectedLines(view, (text) =>
|
||||||
|
toggleMarkdownLinePrefix(text, kind)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyCommands: MarkdownEditorCommand[] = [
|
||||||
|
{
|
||||||
|
id: "undo",
|
||||||
|
group: "history",
|
||||||
|
label: "撤销",
|
||||||
|
toolbarLabel: "↶",
|
||||||
|
shortcut: "Mod-z",
|
||||||
|
isEnabled: ({ view }) => undoDepth(view.state) > 0,
|
||||||
|
execute: ({ view }) => {
|
||||||
|
undo(view);
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redo",
|
||||||
|
group: "history",
|
||||||
|
label: "重做",
|
||||||
|
toolbarLabel: "↷",
|
||||||
|
shortcut: "Mod-Shift-z",
|
||||||
|
isEnabled: ({ view }) => redoDepth(view.state) > 0,
|
||||||
|
execute: ({ view }) => {
|
||||||
|
redo(view);
|
||||||
|
view.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const textCommands: MarkdownEditorCommand[] = [
|
||||||
|
createInlineCommand(
|
||||||
|
"bold",
|
||||||
|
"加粗",
|
||||||
|
"B",
|
||||||
|
"**",
|
||||||
|
"**",
|
||||||
|
"粗体文本",
|
||||||
|
"Mod-b"
|
||||||
|
),
|
||||||
|
createInlineCommand(
|
||||||
|
"italic",
|
||||||
|
"斜体",
|
||||||
|
"I",
|
||||||
|
"*",
|
||||||
|
"*",
|
||||||
|
"斜体文本",
|
||||||
|
"Mod-i"
|
||||||
|
),
|
||||||
|
createInlineCommand(
|
||||||
|
"strikethrough",
|
||||||
|
"删除线",
|
||||||
|
"S",
|
||||||
|
"~~",
|
||||||
|
"~~",
|
||||||
|
"删除线文本",
|
||||||
|
"Mod-Shift-x"
|
||||||
|
),
|
||||||
|
createInlineCommand(
|
||||||
|
"inline-code",
|
||||||
|
"行内代码",
|
||||||
|
"</>",
|
||||||
|
"`",
|
||||||
|
"`",
|
||||||
|
"code"
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
const blockCommands: MarkdownEditorCommand[] = [
|
||||||
|
{
|
||||||
|
id: "code-block",
|
||||||
|
group: "block",
|
||||||
|
label: "代码块",
|
||||||
|
toolbarLabel: "代码块",
|
||||||
|
execute: ({ view }) =>
|
||||||
|
replaceSelections(view, (selectedText) =>
|
||||||
|
wrapMarkdownSelection(
|
||||||
|
selectedText,
|
||||||
|
"```\n",
|
||||||
|
"\n```",
|
||||||
|
"code"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
createLineCommand("quote", "引用", "❞", "quote"),
|
||||||
|
createLineCommand(
|
||||||
|
"unordered-list",
|
||||||
|
"无序列表",
|
||||||
|
"• 列表",
|
||||||
|
"unordered-list",
|
||||||
|
"Mod-Shift-8"
|
||||||
|
),
|
||||||
|
createLineCommand(
|
||||||
|
"ordered-list",
|
||||||
|
"有序列表",
|
||||||
|
"1. 列表",
|
||||||
|
"ordered-list",
|
||||||
|
"Mod-Shift-7"
|
||||||
|
),
|
||||||
|
createLineCommand("task-list", "任务列表", "☑", "task-list")
|
||||||
|
];
|
||||||
|
|
||||||
|
const insertCommands: MarkdownEditorCommand[] = [
|
||||||
|
{
|
||||||
|
id: "table",
|
||||||
|
group: "insert",
|
||||||
|
label: "插入表格",
|
||||||
|
toolbarLabel: "表格",
|
||||||
|
execute: ({ view, openPanel }) => {
|
||||||
|
if (openPanel) {
|
||||||
|
openPanel("table");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertMarkdownTable(view, 3, 3);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "horizontal-rule",
|
||||||
|
group: "insert",
|
||||||
|
label: "插入分隔线",
|
||||||
|
toolbarLabel: "—",
|
||||||
|
execute: ({ view }) =>
|
||||||
|
replaceSelections(view, (_, range, state) =>
|
||||||
|
insertMarkdownBlock("---", range, state)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const headingCommands = Array.from(
|
||||||
|
{ length: 6 },
|
||||||
|
(_, index): MarkdownEditorCommand => {
|
||||||
|
const level = index + 1;
|
||||||
|
return {
|
||||||
|
id: `heading-${level}`,
|
||||||
|
group: "block",
|
||||||
|
label: `${level} 级标题`,
|
||||||
|
toolbarLabel: `H${level}`,
|
||||||
|
execute: ({ view }) =>
|
||||||
|
editSelectedLines(view, (text) =>
|
||||||
|
setMarkdownHeading(text, level)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const markdownEditorCommands: readonly MarkdownEditorCommand[] =
|
||||||
|
[
|
||||||
|
...historyCommands,
|
||||||
|
...headingCommands,
|
||||||
|
...textCommands,
|
||||||
|
...blockCommands,
|
||||||
|
...insertCommands
|
||||||
|
];
|
||||||
|
|
||||||
|
const markdownEditorCommandMap = new Map(
|
||||||
|
markdownEditorCommands.map((command) => [command.id, command])
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getMarkdownEditorCommand(commandId: string) {
|
||||||
|
return markdownEditorCommandMap.get(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeMarkdownEditorCommand(
|
||||||
|
commandId: string,
|
||||||
|
context: EditorCommandContext
|
||||||
|
) {
|
||||||
|
const command = getMarkdownEditorCommand(commandId);
|
||||||
|
if (
|
||||||
|
!command ||
|
||||||
|
(command.isEnabled && !command.isEnabled(context))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void command.execute(context);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
|
||||||
import katexCss from "katex/dist/katex.min.css?inline";
|
|
||||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
|
||||||
import type {
|
|
||||||
PagedDocumentRenderResult,
|
|
||||||
PagedDocumentTimings
|
|
||||||
} from "@md-to-pdf/core";
|
|
||||||
import { Previewer } from "pagedjs";
|
|
||||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
|
||||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
|
||||||
import {
|
|
||||||
fitOversizedMermaidToPage,
|
|
||||||
getPageContentDimensions
|
|
||||||
} from "./mermaid-page-fit";
|
|
||||||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
|
||||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
|
||||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
|
||||||
import { enablePrintMediaForPreview } from "./preview-styles";
|
|
||||||
import "./paged-table-handler";
|
|
||||||
import {
|
|
||||||
buildPagedMediaCss,
|
|
||||||
documentBaseCss,
|
|
||||||
documentGeometryCss,
|
|
||||||
formatPageNumber,
|
|
||||||
type PagedPreviewPayload
|
|
||||||
} from "./paged-preview";
|
|
||||||
import type { PagedRenderTarget } from "./paged-render-target";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
PagedDocumentRenderResult,
|
|
||||||
PagedDocumentTimings
|
|
||||||
} from "@md-to-pdf/core";
|
|
||||||
|
|
||||||
export interface PagedDocumentRenderOptions {
|
|
||||||
target: PagedRenderTarget;
|
|
||||||
shouldContinue?: () => boolean;
|
|
||||||
mermaidOutput?: MermaidOutputMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForImages(container: ParentNode) {
|
|
||||||
const images = Array.from(container.querySelectorAll("img"));
|
|
||||||
return Promise.all(
|
|
||||||
images.map(async (image) => {
|
|
||||||
if (image.complete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await image.decode();
|
|
||||||
} catch {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
image.addEventListener("load", () => resolve(), { once: true });
|
|
||||||
image.addEventListener("error", () => resolve(), { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stylesheet(
|
|
||||||
documentRef: Document,
|
|
||||||
css: string,
|
|
||||||
name: string
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
[`${documentRef.location.href}#${name}`]: css
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountMeasurementContainer(
|
|
||||||
documentRef: Document,
|
|
||||||
content: DocumentFragment,
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
target: PagedRenderTarget
|
|
||||||
) {
|
|
||||||
const pageContent = getPageContentDimensions(payload.exportConfig);
|
|
||||||
const style = documentRef.createElement("style");
|
|
||||||
const themeCss =
|
|
||||||
target === "preview"
|
|
||||||
? enablePrintMediaForPreview(payload.themeCss)
|
|
||||||
: payload.themeCss;
|
|
||||||
style.dataset.pagedMeasurementStyle = "true";
|
|
||||||
style.textContent = [
|
|
||||||
documentBaseCss,
|
|
||||||
highlightCss,
|
|
||||||
katexCss,
|
|
||||||
echartsCss,
|
|
||||||
themeCss,
|
|
||||||
documentGeometryCss
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const host = documentRef.createElement("div");
|
|
||||||
host.dataset.pagedMeasurementHost = "true";
|
|
||||||
host.style.cssText = [
|
|
||||||
"position: fixed",
|
|
||||||
"left: -100000px",
|
|
||||||
"top: 0",
|
|
||||||
`width: ${pageContent.width}px`,
|
|
||||||
"height: auto",
|
|
||||||
"opacity: 0",
|
|
||||||
"pointer-events: none",
|
|
||||||
"contain: layout style paint"
|
|
||||||
].join(";");
|
|
||||||
|
|
||||||
documentRef.head.append(style);
|
|
||||||
host.append(content);
|
|
||||||
documentRef.body.append(host);
|
|
||||||
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
restore() {
|
|
||||||
content.append(...Array.from(host.childNodes));
|
|
||||||
host.remove();
|
|
||||||
style.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPageNumbers(
|
|
||||||
container: ParentNode,
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
totalPages: number
|
|
||||||
) {
|
|
||||||
if (!payload.exportConfig.footer.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alignment = payload.exportConfig.footer.alignment;
|
|
||||||
const pages = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [pageIndex, page] of pages.entries()) {
|
|
||||||
const content = page.querySelector<HTMLElement>(
|
|
||||||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
|
||||||
);
|
|
||||||
if (!content) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
content.textContent = formatPageNumber(
|
|
||||||
payload.exportConfig.footer,
|
|
||||||
pageIndex,
|
|
||||||
totalPages
|
|
||||||
);
|
|
||||||
content.setAttribute("data-page-number-rendered", "true");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTrailingPdfPageBreak(container: ParentNode) {
|
|
||||||
const pages = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
|
||||||
);
|
|
||||||
pages
|
|
||||||
.at(-1)
|
|
||||||
?.style.setProperty("break-after", "auto", "important");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PagedDocumentRuntime {
|
|
||||||
private activePreviewer: Previewer | undefined;
|
|
||||||
private mermaidRenderSequence = 0;
|
|
||||||
private mermaidPromise:
|
|
||||||
| Promise<(typeof import("mermaid"))["default"]>
|
|
||||||
| undefined;
|
|
||||||
private echartsPromise:
|
|
||||||
| Promise<
|
|
||||||
typeof import("@md-to-pdf/markdown-echarts/browser")
|
|
||||||
>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
constructor(private readonly root: HTMLElement) {}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (!this.activePreviewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activePreviewer.chunker.destroy();
|
|
||||||
this.activePreviewer.polisher.destroy();
|
|
||||||
this.activePreviewer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadMermaid() {
|
|
||||||
if (!this.mermaidPromise) {
|
|
||||||
this.mermaidPromise = Promise.all([
|
|
||||||
import("mermaid"),
|
|
||||||
import("@mermaid-js/layout-elk")
|
|
||||||
]).then(
|
|
||||||
([{ default: mermaid }, { default: elkLayouts }]) => {
|
|
||||||
mermaid.registerLayoutLoaders(elkLayouts);
|
|
||||||
return mermaid;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.mermaidPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadECharts() {
|
|
||||||
if (!this.echartsPromise) {
|
|
||||||
this.echartsPromise = import(
|
|
||||||
"@md-to-pdf/markdown-echarts/browser"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.echartsPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderECharts(
|
|
||||||
container: ParentNode,
|
|
||||||
payload: PagedPreviewPayload
|
|
||||||
) {
|
|
||||||
if (!payload.features.includes("echarts")) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { renderEChartsBlocks } = await this.loadECharts();
|
|
||||||
const outcomes = await renderEChartsBlocks(container, {
|
|
||||||
outputMode: "inline-svg",
|
|
||||||
concurrency: 2,
|
|
||||||
timeoutMs: 5_000
|
|
||||||
});
|
|
||||||
|
|
||||||
return outcomes.flatMap((outcome, index) =>
|
|
||||||
outcome.success
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
`图表 ${index + 1}:${
|
|
||||||
outcome.error?.message ?? "未知错误"
|
|
||||||
}`
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderMermaid(
|
|
||||||
container: DocumentFragment,
|
|
||||||
payload: PagedPreviewPayload
|
|
||||||
) {
|
|
||||||
if (!payload.features.includes("mermaid")) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = Array.from(
|
|
||||||
container.querySelectorAll<HTMLElement>(
|
|
||||||
".mermaid[data-mermaid-pending]"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mermaid = await this.loadMermaid();
|
|
||||||
mermaid.initialize(
|
|
||||||
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
|
||||||
);
|
|
||||||
const outcomes = await renderMermaidDefinitions(
|
|
||||||
nodes.map((node) => node.textContent ?? ""),
|
|
||||||
async (definition) => {
|
|
||||||
this.mermaidRenderSequence += 1;
|
|
||||||
return mermaid.render(
|
|
||||||
`mermaid-paged-document-${this.mermaidRenderSequence}`,
|
|
||||||
definition
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
for (const [index, outcome] of outcomes.entries()) {
|
|
||||||
const node = nodes[index];
|
|
||||||
if (!node) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.removeAttribute("data-mermaid-pending");
|
|
||||||
if (outcome.success) {
|
|
||||||
node.innerHTML = outcome.svg;
|
|
||||||
outcome.bindFunctions?.(node);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
outcome.error instanceof Error
|
|
||||||
? outcome.error.message
|
|
||||||
: "未知错误";
|
|
||||||
node.classList.add("mermaid-error");
|
|
||||||
node.textContent =
|
|
||||||
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
|
||||||
errors.push(`图表 ${index + 1}:${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
async render(
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
options: PagedDocumentRenderOptions
|
|
||||||
): Promise<PagedDocumentRenderResult | undefined> {
|
|
||||||
const totalStartedAt = performance.now();
|
|
||||||
const shouldContinue = options.shouldContinue ?? (() => true);
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const setupStartedAt = performance.now();
|
|
||||||
const documentRef = this.root.ownerDocument;
|
|
||||||
this.destroy();
|
|
||||||
this.root.replaceChildren();
|
|
||||||
documentRef.documentElement.dataset.renderTarget = options.target;
|
|
||||||
documentRef.documentElement.lang =
|
|
||||||
payload.metadata.language || "zh-CN";
|
|
||||||
documentRef.title =
|
|
||||||
payload.metadata.title || "Markdown 分页文档";
|
|
||||||
|
|
||||||
const template = documentRef.createElement("template");
|
|
||||||
template.innerHTML = payload.articleHtml;
|
|
||||||
const content = template.content;
|
|
||||||
const setupMs = performance.now() - setupStartedAt;
|
|
||||||
|
|
||||||
const mermaidStartedAt = performance.now();
|
|
||||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
|
||||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
|
||||||
|
|
||||||
let echartsErrors: string[] = [];
|
|
||||||
let echartsMs = 0;
|
|
||||||
let echartsFitMs = 0;
|
|
||||||
let mermaidFitMs = 0;
|
|
||||||
let mermaidConversionMs = 0;
|
|
||||||
if (
|
|
||||||
payload.features.includes("echarts") ||
|
|
||||||
content.querySelector(".mermaid svg")
|
|
||||||
) {
|
|
||||||
const measurement = mountMeasurementContainer(
|
|
||||||
documentRef,
|
|
||||||
content,
|
|
||||||
payload,
|
|
||||||
options.target
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await documentRef.fonts.ready;
|
|
||||||
|
|
||||||
const echartsStartedAt = performance.now();
|
|
||||||
echartsErrors = await this.renderECharts(
|
|
||||||
measurement.host,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
echartsMs = performance.now() - echartsStartedAt;
|
|
||||||
await waitForImages(measurement.host);
|
|
||||||
|
|
||||||
const echartsFitStartedAt = performance.now();
|
|
||||||
fitOversizedEChartsToPage(
|
|
||||||
measurement.host,
|
|
||||||
payload.exportConfig
|
|
||||||
);
|
|
||||||
echartsFitMs =
|
|
||||||
performance.now() - echartsFitStartedAt;
|
|
||||||
|
|
||||||
const mermaidFitStartedAt = performance.now();
|
|
||||||
fitOversizedMermaidToPage(
|
|
||||||
measurement.host,
|
|
||||||
payload.exportConfig
|
|
||||||
);
|
|
||||||
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
|
||||||
|
|
||||||
const mermaidConversionStartedAt = performance.now();
|
|
||||||
if (options.mermaidOutput === "svg-image") {
|
|
||||||
replaceMermaidSvgWithImages(measurement.host);
|
|
||||||
await waitForImages(measurement.host);
|
|
||||||
}
|
|
||||||
mermaidConversionMs =
|
|
||||||
performance.now() - mermaidConversionStartedAt;
|
|
||||||
} finally {
|
|
||||||
measurement.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceWaitStartedAt = performance.now();
|
|
||||||
await documentRef.fonts.ready;
|
|
||||||
await waitForImages(content);
|
|
||||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
|
||||||
|
|
||||||
const paginationStartedAt = performance.now();
|
|
||||||
const previewer = new Previewer();
|
|
||||||
this.activePreviewer = previewer;
|
|
||||||
const flow = await previewer.preview(
|
|
||||||
content,
|
|
||||||
[
|
|
||||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
|
||||||
stylesheet(documentRef, highlightCss, "highlight"),
|
|
||||||
stylesheet(documentRef, katexCss, "katex"),
|
|
||||||
stylesheet(documentRef, echartsCss, "echarts"),
|
|
||||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
|
||||||
stylesheet(
|
|
||||||
documentRef,
|
|
||||||
documentGeometryCss,
|
|
||||||
"document-geometry"
|
|
||||||
),
|
|
||||||
stylesheet(
|
|
||||||
documentRef,
|
|
||||||
buildPagedMediaCss(payload.exportConfig, payload),
|
|
||||||
"paged-media"
|
|
||||||
)
|
|
||||||
],
|
|
||||||
this.root
|
|
||||||
);
|
|
||||||
const paginationMs = performance.now() - paginationStartedAt;
|
|
||||||
|
|
||||||
if (!shouldContinue()) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalizeStartedAt = performance.now();
|
|
||||||
applyPageNumbers(this.root, payload, flow.total);
|
|
||||||
if (options.target === "pdf") {
|
|
||||||
removeTrailingPdfPageBreak(this.root);
|
|
||||||
}
|
|
||||||
const finalizeMs = performance.now() - finalizeStartedAt;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageCount: flow.total,
|
|
||||||
echartsErrors,
|
|
||||||
mermaidErrors,
|
|
||||||
timings: {
|
|
||||||
setupMs,
|
|
||||||
echartsMs,
|
|
||||||
echartsFitMs,
|
|
||||||
mermaidMs,
|
|
||||||
mermaidFitMs,
|
|
||||||
mermaidConversionMs,
|
|
||||||
resourceWaitMs,
|
|
||||||
paginationMs,
|
|
||||||
finalizeMs,
|
|
||||||
totalMs: performance.now() - totalStartedAt
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__mdToPdfRender?: (
|
|
||||||
payload: PagedPreviewPayload,
|
|
||||||
target?: PagedRenderTarget
|
|
||||||
) => Promise<PagedDocumentRenderResult>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import { PagedDocumentRuntime } from "./paged-document-runtime";
|
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||||
import {
|
import {
|
||||||
isPagedPreviewRenderRequest,
|
isPagedPreviewRenderRequest,
|
||||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
PagedDocumentRuntime,
|
||||||
|
SHARED_HIGHLIGHT_CSS,
|
||||||
|
renderDocxMediaCapturePlan,
|
||||||
|
resolveMermaidOutputMode,
|
||||||
|
resolvePagedRenderTarget,
|
||||||
type PagedPreviewFrameMessage,
|
type PagedPreviewFrameMessage,
|
||||||
type PagedPreviewPayload
|
type PagedPreviewPayload
|
||||||
} from "./paged-preview";
|
} from "@md-to-pdf/preview-engine";
|
||||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
import katexCss from "katex/dist/katex.min.css?inline";
|
||||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
|
||||||
import {
|
import {
|
||||||
beginPreviewFrameRender,
|
beginPreviewFrameRender,
|
||||||
completePreviewFrameRender
|
completePreviewFrameRender
|
||||||
} from "./preview-frame-visibility";
|
} from "./preview-frame-visibility";
|
||||||
|
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
|
||||||
|
|
||||||
function getPreviewRoot() {
|
function getPreviewRoot() {
|
||||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||||
@@ -23,7 +28,11 @@ function getPreviewRoot() {
|
|||||||
const previewRoot = getPreviewRoot();
|
const previewRoot = getPreviewRoot();
|
||||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||||
const runtime = new PagedDocumentRuntime(previewRoot);
|
const runtime = new PagedDocumentRuntime(previewRoot, {
|
||||||
|
highlightCss: SHARED_HIGHLIGHT_CSS,
|
||||||
|
katexCss,
|
||||||
|
echartsCss
|
||||||
|
});
|
||||||
document.documentElement.dataset.renderTarget = renderTarget;
|
document.documentElement.dataset.renderTarget = renderTarget;
|
||||||
|
|
||||||
let latestRequestId = 0;
|
let latestRequestId = 0;
|
||||||
@@ -33,9 +42,47 @@ function post(message: PagedPreviewFrameMessage) {
|
|||||||
window.parent.postMessage(message, window.location.origin);
|
window.parent.postMessage(message, window.location.origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function measurePreviewContentHeight() {
|
||||||
|
const rootRect = previewRoot.getBoundingClientRect();
|
||||||
|
const documentRect =
|
||||||
|
document.documentElement.getBoundingClientRect();
|
||||||
|
return Math.ceil(
|
||||||
|
Math.max(
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.scrollHeight,
|
||||||
|
previewRoot.scrollHeight,
|
||||||
|
rootRect.bottom - documentRect.top
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const target =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest<HTMLAnchorElement>("a[href]")
|
||||||
|
: undefined;
|
||||||
|
const href = target?.getAttribute("href")?.trim();
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
post({
|
||||||
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
|
type: "link",
|
||||||
|
href
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ capture: true }
|
||||||
|
);
|
||||||
|
|
||||||
async function renderPagedPreview(
|
async function renderPagedPreview(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
payload: PagedPreviewPayload
|
payload: PagedPreviewPayload,
|
||||||
|
layout: "paged" | "continuous"
|
||||||
) {
|
) {
|
||||||
if (requestId !== latestRequestId) {
|
if (requestId !== latestRequestId) {
|
||||||
return;
|
return;
|
||||||
@@ -45,11 +92,17 @@ async function renderPagedPreview(
|
|||||||
post({
|
post({
|
||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendering",
|
type: "rendering",
|
||||||
requestId
|
requestId,
|
||||||
|
layout
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runtime.render(payload, {
|
const result =
|
||||||
|
layout === "continuous"
|
||||||
|
? await runtime.renderContinuous(payload, {
|
||||||
|
shouldContinue: () => requestId === latestRequestId
|
||||||
|
})
|
||||||
|
: await runtime.render(payload, {
|
||||||
target: renderTarget,
|
target: renderTarget,
|
||||||
mermaidOutput,
|
mermaidOutput,
|
||||||
shouldContinue: () => requestId === latestRequestId
|
shouldContinue: () => requestId === latestRequestId
|
||||||
@@ -62,7 +115,8 @@ async function renderPagedPreview(
|
|||||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||||
type: "rendered",
|
type: "rendered",
|
||||||
requestId,
|
requestId,
|
||||||
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
layout,
|
||||||
|
contentHeight: measurePreviewContentHeight(),
|
||||||
...result
|
...result
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -85,8 +139,39 @@ window.__mdToPdfRender = async (
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
window.__mdToPdfRenderDocxMedia = (payload, dimensions) =>
|
||||||
|
withHiddenWindowAnimationFrames(window, () =>
|
||||||
|
renderDocxMediaCapturePlan(
|
||||||
|
runtime,
|
||||||
|
previewRoot,
|
||||||
|
payload,
|
||||||
|
dimensions
|
||||||
|
)
|
||||||
|
);
|
||||||
document.documentElement.dataset.runtimeReady = "true";
|
document.documentElement.dataset.runtimeReady = "true";
|
||||||
|
|
||||||
|
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||||
|
if (desktopPdfBridge) {
|
||||||
|
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||||
|
try {
|
||||||
|
const result = await withHiddenWindowAnimationFrames(
|
||||||
|
window,
|
||||||
|
async () => window.__mdToPdfRender?.(payload, "pdf")
|
||||||
|
);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("PDF 分页运行时不可用");
|
||||||
|
}
|
||||||
|
desktopPdfBridge.complete(requestId, result);
|
||||||
|
} catch (reason: unknown) {
|
||||||
|
desktopPdfBridge.fail(
|
||||||
|
requestId,
|
||||||
|
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
desktopPdfBridge.ready();
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||||
if (
|
if (
|
||||||
event.origin !== window.location.origin ||
|
event.origin !== window.location.origin ||
|
||||||
@@ -97,12 +182,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
latestRequestId = Math.max(latestRequestId, event.data.requestId);
|
latestRequestId = Math.max(latestRequestId, event.data.requestId);
|
||||||
const { requestId, payload } = event.data;
|
const { requestId, layout, payload } = event.data;
|
||||||
renderQueue = renderQueue
|
renderQueue = renderQueue
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
try {
|
try {
|
||||||
await renderPagedPreview(requestId, payload);
|
await renderPagedPreview(requestId, payload, layout);
|
||||||
} catch (reason: unknown) {
|
} catch (reason: unknown) {
|
||||||
if (requestId !== latestRequestId) {
|
if (requestId !== latestRequestId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { Handler, registerHandlers } from "pagedjs";
|
|
||||||
import { resolveBreakTokenElement } from "./paged-break-token";
|
|
||||||
|
|
||||||
interface PagedBreakToken {
|
|
||||||
node?: Node;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PagedChunker {
|
|
||||||
source: ParentNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PagedHandlerContext {
|
|
||||||
chunker: PagedChunker;
|
|
||||||
}
|
|
||||||
|
|
||||||
function elementAncestors(
|
|
||||||
element: Element,
|
|
||||||
selector: string
|
|
||||||
) {
|
|
||||||
const ancestors: Element[] = [];
|
|
||||||
let current = element.parentElement;
|
|
||||||
|
|
||||||
while (current) {
|
|
||||||
if (current.matches(selector)) {
|
|
||||||
ancestors.unshift(current);
|
|
||||||
}
|
|
||||||
current = current.parentElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RepeatTableHeadersHandler extends Handler {
|
|
||||||
declare chunker: PagedChunker;
|
|
||||||
private splitTableRefs: string[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
chunker: unknown,
|
|
||||||
polisher: unknown,
|
|
||||||
caller: unknown
|
|
||||||
) {
|
|
||||||
super(chunker, polisher, caller);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterPageLayout(
|
|
||||||
pageElement: HTMLElement,
|
|
||||||
_page: unknown,
|
|
||||||
breakToken?: PagedBreakToken
|
|
||||||
) {
|
|
||||||
this.splitTableRefs = [];
|
|
||||||
const element = resolveBreakTokenElement(breakToken?.node);
|
|
||||||
if (!element) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tables = elementAncestors(element, "table");
|
|
||||||
if (element.matches("table")) {
|
|
||||||
tables.push(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.splitTableRefs = Array.from(
|
|
||||||
new Set(
|
|
||||||
tables
|
|
||||||
.map((table) => table.getAttribute("data-ref"))
|
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const ref of this.splitTableRefs) {
|
|
||||||
const renderedTable =
|
|
||||||
pageElement.querySelector<HTMLElement>(
|
|
||||||
`table[data-ref="${CSS.escape(ref)}"]`
|
|
||||||
);
|
|
||||||
if (!renderedTable?.querySelector("tbody > tr")) {
|
|
||||||
renderedTable?.setAttribute(
|
|
||||||
"data-empty-split-table",
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layout(rendered: HTMLElement) {
|
|
||||||
for (const ref of this.splitTableRefs) {
|
|
||||||
const renderedTable =
|
|
||||||
rendered.querySelector<HTMLTableElement>(
|
|
||||||
`table[data-ref="${CSS.escape(ref)}"]`
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!renderedTable ||
|
|
||||||
renderedTable.hasAttribute("data-repeated-header")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceTable =
|
|
||||||
this.chunker.source.querySelector<HTMLTableElement>(
|
|
||||||
`table[data-ref="${CSS.escape(ref)}"]`
|
|
||||||
);
|
|
||||||
if (!sourceTable) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstChild = renderedTable.firstChild;
|
|
||||||
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
|
|
||||||
renderedTable.insertBefore(
|
|
||||||
colgroup.cloneNode(true),
|
|
||||||
firstChild
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderedTable.querySelector("thead")) {
|
|
||||||
const sourceHeader = sourceTable.querySelector("thead");
|
|
||||||
if (sourceHeader) {
|
|
||||||
renderedTable.insertBefore(
|
|
||||||
sourceHeader.cloneNode(true),
|
|
||||||
renderedTable.firstChild
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderedTable.setAttribute("data-repeated-header", "true");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlerRegistration = globalThis as typeof globalThis & {
|
|
||||||
__mdToPdfTableHandlerRegistered?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!handlerRegistration.__mdToPdfTableHandlerRegistered) {
|
|
||||||
registerHandlers(RepeatTableHeadersHandler);
|
|
||||||
handlerRegistration.__mdToPdfTableHandlerRegistered = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RepeatTableHeadersHandler };
|
|
||||||
Vendored
-41
@@ -1,41 +0,0 @@
|
|||||||
declare module "pagedjs" {
|
|
||||||
export interface PagedFlow {
|
|
||||||
total: number;
|
|
||||||
pages: HTMLElement[];
|
|
||||||
performance: number;
|
|
||||||
size: {
|
|
||||||
width: { value: number; unit: string };
|
|
||||||
height: { value: number; unit: string };
|
|
||||||
format?: string;
|
|
||||||
orientation?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PagedStylesheet = string | Record<string, string>;
|
|
||||||
|
|
||||||
export class Previewer {
|
|
||||||
chunker: {
|
|
||||||
destroy(): void;
|
|
||||||
};
|
|
||||||
polisher: {
|
|
||||||
destroy(): void;
|
|
||||||
};
|
|
||||||
preview(
|
|
||||||
content?: HTMLElement | DocumentFragment | string,
|
|
||||||
stylesheets?: PagedStylesheet[],
|
|
||||||
renderTo?: HTMLElement | string
|
|
||||||
): Promise<PagedFlow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Handler {
|
|
||||||
constructor(
|
|
||||||
chunker: unknown,
|
|
||||||
polisher: unknown,
|
|
||||||
caller: unknown
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerHandlers(
|
|
||||||
...handlers: Array<typeof Handler>
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user