54 Commits
Author SHA1 Message Date
SkyJourney effeca7ca8 docs: 修正 v0.6.2 发布链接 2026-08-26 15:16:13 +08:00
SkyJourney 55614fafed docs: 补充版本变更日志与发版规则 2026-08-26 11:48:53 +08:00
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00
SkyJourney 01b06abc2c release: 发布字体直接内置版 v0.6.1
新增能力:14 套主题按纵横方向与五组页边距组成的 140 场景视觉矩阵全部通过,封面执行整页严格门禁,正文执行分页无关的语义块门禁。

问题修复:统一 CSS 到 WordprocessingML 的通用翻译,修复字体嵌入、中文标点字距、精确行距、打印媒体与行内代码连续性,不包含按主题 ID 的特调。

发行变化:Desktop 安装器与免安装 ZIP 直接内置 Serif、Sans、Mono 三套字体包;NSIS 移除字体选择和伴随安装逻辑,不再发布独立字体安装器或字体 ZIP。正式附件收敛为 Desktop 安装器、Desktop ZIP、Docker 离线镜像、Web 部署包和发行说明。

部署兼容:Docker 镜像 md-to-pdf:v0.6.1 使用完整无缓存路径构建,固定 Chromium、Pandoc 3.9.0.2 和三套内置字体包;支持覆盖安装 v0.6.0,Windows 安装器保持未签名的内部发布状态。

验证结果:116 个测试文件、616 项测试、全项目类型检查、生产构建和 git diff --check 全部通过;140/140 视觉矩阵阻断失败和诊断失败均为 0;源码服务、Docker Web API 与实际安装 Desktop 的 DOCX 字体嵌入链路均通过。
2026-08-04 12:25:02 +08:00
SkyJourney f93abf064f docs: 记录 v0.6.1 发布完成
补充发布提交与 annotated tag、8 项附件校验和、离线镜像重新导入及运行态验收结果。
2026-08-04 10:34:55 +08:00
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00
SkyJourney b275c671fc fix: 修复DOCX独立封面视觉门禁
统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
2026-08-03 18:15:21 +08:00
SkyJourney 9dee7e8605 fix: 统一十四套主题DOCX字体映射 2026-08-03 12:47:14 +08:00
SkyJourney 6a6972ae48 fix: 内置DOCX双中文字体包 2026-08-03 12:07:12 +08:00
SkyJourney a094dabf02 fix: 增强DOCX字体实际使用门禁 2026-08-03 11:32:06 +08:00
SkyJourney f0fff7f726 test: 扩展DOCX全主题视觉矩阵 2026-08-03 10:23:22 +08:00
SkyJourney 28c88faabb fix: 重建DOCX视觉一致性门禁 2026-08-03 10:09:43 +08:00
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
460 changed files with 80038 additions and 5788 deletions
+19
View File
@@ -0,0 +1,19 @@
.git
.github
.local
.vscode
apps/desktop
coverage
dist
node_modules
out
output/*
!output/font-packs/
output/font-packs/*
!output/font-packs/root/
!output/font-packs/root/**
playwright-report
test-results
tmp
*.log
*.tsbuildinfo
+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
+6
View File
@@ -3,15 +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/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. 当前交接入口
+239
View File
@@ -0,0 +1,239 @@
# 变更日志
本文档记录 MorphDoc(原 Markdown PDF 导出器)的重要版本变更。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
[Semantic Versioning](https://semver.org/lang/zh-CN/)。发布时以对应版本章节作为
Gitea Release 页面正文的基础;构建产物、校验和及详细验收记录可继续写入
`docs/releases/v<version>.md`
## [Unreleased]
### 变更
- 暂无。
## [0.6.2] - 2026-08-26
### 修复
- 修复真实长文档中表格分页后的列宽、偏移和行样式丢失,冻结逻辑表格列轨并将
打印几何稳定传递到 Paged.js 分片。
- 修复 Markdown 表格中 `<br>` 变体、连续换行、实体和行内代码的 DOCX 翻译,
避免换行逃逸到目标单元格段落之外。
- 修复全 JSON 围栏缩进、表格末行对齐、代码高亮和行内代码连续性。
- 改进 PDF 文本流排序和数值范围归一化,减少窄字符重叠、跨视觉行破折号及重复
表头映射造成的视觉比较误报。
### 验证
- 合成基线与长庆真实文档两套严格门禁为 `280/280`M4N 复杂表格文档为
`140/140`
- 健康数据长文档残余误报为 `6/115``5.22%`),均核查为重复表头自动对齐或
取样错配,基础设施错误为 0。
- 全项目测试、类型检查、生产构建、正式 Docker、内置字体 NSIS/ZIP 和离线发行
归集通过。
### 发行与兼容性
- Desktop 安装器、免安装 ZIP 与 Docker 镜像统一为 0.6.2。
- Docker 继续内置固定 Chromium、Pandoc 3.9.0.2 和受校验字体包;Desktop
继续直接内置 Serif、Sans、Mono 字体,无需安装系统字体。
- Windows 安装器尚未进行 Authenticode 签名,仅适用于公司内部发布。
## [0.6.1] - 2026-08-04
### 修复
- 重构通用 CSS 到 WordprocessingML 翻译链,统一字体、字号、行距、段距、缩进、
边框、底纹、表格、引用和代码样式。
- 修复字体实际嵌入、中文标点字距、精确行距、打印媒体环境和 Markdown 行内代码
连续性问题。
### 验证
- 建立 14 套主题 × 两个方向 × 五组页边距的 140 场景视觉矩阵,`140/140`
全部通过。
- 四套独立封面执行 Chromium、Word、WPS 整页严格门禁,正文执行与物理分页无关
的语义块门禁。
- 源码服务、Docker Web API 和实际安装 Desktop 三条生产 DOCX 导出链通过。
### 发行与兼容性
- Serif、Sans、Mono 三套字体直接内置于 Desktop 安装器和免安装 ZIP,不再发布
独立字体安装器或字体 ZIP。
- Docker 内置固定 Chromium、Pandoc 3.9.0.2 和三套正式字体包。
## [0.6.0] - 2026-08-02
### 新增
- 新增原生可编辑 DOCX 导出,支持 Microsoft Word 与 WPS,并保留纸张、页边距、
字体、段落、标题、代码块、表格、媒体、封面、页眉页脚和页码配置。
- 建立通用主题到 DOCX 样式翻译引擎,覆盖 14 套内置主题。
- 新增可扩展 Markdown 工具栏,支持标题、强调、代码、引用、列表、任务列表、
分隔线及 M×N 表格。
- PDF 与 DOCX 统一收口至“导出”菜单,并提供生成进度提示。
### 变更
- 产品更名为“墨呈”,英文名 MorphDoc,并保留原 NSIS 安装身份以支持覆盖升级。
- Desktop 内置固定 Pandoc 3.9.0.2Docker 内置 Chromium、Pandoc 和正式字体,
支持完全离线部署。
- 正式字体资产改由 Git LFS 管理,发行产物统一归集到 `release/v<version>/`
### 修复
- 修复精确预览放大后的右侧留白、保存与导出默认路径、Desktop DOCX 媒体捕获
超时及品牌迁移后的安装目录问题。
## [0.5.1] - 2026-07-29
### 新增
- 新增 4 套红头、3 套正式文档和 3 套标书主题,内置主题总数增至 14 套。
- 支持主题推荐页边距、页面装饰、页眉页脚、页码和结构化公文、报告及标书
Front Matter。
- 内置 Fandol 中文字体、两份教程和 14 份主题示例。
- Desktop 增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径。
### 修复
- 修复红头标题居中、正式文档字体、代码块长内容越界和大型代码块跨页留白。
- Docker Web 镜像补齐 `samples` 目录。
### 兼容性
- 未声明主题推荐设置的主题继续使用原有 16mm 默认页边距。
## [0.5.0] - 2026-07-28
### 新增
- 新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与
Electron PDF 渲染链路。
- 新增连续预览和修改位置之后的增量分页,支持稳定前缀复用。
- Desktop 支持多窗口、同文件单例、本地路径与 Markdown 链接,以及聚焦时检测
外部文件变化。
- Web 统一处理文档锚点和 HTTP/HTTPS 外链。
### 变更
- 内置主题统一为 Typora Github、Typora Pixyll、Typora whitey 和 Typora Clean。
- Windows 安装包统一使用 NSIS,并提供免安装 ZIP。
### 修复
- 修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块样式和发行包
复用陈旧 Web 构建产物的问题。
## [0.4.5] - 2026-07-28
### 新增
- Web 与 Desktop 支持新建和保存 MarkdownDesktop 增加 `.md``.markdown`
文件关联、单实例系统打开、自定义主题目录和窗口状态恢复。
- 新文档可按一级标题或首行生成合法文件名,并直接导出 PDF。
### 变更
- 图片、Mermaid 和 ECharts 改为按文档顺序串行执行媒体分页回填,每个媒体元素
至多重排一次,并保持标题原尺寸。
- Web 取消本地素材目录入口。
## [0.4.4] - 2026-07-27
### 变更
- Desktop 发行包仅保留简体中文和英文语言资源,显著缩减目录版、安装包和 ZIP
体积,同时保留 Chromium 图形与软件渲染后备组件。
## [0.4.3] - 2026-07-27
### 变更
- Windows Desktop 从 Squirrel 迁移到标准 NSIS 安装向导,支持安装范围和安装目录
选择。
- 安装包改用英文、无空格、包含版本与平台的文件名,并继续提供免安装 ZIP。
## [0.4.2] - 2026-07-27
### 变更
- 移除 Windows Desktop 原生应用菜单栏,使应用内容直接位于系统标题栏下方。
- 此版本仅更新 DesktopWeb 与 Docker Compose 继续保持 0.4.1。
## [0.4.1] - 2026-07-27
### 新增
- 支持 Web 素材目录、Desktop 同目录资源和受限公网图片下载。
- 支持图片标题、图片整块分页以及超高图片在单页内容区内自适应缩放。
### 修复
- 修复本地与网络图片的资源解析、自然尺寸解码和 PDF 分页边界问题。
## [0.4.0] - 2026-07-27
### 新增
- 首次发布 Electron Windows Desktop,复用与 Web 相同的渲染和 PDF 核心。
- 支持桌面 Markdown 文件选择、原生保存和离线 PDF 导出。
### 修复
- 固定桌面端英文可执行文件名,避免中文名称影响安装和自动化链路。
## [0.3.1] - 2026-07-27
### 新增
- 扩展 ECharts 第二阶段图表协议和示例覆盖,完善更多系列、坐标轴及组合图表。
## [0.3.0] - 2026-07-27
### 新增
- 新增 ECharts YAML 围栏、安全校验、浏览器 SVG 渲染和 PDF 输出。
- 增强编辑区折叠、页码导航和快速预览外层滚动交互。
### 修复
- ECharts 媒体块参与统一不可跨页处理,避免图表被分页裁断。
## [0.2.0] - 2026-07-27
### 新增
- 完善 Mermaid 配置、安全渲染、资源等待和预览缩放。
- 增强长文档分页、滚动同步和预览/PDF 边界处理。
### 变更
- 收敛 Web 预览和 Chromium PDF 的共享渲染抽象,减少链路分叉。
## [0.1.0] - 2026-07-26
### 新增
- 实现 Markdown 安全渲染核心、网页实时预览、本地主题兼容和导出设置。
- 支持真实分页预览、固定 Chromium PDF 导出和 PDF 精确预览。
- 支持长文档分页、页码导航、滚动同步以及 AIO Docker 容器部署。
[Unreleased]: https://gitea.rk-health.com/yixiong/MorphDoc/compare/v0.6.2...main
[0.6.2]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.2
[0.6.1]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.1
[0.6.0]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.6.0
[0.5.1]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.5.1
[0.5.0]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.5.0
[0.4.5]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.5
[0.4.4]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.4.4
[0.4.3]: https://gitea.rk-health.com/yixiong/MorphDoc/releases/tag/v0.4.3
[0.4.2]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.2
[0.4.1]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.1
[0.4.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.4.0
[0.3.1]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.3.1
[0.3.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.3.0
[0.2.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.2.0
[0.1.0]: https://gitea.rk-health.com/yixiong/MorphDoc/src/tag/v0.1.0
+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 -102
View File
@@ -1,25 +1,115 @@
# Markdown PDF 导出器 # 墨呈
一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。 > 一稿多式,即写即呈
## 当前状态 面向结构化 Markdown 的主题化文档创作与发布工具,同时提供 Web 与
Windows 桌面端。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题
CSS 和分页运行时,DOCX 使用同源语义文档、主题令牌和可编辑 OOXML。
已经实现: 当前版本:`v0.6.0`
- React Markdown 编辑与本地文件读取; ## v0.6.0 核心更新
- Fastify 预览接口;
- 共享导出配置模型;
- 动态主题清单、CSS 和静态资源接口;
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
- 快速预览与精确预览共用的 50%~400% 显示缩放;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
本地相对资源文件处理将在后续阶段实现。 - 新增可编辑 DOCX 导出,使用固定 Pandoc、动态 Word 样式、原生分节、
页眉页脚、页码和高分辨率 PNG 媒体管线;
- 主题 CSS 通过通用语义槽位翻译为 Word 样式,支持 14 套内置主题、
公文结构、独立封面、代码、表格、图片、Mermaid 与 ECharts
- 新增 CodeMirror 6 Markdown 工具栏,支持标题、行内格式、引用、列表、
代码块、分隔线和尺寸表格;
- 产品品牌升级为“墨呈”,Windows 主程序和发行产物使用英文名
`MorphDoc`
## v0.5.1 主题与公文能力
- 新增政企标准红头、函件红头、企业简版红头、简报红头等 4 套红头主题,
以及 3 套正式文档主题和 3 套标书主题;
- 正式主题可声明推荐页边距、页眉页脚、页码和页面装饰;未声明时继续使用
16mm 默认页边距,选择正式主题时自动采用其规范参数;
- 新增公文、项目报告和标书结构化 Front Matter,支持发文机关、文号、
签发人、密级、主送、抄送、印发信息、封面及落款等文档结构;
- 内置方正书版兼容的 Fandol 中文字体资源,统一正文、红头标题与页眉页脚
字体,并修复红头标题居中;代码块支持按正文宽度自动换行、跨页分片,
同时保留 `8px` 左侧内容留白;
- 内置 ECharts 与公文主题教程,并为 14 套主题提供示例 Markdown
Desktop 发行包包含全部教程和示例,打开示例时自动切换相应主题;
- “更多”菜单按文件、教程与示例、主题管理分区;Desktop 新增
`Ctrl+N/W/S/Shift+S` 工作流、未保存确认及跟随新路径的“另存为”,
Web 端隐藏不适用的“另存为”。
## Web 与桌面端
| 能力 | Web / Docker | Windows Desktop |
| --- | --- | --- |
| 新建 Markdown | 支持 | 支持 |
| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 |
| 保存文件 | 浏览器下载 | 原生保存与另存为 |
| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 |
| 文档内锚点 | 当前预览定位 | 当前预览定位 |
| HTTP/HTTPS 链接 | 浏览器新窗口 | 系统默认浏览器 |
| 本地文件链接 | 不执行 | 相对、绝对路径及 `file:` URI 均可尝试打开 |
| 多文件 | 当前浏览器文档 | 多窗口、同一文件单例 |
| 外部文件变化 | 不适用 | 窗口聚焦时提示重新加载 |
| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 |
| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” |
| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium |
| DOCX 引擎 | 固定版本 Pandoc | 内置固定版本 Pandoc |
Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用
DesktopWeb 端可使用 Base64/Data URL 或受限公网图片。
## 渲染架构
```mermaid
flowchart LR
A["Markdown 与受限资源"] --> B["Renderer 安全渲染"]
B --> C["统一 #write HTML"]
D["主题 CSS 与导出配置"] --> C
C --> E["共享 Preview Engine"]
E --> F["连续预览"]
E --> G["快速分页预览"]
E --> H["Playwright Chromium"]
E --> I["Electron Chromium"]
H --> J["Web PDF"]
I --> K["Desktop PDF"]
```
连续预览适合高频编辑;快速预览提供客户端分页反馈;精确预览直接展示
服务端生成的 PDF,是 Web 最终分页的权威结果。桌面 PDF 复用相同分页
载荷和运行时。
## 项目目录
```text
apps/
web/ React 编辑、设置、快速/精确预览与浏览器文件操作
server/ Fastify API、Playwright PDF 与容器运行入口
desktop/ Electron 主进程、IPC、原生文件和 Windows 打包
packages/
core/ 跨端文档、导出配置和主题协议
renderer/ Markdown 到安全 #write HTML 的渲染管线
application/ 共享渲染、主题注册和图片资源应用服务
markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时
preview-engine/ 连续预览、增量分页、媒体适配和 Paged.js 运行时
themes/ 内置主题与主题清单
deploy/ Dockerfile、Compose、Nginx 和部署文档
docs/ 项目进度与主题开发说明
scripts/ 主题导入等维护脚本
logos/ 应用图标源文件
.local/themes/ 本地开发和 Compose 自定义主题目录
```
包依赖保持单向:`core` 提供基础协议和链接分类;`markdown-echarts`
提供独立图表能力;`renderer` 消费两者生成安全 HTML`application`
组合渲染器、主题和资源用例;`preview-engine` 消费渲染结果完成连续或
分页排版;三个 `apps/*` 只负责平台界面、传输、链接动作与 PDF 适配。
各包的目录和用法:
- [application](packages/application/README.md)
- [core](packages/core/README.md)
- [renderer](packages/renderer/README.md)
- [markdown-echarts](packages/markdown-echarts/README.md)
- [preview-engine](packages/preview-engine/README.md)
## 本地开发 ## 本地开发
@@ -32,110 +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
预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。
## Mermaid 配置与样式
导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体:
- 布局:Dagre、ELK
- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列;
- 外观:Classic、Hand-drawn、Neo
- 字体:任意本地 CSS 字体族。
未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置:
````markdown
```mermaid
---
config:
layout: elk
theme: base
look: handDrawn
fontFamily: Microsoft YaHei
themeVariables:
primaryColor: "#dbeafe"
primaryBorderColor: "#2563eb"
flowchart:
wrappingWidth: 360
---
flowchart TB
classDef important fill:#fee2e2,stroke:#dc2626
A["较长的节点名称<br/>第二行说明"] --> B["下一个节点"]
style A fill:#dcfce7,stroke:#16a34a
class B important
``` ```
````
`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。 桌面端目录包和 Windows 安装包:
ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。 ```powershell
npm run desktop:package
### Mermaid 长标签 npm run make -w @md-to-pdf/desktop
流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。
需要自动换行时可以使用 Mermaid Markdown String
````markdown
```mermaid
flowchart LR
A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"]
``` ```
````
个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef` 桌面 `package``make` 会先完整重建内嵌 Web 及全部共享依赖,再准备并
校验固定 Pandoc `3.9.0.2` 最小运行时,不能复用陈旧的
`apps/web/dist`。产物位于 `apps/desktop/out/v0.6.0/`
正式安装包命名为 `MorphDoc-0.6.0-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包
未配置代码签名。
````markdown ## 文档与媒体
```mermaid
flowchart LR
A["较长节点"] --> B["普通节点"]
classDef compact font-size:12px;
class A compact;
```
````
## 本地导入 Typora 默认主题 Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、
KaTeX、Mermaid 和安全的 ECharts YAML 围栏。
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录: 独立一行的 `![标题](地址)` 会生成带标题的图片块。分页引擎将图片、
Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行
计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时
只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免
跨页、重复重排和后续媒体顺序变化。
ECharts 围栏语法、支持系列和安全边界详见
[markdown-echarts 包文档](packages/markdown-echarts/README.md)。
## 主题
仓库内置 4 套 Typora 风格主题、4 套红头主题、3 套正式文档主题和
3 套标书主题,默认使用 GitHub。主题由版本化
`theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。
- 本地开发与 Compose:将主题目录放入 `.local/themes/`
- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题;
- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。
Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于
刷新本地开发副本:
```powershell ```powershell
npm run theme:import-typora npm run theme:import-typora
``` ```
默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。 ## Docker Compose 部署
导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用:
```powershell
npm run theme:import-typora -- --replace
```
旧副本会移动到 `.local\theme-backups`。
本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。
自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。
## Docker Desktop 部署
```powershell ```powershell
docker compose -f deploy\compose.yaml up --build -d docker compose -f deploy\compose.yaml up --build -d
``` ```
默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes` 默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时
作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见 配置详见 [AIO 容器部署说明](deploy/README.md)。
[AIO 容器部署说明](deploy/README.md)。
## 测试与构建 ## 验证
```powershell ```powershell
npm test npm test
@@ -144,7 +194,18 @@ npm run build
git diff --check git diff --check
``` ```
## 隐私原则 涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、
Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端
验证,并检查 PDF 页面渲染结果。
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 ## 安全与隐私
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
- 不建立 Markdown、图片或 PDF 历史数据库;
- 文档和资源只在当前请求或当前桌面会话中处理;
- Markdown 原始 HTML 不执行,输出经过白名单过滤;
- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML
- 本地资源阻止路径穿越和符号链接越界;
- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时;
- PDF 页面阻止未授权外部网络访问。
当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。
+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 历史。若不能取得授权,
公开发布应使用经过清理的历史或从合规快照创建新的公开仓库。上述门禁
只针对公司外部和公开网络发行,不阻止当前公司内部源码共享、部署和
安装包分发。
+75 -12
View File
@@ -1,8 +1,24 @@
# Desktop 桌面端 # Desktop 桌面端
`apps/desktop` `v0.4.0` 新增的 Electron 桌面应用。当前已经完成 `apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限
Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、
Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。 本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows
安装包。
桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入,
异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口
落在屏幕外。Windows 安装包会注册 `.md``.markdown` 文件关联,
双击文件或使用“打开方式”会在对应文档窗口中加载文件;同一文件只保留
一个窗口,其他文件可以同时使用多个窗口。
窗口每次获得焦点时会检查源文件是否被其他程序修改。检测到变化后提示
重新加载,避免阅读或导出磁盘旧版本。
“打开 Markdown”是顶部独立主操作;新建、保存、另存为、教程、主题示例
和自定义主题目录统一收纳在“更多”分区菜单中。新建文档未保存时显示“未命名文档”,
首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会
自动清理 Windows 非法文件名字符。“另存为”完成后当前窗口会追踪新文件,
行为与常见办公软件一致。新文档无需先保存即可直接导出 PDF。
## 开发 ## 开发
@@ -25,24 +41,71 @@ npm run desktop:package
npm run make -w @md-to-pdf/desktop npm run make -w @md-to-pdf/desktop
``` ```
版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到 `package``make` 会先调用根级 `build:web-runtime`,完整重建
`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以 Markdown ECharts、Core、Renderer、Application、Preview Engine 和 Web
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名, 再按固定清单下载或复用 Pandoc `3.9.0.2` 缓存、校验官方 ZIP 的
Windows 首次运行可能显示“未知发布者”提示 SHA-256,并执行版本与最小 DOCX 冒烟,最后才调用 electron-builder
不得绕过该链路直接调用 electron-builder。
产品名称、窗口标题、安装包名称和开始菜单名称保持为 Pandoc 构建缓存位于被 Git 忽略的 `apps/desktop/.runtime/`。发行目录只
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为 包含 `pandoc.exe``COPYING.rtf``COPYRIGHT.txt` 和生成的审计清单,
`md-to-pdf.exe` 不会携带官方 MSI、手册、下载缓存或用户级 Pandoc 配置。已准备运行时可用
以下命令离线复验:
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。 ```powershell
npm run verify:pandoc-runtime -w @md-to-pdf/desktop
```
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.6.2/`。该目录被 Git 忽略。公司内部分发以
`MorphDoc-0.6.2-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
提示。
Serif、Sans、Mono 三套正式字体随 NSIS 安装包和便携 ZIP 直接进入
`resources/font-packs`,由应用私有加载,不写入 Windows 系统字体目录。
安装向导不再提供字体选择页,正式发行也不附带独立字体安装器或字体 ZIP。
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
发布,不提交进 Git 历史。
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
“运行”操作直接启动 `MorphDoc.exe`,不依赖快捷方式文件。
界面、快捷方式与卸载项使用中文名“墨呈”,窗口标题为
“墨呈 · MorphDoc”,默认安装目录和 Windows 主程序分别为 `MorphDoc`
`MorphDoc.exe`。NSIS appId 保持 `com.md-to-pdf.desktop`,确保
v0.5.1 覆盖升级沿用原卸载身份;v0.6.0 使用独立的安装位置注册表键,
升级时由旧版卸载器清理 `md-to-pdf` 目录,再安装到 `MorphDoc` 目录,
避免产品改名后继续继承旧目录名。
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium
Pandoc 作为未修改的独立程序随 Desktop 内置,通过受控子进程执行。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过 Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。 `mdpdf://bundle/` 自定义协议加载。
## 链接与窗口行为
- 当前文档 `#anchor` 在当前预览中定位;
- HTTP/HTTPS 链接交给系统默认浏览器;
- `mailto:` 等系统协议交给操作系统处理;
- 上级相对路径、本地绝对路径和 `file:` URI 均可尝试通过系统打开;
- Markdown 文件会提示在当前窗口或新窗口打开;
- 同一规范化文件路径只允许一个窗口,重复打开时聚焦已有窗口;
- 本地目标不存在时显示明确错误,不允许链接替换预览 iframe。
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
## 安全边界 ## 安全边界
- 应用窗口和 PDF 窗口均关闭 Node Integration - 应用窗口和 PDF 窗口均关闭 Node Integration
- 启用 Context Isolation 和 Chromium Sandbox - 启用 Context Isolation 和 Chromium Sandbox
- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口; - preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
的窄接口;
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
链接越界;
- 主进程校验 IPC 发送者和分页载荷; - 主进程校验 IPC 发送者和分页载荷;
- PDF 窗口使用独立内存 Session,并阻止外部网络资源; - PDF 窗口使用独立内存 Session,并阻止外部网络资源;
- 应用页面使用自定义协议,不使用 `file://` - 应用页面使用自定义协议,不使用 `file://`
+87
View File
@@ -0,0 +1,87 @@
!define MORPHDOC_DISPLAY_NAME "墨呈"
!define MORPHDOC_EXECUTABLE "MorphDoc.exe"
!define MORPHDOC_LEGACY_DISPLAY_NAME "Markdown PDF 导出器"
!define MORPHDOC_LEGACY_EXECUTABLE "md-to-pdf.exe"
!define MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY "Software\${APP_GUID}"
; 保持 APP_GUID 与卸载身份不变,但不再沿用旧产品名对应的安装目录。
; electron-builder 会从 INSTALL_REGISTRY_KEY 读取历史 InstallLocation;使用
; 独立键可让 v0.6.0 默认进入 MorphDoc 目录,同时仍由相同卸载身份识别旧版。
!define INSTALL_REGISTRY_KEY "Software\${APP_GUID}.MorphDoc"
!macro customInstall
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown" \
"" \
"Markdown 文档"
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \
"" \
'"$INSTDIR\${MORPHDOC_EXECUTABLE}",0'
WriteRegStr SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \
"" \
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
WriteRegStr SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\shell\open\command" \
"" \
'"$INSTDIR\${MORPHDOC_EXECUTABLE}" "%1"'
WriteRegStr SHCTX \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
".md" \
""
WriteRegStr SHCTX \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}\SupportedTypes" \
".markdown" \
""
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
CreateDirectory "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
CreateShortCut \
"$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
"" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
CreateShortCut \
"$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}" \
"" \
"$INSTDIR\${MORPHDOC_EXECUTABLE}"
StrCpy $launchLink "$INSTDIR\${MORPHDOC_EXECUTABLE}"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
!macro customUnInstall
DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"
DeleteRegValue SHCTX \
"Software\Classes\.md\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegValue SHCTX \
"Software\Classes\.markdown\OpenWithProgids" \
"MarkdownPdfExporter.Markdown"
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_EXECUTABLE}"
DeleteRegKey SHCTX \
"Software\Classes\Applications\${MORPHDOC_LEGACY_EXECUTABLE}"
DeleteRegKey SHCTX \
"Software\Classes\MarkdownPdfExporter.Markdown"
Delete "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}\${MORPHDOC_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_DISPLAY_NAME}.lnk"
Delete "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
RMDir "$SMPROGRAMS\${MORPHDOC_LEGACY_DISPLAY_NAME}"
Delete "$DESKTOP\${MORPHDOC_LEGACY_DISPLAY_NAME}.lnk"
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)'
!macroend
+98
View File
@@ -0,0 +1,98 @@
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: "../../output/font-packs/root",
to: "font-packs",
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}`
}
};
-47
View File
@@ -1,47 +0,0 @@
const path = require("node:path");
const windowsIcon = path.resolve(
__dirname,
"../../logos/desktop/windows/app.ico"
);
const macosIcon = path.resolve(
__dirname,
"../../logos/desktop/macos/app.icns"
);
const { version } = require("../../package.json");
module.exports = {
buildIdentifier: `v${version}`,
packagerConfig: {
asar: true,
prune: false,
executableName: "md-to-pdf",
icon: process.platform === "darwin" ? macosIcon : windowsIcon,
ignore: [
/^\/node_modules(?:\/|$)/,
/^\/out(?:\/|$)/,
/^\/src(?:\/|$)/,
/^\/tests(?:\/|$)/,
/^\/tsconfig\.json$/
],
extraResource: [
path.resolve(__dirname, "../web/dist"),
path.resolve(__dirname, "../../themes"),
windowsIcon,
macosIcon
]
},
makers: [
{
name: "@electron-forge/maker-squirrel",
config: {
name: "markdown_pdf_exporter",
exe: "md-to-pdf.exe",
setupIcon: windowsIcon
}
},
{
name: "@electron-forge/maker-zip",
platforms: ["win32", "darwin"]
}
]
};
+16 -13
View File
@@ -1,33 +1,36 @@
{ {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.4.0", "version": "0.6.2",
"private": true, "private": true,
"productName": "Markdown PDF 导出器", "productName": "MorphDoc",
"description": "Markdown PDF 导出器桌面端", "description": "墨呈桌面端:面向结构化 Markdown 的主题化文档创作与发布工具",
"author": "md-to-pdf contributors", "author": "YIXIONG Tech.ltd",
"type": "module", "type": "module",
"main": "dist/main.js", "main": "dist/main.js",
"dependencies": {
"@md-to-pdf/application": "0.4.0"
},
"scripts": { "scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload", "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: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", "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})\"", "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", "dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
"package": "npm run build && electron-forge package", "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 && electron-forge make", "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", "test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false" "typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
"verify:docx-theme-styles": "electron scripts/verify-docx-theme-styles.cjs"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "7.11.2", "@md-to-pdf/application": "0.4.1",
"@electron-forge/maker-squirrel": "7.11.2", "@md-to-pdf/docx-engine": "0.1.0",
"@electron-forge/maker-zip": "7.11.2", "@md-to-pdf/docx-theme-engine": "0.1.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"electron": "43.2.0", "electron": "43.2.0",
"electron-builder": "26.15.3",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fflate": "0.8.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"wait-on": "^9.0.1" "wait-on": "^9.0.1"
+34 -1
View File
@@ -1,4 +1,13 @@
import { build } from "esbuild"; 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({ await build({
entryPoints: ["src/main.ts"], entryPoints: ["src/main.ts"],
@@ -7,10 +16,34 @@ await build({
format: "esm", format: "esm",
external: ["electron"], external: ["electron"],
outfile: "dist/main.js", 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: { banner: {
js: [ js: [
'import { createRequire as __mdToPdfCreateRequire } from "node:module";', 'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
"const require = __mdToPdfCreateRequire(import.meta.url);" '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") ].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();
}
+70 -2
View File
@@ -1,15 +1,79 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { PagedDocumentPayload } from "@md-to-pdf/core"; import type {
DocxExportRequestInput,
PagedDocumentPayload
} from "@md-to-pdf/core";
import type { MarkdownRenderRequest } from "@md-to-pdf/application"; import type { MarkdownRenderRequest } from "@md-to-pdf/application";
import { import {
DESKTOP_CONSUME_PENDING_MARKDOWN,
DESKTOP_CREATE_NEW_WINDOW,
DESKTOP_DISCARD_PENDING_MARKDOWN,
DESKTOP_GET_THEME_CSS, DESKTOP_GET_THEME_CSS,
DESKTOP_GET_DOCX_CAPABILITY,
DESKTOP_EXPORT_DOCX,
DESKTOP_GENERATE_PDF, DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES, DESKTOP_LIST_THEMES,
DESKTOP_MARKDOWN_OPENED,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_OPEN_DOCUMENT_LINK,
DESKTOP_OPEN_THEME_DIRECTORY,
DESKTOP_REFRESH_THEMES,
DESKTOP_RENDER_MARKDOWN, 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 DESKTOP_SAVE_PDF
} from "./channels.js"; } from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", { 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) => renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input), ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES), listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
@@ -18,5 +82,9 @@ contextBridge.exposeInMainWorld("mdToPdfDesktop", {
generatePdf: (payload: PagedDocumentPayload) => generatePdf: (payload: PagedDocumentPayload) =>
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload), ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
savePdf: (fileName: string, pdf: Uint8Array) => savePdf: (fileName: string, pdf: Uint8Array) =>
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf) ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf),
getDocxCapability: () =>
ipcRenderer.invoke(DESKTOP_GET_DOCX_CAPABILITY),
exportDocx: (request: DocxExportRequestInput) =>
ipcRenderer.invoke(DESKTOP_EXPORT_DOCX, request)
}); });
+32 -1
View File
@@ -12,7 +12,8 @@ export function parseMarkdownRenderRequest(
} }
return { return {
markdown: value.markdown, markdown: value.markdown,
language: value.language language: value.language,
resources: value.resources
}; };
} }
@@ -51,3 +52,33 @@ export function parseThemeResourceUrl(requestUrl: string) {
assetPath: assetSegments.join("/") 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;
+32
View File
@@ -1,5 +1,33 @@
export const DESKTOP_RENDER_MARKDOWN = export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf: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 = export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes"; "md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS = export const DESKTOP_GET_THEME_CSS =
@@ -8,6 +36,10 @@ export const DESKTOP_GENERATE_PDF =
"md-to-pdf:desktop:generate-pdf"; "md-to-pdf:desktop:generate-pdf";
export const DESKTOP_SAVE_PDF = export const DESKTOP_SAVE_PDF =
"md-to-pdf: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 = export const PDF_RUNTIME_READY =
"md-to-pdf:desktop-pdf:ready"; "md-to-pdf:desktop-pdf:ready";
export const PDF_RUNTIME_RENDER = export const PDF_RUNTIME_RENDER =
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,146 @@
import type { WebContents } from "electron";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
interface CaptureScreenshotResult {
data: string;
}
export interface ElectronDocxMediaCaptureTimeouts {
renderMs: number;
screenshotMs: number;
}
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
renderMs: 30_000,
screenshotMs: 20_000
};
function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
message: string,
signal?: AbortSignal
) {
return new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
callback();
};
const abort = () =>
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
const timeout = setTimeout(
() => finish(() => reject(new Error(message))),
timeoutMs
);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
operation.then(
(value) => finish(() => resolve(value)),
(error) => finish(() => reject(error))
);
});
}
function createRenderScript(request: DocxMediaCaptureRequest) {
const serialized = JSON.stringify(request);
return `(async () => {
const request = ${serialized};
const renderer = window.__mdToPdfRenderDocxMedia;
if (!renderer) {
throw new Error("DOCX 媒体渲染运行时不可用");
}
return renderer(request.payload, request.dimensions);
})()`;
}
export async function captureDocxMediaWithElectronWebContents(
webContents: WebContents,
request: DocxMediaCaptureRequest,
signal?: AbortSignal,
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
) {
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
webContents.debugger.attach("1.3");
}
try {
await webContents.debugger.sendCommand("Emulation.setEmulatedMedia", {
media: "print"
});
const plan = (await withTimeout(
webContents.executeJavaScript(createRenderScript(request), true),
timeouts.renderMs,
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
signal
)) as DocxMediaCapturePlan;
signal?.throwIfAborted();
const captures = [];
for (const target of plan.targets) {
signal?.throwIfAborted();
const result = (await withTimeout(
webContents.debugger.sendCommand(
"Page.captureScreenshot",
{
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
}
),
timeouts.screenshotMs,
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind}PNG 捕获超过 ${timeouts.screenshotMs}ms`,
signal
)) as CaptureScreenshotResult;
captures.push({
id: target.id,
png: Buffer.from(result.data, "base64")
});
}
signal?.throwIfAborted();
return { plan, captures };
} finally {
if (webContents.debugger.isAttached()) {
await webContents.debugger
.sendCommand("Emulation.setEmulatedMedia", { media: "screen" })
.catch(() => undefined);
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
}
}
}
}
export class ElectronDocxMediaCaptureAdapter
implements DocxMediaCaptureAdapter
{
constructor(private readonly webContents: WebContents) {}
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
return captureDocxMediaWithElectronWebContents(
this.webContents,
request,
signal
);
}
}
@@ -0,0 +1,248 @@
import { BrowserWindow } from "electron";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type {
DocxThemeStyleCaptureAdapter,
DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { captureDocxMediaWithElectronWebContents } from "./electron-docx-media-capture.js";
import { captureDocxThemeStyleWithElectronWebContents } from "./electron-docx-theme-style.js";
import { isAllowedPdfRuntimeUrl } from "./pdf-contract.js";
export const DESKTOP_DOCX_PARTITION =
"md-to-pdf-desktop-docx";
export interface ElectronDocxMediaEngineOptions {
renderUrl: string;
}
export async function withActiveDocxCaptureSurface<T>(
window: Pick<
BrowserWindow,
"showInactive" | "hide" | "isDestroyed"
>,
operation: () => Promise<T>,
settleMs = 50
) {
window.showInactive();
await new Promise((resolve) => setTimeout(resolve, settleMs));
try {
return await operation();
} finally {
if (!window.isDestroyed()) {
window.hide();
}
}
}
export class ElectronDocxMediaEngine
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
{
readonly themeStyleBaseUrl: string;
private readonly renderUrl: string;
private windowPromise: Promise<BrowserWindow> | undefined;
private queue = Promise.resolve();
private closed = false;
constructor(options: ElectronDocxMediaEngineOptions) {
this.renderUrl = options.renderUrl;
this.themeStyleBaseUrl = this.renderUrl;
}
capture(
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
}
const operation = this.queue.then(() =>
this.captureNow(request, signal)
);
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
captureThemeStyle(
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
) {
if (this.closed) {
return Promise.reject(new Error("桌面 DOCX 媒体引擎已关闭"));
}
const operation = this.queue.then(() =>
this.captureThemeStyleNow(request, signal)
);
this.queue = operation.then(
() => undefined,
() => undefined
);
return operation;
}
async close() {
this.closed = true;
await this.queue;
const windowPromise = this.windowPromise;
this.windowPromise = undefined;
if (!windowPromise) {
return;
}
try {
const window = await windowPromise;
if (!window.isDestroyed()) {
window.destroy();
}
} catch {
// 创建失败时没有需要关闭的窗口。
}
}
private async captureNow(
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
signal?.throwIfAborted();
const window = await this.getWindow();
signal?.throwIfAborted();
const abort = () => {
if (!window.isDestroyed()) {
window.destroy();
}
};
signal?.addEventListener("abort", abort, { once: true });
try {
return await withActiveDocxCaptureSurface(
window,
() =>
captureDocxMediaWithElectronWebContents(
window.webContents,
request,
signal
)
);
} finally {
signal?.removeEventListener("abort", abort);
}
}
private async captureThemeStyleNow(
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
) {
signal?.throwIfAborted();
if (
new URL(request.baseUrl).origin !==
new URL(this.renderUrl).origin
) {
throw new Error("桌面 DOCX 主题资源地址必须与渲染地址同源");
}
const window = await this.getWindow();
signal?.throwIfAborted();
const abort = () => {
if (!window.isDestroyed()) {
window.destroy();
}
};
signal?.addEventListener("abort", abort, { once: true });
try {
return await captureDocxThemeStyleWithElectronWebContents(
window.webContents,
request,
signal
);
} finally {
signal?.removeEventListener("abort", abort);
}
}
private getWindow() {
if (!this.windowPromise) {
this.windowPromise = this.createWindow().catch((error) => {
this.windowPromise = undefined;
throw error;
});
}
return this.windowPromise;
}
private async createWindow() {
const window = new BrowserWindow({
show: false,
focusable: false,
opacity: 0,
skipTaskbar: true,
x: -32_000,
y: -32_000,
width: 1280,
height: 900,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: DESKTOP_DOCX_PARTITION
}
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, url) => {
const target = new URL(url);
const renderTarget = new URL(this.renderUrl);
if (
target.origin !== renderTarget.origin ||
target.pathname !== renderTarget.pathname
) {
event.preventDefault();
}
});
window.webContents.on("destroyed", () => {
this.windowPromise = undefined;
});
window.webContents.session.webRequest.onBeforeRequest(
{ urls: ["*://*/*"] },
(details, callback) => {
callback({
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
});
}
);
try {
await window.loadURL(this.renderUrl);
await window.webContents.executeJavaScript(`
new Promise((resolve, reject) => {
if (document.documentElement.dataset.runtimeReady === "true") {
resolve();
return;
}
const timeout = setTimeout(() => {
observer.disconnect();
reject(new Error("桌面 DOCX 媒体运行时启动超时"));
}, 10000);
const observer = new MutationObserver(() => {
if (document.documentElement.dataset.runtimeReady === "true") {
clearTimeout(timeout);
observer.disconnect();
resolve();
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-runtime-ready"]
});
})
`);
window.webContents.setZoomFactor(1);
return window;
} catch (error) {
window.destroy();
throw error;
}
}
}
@@ -0,0 +1,45 @@
import {
createDocxThemeStyleCaptureScript,
parseDocxThemeStyleRuntimeCapture,
type DocxThemeStyleCaptureRequest,
type DocxThemeStyleSnapshot
} from "@md-to-pdf/docx-theme-engine";
import type { WebContents } from "electron";
export async function captureDocxThemeStyleWithElectronWebContents(
webContents: WebContents,
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
): Promise<DocxThemeStyleSnapshot> {
signal?.throwIfAborted();
const ownsDebugger = !webContents.debugger.isAttached();
if (ownsDebugger) {
webContents.debugger.attach("1.3");
}
try {
await webContents.debugger.sendCommand(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
const capture = await webContents.executeJavaScript(
createDocxThemeStyleCaptureScript(request),
true
);
signal?.throwIfAborted();
return parseDocxThemeStyleRuntimeCapture(request, capture);
} finally {
if (
!webContents.isDestroyed() &&
webContents.debugger.isAttached()
) {
await webContents.debugger
.sendCommand("Emulation.setEmulatedMedia", {
media: "screen"
})
.catch(() => undefined);
if (ownsDebugger && webContents.debugger.isAttached()) {
webContents.debugger.detach();
}
}
}
}
+225 -177
View File
@@ -1,14 +1,12 @@
import { import {
app, app,
BrowserWindow, Menu,
dialog,
ipcMain,
net, net,
protocol, protocol,
session, session
type IpcMainInvokeEvent
} from "electron"; } from "electron";
import { access, writeFile } from "node:fs/promises"; import { access, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { import {
@@ -16,27 +14,52 @@ import {
type ApplicationService type ApplicationService
} from "@md-to-pdf/application"; } from "@md-to-pdf/application";
import { import {
DESKTOP_GET_THEME_CSS, parseFontPackResourceUrl,
DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
import {
parseMarkdownRenderRequest,
parseThemeId,
parseThemeResourceUrl parseThemeResourceUrl
} from "./application-contract.js"; } from "./application-contract.js";
import { import {
DESKTOP_PDF_PARTITION, DesktopApplicationController
ElectronPdfGenerator } from "./desktop-application-controller.js";
} from "./electron-pdf-generator.js"; import { DESKTOP_PDF_PARTITION } from "./electron-pdf-generator.js";
import { parsePagedDocumentPayload } from "./pdf-contract.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_SCHEME = "mdpdf";
const APP_HOST = "bundle"; const APP_HOST = "bundle";
const THEME_HOST = "theme"; const THEME_HOST = "theme";
const FONT_PACK_HOST = "font-pack";
const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); 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([ protocol.registerSchemesAsPrivileged([
{ {
@@ -89,17 +112,73 @@ function encodeThemeAssetPath(assetPath: string) {
.join("/"); .join("/");
} }
function dispatchExternalMarkdown(filePath: string) {
if (!applicationController) {
pendingExternalMarkdownPath = filePath;
return;
}
void applicationController.openExternalMarkdown(filePath).catch(
(error: unknown) => {
console.warn("无法打开系统传入的 Markdown 文件", error);
}
);
}
function getDesktopThemeDirectory() {
return app.isPackaged
? path.join(app.getPath("userData"), "themes")
: path.resolve(currentDirectory, "../../..", ".local", "themes");
}
function getDesktopUserFontPackDirectory() {
if (!app.isPackaged) {
return path.resolve(
currentDirectory,
"../../..",
".local",
"font-packs"
);
}
const localApplicationData = process.env.LOCALAPPDATA?.trim();
return localApplicationData
? path.join(localApplicationData, "md-to-pdf", "font-packs")
: path.join(app.getPath("userData"), "font-packs");
}
function createDesktopApplicationService() { function createDesktopApplicationService() {
const projectRoot = path.resolve(currentDirectory, "../../.."); const projectRoot = path.resolve(currentDirectory, "../../..");
const fontPackRoots = app.isPackaged
? [
path.join(process.resourcesPath, "font-packs"),
getDesktopUserFontPackDirectory()
]
: [
path.join(projectRoot, "output", "font-packs", "root"),
getDesktopUserFontPackDirectory()
];
return createApplicationService({ return createApplicationService({
bundledRoot: app.isPackaged bundledRoot: app.isPackaged
? path.join(process.resourcesPath, "themes") ? path.join(process.resourcesPath, "themes")
: path.join(projectRoot, "themes"), : path.join(projectRoot, "themes"),
localRoot: app.isPackaged localRoot: getDesktopThemeDirectory(),
? path.join(app.getPath("userData"), "themes")
: path.join(projectRoot, ".local", "themes"),
createAssetUrl: (themeId, assetPath) => createAssetUrl: (themeId, assetPath) =>
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`, `${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
fontPacks: {
roots: fontPackRoots,
appVersion: app.getVersion(),
createAssetUrl: (
packId,
version,
faceId,
kind,
sha256
) =>
`${APP_SCHEME}://${FONT_PACK_HOST}/${encodeURIComponent(
packId
)}/${encodeURIComponent(version)}/${encodeURIComponent(
faceId
)}/${kind}?v=${sha256}`
},
onWarning: (message) => console.warn(message) onWarning: (message) => console.warn(message)
}); });
} }
@@ -144,6 +223,34 @@ async function registerApplicationProtocol(
return new Response("Not found", { status: 404 }); 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) { if (url.host !== APP_HOST) {
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
} }
@@ -156,12 +263,10 @@ async function registerApplicationProtocol(
} catch { } catch {
return new Response("Bad request", { status: 400 }); return new Response("Bad request", { status: 400 });
} }
const target = path.resolve(webRoot, relativePath); const target = path.resolve(webRoot, relativePath);
if (!isContainedPath(webRoot, target)) { if (!isContainedPath(webRoot, target)) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
try { try {
await access(target); await access(target);
} catch { } catch {
@@ -174,174 +279,117 @@ async function registerApplicationProtocol(
protocol.handle(APP_SCHEME, handleRequest), protocol.handle(APP_SCHEME, handleRequest),
session session
.fromPartition(DESKTOP_PDF_PARTITION) .fromPartition(DESKTOP_PDF_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest),
session
.fromPartition(DESKTOP_DOCX_PARTITION)
.protocol.handle(APP_SCHEME, handleRequest) .protocol.handle(APP_SCHEME, handleRequest)
]); ]);
} }
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) { const hasSingleInstanceLock = app.requestSingleInstanceLock();
window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); let windowStateReadyToQuit = false;
window.webContents.on("will-navigate", (event, targetUrl) => {
if (new URL(targetUrl).origin !== allowedOrigin) { if (!hasSingleInstanceLock) {
event.preventDefault(); app.quit();
} else {
app.on("second-instance", (_event, arguments_, workingDirectory) => {
const filePath = findMarkdownFileArgument(
arguments_,
workingDirectory
);
if (filePath) {
dispatchExternalMarkdown(filePath);
} else if (!applicationController?.focusMostRecentWindow()) {
void applicationController?.createWindow();
} }
}); });
const applicationSession = window.webContents.session; app.on("open-file", (event, filePath) => {
applicationSession.setPermissionCheckHandler(() => false); event.preventDefault();
applicationSession.setPermissionRequestHandler( if (isMarkdownFilePath(filePath)) {
(_webContents, _permission, callback) => callback(false) dispatchExternalMarkdown(filePath);
);
}
async function createApplication(
applicationService: ApplicationService
) {
const developmentUrl = getCommandLineValue("web-url");
const applicationUrl =
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
const renderUrl = new URL(
"/preview-frame.html?target=pdf",
applicationUrl
).href;
const window = new BrowserWindow({
title: `Markdown PDF 导出器 v${app.getVersion()}`,
icon: getApplicationIcon(),
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(currentDirectory, "app-preload.cjs")
} }
}); });
lockDownWindow(window, new URL(applicationUrl).origin); app.on("activate", () => {
if (!applicationController?.focusMostRecentWindow()) {
const pdfGenerator = new ElectronPdfGenerator({ void applicationController?.createWindow();
renderUrl, }
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
}); });
const validateSender = (event: IpcMainInvokeEvent) => app.on("before-quit", (event) => {
event.sender === window.webContents && if (windowStateReadyToQuit || !applicationController) {
event.senderFrame !== null && return;
new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).origin;
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest)
);
} }
); event.preventDefault();
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => { void Promise.all([
if (!validateSender(event)) { applicationController.flushWindowState(),
throw new Error("拒绝未授权的桌面主题请求"); applicationController.closeResources()
} ]).finally(() => {
return applicationService.listThemes(); windowStateReadyToQuit = true;
}); app.quit();
ipcMain.handle( setTimeout(() => {
DESKTOP_GET_THEME_CSS, windowStateReadyToQuit = false;
async (event, unsafeThemeId: unknown) => { }, 1_000);
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面主题请求");
}
const css = await applicationService.getThemeCss(
parseThemeId(unsafeThemeId)
);
if (css === undefined) {
throw new Error("未找到指定主题");
}
return css;
}
);
ipcMain.handle(
DESKTOP_GENERATE_PDF,
async (event, unsafePayload: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 请求");
}
const payload = parsePagedDocumentPayload(unsafePayload);
const result = await pdfGenerator.generate(payload);
return {
pdf: result.pdf,
pageCount: result.pageCount,
echartsErrors: result.echartsErrors,
mermaidErrors: result.mermaidErrors
};
}
);
ipcMain.handle(
DESKTOP_SAVE_PDF,
async (
event,
unsafeFileName: unknown,
unsafePdf: unknown
) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面 PDF 保存请求");
}
if (
typeof unsafeFileName !== "string" ||
unsafeFileName.length === 0 ||
unsafeFileName.length > 500 ||
!(unsafePdf instanceof Uint8Array) ||
unsafePdf.byteLength === 0 ||
unsafePdf.byteLength > 200 * 1024 * 1024
) {
throw new Error("PDF 保存参数无效");
}
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
? path.basename(unsafeFileName)
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(window, {
title: "导出 PDF",
defaultPath: path.join(app.getPath("documents"), suggestedName),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
}); });
if (selection.canceled || !selection.filePath) { });
return false; app
.whenReady()
.then(async () => {
await userDataMigration;
Menu.setApplicationMenu(null);
if (process.platform === "win32") {
app.setAppUserModelId(__APP_ID__);
} }
await writeFile(selection.filePath, unsafePdf); const themeDirectory = getDesktopThemeDirectory();
return true; await mkdir(themeDirectory, { recursive: true });
} const applicationService = createDesktopApplicationService();
); await registerApplicationProtocol(applicationService);
window.once("ready-to-show", () => window.show()); const developmentUrl = getCommandLineValue("web-url");
window.on("closed", () => { const applicationUrl =
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN); developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
ipcMain.removeHandler(DESKTOP_LIST_THEMES); applicationController = new DesktopApplicationController({
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS); applicationService,
ipcMain.removeHandler(DESKTOP_GENERATE_PDF); applicationUrl,
ipcMain.removeHandler(DESKTOP_SAVE_PDF); renderUrl: new URL(
void pdfGenerator.close(); "/preview-frame.html?target=pdf",
}); applicationUrl
await window.loadURL(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
.whenReady()
.then(async () => {
if (process.platform === "win32") {
app.setAppUserModelId("com.md-to-pdf.desktop");
}
const applicationService = createDesktopApplicationService();
await registerApplicationProtocol(applicationService);
await createApplication(applicationService);
})
.catch((error: unknown) => {
console.error("桌面应用启动失败", error);
app.quit();
});
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); 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)
};
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { import {
exportConfigSchema, exportConfigSchema,
semanticDocumentModelSchema,
themeFeatureSchema, themeFeatureSchema,
type PagedDocumentPayload, type PagedDocumentPayload,
type PagedDocumentRenderResult type PagedDocumentRenderResult
@@ -66,6 +67,9 @@ export function parsePagedDocumentPayload(
), ),
language: readString(metadata, "language", 50) language: readString(metadata, "language", 50)
}, },
semanticDocument: semanticDocumentModelSchema.parse(
value.semanticDocument
),
features: parsedFeatures, features: parsedFeatures,
themeCss: readString( themeCss: readString(
value, value,
@@ -117,7 +121,10 @@ export function isAllowedPdfRuntimeUrl(
if (["about:", "blob:", "data:"].includes(requested.protocol)) { if (["about:", "blob:", "data:"].includes(requested.protocol)) {
return true; return true;
} }
if (requested.protocol === "mdpdf:" && requested.host === "theme") { if (
requested.protocol === "mdpdf:" &&
["theme", "font-pack"].includes(requested.host)
) {
return true; return true;
} }
return requested.origin === new URL(renderUrl).origin; return requested.origin === new URL(renderUrl).origin;
+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,175 @@
import { describe, expect, it, vi } from "vitest";
import type { WebContents } from "electron";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { captureDocxMediaWithElectronWebContents } from "../src/electron-docx-media-capture.js";
const plan: DocxMediaCapturePlan = {
targets: [
{
id: "docx-media-1",
kind: "image",
ordinal: 1,
kindOrdinal: 1,
altText: "图片",
alignment: "center",
displayWidthPx: 200,
displayHeightPx: 100,
captureX: 12,
captureY: 24,
captureWidthPx: 200,
captureHeightPx: 100,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Electron DOCX 媒体捕获", () => {
it("复用隐藏 Chromium 并在捕获后释放调试会话", async () => {
let attached = false;
const attach = vi.fn(() => {
attached = true;
});
const detach = vi.fn(() => {
attached = false;
});
const sendCommand = vi.fn(async () => ({
data: Buffer.from("png").toString("base64")
}));
const webContents = {
executeJavaScript: vi.fn(async () => plan),
debugger: {
isAttached: vi.fn(() => attached),
attach,
detach,
sendCommand
}
} as unknown as WebContents;
const result = await captureDocxMediaWithElectronWebContents(
webContents,
request
);
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
});
expect(attach).toHaveBeenCalledWith("1.3");
expect(sendCommand).toHaveBeenCalledWith(
"Emulation.setEmulatedMedia",
{ media: "print" }
);
expect(sendCommand).toHaveBeenCalledWith(
"Emulation.setEmulatedMedia",
{ media: "screen" }
);
expect(sendCommand).toHaveBeenCalledWith(
"Page.captureScreenshot",
expect.objectContaining({
format: "png",
clip: expect.objectContaining({ scale: 3.125 })
})
);
expect(detach).toHaveBeenCalledOnce();
});
it("媒体布局永久不返回时报告局部超时", async () => {
let attached = false;
const webContents = {
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
debugger: {
isAttached: vi.fn(() => attached),
attach: vi.fn(() => {
attached = true;
}),
detach: vi.fn(() => {
attached = false;
}),
sendCommand: vi.fn(async () => undefined)
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
});
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
let attached = false;
const detach = vi.fn(() => {
attached = false;
});
const webContents = {
executeJavaScript: vi.fn(async () => plan),
debugger: {
isAttached: vi.fn(() => attached),
attach: vi.fn(() => {
attached = true;
}),
detach,
sendCommand: vi.fn((method: string) =>
method === "Page.captureScreenshot"
? new Promise(() => undefined)
: Promise.resolve(undefined)
)
}
} as unknown as WebContents;
await expect(
captureDocxMediaWithElectronWebContents(
webContents,
request,
undefined,
{ renderMs: 10, screenshotMs: 10 }
)
).rejects.toThrow(
"桌面 DOCX 第 1/1 个媒体(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()
);
});
});
+33
View File
@@ -10,6 +10,7 @@ import {
} from "../src/pdf-contract.js"; } from "../src/pdf-contract.js";
import { import {
parseMarkdownRenderRequest, parseMarkdownRenderRequest,
parseFontPackResourceUrl,
parseThemeId, parseThemeId,
parseThemeResourceUrl parseThemeResourceUrl
} from "../src/application-contract.js"; } from "../src/application-contract.js";
@@ -26,6 +27,14 @@ const payload = createPagedDocumentPayload({
keywords: [], keywords: [],
language: "zh-CN" language: "zh-CN"
}, },
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
features: ["mermaid"], features: ["mermaid"],
warnings: [] warnings: []
}, },
@@ -97,6 +106,12 @@ describe("桌面 PDF 网络边界", () => {
renderUrl renderUrl
) )
).toBe(true); ).toBe(true);
expect(
isAllowedPdfRuntimeUrl(
"mdpdf://font-pack/official-cjk/1.0.0/serif/web?v=abc",
renderUrl
)
).toBe(true);
}); });
it("拒绝外部网络资源", () => { it("拒绝外部网络资源", () => {
@@ -135,4 +150,22 @@ describe("桌面应用服务 IPC 载荷", () => {
parseThemeResourceUrl("mdpdf://bundle/index.html") parseThemeResourceUrl("mdpdf://bundle/index.html")
).toBeUndefined(); ).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("字体包资源地址无效");
});
}); });
+265
View File
@@ -0,0 +1,265 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
interface PackageManifest {
version?: string;
productName?: string;
scripts?: Record<string, string>;
}
interface ProductBrand {
appId: string;
englishName: string;
displayName: string;
windowTitle: string;
executableName: string;
artifactPrefix: string;
}
interface PandocRuntimeManifest {
version: string;
artifacts: Array<{
platform: string;
architecture: string;
sha256: string;
licenseFiles: string[];
files?: Record<string, { bytes: number; sha256: string }>;
}>;
}
const desktopRoot = fileURLToPath(new URL("../", import.meta.url));
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
function readManifest<T = PackageManifest>(path: string): T {
return JSON.parse(readFileSync(path, "utf8")) as T;
}
describe("桌面发行构建链", () => {
it("打包前按依赖顺序完整重建内嵌 Web", () => {
const rootManifest = readManifest(`${projectRoot}/package.json`);
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
const webBuild = rootManifest.scripts?.["build:web-runtime"] ?? "";
const expectedWorkspaces = [
"@md-to-pdf/markdown-echarts",
"@md-to-pdf/core",
"@md-to-pdf/renderer",
"@md-to-pdf/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 build:font-pack && npm run package -w @md-to-pdf/desktop"
);
expect(rootManifest.scripts?.["desktop:make"]).toBe(
"npm run build:font-pack && node scripts/build-desktop-release.mjs"
);
expect(rootManifest.scripts?.["desktop:make:with-font-pack"]).toBeUndefined();
});
it("将固定 Pandoc 最小运行时作为 Desktop 独立资源打包", () => {
const desktopManifest = readManifest(`${desktopRoot}/package.json`);
const runtimeManifest = readManifest<PandocRuntimeManifest>(
`${projectRoot}/packages/docx-engine/src/pandoc-runtime.json`
);
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const prepareScript = readFileSync(
`${desktopRoot}/scripts/prepare-pandoc-runtime.mjs`,
"utf8"
);
const windowsArtifact = runtimeManifest.artifacts.find(
(artifact) =>
artifact.platform === "win32" && artifact.architecture === "x64"
);
expect(runtimeManifest.version).toBe("3.9.0.2");
expect(windowsArtifact).toMatchObject({
sha256:
"C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A",
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"],
files: {
"pandoc.exe": {
bytes: 231056136,
sha256:
"E83F8354C0F507222B5684797B9C5AE766F03889785995D14AAC27816EC456BA"
}
}
});
expect(desktopManifest.scripts?.["prepare:pandoc"]).toBe(
"node scripts/prepare-pandoc-runtime.mjs"
);
expect(builderConfig).toContain(
"`.runtime/pandoc/${pandocRuntime.version}/windows-x86_64`"
);
expect(builderConfig).toContain('"pandoc.exe"');
expect(builderConfig).toContain('"COPYING.rtf"');
expect(builderConfig).toContain('"COPYRIGHT.txt"');
expect(prepareScript).toContain("sha256(downloaded)");
expect(prepareScript).toContain("smokeTestPandoc");
expect(prepareScript).toContain('process.argv.includes("--check")');
});
it("将内置主题示例作为独立资源复制到发行目录", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
expect(builderConfig).toContain('from: "../../samples"');
expect(builderConfig).toContain('to: "samples"');
});
it("保持安装身份并从统一配置生成 MorphDoc 发行名称", () => {
const brand = readManifest<ProductBrand>(
`${projectRoot}/product.json`
);
const desktopManifest = readManifest(
`${desktopRoot}/package.json`
);
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
expect(brand).toMatchObject({
appId: "com.md-to-pdf.desktop",
englishName: "MorphDoc",
displayName: "墨呈",
windowTitle: "墨呈 · MorphDoc",
executableName: "MorphDoc",
artifactPrefix: "MorphDoc"
});
expect(desktopManifest).toMatchObject({
version: "0.6.2",
productName: "MorphDoc"
});
expect(builderConfig).toContain("appId: brand.appId");
expect(builderConfig).toContain("productName: brand.englishName");
expect(builderConfig).toContain("executableName: brand.executableName");
expect(builderConfig).toContain("shortcutName: brand.displayName");
expect(installerInclude).toContain(
'!define MORPHDOC_EXECUTABLE "MorphDoc.exe"'
);
expect(installerInclude).toContain(
'!define MORPHDOC_DISPLAY_NAME "墨呈"'
);
expect(installerInclude).toContain(
'!define INSTALL_REGISTRY_KEY "Software\\${APP_GUID}.MorphDoc"'
);
expect(installerInclude).toContain(
'DeleteRegKey SHCTX "${MORPHDOC_LEGACY_INSTALL_REGISTRY_KEY}"'
);
expect(installerInclude).toContain(
"Software\\Classes\\Applications\\${MORPHDOC_LEGACY_EXECUTABLE}"
);
});
it("在安装版与便携版中强制内置完整字体包集合", () => {
const builderConfig = readFileSync(
`${desktopRoot}/electron-builder.config.cjs`,
"utf8"
);
const desktopMain = readFileSync(
`${desktopRoot}/src/main.ts`,
"utf8"
);
const bundledBuild = readFileSync(
`${projectRoot}/scripts/build-desktop-release.mjs`,
"utf8"
);
const installerInclude = readFileSync(
`${desktopRoot}/build/installer.nsh`,
"utf8"
);
expect(builderConfig).toContain('../../output/font-packs/root');
expect(builderConfig).toContain('to: "font-packs"');
expect(desktopMain).toContain(
'path.join(process.resourcesPath, "font-packs")'
);
expect(desktopMain).toContain("getDesktopUserFontPackDirectory()");
expect(bundledBuild).not.toContain("createDesktopCompanionInclude");
expect(bundledBuild).toContain("已生成字体直接内置");
expect(installerInclude).not.toContain("FONT_PACK_COMPANION");
expect(installerInclude).not.toContain("FontPackCompanionPage");
expect(installerInclude).not.toContain("/WITHFONTPACK=");
});
it("将 DOCX Lua Filter 复制到桌面主进程资源目录", () => {
const buildScript = readFileSync(
`${desktopRoot}/scripts/build-main.mjs`,
"utf8"
);
expect(buildScript).toContain(
"packages/docx-engine/assets/docx-media-filter.lua"
);
expect(buildScript).toContain("dist/docx-media-filter.lua");
});
it("为 ESM 主进程中的 CommonJS 字体转换依赖保留 __dirname", () => {
const buildScript = readFileSync(
`${desktopRoot}/scripts/build-main.mjs`,
"utf8"
);
expect(buildScript).toContain(
'dirname as __mdToPdfDirname'
);
expect(buildScript).toContain(
'fileURLToPath as __mdToPdfFileURLToPath'
);
expect(buildScript).toContain(
"const __dirname = __mdToPdfDirname(__mdToPdfFileURLToPath(import.meta.url));"
);
});
it("在 Docker Web 构建前复制样例并保留到运行镜像", () => {
const dockerfile = readFileSync(
`${projectRoot}/deploy/Dockerfile`,
"utf8"
);
const samplesBuildCopyIndex = dockerfile.indexOf(
"COPY samples ./samples"
);
const webBuildIndex = dockerfile.indexOf(
"npm run build -w @md-to-pdf/web"
);
expect(samplesBuildCopyIndex).toBeGreaterThan(-1);
expect(webBuildIndex).toBeGreaterThan(samplesBuildCopyIndex);
expect(dockerfile).toContain(
"COPY --chown=pwuser:pwuser --from=build /app/samples ./samples"
);
});
});
@@ -0,0 +1,153 @@
import {
mkdir,
mkdtemp,
readFile,
rm
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
SaveLocationStore,
loadSaveLocationState,
parseSaveLocationState,
resolveDefaultSavePath,
saveSaveLocationState
} from "../src/save-location-state.js";
let temporaryDirectory: string | undefined;
async function createTemporaryDirectory() {
temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "md-to-pdf-save-location-")
);
return temporaryDirectory;
}
afterEach(async () => {
if (temporaryDirectory) {
await rm(temporaryDirectory, { recursive: true, force: true });
temporaryDirectory = undefined;
}
});
describe("桌面默认保存位置", () => {
it("只接受版本匹配的绝对目录状态", () => {
expect(
parseSaveLocationState({
version: 1,
directory: path.resolve("C:/Documents")
})
).toEqual({
version: 1,
directory: path.resolve("C:/Documents")
});
expect(
parseSaveLocationState({ version: 2, directory: "C:/Documents" })
).toBeUndefined();
expect(
parseSaveLocationState({ version: 1, directory: "relative" })
).toBeUndefined();
});
it("优先使用有效的源文件目录", async () => {
const root = await createTemporaryDirectory();
const sourceDirectory = path.join(root, "source");
const historyDirectory = path.join(root, "history");
const documentsDirectory = path.join(root, "documents");
await Promise.all([
mkdir(sourceDirectory),
mkdir(historyDirectory),
mkdir(documentsDirectory)
]);
await expect(
resolveDefaultSavePath({
sourceFilePath: path.join(sourceDirectory, "source.md"),
lastSaveDirectory: historyDirectory,
documentsDirectory,
suggestedFileName: "report.pdf"
})
).resolves.toBe(path.join(sourceDirectory, "report.pdf"));
});
it("源目录不可用时使用最近成功目录", async () => {
const root = await createTemporaryDirectory();
const historyDirectory = path.join(root, "history");
const documentsDirectory = path.join(root, "documents");
await Promise.all([
mkdir(historyDirectory),
mkdir(documentsDirectory)
]);
await expect(
resolveDefaultSavePath({
sourceFilePath: path.join(root, "missing", "source.md"),
lastSaveDirectory: historyDirectory,
documentsDirectory,
suggestedFileName: "report.docx"
})
).resolves.toBe(path.join(historyDirectory, "report.docx"));
});
it("源目录和历史目录均不可用时回退到文档目录", async () => {
const root = await createTemporaryDirectory();
const documentsDirectory = path.join(root, "documents");
await mkdir(documentsDirectory);
await expect(
resolveDefaultSavePath({
lastSaveDirectory: path.join(root, "missing"),
documentsDirectory,
suggestedFileName: "draft.md"
})
).resolves.toBe(path.join(documentsDirectory, "draft.md"));
});
it("以原子 JSON 状态持久化并恢复最近目录", async () => {
const root = await createTemporaryDirectory();
const directory = path.join(root, "saved");
const statePath = path.join(root, "state", "save-location.json");
await mkdir(directory);
await saveSaveLocationState(statePath, directory);
await expect(loadSaveLocationState(statePath)).resolves.toEqual({
version: 1,
directory
});
expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({
version: 1,
directory
});
});
it("跨 Store 实例恢复最近成功文件目录", async () => {
const root = await createTemporaryDirectory();
const savedDirectory = path.join(root, "saved");
const documentsDirectory = path.join(root, "documents");
const statePath = path.join(root, "save-location.json");
await Promise.all([
mkdir(savedDirectory),
mkdir(documentsDirectory)
]);
const first = new SaveLocationStore(
statePath,
documentsDirectory
);
await first.initialize();
await first.recordSuccessfulFile(
path.join(savedDirectory, "first.pdf")
);
const restored = new SaveLocationStore(
statePath,
documentsDirectory
);
await restored.initialize();
await expect(
restored.resolveDefaultPath(undefined, "second.docx")
).resolves.toBe(path.join(savedDirectory, "second.docx"));
});
});
@@ -0,0 +1,84 @@
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { rm } from "node:fs/promises";
import { migrateLegacyUserData } from "../src/user-data-migration.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
async function createRoot() {
const root = await mkdtemp(path.join(tmpdir(), "morphdoc-migration-"));
temporaryDirectories.push(root);
return root;
}
describe("品牌升级用户数据迁移", () => {
it("首次启动时复制状态、主题和浏览器本地存储但保留旧目录", async () => {
const root = await createRoot();
const legacy = path.join(root, "Markdown PDF 导出器");
const target = path.join(root, "MorphDoc");
await mkdir(path.join(legacy, "themes", "custom"), {
recursive: true
});
await mkdir(path.join(legacy, "Local Storage"), {
recursive: true
});
await writeFile(
path.join(legacy, "window-state.json"),
'{"width":1280}',
"utf8"
);
await writeFile(
path.join(legacy, "themes", "custom", "theme.css"),
"#write{}",
"utf8"
);
const result = await migrateLegacyUserData([legacy], target);
expect(result).toMatchObject({
migrated: true,
sourceDirectory: legacy,
copiedEntries: ["window-state.json", "themes", "Local Storage"]
});
expect(
await readFile(path.join(target, "window-state.json"), "utf8")
).toBe('{"width":1280}');
expect(
await readFile(
path.join(target, "themes", "custom", "theme.css"),
"utf8"
)
).toBe("#write{}");
expect(
await readFile(path.join(legacy, "window-state.json"), "utf8")
).toBe('{"width":1280}');
});
it("目标目录已经存在时不覆盖新版本数据", async () => {
const root = await createRoot();
const legacy = path.join(root, "Markdown PDF 导出器");
const target = path.join(root, "MorphDoc");
await mkdir(legacy, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(path.join(legacy, "window-state.json"), "legacy", "utf8");
await writeFile(path.join(target, "window-state.json"), "current", "utf8");
expect(await migrateLegacyUserData([legacy], target)).toEqual({
migrated: false,
copiedEntries: []
});
expect(
await readFile(path.join(target, "window-state.json"), "utf8")
).toBe("current");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { getWindowCloseDecision } from "../src/window-close-policy.js";
describe("桌面窗口关闭策略", () => {
it("干净文档或已授权窗口可以直接关闭", () => {
expect(
getWindowCloseDecision({
dirty: false,
allowClose: false,
closeRequestPending: false
})
).toBe("allow");
expect(
getWindowCloseDecision({
dirty: true,
allowClose: true,
closeRequestPending: false
})
).toBe("allow");
});
it("未保存文档只发出一次关闭确认请求", () => {
expect(
getWindowCloseDecision({
dirty: true,
allowClose: false,
closeRequestPending: false
})
).toBe("request");
expect(
getWindowCloseDecision({
dirty: true,
allowClose: false,
closeRequestPending: true
})
).toBe("wait");
});
});
+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 });
}
});
});
+8 -2
View File
@@ -8,12 +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.0", "@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,923 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defaultExportConfig, themeManifestSchema } from "@md-to-pdf/core";
import { unzipSync } from "fflate";
import matter from "gray-matter";
import { buildApp } from "../dist/app.js";
import { createDocxMediaEngine } from "../dist/docx-media-engine.js";
import { createPdfGenerator } from "../dist/pdf-engine.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const appVersion = JSON.parse(
fs.readFileSync(path.join(repositoryDirectory, "package.json"), "utf8")
).version;
const themesDirectory = path.join(repositoryDirectory, "themes");
const samplesDirectory = path.join(repositoryDirectory, "samples", "themes");
const webDirectory = path.join(repositoryDirectory, "apps", "web", "dist");
const outputDirectory = path.resolve(
process.env.MD_TO_PDF_R4_OUTPUT_DIR?.trim() ||
path.join(repositoryDirectory, "output", "docx-r4")
);
const pdfDirectory = path.join(outputDirectory, "chromium-pdf");
const docxDirectory = path.join(outputDirectory, "docx");
const standardReportPath = path.join(
repositoryDirectory,
"output",
"docx-theme-matrix",
"theme-matrix-report.json"
);
const fontPackRoot = path.resolve(
process.env.MD_TO_PDF_FONT_PACK_DIR?.trim() ||
path.join(repositoryDirectory, "output", "font-packs", "root")
);
const decoder = new TextDecoder();
function writeArtifactWithBusyFallback(filePath, content) {
try {
fs.writeFileSync(filePath, content);
return filePath;
} catch (error) {
if (error?.code !== "EBUSY") {
throw error;
}
const extension = path.extname(filePath);
const fallbackPath = path.join(
path.dirname(filePath),
`${path.basename(filePath, extension)}-attempt-${randomUUID()}${extension}`
);
fs.writeFileSync(fallbackPath, content);
process.stderr.write(
`[DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件:${path.basename(fallbackPath)}\n`
);
return fallbackPath;
}
}
const inlineCodeContinuityProbes = [
{
id: "paragraph",
code: "mdtp_ic_p",
text: "段落前mdtp_ic_p段落后"
},
{
id: "list-item",
code: "mdtp_ic_l",
text: "列表前mdtp_ic_l列表后"
},
{
id: "table-cell",
code: "mdtp_ic_c",
text: "单元格前mdtp_ic_c单元格后"
}
];
const inlineCodeContinuityMarkdown = `
## 行内代码连续性门禁
段落前\`mdtp_ic_p\`段落后
- 列表前\`mdtp_ic_l\`列表后
| 门禁 | 内容 | 邻接单元格 |
| --- | --- | --- |
| 行内代码 | 单元格前\`mdtp_ic_c\`单元格后 | 邻甲<br>邻乙 |
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
| 场景 | 内容 |
| --- | --- |
| 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
| 叁式 | 连甲<br><br>连乙 |
| 肆式 | <br>边界<br> |
| 伍式 | 转甲\\<br>转乙 &lt;br&gt; 转丙 |
| 陆式 | 属甲<br class="unsafe">属乙 |
| 柒式 | 码甲\`<br>\`码乙 |
表格外保留 外一<br>外二 原文。
\`\`\`html
围一<br>围二
\`\`\`
`;
const docxBreakParagraphProbes = [
{
id: "basic",
marker: "标甲",
expectedText: "标甲标乙标丙标丁",
hardBreakCount: 3
},
{
id: "case-insensitive",
marker: "大甲",
expectedText: "大甲大乙大丙大丁",
hardBreakCount: 3
},
{
id: "multiple",
marker: "连甲",
expectedText: "连甲连乙",
hardBreakCount: 2
},
{
id: "boundary",
marker: "边界",
expectedText: "边界",
hardBreakCount: 2
},
{
id: "adjacent-a",
marker: "邻乙",
expectedText: "邻甲邻乙",
hardBreakCount: 1
},
{
id: "adjacent-b",
marker: "旁乙",
expectedText: "旁甲旁乙",
hardBreakCount: 1
},
{
id: "escaped-and-entity",
marker: "转甲",
expectedText: "转甲&lt;br&gt;转乙 &lt;br&gt; 转丙",
hardBreakCount: 0
},
{
id: "attribute",
marker: "属甲",
expectedText: "属甲&lt;br class=\"unsafe\"&gt;属乙",
hardBreakCount: 0
},
{
id: "inline-code",
marker: "码甲",
expectedText: "码甲&lt;br&gt;码乙",
hardBreakCount: 0,
requiredCharacterStyle: "VerbatimChar"
},
{
id: "outside-table",
marker: "外一",
expectedText: "表格外保留 外一&lt;br&gt;外二 原文。",
hardBreakCount: 0
},
{
id: "fenced-code",
marker: "围一",
expectedText: "围一&lt;br&gt;围二",
hardBreakCount: 0,
requiredParagraphStyle: "SourceCode"
}
];
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"]
]);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function newestModifiedAt(targetPath) {
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
return stat.mtimeMs;
}
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
(newest, entry) =>
Math.max(
newest,
newestModifiedAt(path.join(targetPath, entry.name))
),
stat.mtimeMs
);
}
function assertFreshArtifact(label, sourcePaths, artifactPath) {
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
assert(
artifactModifiedAt + 1_500 >= newestSource,
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
);
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExportConfigOverride() {
const source = process.env.MD_TO_PDF_R4_EXPORT_CONFIG_JSON?.trim();
if (!source) {
return undefined;
}
const parsed = JSON.parse(source);
assert(
parsed && typeof parsed === "object" && !Array.isArray(parsed),
"R4 导出配置覆盖必须是 JSON 对象"
);
return parsed;
}
function mergeExportConfig(base, override) {
if (!override) {
return base;
}
return {
...base,
...override,
mermaid: { ...base.mermaid, ...override.mermaid },
paper: {
...base.paper,
...override.paper,
margins: { ...base.paper.margins, ...override.paper?.margins }
},
header: {
...base.header,
...override.header,
left: { ...base.header.left, ...override.header?.left },
center: { ...base.header.center, ...override.header?.center },
right: { ...base.header.right, ...override.header?.right }
},
footer: { ...base.footer, ...override.footer },
metadata: { ...base.metadata, ...override.metadata },
print: { ...base.print, ...override.print }
};
}
function readThemes() {
return fs
.readdirSync(themesDirectory, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(themesDirectory, entry.name, "theme.json"))
)
.map((entry) =>
themeManifestSchema.parse(
readJson(path.join(themesDirectory, entry.name, "theme.json"))
)
)
.sort((first, second) => first.id.localeCompare(second.id, "en"));
}
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port =
typeof address === "object" && address ? address.port : undefined;
server.close((error) =>
error || !port
? reject(error ?? new Error("无法分配 R4 验收端口"))
: resolve(port)
);
});
});
}
function resolveWebFile(relativePath) {
const resolved = path.resolve(webDirectory, relativePath);
const relative = path.relative(webDirectory, resolved);
assert(
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative),
`Web 资源路径越界:${relativePath}`
);
return resolved;
}
function registerWebRuntime(app) {
app.get("/preview-frame.html", async (_request, reply) => {
const content = fs.readFileSync(resolveWebFile("preview-frame.html"));
return reply.type("text/html; charset=utf-8").send(content);
});
app.get("/assets/*", async (request, reply) => {
const relativePath = String(request.params["*"] ?? "");
const filePath = resolveWebFile(path.join("assets", relativePath));
const stat = fs.statSync(filePath);
assert(stat.isFile(), `Web 资源不是普通文件:${relativePath}`);
return reply
.type(
contentTypes.get(path.extname(filePath).toLowerCase()) ||
"application/octet-stream"
)
.send(fs.readFileSync(filePath));
});
}
function inspectDocx(content) {
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
const fontTableXml = decoder.decode(entries["word/fontTable.xml"]);
const fontParts = Object.keys(entries).filter((name) =>
name.startsWith("word/fonts/")
);
const fontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"/gu)
].map((match) => match[1]);
const embeddedFontNames = [
...fontTableXml.matchAll(/<w:font\s+w:name="([^"]+)"[\s\S]*?<\/w:font>/gu)
].flatMap((match) => /<w:embed(?:Regular|Bold|Italic|BoldItalic)\b/u.test(match[0])
? [match[1]]
: []);
const paragraphs = [
...documentXml.matchAll(/<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu)
].map((match) => match[0]);
const authorParagraphCount = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="Author"\s*\/>/u.test(paragraph)
).length;
const officialIssueRows = paragraphs.filter((paragraph) =>
/<w:pStyle\s+w:val="MdOfficialIssueRow"\s*\/>/u.test(
paragraph
)
);
const officialIssueRowsWithRightTab = officialIssueRows.filter(
(paragraph) =>
[...paragraph.matchAll(/<w:tab(?:\s[^>]*)?\s*\/>/gu)].some(
(match) =>
/\bw:val="right"/u.test(match[0]) &&
/\bw:pos="\d+"/u.test(match[0])
)
);
const officialIssueRowContentTabCount = officialIssueRows.reduce(
(count, paragraph) =>
count + (paragraph.match(/<w:tab\s*\/>/gu)?.length ?? 0),
0
);
const inlineCodeContinuity = inlineCodeContinuityProbes.map((probe) => {
const matchingParagraphs = paragraphs.filter((paragraph) =>
paragraph.includes(probe.code)
);
const paragraph = matchingParagraphs[0] ?? "";
const text = [...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)]
.map((match) => match[1])
.join("");
const codeRunPattern = new RegExp(
`<w:r(?:\\s|>)[\\s\\S]*?<w:rStyle\\s+w:val="VerbatimChar"\\s*\\/>[\\s\\S]*?<w:t(?:\\s[^>]*)?>${probe.code}<\\/w:t>[\\s\\S]*?<\\/w:r>`,
"u"
);
const hardBreakCount =
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
return {
id: probe.id,
code: probe.code,
expectedText: probe.text,
actualText: text,
paragraphCount: matchingParagraphs.length,
hardBreakCount,
characterStylePresent: codeRunPattern.test(paragraph),
passed:
matchingParagraphs.length === 1 &&
text === probe.text &&
hardBreakCount === 0 &&
codeRunPattern.test(paragraph)
};
});
const breakProbes = docxBreakParagraphProbes.map((probe) => {
const matchingParagraphs = paragraphs.map((paragraph) => ({
paragraph,
actualText: [
...paragraph.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu)
].map((match) => match[1]).join("")
})).filter(({ actualText }) => actualText === probe.expectedText);
const paragraph = matchingParagraphs[0]?.paragraph ?? "";
const actualText = matchingParagraphs[0]?.actualText ?? "";
const hardBreakCount =
paragraph.match(/<w:br(?:\s[^>]*)?\s*\/>/gu)?.length ?? 0;
const runs = paragraph.match(/<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu) ?? [];
const characterStylePresent = !probe.requiredCharacterStyle ||
runs.some((run) =>
run.includes(`w:rStyle w:val="${probe.requiredCharacterStyle}"`) &&
run.includes("&lt;br&gt;")
);
const paragraphStylePresent = !probe.requiredParagraphStyle ||
new RegExp(
`<w:pStyle\\s+w:val="${probe.requiredParagraphStyle}"\\s*\\/>`,
"u"
).test(paragraph);
return {
id: probe.id,
marker: probe.marker,
expectedText: probe.expectedText,
actualText,
expectedHardBreakCount: probe.hardBreakCount,
hardBreakCount,
paragraphCount: matchingParagraphs.length,
characterStylePresent,
paragraphStylePresent,
passed:
matchingParagraphs.length === 1 &&
actualText === probe.expectedText &&
hardBreakCount === probe.hardBreakCount &&
characterStylePresent &&
paragraphStylePresent
};
});
const adjacentA = breakProbes.find((probe) => probe.id === "adjacent-a");
const adjacentB = breakProbes.find((probe) => probe.id === "adjacent-b");
const tableBreakSemantics = {
probes: breakProbes,
adjacentCellsIsolated:
adjacentA?.paragraphCount === 1 &&
adjacentB?.paragraphCount === 1 &&
adjacentA.actualText !== adjacentB.actualText,
passed:
breakProbes.every((probe) => probe.passed) &&
adjacentA?.paragraphCount === 1 &&
adjacentB?.paragraphCount === 1
};
return {
bytes: content.byteLength,
sha256: sha256(content),
partCount: Object.keys(entries).length,
sectionCount: documentXml.match(/<w:sectPr(?:\s|>)/gu)?.length ?? 0,
embeddedFontPartCount: fontParts.length,
fontNames: [...new Set(fontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
embeddedFontNames: [...new Set(embeddedFontNames)].sort((first, second) =>
first.localeCompare(second, "en")
),
optionalFontPackApplied: fontTableXml.includes("MdTP Serif SC"),
hasAltChunk: /<w:altChunk(?:\s|>)/u.test(documentXml),
internalMarkerCount:
documentXml.match(/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu)?.length ?? 0,
authorParagraphCount,
officialIssueRowCount: officialIssueRows.length,
officialIssueRowRightTabCount:
officialIssueRowsWithRightTab.length,
officialIssueRowContentTabCount,
inlineCodeContinuity,
inlineCodeContinuityPassed: inlineCodeContinuity.every(
(probe) => probe.passed
),
tableBreakSemantics,
requiredStylesPresent: [
"Normal",
"Heading1",
"SourceCode",
"Table",
"Caption"
].every((styleId) => stylesXml.includes(`w:styleId="${styleId}"`))
};
}
async function requestArtifact(origin, route, payload, contentType) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const content = new Uint8Array(await response.arrayBuffer());
assert(
response.ok,
`${route} 返回 ${response.status}${decoder.decode(content)}`
);
assert(
response.headers.get("content-type")?.includes(contentType),
`${route} MIME 无效:${response.headers.get("content-type")}`
);
return { response, content };
}
async function requestJson(origin, route, payload) {
const response = await fetch(`${origin}${route}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const text = await response.text();
assert(response.ok, `${route} 返回 ${response.status}${text}`);
assert(
response.headers.get("content-type")?.includes("application/json"),
`${route} MIME 无效:${response.headers.get("content-type")}`
);
return JSON.parse(text);
}
function inspectHtmlBreakSemantics(articleHtml) {
const cellFor = (marker) => {
const cells = articleHtml.match(/<t[dh](?:\s[^>]*)?>[\s\S]*?<\/t[dh]>/gu) ?? [];
return cells.find((cell) => cell.includes(marker)) ?? "";
};
const fragmentFor = (start, end) => {
const startIndex = articleHtml.indexOf(start);
const endIndex = articleHtml.indexOf(end, startIndex + start.length);
return startIndex >= 0 && endIndex >= 0
? articleHtml.slice(startIndex, endIndex + end.length)
: "";
};
const probes = [
{
id: "basic",
passed: cellFor("标甲").includes(
"标甲<br />标乙<br />标丙<br />标丁"
)
},
{
id: "case-insensitive",
passed: cellFor("大甲").includes(
"大甲<br />大乙<br />大丙<br />大丁"
)
},
{
id: "multiple",
passed: cellFor("连甲").includes(
"连甲<br /><br />连乙"
)
},
{
id: "boundary",
passed: cellFor("边界").includes("<br />边界<br />")
},
{
id: "escaped-and-entity",
passed: cellFor("转甲").includes(
"转甲&lt;br&gt;转乙 &lt;br&gt; 转丙"
)
},
{
id: "attribute-literal",
passed: cellFor("属甲").includes(
"属甲&lt;br class=\"unsafe\"&gt;属乙"
)
},
{
id: "inline-code-literal",
passed: cellFor("码甲").includes(
"码甲<code>&lt;br&gt;</code>码乙"
)
},
{
id: "outside-table-literal",
passed: fragmentFor("外一", "外二").includes(
"外一&lt;br&gt;外二"
)
},
{
id: "fenced-code-literal",
passed: (() => {
const fragment = fragmentFor("围一", "围二");
return fragment.includes("&lt;") && fragment.includes("br") &&
fragment.includes("&gt;") && !/<br(?:\s|\/?>)/iu.test(fragment);
})()
}
];
const adjacentA = cellFor("邻甲");
const adjacentB = cellFor("旁甲");
const adjacentCellsIsolated =
adjacentA.includes("邻甲<br />邻乙") &&
adjacentB.includes("旁甲<br />旁乙") &&
!adjacentA.includes("旁甲") &&
!adjacentB.includes("邻甲");
return {
probes,
adjacentCellsIsolated,
passed: probes.every((probe) => probe.passed) && adjacentCellsIsolated
};
}
function responseDiagnostics(response, kind) {
return {
serverTiming: response.headers.get("server-timing"),
pageCount:
kind === "pdf"
? Number(response.headers.get("x-pdf-page-count"))
: undefined,
warningCount:
kind === "docx"
? Number(response.headers.get("x-docx-warning-count"))
: undefined,
echartsErrorCount: Number(
response.headers.get("x-echarts-error-count")
),
mermaidErrorCount: Number(
response.headers.get("x-mermaid-error-count")
)
};
}
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
assertFreshArtifact(
"Preview Engine",
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
);
assertFreshArtifact(
"DOCX Engine",
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"docx-engine",
"dist",
"document-structure-transform.js"
)
);
assertFreshArtifact(
"Web Preview Runtime",
[
path.join(repositoryDirectory, "apps", "web", "src"),
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
],
path.join(webDirectory, "preview-frame.html")
);
assert(
fs.statSync(fontPackRoot).isDirectory(),
`缺少有效字体包根目录:${fontPackRoot}`
);
const allThemes = readThemes();
assert(
allThemes.length === 14,
`内置主题数量应为 14,实际为 ${allThemes.length}`
);
const selectedThemeId =
process.env.MD_TO_PDF_R4_THEME_ID?.trim() || undefined;
const externalMarkdownPath =
process.env.MD_TO_PDF_R4_MARKDOWN_PATH?.trim() || undefined;
const appendInlineCodeProbes =
process.env.MD_TO_PDF_R4_APPEND_INLINE_CODE_PROBES?.trim() !== "0";
const exportConfigOverride = readExportConfigOverride();
const themes = selectedThemeId
? allThemes.filter((theme) => theme.id === selectedThemeId)
: allThemes;
assert(
themes.length > 0,
`R4 验收主题不存在:${selectedThemeId}`
);
const standardReport = readJson(standardReportPath);
assert(
standardReport.themeCount === allThemes.length,
"标准 DOCX 主题矩阵与 R4 主题数量不一致"
);
const standardByTheme = new Map(
standardReport.results.map((result) => [result.id, result])
);
fs.mkdirSync(pdfDirectory, { recursive: true });
fs.mkdirSync(docxDirectory, { recursive: true });
const port = await reservePort();
const origin = `http://127.0.0.1:${port}`;
const pdfGenerator = createPdfGenerator({ renderOrigin: origin });
const docxMediaAdapter = createDocxMediaEngine({ renderOrigin: origin });
const captureDocxMedia = docxMediaAdapter.capture.bind(docxMediaAdapter);
let capturedDocxDocumentLayout;
docxMediaAdapter.capture = async (...args) => {
const output = await captureDocxMedia(...args);
capturedDocxDocumentLayout = structuredClone(
output.plan.documentLayout ?? { tables: [] }
);
return output;
};
const app = buildApp({
logger: { level: "error" },
pdfGenerator,
docxMediaAdapter,
fontPacks: {
roots: [fontPackRoot],
appVersion
}
});
registerWebRuntime(app);
const results = [];
try {
await app.listen({ port, host: "127.0.0.1" });
const capabilityResponse = await fetch(`${origin}/api/docx/capability`);
const capability = await capabilityResponse.json();
assert(
capabilityResponse.ok &&
capability.status === "available" &&
capability.detectedVersion === "3.9.0.2",
`DOCX capability 无效:${JSON.stringify(capability)}`
);
for (const theme of themes) {
capturedDocxDocumentLayout = undefined;
process.stderr.write(`[DOCX R4 matrix] generating ${theme.id}\n`);
const samplePath = externalMarkdownPath
? path.resolve(repositoryDirectory, externalMarkdownPath)
: path.join(samplesDirectory, `${theme.id}.md`);
assert(fs.existsSync(samplePath), `主题缺少验收示例:${theme.id}`);
const sourceMarkdown = fs.readFileSync(samplePath, "utf8");
const markdown = `${sourceMarkdown.trimEnd()}${appendInlineCodeProbes ? inlineCodeContinuityMarkdown : ""}`;
const sourceMetadata = matter(sourceMarkdown).data;
const exportConfig = mergeExportConfig({
...defaultExportConfig,
name: `${theme.name} R4 生产链验收`,
themeId: theme.id,
pageDecorationsMode: "theme",
header: structuredClone(
theme.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme.pageDefaults?.footer ?? defaultExportConfig.footer
),
paper: {
...defaultExportConfig.paper,
format: "A4",
orientation: "portrait",
marginMode: "theme",
margins: structuredClone(
theme.pageDefaults?.margins ??
defaultExportConfig.paper.margins
)
}
}, exportConfigOverride);
const payload = {
markdown,
fileName: path.basename(samplePath),
language: "zh-CN",
resources: [],
exportConfig
};
const rendered = await requestJson(origin, "/api/render", payload);
const htmlBreakSemantics = appendInlineCodeProbes
? inspectHtmlBreakSemantics(rendered.articleHtml)
: undefined;
if (appendInlineCodeProbes) {
assert(
htmlBreakSemantics.passed,
`主题 ${theme.id} 的 Chromium br 语义门禁失败:${JSON.stringify(htmlBreakSemantics)}`
);
}
const pdf = await requestArtifact(
origin,
"/api/pdf",
payload,
"application/pdf"
);
assert(
decoder.decode(pdf.content.subarray(0, 5)) === "%PDF-",
`主题 ${theme.id} 的 PDF 签名无效`
);
const docx = await requestArtifact(
origin,
"/api/docx",
payload,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
const inspection = inspectDocx(docx.content);
assert(!inspection.hasAltChunk, `主题 ${theme.id} 包含 altChunk`);
assert(
inspection.internalMarkerCount === 0,
`主题 ${theme.id} 残留内部结构标记`
);
assert(
inspection.requiredStylesPresent,
`主题 ${theme.id} 缺少标准 Word 样式`
);
if (appendInlineCodeProbes) {
assert(
inspection.inlineCodeContinuityPassed,
`主题 ${theme.id} 的行内代码连续性门禁失败:${JSON.stringify(inspection.inlineCodeContinuity)}`
);
assert(
inspection.tableBreakSemantics.passed,
`主题 ${theme.id} 的表格 br 换行门禁失败:${JSON.stringify(inspection.tableBreakSemantics)}`
);
}
const standard = standardByTheme.get(theme.id);
assert(standard, `标准矩阵缺少主题 ${theme.id}`);
assert(
standard.inspection.structureCoverage === 1 &&
standard.inspection.titlePolicyPassed &&
standard.inspection.titleDeduplicationPassed,
`主题 ${theme.id} 的标准结构门禁未通过`
);
if (standard.inspection.profile) {
assert(
inspection.authorParagraphCount === 0,
`结构化主题 ${theme.id} 残留重复 Author 段落`
);
}
const expectsOfficialDualEndedRow = Boolean(
sourceMetadata.document?.profile === "official" &&
sourceMetadata.document.signatory
);
if (expectsOfficialDualEndedRow) {
assert(
inspection.officialIssueRowCount > 0 &&
inspection.officialIssueRowCount ===
inspection.officialIssueRowRightTabCount &&
inspection.officialIssueRowContentTabCount ===
inspection.officialIssueRowCount,
`主题 ${theme.id} 的公文双端行缺少右制表位或签发人分隔`
);
} else {
assert(
inspection.officialIssueRowRightTabCount === 0 &&
inspection.officialIssueRowContentTabCount === 0,
`主题 ${theme.id} 的单端公文行包含无意义制表位`
);
}
const pdfPath = path.join(pdfDirectory, `${theme.id}.pdf`);
const preferredDocxPath = path.join(docxDirectory, `${theme.id}.docx`);
fs.writeFileSync(pdfPath, pdf.content);
const docxPath = writeArtifactWithBusyFallback(
preferredDocxPath,
docx.content
);
results.push({
id: theme.id,
name: theme.name,
category: theme.category,
compatibleProfiles: theme.compatibleProfiles,
sample: path.relative(repositoryDirectory, samplePath),
exportConfig,
htmlBreakSemantics,
pdf: {
outputFile: path.relative(repositoryDirectory, pdfPath),
bytes: pdf.content.byteLength,
sha256: sha256(pdf.content),
diagnostics: responseDiagnostics(pdf.response, "pdf")
},
docx: {
outputFile: path.relative(repositoryDirectory, docxPath),
inspection,
documentLayout: capturedDocxDocumentLayout ?? { tables: [] },
diagnostics: responseDiagnostics(docx.response, "docx")
},
standardInspection: standard.inspection,
expectsOfficialDualEndedRow
});
}
} finally {
await app.close();
}
const enhancedThemes = results.filter(
(result) => result.docx.inspection.optionalFontPackApplied
);
for (const result of results) {
assert(
result.pdf.diagnostics.pageCount > 0,
`主题 ${result.id} 的 PDF 页数无效`
);
assert(
result.pdf.diagnostics.echartsErrorCount === 0 &&
result.pdf.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 PDF 图表渲染失败`
);
assert(
result.docx.diagnostics.echartsErrorCount === 0 &&
result.docx.diagnostics.mermaidErrorCount === 0,
`主题 ${result.id} 的 DOCX 图表渲染失败`
);
}
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
origin,
themeCount: themes.length,
totalThemeCount: allThemes.length,
selectedThemeId,
fontPackRoot,
standardReport: path.relative(repositoryDirectory, standardReportPath),
outputDirectory: path.relative(repositoryDirectory, outputDirectory),
enhancedThemeIds: enhancedThemes.map((result) => result.id),
results
};
const reportPath = path.join(outputDirectory, "r4-matrix-report.json");
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
@@ -0,0 +1,332 @@
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createApplicationService } from "@md-to-pdf/application";
import {
DOCX_PANDOC_VERSION,
defaultExportConfig
} from "@md-to-pdf/core";
import {
DOCX_STYLE_SLOT_NAMES,
createDocxThemeStyleFingerprint,
normalizeDocxThemeMappingConfig,
resolveDocxThemeTokens
} from "@md-to-pdf/docx-theme-engine";
import {
PandocRuntime,
createDynamicReferenceDocx,
readReferenceDocxPackage,
resolveTokenFonts
} from "@md-to-pdf/docx-engine";
import { chromium } from "playwright";
import { captureDocxThemeStyleWithPlaywrightPage } from "../dist/playwright-docx-theme-style.js";
const directory = path.dirname(fileURLToPath(import.meta.url));
const repositoryDirectory = path.resolve(directory, "../../..");
const outputDirectory = path.join(
repositoryDirectory,
"output",
"docx-theme-styles"
);
const referenceDirectory = path.join(
outputDirectory,
"references"
);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function styleBlock(stylesXml, styleId) {
const escaped = styleId.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return stylesXml.match(
new RegExp(
`<w:style[^>]*w:styleId="${escaped}"[^>]*>[\\s\\S]*?<\\/w:style>`,
"u"
)
)?.[0];
}
function inspectReference(reference, tokens) {
const entries = readReferenceDocxPackage(reference.content).entries;
const stylesXml = new TextDecoder().decode(
entries.get("word/styles.xml")
);
const fontTableXml = new TextDecoder().decode(
entries.get("word/fontTable.xml")
);
const normal = styleBlock(stylesXml, "Normal");
const paragraph = tokens.slots.find(
(slot) => slot.slot === "paragraph"
)?.style;
assert(normal, `主题 ${tokens.themeId} 缺少 Normal 样式`);
const fonts = resolveTokenFonts(paragraph, {
latin: "Arial",
eastAsia: "SimSun"
});
if (paragraph?.fontCandidates.length) {
assert(
normal.includes(`w:ascii="${fonts.latin}"`),
`主题 ${tokens.themeId} 的 Latin 正文字体未写入模板`
);
assert(
normal.includes(`w:eastAsia="${fonts.eastAsia}"`),
`主题 ${tokens.themeId} 的东亚正文字体未写入模板`
);
assert(
fontTableXml.includes(`w:name="${fonts.eastAsia}"`),
`主题 ${tokens.themeId} 的东亚字体未写入字体表`
);
}
if (paragraph?.fontSizePt !== undefined) {
assert(
normal.includes(
`w:sz w:val="${Math.round(paragraph.fontSizePt * 2)}"`
),
`主题 ${tokens.themeId} 的正文字号未写入模板`
);
}
for (const styleId of [
"Heading1",
"SourceCode",
"Table",
"MdOfficialTitle",
"MdBriefingTitle",
"MdProjectReportTitle",
"MdTenderTitle"
]) {
assert(
stylesXml.includes(`w:styleId="${styleId}"`),
`主题 ${tokens.themeId} 缺少样式 ${styleId}`
);
}
return {
bytes: reference.content.byteLength,
templateFingerprint: reference.templateFingerprint,
cacheKey: reference.cacheKey,
stylePreset: reference.stylePreset,
partCount: reference.partCount,
paragraphFonts: fonts,
customStyleCount:
stylesXml.match(/w:styleId="Md[A-Za-z]+"/gu)?.length ?? 0
};
}
const application = createApplicationService({
bundledRoot: path.join(repositoryDirectory, "themes"),
localRoot: path.join(repositoryDirectory, ".local", "themes")
});
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname === "/") {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8"
});
response.end("<!doctype html><html><body></body></html>");
return;
}
const match = url.pathname.match(
/^\/api\/themes\/([^/]+)\/assets\/(.+)$/u
);
if (!match) {
response.writeHead(404);
response.end("Not found");
return;
}
const themeId = decodeURIComponent(match[1]);
const assetPath = match[2]
.split("/")
.map((segment) => decodeURIComponent(segment))
.join("/");
const asset = await application.getThemeAsset(
themeId,
assetPath
);
if (!asset) {
response.writeHead(404);
response.end("Not found");
return;
}
response.writeHead(200, {
"access-control-allow-origin": "*",
"content-type": asset.contentType,
"x-content-type-options": "nosniff"
});
response.end(asset.content);
} catch (error) {
response.writeHead(500);
response.end(
error instanceof Error ? error.message : "Unknown error"
);
}
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("无法获取 DOCX 主题样式验收地址");
}
const baseUrl = `http://127.0.0.1:${address.port}/`;
const browser = await chromium.launch({ headless: true });
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
: {}
);
const capability = await runtime.probe();
if (capability.capability.status !== "available") {
throw new Error(capability.capability.message);
}
assert(
capability.capability.detectedVersion === DOCX_PANDOC_VERSION,
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${capability.capability.detectedVersion}`
);
const baseline = await runtime.getDefaultReferenceDocx();
try {
const context = await browser.newContext({
locale: "zh-CN",
serviceWorkers: "block"
});
const page = await context.newPage();
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
const { themes } = await application.listThemes();
const bundledThemes = themes
.filter((theme) => theme.source === "bundled")
.sort((first, second) =>
first.id.localeCompare(second.id, "en")
);
const snapshots = [];
const tokenSets = [];
const references = [];
for (const theme of bundledThemes) {
console.error(`[DOCX theme styles] capturing ${theme.id}`);
const prepared = await application.prepareDocxExport({
markdown: `# ${theme.name}`,
fileName: `${theme.id}.md`,
language: "zh-CN",
exportConfig: {
...defaultExportConfig,
themeId: theme.id
}
});
const themeCss = prepared.theme.css;
const manifest = prepared.theme.manifest;
const themeFingerprint =
await createDocxThemeStyleFingerprint(theme.id, themeCss);
const snapshot =
await captureDocxThemeStyleWithPlaywrightPage(page, {
themeId: theme.id,
themeFingerprint,
themeCss,
baseUrl
});
assert(
snapshot.slots.length === DOCX_STYLE_SLOT_NAMES.length,
`主题 ${theme.id} 的槽位数量不正确`
);
const missing = snapshot.slots.filter((slot) => !slot.matched);
assert(
missing.length === 0,
`主题 ${theme.id} 缺少槽位:${missing
.map((slot) => slot.slot)
.join("、")}`
);
snapshots.push(snapshot);
const tokens = resolveDocxThemeTokens({
snapshot,
config: normalizeDocxThemeMappingConfig(manifest)
});
tokenSets.push(tokens);
const reference = createDynamicReferenceDocx(baseline, {
exportConfig: prepared.request.exportConfig,
theme: manifest,
fileName: prepared.request.fileName,
metadata: prepared.document.metadata,
themeTokens: tokens
});
fs.mkdirSync(referenceDirectory, { recursive: true });
fs.writeFileSync(
path.join(referenceDirectory, `${theme.id}.docx`),
reference.content
);
references.push({
themeId: theme.id,
...inspectReference(reference, tokens)
});
}
assert(
snapshots.length === 14,
`内置主题数量应为 14,实际为 ${snapshots.length}`
);
const invalidDiagnostics = tokenSets.flatMap((tokens) =>
tokens.diagnostics.filter(
(diagnostic) =>
diagnostic.severity === "error" ||
diagnostic.code === "css-value-invalid"
)
);
assert(
invalidDiagnostics.length === 0,
`DOCX 样式令牌存在 ${invalidDiagnostics.length} 条无效诊断`
);
const paragraphStyles = new Set(
snapshots.map(
(snapshot) =>
snapshot.slots.find((slot) => slot.slot === "paragraph")
?.computed?.fontFamily
)
);
assert(
paragraphStyles.size > 1,
"主题 CSS 未产生可区分的正文字体计算结果"
);
fs.mkdirSync(outputDirectory, { recursive: true });
fs.writeFileSync(
path.join(outputDirectory, "snapshots.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
chromiumVersion: browser.version(),
themeCount: snapshots.length,
slotCount: DOCX_STYLE_SLOT_NAMES.length,
snapshots,
tokenSets,
pandocVersion: capability.capability.detectedVersion,
references
},
null,
2
)}\n`,
"utf8"
);
console.log(
JSON.stringify({
chromiumVersion: browser.version(),
themes: snapshots.length,
slotsPerTheme: DOCX_STYLE_SLOT_NAMES.length,
totalSlots:
snapshots.length * DOCX_STYLE_SLOT_NAMES.length,
tokenSets: tokenSets.length,
diagnostics: tokenSets.reduce(
(count, tokens) => count + tokens.diagnostics.length,
0
),
pandocVersion: capability.capability.detectedVersion,
references: references.length
})
);
await context.close();
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
}
+264 -19
View File
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
import Fastify from "fastify"; import Fastify from "fastify";
import { import {
EXPORT_CONFIG_VERSION, EXPORT_CONFIG_VERSION,
DOCX_MIME_TYPE,
createPagedDocumentPayload, createPagedDocumentPayload,
defaultExportConfig, defaultExportConfig,
exportConfigSchema, exportConfigSchema,
@@ -11,10 +12,23 @@ import {
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { import {
ApplicationRequestError, ApplicationRequestError,
DocxExportService,
DocxExportServiceError,
DocxThemeTokenService,
createApplicationService, createApplicationService,
type ApplicationService readDocxExportRuntimeLimits,
type ApplicationService,
type ApplicationServiceOptions
} from "@md-to-pdf/application"; } from "@md-to-pdf/application";
import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import { RENDERER_VERSION } from "@md-to-pdf/renderer"; import { RENDERER_VERSION } from "@md-to-pdf/renderer";
import {
createDocxMediaEngine,
type ServerDocxMediaCaptureAdapter
} from "./docx-media-engine.js";
import { import {
createPdfGenerator, createPdfGenerator,
PdfEngineClosedError, PdfEngineClosedError,
@@ -26,6 +40,7 @@ import {
interface RenderRequestBody { interface RenderRequestBody {
markdown?: unknown; markdown?: unknown;
language?: unknown; language?: unknown;
resources?: unknown;
} }
interface PdfRequestBody extends RenderRequestBody { interface PdfRequestBody extends RenderRequestBody {
@@ -67,7 +82,14 @@ export interface BuildAppOptions {
logger?: boolean; logger?: boolean;
pdfGenerator?: PdfGenerator; pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean; prewarmPdfBrowser?: boolean;
prewarmDocxRuntime?: boolean;
applicationService?: ApplicationService; applicationService?: ApplicationService;
docxExportService?: Pick<
DocxExportService,
"getCapability" | "generate" | "close"
>;
docxMediaAdapter?: ServerDocxMediaCaptureAdapter;
fontPacks?: NonNullable<ApplicationServiceOptions["fontPacks"]>;
} }
function milliseconds(value: number) { function milliseconds(value: number) {
@@ -105,10 +127,35 @@ 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 applicationService = const applicationService =
options.applicationService ?? options.applicationService ??
@@ -116,10 +163,29 @@ export function buildApp(options: BuildAppOptions = {}) {
bundledRoot: resolve(projectRoot, "themes"), bundledRoot: resolve(projectRoot, "themes"),
localRoot: localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
onWarning: (message) => app.log.warn(message) onWarning: (message) => app.log.warn(message),
...(options.fontPacks ? { fontPacks: options.fontPacks } : {})
}); });
const pdfGenerator = const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator(); options.pdfGenerator ?? createPdfGenerator();
const pandocRuntime = options.docxExportService
? undefined
: new PandocRuntime();
const docxMediaAdapter =
options.docxMediaAdapter ?? createDocxMediaEngine();
const docxExportService =
options.docxExportService ??
new DocxExportService({
application: applicationService,
runtime: pandocRuntime!,
converter: new PandocDocxConverter({
runtime: pandocRuntime!
}),
themeTokens: new DocxThemeTokenService(
docxMediaAdapter
),
limits: readDocxExportRuntimeLimits()
});
if (options.prewarmPdfBrowser) { if (options.prewarmPdfBrowser) {
app.addHook("onReady", async () => { app.addHook("onReady", async () => {
@@ -131,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 () => ({
@@ -142,19 +227,28 @@ 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/docx/capability", async () =>
docxExportService.getCapability()
);
app.get("/api/themes", async () => app.get("/api/themes", async () =>
applicationService.listThemes() applicationService.listThemes()
@@ -210,11 +304,49 @@ 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) => {
try { try {
return applicationService.render(request.body ?? {}); return await applicationService.render(request.body ?? {});
} catch (error) { } catch (error) {
if (error instanceof ApplicationRequestError) { if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({ return reply.code(error.statusCode).send({
@@ -267,7 +399,7 @@ export function buildApp(options: BuildAppOptions = {}) {
const markdownStartedAt = performance.now(); const markdownStartedAt = performance.now();
let rendered; let rendered;
try { try {
rendered = applicationService.render(request.body ?? {}); rendered = await applicationService.render(request.body ?? {});
} catch (error) { } catch (error) {
if (error instanceof ApplicationRequestError) { if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({ return reply.code(error.statusCode).send({
@@ -354,7 +486,16 @@ export function buildApp(options: BuildAppOptions = {}) {
}); });
} }
request.log.error({ error }, "PDF generation failed"); request.log.error(
{
error,
errorMessage:
error instanceof Error ? error.message : String(error),
errorStack:
error instanceof Error ? error.stack : undefined
},
"PDF generation failed"
);
return reply.code(500).send({ return reply.code(500).send({
error: "PDF_GENERATION_FAILED", error: "PDF_GENERATION_FAILED",
message: "PDF 生成失败" message: "PDF 生成失败"
@@ -363,5 +504,109 @@ export function buildApp(options: BuildAppOptions = {}) {
} }
); );
app.post<{ Body: unknown }>("/api/docx", async (request, reply) => {
const abortController = new AbortController();
const abortRequest = () =>
abortController.abort(new Error("HTTP 请求已中断"));
request.raw.once("aborted", abortRequest);
try {
const generated = await docxExportService.generate(
request.body ?? {},
{
mediaAdapter: docxMediaAdapter,
signal: abortController.signal
}
);
request.log.info(
{
fileName: generated.fileName,
bytes: generated.docx.byteLength,
diagnostics: generated.diagnostics,
timings: generated.timings
},
"DOCX generated"
);
return reply
.header("content-type", DOCX_MIME_TYPE)
.header(
"content-disposition",
createDocxContentDisposition(generated.fileName)
)
.header("cache-control", "no-store")
.header(
"server-timing",
createDocxServerTiming(generated.timings)
)
.header(
"x-docx-warning-count",
String(generated.diagnostics.warnings.length)
)
.header(
"x-echarts-error-count",
String(generated.diagnostics.echartsErrors.length)
)
.header(
"x-mermaid-error-count",
String(generated.diagnostics.mermaidErrors.length)
)
.send(
Buffer.from(
generated.docx.buffer,
generated.docx.byteOffset,
generated.docx.byteLength
)
);
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
error: error.code,
message: error.message,
retryable: false
});
}
if (error instanceof DocxExportServiceError) {
if (error.statusCode >= 500) {
request.log.error(
{
error,
cause: error.cause,
causeMessage:
error.cause instanceof Error
? error.cause.message
: undefined,
causeStack:
error.cause instanceof Error
? error.cause.stack
: undefined,
causeDiagnostics:
error.cause instanceof Error &&
"diagnostics" in error.cause
? error.cause.diagnostics
: undefined
},
"DOCX generation service failed"
);
}
if (error.retryable) {
reply.header("retry-after", "5");
}
return reply.code(error.statusCode).send({
error: error.code,
message: error.message,
retryable: error.retryable
});
}
request.log.error({ error }, "DOCX generation failed");
return reply.code(500).send({
error: "DOCX_GENERATION_FAILED",
message: "DOCX 生成失败",
retryable: false
});
} finally {
request.raw.removeListener("aborted", abortRequest);
}
});
return app; return app;
} }
+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.2"
}
}
: {})
}); });
async function start() { async function start() {
+1 -1
View File
@@ -194,7 +194,7 @@ export function readPdfEngineRuntimeLimits(
timeoutMs: readIntegerEnvironment( timeoutMs: readIntegerEnvironment(
environment, environment,
"PDF_TIMEOUT_MS", "PDF_TIMEOUT_MS",
60_000, 180_000,
1_000 1_000
) )
}; };
@@ -0,0 +1,85 @@
import type { Page } from "playwright";
import type {
DocxMediaCaptureAdapter,
DocxMediaCaptureRequest
} from "@md-to-pdf/application";
import type { DocxMediaCapturePlan } from "@md-to-pdf/core";
interface CaptureScreenshotResult {
data: string;
}
async function renderCapturePlan(
page: Page,
request: DocxMediaCaptureRequest
) {
return page.evaluate(
async ({ payload, dimensions }): Promise<DocxMediaCapturePlan> => {
const renderer = (
window as typeof window & {
__mdToPdfRenderDocxMedia?: (
renderPayload: typeof payload,
renderDimensions: typeof dimensions
) => Promise<DocxMediaCapturePlan>;
}
).__mdToPdfRenderDocxMedia;
if (!renderer) {
throw new Error("DOCX 媒体渲染运行时不可用");
}
return renderer(payload, dimensions);
},
request
);
}
export async function captureDocxMediaWithPlaywrightPage(
page: Page,
request: DocxMediaCaptureRequest,
signal?: AbortSignal
) {
signal?.throwIfAborted();
await page.emulateMedia({ media: "print" });
const plan = await renderCapturePlan(page, request);
signal?.throwIfAborted();
const session = await page.context().newCDPSession(page);
try {
const captures = [];
for (const target of plan.targets) {
signal?.throwIfAborted();
const result = (await session.send("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: target.captureX,
y: target.captureY,
width: target.captureWidthPx,
height: target.captureHeightPx,
scale: target.rasterScale
}
})) as CaptureScreenshotResult;
captures.push({
id: target.id,
png: Buffer.from(result.data, "base64")
});
}
signal?.throwIfAborted();
return { plan, captures };
} finally {
await session.detach();
}
}
export class PlaywrightDocxMediaCaptureAdapter
implements DocxMediaCaptureAdapter
{
constructor(private readonly page: Page) {}
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
return captureDocxMediaWithPlaywrightPage(
this.page,
request,
signal
);
}
}
@@ -0,0 +1,21 @@
import {
createDocxThemeStyleCaptureScript,
parseDocxThemeStyleRuntimeCapture,
type DocxThemeStyleCaptureRequest,
type DocxThemeStyleSnapshot
} from "@md-to-pdf/docx-theme-engine";
import type { Page } from "playwright";
export async function captureDocxThemeStyleWithPlaywrightPage(
page: Page,
request: DocxThemeStyleCaptureRequest,
signal?: AbortSignal
): Promise<DocxThemeStyleSnapshot> {
signal?.throwIfAborted();
await page.emulateMedia({ media: "print" });
const capture = await page.evaluate(
createDocxThemeStyleCaptureScript(request)
);
signal?.throwIfAborted();
return parseDocxThemeStyleRuntimeCapture(request, capture);
}
+93
View File
@@ -3,6 +3,7 @@ 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,
@@ -39,6 +40,61 @@ function localThemeManifest(
}; };
} }
function sha256(content: Uint8Array | string) {
return createHash("sha256").update(content).digest("hex");
}
async function createServerFontPack(root: string) {
const directory = join(root, "official-cjk", "1.0.0");
const web = Buffer.from([31, 32, 33, 34]);
const docx = Buffer.from([41, 42, 43, 44, 45]);
const license = "SIL Open Font License 1.1";
await mkdir(join(directory, "fonts"), { recursive: true });
await writeFile(join(directory, "fonts", "serif.woff2"), web);
await writeFile(join(directory, "fonts", "serif.ttf"), docx);
await writeFile(join(directory, "OFL.txt"), license, "utf8");
await writeFile(
join(directory, "font-pack.json"),
JSON.stringify({
manifestVersion: 1,
id: "official-cjk",
version: "1.0.0",
name: "政务中文字体包",
description: "Server 字体包资源测试",
license: "OFL-1.1",
licenseFile: "OFL.txt",
licenseSha256: sha256(license),
licenseBytes: Buffer.byteLength(license),
compatibility: {
minimumAppVersion: "0.6.0",
maximumAppVersionExclusive: "0.7.0"
},
faces: [
{
id: "serif-regular",
targets: ["FandolSong", "Mdpdf Fandol Song"],
weight: 400,
style: "normal",
web: {
path: "fonts/serif.woff2",
format: "woff2",
bytes: web.byteLength,
sha256: sha256(web)
},
docx: {
path: "fonts/serif.ttf",
format: "truetype",
bytes: docx.byteLength,
sha256: sha256(docx)
}
}
]
}),
"utf8"
);
return { web };
}
afterEach(async () => { afterEach(async () => {
await app?.close(); await app?.close();
if (temporaryDirectory) { if (temporaryDirectory) {
@@ -136,6 +192,43 @@ describe("预览 API", () => {
expect(response.body).toContain("#write"); expect(response.body).toContain("#write");
}); });
it("为主题 CSS 提供经过注册器验证的字体包 Web 资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-font-pack-"));
const fontPack = await createServerFontPack(temporaryDirectory);
app = buildApp({
logger: false,
fontPacks: {
roots: [temporaryDirectory],
appVersion: "0.6.0"
}
});
const cssResponse = await app.inject({
method: "GET",
url: "/api/themes/gov-red-standard/css"
});
expect(cssResponse.statusCode).toBe(200);
expect(cssResponse.body).toContain("md-to-pdf-font-packs:official-cjk@1.0.0");
expect(cssResponse.body).toContain(
"/api/font-packs/official-cjk/1.0.0/serif-regular/web?v="
);
const assetResponse = await app.inject({
method: "GET",
url: `/api/font-packs/official-cjk/1.0.0/serif-regular/web?v=${sha256(fontPack.web)}`
});
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("font/woff2");
expect(assetResponse.headers["cache-control"]).toContain("immutable");
expect(assetResponse.rawPayload).toEqual(fontPack.web);
const missingResponse = await app.inject({
method: "GET",
url: "/api/font-packs/official-cjk/2.0.0/serif-regular/web"
});
expect(missingResponse.statusCode).toBe(404);
});
it("发现本地主题并安全提供相对字体资源", async () => { it("发现本地主题并安全提供相对字体资源", async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-"));
const themeDirectory = join(temporaryDirectory, "local-test"); const themeDirectory = join(temporaryDirectory, "local-test");
+101
View File
@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
interface PackageManifest {
scripts?: Record<string, string>;
}
const projectRoot = fileURLToPath(
new URL("../../../", import.meta.url)
);
function readProjectFile(relativePath: string) {
return readFileSync(`${projectRoot}/${relativePath}`, "utf8");
}
describe("Docker v0.6.2 部署契约", () => {
it("完整构建并复制 DOCX 运行时工作区和 Lua Filter", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
const workspaces = [
"semantic-document",
"docx-theme-engine",
"font-pack-registry",
"docx-engine"
];
for (const workspace of workspaces) {
expect(dockerfile).toContain(
`COPY packages/${workspace}/package.json packages/${workspace}/package.json`
);
expect(dockerfile).toContain(
`npm run build -w @md-to-pdf/${workspace}`
);
expect(dockerfile).toContain(
`/app/packages/${workspace}/dist ./packages/${workspace}/dist`
);
}
expect(dockerfile).toContain(
"/app/packages/docx-engine/assets ./packages/docx-engine/assets"
);
expect(dockerfile).toContain("COPY tsconfig.base.json product.json ./");
});
it("固定校验 Pandoc 二进制、许可证、版权和最终版本", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
expect(dockerfile).toContain("ARG PANDOC_VERSION=3.9.0.2");
expect(dockerfile).toContain(
"A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508"
);
expect(dockerfile).toContain(
"9D56CAC92294E206AF026A5502BEE0FED77200B08B51EC28AA63C9EFDA4DCFDD"
);
expect(dockerfile).toContain(
"842E33EF01625E93F85BEBB8BAC83AA570186B7AA77A09971257CC29F8F60740"
);
expect(dockerfile).toContain(
"COPY --from=pandoc-runtime /opt/pandoc/3.9.0.2 /opt/pandoc/3.9.0.2"
);
expect(dockerfile).toContain(
"ln -sfn /opt/pandoc/3.9.0.2/bin/pandoc /usr/local/bin/pandoc"
);
expect(dockerfile).toContain(
'test "$(pandoc --version | head -n 1)" = "pandoc 3.9.0.2"'
);
});
it("将受校验字体包内置到镜像并取消运行时字体挂载", () => {
const dockerfile = readProjectFile("deploy/Dockerfile");
const compose = readProjectFile("deploy/compose.yaml");
const dockerignore = readProjectFile(".dockerignore");
const dockerfileIgnore = readProjectFile(
"deploy/Dockerfile.dockerignore"
);
expect(compose).toContain(
"FONT_PACK_ROOT: /app/.local/font-packs"
);
expect(compose).not.toContain("MD_TO_PDF_FONT_PACK_DIR");
expect(dockerfile).toContain(
"COPY --chown=pwuser:pwuser output/font-packs/root /app/.local/font-packs"
);
expect(dockerfile).toContain("FONT_PACK_ROOT=/app/.local/font-packs");
expect(compose).toContain(
'APP_VERSION: "${MD_TO_PDF_APP_VERSION:-0.6.2}"'
);
expect(dockerignore.split(/\r?\n/u)).toContain(".local");
expect(dockerignore).toContain("!output/font-packs/root/**");
expect(dockerfileIgnore).toBe(dockerignore);
});
it("提供显式真实 Docker 字体包验收命令", () => {
const manifest = JSON.parse(
readProjectFile("package.json")
) as PackageManifest;
expect(manifest.scripts?.["verify:docker-font-pack"]).toContain(
"deploy/verify-font-pack-compose.mjs"
);
});
});
+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;
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import type { Browser } from "playwright";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { PlaywrightDocxMediaEngine } from "../src/docx-media-engine.js";
const emptyPlan: DocxMediaCapturePlan = {
targets: [],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Server DOCX 媒体浏览器", () => {
it("使用请求级上下文并阻止非同源网络访问", async () => {
const continueRoute = vi.fn(async () => undefined);
const abortRoute = vi.fn(async () => undefined);
const detach = vi.fn(async () => undefined);
const page = {
goto: vi.fn(async () => undefined),
emulateMedia: vi.fn(async () => undefined),
waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => emptyPlan),
context: () => context
};
const context = {
route: vi.fn(
async (
_pattern: string,
handler: (route: {
request(): { url(): string };
continue(): Promise<void>;
abort(reason: string): Promise<void>;
}) => Promise<void>
) => {
await handler({
request: () => ({
url: () => "http://renderer.test/assets/runtime.js"
}),
continue: continueRoute,
abort: abortRoute
});
await handler({
request: () => ({
url: () => "https://external.test/tracker.js"
}),
continue: continueRoute,
abort: abortRoute
});
}
),
newPage: vi.fn(async () => page),
newCDPSession: vi.fn(async () => ({
send: vi.fn(),
detach
})),
close: vi.fn(async () => undefined)
};
const browser = {
newContext: vi.fn(async () => context),
on: vi.fn(),
close: vi.fn(async () => undefined)
} as unknown as Browser;
const engine = new PlaywrightDocxMediaEngine({
renderOrigin: "http://renderer.test",
launchBrowser: vi.fn(async () => browser)
});
const result = await engine.capture(request);
expect(result.plan).toEqual(emptyPlan);
expect(browser.newContext).toHaveBeenCalledWith({
locale: "zh-CN",
serviceWorkers: "block"
});
expect(page.goto).toHaveBeenCalledWith(
"http://renderer.test/preview-frame.html?target=continuous",
{ waitUntil: "domcontentloaded" }
);
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
expect(continueRoute).toHaveBeenCalledTimes(1);
expect(abortRoute).toHaveBeenCalledWith("blockedbyclient");
expect(detach).toHaveBeenCalledOnce();
expect(context.close).toHaveBeenCalledOnce();
await engine.close();
expect(browser.close).toHaveBeenCalledOnce();
});
it("关闭后拒绝预热和媒体捕获", async () => {
const engine = new PlaywrightDocxMediaEngine({
renderOrigin: "http://renderer.test",
launchBrowser: vi.fn()
});
await engine.close();
await expect(engine.warmup()).rejects.toThrow(
"DOCX 媒体服务已关闭"
);
await expect(engine.capture(request)).rejects.toThrow(
"DOCX 媒体服务已关闭"
);
});
});
+1 -1
View File
@@ -137,7 +137,7 @@ describe("PDF 运行参数", () => {
expect(readPdfEngineRuntimeLimits({})).toEqual({ expect(readPdfEngineRuntimeLimits({})).toEqual({
concurrency: 2, concurrency: 2,
maxQueue: 8, maxQueue: 8,
timeoutMs: 60_000 timeoutMs: 180_000
}); });
}); });
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import type { Page } from "playwright";
import {
createPagedDocumentPayload,
defaultExportConfig,
type DocxMediaCapturePlan
} from "@md-to-pdf/core";
import { captureDocxMediaWithPlaywrightPage } from "../src/playwright-docx-media-capture.js";
const plan: DocxMediaCapturePlan = {
targets: [
{
id: "docx-media-1",
kind: "echarts",
ordinal: 1,
kindOrdinal: 1,
altText: "图表",
alignment: "center",
displayWidthPx: 320,
displayHeightPx: 180,
captureX: 10,
captureY: 20,
captureWidthPx: 320,
captureHeightPx: 180,
rasterScale: 3.125
}
],
echartsErrors: [],
mermaidErrors: []
};
const request = {
payload: createPagedDocumentPayload({
document: {
rendererVersion: 1,
articleHtml: '<article id="write"></article>',
bodyHtml: "",
metadata: {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
warnings: []
},
fileName: "test.md",
themeCss: "",
exportConfig: defaultExportConfig
}),
dimensions: {
contentWidthPx: 600,
contentHeightPx: 900
}
};
describe("Playwright DOCX 媒体捕获", () => {
it("按运行时计划以目标倍率捕获 PNG", async () => {
const detach = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
data: Buffer.from("png").toString("base64")
}));
const page = {
emulateMedia: vi.fn(async () => undefined),
evaluate: vi.fn(async () => plan),
context: () => ({
newCDPSession: vi.fn(async () => ({ send, detach }))
})
} as unknown as Page;
const result = await captureDocxMediaWithPlaywrightPage(
page,
request
);
expect(result.plan).toBe(plan);
expect(page.emulateMedia).toHaveBeenCalledWith({ media: "print" });
expect(result.captures[0]).toEqual({
id: "docx-media-1",
png: Buffer.from("png")
});
expect(send).toHaveBeenCalledWith("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: true,
clip: {
x: 10,
y: 20,
width: 320,
height: 180,
scale: 3.125
}
});
expect(detach).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,89 @@
import {
DOCX_STYLE_SLOT_NAMES,
type DocxComputedStyle,
type DocxThemeStyleCaptureRequest
} from "@md-to-pdf/docx-theme-engine";
import { describe, expect, it, vi } from "vitest";
import { captureDocxThemeStyleWithPlaywrightPage } from "../src/playwright-docx-theme-style.js";
const computed: DocxComputedStyle = {
fontFamily: "Arial",
fontSize: "16px",
fontWeight: "400",
fontStyle: "normal",
color: "rgb(0, 0, 0)",
backgroundColor: "rgba(0, 0, 0, 0)",
lineHeight: "normal",
letterSpacing: "normal",
textAlign: "start",
direction: "ltr",
textIndent: "0px",
textDecorationLine: "none",
marginTop: "0px",
marginRight: "0px",
marginBottom: "0px",
marginLeft: "0px",
paddingTop: "0px",
paddingRight: "0px",
paddingBottom: "0px",
paddingLeft: "0px",
borderTop: "0px none rgb(0, 0, 0)",
borderRight: "0px none rgb(0, 0, 0)",
borderBottom: "0px none rgb(0, 0, 0)",
borderLeft: "0px none rgb(0, 0, 0)",
width: "640px",
maxWidth: "none",
minWidth: "0px",
minHeight: "0px",
height: "24px",
breakBefore: "auto",
breakAfter: "auto",
breakInside: "auto",
display: "block",
flexDirection: "row",
alignItems: "normal",
justifyContent: "normal",
outline: "rgb(0, 0, 0) none 0px",
outlineOffset: "0px"
};
const request: DocxThemeStyleCaptureRequest = {
themeId: "external-clean",
themeFingerprint: "a".repeat(64),
themeCss: "#write { color: #000; }",
baseUrl: "http://localhost:3001/"
};
describe("Playwright DOCX 主题样式采集", () => {
it("使用打印媒体并校验浏览器返回的快照", async () => {
const page = {
emulateMedia: vi.fn(async () => undefined),
evaluate: vi.fn(async () => ({
rootFontSizePx: 16,
viewport: {
widthPx: 794,
heightPx: 1123,
deviceScaleFactor: 1
},
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
matched: true,
computed
}))
}))
};
const snapshot =
await captureDocxThemeStyleWithPlaywrightPage(
page as never,
request
);
expect(page.emulateMedia).toHaveBeenCalledWith({
media: "print"
});
expect(page.evaluate).toHaveBeenCalledTimes(1);
expect(snapshot.themeId).toBe("external-clean");
expect(snapshot.slots).toHaveLength(
DOCX_STYLE_SLOT_NAMES.length
);
});
});
+3 -3
View File
@@ -12,14 +12,14 @@
<meta name="theme-color" content="#1f2937" /> <meta name="theme-color" content="#1f2937" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:;" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:; manifest-src 'self';"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta 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>
+6 -3
View File
@@ -10,13 +10,16 @@
"typecheck": "tsc -b --pretty false" "typecheck": "tsc -b --pretty false"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0", "@md-to-pdf/markdown-echarts": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2", "@md-to-pdf/preview-engine": "0.1.0",
"@uiw/react-codemirror": "^4.25.11",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"mermaid": "11.16.0",
"pagedjs": "^0.4.3",
"pdfjs-dist": "6.1.200", "pdfjs-dist": "6.1.200",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
+1172 -360
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>
);
}
+235 -2
View File
@@ -1,4 +1,5 @@
import { import {
defaultExportConfig,
getPaperDimensionsMm, getPaperDimensionsMm,
lengthToMillimeters, lengthToMillimeters,
paperFormatLabels, paperFormatLabels,
@@ -12,9 +13,11 @@ import {
type MermaidExportConfig type MermaidExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ThemeSummary } from "./application-backend";
interface ExportSettingsDrawerProps { interface ExportSettingsDrawerProps {
config: ExportConfig; config: ExportConfig;
theme: ThemeSummary | undefined;
onChange: (config: ExportConfig) => void; onChange: (config: ExportConfig) => void;
onClose: () => void; onClose: () => void;
onReset: () => void; onReset: () => void;
@@ -24,6 +27,9 @@ interface LengthInputProps {
label: string; label: string;
value: string; value: string;
maximum: number; maximum: number;
disabled?: boolean;
precision?: number;
step?: number;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -32,6 +38,7 @@ const pageNumberFormatLabels: Record<FooterConfig["format"], string> = {
"page-total": "1 / 10", "page-total": "1 / 10",
"chinese-page-total": "第 1 页 / 共 10 页", "chinese-page-total": "第 1 页 / 共 10 页",
"dash-page": "- 1 -", "dash-page": "- 1 -",
"official-page": "— 1 —(标准公文)",
custom: "自定义模板" custom: "自定义模板"
}; };
@@ -98,6 +105,9 @@ function LengthInput({
label, label,
value, value,
maximum, maximum,
disabled = false,
precision = 1,
step = 0.1,
onChange onChange
}: LengthInputProps) { }: LengthInputProps) {
const [draft, setDraft] = useState( const [draft, setDraft] = useState(
@@ -115,7 +125,8 @@ function LengthInput({
return; return;
} }
const normalized = Math.min(Math.max(number, 0), maximum); const normalized = Math.min(Math.max(number, 0), maximum);
const rounded = Math.round(normalized * 10) / 10; const factor = 10 ** precision;
const rounded = Math.round(normalized * factor) / factor;
setDraft(String(rounded)); setDraft(String(rounded));
onChange(`${rounded}mm`); onChange(`${rounded}mm`);
} }
@@ -128,8 +139,9 @@ function LengthInput({
type="number" type="number"
min="0" min="0"
max={maximum} max={maximum}
step="0.1" step={step}
value={draft} value={draft}
disabled={disabled}
onBlur={commit} onBlur={commit}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -144,6 +156,57 @@ function LengthInput({
); );
} }
function PageDecorationFontInput({
label,
value,
disabled,
onChange
}: {
label: string;
value: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const normalized = draft.trim();
if (
!normalized ||
!/^[\p{L}\p{N}\s,"'-]+$/u.test(normalized)
) {
setDraft(value);
return;
}
setDraft(normalized);
onChange(normalized);
}
return (
<label className="setting-field stacked">
<span>{label}</span>
<input
type="text"
maxLength={300}
value={draft}
disabled={disabled}
placeholder='"Segoe UI", "Microsoft YaHei", sans-serif'
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
/>
</label>
);
}
function MermaidFontInput({ function MermaidFontInput({
value, value,
onChange onChange
@@ -208,6 +271,7 @@ function updateHeaderSlot(
export function ExportSettingsDrawer({ export function ExportSettingsDrawer({
config, config,
theme,
onChange, onChange,
onClose, onClose,
onReset onReset
@@ -259,6 +323,7 @@ export function ExportSettingsDrawer({
value: string value: string
) { ) {
updatePaper({ updatePaper({
marginMode: "custom",
margins: { margins: {
...margins, ...margins,
[side]: value [side]: value
@@ -266,9 +331,22 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemeMargins() {
updatePaper({
marginMode: "theme",
margins: theme?.pageDefaults?.margins ?? {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
});
}
function updateHeader(header: Partial<HeaderConfig>) { function updateHeader(header: Partial<HeaderConfig>) {
onChange({ onChange({
...config, ...config,
pageDecorationsMode: "custom",
header: { header: {
...config.header, ...config.header,
...header ...header
@@ -279,6 +357,7 @@ export function ExportSettingsDrawer({
function updateFooter(footer: Partial<FooterConfig>) { function updateFooter(footer: Partial<FooterConfig>) {
onChange({ onChange({
...config, ...config,
pageDecorationsMode: "custom",
footer: { footer: {
...config.footer, ...config.footer,
...footer ...footer
@@ -286,6 +365,19 @@ export function ExportSettingsDrawer({
}); });
} }
function followThemePageDecorations() {
onChange({
...config,
pageDecorationsMode: "theme",
header: structuredClone(
theme?.pageDefaults?.header ?? defaultExportConfig.header
),
footer: structuredClone(
theme?.pageDefaults?.footer ?? defaultExportConfig.footer
)
});
}
function updateMermaid(mermaid: Partial<MermaidExportConfig>) { function updateMermaid(mermaid: Partial<MermaidExportConfig>) {
onChange({ onChange({
...config, ...config,
@@ -374,6 +466,34 @@ export function ExportSettingsDrawer({
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="margin-mode"
checked={config.paper.marginMode === "theme"}
onChange={followThemeMargins}
/>
</label>
<label>
<input
type="radio"
name="margin-mode"
checked={config.paper.marginMode === "custom"}
onChange={() => updatePaper({ marginMode: "custom" })}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.paper.marginMode === "theme"
? theme?.pageDefaults
? `当前采用“${theme.name}”推荐值`
: "当前主题未设置推荐值,采用全局默认 16mm"
: "当前采用自定义值;修改任意页边距会自动进入此模式"}
</p>
<div className="margin-grid"> <div className="margin-grid">
<LengthInput <LengthInput
label="上" label="上"
@@ -416,6 +536,15 @@ export function ExportSettingsDrawer({
onChange={(value) => updateMargin("left", value)} onChange={(value) => updateMargin("left", value)}
/> />
</div> </div>
{config.paper.marginMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemeMargins}
>
</button>
) : null}
</section> </section>
<section className="settings-section"> <section className="settings-section">
@@ -485,6 +614,53 @@ export function ExportSettingsDrawer({
</p> </p>
</section> </section>
<section className="settings-section">
<h3></h3>
<fieldset className="segmented-control margin-mode-control">
<legend></legend>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "theme"}
onChange={followThemePageDecorations}
/>
</label>
<label>
<input
type="radio"
name="page-decorations-mode"
checked={config.pageDecorationsMode === "custom"}
onChange={() =>
onChange({
...config,
pageDecorationsMode: "custom"
})
}
/>
</label>
</fieldset>
<p className="setting-hint margin-source-hint">
{config.pageDecorationsMode === "theme"
? theme?.pageDefaults?.header ||
theme?.pageDefaults?.footer
? `当前采用“${theme.name}”推荐的页眉与页码`
: "当前主题未设置推荐值,采用全局默认配置"
: "当前采用自定义配置;修改页眉或页码会自动进入此模式"}
</p>
{config.pageDecorationsMode === "custom" ? (
<button
type="button"
className="secondary-action margin-reset-action"
onClick={followThemePageDecorations}
>
</button>
) : null}
</section>
<section className="settings-section"> <section className="settings-section">
<div className="setting-section-title"> <div className="setting-section-title">
<h3></h3> <h3></h3>
@@ -502,6 +678,21 @@ export function ExportSettingsDrawer({
<p className="setting-hint"> <p className="setting-hint">
{"${title}"}{"${author}"} {"${filename}"} {"${title}"}{"${author}"} {"${filename}"}
</p> </p>
<PageDecorationFontInput
label="页眉字体"
value={config.header.fontFamily}
disabled={!config.header.enabled}
onChange={(fontFamily) => updateHeader({ fontFamily })}
/>
<LengthInput
label="页眉字号"
value={config.header.fontSize}
maximum={20}
disabled={!config.header.enabled}
precision={2}
step={0.01}
onChange={(fontSize) => updateHeader({ fontSize })}
/>
{(["left", "center", "right"] as const).map((slot) => { {(["left", "center", "right"] as const).map((slot) => {
const labels = { left: "左侧", center: "中间", right: "右侧" }; const labels = { left: "左侧", center: "中间", right: "右侧" };
return ( return (
@@ -540,6 +731,19 @@ export function ExportSettingsDrawer({
</div> </div>
); );
})} })}
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.header.showOnFirstPage}
disabled={!config.header.enabled}
onChange={(event) =>
updateHeader({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control"> <label className="switch-control divider-control">
<input <input
type="checkbox" type="checkbox"
@@ -567,6 +771,21 @@ export function ExportSettingsDrawer({
<span>{config.footer.enabled ? "开启" : "关闭"}</span> <span>{config.footer.enabled ? "开启" : "关闭"}</span>
</label> </label>
</div> </div>
<PageDecorationFontInput
label="页码字体"
value={config.footer.fontFamily}
disabled={!config.footer.enabled}
onChange={(fontFamily) => updateFooter({ fontFamily })}
/>
<LengthInput
label="页码字号"
value={config.footer.fontSize}
maximum={20}
disabled={!config.footer.enabled}
precision={2}
step={0.01}
onChange={(fontSize) => updateFooter({ fontSize })}
/>
<label className="setting-field"> <label className="setting-field">
<span></span> <span></span>
<select <select
@@ -616,6 +835,7 @@ export function ExportSettingsDrawer({
<option value="left"></option> <option value="left"></option>
<option value="center"></option> <option value="center"></option>
<option value="right"></option> <option value="right"></option>
<option value="outer"></option>
</select> </select>
</label> </label>
<label className="setting-field"> <label className="setting-field">
@@ -636,6 +856,19 @@ export function ExportSettingsDrawer({
} }
/> />
</label> </label>
<label className="switch-control divider-control">
<input
type="checkbox"
checked={config.footer.showOnFirstPage}
disabled={!config.footer.enabled}
onChange={(event) =>
updateFooter({
showOnFirstPage: event.target.checked
})
}
/>
<span></span>
</label>
<label className="switch-control divider-control"> <label className="switch-control divider-control">
<input <input
type="checkbox" type="checkbox"
+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>
);
}
+94 -4
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;
@@ -33,12 +42,16 @@ interface PdfPageDescriptor {
export interface PrecisePdfPreviewProps { export interface PrecisePdfPreviewProps {
blob: Blob; blob: Blob;
zoomPercent: number; zoomPercent: number;
onExternalLink?: (href: string) => void;
onNavigateToPage?: (pageNumber: number) => void;
onPageCount?: (pageCount: number) => void; onPageCount?: (pageCount: number) => void;
} }
interface PrecisePdfPageProps { interface PrecisePdfPageProps {
document: PDFDocumentProxy; document: PDFDocumentProxy;
descriptor: PdfPageDescriptor; descriptor: PdfPageDescriptor;
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
renderQueue: PdfPageRenderQueue; renderQueue: PdfPageRenderQueue;
zoomPercent: number; zoomPercent: number;
} }
@@ -92,12 +105,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
function PrecisePdfPage({ function PrecisePdfPage({
document, document,
descriptor, descriptor,
onExternalLink,
onNavigateToPage,
renderQueue, renderQueue,
zoomPercent zoomPercent
}: PrecisePdfPageProps) { }: PrecisePdfPageProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const textLayerRef = useRef<HTMLDivElement>(null); const textLayerRef = useRef<HTMLDivElement>(null);
const annotationLayerRef = useRef<HTMLDivElement>(null);
const [displayWidth, setDisplayWidth] = useState(0); const [displayWidth, setDisplayWidth] = useState(0);
const [renderError, setRenderError] = useState(""); const [renderError, setRenderError] = useState("");
const [renderState, setRenderState] = useState< const [renderState, setRenderState] = useState<
@@ -158,11 +174,13 @@ function PrecisePdfPage({
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const textContainer = textLayerRef.current; const textContainer = textLayerRef.current;
const annotationContainer = annotationLayerRef.current;
if ( if (
!nearViewport || !nearViewport ||
displayWidth <= 0 || displayWidth <= 0 ||
!canvas || !canvas ||
!textContainer !textContainer ||
!annotationContainer
) { ) {
return; return;
} }
@@ -171,6 +189,7 @@ function PrecisePdfPage({
let page: PDFPageProxy | undefined; let page: PDFPageProxy | undefined;
let renderTask: RenderTask | undefined; let renderTask: RenderTask | undefined;
let textLayer: TextLayer | undefined; let textLayer: TextLayer | undefined;
let annotationLayer: AnnotationLayer | undefined;
setRenderError(""); setRenderError("");
setRenderState("queued"); setRenderState("queued");
@@ -212,6 +231,7 @@ function PrecisePdfPage({
canvas.style.width = `${displayViewport.width}px`; canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`; canvas.style.height = `${displayViewport.height}px`;
textContainer.replaceChildren(); textContainer.replaceChildren();
annotationContainer.replaceChildren();
renderTask = page.render({ renderTask = page.render({
canvas, canvas,
@@ -229,7 +249,43 @@ function PrecisePdfPage({
textContentSource: page.streamTextContent(), textContentSource: page.streamTextContent(),
viewport: displayViewport viewport: displayViewport
}); });
await textLayer.render(); const annotations = await page.getAnnotations({
intent: "display"
});
const linkService = createPdfAnnotationLinkService(
document,
descriptor.pageNumber,
{
onExternalLink,
onNavigateToPage
}
);
annotationLayer = new AnnotationLayer({
div: annotationContainer,
accessibilityManager: undefined,
annotationCanvasMap: undefined,
annotationEditorUIManager: undefined,
page,
viewport: displayViewport,
structTreeLayer: undefined,
commentManager: undefined,
linkService,
annotationStorage: document.annotationStorage
});
await Promise.all([
textLayer.render(),
annotationLayer.render({
viewport: displayViewport,
div: annotationContainer,
annotations,
page,
linkService:
linkService as unknown as PdfJsAnnotationLinkService,
annotationStorage: document.annotationStorage,
renderForms: false,
enableScripting: false
})
]);
} catch (reason: unknown) { } catch (reason: unknown) {
if ( if (
disposed || disposed ||
@@ -251,10 +307,12 @@ function PrecisePdfPage({
cancelQueuedRender(); cancelQueuedRender();
renderTask?.cancel(); renderTask?.cancel();
textLayer?.cancel(); textLayer?.cancel();
annotationLayer?.destroy();
page?.cleanup(); page?.cleanup();
canvas.width = 0; canvas.width = 0;
canvas.height = 0; canvas.height = 0;
textContainer.replaceChildren(); textContainer.replaceChildren();
annotationContainer.replaceChildren();
setRenderState("idle"); setRenderState("idle");
}; };
}, [ }, [
@@ -264,6 +322,8 @@ function PrecisePdfPage({
displayWidth, displayWidth,
document, document,
nearViewport, nearViewport,
onExternalLink,
onNavigateToPage,
renderQueue renderQueue
]); ]);
@@ -282,6 +342,10 @@ function PrecisePdfPage({
aria-hidden="true" aria-hidden="true"
/> />
<div ref={textLayerRef} className="textLayer" /> <div ref={textLayerRef} className="textLayer" />
<div
ref={annotationLayerRef}
className="annotationLayer"
/>
{renderState !== "rendered" ? ( {renderState !== "rendered" ? (
<span className="precise-pdf-page-placeholder"> <span className="precise-pdf-page-placeholder">
{renderState === "rendering" {renderState === "rendering"
@@ -299,6 +363,8 @@ function PrecisePdfPage({
export function PrecisePdfPreview({ export function PrecisePdfPreview({
blob, blob,
zoomPercent, zoomPercent,
onExternalLink,
onNavigateToPage,
onPageCount onPageCount
}: PrecisePdfPreviewProps) { }: PrecisePdfPreviewProps) {
const [document, setDocument] = useState<PDFDocumentProxy>(); const [document, setDocument] = useState<PDFDocumentProxy>();
@@ -308,6 +374,19 @@ export function PrecisePdfPreview({
() => new PdfPageRenderQueue(1), () => new PdfPageRenderQueue(1),
[document] [document]
); );
const externalLinkRef = useRef(onExternalLink);
const navigateToPageRef = useRef(onNavigateToPage);
externalLinkRef.current = onExternalLink;
navigateToPageRef.current = onNavigateToPage;
const handleExternalLink = useCallback((href: string) => {
externalLinkRef.current?.(href);
}, []);
const handleNavigateToPage = useCallback(
(pageNumber: number) => {
navigateToPageRef.current?.(pageNumber);
},
[]
);
useEffect(() => () => renderQueue.dispose(), [renderQueue]); useEffect(() => () => renderQueue.dispose(), [renderQueue]);
@@ -379,13 +458,24 @@ export function PrecisePdfPreview({
return <div className="precise-pdf-status"></div>; return <div className="precise-pdf-status"></div>;
} }
const documentWidth = getPrecisePdfDocumentWidth(
pages.map((page) => page.width),
zoomPercent / 100,
PRECISE_PDF_INLINE_GUTTER
);
return ( return (
<div className="precise-pdf-document"> <div
className="precise-pdf-document"
style={{ width: `${documentWidth}px` }}
>
{pages.map((page) => ( {pages.map((page) => (
<PrecisePdfPage <PrecisePdfPage
key={page.pageNumber} key={page.pageNumber}
document={document} document={document}
descriptor={page} descriptor={page}
onExternalLink={handleExternalLink}
onNavigateToPage={handleNavigateToPage}
renderQueue={renderQueue} renderQueue={renderQueue}
zoomPercent={zoomPercent} zoomPercent={zoomPercent}
/> />
+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__;
+21 -1
View File
@@ -1,10 +1,24 @@
import type { RenderedMarkdownDocument } from "@md-to-pdf/core"; import type {
DocumentProfileName,
FooterConfig,
HeaderConfig,
PageMargins,
RenderedMarkdownDocument,
ThemeCategory
} from "@md-to-pdf/core";
export interface ThemeSummary { export interface ThemeSummary {
id: string; id: string;
name: string; name: string;
version: string; version: string;
description: string; description: string;
category: ThemeCategory;
compatibleProfiles: DocumentProfileName[];
pageDefaults?: {
margins: PageMargins;
header?: HeaderConfig;
footer?: FooterConfig;
};
bundled: boolean; bundled: boolean;
source: "bundled" | "local"; source: "bundled" | "local";
} }
@@ -12,6 +26,12 @@ export interface ThemeSummary {
export interface MarkdownRenderInput { export interface MarkdownRenderInput {
markdown: string; markdown: string;
language: string; language: string;
resources?: MarkdownImageResource[];
}
export interface MarkdownImageResource {
path: string;
data: string;
} }
function throwIfAborted(signal?: AbortSignal) { function throwIfAborted(signal?: AbortSignal) {
+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("")
: "纸张、样式和可编辑结构已写入文档";
}
+121 -14
View File
@@ -4,8 +4,10 @@ import {
type ExportConfig type ExportConfig
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v3"; export const EXPORT_CONFIG_STORAGE_KEY = "md-to-pdf.export-config.v4";
export const LEGACY_EXPORT_CONFIG_STORAGE_KEY = export const LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY =
"md-to-pdf.export-config.v3";
export const LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY =
"md-to-pdf.export-config.v2"; "md-to-pdf.export-config.v2";
type SettingsStorage = Pick<Storage, "getItem" | "setItem">; type SettingsStorage = Pick<Storage, "getItem" | "setItem">;
@@ -14,6 +16,119 @@ function cloneDefaultExportConfig() {
return structuredClone(defaultExportConfig); return structuredClone(defaultExportConfig);
} }
function pickLegacyPageDecorations(value: unknown) {
if (typeof value !== "object" || value === null) {
return value;
}
const source = value as Record<string, unknown>;
if ("left" in source) {
return {
enabled: source.enabled,
height: source.height,
showDivider: source.showDivider,
fontSize: source.fontSize,
color: source.color,
left: source.left,
center: source.center,
right: source.right
};
}
return {
enabled: source.enabled,
height: source.height,
showDivider: source.showDivider,
fontSize: source.fontSize,
color: source.color,
alignment: source.alignment,
format: source.format,
template: source.template,
startFrom: source.startFrom
};
}
function usesLegacyPageDecorationDefaults(
header: unknown,
footer: unknown
) {
return (
JSON.stringify(pickLegacyPageDecorations(header)) ===
JSON.stringify(
pickLegacyPageDecorations(defaultExportConfig.header)
) &&
JSON.stringify(pickLegacyPageDecorations(footer)) ===
JSON.stringify(
pickLegacyPageDecorations(defaultExportConfig.footer)
)
);
}
function migrateStoredExportConfig(value: unknown): unknown {
if (
typeof value !== "object" ||
value === null ||
!("version" in value) ||
!("paper" in value)
) {
return value;
}
if (
value.version !== 2 &&
value.version !== 3 &&
value.version !== defaultExportConfig.version
) {
return value;
}
const source = value as Record<string, unknown>;
const hasPageDecorationsMode =
typeof source.pageDecorationsMode === "string";
const paper =
typeof value.paper === "object" && value.paper !== null
? value.paper
: {};
const margins =
"margins" in paper &&
typeof paper.margins === "object" &&
paper.margins !== null
? paper.margins
: {};
const usesLegacyDefaults =
JSON.stringify(margins) ===
JSON.stringify(defaultExportConfig.paper.margins);
const hasMarginMode =
typeof (paper as Record<string, unknown>).marginMode === "string";
return {
...value,
...(value.version === 2 || value.version === 3
? { version: defaultExportConfig.version }
: {}),
...(value.version === 2
? { mermaid: structuredClone(defaultExportConfig.mermaid) }
: {}),
...(!hasPageDecorationsMode
? {
pageDecorationsMode: usesLegacyPageDecorationDefaults(
source.header,
source.footer
)
? "theme"
: "custom"
}
: {}),
paper: {
...paper,
...(!hasMarginMode
? {
marginMode: usesLegacyDefaults ? "theme" : "custom"
}
: {})
}
};
}
export function parseStoredExportConfig(rawValue: string | null): ExportConfig { export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
if (!rawValue) { if (!rawValue) {
return cloneDefaultExportConfig(); return cloneDefaultExportConfig();
@@ -21,17 +136,7 @@ export function parseStoredExportConfig(rawValue: string | null): ExportConfig {
try { try {
const value: unknown = JSON.parse(rawValue); const value: unknown = JSON.parse(rawValue);
const migratedValue = const migratedValue = migrateStoredExportConfig(value);
typeof value === "object" &&
value !== null &&
"version" in value &&
value.version === 2
? {
...value,
version: defaultExportConfig.version,
mermaid: structuredClone(defaultExportConfig.mermaid)
}
: value;
const parsed = exportConfigSchema.safeParse(migratedValue); const parsed = exportConfigSchema.safeParse(migratedValue);
return parsed.success ? parsed.data : cloneDefaultExportConfig(); return parsed.success ? parsed.data : cloneDefaultExportConfig();
} catch { } catch {
@@ -47,7 +152,9 @@ export function loadExportConfig(
return parseStoredExportConfig(currentValue); return parseStoredExportConfig(currentValue);
} }
const legacyValue = storage.getItem(LEGACY_EXPORT_CONFIG_STORAGE_KEY); const legacyValue =
storage.getItem(LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY) ??
storage.getItem(LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY);
const config = parseStoredExportConfig(legacyValue); const config = parseStoredExportConfig(legacyValue);
if (legacyValue) { if (legacyValue) {
storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config)); storage.setItem(EXPORT_CONFIG_STORAGE_KEY, JSON.stringify(config));
@@ -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;
}
-448
View File
@@ -1,448 +0,0 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
import { createMermaidSiteConfig } from "./mermaid-config";
import {
fitOversizedMermaidToPage,
getPageContentDimensions
} from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
import type { MermaidOutputMode } from "./mermaid-static-image";
import { enablePrintMediaForPreview } from "./preview-styles";
import "./paged-table-handler";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
type PagedPreviewPayload
} from "./paged-preview";
import type { PagedRenderTarget } from "./paged-render-target";
export type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
export interface PagedDocumentRenderOptions {
target: PagedRenderTarget;
shouldContinue?: () => boolean;
mermaidOutput?: MermaidOutputMode;
}
function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
return Promise.all(
images.map(async (image) => {
if (image.complete) {
return;
}
try {
await image.decode();
} catch {
await new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
}
})
);
}
function stylesheet(
documentRef: Document,
css: string,
name: string
) {
return {
[`${documentRef.location.href}#${name}`]: css
};
}
function mountMeasurementContainer(
documentRef: Document,
content: DocumentFragment,
payload: PagedPreviewPayload,
target: PagedRenderTarget
) {
const pageContent = getPageContentDimensions(payload.exportConfig);
const style = documentRef.createElement("style");
const themeCss =
target === "preview"
? enablePrintMediaForPreview(payload.themeCss)
: payload.themeCss;
style.dataset.pagedMeasurementStyle = "true";
style.textContent = [
documentBaseCss,
highlightCss,
katexCss,
echartsCss,
themeCss,
documentGeometryCss
].join("\n");
const host = documentRef.createElement("div");
host.dataset.pagedMeasurementHost = "true";
host.style.cssText = [
"position: fixed",
"left: -100000px",
"top: 0",
`width: ${pageContent.width}px`,
"height: auto",
"opacity: 0",
"pointer-events: none",
"contain: layout style paint"
].join(";");
documentRef.head.append(style);
host.append(content);
documentRef.body.append(host);
return {
host,
restore() {
content.append(...Array.from(host.childNodes));
host.remove();
style.remove();
}
};
}
function applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
function removeTrailingPdfPageBreak(container: ParentNode) {
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
pages
.at(-1)
?.style.setProperty("break-after", "auto", "important");
}
export class PagedDocumentRuntime {
private activePreviewer: Previewer | undefined;
private mermaidRenderSequence = 0;
private mermaidPromise:
| Promise<(typeof import("mermaid"))["default"]>
| undefined;
private echartsPromise:
| Promise<
typeof import("@md-to-pdf/markdown-echarts/browser")
>
| undefined;
constructor(private readonly root: HTMLElement) {}
destroy() {
if (!this.activePreviewer) {
return;
}
this.activePreviewer.chunker.destroy();
this.activePreviewer.polisher.destroy();
this.activePreviewer = undefined;
}
private loadMermaid() {
if (!this.mermaidPromise) {
this.mermaidPromise = Promise.all([
import("mermaid"),
import("@mermaid-js/layout-elk")
]).then(
([{ default: mermaid }, { default: elkLayouts }]) => {
mermaid.registerLayoutLoaders(elkLayouts);
return mermaid;
}
);
}
return this.mermaidPromise;
}
private loadECharts() {
if (!this.echartsPromise) {
this.echartsPromise = import(
"@md-to-pdf/markdown-echarts/browser"
);
}
return this.echartsPromise;
}
private async renderECharts(
container: ParentNode,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("echarts")) {
return [];
}
const { renderEChartsBlocks } = await this.loadECharts();
const outcomes = await renderEChartsBlocks(container, {
outputMode: "inline-svg",
concurrency: 2,
timeoutMs: 5_000
});
return outcomes.flatMap((outcome, index) =>
outcome.success
? []
: [
`图表 ${index + 1}${
outcome.error?.message ?? "未知错误"
}`
]
);
}
private async renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await this.loadMermaid();
mermaid.initialize(
createMermaidSiteConfig(payload.exportConfig.mermaid)
);
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
this.mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-document-${this.mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error
? outcome.error.message
: "未知错误";
node.classList.add("mermaid-error");
node.textContent =
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
return errors;
}
async render(
payload: PagedPreviewPayload,
options: PagedDocumentRenderOptions
): Promise<PagedDocumentRenderResult | undefined> {
const totalStartedAt = performance.now();
const shouldContinue = options.shouldContinue ?? (() => true);
if (!shouldContinue()) {
return undefined;
}
const setupStartedAt = performance.now();
const documentRef = this.root.ownerDocument;
this.destroy();
this.root.replaceChildren();
documentRef.documentElement.dataset.renderTarget = options.target;
documentRef.documentElement.lang =
payload.metadata.language || "zh-CN";
documentRef.title =
payload.metadata.title || "Markdown 分页文档";
const template = documentRef.createElement("template");
template.innerHTML = payload.articleHtml;
const content = template.content;
const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now();
const mermaidErrors = await this.renderMermaid(content, payload);
const mermaidMs = performance.now() - mermaidStartedAt;
let echartsErrors: string[] = [];
let echartsMs = 0;
let echartsFitMs = 0;
let mermaidFitMs = 0;
let mermaidConversionMs = 0;
if (
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg")
) {
const measurement = mountMeasurementContainer(
documentRef,
content,
payload,
options.target
);
try {
await documentRef.fonts.ready;
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
measurement.host,
payload
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host);
const echartsFitStartedAt = performance.now();
fitOversizedEChartsToPage(
measurement.host,
payload.exportConfig
);
echartsFitMs =
performance.now() - echartsFitStartedAt;
const mermaidFitStartedAt = performance.now();
fitOversizedMermaidToPage(
measurement.host,
payload.exportConfig
);
mermaidFitMs = performance.now() - mermaidFitStartedAt;
const mermaidConversionStartedAt = performance.now();
if (options.mermaidOutput === "svg-image") {
replaceMermaidSvgWithImages(measurement.host);
await waitForImages(measurement.host);
}
mermaidConversionMs =
performance.now() - mermaidConversionStartedAt;
} finally {
measurement.restore();
}
}
if (!shouldContinue()) {
return undefined;
}
const resourceWaitStartedAt = performance.now();
await documentRef.fonts.ready;
await waitForImages(content);
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
const paginationStartedAt = performance.now();
const previewer = new Previewer();
this.activePreviewer = previewer;
const flow = await previewer.preview(
content,
[
stylesheet(documentRef, documentBaseCss, "document-base"),
stylesheet(documentRef, highlightCss, "highlight"),
stylesheet(documentRef, katexCss, "katex"),
stylesheet(documentRef, echartsCss, "echarts"),
stylesheet(documentRef, payload.themeCss, "theme"),
stylesheet(
documentRef,
documentGeometryCss,
"document-geometry"
),
stylesheet(
documentRef,
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
],
this.root
);
const paginationMs = performance.now() - paginationStartedAt;
if (!shouldContinue()) {
return undefined;
}
const finalizeStartedAt = performance.now();
applyPageNumbers(this.root, payload, flow.total);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
}
const finalizeMs = performance.now() - finalizeStartedAt;
return {
pageCount: flow.total,
echartsErrors,
mermaidErrors,
timings: {
setupMs,
echartsMs,
echartsFitMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
resourceWaitMs,
paginationMs,
finalizeMs,
totalMs: performance.now() - totalStartedAt
}
};
}
}
declare global {
interface Window {
__mdToPdfRender?: (
payload: PagedPreviewPayload,
target?: PagedRenderTarget
) => Promise<PagedDocumentRenderResult>;
}
}
+82 -21
View File
@@ -1,16 +1,21 @@
import { PagedDocumentRuntime } from "./paged-document-runtime"; import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import { import {
isPagedPreviewRenderRequest, isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE, PAGED_PREVIEW_MESSAGE_SCOPE,
PagedDocumentRuntime,
SHARED_HIGHLIGHT_CSS,
renderDocxMediaCapturePlan,
resolveMermaidOutputMode,
resolvePagedRenderTarget,
type PagedPreviewFrameMessage, type PagedPreviewFrameMessage,
type PagedPreviewPayload type PagedPreviewPayload
} from "./paged-preview"; } from "@md-to-pdf/preview-engine";
import { resolvePagedRenderTarget } from "./paged-render-target"; import katexCss from "katex/dist/katex.min.css?inline";
import { resolveMermaidOutputMode } from "./mermaid-static-image";
import { import {
beginPreviewFrameRender, beginPreviewFrameRender,
completePreviewFrameRender completePreviewFrameRender
} from "./preview-frame-visibility"; } from "./preview-frame-visibility";
import { withHiddenWindowAnimationFrames } from "./hidden-window-animation-frame";
function getPreviewRoot() { function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root"); const element = document.querySelector<HTMLElement>("#preview-root");
@@ -23,7 +28,11 @@ function getPreviewRoot() {
const previewRoot = getPreviewRoot(); const previewRoot = getPreviewRoot();
const renderTarget = resolvePagedRenderTarget(window.location.search); const renderTarget = resolvePagedRenderTarget(window.location.search);
const mermaidOutput = resolveMermaidOutputMode(window.location.search); const mermaidOutput = resolveMermaidOutputMode(window.location.search);
const runtime = new PagedDocumentRuntime(previewRoot); const runtime = new PagedDocumentRuntime(previewRoot, {
highlightCss: SHARED_HIGHLIGHT_CSS,
katexCss,
echartsCss
});
document.documentElement.dataset.renderTarget = renderTarget; document.documentElement.dataset.renderTarget = renderTarget;
let latestRequestId = 0; let latestRequestId = 0;
@@ -33,9 +42,47 @@ function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin); window.parent.postMessage(message, window.location.origin);
} }
function measurePreviewContentHeight() {
const rootRect = previewRoot.getBoundingClientRect();
const documentRect =
document.documentElement.getBoundingClientRect();
return Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight,
rootRect.bottom - documentRect.top
)
);
}
document.addEventListener(
"click",
(event) => {
const target =
event.target instanceof Element
? event.target.closest<HTMLAnchorElement>("a[href]")
: undefined;
const href = target?.getAttribute("href")?.trim();
if (!href) {
return;
}
event.preventDefault();
event.stopPropagation();
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "link",
href
});
},
{ capture: true }
);
async function renderPagedPreview( async function renderPagedPreview(
requestId: number, requestId: number,
payload: PagedPreviewPayload payload: PagedPreviewPayload,
layout: "paged" | "continuous"
) { ) {
if (requestId !== latestRequestId) { if (requestId !== latestRequestId) {
return; return;
@@ -45,15 +92,21 @@ async function renderPagedPreview(
post({ post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering", type: "rendering",
requestId requestId,
layout
}); });
try { try {
const result = await runtime.render(payload, { const result =
target: renderTarget, layout === "continuous"
mermaidOutput, ? await runtime.renderContinuous(payload, {
shouldContinue: () => requestId === latestRequestId shouldContinue: () => requestId === latestRequestId
}); })
: await runtime.render(payload, {
target: renderTarget,
mermaidOutput,
shouldContinue: () => requestId === latestRequestId
});
if (!result) { if (!result) {
return; return;
} }
@@ -62,7 +115,8 @@ async function renderPagedPreview(
scope: PAGED_PREVIEW_MESSAGE_SCOPE, scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered", type: "rendered",
requestId, requestId,
contentHeight: Math.ceil(document.documentElement.scrollHeight), layout,
contentHeight: measurePreviewContentHeight(),
...result ...result
}); });
} finally { } finally {
@@ -85,16 +139,25 @@ 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; const desktopPdfBridge = window.__mdToPdfDesktopPdf;
if (desktopPdfBridge) { if (desktopPdfBridge) {
desktopPdfBridge.onRender(async (requestId, payload) => { desktopPdfBridge.onRender(async (requestId, payload) => {
const requestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = (callback) =>
window.setTimeout(() => callback(performance.now()), 0);
try { try {
const result = await window.__mdToPdfRender?.(payload, "pdf"); const result = await withHiddenWindowAnimationFrames(
window,
async () => window.__mdToPdfRender?.(payload, "pdf")
);
if (!result) { if (!result) {
throw new Error("PDF 分页运行时不可用"); throw new Error("PDF 分页运行时不可用");
} }
@@ -104,8 +167,6 @@ if (desktopPdfBridge) {
requestId, requestId,
reason instanceof Error ? reason.message : "PDF 分页失败" reason instanceof Error ? reason.message : "PDF 分页失败"
); );
} finally {
window.requestAnimationFrame = requestAnimationFrame;
} }
}); });
desktopPdfBridge.ready(); desktopPdfBridge.ready();
@@ -121,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;
-136
View File
@@ -1,136 +0,0 @@
import { Handler, registerHandlers } from "pagedjs";
import { resolveBreakTokenElement } from "./paged-break-token";
interface PagedBreakToken {
node?: Node;
}
interface PagedChunker {
source: ParentNode;
}
interface PagedHandlerContext {
chunker: PagedChunker;
}
function elementAncestors(
element: Element,
selector: string
) {
const ancestors: Element[] = [];
let current = element.parentElement;
while (current) {
if (current.matches(selector)) {
ancestors.unshift(current);
}
current = current.parentElement;
}
return ancestors;
}
class RepeatTableHeadersHandler extends Handler {
declare chunker: PagedChunker;
private splitTableRefs: string[] = [];
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
) {
super(chunker, polisher, caller);
}
afterPageLayout(
pageElement: HTMLElement,
_page: unknown,
breakToken?: PagedBreakToken
) {
this.splitTableRefs = [];
const element = resolveBreakTokenElement(breakToken?.node);
if (!element) {
return;
}
const tables = elementAncestors(element, "table");
if (element.matches("table")) {
tables.push(element);
}
this.splitTableRefs = Array.from(
new Set(
tables
.map((table) => table.getAttribute("data-ref"))
.filter((ref): ref is string => Boolean(ref))
)
);
for (const ref of this.splitTableRefs) {
const renderedTable =
pageElement.querySelector<HTMLElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (!renderedTable?.querySelector("tbody > tr")) {
renderedTable?.setAttribute(
"data-empty-split-table",
"true"
);
}
}
}
layout(rendered: HTMLElement) {
for (const ref of this.splitTableRefs) {
const renderedTable =
rendered.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (
!renderedTable ||
renderedTable.hasAttribute("data-repeated-header")
) {
continue;
}
const sourceTable =
this.chunker.source.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (!sourceTable) {
continue;
}
const firstChild = renderedTable.firstChild;
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
renderedTable.insertBefore(
colgroup.cloneNode(true),
firstChild
);
}
if (!renderedTable.querySelector("thead")) {
const sourceHeader = sourceTable.querySelector("thead");
if (sourceHeader) {
renderedTable.insertBefore(
sourceHeader.cloneNode(true),
renderedTable.firstChild
);
}
}
renderedTable.setAttribute("data-repeated-header", "true");
}
}
}
const handlerRegistration = globalThis as typeof globalThis & {
__mdToPdfTableHandlerRegistered?: boolean;
};
if (!handlerRegistration.__mdToPdfTableHandlerRegistered) {
registerHandlers(RepeatTableHeadersHandler);
handlerRegistration.__mdToPdfTableHandlerRegistered = true;
}
export { RepeatTableHeadersHandler };
-41
View File
@@ -1,41 +0,0 @@
declare module "pagedjs" {
export interface PagedFlow {
total: number;
pages: HTMLElement[];
performance: number;
size: {
width: { value: number; unit: string };
height: { value: number; unit: string };
format?: string;
orientation?: string;
};
}
export type PagedStylesheet = string | Record<string, string>;
export class Previewer {
chunker: {
destroy(): void;
};
polisher: {
destroy(): void;
};
preview(
content?: HTMLElement | DocumentFragment | string,
stylesheets?: PagedStylesheet[],
renderTo?: HTMLElement | string
): Promise<PagedFlow>;
}
export class Handler {
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
);
}
export function registerHandlers(
...handlers: Array<typeof Handler>
): void;
}
+152
View File
@@ -0,0 +1,152 @@
import type { PDFDocumentProxy } from "pdfjs-dist";
import { decodeLocalDocumentLinkFromPdf } from "@md-to-pdf/core";
export type PdfDestination = string | unknown[];
export interface PdfAnnotationLinkCallbacks {
onExternalLink: (href: string) => void;
onNavigateToPage: (pageNumber: number) => void;
}
export interface PdfAnnotationLinkService {
externalLinkEnabled: boolean;
addLinkAttributes(
link: HTMLAnchorElement,
url: string,
newWindow?: boolean
): void;
getDestinationHash(destination: PdfDestination): string;
getAnchorUrl(anchor: string): string;
goToDestination(destination: PdfDestination): Promise<void>;
executeNamedAction(action: string): void;
executeSetOCGState(action: object): Promise<void>;
getAttachmentContent(id: string): Promise<undefined>;
}
export async function resolvePdfDestinationPage(
document: Pick<
PDFDocumentProxy,
"getDestination" | "getPageIndex" | "numPages"
>,
destination: PdfDestination
) {
const explicitDestination =
typeof destination === "string"
? await document.getDestination(destination)
: destination;
if (
!Array.isArray(explicitDestination) ||
explicitDestination.length === 0
) {
return undefined;
}
const pageReference = explicitDestination[0];
let pageNumber: number;
if (
typeof pageReference === "number" &&
Number.isInteger(pageReference)
) {
pageNumber = pageReference + 1;
} else if (
pageReference &&
typeof pageReference === "object"
) {
pageNumber =
(await document.getPageIndex(
pageReference as Parameters<
PDFDocumentProxy["getPageIndex"]
>[0]
)) + 1;
} else {
return undefined;
}
return pageNumber >= 1 && pageNumber <= document.numPages
? pageNumber
: undefined;
}
export function resolvePdfNamedActionPage(
action: string,
currentPage: number,
pageCount: number
) {
switch (action) {
case "FirstPage":
return 1;
case "LastPage":
return pageCount;
case "NextPage":
case "PageDown":
return Math.min(pageCount, currentPage + 1);
case "PrevPage":
case "PageUp":
return Math.max(1, currentPage - 1);
default:
return undefined;
}
}
export function createPdfAnnotationLinkService(
document: Pick<
PDFDocumentProxy,
"getDestination" | "getPageIndex" | "numPages"
>,
currentPage: number,
callbacks: PdfAnnotationLinkCallbacks
): PdfAnnotationLinkService {
return {
externalLinkEnabled: true,
addLinkAttributes(link, url) {
const resolvedUrl =
decodeLocalDocumentLinkFromPdf(url) ?? url;
link.href = "#";
link.rel = "noopener noreferrer";
link.title ||= resolvedUrl;
link.dataset.pdfExternalHref = resolvedUrl;
link.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
callbacks.onExternalLink(resolvedUrl);
return false;
};
},
getDestinationHash() {
return "#pdf-destination";
},
getAnchorUrl() {
return "#pdf-action";
},
async goToDestination(destination) {
try {
const pageNumber = await resolvePdfDestinationPage(
document,
destination
);
if (pageNumber !== undefined) {
callbacks.onNavigateToPage(pageNumber);
}
} catch {
// 损坏或无法解析的 PDF 目标保持无行为。
}
},
executeNamedAction(action) {
const pageNumber = resolvePdfNamedActionPage(
action,
currentPage,
document.numPages
);
if (pageNumber !== undefined) {
callbacks.onNavigateToPage(pageNumber);
}
},
async executeSetOCGState() {
// 精确预览不改变 PDF 可选内容组状态。
},
async getAttachmentContent() {
// 精确预览不下载或执行 PDF 内嵌附件。
return undefined;
}
};
}
+6 -2
View File
@@ -2,11 +2,13 @@ import type {
ExportConfig, ExportConfig,
PagedDocumentPayload PagedDocumentPayload
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import type { MarkdownImageResource } from "./application-backend";
export interface PdfExportRequest { export interface PdfExportRequest {
markdown: string; markdown: string;
fileName: string; fileName: string;
language: string; language: string;
resources: MarkdownImageResource[];
exportConfig: ExportConfig; exportConfig: ExportConfig;
} }
@@ -28,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
request.markdown, request.markdown,
request.fileName, request.fileName,
request.language, request.language,
request.resources,
request.exportConfig request.exportConfig
]); ]);
} }
@@ -63,7 +66,8 @@ function parseNumericHeader(value: string | null) {
} }
export function parseContentDispositionFileName( export function parseContentDispositionFileName(
contentDisposition: string | null contentDisposition: string | null,
fallbackFileName = "document.pdf"
) { ) {
const encoded = contentDisposition?.match( const encoded = contentDisposition?.match(
/filename\*=UTF-8''([^;]+)/i /filename\*=UTF-8''([^;]+)/i
@@ -79,7 +83,7 @@ export function parseContentDispositionFileName(
return ( return (
contentDisposition contentDisposition
?.match(/filename="([^"]+)"/i)?.[1] ?.match(/filename="([^"]+)"/i)?.[1]
?.trim() || "document.pdf" ?.trim() || fallbackFileName
); );
} }
+19
View File
@@ -31,3 +31,22 @@ export function getPdfCanvasRenderScale(
} }
return (displayWidthCssPixels / widthPoints) * devicePixelRatio; return (displayWidthCssPixels / widthPoints) * devicePixelRatio;
} }
export function getPrecisePdfDocumentWidth(
pageWidthsPoints: readonly number[],
displayScale: number,
inlineGutter: number
) {
const maximumPageWidth = Math.max(0, ...pageWidthsPoints);
if (
maximumPageWidth <= 0 ||
displayScale <= 0 ||
inlineGutter < 0
) {
return 0;
}
return Math.ceil(
maximumPageWidth * PDF_PAGE_CSS_SCALE * displayScale +
inlineGutter * 2
);
}
+194
View File
@@ -0,0 +1,194 @@
const wideImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAABkAAAAOEAQMAAADDg2/hAAAABlBMVEVPRuUOpntYb4NEAAAB+klEQVR42u3bAQEAMAiAMPuXvjm4WwMCMAMAAAAAAAAAAAAAAHHvE0KECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEi5G4IAAAAAAAAAAAAAADUuRWECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiREg9BAAAAAAAAAAAAAAA6twKQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESKkHgIAAAAAAAAAAAAAAHVuBSFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRUg8BAAAAAAAAAAAAAIA6t4IQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECBEiRIgQIUKECKmHAAAAAAAAAAAAAABAnVtBiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoQIESJEiBAhQoTUQwAAAAAAAAAAAAAAoM6tIESIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFChAgRIkSIECFC6iEAAAAAAAAAAAAAABC3woXq+yBn0OwAAAAASUVORK5CYII=";
const portraitImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAAA4QAAAcIAQMAAABevZ3IAAAABlBMVEUPdm5e6tS6V5BUAAAB3ElEQVR42u3NMQ0AAAgDsPk3DQ52cpDWQBMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACo5prRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0Go1Go9FoNBqNRqPRaDQajUaj0Wg0fhoXJUddU5DUXZIAAAAASUVORK5CYII=";
const boundaryImageBase64 =
"iVBORw0KGgoAAAANSUhEUgAABLAAAANwAQMAAAAFoAG2AAAABlBMVEXCQQz7vyQwzQKmAAACUElEQVR42u3aoREAQAgDQZr8JqnyDXhkxOJwqzNXb67nMv7CwsLCwsLCwsLCwsLCwsLCwsI6sbI4+2NhYWFhYWFhYWFhYWFhYWFhYd1Y1kAsLCwsLCwsLCwsLCwsLCwsLP2WkRILCwsLCwsLCwsLCwsLCwsLS79lpMTCwsLCwsLCwsLCwsLCwsLC0m9hYWFhYWFhYWFhYWFhYWFhYem3rIFYWFhYWFhYWFhYWFhYWFhYWPotIyUWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFpd+yBmJhYWFhYWFhYWFhYWFhYWFh6beMlFhYWFhYWFhYWFhYWFhYWFhY+i0sLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhaWfstIiYWFhYWFhYWFhYWFhYWFhYWl3zJSYmFhYWFhYWFhYWFhYWFhYWHpt7CwsLCwsLCwsLCwsLCwsLCw9FvWQCwsLCwsLCwsLCwsLCwsLCws/ZaREgsLCwsLCwsLCwsLCwsLCwtLv4WFhYWFhYWFhYWFhYWFhYWFhaXfwsLCwsLCwsLCwsLCwsLCwsLSb1kDsbCwsLCwsLCwsLCwsLCwsLD0W0ZKLCwsLCwsLCwsLCwsLCwsLCz9FhYWFhYWFhYWFhYWFhYWFhYWln4LCwsLCwsLCwsLCwsLCwsLC0u/ZaTEwsLCwsLCwsLCwsLCwsLCwtJvGSmxsLCwsLCwsLCwsLCwsLCwsPRbWFhYWFhYWFiRrA8Rs81BfKlBmwAAAABJRU5ErkJggg==";
export const sampleMarkdown = `---
title: Markdown PDF 媒体分页示例
author: 内网文档团队
keywords:
- Markdown
- PDF
- 媒体分页
---
# Markdown PDF 媒体分页示例
这份文档集中展示常用排版、不同尺寸图片、Mermaid 与 ECharts 的渲染和分页效果。
## 基础排版
- [x] Markdown 实时预览
- [x] 图片、Mermaid 与 ECharts
- [x] 快速预览和精确 PDF 预览
- [ ] 根据实际文档继续扩展主题
| 能力 | 示例用途 |
| --- | --- |
| 表格与任务列表 | 验证主题基础样式 |
| 数学公式 | 验证字体和基线 |
| 媒体块 | 验证限宽、缩放与分页 |
行内公式:$E = mc^2$
> 预览与 PDF 共用相同的渲染结果、主题 CSS 和分页结构。
## 网络图片
下面只保留一张网络图片,用于验证远程资源获取与离线失败提示。
![NASA 地球网络图片](https://images-assets.nasa.gov/image/PIA18033/PIA18033~medium.jpg)
## Base64 图片块
### 横向图片:限宽基准
1600 × 900 的横图会先按正文宽度等比例收缩,收缩后的高度才是分页算法使用的基准高度。
![Base64 横向图片 1600×900][base64-wide]
### 边界图片:上一页空白回填
这一段用于形成稳定的分页边界。分页算法会保留标题、图注和间距的原始尺寸,只计算图片主体还能使用的高度。如果主体只需轻微缩小,并且缩放率仍在允许范围内,就尝试回填到上一页空白区域。
为了覆盖长文档场景,这里继续补充少量正文,使媒体块靠近默认 A4 页面底部。媒体元素必须按照源文档顺序串行处理,前一个媒体完成重排以后,后一个媒体才基于新的版面重新计算。
![Base64 边界图片 1200×880][base64-boundary]
### 纵向图片:单页高度适配
900 × 1800 的纵图在限宽后仍可能超过单页内容区,此时应继续等比例缩小并完整放在一页内。
![Base64 纵向图片 900×1800][base64-portrait]
## Mermaid 尺寸示例
### 横向流程图:限宽
\`\`\`mermaid
flowchart LR
A["Markdown"] --> B["统一 HTML"]
B --> C["主题 CSS"]
C --> D["网页预览"]
C --> E["PDF 导出"]
\`\`\`
### 纵向流程图:高度和分页
\`\`\`mermaid
flowchart TB
A["读取文档"] --> B["解析 Front Matter"]
B --> C["渲染 Markdown"]
C --> D["渲染 Mermaid"]
D --> E["渲染 ECharts"]
E --> F["等待图片与字体"]
F --> G["首次分页"]
G --> H["串行检查媒体块"]
H --> I["必要时重新分页"]
I --> J["生成 PDF"]
\`\`\`
## ECharts 尺寸示例
### 紧凑图表:40mm
\`\`\`echarts
caption: 紧凑柱状图(40mm
height: 40mm
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- type: bar
data: [28, 18, 15, 22]
\`\`\`
### 标准图表:58mm
\`\`\`echarts
caption: 产品能力树(58mm
height: 58mm
option:
series:
- type: tree
orient: LR
label:
position: left
data:
- name: 文档平台
children:
- name: Markdown
children:
- name: Mermaid
- name: ECharts
- name: PDF
children:
- name: 快速预览
- name: 精确预览
\`\`\`
大型图表的 caption 保持原尺寸,仅缩放图表主体。
### 大型图表:85mm
\`\`\`echarts
caption: 模块工作量占比(85mm
height: 85mm
option:
series:
- type: treemap
roam: false
nodeClick: false
breadcrumb:
show: false
label:
show: true
formatter: "{b}"
data:
- name: 渲染
children:
- { name: Markdown, value: 32 }
- { name: ECharts, value: 24 }
- { name: Mermaid, value: 18 }
- name: 导出
children:
- { name: PDF, value: 28 }
- { name: 主题, value: 12 }
\`\`\`
## 串行媒体块
下面连续放置图片、Mermaid 和 ECharts,用于观察长文档中多个媒体元素是否始终按源文档顺序计算。
![Base64 串行边界图片][base64-boundary]
\`\`\`mermaid
flowchart LR
A["图片完成"] --> B["Mermaid 计算"]
B --> C["ECharts 计算"]
\`\`\`
\`\`\`echarts
caption: 串行媒体收尾图(58mm
height: 58mm
option:
series:
- type: pie
radius: [35%, 70%]
data:
- { name: 图片, value: 36 }
- { name: Mermaid, value: 28 }
- { name: ECharts, value: 36 }
\`\`\`
> 所有媒体仅在额外缩放不超过阈值时回填;否则保持完整媒体块并移动到下一页。
[base64-wide]: data:image/png;base64,${wideImageBase64}
[base64-portrait]: data:image/png;base64,${portraitImageBase64}
[base64-boundary]: data:image/png;base64,${boundaryImageBase64}
`;
+513 -24
View File
@@ -24,8 +24,7 @@ textarea {
font: inherit; font: inherit;
} }
button, button {
.file-button {
min-height: 40px; min-height: 40px;
padding: 0 16px; padding: 0 16px;
border: 1px solid #b8c2bd; border: 1px solid #b8c2bd;
@@ -102,13 +101,31 @@ button:disabled {
border-left: 1px solid #d4dad7; border-left: 1px solid #d4dad7;
} }
.document-summary > strong { .document-name-row {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.document-name-row > strong {
overflow: hidden; overflow: hidden;
font-size: 0.82rem; font-size: 0.82rem;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.unsaved-badge {
flex: none;
padding: 1px 6px;
border: 1px solid #d9bd86;
border-radius: 999px;
background: #fff7e7;
color: #8a5a13;
font-size: 0.62rem;
font-weight: 800;
}
.document-summary .render-status { .document-summary .render-status {
max-width: 100%; max-width: 100%;
margin-top: 3px; margin-top: 3px;
@@ -138,17 +155,236 @@ h1 {
gap: 10px; gap: 10px;
} }
.file-button { .more-menu {
display: inline-flex; position: relative;
align-items: center;
} }
.file-button input { .more-menu-popover {
position: absolute; position: absolute;
width: 1px; z-index: 30;
height: 1px; top: calc(100% + 8px);
right: 0;
display: flex;
min-width: 220px;
padding: 6px;
border: 1px solid #c8d1cc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
flex-direction: column;
}
.more-menu-popover button {
width: 100%;
min-height: 36px;
padding: 0 11px;
border: 0;
background: transparent;
text-align: left;
}
.more-menu-popover button:hover,
.more-menu-popover button:focus-visible {
background: #edf3f0;
}
.more-menu-popover button:disabled:hover {
background: transparent;
}
.export-menu-popover {
width: 286px;
}
.more-menu-popover .export-menu-command {
min-height: 58px;
padding: 9px 11px;
}
.export-menu-command > span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-menu-command strong {
color: inherit;
font-size: 0.82rem;
}
.export-menu-command small {
overflow: hidden; overflow: hidden;
clip: rect(0 0 0 0); color: #74827b;
font-size: 0.68rem;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-menu-command:disabled small {
color: #929b97;
}
.more-menu-popover .menu-command {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.menu-command kbd {
color: #78857e;
font-family: inherit;
font-size: 0.68rem;
font-weight: 600;
}
.more-menu-submenu-item {
position: relative;
}
.menu-separator {
height: 1px;
margin: 5px 7px;
background: #e0e6e3;
}
.submenu-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.more-menu-submenu {
position: absolute;
z-index: 31;
top: -7px;
right: calc(100% + 8px);
width: 250px;
max-height: min(70vh, 560px);
padding: 6px;
overflow-y: auto;
border: 1px solid #c8d1cc;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(38 58 50 / 18%);
}
.sample-menu-group {
display: flex;
flex-direction: column;
margin: 0;
}
.sample-menu-group + .sample-menu-group {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid #e3e8e5;
}
.sample-menu-group-label {
padding: 6px 11px 3px;
color: #718078;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.06em;
}
.export-toast {
position: fixed;
z-index: 70;
top: 16px;
left: 50%;
display: flex;
width: min(440px, calc(100vw - 32px));
min-height: 62px;
padding: 13px 46px 13px 16px;
overflow: hidden;
border: 1px solid #bfd0c7;
border-radius: 12px;
background: rgb(255 255 255 / 97%);
box-shadow: 0 14px 38px rgb(38 58 50 / 24%);
transform: translateX(-50%);
}
.export-toast.is-success {
border-color: #9dc5b0;
}
.export-toast.is-error {
border-color: #dfaaa7;
background: rgb(255 250 249 / 98%);
}
.export-toast-content {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.export-toast-content strong {
color: #2d493c;
font-size: 0.84rem;
}
.export-toast.is-error .export-toast-content strong {
color: #943d39;
}
.export-toast-content span {
overflow: hidden;
color: #64736b;
font-size: 0.72rem;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-toast-close {
position: absolute;
top: 9px;
right: 9px;
width: 32px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: #718078;
font-size: 1.25rem;
line-height: 1;
}
.export-toast-progress {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
overflow: hidden;
background: #e2eae6;
}
.export-toast-progress span {
display: block;
width: 42%;
height: 100%;
border-radius: 999px;
background: #4d8b6e;
animation: export-progress 1.25s ease-in-out infinite;
}
@keyframes export-progress {
from {
transform: translateX(-110%);
}
to {
transform: translateX(340%);
}
} }
.editor-layout { .editor-layout {
@@ -448,24 +684,134 @@ h1 {
border: 0; border: 0;
} }
textarea { .markdown-editor {
flex: 1; flex: 1;
width: 100%; width: 100%;
min-height: 0; min-height: 0;
padding: 24px;
border: 0;
outline: 0;
resize: none;
background: #f6f7f5; background: #f6f7f5;
color: #26312d; overflow: hidden;
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-size: 0.86rem;
line-height: 1.72;
tab-size: 2;
} }
textarea:focus { .markdown-editor > div,
box-shadow: inset 3px 0 #74a88f; .markdown-editor .cm-editor {
height: 100%;
}
.markdown-toolbar {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 6px 10px;
border-bottom: 1px solid #d9dedb;
background: #f8faf9;
}
.markdown-toolbar-group {
display: flex;
align-items: center;
gap: 3px;
padding-left: 6px;
border-left: 1px solid #d9dedb;
}
.markdown-toolbar-button {
min-width: 32px;
min-height: 30px;
padding: 0 7px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: #41584e;
font-size: 0.72rem;
white-space: nowrap;
}
.markdown-toolbar-button:hover:not(:disabled) {
border-color: #c3d1ca;
background: #e8efeb;
}
.markdown-toolbar-button.command-bold {
font-weight: 900;
}
.markdown-toolbar-button.command-italic {
font-style: italic;
}
.markdown-toolbar-button.command-strikethrough {
text-decoration: line-through;
}
.markdown-toolbar-heading select {
height: 30px;
padding: 0 24px 0 8px;
border: 1px solid #c9d4ce;
border-radius: 6px;
background: #fff;
color: #41584e;
font-size: 0.72rem;
}
.markdown-table-tool {
position: relative;
}
.markdown-table-popover {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: 0;
display: flex;
width: 250px;
padding: 12px;
border: 1px solid #bfcac4;
border-radius: 9px;
background: #fff;
box-shadow: 0 12px 30px rgb(40 58 49 / 18%);
flex-direction: column;
gap: 4px;
}
.markdown-table-popover strong {
color: #2f493e;
font-size: 0.8rem;
}
.markdown-table-popover > span {
color: #718078;
font-size: 0.68rem;
}
.markdown-table-grid {
display: grid;
margin-top: 6px;
grid-template-columns: repeat(10, 18px);
gap: 4px;
}
.markdown-table-cell {
width: 18px;
min-width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
border: 1px solid #cbd4cf;
border-radius: 3px;
background: #f4f7f5;
}
.markdown-table-cell.is-selected {
border-color: #65957d;
background: #a9cbb9;
}
.markdown-table-cell:focus-visible {
outline: 2px solid #356c55;
outline-offset: 1px;
} }
.preview-panel { .preview-panel {
@@ -546,7 +892,8 @@ textarea:focus {
} }
.precise-pdf-page canvas, .precise-pdf-page canvas,
.precise-pdf-page .textLayer { .precise-pdf-page .textLayer,
.precise-pdf-page .annotationLayer {
position: absolute; position: absolute;
inset: 0; inset: 0;
} }
@@ -580,6 +927,45 @@ textarea:focus {
--min-font-size-inv: calc(1 / var(--min-font-size)); --min-font-size-inv: calc(1 / var(--min-font-size));
} }
.precise-pdf-page .annotationLayer {
z-index: 2;
overflow: hidden;
color-scheme: only light;
pointer-events: none;
transform-origin: 0 0;
}
.precise-pdf-page .annotationLayer section {
position: absolute;
box-sizing: border-box;
text-align: initial;
pointer-events: auto;
user-select: none;
transform-origin: 0 0;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a {
position: absolute;
inset: 0;
cursor: pointer;
outline: none;
}
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:hover,
.precise-pdf-page
.annotationLayer
.linkAnnotation
> a:focus-visible {
background: rgb(31 111 83 / 14%);
outline: 1px solid rgb(31 111 83 / 45%);
}
.precise-pdf-page .textLayer :is(span, br) { .precise-pdf-page .textLayer :is(span, br) {
position: absolute; position: absolute;
color: transparent; color: transparent;
@@ -672,6 +1058,59 @@ textarea:focus {
inset: 0; inset: 0;
} }
.document-dialog-layer {
position: fixed;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
inset: 0;
}
.document-dialog-backdrop {
position: absolute;
background: rgb(24 35 30 / 46%);
inset: 0;
}
.document-dialog {
position: relative;
width: min(480px, 100%);
padding: 24px;
border: 1px solid #c9d2cd;
border-radius: 14px;
background: #fff;
box-shadow: 0 24px 70px rgb(20 34 28 / 28%);
}
.document-dialog h2 {
margin: 4px 0 10px;
color: #25332d;
font-family: Georgia, "Songti SC", serif;
font-size: 1.45rem;
font-weight: 500;
}
.document-dialog p {
margin: 0;
color: #5d6a64;
font-size: 0.88rem;
line-height: 1.65;
}
.document-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
.document-dialog-actions .is-primary {
border-color: #326b54;
background: #326b54;
color: #fff;
}
.settings-backdrop { .settings-backdrop {
position: absolute; position: absolute;
width: 100%; width: 100%;
@@ -850,6 +1289,19 @@ textarea:focus {
gap: 8px 18px; gap: 8px 18px;
} }
.margin-mode-control {
margin-bottom: 8px;
}
.margin-source-hint {
margin: 0 0 10px;
}
.margin-reset-action {
width: 100%;
margin-top: 10px;
}
.margin-grid .setting-field { .margin-grid .setting-field {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
@@ -981,7 +1433,7 @@ textarea:focus {
min-height: 650px; min-height: 650px;
} }
textarea { .markdown-editor {
min-height: 560px; min-height: 560px;
} }
@@ -1044,6 +1496,11 @@ textarea:focus {
.editor-layout { .editor-layout {
transition: none; transition: none;
} }
.export-toast-progress span {
width: 100%;
animation: none;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
@@ -1056,4 +1513,36 @@ textarea:focus {
.settings-drawer { .settings-drawer {
width: 100vw; width: 100vw;
} }
.more-menu-popover {
max-height: min(72vh, 620px);
overflow-y: auto;
}
.export-menu-popover {
right: 0;
width: min(286px, calc(100vw - 24px));
}
.more-menu-submenu {
position: static;
width: auto;
max-height: 48vh;
margin: 4px 0 0 8px;
box-shadow: none;
}
.document-dialog-layer {
align-items: end;
padding: 12px;
}
.document-dialog {
padding: 20px;
}
.document-dialog-actions {
align-items: stretch;
flex-direction: column-reverse;
}
} }

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