50 Commits
Author SHA1 Message Date
SkyJourney 842c3f1782 release: 发布墨呈 v0.6.0
新增能力:实现可编辑 DOCX 导出、通用主题样式翻译、Markdown 基础工具栏、统一导出菜单与导出进度提示;14 套主题支持纸张、页边距、字体、段落、代码、图片、图表、封面、页眉页脚和页码映射。

问题修复:修复精确预览放大后的右侧留白、保存与导出默认路径、Desktop DOCX 媒体捕获超时、品牌改名后的安装目录迁移,并统一主题展示名称。

兼容与部署:产品更名为墨呈/MorphDoc,保留 NSIS 安装身份;Desktop 内置 Pandoc 3.9.0.2,字体包支持安装向导可选与独立分发;Docker 内置 Chromium、Pandoc 和正式字体,支持离线部署。

发布规则:新增 release/v<version> 唯一产物目录、Docker 镜像 tar.gz、Web 部署 ZIP、manifest.json 和 SHA256SUMS.txt;正式字体迁入版本化仓库目录并由 Git LFS 管理。

验证结果:全项目测试、类型检查、生产构建、git diff --check、14 主题 DOCX 与视觉矩阵、Web API、Desktop 安装版、Word/WPS 可编辑性和字体门禁全部通过;离线镜像经 docker load 复验且容器保持 healthy。

发布产物:Desktop 安装器与 ZIP、字体安装器与 ZIP、Web 镜像压缩包、离线部署包、发行说明和校验清单已整理至本地 release/v0.6.0。Windows EXE 当前未进行 Authenticode 签名,仅按公司内部发布风险规则分发。
2026-08-02 15:54:27 +08:00
SkyJourney 3e89e3faff fix: 修复品牌改名后的安装目录迁移 2026-08-02 14:47:13 +08:00
SkyJourney 8f4d16a9b7 chore: 统一内置主题展示名称 2026-08-02 13:54:18 +08:00
SkyJourney 5c82b3df7f fix: 修复桌面 DOCX 媒体捕获超时 2026-08-02 13:50:25 +08:00
SkyJourney 14f470454c feat: 完善字体包发行与 Docker 内置字体 2026-08-02 11:33:27 +08:00
SkyJourney 3a8d2c1c2e feat: 内置 Desktop Pandoc 最小运行时 2026-08-02 10:44:40 +08:00
SkyJourney 9a2aa3c341 feat: 完成 v0.6.0 墨呈品牌迁移 2026-08-02 10:09:14 +08:00
SkyJourney 58f87cc19f feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
2026-08-02 03:27:11 +08:00
SkyJourney f9f5fccfc9 fix: 修复桌面字体转换依赖启动崩溃 2026-08-01 11:12:30 +08:00
SkyJourney 3081c02887 feat: 完成 Docker DOCX 与可选字体包部署链 2026-08-01 08:56:20 +08:00
SkyJourney 7f2c169405 feat: 优化桌面文件默认保存路径 2026-08-01 04:09:21 +08:00
SkyJourney dc026835b7 fix: 修复精确预览放大后右侧留白丢失 2026-07-31 23:42:04 +08:00
SkyJourney 6f62cfebd6 feat: 实现 Windows 可选字体包安装链 2026-07-31 22:29:23 +08:00
SkyJourney ae9fde3ac6 feat: 建立可复现字体包构建链 2026-07-31 21:52:36 +08:00
SkyJourney 9f96d51922 feat: 接入可选字体包同源渲染链 2026-07-31 21:15:45 +08:00
SkyJourney c76454be9d feat: 建立可选字体包协议与安全注册器 2026-07-31 20:36:22 +08:00
SkyJourney e5da699ea3 feat: 完成 DOCX 视觉门禁与字体兼容映射 2026-07-31 20:13:19 +08:00
SkyJourney 10bc62cee4 feat: 实现 DOCX 可编辑字体嵌入 2026-07-31 16:18:30 +08:00
SkyJourney f3847f3e4b feat: 完成 DOCX 通用结构映射与严格收口 2026-07-31 15:19:34 +08:00
SkyJourney 6e40374200 feat: 将主题令牌接入 DOCX 动态模板 2026-07-31 08:43:26 +08:00
SkyJourney 459a079f3b feat: 建立统一语义文档模型 2026-07-30 23:47:51 +08:00
SkyJourney 7f72258126 feat: 实现 DOCX 主题令牌归一化 2026-07-30 22:57:46 +08:00
SkyJourney d2acc9c5ce feat: 实现 DOCX 主题样式采集 2026-07-30 22:09:30 +08:00
SkyJourney 6086762b4f feat: 建立 DOCX 标准样式探针 2026-07-30 21:27:29 +08:00
SkyJourney 6fed666f44 feat: 建立 DOCX 主题映射引擎 2026-07-30 20:28:47 +08:00
SkyJourney e7c688df6e test: 完成 DOCX 全主题视觉验收 2026-07-30 19:04:12 +08:00
SkyJourney aff2830c28 test: 建立 DOCX 自动化验收门禁 2026-07-30 16:48:41 +08:00
SkyJourney f2dfc6ef72 feat: 实现 Web 与 Desktop DOCX 导出交互 2026-07-30 16:05:45 +08:00
SkyJourney bf43433d38 feat: 实现 Pandoc DOCX 转换服务 2026-07-30 15:05:50 +08:00
SkyJourney fa159d916f feat: 实现动态 DOCX 模板与样式映射 2026-07-30 14:10:43 +08:00
SkyJourney fe5d67fb6d feat: 实现 DOCX 媒体预处理与跨端 PNG 捕获 2026-07-30 13:21:18 +08:00
SkyJourney 7831bc23f0 feat: 升级 CodeMirror 编辑器并新增 Markdown 工具栏 2026-07-30 12:39:35 +08:00
SkyJourney f7d4efeafc feat: 建立 DOCX 共享协议与准备服务 2026-07-30 11:04:18 +08:00
SkyJourney fb3ddada39 docs: 固化 Pandoc 分发与内部许可策略 2026-07-30 10:49:21 +08:00
SkyJourney 81d9099eae docs: 冻结 v0.6.0 架构与验收边界 2026-07-30 10:23:04 +08:00
SkyJourney 83e6559c2c release: 发布 v0.5.1
新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。

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

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

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

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

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

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

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

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

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

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

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

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

发布产物:Setup.exe SHA-256 9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D;ZIP SHA-256 97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850;当前 Windows 产物未配置代码签名。
2026-07-28 13:31:42 +08:00
SkyJourney ac54ce46be chore: 精简桌面端语言包 2026-07-27 23:16:47 +08:00
SkyJourney 06407650e0 feat: 发布标准 Windows 安装向导 2026-07-27 23:01:22 +08:00
SkyJourney 8d91d08809 fix: 移除桌面端原生菜单栏 2026-07-27 22:23:39 +08:00
SkyJourney 32a37293d0 fix: 完善本地与网络图片渲染 2026-07-27 22:12:16 +08:00
SkyJourney 06b30d084e fix: 固定桌面端英文可执行文件名 2026-07-27 21:05:47 +08:00
SkyJourney 4d60741f5a feat: 发布 v0.4.0 桌面端 2026-07-27 20:56:39 +08:00
SkyJourney 92a5bfd016 feat: 扩展 ECharts 第二阶段图表支持
新增 tree、treemap、sunburst、graph、sankey、chord、funnel、gauge 和 pictorialBar 系列,并注册对应的 ECharts 按需模块。

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

静态渲染全局关闭 ECharts 动画,修复 Treemap 标签冻结在透明动画帧的问题,并完成快速预览、精确 PDF、容器镜像和 147 项全项目测试验证。
2026-07-27 17:47:28 +08:00
SkyJourney f5a46f79f1 feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
2026-07-27 16:05:14 +08:00
SkyJourney 1db21c80fc docs: 记录显示缩放导致的分页差异 2026-07-27 12:50:28 +08:00
SkyJourney ad18149c10 refactor: 收敛渲染链路共享抽象 2026-07-27 00:46:16 +08:00
SkyJourney b459def232 fix: 修复预览与 PDF 渲染边界问题 2026-07-27 00:33:56 +08:00
SkyJourney d93728bb8b feat: 完善 Mermaid 配置与预览缩放 2026-07-26 23:10:22 +08:00
501 changed files with 76867 additions and 1860 deletions
+6
View File
@@ -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
+7
View File
@@ -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
+20 -7
View File
@@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8
- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件; - 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件;
- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式; - 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式;
- 支持主题清单与 CSS 主题扩展; - 支持主题清单与 CSS 主题扩展;
- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeXMermaid - 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeXMermaid 和 ECharts
- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心;
- 使用 Docker Compose 在内网部署; - 使用 Docker Compose 在内网部署;
- 文档只在当前请求中临时处理,不建立文档历史数据库。 - 文档只在当前请求中临时处理,不建立文档历史数据库。
@@ -75,15 +76,19 @@ flowchart LR
- 前端:React、TypeScript、Vite。 - 前端:React、TypeScript、Vite。
- 后端:Node.js、TypeScript、Fastify。 - 后端:Node.js、TypeScript、Fastify。
- 桌面端:Electron、electron-builder、NSIS。
- 应用服务:`packages/application`
- 共享模型:`packages/core` - 共享模型:`packages/core`
- Markdown 渲染:`packages/renderer` - Markdown 渲染:`packages/renderer`
- PDF 引擎:计划使用 Playwright Chromium - 连续预览与分页引擎:`packages/preview-engine`
- ECharts 协议与运行时:`packages/markdown-echarts`
- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。
- Markdownmarkdown-it 及 `@mdit/plugin-*` 插件。 - Markdownmarkdown-it 及 `@mdit/plugin-*` 插件。
- 元数据:gray-matter。 - 元数据:gray-matter。
- 安全过滤:sanitize-html。 - 安全过滤:sanitize-html。
- 代码高亮:highlight.js。 - 代码高亮:highlight.js。
- 数学公式:KaTeX。 - 数学公式:KaTeX。
- 图表:Mermaid。 - 图表:Mermaid、ECharts
- 校验:Zod。 - 校验:Zod。
- 测试:Vitest。 - 测试:Vitest。
- 部署:Docker、Docker Compose。 - 部署:Docker、Docker Compose。
@@ -92,12 +97,15 @@ flowchart LR
```text ```text
apps/web/ 前端编辑、配置与预览界面 apps/web/ 前端编辑、配置与预览界面
apps/server/ HTTP API、主题读取及未来 PDF 服务 apps/server/ HTTP API、主题读取和 Playwright PDF 服务
apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力
packages/application/ 共享应用服务、主题注册和图片资源处理
packages/core/ 导出配置和主题清单等共享模型 packages/core/ 导出配置和主题清单等共享模型
packages/renderer/ Markdown 到安全 HTML 的渲染管线 packages/renderer/ Markdown 到安全 HTML 的渲染管线
themes/ 内置主题及未来可安装主题 packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时
docker/ 容器化配置 packages/preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
tests/ 跨包和端到端测试 themes/ 内置主题
deploy/ Docker、Compose、Nginx 和部署文档
docs/ 进度、架构和部署文档 docs/ 进度、架构和部署文档
``` ```
@@ -186,6 +194,11 @@ chore: 初始化项目骨架
``` ```
- 不修改或重写已有提交,除非用户明确要求。 - 不修改或重写已有提交,除非用户明确要求。
- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀
`release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、
兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。
- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整
记录本版本更新,不得使用只有版本号的标签说明。
## 9. 当前交接入口 ## 9. 当前交接入口
+107
View File
@@ -0,0 +1,107 @@
YIXIONG Tech.ltd 公司内部专属软件许可证
版本 1.02026 年
版权所有 © 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 以明确书面形式作出。
+10
View File
@@ -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.
+163 -74
View File
@@ -1,24 +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 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码和浏览器配置缓存;
- 内置 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 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
DesktopWeb 端可使用 Base64/Data URL 或受限公网图片。
## 渲染架构
```mermaid
flowchart LR
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
B --> C["统一 #write HTML"]
D["主题 CSS 与导出配置"] --> C
C --> E["共享 Preview Engine"]
E --> F["连续预览"]
E --> G["快速分页预览"]
E --> H["Playwright Chromium"]
E --> I["Electron Chromium"]
H --> J["Web PDF"]
I --> K["Desktop PDF"]
```
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
载荷和运行时。
## 项目目录
```text
apps/
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
server/ Fastify API、Playwright PDF 与容器运行入口
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
packages/
core/ 跨端文档、导出配置和主题协议
renderer/ Markdown 到安全 #write HTML 的渲染管线
application/ 共享渲染、主题注册和图片资源应用服务
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
themes/ 内置主题与主题清单
deploy/ Dockerfile、Compose、Nginx 和部署文档
docs/ 项目进度与主题开发说明
scripts/ 主题导入等维护脚本
logos/ 应用图标源文件
.local/themes/ 本地开发和 Compose 自定义主题目录
```
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
提供独立图表能力;`renderer` 消费两者生成安全 HTML`application`
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
各包的目录和用法:
- [application](packages/application/README.md)
- [core](packages/core/README.md)
- [renderer](packages/renderer/README.md)
- [markdown-echarts](packages/markdown-echarts/README.md)
- [preview-engine](packages/preview-engine/README.md)
## 本地开发 ## 本地开发
@@ -31,83 +122,70 @@ npm run dev
默认地址: 默认地址:
- 前端http://localhost:5173 - Webhttp://localhost:5173
- 后端http://localhost:3001 - APIhttp://localhost:3001
- 健康检查:http://localhost:3001/api/health - 健康检查:http://localhost:3001/api/health
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。 桌面端开发:
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。 ```powershell
npm run desktop:dev
## Mermaid 长标签
流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖:
````markdown
```mermaid
---
config:
flowchart:
wrappingWidth: 360
---
flowchart TB
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
``` ```
````
需要自动换行时可以使用 Mermaid Markdown String 桌面端目录包和 Windows 安装包
````markdown ```powershell
```mermaid npm run desktop:package
flowchart LR npm run make -w @md-to-pdf/desktop
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
@@ -116,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)。
+80
View File
@@ -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 Licenseversion 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 历史。若不能取得授权,
公开发布应使用经过清理的历史或从合规快照创建新的公开仓库。上述门禁
只针对公司外部和公开网络发行,不阻止当前公司内部源码共享、部署和
安装包分发。
+107
View File
@@ -0,0 +1,107 @@
# 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.0/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.0-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
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://`
+169
View File
@@ -0,0 +1,169 @@
!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"
!ifdef FONT_PACK_COMPANION_FILE
!ifndef BUILD_UNINSTALLER
!include "FileFunc.nsh"
!include "nsDialogs.nsh"
Var fontPackCompanionAvailable
Var fontPackCompanionCheckbox
Var fontPackCompanionExitCode
Var fontPackCompanionHash
Var fontPackCompanionPath
Var fontPackCompanionSelected
!macro customInit
StrCpy $fontPackCompanionAvailable "0"
StrCpy $fontPackCompanionSelected "0"
StrCpy $fontPackCompanionPath "$EXEDIR\${FONT_PACK_COMPANION_FILE}"
IfFileExists "$fontPackCompanionPath" 0 font_pack_init_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_init_done
StrCpy $fontPackCompanionAvailable "1"
IfSilent font_pack_silent font_pack_interactive
font_pack_interactive:
StrCpy $fontPackCompanionSelected "1"
Goto font_pack_init_done
font_pack_silent:
${GetParameters} $R0
${GetOptions} $R0 "/WITHFONTPACK=" $R1
StrCmp $R1 "1" 0 font_pack_init_done
StrCpy $fontPackCompanionSelected "1"
font_pack_init_done:
!macroend
Function FontPackCompanionPage
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_page_skip
nsDialogs::Create 1018
Pop $R0
StrCmp $R0 "error" 0 +2
Abort
${NSD_CreateLabel} 0 0 100% 28u "检测到经过完整性校验的墨呈可选字体包。安装后,政企公文主题可使用与 PDF 相同的原生中文字体资产。"
Pop $R0
${NSD_CreateCheckbox} 0 38u 100% 14u "安装 ${FONT_PACK_COMPANION_NAME}"
Pop $fontPackCompanionCheckbox
${NSD_Check} $fontPackCompanionCheckbox
nsDialogs::Show
Return
font_pack_page_skip:
Abort
FunctionEnd
Function FontPackCompanionPageLeave
${NSD_GetState} $fontPackCompanionCheckbox $R0
StrCmp $R0 ${BST_CHECKED} 0 font_pack_page_unchecked
StrCpy $fontPackCompanionSelected "1"
Return
font_pack_page_unchecked:
StrCpy $fontPackCompanionSelected "0"
FunctionEnd
!macro customPageAfterChangeDir
Page custom FontPackCompanionPage FontPackCompanionPageLeave
!macroend
!endif
!endif
!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}"
!ifdef FONT_PACK_COMPANION_FILE
StrCmp $fontPackCompanionAvailable "1" 0 font_pack_install_done
StrCmp $fontPackCompanionSelected "1" 0 font_pack_install_done
${StdUtils.HashFile} $fontPackCompanionHash "SHA2-256" "$fontPackCompanionPath"
StrCmp $fontPackCompanionHash "${FONT_PACK_COMPANION_SHA256}" 0 font_pack_integrity_failed
DetailPrint "正在安装可选字体包:${FONT_PACK_COMPANION_NAME}"
ExecWait '"$fontPackCompanionPath" /S' $fontPackCompanionExitCode
StrCmp $fontPackCompanionExitCode "0" font_pack_install_done
DetailPrint "可选字体包安装失败,退出码:$fontPackCompanionExitCode"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "主程序已安装,但可选字体包安装失败(退出码 $fontPackCompanionExitCode)。您可以稍后单独运行字体包安装器。"
Goto font_pack_install_done
font_pack_integrity_failed:
DetailPrint "可选字体包完整性复核失败,已跳过安装。"
IfSilent font_pack_install_done 0
MessageBox MB_ICONEXCLAMATION|MB_OK "可选字体包完整性复核失败,主程序已安装,但字体包已跳过。"
font_pack_install_done:
!endif
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
+93
View File
@@ -0,0 +1,93 @@
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 nsisInclude = process.env.MD_TO_PDF_NSIS_INCLUDE?.trim() ||
"build/installer.nsh";
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: `.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`,
to: `pandoc/${pandocRuntime.version}/windows-x86_64`,
filter: [
"pandoc.exe",
"COPYING.rtf",
"COPYRIGHT.txt",
"runtime-manifest.json"
]
},
{
from: windowsIcon,
to: "app.ico"
},
{
from: "../../logos/desktop/macos/app.icns",
to: "app.icns"
}
],
win: {
target: [
{
target: "nsis",
arch: ["x64"]
},
{
target: "zip",
arch: ["x64"]
}
],
icon: windowsIcon,
executableName: brand.executableName,
artifactName: `${brand.artifactPrefix}-${version}-x86_64.\${ext}`
},
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}`
}
};
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.6.0",
"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",
"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",
"make": "npm run build:embedded-web && npm run prepare:pandoc && npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"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"
}
}
+49
View File
@@ -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,359 @@
import { createHash } from "node:crypto";
import {
access,
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;
}
}
async function readRuntimeManifest() {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const artifact = manifest.artifacts?.find(
(candidate) =>
candidate.platform === "win32" && candidate.architecture === "x64"
);
if (!artifact || artifact.archiveType !== "zip") {
throw new Error("Pandoc 清单缺少 Windows x64 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 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([
normalizedBaseName(artifact.executableRelativePath),
...artifact.licenseFiles.map(normalizedBaseName)
]);
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 } = {}) {
const { manifest, artifact } = await readRuntimeManifest();
const platformDirectory = "windows-x86_64";
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 = extractRequiredFiles(archive, 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)
);
verifyPandocVersion(executablePath, manifest.version);
await smokeTestPandoc(executablePath);
await writeFile(
path.join(temporaryDirectory, "runtime-manifest.json"),
`${JSON.stringify(
{
version: manifest.version,
platform: "win32",
architecture: "x64",
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;
}
const isDirectInvocation =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).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();
}
+90
View File
@@ -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)
});
+84
View File
@@ -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;
}
+5
View File
@@ -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;
+50
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { classifyDocumentLink } from "@md-to-pdf/core";
export type DesktopDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "external";
href: string;
}
| {
type: "local";
filePath: string;
}
| {
type: "ignore";
};
function decodeLocalPath(href: string) {
const pathOnly = href.split(/[?#]/u, 1)[0] ?? "";
try {
return decodeURIComponent(pathOnly);
} catch {
throw new Error("本地链接的 URL 编码无效");
}
}
export function resolveDesktopDocumentLinkAction(
href: string,
markdownFilePath?: string
): DesktopDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return { type: "anchor", href: link.normalizedHref };
}
if (link.kind === "network" || link.kind === "protocol") {
return { type: "external", href: link.normalizedHref };
}
if (link.kind !== "local") {
return { type: "ignore" };
}
if (link.scheme === "file") {
try {
return {
type: "local",
filePath: path.normalize(fileURLToPath(new URL(link.href)))
};
} catch {
throw new Error("file: 链接格式无效");
}
}
const decoded = decodeLocalPath(link.href);
if (path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) {
return { type: "local", filePath: path.normalize(decoded) };
}
if (!markdownFilePath) {
throw new Error("当前文档尚未保存,无法解析相对本地链接");
}
return {
type: "local",
filePath: path.resolve(path.dirname(markdownFilePath), decoded)
};
}
+23
View File
@@ -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)
};
}
+83
View File
@@ -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,138 @@
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 plan = (await withTimeout(
webContents.executeJavaScript(createRenderScript(request), true),
timeouts.renderMs,
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
signal
)) as DocxMediaCapturePlan;
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
webContents.debugger.attach("1.3");
}
try {
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 (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();
}
}
}
}
+342
View File
@@ -0,0 +1,342 @@
import {
BrowserWindow,
ipcMain,
type IpcMainEvent
} from "electron";
import { performance } from "node:perf_hooks";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
import {
createElectronPdfPrintOptions,
isAllowedPdfRuntimeUrl
} from "./pdf-contract.js";
export const DESKTOP_PDF_PARTITION =
"md-to-pdf-desktop-pdf";
export interface ElectronPdfGenerationResult {
pdf: Buffer;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
timings: {
documentRenderMs: number;
pdfPrintMs: number;
totalMs: number;
};
}
export interface ElectronPdfGeneratorOptions {
renderUrl: string;
preloadPath: string;
timeoutMs?: number;
}
interface PendingRender {
requestId: number;
resolve: (result: PagedDocumentRenderResult) => void;
reject: (reason: Error) => void;
}
async function waitWithTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(message)),
timeoutMs
);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export class ElectronPdfGenerator {
private readonly renderUrl: string;
private readonly preloadPath: string;
private readonly timeoutMs: number;
private windowPromise: Promise<BrowserWindow> | undefined;
private renderWindow: BrowserWindow | undefined;
private pendingRender: PendingRender | undefined;
private nextRequestId = 1;
private queue = Promise.resolve();
private closed = false;
constructor(options: ElectronPdfGeneratorOptions) {
this.renderUrl = options.renderUrl;
this.preloadPath = options.preloadPath;
this.timeoutMs = options.timeoutMs ?? 60_000;
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
}
generate(payload: PagedDocumentPayload) {
if (this.closed) {
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
}
const operation = this.queue.then(() => this.generateNow(payload));
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
async close() {
this.closed = true;
ipcMain.removeListener(
PDF_RUNTIME_COMPLETE,
this.handleRenderComplete
);
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
this.pendingRender = undefined;
const windowPromise = this.windowPromise;
this.windowPromise = undefined;
this.renderWindow = undefined;
if (!windowPromise) {
return;
}
try {
const window = await windowPromise;
if (!window.isDestroyed()) {
window.destroy();
}
} catch {
// 创建失败时没有需要关闭的窗口。
}
}
private async generateNow(payload: PagedDocumentPayload) {
const startedAt = performance.now();
const window = await this.getWindow();
const documentRenderStartedAt = performance.now();
const renderResult = await this.requestRender(window, payload);
const documentRenderMs =
performance.now() - documentRenderStartedAt;
await window.webContents.executeJavaScript(
"new Promise((resolve) => setTimeout(resolve, 0))"
);
const pdfPrintStartedAt = performance.now();
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, 50));
let pdf: Buffer;
try {
pdf = await waitWithTimeout(
window.webContents.printToPDF(
createElectronPdfPrintOptions(payload, renderResult)
),
this.timeoutMs,
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
);
} catch (error) {
if (!window.isDestroyed()) {
window.destroy();
}
throw error;
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
return {
pdf,
pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors,
timings: {
documentRenderMs,
pdfPrintMs,
totalMs: performance.now() - startedAt
}
} satisfies ElectronPdfGenerationResult;
}
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
this.windowPromise = undefined;
throw error;
});
}
return this.windowPromise;
}
private async createWindow() {
const window = new BrowserWindow({
show: false,
focusable: false,
opacity: 0,
skipTaskbar: true,
x: -32_000,
y: -32_000,
width: 1280,
height: 900,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: DESKTOP_PDF_PARTITION,
preload: this.preloadPath
}
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
const target = new URL(url);
const renderTarget = new URL(this.renderUrl);
if (
target.origin !== renderTarget.origin ||
target.pathname !== renderTarget.pathname
) {
event.preventDefault();
}
});
window.webContents.on("destroyed", () => {
if (this.windowPromise) {
this.windowPromise = undefined;
}
this.renderWindow = undefined;
this.pendingRender?.reject(
new Error("桌面 PDF 渲染窗口已关闭")
);
this.pendingRender = undefined;
});
window.webContents.session.webRequest.onBeforeRequest(
{ urls: ["*://*/*"] },
(details, callback) => {
callback({
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
});
}
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("桌面 PDF 分页运行时启动超时"));
}, this.timeoutMs);
const handleReady = (event: IpcMainEvent) => {
if (event.sender !== window.webContents) {
return;
}
cleanup();
resolve();
};
const cleanup = () => {
clearTimeout(timeout);
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
};
ipcMain.on(PDF_RUNTIME_READY, handleReady);
});
try {
await Promise.all([window.loadURL(this.renderUrl), ready]);
window.webContents.setZoomFactor(1);
this.renderWindow = window;
return window;
} catch (error) {
window.destroy();
throw error;
}
}
private requestRender(
window: BrowserWindow,
payload: PagedDocumentPayload
) {
if (this.pendingRender) {
throw new Error("桌面 PDF 渲染器状态异常");
}
const requestId = this.nextRequestId++;
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.pendingRender?.requestId === requestId) {
this.pendingRender = undefined;
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
if (!window.isDestroyed()) {
window.destroy();
}
}
}, this.timeoutMs);
this.pendingRender = {
requestId,
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
}
};
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
});
}
private readonly handleRenderComplete = (
event: IpcMainEvent,
requestId: number,
result: PagedDocumentRenderResult
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.resolve(result);
};
private readonly handleRenderFailure = (
event: IpcMainEvent,
requestId: number,
message: string
) => {
const pending = this.pendingRender;
if (
!pending ||
pending.requestId !== requestId ||
event.sender !== this.renderWindow?.webContents
) {
return;
}
this.pendingRender = undefined;
pending.reject(
new Error(
typeof message === "string" ? message : "桌面 PDF 分页失败"
)
);
};
}
+388
View File
@@ -0,0 +1,388 @@
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 getDesktopFontPackDirectory() {
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, "../../..");
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: [getDesktopFontPackDirectory()],
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();
}
});
+96
View File
@@ -0,0 +1,96 @@
import { createHash } from "node:crypto";
import { readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
const markdownExtensions = new Set([".md", ".markdown"]);
export interface OpenedMarkdownDocument {
markdown: string;
fileName: string;
}
export interface MarkdownFileSnapshot {
document: OpenedMarkdownDocument;
filePath: string;
documentKey: string;
contentHash: string;
}
export function createMarkdownContentHash(markdown: string) {
return createHash("sha256").update(markdown, "utf8").digest("hex");
}
export function createMarkdownDocumentKey(filePath: string) {
const normalized = path.normalize(filePath);
return process.platform === "win32"
? normalized.toLocaleLowerCase("en-US")
: normalized;
}
export function isMarkdownFilePath(filePath: string) {
return markdownExtensions.has(path.extname(filePath).toLowerCase());
}
export function findMarkdownFileArgument(
arguments_: string[],
workingDirectory: string
) {
for (const argument of arguments_) {
if (!argument || argument.startsWith("-")) {
continue;
}
const candidate = path.isAbsolute(argument)
? path.normalize(argument)
: path.resolve(workingDirectory, argument);
if (isMarkdownFilePath(candidate)) {
return candidate;
}
}
return undefined;
}
export async function readMarkdownDocument(
filePath: string,
maximumLength: number
): Promise<OpenedMarkdownDocument> {
if (!isMarkdownFilePath(filePath)) {
throw new Error("只允许打开 .md 或 .markdown 文件");
}
if (!Number.isSafeInteger(maximumLength) || maximumLength <= 0) {
throw new Error("Markdown 长度限制无效");
}
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
throw new Error("Markdown 路径不是文件");
}
if (fileStats.size > maximumLength * 4) {
throw new Error("Markdown 文件过大");
}
const markdown = await readFile(filePath, "utf8");
if (markdown.length > maximumLength) {
throw new Error("Markdown 文件过大");
}
return {
markdown,
fileName: path.basename(filePath)
};
}
export async function readMarkdownFileSnapshot(
filePath: string,
maximumLength: number
): Promise<MarkdownFileSnapshot> {
const canonicalPath = await realpath(filePath);
const document = await readMarkdownDocument(
canonicalPath,
maximumLength
);
return {
document,
filePath: canonicalPath,
documentKey: createMarkdownDocumentKey(canonicalPath),
contentHash: createMarkdownContentHash(document.markdown)
};
}
+131
View File
@@ -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;
}
+41
View File
@@ -0,0 +1,41 @@
import {
contextBridge,
ipcRenderer,
type IpcRendererEvent
} from "electron";
import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
import {
PDF_RUNTIME_COMPLETE,
PDF_RUNTIME_FAIL,
PDF_RUNTIME_READY,
PDF_RUNTIME_RENDER
} from "./channels.js";
type RenderListener = (
requestId: number,
payload: PagedDocumentPayload
) => Promise<void> | void;
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
onRender: (listener: RenderListener) => {
const handler = (
_event: IpcRendererEvent,
requestId: number,
payload: PagedDocumentPayload
) => {
void listener(requestId, payload);
};
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
},
complete: (
requestId: number,
result: PagedDocumentRenderResult
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
fail: (requestId: number, message: string) =>
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
});
+164
View File
@@ -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;
}
}
+70
View File
@@ -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: [] };
}
+16
View File
@@ -0,0 +1,16 @@
export interface WindowCloseState {
dirty: boolean;
allowClose: boolean;
closeRequestPending: boolean;
}
export type WindowCloseDecision = "allow" | "request" | "wait";
export function getWindowCloseDecision(
state: WindowCloseState
): WindowCloseDecision {
if (!state.dirty || state.allowClose) {
return "allow";
}
return state.closeRequestPending ? "wait" : "request";
}
+200
View File
@@ -0,0 +1,200 @@
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from "node:fs/promises";
import path from "node:path";
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowState {
version: 1;
bounds: WindowBounds;
maximized: boolean;
}
export interface WindowSize {
width: number;
height: number;
}
const WINDOW_STATE_VERSION = 1;
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function parseWindowBounds(value: unknown): WindowBounds | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const bounds = value as Partial<WindowBounds>;
if (
!isFiniteNumber(bounds.x) ||
!isFiniteNumber(bounds.y) ||
!isFiniteNumber(bounds.width) ||
!isFiniteNumber(bounds.height) ||
bounds.width <= 0 ||
bounds.height <= 0
) {
return undefined;
}
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height)
};
}
export function parseWindowState(value: unknown): WindowState | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const state = value as Partial<WindowState>;
const bounds = parseWindowBounds(state.bounds);
if (
state.version !== WINDOW_STATE_VERSION ||
!bounds ||
typeof state.maximized !== "boolean"
) {
return undefined;
}
return {
version: WINDOW_STATE_VERSION,
bounds,
maximized: state.maximized
};
}
function intersectionArea(left: WindowBounds, right: WindowBounds) {
const width = Math.max(
0,
Math.min(left.x + left.width, right.x + right.width) -
Math.max(left.x, right.x)
);
const height = Math.max(
0,
Math.min(left.y + left.height, right.y + right.height) -
Math.max(left.y, right.y)
);
return width * height;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
function centerBounds(size: WindowSize, workArea: WindowBounds): WindowBounds {
const width = Math.min(
Math.max(1, Math.round(size.width)),
workArea.width
);
const height = Math.min(
Math.max(1, Math.round(size.height)),
workArea.height
);
return {
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + (workArea.height - height) / 2),
width,
height
};
}
export function resolveWindowBounds(
savedBounds: WindowBounds | undefined,
workAreas: WindowBounds[],
primaryWorkArea: WindowBounds,
defaultSize: WindowSize,
minimumSize: WindowSize
): WindowBounds {
if (!savedBounds) {
return centerBounds(defaultSize, primaryWorkArea);
}
let selectedArea = primaryWorkArea;
let selectedIntersection = 0;
for (const workArea of workAreas) {
const area = intersectionArea(savedBounds, workArea);
if (area > selectedIntersection) {
selectedArea = workArea;
selectedIntersection = area;
}
}
if (selectedIntersection === 0) {
return centerBounds(
{
width: Math.min(
Math.max(savedBounds.width, minimumSize.width),
primaryWorkArea.width
),
height: Math.min(
Math.max(savedBounds.height, minimumSize.height),
primaryWorkArea.height
)
},
primaryWorkArea
);
}
const width = Math.min(
Math.max(savedBounds.width, minimumSize.width),
selectedArea.width
);
const height = Math.min(
Math.max(savedBounds.height, minimumSize.height),
selectedArea.height
);
return {
x: clamp(
savedBounds.x,
selectedArea.x,
selectedArea.x + selectedArea.width - width
),
y: clamp(
savedBounds.y,
selectedArea.y,
selectedArea.y + selectedArea.height - height
),
width,
height
};
}
export async function loadWindowState(filePath: string) {
try {
return parseWindowState(
JSON.parse(await readFile(filePath, "utf8")) as unknown
);
} catch {
return undefined;
}
}
export async function saveWindowState(
filePath: string,
state: WindowState
) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8"
);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
}
@@ -0,0 +1,88 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveDesktopDocumentLinkAction } from "../src/desktop-document-link.js";
describe("桌面文档链接动作", () => {
const markdownPath = path.resolve("D:/文档/章节/当前.md");
it("保留锚点并把网络和自定义协议交给系统", () => {
expect(resolveDesktopDocumentLinkAction("#标题")).toEqual({
type: "anchor",
href: "#标题"
});
expect(
resolveDesktopDocumentLinkAction("//example.com/path")
).toEqual({
type: "external",
href: "https://example.com/path"
});
expect(
resolveDesktopDocumentLinkAction("mailto:user@example.com")
).toEqual({
type: "external",
href: "mailto:user@example.com"
});
expect(
resolveDesktopDocumentLinkAction("obsidian://open?vault=docs")
).toEqual({
type: "external",
href: "obsidian://open?vault=docs"
});
});
it("允许相对路径越过 Markdown 目录", () => {
expect(
resolveDesktopDocumentLinkAction(
"../../共享/说明.pdf#page=2",
markdownPath
)
).toEqual({
type: "local",
filePath: path.resolve(
path.dirname(markdownPath),
"../../共享/说明.pdf"
)
});
});
it("解析绝对路径、file URI 和 URL 编码", () => {
const absolutePath = path.resolve("C:/资料/项目说明.md");
expect(
resolveDesktopDocumentLinkAction(absolutePath, markdownPath)
).toEqual({
type: "local",
filePath: path.normalize(absolutePath)
});
expect(
resolveDesktopDocumentLinkAction(
pathToFileURL(absolutePath).href,
markdownPath
)
).toEqual({
type: "local",
filePath: path.normalize(absolutePath)
});
expect(
resolveDesktopDocumentLinkAction(
"../%E5%85%B1%E4%BA%AB/%E8%AF%B4%E6%98%8E.pdf",
markdownPath
)
).toEqual({
type: "local",
filePath: path.resolve(
path.dirname(markdownPath),
"../共享/说明.pdf"
)
});
});
it("拒绝危险链接和无文件基准的相对路径", () => {
expect(
resolveDesktopDocumentLinkAction("javascript:alert(1)")
).toEqual({ type: "ignore" });
expect(() =>
resolveDesktopDocumentLinkAction("../说明.pdf")
).toThrow("尚未保存");
});
});
@@ -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 保存参数无效");
});
});
+107
View File
@@ -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 个媒体(echartsPNG 捕获超过 20000ms"
)
}
)
)
).toMatchObject({
message:
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echartsPNG 捕获超过 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,155 @@
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(
"Page.captureScreenshot",
expect.objectContaining({
format: "png",
clip: expect.objectContaining({ scale: 3.125 })
})
);
expect(detach).toHaveBeenCalledOnce();
});
it("媒体布局永久不返回时报告局部超时", async () => {
const webContents = {
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
debugger: {
isAttached: vi.fn(() => false)
}
} 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(() => new Promise(() => undefined))
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow(
"桌面 DOCX 第 1/1 个媒体(imagePNG 捕获超过 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
);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
createMarkdownContentHash,
createMarkdownDocumentKey,
findMarkdownFileArgument,
isMarkdownFilePath,
readMarkdownDocument,
readMarkdownFileSnapshot
} from "../src/markdown-file.js";
describe("桌面 Markdown 文件参数", () => {
it("识别两种 Markdown 扩展名且忽略大小写", () => {
expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true);
expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true);
expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false);
});
it("从启动参数中提取绝对 Markdown 路径", () => {
expect(
findMarkdownFileArgument(
[
"C:\\Program Files\\md-to-pdf\\md-to-pdf.exe",
"--original-process-start-time=1",
"D:\\文档\\项目周报.md"
],
"C:\\Windows"
)
).toBe(path.normalize("D:\\文档\\项目周报.md"));
});
it("根据第二实例的工作目录解析相对路径", () => {
expect(
findMarkdownFileArgument(
["md-to-pdf.exe", "docs\\readme.markdown"],
"D:\\项目"
)
).toBe(path.resolve("D:\\项目", "docs\\readme.markdown"));
});
it("忽略非 Markdown 文件及 Chromium 参数", () => {
expect(
findMarkdownFileArgument(
["md-to-pdf.exe", "--inspect", "notes.txt"],
"C:\\项目"
)
).toBeUndefined();
});
it("统一读取 Markdown 并限制解码后的字符数", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-markdown-")
);
const filePath = path.join(directory, "示例.md");
try {
await writeFile(filePath, "# 示例", "utf8");
await expect(
readMarkdownDocument(filePath, 20)
).resolves.toEqual({
markdown: "# 示例",
fileName: "示例.md"
});
await expect(
readMarkdownDocument(filePath, 2)
).rejects.toThrow("Markdown 文件过大");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("拒绝非 Markdown 扩展名", async () => {
await expect(
readMarkdownDocument("C:\\docs\\说明.txt", 100)
).rejects.toThrow("只允许打开 .md 或 .markdown 文件");
});
it("使用真实路径和内容哈希创建文件快照", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-snapshot-")
);
const filePath = path.join(directory, "快照.md");
try {
await writeFile(filePath, "# 第一版", "utf8");
const snapshot = await readMarkdownFileSnapshot(filePath, 100);
expect(snapshot.document).toEqual({
markdown: "# 第一版",
fileName: "快照.md"
});
expect(snapshot.filePath).toBe(filePath);
expect(snapshot.documentKey).toBe(
createMarkdownDocumentKey(filePath)
);
expect(snapshot.contentHash).toBe(
createMarkdownContentHash("# 第一版")
);
await writeFile(filePath, "# 第二版", "utf8");
const changed = await readMarkdownFileSnapshot(filePath, 100);
expect(changed.contentHash).not.toBe(snapshot.contentHash);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -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()
);
});
});
+171
View File
@@ -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("字体包资源地址无效");
});
});
+258
View File
@@ -0,0 +1,258 @@
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/application",
"@md-to-pdf/preview-engine",
"@md-to-pdf/web"
];
let previousIndex = -1;
for (const workspace of expectedWorkspaces) {
const workspaceIndex = webBuild.indexOf(workspace);
expect(workspaceIndex).toBeGreaterThan(previousIndex);
previousIndex = workspaceIndex;
}
expect(desktopManifest.scripts?.["build:embedded-web"]).toBe(
"npm --prefix ../.. run build:web-runtime"
);
expect(desktopManifest.scripts?.package).toMatch(
/^npm run build:embedded-web && /
);
expect(desktopManifest.scripts?.make).toMatch(
/^npm run build:embedded-web && /
);
expect(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 package -w @md-to-pdf/desktop"
);
});
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.0",
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 installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
const companionBuild = readFileSync(
`${projectRoot}/scripts/build-desktop-with-font-pack.mjs`,
"utf8"
);
expect(builderConfig).toContain("MD_TO_PDF_NSIS_INCLUDE");
expect(installerInclude).toContain("FONT_PACK_COMPANION_SHA256");
expect(installerInclude).toContain("StdUtils.HashFile");
expect(installerInclude).toContain("/WITHFONTPACK=");
expect(companionBuild).toContain("createDesktopCompanionInclude");
expect(companionBuild).toContain("MD_TO_PDF_NSIS_INCLUDE");
expect(companionBuild).toContain(
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0-x86_64-Setup.exe"
);
expect(companionBuild).toContain(
"MorphDoc-FontPack-mdtp-serif-sc-1.0.0.zip"
);
});
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");
});
});
+165
View File
@@ -0,0 +1,165 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
loadWindowState,
parseWindowState,
resolveWindowBounds,
saveWindowState
} from "../src/window-state.js";
const primaryWorkArea = {
x: 0,
y: 0,
width: 1920,
height: 1040
};
const defaultSize = { width: 1440, height: 960 };
const minimumSize = { width: 1024, height: 720 };
describe("桌面窗口状态", () => {
it("校验并规范化持久化状态", () => {
expect(
parseWindowState({
version: 1,
bounds: {
x: 120.4,
y: 80.6,
width: 1280.2,
height: 800.8
},
maximized: true
})
).toEqual({
version: 1,
bounds: {
x: 120,
y: 81,
width: 1280,
height: 801
},
maximized: true
});
});
it("拒绝损坏或不受支持的状态", () => {
expect(parseWindowState(undefined)).toBeUndefined();
expect(
parseWindowState({
version: 2,
bounds: primaryWorkArea,
maximized: false
})
).toBeUndefined();
expect(
parseWindowState({
version: 1,
bounds: { x: 0, y: 0, width: -1, height: 720 },
maximized: false
})
).toBeUndefined();
});
it("首次启动时在主显示器居中", () => {
expect(
resolveWindowBounds(
undefined,
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 240,
y: 40,
width: 1440,
height: 960
});
});
it("将部分溢出的窗口完整收回原显示器", () => {
expect(
resolveWindowBounds(
{ x: 1700, y: 900, width: 1280, height: 800 },
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 640,
y: 240,
width: 1280,
height: 800
});
});
it("显示器移除后在主显示器居中恢复", () => {
expect(
resolveWindowBounds(
{ x: 3000, y: 120, width: 1200, height: 780 },
[primaryWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 360,
y: 130,
width: 1200,
height: 780
});
});
it("窗口大于工作区时缩小到可完整展示", () => {
const compactWorkArea = {
x: 1920,
y: 0,
width: 1280,
height: 680
};
expect(
resolveWindowBounds(
{ x: 2000, y: -100, width: 1800, height: 1000 },
[primaryWorkArea, compactWorkArea],
primaryWorkArea,
defaultSize,
minimumSize
)
).toEqual({
x: 1920,
y: 0,
width: 1280,
height: 680
});
});
it("通过临时文件原子保存并可重新载入", async () => {
const directory = await mkdtemp(
path.join(os.tmpdir(), "md-to-pdf-window-")
);
const filePath = path.join(directory, "window-state.json");
const state = {
version: 1 as const,
bounds: { x: 10, y: 20, width: 1200, height: 800 },
maximized: false
};
try {
await saveWindowState(filePath, state);
await expect(loadWindowState(filePath)).resolves.toEqual(state);
const updatedState = {
...state,
bounds: { x: 30, y: 40, width: 1280, height: 900 },
maximized: true
};
await saveWindowState(filePath, updatedState);
await expect(loadWindowState(filePath)).resolves.toEqual(
updatedState
);
expect(await readdir(directory)).toEqual(["window-state.json"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"types": [
"node"
],
"noEmit": true
},
"include": [
"src",
"tests"
]
}
+8 -1
View File
@@ -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"
}, },
+301
View File
@@ -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,489 @@
import { createHash } 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 { 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 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();
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 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 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
);
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")
),
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,
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 };
}
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 主题矩阵报告");
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 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 app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter,
fontPacks: {
roots: [fontPackRoot],
appVersion: "0.6.0"
}
});
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) {
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
const samplePath = path.join(samplesDirectory, `${theme.id}.md`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const markdown = fs.readFileSync(samplePath, "utf8");
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: `${theme.id}.md`,
language: "zh-CN",
resources: [],
exportConfig
};
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 样式`
);
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(
standard.metadata?.document?.profile === "official" &&
standard.metadata.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 docxPath = path.join(docxDirectory, `${theme.id}.docx`);
fs.writeFileSync(pdfPath, pdf.content);
fs.writeFileSync(docxPath, docx.content);
results.push({
id: theme.id,
name: theme.name,
category: theme.category,
compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath),
exportConfig,
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,
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
expectsOfficialDualEndedRow
});
}
} finally {
await app.close();
}
const enhancedThemes = results.filter(
(result) => result.docx.inspection.optionalFontPackApplied
);
if (themes.some((theme) => theme.id === "gov-red-standard")) {
assert(
enhancedThemes.some((result) => result.id === "gov-red-standard"),
"有效字体包未增强 gov-red-standard"
);
}
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));
}
+297 -103
View File
@@ -4,11 +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,
defaultExportConfig, defaultExportConfig,
exportConfigSchema, exportConfigSchema,
supportedPaperFormats supportedPaperFormats
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer"; import {
ApplicationRequestError,
DocxExportService,
DocxExportServiceError,
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,
@@ -16,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 {
@@ -29,43 +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 encodeRfc5987(value: string) { function encodeRfc5987(value: string) {
return encodeURIComponent(value).replace( return encodeURIComponent(value).replace(
/[!'()*]/g, /[!'()*]/g,
@@ -99,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) {
@@ -121,6 +112,8 @@ function createPdfServerTiming(
`navigation;dur=${milliseconds(timings.navigationMs)}`, `navigation;dur=${milliseconds(timings.navigationMs)}`,
`document-render;dur=${milliseconds(timings.documentRenderMs)}`, `document-render;dur=${milliseconds(timings.documentRenderMs)}`,
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`, `runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
`echarts;dur=${milliseconds(timings.echartsMs)}`,
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
`mermaid;dur=${milliseconds(timings.mermaidMs)}`, `mermaid;dur=${milliseconds(timings.mermaidMs)}`,
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`, `mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
`mermaid-conversion;dur=${milliseconds( `mermaid-conversion;dur=${milliseconds(
@@ -134,18 +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({
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes")
}); });
const applicationService =
options.applicationService ??
createApplicationService({
bundledRoot: resolve(projectRoot, "themes"),
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
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 () => {
@@ -157,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 () => ({
@@ -168,37 +227,39 @@ export function buildApp(options: BuildAppOptions = {}) {
rendererVersion: RENDERER_VERSION rendererVersion: RENDERER_VERSION
})); }));
app.get("/api/capabilities", async () => ({ app.get("/api/capabilities", async () => {
defaultExportConfig, const docx = await docxExportService.getCapability();
supportedPaperFormats, return {
implemented: [ defaultExportConfig,
"project-skeleton", supportedPaperFormats,
"export-config", docx,
"theme-manifest", implemented: [
"markdown-render", "project-skeleton",
"html-preview", "export-config",
"pdf-export" "theme-manifest",
], "markdown-render",
planned: [] "html-preview",
})); "pdf-export",
"docx-export"
],
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",
@@ -217,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["*"]
); );
@@ -243,20 +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;
} }
return renderMarkdown(validated.markdown, {
...(validated.language ? { language: validated.language } : {})
});
} }
); );
@@ -264,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
); );
@@ -294,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({
@@ -304,23 +397,32 @@ export function buildApp(options: BuildAppOptions = {}) {
} }
const markdownStartedAt = performance.now(); const markdownStartedAt = performance.now();
const rendered = renderMarkdown(validated.markdown, { let rendered;
...(validated.language ? { language: validated.language } : {}) try {
}); rendered = await applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
error: error.code,
message: error.message
});
}
throw error;
}
const markdownMs = performance.now() - markdownStartedAt; const markdownMs = performance.now() - markdownStartedAt;
try { try {
const generated = await pdfGenerator.generate({ const generated = await pdfGenerator.generate(
articleHtml: rendered.articleHtml, createPagedDocumentPayload({
fileName: document: rendered,
typeof request.body.fileName === "string" fileName:
? request.body.fileName typeof request.body.fileName === "string"
: "文档.md", ? request.body.fileName
metadata: rendered.metadata, : "文档.md",
features: rendered.features, themeCss,
themeCss, exportConfig: parsedConfig.data
exportConfig: parsedConfig.data })
}); );
const pdfFileName = createPdfFileName(request.body.fileName); const pdfFileName = createPdfFileName(request.body.fileName);
const requestMs = performance.now() - requestStartedAt; const requestMs = performance.now() - requestStartedAt;
const serverTiming = createPdfServerTiming( const serverTiming = createPdfServerTiming(
@@ -352,6 +454,10 @@ export function buildApp(options: BuildAppOptions = {}) {
.header("cache-control", "no-store") .header("cache-control", "no-store")
.header("server-timing", serverTiming) .header("server-timing", serverTiming)
.header("x-pdf-page-count", String(generated.pageCount)) .header("x-pdf-page-count", String(generated.pageCount))
.header(
"x-echarts-error-count",
String(generated.echartsErrors.length)
)
.header( .header(
"x-mermaid-error-count", "x-mermaid-error-count",
String(generated.mermaidErrors.length) String(generated.mermaidErrors.length)
@@ -389,5 +495,93 @@ 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 },
"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;
} }
+219
View File
@@ -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);
}
+11 -1
View File
@@ -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.0"
}
}
: {})
}); });
async function start() { async function start() {
+110 -78
View File
@@ -1,25 +1,16 @@
import { chromium, type Browser } from "playwright"; import { chromium, type Browser } from "playwright";
import { performance } from "node:perf_hooks"; import { performance } from "node:perf_hooks";
import type { ExportConfig } from "@md-to-pdf/core"; import type {
PagedDocumentPayload,
PagedDocumentRenderResult
} from "@md-to-pdf/core";
export interface PdfRenderPayload { export type PdfRenderPayload = PagedDocumentPayload;
articleHtml: string;
fileName: string;
metadata: {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
};
features: string[];
themeCss: string;
exportConfig: ExportConfig;
}
export interface PdfGenerationResult { export interface PdfGenerationResult {
pdf: Buffer; pdf: Buffer;
pageCount: number; pageCount: number;
echartsErrors: string[];
mermaidErrors: string[]; mermaidErrors: string[];
timings: PdfGenerationTimings; timings: PdfGenerationTimings;
} }
@@ -31,6 +22,8 @@ export interface PdfGenerationTimings {
navigationMs: number; navigationMs: number;
documentRenderMs: number; documentRenderMs: number;
runtimeSetupMs: number; runtimeSetupMs: number;
echartsMs: number;
echartsFitMs: number;
mermaidMs: number; mermaidMs: number;
mermaidFitMs: number; mermaidFitMs: number;
mermaidConversionMs: number; mermaidConversionMs: number;
@@ -62,22 +55,6 @@ export interface PdfEngineRuntimeLimits {
timeoutMs: number; timeoutMs: number;
} }
interface PagedRuntimeResult {
pageCount: number;
contentHeight: number;
mermaidErrors: string[];
timings: {
setupMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
resourceWaitMs: number;
paginationMs: number;
finalizeMs: number;
totalMs: number;
};
}
interface PdfDocumentResult interface PdfDocumentResult
extends Omit<PdfGenerationResult, "timings"> { extends Omit<PdfGenerationResult, "timings"> {
timings: Pick< timings: Pick<
@@ -85,6 +62,8 @@ interface PdfDocumentResult
| "navigationMs" | "navigationMs"
| "documentRenderMs" | "documentRenderMs"
| "runtimeSetupMs" | "runtimeSetupMs"
| "echartsMs"
| "echartsFitMs"
| "mermaidMs" | "mermaidMs"
| "mermaidFitMs" | "mermaidFitMs"
| "mermaidConversionMs" | "mermaidConversionMs"
@@ -232,6 +211,46 @@ export function isAllowedPdfRequestUrl(
return url.origin === renderOrigin; return url.origin === renderOrigin;
} }
async function waitWithPdfTimeout<T>(
operation: Promise<T>,
timeoutMs: number
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new PdfRenderTimeoutError(timeoutMs));
}, timeoutMs);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function closeContextWithin(
context: Awaited<ReturnType<Browser["newContext"]>>,
timeoutMs: number
) {
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
context.close().catch(() => undefined),
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, Math.min(timeoutMs, 5_000));
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
export class PlaywrightPdfGenerator implements PdfGenerator { export class PlaywrightPdfGenerator implements PdfGenerator {
private readonly renderOrigin: string; private readonly renderOrigin: string;
private readonly renderUrl: string; private readonly renderUrl: string;
@@ -284,7 +303,7 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
if (this.closed) { if (this.closed) {
throw new PdfEngineClosedError(); throw new PdfEngineClosedError();
} }
await this.getBrowser(); await waitWithPdfTimeout(this.getBrowser(), this.timeoutMs);
} }
async close() { async close() {
@@ -330,57 +349,67 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
queuedAt: number, queuedAt: number,
queueMs: number queueMs: number
): Promise<PdfGenerationResult> { ): Promise<PdfGenerationResult> {
const browserStartedAt = performance.now(); let context:
const browser = await this.getBrowser(); | Awaited<ReturnType<Browser["newContext"]>>
const browserMs = performance.now() - browserStartedAt; | undefined;
const contextStartedAt = performance.now(); let expired = false;
const context = await browser.newContext({
locale: payload.metadata.language || "zh-CN",
serviceWorkers: "block"
});
const contextMs = performance.now() - contextStartedAt;
let timeout: NodeJS.Timeout | undefined;
try { try {
await context.route("**/*", async (route) => { const operation = (async (): Promise<PdfGenerationResult> => {
if ( const browserStartedAt = performance.now();
isAllowedPdfRequestUrl( const browser = await this.getBrowser();
route.request().url(), const browserMs = performance.now() - browserStartedAt;
this.renderOrigin if (expired) {
) throw new PdfRenderTimeoutError(this.timeoutMs);
) {
await route.continue();
return;
} }
await route.abort("blockedbyclient");
});
const operation = this.renderWithContext(context, payload); const contextStartedAt = performance.now();
const timeoutPromise = new Promise<never>((_, reject) => { const createdContext = await browser.newContext({
timeout = setTimeout(() => { locale: payload.metadata.language || "zh-CN",
reject(new PdfRenderTimeoutError(this.timeoutMs)); serviceWorkers: "block"
}, this.timeoutMs); });
}); context = createdContext;
const contextMs = performance.now() - contextStartedAt;
const generated = await Promise.race([ if (expired) {
operation, await closeContextWithin(createdContext, this.timeoutMs);
timeoutPromise throw new PdfRenderTimeoutError(this.timeoutMs);
]);
return {
...generated,
timings: {
queueMs,
browserMs,
contextMs,
...generated.timings,
totalMs: performance.now() - queuedAt
} }
};
await createdContext.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const generated = await this.renderWithContext(
createdContext,
payload
);
return {
...generated,
timings: {
queueMs,
browserMs,
contextMs,
...generated.timings,
totalMs: performance.now() - queuedAt
}
};
})();
return await waitWithPdfTimeout(operation, this.timeoutMs);
} finally { } finally {
if (timeout) { expired = true;
clearTimeout(timeout); if (context) {
await closeContextWithin(context, this.timeoutMs);
} }
await context.close().catch(() => undefined);
} }
} }
@@ -407,13 +436,13 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
const documentRenderStartedAt = performance.now(); const documentRenderStartedAt = performance.now();
const renderResult = await page.evaluate( const renderResult = await page.evaluate(
async (renderPayload): Promise<PagedRuntimeResult> => { async (renderPayload): Promise<PagedDocumentRenderResult> => {
const renderer = ( const renderer = (
window as typeof window & { window as typeof window & {
__mdToPdfRender?: ( __mdToPdfRender?: (
payload: PdfRenderPayload, payload: PdfRenderPayload,
target: "pdf" target: "pdf"
) => Promise<PagedRuntimeResult>; ) => Promise<PagedDocumentRenderResult>;
} }
).__mdToPdfRender; ).__mdToPdfRender;
if (!renderer) { if (!renderer) {
@@ -445,11 +474,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
return { return {
pdf, pdf,
pageCount: renderResult.pageCount, pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors, mermaidErrors: renderResult.mermaidErrors,
timings: { timings: {
navigationMs, navigationMs,
documentRenderMs, documentRenderMs,
runtimeSetupMs: renderResult.timings.setupMs, runtimeSetupMs: renderResult.timings.setupMs,
echartsMs: renderResult.timings.echartsMs,
echartsFitMs: renderResult.timings.echartsFitMs,
mermaidMs: renderResult.timings.mermaidMs, mermaidMs: renderResult.timings.mermaidMs,
mermaidFitMs: renderResult.timings.mermaidFitMs, mermaidFitMs: renderResult.timings.mermaidFitMs,
mermaidConversionMs: mermaidConversionMs:
@@ -0,0 +1,84 @@
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();
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);
}
-339
View File
@@ -1,339 +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;
}
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"]
): 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);
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) {
async function list() {
const [bundledThemes, localThemes] = await Promise.all([
readThemeRoot(options.bundledRoot, "bundled"),
readThemeRoot(options.localRoot, "local")
]);
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;
}
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,
list
};
}
+226 -1
View File
@@ -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) {
@@ -110,6 +166,20 @@ describe("预览 API", () => {
expect(response.json().error).toBe("INVALID_MARKDOWN"); expect(response.json().error).toBe("INVALID_MARKDOWN");
}); });
it("将无效 Front Matter 映射为文档请求错误", async () => {
app = buildApp({ logger: false });
const response = await app.inject({
method: "POST",
url: "/api/render",
payload: {
markdown: "---\ntitle: [未闭合\n---\n\n# 文档"
}
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("INVALID_FRONT_MATTER");
});
it("返回内置主题 CSS", async () => { it("返回内置主题 CSS", async () => {
app = buildApp(); app = buildApp();
const response = await app.inject({ const response = await app.inject({
@@ -122,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");
@@ -237,6 +344,94 @@ describe("预览 API", () => {
); );
}); });
it("忽略无效本地主题而不影响其他主题", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const validThemeRoot = join(localRoot, "valid-theme");
const invalidThemeRoot = join(localRoot, "invalid-theme");
await mkdir(bundledRoot, { recursive: true });
await mkdir(validThemeRoot, { recursive: true });
await mkdir(invalidThemeRoot, { recursive: true });
await writeFile(
join(validThemeRoot, "theme.json"),
JSON.stringify(localThemeManifest("valid-theme")),
"utf8"
);
await writeFile(
join(validThemeRoot, "theme.css"),
"#write { color: #333; }",
"utf8"
);
const onWarning = vi.fn();
const registry = createThemeRegistry({
bundledRoot,
localRoot,
onWarning
});
await expect(registry.list()).resolves.toEqual([
expect.objectContaining({
manifest: expect.objectContaining({ id: "valid-theme" })
})
]);
expect(onWarning).toHaveBeenCalledWith(
expect.stringContaining("invalid-theme")
);
});
it("缓存主题目录扫描并支持主动失效", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const firstThemeRoot = join(localRoot, "first-theme");
const secondThemeRoot = join(localRoot, "second-theme");
await mkdir(bundledRoot, { recursive: true });
await mkdir(firstThemeRoot, { recursive: true });
await writeFile(
join(firstThemeRoot, "theme.json"),
JSON.stringify(localThemeManifest("first-theme")),
"utf8"
);
await writeFile(
join(firstThemeRoot, "theme.css"),
"#write { color: #333; }",
"utf8"
);
const registry = createThemeRegistry({
bundledRoot,
localRoot,
cacheTtlMs: 60_000
});
await expect(registry.list()).resolves.toHaveLength(1);
await mkdir(secondThemeRoot, { recursive: true });
await writeFile(
join(secondThemeRoot, "theme.json"),
JSON.stringify(localThemeManifest("second-theme")),
"utf8"
);
await writeFile(
join(secondThemeRoot, "theme.css"),
"#write { color: #666; }",
"utf8"
);
await expect(registry.list()).resolves.toHaveLength(1);
registry.invalidate();
await expect(registry.list()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
manifest: expect.objectContaining({ id: "first-theme" })
}),
expect.objectContaining({
manifest: expect.objectContaining({ id: "second-theme" })
})
])
);
});
it("拒绝主题 CSS 导入外部地址", async () => { it("拒绝主题 CSS 导入外部地址", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const bundledRoot = join(temporaryDirectory, "bundled"); const bundledRoot = join(temporaryDirectory, "bundled");
@@ -267,6 +462,7 @@ describe("PDF API", () => {
const generate = vi.fn(async () => ({ const generate = vi.fn(async () => ({
pdf: Buffer.from("%PDF-test"), pdf: Buffer.from("%PDF-test"),
pageCount: 3, pageCount: 3,
echartsErrors: ["图表 2"],
mermaidErrors: ["图表 1"], mermaidErrors: ["图表 1"],
timings: { timings: {
queueMs: 0, queueMs: 0,
@@ -275,6 +471,8 @@ describe("PDF API", () => {
navigationMs: 3, navigationMs: 3,
documentRenderMs: 4, documentRenderMs: 4,
runtimeSetupMs: 0.1, runtimeSetupMs: 0.1,
echartsMs: 0.15,
echartsFitMs: 0.18,
mermaidMs: 0.2, mermaidMs: 0.2,
mermaidFitMs: 0.3, mermaidFitMs: 0.3,
mermaidConversionMs: 0.35, mermaidConversionMs: 0.35,
@@ -313,6 +511,7 @@ describe("PDF API", () => {
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf" "filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
); );
expect(response.headers["x-pdf-page-count"]).toBe("3"); expect(response.headers["x-pdf-page-count"]).toBe("3");
expect(response.headers["x-echarts-error-count"]).toBe("1");
expect(response.headers["x-mermaid-error-count"]).toBe("1"); expect(response.headers["x-mermaid-error-count"]).toBe("1");
expect(response.headers["server-timing"]).toContain( expect(response.headers["server-timing"]).toContain(
"document-render;dur=4.0" "document-render;dur=4.0"
@@ -320,6 +519,9 @@ describe("PDF API", () => {
expect(response.headers["server-timing"]).toContain( expect(response.headers["server-timing"]).toContain(
"pdf-print;dur=5.0" "pdf-print;dur=5.0"
); );
expect(response.headers["server-timing"]).toContain(
"echarts;dur=0.1"
);
expect(response.headers["server-timing"]).toContain( expect(response.headers["server-timing"]).toContain(
"mermaid;dur=0.2" "mermaid;dur=0.2"
); );
@@ -357,6 +559,29 @@ describe("PDF API", () => {
expect(response.json().error).toBe("INVALID_EXPORT_CONFIG"); expect(response.json().error).toBe("INVALID_EXPORT_CONFIG");
}); });
it("在 PDF 生成前拒绝无效 Front Matter", async () => {
const generate = vi.fn();
app = buildApp({
logger: false,
pdfGenerator: {
generate,
close: vi.fn(async () => undefined)
}
});
const response = await app.inject({
method: "POST",
url: "/api/pdf",
payload: {
markdown: "---\ntitle: [未闭合\n---\n",
exportConfig: defaultExportConfig
}
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("INVALID_FRONT_MATTER");
expect(generate).not.toHaveBeenCalled();
});
it("映射 PDF 队列已满错误", async () => { it("映射 PDF 队列已满错误", async () => {
app = buildApp({ app = buildApp({
logger: false, logger: false,
@@ -0,0 +1,96 @@
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.0 部署契约", () => {
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(
'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(
"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.0}"'
);
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
expect(dockerignore).toContain("!output/font-packs/root/**");
});
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"
);
});
});
+261
View File
@@ -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;
});
});
+131
View File
@@ -0,0 +1,131 @@
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),
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(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 媒体服务已关闭"
);
});
});
+55 -3
View File
@@ -29,7 +29,10 @@ function payload(): PdfRenderPayload {
}; };
} }
function fakeBrowser(options: { hangOnGoto?: boolean } = {}) { function fakeBrowser(
options: { hangOnGoto?: boolean; hangOnNewContext?: boolean } = {}
) {
let releaseNewContext: (() => void) | undefined;
const route = vi.fn(); const route = vi.fn();
const closeContext = vi.fn(async () => undefined); const closeContext = vi.fn(async () => undefined);
const closeBrowser = vi.fn(async () => undefined); const closeBrowser = vi.fn(async () => undefined);
@@ -44,10 +47,12 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
waitForFunction: vi.fn(async () => undefined), waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => ({ evaluate: vi.fn(async () => ({
pageCount: 2, pageCount: 2,
contentHeight: 2200, echartsErrors: [],
mermaidErrors: [], mermaidErrors: [],
timings: { timings: {
setupMs: 1, setupMs: 1,
echartsMs: 1.5,
echartsFitMs: 1.75,
mermaidMs: 2, mermaidMs: 2,
mermaidFitMs: 3, mermaidFitMs: 3,
mermaidConversionMs: 3.5, mermaidConversionMs: 3.5,
@@ -65,7 +70,14 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
close: closeContext close: closeContext
}; };
const browser = { const browser = {
newContext: vi.fn(async () => context), newContext: options.hangOnNewContext
? vi.fn(
() =>
new Promise<typeof context>((resolve) => {
releaseNewContext = () => resolve(context);
})
)
: vi.fn(async () => context),
close: closeBrowser, close: closeBrowser,
on: vi.fn() on: vi.fn()
} as unknown as Browser; } as unknown as Browser;
@@ -77,6 +89,9 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
context, context,
page, page,
pdfBuffer, pdfBuffer,
releaseNewContext() {
releaseNewContext?.();
},
route route
}; };
} }
@@ -179,6 +194,8 @@ describe("Playwright PDF 生成器", () => {
navigationMs: expect.any(Number), navigationMs: expect.any(Number),
documentRenderMs: expect.any(Number), documentRenderMs: expect.any(Number),
runtimeSetupMs: 1, runtimeSetupMs: 1,
echartsMs: 1.5,
echartsFitMs: 1.75,
mermaidMs: 2, mermaidMs: 2,
mermaidFitMs: 3, mermaidFitMs: 3,
mermaidConversionMs: 3.5, mermaidConversionMs: 3.5,
@@ -240,4 +257,39 @@ describe("Playwright PDF 生成器", () => {
expect(fake.closeContext).toHaveBeenCalledTimes(1); expect(fake.closeContext).toHaveBeenCalledTimes(1);
await generator.close(); await generator.close();
}); });
it("浏览器启动阶段也受生成超时限制", async () => {
const fake = fakeBrowser();
let releaseBrowser: (() => void) | undefined;
const generator = new PlaywrightPdfGenerator({
launchBrowser: () =>
new Promise<Browser>((resolve) => {
releaseBrowser = () => resolve(fake.browser);
}),
timeoutMs: 10
});
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
PdfRenderTimeoutError
);
releaseBrowser?.();
await new Promise((resolve) => setTimeout(resolve, 0));
await generator.close();
});
it("上下文创建阶段也受生成超时限制并清理迟到上下文", async () => {
const fake = fakeBrowser({ hangOnNewContext: true });
const generator = new PlaywrightPdfGenerator({
launchBrowser: async () => fake.browser,
timeoutMs: 10
});
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
PdfRenderTimeoutError
);
fake.releaseNewContext();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fake.closeContext).toHaveBeenCalledTimes(1);
await generator.close();
});
}); });
@@ -0,0 +1,95 @@
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 = {
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(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
View File
@@ -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>
+7 -2
View File
@@ -10,11 +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/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.12.3",
"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"
+6 -1
View File
@@ -4,9 +4,14 @@
<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>
html[data-rendering="true"] body {
visibility: hidden;
}
</style>
<title>分页预览</title> <title>分页预览</title>
</head> </head>
<body> <body>
+1470 -305
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from "react";
type DocumentActionDialogProps =
| {
type: "new-location";
busy?: false;
onCurrentWindow: () => void;
onNewWindow: () => void;
onCancel: () => void;
}
| {
type: "unsaved";
busy: boolean;
onSave: () => void;
onDiscard: () => void;
onCancel: () => void;
};
export function DocumentActionDialog(
props: DocumentActionDialogProps
) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !props.busy) {
event.preventDefault();
props.onCancel();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [props]);
const isNewLocation = props.type === "new-location";
return (
<div className="document-dialog-layer">
<div className="document-dialog-backdrop" />
<section
className="document-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="document-dialog-title"
aria-describedby="document-dialog-description"
>
<span className="panel-kicker"></span>
<h2 id="document-dialog-title">
{isNewLocation ? "新建 Markdown" : "保存当前修改?"}
</h2>
<p id="document-dialog-description">
{isNewLocation
? "请选择在当前窗口新建,或者保留当前文档并打开一个新窗口。"
: "当前文档包含尚未保存的修改。继续操作前,可以先保存这些内容。"}
</p>
<div className="document-dialog-actions">
<button
type="button"
disabled={props.busy}
onClick={props.onCancel}
>
</button>
{isNewLocation ? (
<>
<button
type="button"
onClick={props.onNewWindow}
>
</button>
<button
type="button"
className="is-primary"
autoFocus
onClick={props.onCurrentWindow}
>
</button>
</>
) : (
<>
<button
type="button"
disabled={props.busy}
onClick={props.onDiscard}
>
</button>
<button
type="button"
className="is-primary"
disabled={props.busy}
autoFocus
onClick={props.onSave}
>
{props.busy ? "正在保存…" : "保存"}
</button>
</>
)}
</div>
</section>
</div>
);
}
+93
View File
@@ -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>
);
}
+428 -17
View File
@@ -1,15 +1,23 @@
import { import {
defaultExportConfig,
getPaperDimensionsMm, getPaperDimensionsMm,
lengthToMillimeters,
paperFormatLabels, paperFormatLabels,
supportedMermaidLayouts,
supportedMermaidLooks,
supportedMermaidThemes,
supportedPaperFormats, supportedPaperFormats,
type ExportConfig, type ExportConfig,
type FooterConfig, type FooterConfig,
type HeaderConfig type HeaderConfig,
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;
@@ -19,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;
} }
@@ -27,12 +38,51 @@ 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: "自定义模板"
}; };
function millimeters(value: string) { const mermaidLayoutLabels: Record<
return Number.parseFloat(value.replace(/mm$/i, "")); MermaidExportConfig["layout"],
} string
> = {
dagre: "Dagre",
elk: "ELK"
};
const mermaidThemeLabels: Record<
MermaidExportConfig["theme"],
string
> = {
default: "Default",
base: "Base(可定制基础主题)",
dark: "Dark",
forest: "Forest",
neutral: "Neutral",
neo: "Neo",
"neo-dark": "Neo Dark",
redux: "Redux",
"redux-dark": "Redux Dark",
"redux-color": "Redux Color",
"redux-dark-color": "Redux Dark Color"
};
const mermaidLookLabels: Record<
MermaidExportConfig["look"],
string
> = {
classic: "Classic",
handDrawn: "Hand-drawn",
neo: "Neo"
};
const mermaidFontFamilies = [
"Segoe UI, Microsoft YaHei, sans-serif",
"Microsoft YaHei, sans-serif",
"Arial, Helvetica, sans-serif",
"Georgia, Times New Roman, serif",
"Consolas, Microsoft YaHei, monospace"
] as const;
function fitMarginPair( function fitMarginPair(
first: number, first: number,
@@ -55,22 +105,28 @@ function LengthInput({
label, label,
value, value,
maximum, maximum,
disabled = false,
precision = 1,
step = 0.1,
onChange onChange
}: LengthInputProps) { }: LengthInputProps) {
const [draft, setDraft] = useState(String(millimeters(value))); const [draft, setDraft] = useState(
String(lengthToMillimeters(value))
);
useEffect(() => { useEffect(() => {
setDraft(String(millimeters(value))); setDraft(String(lengthToMillimeters(value)));
}, [value]); }, [value]);
function commit() { function commit() {
const number = Number.parseFloat(draft); const number = Number.parseFloat(draft);
if (!Number.isFinite(number)) { if (!Number.isFinite(number)) {
setDraft(String(millimeters(value))); setDraft(String(lengthToMillimeters(value)));
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`);
} }
@@ -83,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) => {
@@ -99,6 +156,105 @@ function LengthInput({
); );
} }
function PageDecorationFontInput({
label,
value,
disabled,
onChange
}: {
label: string;
value: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const normalized = draft.trim();
if (
!normalized ||
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
) {
setDraft(value);
return;
}
setDraft(normalized);
onChange(normalized);
}
return (
<label className="setting-field stacked">
<span>{label}</span>
<input
type="text"
maxLength={300}
value={draft}
disabled={disabled}
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</label>
);
}
function MermaidFontInput({
value,
onChange
}: {
value: string;
onChange: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const normalized = draft.trim();
if (!normalized) {
setDraft(value);
return;
}
setDraft(normalized);
onChange(normalized);
}
return (
<label className="setting-field stacked">
<span></span>
<input
type="text"
list="mermaid-font-family-options"
maxLength={300}
value={draft}
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<datalist id="mermaid-font-family-options">
{mermaidFontFamilies.map((fontFamily) => (
<option key={fontFamily} value={fontFamily} />
))}
</datalist>
</label>
);
}
function updateHeaderSlot( function updateHeaderSlot(
header: HeaderConfig, header: HeaderConfig,
slot: "left" | "center" | "right", slot: "left" | "center" | "right",
@@ -115,6 +271,7 @@ function updateHeaderSlot(
export function ExportSettingsDrawer({ export function ExportSettingsDrawer({
config, config,
theme,
onChange, onChange,
onClose, onClose,
onReset onReset
@@ -137,13 +294,13 @@ export function ExportSettingsDrawer({
nextPaper.orientation nextPaper.orientation
); );
const [left, right] = fitMarginPair( const [left, right] = fitMarginPair(
millimeters(nextPaper.margins.left), lengthToMillimeters(nextPaper.margins.left),
millimeters(nextPaper.margins.right), lengthToMillimeters(nextPaper.margins.right),
nextDimensions.width - 1 nextDimensions.width - 1
); );
const [top, bottom] = fitMarginPair( const [top, bottom] = fitMarginPair(
millimeters(nextPaper.margins.top), lengthToMillimeters(nextPaper.margins.top),
millimeters(nextPaper.margins.bottom), lengthToMillimeters(nextPaper.margins.bottom),
nextDimensions.height - 1 nextDimensions.height - 1
); );
@@ -166,6 +323,7 @@ export function ExportSettingsDrawer({
value: string value: string
) { ) {
updatePaper({ updatePaper({
marginMode: "custom",
margins: { margins: {
...margins, ...margins,
[side]: value [side]: value
@@ -173,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
@@ -186,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
@@ -193,6 +365,29 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemePageDecorations() {
onChange({
...config,
pageDecorationsMode: "theme",
header: structuredClone(
theme?.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
)
});
}
function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
onChange({
...config,
mermaid: {
...config.mermaid,
...mermaid
}
});
}
return ( return (
<div className="settings-layer"> <div className="settings-layer">
<button <button
@@ -271,12 +466,42 @@ 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="上"
value={margins.top} value={margins.top}
maximum={ maximum={
dimensions.height - millimeters(margins.bottom) - verticalGap dimensions.height -
lengthToMillimeters(margins.bottom) -
verticalGap
} }
onChange={(value) => updateMargin("top", value)} onChange={(value) => updateMargin("top", value)}
/> />
@@ -284,7 +509,9 @@ export function ExportSettingsDrawer({
label="右" label="右"
value={margins.right} value={margins.right}
maximum={ maximum={
dimensions.width - millimeters(margins.left) - horizontalGap dimensions.width -
lengthToMillimeters(margins.left) -
horizontalGap
} }
onChange={(value) => updateMargin("right", value)} onChange={(value) => updateMargin("right", value)}
/> />
@@ -292,7 +519,9 @@ export function ExportSettingsDrawer({
label="下" label="下"
value={margins.bottom} value={margins.bottom}
maximum={ maximum={
dimensions.height - millimeters(margins.top) - verticalGap dimensions.height -
lengthToMillimeters(margins.top) -
verticalGap
} }
onChange={(value) => updateMargin("bottom", value)} onChange={(value) => updateMargin("bottom", value)}
/> />
@@ -300,11 +529,136 @@ export function ExportSettingsDrawer({
label="左" label="左"
value={margins.left} value={margins.left}
maximum={ maximum={
dimensions.width - millimeters(margins.right) - horizontalGap dimensions.width -
lengthToMillimeters(margins.right) -
horizontalGap
} }
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 className="settings-section">
<h3>Mermaid </h3>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.layout}
onChange={(event) =>
updateMermaid({
layout: event.target
.value as MermaidExportConfig["layout"]
})
}
>
{supportedMermaidLayouts.map((layout) => (
<option key={layout} value={layout}>
{mermaidLayoutLabels[layout]}
</option>
))}
</select>
</label>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.theme}
onChange={(event) =>
updateMermaid({
theme: event.target
.value as MermaidExportConfig["theme"]
})
}
>
{supportedMermaidThemes.map((theme) => (
<option key={theme} value={theme}>
{mermaidThemeLabels[theme]}
</option>
))}
</select>
</label>
<label className="setting-field">
<span></span>
<select
value={config.mermaid.look}
onChange={(event) =>
updateMermaid({
look: event.target
.value as MermaidExportConfig["look"]
})
}
>
{supportedMermaidLooks.map((look) => (
<option key={look} value={look}>
{mermaidLookLabels[look]}
</option>
))}
</select>
</label>
<MermaidFontInput
value={config.mermaid.fontFamily}
onChange={(fontFamily) => updateMermaid({ fontFamily })}
/>
<p className="setting-hint">
Mermaid config frontmatter
styleclassDef themeVariables
Mermaid
</p>
</section>
<section className="settings-section">
<h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "theme"}
onChange={followThemePageDecorations}
/>
</label>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "custom"}
onChange={() =>
onChange({
...config,
pageDecorationsMode: "custom"
})
}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.pageDecorationsMode === "theme"
? theme?.pageDefaults?.header ||
theme?.pageDefaults?.footer
? `当前采用“${theme.name}”推荐的页眉与页码`
: "当前主题未设置推荐值,采用全局默认配置"
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
</p>
{config.pageDecorationsMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemePageDecorations}
>
</button>
) : null}
</section> </section>
<section className="settings-section"> <section className="settings-section">
@@ -324,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 (
@@ -362,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"
@@ -389,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
@@ -438,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">
@@ -458,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"
+53
View File
@@ -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>
);
}
+158
View File
@@ -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>
);
});
+208
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+103 -6
View File
@@ -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;
@@ -32,13 +41,19 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps { export interface PrecisePdfPreviewProps {
blob: Blob; blob: Blob;
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;
} }
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) { function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
@@ -90,11 +105,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({ function PrecisePdfPage({
document, document,
descriptor, descriptor,
renderQueue onExternalLink,
onNavigateToPage,
renderQueue,
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<
@@ -103,10 +122,12 @@ function PrecisePdfPage({
const nearViewport = useNearViewport(containerRef); const nearViewport = useNearViewport(containerRef);
const dimensions = getPdfPageCssDimensions( const dimensions = getPdfPageCssDimensions(
descriptor.width, descriptor.width,
descriptor.height descriptor.height,
zoomPercent / 100
); );
const pageStyle = { const pageStyle = {
width: `${dimensions.width}px`, width: `${dimensions.width}px`,
maxWidth: `${zoomPercent}%`,
aspectRatio: `${descriptor.width} / ${descriptor.height}` aspectRatio: `${descriptor.width} / ${descriptor.height}`
} satisfies CSSProperties; } satisfies CSSProperties;
@@ -153,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;
} }
@@ -166,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");
@@ -207,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,
@@ -224,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 ||
@@ -246,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");
}; };
}, [ }, [
@@ -259,6 +322,8 @@ function PrecisePdfPage({
displayWidth, displayWidth,
document, document,
nearViewport, nearViewport,
onExternalLink,
onNavigateToPage,
renderQueue renderQueue
]); ]);
@@ -277,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"
@@ -293,6 +362,9 @@ function PrecisePdfPage({
export function PrecisePdfPreview({ export function PrecisePdfPreview({
blob, blob,
zoomPercent,
onExternalLink,
onNavigateToPage,
onPageCount onPageCount
}: PrecisePdfPreviewProps) { }: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>(); const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -302,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]);
@@ -373,14 +458,26 @@ 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}
/> />
))} ))}
</div> </div>
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from "react";
import { normalizePreviewPageNumber } from "./preview-page";
interface PreviewPageControlProps {
current: number;
total: number;
onNavigate: (pageNumber: number) => void;
}
export function PreviewPageControl({
current,
total,
onNavigate
}: PreviewPageControlProps) {
const [draft, setDraft] = useState(String(current));
const editingRef = useRef(false);
useEffect(() => {
if (!editingRef.current) {
setDraft(String(current));
}
}, [current, total]);
function commitPageNumber() {
const pageNumber = normalizePreviewPageNumber(draft, current, total);
setDraft(String(pageNumber));
if (pageNumber !== current) {
onNavigate(pageNumber);
}
}
return (
<label className="preview-page-control">
<span></span>
<input
type="number"
inputMode="numeric"
min={1}
max={total}
step={1}
value={draft}
aria-label="跳转页码"
onBlur={() => {
editingRef.current = false;
commitPageNumber();
}}
onChange={(event) => setDraft(event.target.value)}
onFocus={(event) => {
editingRef.current = true;
event.currentTarget.select();
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
<span aria-live="polite">/ {total} </span>
</label>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import {
DEFAULT_PREVIEW_ZOOM,
MAXIMUM_PREVIEW_ZOOM,
MINIMUM_PREVIEW_ZOOM,
normalizePreviewZoom,
parsePreviewZoom,
PREVIEW_ZOOM_SLIDER_STEP
} from "./preview-zoom";
interface PreviewZoomControlProps {
value: number;
onChange: (value: number) => void;
}
export function PreviewZoomControl({
value,
onChange
}: PreviewZoomControlProps) {
const [draft, setDraft] = useState(String(value));
useEffect(() => {
setDraft(String(value));
}, [value]);
function commitDraft() {
const normalized = parsePreviewZoom(draft, value);
setDraft(String(normalized));
onChange(normalized);
}
return (
<div
className="preview-zoom-control"
role="group"
aria-label="预览缩放"
>
<span></span>
<input
className="preview-zoom-slider"
type="range"
aria-label="预览缩放滑块"
min={MINIMUM_PREVIEW_ZOOM}
max={MAXIMUM_PREVIEW_ZOOM}
step={PREVIEW_ZOOM_SLIDER_STEP}
value={value}
onChange={(event) =>
onChange(normalizePreviewZoom(Number(event.target.value)))
}
/>
<label className="preview-zoom-number">
<span className="sr-only"></span>
<input
type="number"
aria-label="预览缩放百分比"
min={MINIMUM_PREVIEW_ZOOM}
max={MAXIMUM_PREVIEW_ZOOM}
step="1"
value={draft}
onBlur={commitDraft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
<span>%</span>
</label>
<button
type="button"
className="preview-zoom-reset"
disabled={value === DEFAULT_PREVIEW_ZOOM}
onClick={() => onChange(DEFAULT_PREVIEW_ZOOM)}
>
100%
</button>
</div>
);
}
+4
View File
@@ -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__;
+2
View File
@@ -0,0 +1,2 @@
export const APP_VERSION = __APP_VERSION__;
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+116
View File
@@ -0,0 +1,116 @@
import type {
DocumentProfileName,
FooterConfig,
HeaderConfig,
PageMargins,
RenderedMarkdownDocument,
ThemeCategory
} from "@md-to-pdf/core";
export interface ThemeSummary {
id: string;
name: string;
version: string;
description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
pageDefaults?: {
margins: PageMargins;
header?: HeaderConfig;
footer?: FooterConfig;
};
bundled: boolean;
source: "bundled" | "local";
}
export interface MarkdownRenderInput {
markdown: string;
language: string;
resources?: MarkdownImageResource[];
}
export interface MarkdownImageResource {
path: string;
data: string;
}
function throwIfAborted(signal?: AbortSignal) {
signal?.throwIfAborted();
}
async function readErrorMessage(
response: Response,
fallback: string
) {
try {
const payload = (await response.json()) as { message?: unknown };
return typeof payload.message === "string"
? payload.message
: fallback;
} catch {
return fallback;
}
}
export async function renderMarkdownDocument(
input: MarkdownRenderInput,
signal?: AbortSignal
) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.renderMarkdown(input);
throwIfAborted(signal);
return result;
}
const response = await fetch("/api/render", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(input),
...(signal ? { signal } : {})
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, "渲染失败"));
}
return response.json() as Promise<RenderedMarkdownDocument>;
}
export async function listThemes(signal?: AbortSignal) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.listThemes();
throwIfAborted(signal);
return result;
}
const response = await fetch("/api/themes", {
...(signal ? { signal } : {})
});
if (!response.ok) {
throw new Error("无法加载主题清单");
}
return response.json() as Promise<{ themes: ThemeSummary[] }>;
}
export async function getThemeCss(
themeId: string,
signal?: AbortSignal
) {
throwIfAborted(signal);
if (window.mdToPdfDesktop) {
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
throwIfAborted(signal);
return result;
}
const response = await fetch(
`/api/themes/${encodeURIComponent(themeId)}/css`,
{ ...(signal ? { signal } : {}) }
);
if (!response.ok) {
throw new Error("无法加载主题");
}
return response.text();
}
+41
View File
@@ -0,0 +1,41 @@
const sampleModules = import.meta.glob<string>(
"../../../samples/themes/*.md",
{
eager: true,
query: "?raw",
import: "default"
}
);
export interface BuiltinThemeSample {
themeId: string;
fileName: string;
markdown: string;
}
function getFileName(modulePath: string) {
return modulePath.split("/").at(-1) ?? "";
}
export const builtinThemeSamples: BuiltinThemeSample[] = Object.entries(
sampleModules
)
.map(([modulePath, markdown]) => {
const fileName = getFileName(modulePath);
return {
themeId: fileName.replace(/\.md$/i, ""),
fileName,
markdown
};
})
.sort((first, second) =>
first.themeId.localeCompare(second.themeId, "en")
);
const builtinThemeSampleMap = new Map(
builtinThemeSamples.map((sample) => [sample.themeId, sample])
);
export function getBuiltinThemeSample(themeId: string) {
return builtinThemeSampleMap.get(themeId);
}
+49
View File
@@ -0,0 +1,49 @@
const tutorialModules = import.meta.glob<string>(
"../../../samples/tutorials/*.md",
{
eager: true,
query: "?raw",
import: "default"
}
);
export interface BuiltinTutorial {
id: string;
name: string;
fileName: string;
themeId: string;
markdown: string;
}
const tutorialDefinitions = [
{
id: "echarts-tutorial",
name: "ECharts 图表教程",
themeId: "typora-github"
},
{
id: "formal-document-tutorial",
name: "公文主题使用教程",
themeId: "typora-github"
}
] as const;
const tutorialMarkdownById = new Map(
Object.entries(tutorialModules).map(([modulePath, markdown]) => {
const fileName = modulePath.split("/").at(-1) ?? "";
return [fileName.replace(/\.md$/i, ""), markdown];
})
);
export const builtinTutorials: BuiltinTutorial[] =
tutorialDefinitions.map((definition) => {
const markdown = tutorialMarkdownById.get(definition.id);
if (!markdown) {
throw new Error(`缺少内置教程:${definition.id}.md`);
}
return {
...definition,
fileName: `${definition.id}.md`,
markdown
};
});
+110
View File
@@ -0,0 +1,110 @@
const fallbackBaseName = "未命名文档";
const windowsReservedName =
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
function stripMarkdownFormatting(value: string) {
return value
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/<\/?[A-Za-z][^>]*>/gu, " ")
.replace(/[`*_~]/gu, "")
.replace(/^\s{0,3}(?:>\s*|[-+*]\s+|\d+[.)]\s+)/u, "")
.trim();
}
export function legalizeMarkdownBaseName(value: string) {
const normalized = stripMarkdownFormatting(value)
.normalize("NFKC")
.replace(/[^\p{L}\p{N}\s_-]+/gu, " ")
.replace(/\s+/gu, " ")
.trim()
.replace(/[ .]+$/u, "");
const shortened = Array.from(normalized).slice(0, 100).join("").trim();
if (!shortened) {
return fallbackBaseName;
}
return windowsReservedName.test(shortened)
? `文档 ${shortened}`
: shortened;
}
export function deriveMarkdownBaseName(markdown: string) {
const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u);
let inFrontMatter = lines[0]?.trim() === "---";
let inFence = false;
let fenceMarker = "";
let fenceLength = 0;
let firstBodyLine = "";
for (let index = inFrontMatter ? 1 : 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const trimmed = line.trim();
if (inFrontMatter) {
if (trimmed === "---" || trimmed === "...") {
inFrontMatter = false;
}
continue;
}
if (inFence) {
const closingFence = trimmed.match(/^(`+|~+)\s*$/u)?.[1];
if (
closingFence?.[0] === fenceMarker &&
closingFence.length >= fenceLength
) {
inFence = false;
fenceMarker = "";
fenceLength = 0;
}
continue;
}
const fence = trimmed.match(/^(`{3,}|~{3,})/u)?.[1];
if (fence) {
if (!inFence) {
inFence = true;
fenceMarker = fence[0] ?? "";
fenceLength = fence.length;
}
continue;
}
if (inFence || !trimmed) {
continue;
}
const heading = line.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/u)?.[1];
if (heading) {
return legalizeMarkdownBaseName(heading);
}
if (!firstBodyLine) {
firstBodyLine = line;
}
}
return legalizeMarkdownBaseName(firstBodyLine);
}
export function createMarkdownFileName(markdown: string) {
return `${deriveMarkdownBaseName(markdown)}.md`;
}
export function ensureMarkdownFileName(fileName: string, markdown: string) {
const trimmed = fileName.trim();
if (!trimmed) {
return createMarkdownFileName(markdown);
}
return /\.(?:md|markdown)$/iu.test(trimmed)
? trimmed
: `${trimmed}.md`;
}
export function downloadMarkdown(markdown: string, fileName: string) {
const url = URL.createObjectURL(
new Blob([markdown], { type: "text/markdown;charset=utf-8" })
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
+39
View File
@@ -0,0 +1,39 @@
import { classifyDocumentLink } from "@md-to-pdf/core";
export type WebDocumentLinkAction =
| {
type: "anchor";
href: string;
}
| {
type: "open";
href: string;
}
| {
type: "ignore";
};
export function resolveWebDocumentLinkAction(
href: string
): WebDocumentLinkAction {
const link = classifyDocumentLink(href);
if (link.kind === "anchor") {
return {
type: "anchor",
href: link.normalizedHref
};
}
if (
link.kind === "network" ||
(link.kind === "protocol" &&
(link.scheme === "mailto" || link.scheme === "tel"))
) {
return {
type: "open",
href: link.normalizedHref
};
}
return { type: "ignore" };
}
+45
View File
@@ -0,0 +1,45 @@
export type DocumentShortcut =
| "new"
| "save"
| "save-as"
| "close";
export interface DocumentShortcutEvent {
key: string;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
shiftKey: boolean;
isComposing: boolean;
repeat: boolean;
}
export function getDocumentShortcut(
event: DocumentShortcutEvent,
desktop: boolean
): DocumentShortcut | undefined {
if (
event.isComposing ||
event.repeat ||
event.altKey ||
(!event.ctrlKey && !event.metaKey)
) {
return undefined;
}
const key = event.key.toLowerCase();
if (event.shiftKey) {
return desktop && key === "s" ? "save-as" : undefined;
}
switch (key) {
case "s":
return "save";
case "n":
return desktop ? "new" : undefined;
case "w":
return desktop ? "close" : undefined;
default:
return undefined;
}
}
+144
View File
@@ -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("")
: "纸张、样式和可编辑结构已写入文档";
}
+134 -3
View File
@@ -4,7 +4,11 @@ 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.v2"; export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
"md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
"md-to-pdf.export-config.v2";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">; type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -12,13 +16,128 @@ 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();
} }
try { try {
const parsed = exportConfigSchema.safeParse(JSON.parse(rawValue)); const value: unknown = JSON.parse(rawValue);
const migratedValue = migrateStoredExportConfig(value);
const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig(); return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch { } catch {
return cloneDefaultExportConfig(); return cloneDefaultExportConfig();
@@ -28,7 +147,19 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
export function loadExportConfig( export function loadExportConfig(
storage: SettingsStorage = window.localStorage storage: SettingsStorage = window.localStorage
) { ) {
return parseStoredExportConfig(storage.getItem(EXPORT_CONFIG_STORAGE_KEY)); const currentValue = storage.getItem(EXPORT_CONFIG_STORAGE_KEY);
if (currentValue) {
return parseStoredExportConfig(currentValue);
}
const legacyValue =
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
const config = parseStoredExportConfig(legacyValue);
if (legacyValue) {
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
}
return config;
} }
export function saveExportConfig( export function saveExportConfig(
@@ -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;
}
}
+4
View File
@@ -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) {
+514
View File
@@ -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("表格尺寸必须为 110 列、210 行");
}
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;
}
-123
View File
@@ -1,123 +0,0 @@
import {
getPaperDimensionsMm,
lengthToMillimeters,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
export interface MermaidDimensions {
width: number;
height: number;
}
export interface MermaidPageFit extends MermaidDimensions {
scaled: boolean;
}
const PAGE_FIT_EPSILON_PX = 1;
export function parseSvgViewBox(
value: string | null
): MermaidDimensions | undefined {
if (!value) {
return undefined;
}
const values = value
.trim()
.split(/[\s,]+/)
.map((part) => Number(part));
const [, , width = Number.NaN, height = Number.NaN] = values;
if (
values.length !== 4 ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
export function getPageContentDimensions(
config: ExportConfig
): MermaidDimensions {
const paper = getPaperDimensionsMm(
config.paper.format,
config.paper.orientation
);
const width =
paper.width -
lengthToMillimeters(config.paper.margins.left) -
lengthToMillimeters(config.paper.margins.right);
const height =
paper.height -
lengthToMillimeters(config.paper.margins.top) -
lengthToMillimeters(config.paper.margins.bottom);
return {
width: millimetersToCssPixels(width),
height: millimetersToCssPixels(height)
};
}
export function calculateMermaidPageFit(
intrinsic: MermaidDimensions,
pageContent: MermaidDimensions
): MermaidPageFit {
const heightAfterWidthFit =
(pageContent.width * intrinsic.height) / intrinsic.width;
if (heightAfterWidthFit <= pageContent.height) {
return {
width: pageContent.width,
height: heightAfterWidthFit,
scaled: false
};
}
const height = Math.max(
0,
pageContent.height - PAGE_FIT_EPSILON_PX
);
return {
width: (height * intrinsic.width) / intrinsic.height,
height,
scaled: true
};
}
export function fitOversizedMermaidToPage(
container: ParentNode,
config: ExportConfig
) {
const pageContent = getPageContentDimensions(config);
const diagrams = Array.from(
container.querySelectorAll<HTMLElement>(".mermaid")
);
for (const diagram of diagrams) {
const svg = diagram.querySelector<SVGSVGElement>("svg");
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
if (!svg || !intrinsic) {
continue;
}
const fit = calculateMermaidPageFit(intrinsic, pageContent);
if (!fit.scaled) {
continue;
}
diagram.dataset.pageHeightFitted = "true";
diagram.style.setProperty("margin-block", "0", "important");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.setProperty("display", "block");
svg.style.setProperty("width", `${fit.width}px`, "important");
svg.style.setProperty("height", `${fit.height}px`, "important");
svg.style.setProperty("max-width", "100%", "important");
svg.style.setProperty("max-height", `${fit.height}px`, "important");
}
}
-317
View File
@@ -1,317 +0,0 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import { Previewer } from "pagedjs";
import { createMermaidSiteConfig } from "./mermaid-config";
import { fitOversizedMermaidToPage } from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
import type { MermaidOutputMode } from "./mermaid-static-image";
import "./paged-table-handler";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
type PagedPreviewPayload
} from "./paged-preview";
import type { PagedRenderTarget } from "./paged-render-target";
export interface PagedDocumentRenderResult {
pageCount: number;
contentHeight: number;
mermaidErrors: string[];
timings: PagedDocumentTimings;
}
export interface PagedDocumentTimings {
setupMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
resourceWaitMs: number;
paginationMs: number;
finalizeMs: number;
totalMs: number;
}
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 applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
function removeTrailingPdfPageBreak(container: ParentNode) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
pages
.at(-1)
?.style.setProperty("break-after", "auto", "important");
}
export class PagedDocumentRuntime {
private activePreviewer: Previewer | undefined;
private mermaidRenderSequence = 0;
private mermaidPromise:
| Promise<(typeof import("mermaid"))["default"]>
| undefined;
constructor(private readonly root: HTMLElement) {}
destroy() {
if (!this.activePreviewer) {
return;
}
this.activePreviewer.chunker.destroy();
this.activePreviewer.polisher.destroy();
this.activePreviewer = undefined;
}
private loadMermaid() {
if (!this.mermaidPromise) {
this.mermaidPromise = import("mermaid").then(
({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
}
);
}
return this.mermaidPromise;
}
private async renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await this.loadMermaid();
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;
const mermaidFitStartedAt = performance.now();
fitOversizedMermaidToPage(content, payload.exportConfig);
const mermaidFitMs = performance.now() - mermaidFitStartedAt;
const mermaidConversionStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") {
replaceMermaidSvgWithImages(content);
}
const mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
if (!shouldContinue()) {
return undefined;
}
const resourceWaitStartedAt = performance.now();
await documentRef.fonts.ready;
await waitForImages(content);
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
const paginationStartedAt = performance.now();
const previewer = new Previewer();
this.activePreviewer = previewer;
const flow = await previewer.preview(
content,
[
stylesheet(documentRef, documentBaseCss, "document-base"),
stylesheet(documentRef, highlightCss, "highlight"),
stylesheet(documentRef, katexCss, "katex"),
stylesheet(documentRef, payload.themeCss, "theme"),
stylesheet(
documentRef,
documentGeometryCss,
"document-geometry"
),
stylesheet(
documentRef,
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
],
this.root
);
const paginationMs = performance.now() - paginationStartedAt;
if (!shouldContinue()) {
return undefined;
}
const finalizeStartedAt = performance.now();
applyPageNumbers(this.root, payload, flow.total);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
}
const finalizeMs = performance.now() - finalizeStartedAt;
return {
pageCount: flow.total,
contentHeight: Math.ceil(
Math.max(
documentRef.documentElement.scrollHeight,
documentRef.body.scrollHeight,
this.root.scrollHeight
)
),
mermaidErrors,
timings: {
setupMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
resourceWaitMs,
paginationMs,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
}
}
declare global {
interface Window {
__mdToPdfRender?: (
payload: PagedPreviewPayload,
target?: PagedRenderTarget
) => Promise<PagedDocumentRenderResult>;
}
}
+121 -24
View File
@@ -1,12 +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,
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 highlightCss from "highlight.js/styles/github.css?inline";
import { resolveMermaidOutputMode } from "./mermaid-static-image"; import katexCss from "katex/dist/katex.min.css?inline";
import {
beginPreviewFrameRender,
completePreviewFrameRender
} 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");
@@ -19,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,
katexCss,
echartsCss
});
document.documentElement.dataset.renderTarget = renderTarget; document.documentElement.dataset.renderTarget = renderTarget;
let latestRequestId = 0; let latestRequestId = 0;
@@ -29,35 +42,88 @@ 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;
} }
beginPreviewFrameRender(document.documentElement, requestId);
post({ post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering", type: "rendering",
requestId
});
const result = await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId, requestId,
...result layout
}); });
try {
const result =
layout === "continuous"
? await runtime.renderContinuous(payload, {
shouldContinue: () => requestId === latestRequestId
})
: await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
layout,
contentHeight: measurePreviewContentHeight(),
...result
});
} finally {
if (requestId === latestRequestId) {
completePreviewFrameRender(document.documentElement, requestId);
}
}
} }
window.__mdToPdfRender = async ( window.__mdToPdfRender = async (
@@ -73,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 ||
@@ -85,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;
-41
View File
@@ -1,41 +0,0 @@
declare module "pagedjs" {
export interface PagedFlow {
total: number;
pages: HTMLElement[];
performance: number;
size: {
width: { value: number; unit: string };
height: { value: number; unit: string };
format?: string;
orientation?: string;
};
}
export type PagedStylesheet = string | Record<string, string>;
export class Previewer {
chunker: {
destroy(): void;
};
polisher: {
destroy(): void;
};
preview(
content?: HTMLElement | DocumentFragment | string,
stylesheets?: PagedStylesheet[],
renderTo?: HTMLElement | string
): Promise<PagedFlow>;
}
export class Handler {
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
);
}
export function registerHandlers(
...handlers: Array<typeof Handler>
): void;
}

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