diff --git a/AGENTS.md b/AGENTS.md index 6a78f6a..d99e300 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8 - 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件; - 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式; - 支持主题清单与 CSS 主题扩展; -- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; +- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX、Mermaid 和 ECharts; +- 提供 Web 与 Electron 桌面端,共用渲染、主题和分页核心; - 使用 Docker Compose 在内网部署; - 文档只在当前请求中临时处理,不建立文档历史数据库。 @@ -75,15 +76,18 @@ flowchart LR - 前端:React、TypeScript、Vite。 - 后端:Node.js、TypeScript、Fastify。 +- 桌面端:Electron、electron-builder、NSIS。 +- 应用服务:`packages/application`。 - 共享模型:`packages/core`。 - Markdown 渲染:`packages/renderer`。 -- PDF 引擎:计划使用 Playwright Chromium。 +- ECharts 协议与运行时:`packages/markdown-echarts`。 +- PDF 引擎:Web 使用固定版本 Playwright Chromium,桌面端使用 Electron Chromium。 - Markdown:markdown-it 及 `@mdit/plugin-*` 插件。 - 元数据:gray-matter。 - 安全过滤:sanitize-html。 - 代码高亮:highlight.js。 - 数学公式:KaTeX。 -- 图表:Mermaid。 +- 图表:Mermaid、ECharts。 - 校验:Zod。 - 测试:Vitest。 - 部署:Docker、Docker Compose。 @@ -92,12 +96,14 @@ flowchart LR ```text apps/web/ 前端编辑、配置与预览界面 -apps/server/ HTTP API、主题读取及未来 PDF 服务 +apps/server/ HTTP API、主题读取和 Playwright PDF 服务 +apps/desktop/ Electron 桌面壳、IPC、原生文件与 PDF 能力 +packages/application/ 共享应用服务、主题注册和图片资源处理 packages/core/ 导出配置和主题清单等共享模型 packages/renderer/ Markdown 到安全 HTML 的渲染管线 -themes/ 内置主题及未来可安装主题 -docker/ 容器化配置 -tests/ 跨包和端到端测试 +packages/markdown-echarts/ ECharts YAML 协议、验证和浏览器 SVG 运行时 +themes/ 内置主题 +deploy/ Docker、Compose、Nginx 和部署文档 docs/ 进度、架构和部署文档 ``` @@ -186,6 +192,11 @@ chore: 初始化项目骨架 ``` - 不修改或重写已有提交,除非用户明确要求。 +- 版本标签必须指向专门的发布提交。发布提交信息使用标准英文前缀 + `release:`,简体中文正文必须完整列出该版本的新增能力、问题修复、 + 兼容性或部署变化、验证结果和发布产物,不得只写版本号或简略摘要。 +- 版本标签使用 annotated tag;标签说明应与发布提交正文保持一致,完整 + 记录本版本更新,不得使用只有版本号的标签说明。 ## 9. 当前交接入口 diff --git a/README.md b/README.md index 13fc255..cdb763a 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,90 @@ # Markdown PDF 导出器 -一个面向内网部署的 Markdown 排版与 PDF 导出工具。项目目标是使用同一份 HTML 和 CSS 完成网页预览与 Chromium PDF 渲染,并提供纸张、页边距、页眉页脚、页码及主题扩展能力。 +一个同时提供 Web 与 Windows 桌面端的 Markdown 排版、分页预览和 PDF +导出工具。预览与 PDF 复用同一份安全 HTML、`#write` DOM、主题 CSS 和 +分页运行时,适合内网部署,也可以作为不依赖本地服务的桌面应用使用。 -## 当前状态 +当前版本:`v0.4.5`。 -已经实现: +## v0.4.5 主要能力 -- React Markdown 编辑与本地文件读取; -- Fastify 预览接口; -- 共享导出配置模型; -- 动态主题清单、CSS 和静态资源接口; -- Markdown、Front Matter、安全过滤和扩展语法渲染核心; -- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; -- iframe 隔离的真实分页预览、双向滚动同步与主题切换; -- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测; -- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存; -- 快速预览与精确预览共用的 50%~400% 显示缩放; -- 内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题; -- Web 素材目录上传、桌面同目录相对图片和受限公网图片下载; -- 图片标题、整块分页及超高图片单页等比例适配; -- 无本地 Server 的 Electron 桌面端与 Windows 安装包; -- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。 +- 新建、打开和保存 Markdown;新文档可直接导出 PDF; +- Windows 注册 `.md`、`.markdown` 文件关联,系统打开文件时自动加载; +- 桌面窗口位置、尺寸和最大化状态延迟持久化,并在多显示器变化后自动 + 修正到可见工作区; +- “打开 Markdown”保留为独立主按钮,新建、保存和主题目录收纳在“更多” + 菜单; +- 桌面端提供可直接放置自定义主题的本地目录; +- 图片、Mermaid 和 ECharts 采用串行媒体分页:每个媒体块至多重排一次, + 严格保持文档顺序; +- 媒体先按内容限宽计算基准高度。当上一页空白接近媒体所需空间时,允许 + 将媒体内容等比例缩小回填;图片标题或图表标题保持原字号且与媒体成块; +- 示例文档覆盖横图、竖图、Base64 图片、Mermaid、ECharts 以及不同尺寸 + 的分页边界。 + +## Web 与桌面端 + +| 能力 | Web / Docker | Windows Desktop | +| --- | --- | --- | +| 新建 Markdown | 支持 | 支持 | +| 打开 Markdown | 浏览器文件选择 | 原生对话框、文件关联、第二实例转交 | +| 保存 Markdown | 浏览器下载 | 原生另存为 | +| 相对本地图片 | 默认不支持 | 以 Markdown 所在目录为安全边界 | +| 远程图片 | 服务端受限下载并内嵌 | 应用服务受限下载并内嵌 | +| 自定义主题 | Compose 只读挂载目录 | “更多 → 打开自定义主题目录” | +| PDF 引擎 | 固定版本 Playwright Chromium | Electron 自带 Chromium | + +Web 端不提供本地素材目录能力。需要包含本地相对资源的文档时,请使用 +Desktop;Web 端可使用 Base64/Data URL 或受限公网图片。 + +## 渲染架构 + +```mermaid +flowchart LR + A["Markdown 与受限资源"] --> B["Renderer 安全渲染"] + B --> C["统一 #write HTML"] + D["主题 CSS 与导出配置"] --> C + C --> E["共享分页运行时"] + E --> F["Web 快速预览"] + E --> G["Playwright Chromium"] + E --> H["Electron Chromium"] + G --> I["Web PDF"] + H --> J["Desktop PDF"] +``` + +快速预览适合编辑反馈;精确预览直接展示服务端生成的 PDF,是 Web 最终 +分页的权威结果。桌面 PDF 复用相同分页载荷和运行时。 + +## 项目目录 + +```text +apps/ + web/ React 编辑、设置、快速/精确预览与浏览器文件操作 + server/ Fastify API、Playwright PDF 与容器运行入口 + desktop/ Electron 主进程、IPC、原生文件和 Windows 打包 +packages/ + core/ 跨端文档、导出配置和主题协议 + renderer/ Markdown 到安全 #write HTML 的渲染管线 + application/ 共享渲染、主题注册和图片资源应用服务 + markdown-echarts/ ECharts YAML 协议、验证与浏览器 SVG 运行时 +themes/ 内置主题与主题清单 +deploy/ Dockerfile、Compose、Nginx 和部署文档 +docs/ 项目进度与主题开发说明 +scripts/ 主题导入等维护脚本 +logos/ 应用图标源文件 +.local/themes/ 本地开发和 Compose 自定义主题目录 +``` + +包依赖保持单向:`core` 提供基础协议;`markdown-echarts` 提供独立图表 +能力;`renderer` 消费两者生成安全 HTML;`application` 组合渲染器、主题 +和资源用例;三个 `apps/*` 只负责各自平台的界面、传输与 PDF 适配。 + +各包的目录和用法: + +- [application](packages/application/README.md) +- [core](packages/core/README.md) +- [renderer](packages/renderer/README.md) +- [markdown-echarts](packages/markdown-echarts/README.md) ## 本地开发 @@ -33,118 +97,67 @@ npm run dev 默认地址: -- 前端:http://localhost:5173 -- 后端:http://localhost:3001 +- Web:http://localhost:5173 +- API:http://localhost:3001 - 健康检查:http://localhost:3001/api/health -页面支持直接编辑 Markdown,或选择本地 `.md` 文件。若 Markdown 使用 -相对图片路径,再选择对应的素材目录即可;文件与图片只进入当前渲染请求, -不写入数据库。桌面端使用原生“打开 Markdown”对话框,并自动从 Markdown -所在目录安全解析相对图片。 +桌面端开发: -独立一行的 `![图片标题](地址)` 会显示图片标题。图片与标题作为一个整体 -参与分页;超出单页内容区时,图片会在预留标题高度后等比例缩放,不跨页 -切割。远程图片会先由应用服务受限下载并内嵌,失败时显示可诊断占位。 - -编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。 - -预览工具栏支持滑块和手动百分比输入。缩放比例只改变浏览器显示,不改变纸张尺寸、分页结果或最终 PDF,并会单独保存在浏览器中。 - -## Mermaid 配置与样式 - -导出设置可以统一控制整篇文档的 Mermaid 布局、主题、外观和字体: - -- 布局:Dagre、ELK; -- 主题:Mermaid 11.16.0 提供的 Default、Base、Dark、Forest、Neutral、Neo 和 Redux 系列; -- 外观:Classic、Hand-drawn、Neo; -- 字体:任意本地 CSS 字体族。 - -未设置时使用 `dagre + default + classic`。单个 Mermaid 区块的 `config` Front Matter 优先于全局导出设置: - -````markdown -```mermaid ---- -config: - layout: elk - theme: base - look: handDrawn - fontFamily: Microsoft YaHei - themeVariables: - primaryColor: "#dbeafe" - primaryBorderColor: "#2563eb" - flowchart: - wrappingWidth: 360 ---- -flowchart TB - classDef important fill:#fee2e2,stroke:#dc2626 - A["较长的节点名称
第二行说明"] --> B["下一个节点"] - style A fill:#dcfce7,stroke:#16a34a - class B important +```powershell +npm run desktop:dev ``` -```` -`style`、`classDef`、`linkStyle`、`themeVariables` 和图表专属配置均由 Mermaid 官方渲染器处理。原始 `themeCSS`、`securityLevel` 和资源限制属于安全配置,不能由 Markdown 代码块覆盖。 +桌面端目录包和 Windows 安装包: -ELK 使用官方 `@mermaid-js/layout-elk`,只有图表实际请求 ELK 时才加载核心布局代码。 - -### Mermaid 长标签 - -流程图节点标签的默认最大宽度为 320px,可以通过上例中的 `flowchart.wrappingWidth` 调整。 - -需要自动换行时可以使用 Mermaid Markdown String: - -````markdown -```mermaid -flowchart LR - A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"] +```powershell +npm run desktop:package +npm run make -w @md-to-pdf/desktop ``` -```` -个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`: +产物位于 `apps/desktop/out/v0.4.5/`。正式安装包命名为 +`md-to-pdf-0.4.5-x86_64-Setup.exe`,同时生成免安装 ZIP;当前发行包 +未配置代码签名。 -````markdown -```mermaid -flowchart LR - A["较长节点"] --> B["普通节点"] - classDef compact font-size:12px; - class A compact; -``` -```` +## 文档与媒体 -## 内置与本地 Typora 主题 +Markdown 支持 Front Matter、表格、任务列表、脚注、highlight.js、 +KaTeX、Mermaid 和安全的 ECharts YAML 围栏。 -公司内部版本已经内置 GitHub、Pixyll 和 Whitey 三套适合打印的白色 -Typora 默认主题。如需从本机 Typora 安装目录刷新本地开发副本,可以运行: +独立一行的 `![标题](地址)` 会生成带标题的图片块。分页引擎将图片、 +Mermaid、ECharts 及其紧邻标题视为不可拆分媒体块,并按文档顺序串行 +计算。图片的缩放基准是原图先收缩到内容限宽后的高度;需要回填上一页时 +只缩放媒体内容,不缩放标题。这样可利用接近可容纳的页尾空白,同时避免 +跨页、重复重排和后续媒体顺序变化。 + +ECharts 围栏语法、支持系列和安全边界详见 +[markdown-echarts 包文档](packages/markdown-echarts/README.md)。 + +## 主题 + +仓库内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题。主题由 +版本化 `theme.json` 清单、主体 CSS、可选基础 CSS、打印 CSS 和资源组成。 + +- 本地开发与 Compose:将主题目录放入 `.local/themes/`; +- Desktop:从“更多 → 打开自定义主题目录”打开实际安装目录,再放入主题; +- 主题格式、安全限制和资源引用详见[主题开发指南](docs/THEMES.md)。 + +Compose 会把 `.local/themes` 只读挂载到容器。Typora 主题导入脚本只用于 +刷新本地开发副本: ```powershell npm run theme:import-typora ``` -默认从 Typora 安装目录的 `resources\style` 读取基础 CSS、主题和资源,写入 `.local\themes\typora-*`。启动开发服务后,这些主题会出现在预览页面的主题选择器中。 - -导入工具不会默认覆盖已存在的目标。确认替换本地副本时使用: - -```powershell -npm run theme:import-typora -- --replace -``` - -旧副本会移动到 `.local\theme-backups`。 - -本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。 - -自定义主题目录、`theme.json`、CSS 组合顺序、资源引用和安全限制详见 [主题开发指南](docs/THEMES.md)。 - -## Docker Desktop 部署 +## Docker Compose 部署 ```powershell docker compose -f deploy\compose.yaml up --build -d ``` -默认访问地址为 http://localhost:8080。Compose 会将 `.local/themes` -作为只读主题目录挂载;端口、主题目录和 PDF 并发参数的配置方式详见 -[AIO 容器部署说明](deploy/README.md)。 +默认访问 http://localhost:8080。端口、镜像、主题挂载、PDF 并发和超时 +配置详见 [AIO 容器部署说明](deploy/README.md)。 -## 测试与构建 +## 验证 ```powershell npm test @@ -153,8 +166,18 @@ npm run build git diff --check ``` -## 隐私原则 +涉及分页或 PDF 的改动还应使用包含中文、长表格、代码、公式、图片、 +Mermaid、ECharts 和分页边界的示例完成 Web、Compose 与 Desktop 端到端 +验证,并检查 PDF 页面渲染结果。 -文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 -相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程 -地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。 +## 安全与隐私 + +- 不建立 Markdown、图片或 PDF 历史数据库; +- 文档和资源只在当前请求或当前桌面会话中处理; +- Markdown 原始 HTML 不执行,输出经过白名单过滤; +- Mermaid 使用严格安全模式,ECharts 只接受受限纯数据 YAML; +- 本地资源阻止路径穿越和符号链接越界; +- 远程资源阻止内网地址,并限制数量、单文件大小、总大小和超时; +- PDF 页面阻止未授权外部网络访问。 + +当前实现与交接状态详见 [docs/PROGRESS.md](docs/PROGRESS.md)。 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 56eb4f6..4065e7a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,9 +1,19 @@ # Desktop 桌面端 -`apps/desktop` 是 `v0.4.0` 新增的 Electron 桌面应用。当前已经完成 -Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择、 -同目录图片解析、隐藏分页窗口、Electron Chromium PDF 输出、原生 PDF -另存为和 Windows 安装包验证。 +`apps/desktop` 是 Electron 桌面应用。当前已经完成 Electron 壳、受限 +preload、进程内应用服务、原生 Markdown 打开与保存、同目录图片解析、 +本地主题目录、隐藏分页窗口、Electron Chromium PDF 输出和 Windows +安装包。 + +桌面端会记住上次窗口的位置、尺寸和最大化状态;窗口变化后延迟写入, +异常退出后也能恢复。恢复时会按当前显示器工作区修正位置与尺寸,避免窗口 +落在屏幕外。Windows 安装包会注册 `.md` 和 `.markdown` 文件关联, +双击文件或使用“打开方式”会复用已有应用窗口并加载文档。 + +“打开 Markdown”是顶部独立主操作;新建文档、保存 Markdown 和打开 +自定义主题目录收纳在“更多”菜单中。新建文档未保存时显示“未命名文档”, +首次保存默认使用一级标题作为文件名;没有一级标题时使用首行文本,并会 +自动清理 Windows 非法文件名字符。新文档无需先保存即可直接导出 PDF。 ## 开发 @@ -27,8 +37,8 @@ npm run make -w @md-to-pdf/desktop ``` 版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到 -`apps/desktop/out/v0.4.4/`。该目录被 Git 忽略。公司内部分发以 -`md-to-pdf-0.4.4-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 +`apps/desktop/out/v0.4.5/`。该目录被 Git 忽略。公司内部分发以 +`md-to-pdf-0.4.5-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装 辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者” 提示。 diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index 6fc59f1..0d7c533 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -1,4 +1,36 @@ !macro customInstall + WriteRegStr SHCTX \ + "Software\Classes\MarkdownPdfExporter.Markdown" \ + "" \ + "Markdown 文档" + WriteRegStr SHCTX \ + "Software\Classes\MarkdownPdfExporter.Markdown\DefaultIcon" \ + "" \ + '"$INSTDIR\md-to-pdf.exe",0' + WriteRegStr SHCTX \ + "Software\Classes\MarkdownPdfExporter.Markdown\shell\open\command" \ + "" \ + '"$INSTDIR\md-to-pdf.exe" "%1"' + WriteRegStr SHCTX \ + "Software\Classes\.md\OpenWithProgids" \ + "MarkdownPdfExporter.Markdown" \ + "" + WriteRegStr SHCTX \ + "Software\Classes\.markdown\OpenWithProgids" \ + "MarkdownPdfExporter.Markdown" \ + "" + WriteRegStr SHCTX \ + "Software\Classes\Applications\md-to-pdf.exe\shell\open\command" \ + "" \ + '"$INSTDIR\md-to-pdf.exe" "%1"' + WriteRegStr SHCTX \ + "Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \ + ".md" \ + "" + WriteRegStr SHCTX \ + "Software\Classes\Applications\md-to-pdf.exe\SupportedTypes" \ + ".markdown" \ + "" CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器" CreateShortCut \ "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \ @@ -11,10 +43,22 @@ "" \ "$INSTDIR\md-to-pdf.exe" StrCpy $launchLink "$INSTDIR\md-to-pdf.exe" + System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)' !macroend !macro customUnInstall + DeleteRegValue SHCTX \ + "Software\Classes\.md\OpenWithProgids" \ + "MarkdownPdfExporter.Markdown" + DeleteRegValue SHCTX \ + "Software\Classes\.markdown\OpenWithProgids" \ + "MarkdownPdfExporter.Markdown" + DeleteRegKey SHCTX \ + "Software\Classes\Applications\md-to-pdf.exe" + DeleteRegKey SHCTX \ + "Software\Classes\MarkdownPdfExporter.Markdown" Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" RMDir "$SMPROGRAMS\Markdown PDF 导出器" Delete "$DESKTOP\Markdown PDF 导出器.lnk" + System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)' !macroend diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a72194b..f991c73 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@md-to-pdf/desktop", - "version": "0.4.4", + "version": "0.4.5", "private": true, "productName": "Markdown PDF 导出器", "description": "Markdown PDF 导出器桌面端", diff --git a/apps/desktop/src/app-preload.ts b/apps/desktop/src/app-preload.ts index a4aeda8..bdc69f3 100644 --- a/apps/desktop/src/app-preload.ts +++ b/apps/desktop/src/app-preload.ts @@ -2,16 +2,40 @@ import { contextBridge, ipcRenderer } from "electron"; import type { PagedDocumentPayload } from "@md-to-pdf/core"; import type { MarkdownRenderRequest } from "@md-to-pdf/application"; import { + DESKTOP_CONSUME_PENDING_MARKDOWN, + DESKTOP_DISCARD_PENDING_MARKDOWN, DESKTOP_GET_THEME_CSS, DESKTOP_GENERATE_PDF, DESKTOP_LIST_THEMES, + DESKTOP_MARKDOWN_OPENED, DESKTOP_OPEN_MARKDOWN, + DESKTOP_OPEN_THEME_DIRECTORY, + DESKTOP_REFRESH_THEMES, DESKTOP_RENDER_MARKDOWN, + DESKTOP_SAVE_MARKDOWN, + DESKTOP_START_NEW_MARKDOWN, DESKTOP_SAVE_PDF } from "./channels.js"; contextBridge.exposeInMainWorld("mdToPdfDesktop", { openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN), + consumePendingMarkdown: () => + ipcRenderer.invoke(DESKTOP_CONSUME_PENDING_MARKDOWN), + discardPendingMarkdown: () => + ipcRenderer.invoke(DESKTOP_DISCARD_PENDING_MARKDOWN), + onMarkdownOpened: (listener: () => void) => { + const handleOpened = () => listener(); + ipcRenderer.on(DESKTOP_MARKDOWN_OPENED, handleOpened); + return () => + ipcRenderer.removeListener(DESKTOP_MARKDOWN_OPENED, handleOpened); + }, + startNewMarkdown: () => + ipcRenderer.invoke(DESKTOP_START_NEW_MARKDOWN), + saveMarkdown: (fileName: string, markdown: string) => + ipcRenderer.invoke(DESKTOP_SAVE_MARKDOWN, fileName, markdown), + openThemeDirectory: () => + ipcRenderer.invoke(DESKTOP_OPEN_THEME_DIRECTORY), + refreshThemes: () => ipcRenderer.invoke(DESKTOP_REFRESH_THEMES), renderMarkdown: (input: MarkdownRenderRequest) => ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input), listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES), diff --git a/apps/desktop/src/channels.ts b/apps/desktop/src/channels.ts index 1878b13..117cd06 100644 --- a/apps/desktop/src/channels.ts +++ b/apps/desktop/src/channels.ts @@ -2,6 +2,20 @@ export const DESKTOP_RENDER_MARKDOWN = "md-to-pdf:desktop:render-markdown"; export const DESKTOP_OPEN_MARKDOWN = "md-to-pdf:desktop:open-markdown"; +export const DESKTOP_CONSUME_PENDING_MARKDOWN = + "md-to-pdf:desktop:consume-pending-markdown"; +export const DESKTOP_DISCARD_PENDING_MARKDOWN = + "md-to-pdf:desktop:discard-pending-markdown"; +export const DESKTOP_MARKDOWN_OPENED = + "md-to-pdf:desktop:markdown-opened"; +export const DESKTOP_START_NEW_MARKDOWN = + "md-to-pdf:desktop:start-new-markdown"; +export const DESKTOP_SAVE_MARKDOWN = + "md-to-pdf:desktop:save-markdown"; +export const DESKTOP_OPEN_THEME_DIRECTORY = + "md-to-pdf:desktop:open-theme-directory"; +export const DESKTOP_REFRESH_THEMES = + "md-to-pdf:desktop:refresh-themes"; export const DESKTOP_LIST_THEMES = "md-to-pdf:desktop:list-themes"; export const DESKTOP_GET_THEME_CSS = diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 04f54f8..de5bf45 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,23 +6,37 @@ import { Menu, net, protocol, + screen, session, + shell, type IpcMainInvokeEvent } from "electron"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { + access, + mkdir, + writeFile +} from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createApplicationService, + MAXIMUM_MARKDOWN_LENGTH, type ApplicationService } from "@md-to-pdf/application"; import { + DESKTOP_CONSUME_PENDING_MARKDOWN, + DESKTOP_DISCARD_PENDING_MARKDOWN, DESKTOP_GET_THEME_CSS, DESKTOP_GENERATE_PDF, DESKTOP_LIST_THEMES, + DESKTOP_MARKDOWN_OPENED, DESKTOP_OPEN_MARKDOWN, + DESKTOP_OPEN_THEME_DIRECTORY, + DESKTOP_REFRESH_THEMES, DESKTOP_RENDER_MARKDOWN, - DESKTOP_SAVE_PDF + DESKTOP_SAVE_MARKDOWN, + DESKTOP_SAVE_PDF, + DESKTOP_START_NEW_MARKDOWN } from "./channels.js"; import { parseMarkdownRenderRequest, @@ -34,11 +48,36 @@ import { ElectronPdfGenerator } from "./electron-pdf-generator.js"; import { parsePagedDocumentPayload } from "./pdf-contract.js"; +import { + findMarkdownFileArgument, + isMarkdownFilePath, + readMarkdownDocument, + type OpenedMarkdownDocument +} from "./markdown-file.js"; +import { + loadWindowState, + resolveWindowBounds, + saveWindowState, + type WindowState +} from "./window-state.js"; const APP_SCHEME = "mdpdf"; const APP_HOST = "bundle"; const THEME_HOST = "theme"; const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 }; +const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 }; +let applicationController: + | { + window: BrowserWindow; + openMarkdownFile(filePath: string): Promise; + flushWindowState(): Promise; + } + | undefined; +let pendingExternalMarkdownPath = findMarkdownFileArgument( + process.argv, + process.cwd() +); protocol.registerSchemesAsPrivileged([ { @@ -91,15 +130,44 @@ function encodeThemeAssetPath(assetPath: string) { .join("/"); } +function focusApplicationWindow() { + const window = applicationController?.window; + if (!window || window.isDestroyed()) { + return; + } + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); +} + +function dispatchExternalMarkdown(filePath: string) { + if (!applicationController) { + pendingExternalMarkdownPath = filePath; + return; + } + void applicationController.openMarkdownFile(filePath).catch( + (error: unknown) => { + console.warn("无法打开系统传入的 Markdown 文件", error); + } + ); + focusApplicationWindow(); +} + +function getDesktopThemeDirectory() { + return app.isPackaged + ? path.join(app.getPath("userData"), "themes") + : path.resolve(currentDirectory, "../../..", ".local", "themes"); +} + function createDesktopApplicationService() { const projectRoot = path.resolve(currentDirectory, "../../.."); return createApplicationService({ bundledRoot: app.isPackaged ? path.join(process.resourcesPath, "themes") : path.join(projectRoot, "themes"), - localRoot: app.isPackaged - ? path.join(app.getPath("userData"), "themes") - : path.join(projectRoot, ".local", "themes"), + localRoot: getDesktopThemeDirectory(), createAssetUrl: (themeId, assetPath) => `${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`, onWarning: (message) => console.warn(message) @@ -204,14 +272,26 @@ async function createApplication( "/preview-frame.html?target=pdf", applicationUrl ).href; + const windowStatePath = path.join( + app.getPath("userData"), + "window-state.json" + ); + const savedWindowState = await loadWindowState(windowStatePath); + const primaryWorkArea = screen.getPrimaryDisplay().workArea; + const initialBounds = resolveWindowBounds( + savedWindowState?.bounds, + screen.getAllDisplays().map((display) => display.workArea), + primaryWorkArea, + DEFAULT_WINDOW_SIZE, + MINIMUM_WINDOW_SIZE + ); const window = new BrowserWindow({ title: `Markdown PDF 导出器 v${app.getVersion()}`, icon: getApplicationIcon(), - width: 1440, - height: 960, - minWidth: 1024, - minHeight: 720, + ...initialBounds, + minWidth: Math.min(MINIMUM_WINDOW_SIZE.width, initialBounds.width), + minHeight: Math.min(MINIMUM_WINDOW_SIZE.height, initialBounds.height), autoHideMenuBar: true, show: false, webPreferences: { @@ -221,6 +301,55 @@ async function createApplication( preload: path.join(currentDirectory, "app-preload.cjs") } }); + let saveWindowStateTimer: NodeJS.Timeout | undefined; + let pendingWindowStateWrite = Promise.resolve(); + let latestWindowState: WindowState = { + version: 1, + bounds: window.getNormalBounds(), + maximized: window.isMaximized() + }; + const captureWindowState = () => { + if (window.isDestroyed()) { + return; + } + latestWindowState = { + version: 1, + bounds: window.getNormalBounds(), + maximized: window.isMaximized() + }; + }; + const persistWindowState = () => { + const state = structuredClone(latestWindowState); + pendingWindowStateWrite = pendingWindowStateWrite + .catch(() => undefined) + .then(() => saveWindowState(windowStatePath, state)) + .catch((error: unknown) => { + console.warn("无法保存窗口状态", error); + }); + return pendingWindowStateWrite; + }; + const scheduleWindowStateSave = () => { + captureWindowState(); + if (saveWindowStateTimer) { + clearTimeout(saveWindowStateTimer); + } + saveWindowStateTimer = setTimeout(() => { + saveWindowStateTimer = undefined; + void persistWindowState(); + }, 2_000); + }; + const flushWindowState = async () => { + if (saveWindowStateTimer) { + clearTimeout(saveWindowStateTimer); + saveWindowStateTimer = undefined; + } + captureWindowState(); + await persistWindowState(); + }; + window.on("move", scheduleWindowStateSave); + window.on("resize", scheduleWindowStateSave); + window.on("maximize", scheduleWindowStateSave); + window.on("unmaximize", scheduleWindowStateSave); lockDownWindow(window, new URL(applicationUrl).origin); const pdfGenerator = new ElectronPdfGenerator({ @@ -233,6 +362,24 @@ async function createApplication( new URL(event.senderFrame.url).origin === new URL(applicationUrl).origin; let documentRoot: string | undefined; + let currentMarkdownPath: string | undefined; + let pendingOpenedMarkdown: + | { + document: OpenedMarkdownDocument; + filePath: string; + } + | undefined; + const queueOpenedMarkdown = async (filePath: string) => { + const opened = await readMarkdownDocument( + filePath, + MAXIMUM_MARKDOWN_LENGTH + ); + pendingOpenedMarkdown = { + document: opened, + filePath + }; + window.webContents.send(DESKTOP_MARKDOWN_OPENED); + }; ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => { if (!validateSender(event)) { @@ -242,20 +389,116 @@ async function createApplication( title: "打开 Markdown", properties: ["openFile"], filters: [ - { name: "Markdown 文件", extensions: ["md", "markdown"] }, - { name: "文本文件", extensions: ["txt"] } + { name: "Markdown 文件", extensions: ["md", "markdown"] } ] }); const filePath = selection.filePaths[0]; if (selection.canceled || !filePath) { return undefined; } - const content = await readFile(filePath, "utf8"); + const opened = await readMarkdownDocument( + filePath, + MAXIMUM_MARKDOWN_LENGTH + ); documentRoot = path.dirname(filePath); - return { - markdown: content, - fileName: path.basename(filePath) - }; + currentMarkdownPath = filePath; + return opened; + }); + ipcMain.handle(DESKTOP_CONSUME_PENDING_MARKDOWN, async (event) => { + if (!validateSender(event)) { + throw new Error("拒绝未授权的桌面文件请求"); + } + const pending = pendingOpenedMarkdown; + pendingOpenedMarkdown = undefined; + if (!pending) { + return undefined; + } + currentMarkdownPath = pending.filePath; + documentRoot = path.dirname(pending.filePath); + return pending.document; + }); + ipcMain.handle(DESKTOP_DISCARD_PENDING_MARKDOWN, async (event) => { + if (!validateSender(event)) { + throw new Error("拒绝未授权的桌面文件请求"); + } + pendingOpenedMarkdown = undefined; + }); + ipcMain.handle(DESKTOP_START_NEW_MARKDOWN, async (event) => { + if (!validateSender(event)) { + throw new Error("拒绝未授权的桌面文件请求"); + } + currentMarkdownPath = undefined; + documentRoot = undefined; + pendingOpenedMarkdown = undefined; + }); + ipcMain.handle( + DESKTOP_SAVE_MARKDOWN, + async ( + event, + unsafeFileName: unknown, + unsafeMarkdown: unknown + ) => { + if (!validateSender(event)) { + throw new Error("拒绝未授权的桌面文件请求"); + } + if ( + typeof unsafeFileName !== "string" || + unsafeFileName.length === 0 || + unsafeFileName.length > 500 || + typeof unsafeMarkdown !== "string" || + unsafeMarkdown.length > MAXIMUM_MARKDOWN_LENGTH + ) { + throw new Error("Markdown 保存参数无效"); + } + + let targetPath = currentMarkdownPath; + if (!targetPath) { + const suggestedName = /\.(?:md|markdown)$/iu.test( + path.basename(unsafeFileName) + ) + ? path.basename(unsafeFileName) + : `${path.basename(unsafeFileName)}.md`; + const selection = await dialog.showSaveDialog(window, { + title: "保存 Markdown", + defaultPath: path.join( + app.getPath("documents"), + suggestedName + ), + filters: [ + { name: "Markdown 文件", extensions: ["md", "markdown"] } + ] + }); + if (selection.canceled || !selection.filePath) { + return undefined; + } + targetPath = /\.(?:md|markdown)$/iu.test(selection.filePath) + ? selection.filePath + : `${selection.filePath}.md`; + } + + await writeFile(targetPath, unsafeMarkdown, "utf8"); + currentMarkdownPath = targetPath; + documentRoot = path.dirname(targetPath); + return { fileName: path.basename(targetPath) }; + } + ); + ipcMain.handle(DESKTOP_OPEN_THEME_DIRECTORY, async (event) => { + if (!validateSender(event)) { + throw new Error("拒绝未授权的桌面主题请求"); + } + const themeDirectory = getDesktopThemeDirectory(); + await mkdir(themeDirectory, { recursive: true }); + const errorMessage = await shell.openPath(themeDirectory); + if (errorMessage) { + throw new Error(`无法打开自定义主题目录:${errorMessage}`); + } + return themeDirectory; + }); + ipcMain.handle(DESKTOP_REFRESH_THEMES, async (event) => { + if (!validateSender(event)) { + throw new Error("拒绝未授权的桌面主题请求"); + } + applicationService.invalidateThemes(); }); ipcMain.handle( DESKTOP_RENDER_MARKDOWN, @@ -343,9 +586,23 @@ async function createApplication( } ); - window.once("ready-to-show", () => window.show()); + window.once("ready-to-show", () => { + if (savedWindowState?.maximized) { + window.maximize(); + } + window.show(); + }); + window.on("close", () => { + void flushWindowState(); + }); window.on("closed", () => { ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN); + ipcMain.removeHandler(DESKTOP_CONSUME_PENDING_MARKDOWN); + ipcMain.removeHandler(DESKTOP_DISCARD_PENDING_MARKDOWN); + ipcMain.removeHandler(DESKTOP_START_NEW_MARKDOWN); + ipcMain.removeHandler(DESKTOP_SAVE_MARKDOWN); + ipcMain.removeHandler(DESKTOP_OPEN_THEME_DIRECTORY); + ipcMain.removeHandler(DESKTOP_REFRESH_THEMES); ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN); ipcMain.removeHandler(DESKTOP_LIST_THEMES); ipcMain.removeHandler(DESKTOP_GET_THEME_CSS); @@ -354,23 +611,71 @@ async function createApplication( void pdfGenerator.close(); }); await window.loadURL(applicationUrl); + return { + window, + openMarkdownFile: queueOpenedMarkdown, + flushWindowState + }; } -app - .whenReady() - .then(async () => { - Menu.setApplicationMenu(null); - if (process.platform === "win32") { - app.setAppUserModelId("com.md-to-pdf.desktop"); +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +let windowStateReadyToQuit = false; + +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on("second-instance", (_event, arguments_, workingDirectory) => { + const filePath = findMarkdownFileArgument( + arguments_, + workingDirectory + ); + if (filePath) { + dispatchExternalMarkdown(filePath); + } else { + focusApplicationWindow(); } - const applicationService = createDesktopApplicationService(); - await registerApplicationProtocol(applicationService); - await createApplication(applicationService); - }) - .catch((error: unknown) => { - console.error("桌面应用启动失败", error); - app.quit(); }); + app.on("open-file", (event, filePath) => { + event.preventDefault(); + if (isMarkdownFilePath(filePath)) { + dispatchExternalMarkdown(filePath); + } + }); + app.on("before-quit", (event) => { + if (windowStateReadyToQuit || !applicationController) { + return; + } + event.preventDefault(); + void applicationController.flushWindowState().finally(() => { + windowStateReadyToQuit = true; + app.quit(); + }); + }); + app + .whenReady() + .then(async () => { + Menu.setApplicationMenu(null); + if (process.platform === "win32") { + app.setAppUserModelId("com.md-to-pdf.desktop"); + } + await mkdir(getDesktopThemeDirectory(), { recursive: true }); + const applicationService = createDesktopApplicationService(); + await registerApplicationProtocol(applicationService); + applicationController = + await createApplication(applicationService); + const startupMarkdownPath = pendingExternalMarkdownPath; + pendingExternalMarkdownPath = undefined; + if (startupMarkdownPath) { + await applicationController.openMarkdownFile( + startupMarkdownPath + ); + } + }) + .catch((error: unknown) => { + console.error("桌面应用启动失败", error); + app.quit(); + }); +} app.on("window-all-closed", () => { if (process.platform !== "darwin") { diff --git a/apps/desktop/src/markdown-file.ts b/apps/desktop/src/markdown-file.ts new file mode 100644 index 0000000..cdd7291 --- /dev/null +++ b/apps/desktop/src/markdown-file.ts @@ -0,0 +1,60 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +const markdownExtensions = new Set([".md", ".markdown"]); + +export interface OpenedMarkdownDocument { + markdown: string; + fileName: string; +} + +export function isMarkdownFilePath(filePath: string) { + return markdownExtensions.has(path.extname(filePath).toLowerCase()); +} + +export function findMarkdownFileArgument( + arguments_: string[], + workingDirectory: string +) { + for (const argument of arguments_) { + if (!argument || argument.startsWith("-")) { + continue; + } + const candidate = path.isAbsolute(argument) + ? path.normalize(argument) + : path.resolve(workingDirectory, argument); + if (isMarkdownFilePath(candidate)) { + return candidate; + } + } + return undefined; +} + +export async function readMarkdownDocument( + filePath: string, + maximumLength: number +): Promise { + 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) + }; +} diff --git a/apps/desktop/src/window-state.ts b/apps/desktop/src/window-state.ts new file mode 100644 index 0000000..659733c --- /dev/null +++ b/apps/desktop/src/window-state.ts @@ -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; + 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; + 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; + } +} diff --git a/apps/desktop/tests/markdown-file.test.ts b/apps/desktop/tests/markdown-file.test.ts new file mode 100644 index 0000000..af4da37 --- /dev/null +++ b/apps/desktop/tests/markdown-file.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + findMarkdownFileArgument, + isMarkdownFilePath, + readMarkdownDocument +} from "../src/markdown-file.js"; + +describe("桌面 Markdown 文件参数", () => { + it("识别两种 Markdown 扩展名且忽略大小写", () => { + expect(isMarkdownFilePath("C:\\docs\\周报.md")).toBe(true); + expect(isMarkdownFilePath("C:\\docs\\说明.MARKDOWN")).toBe(true); + expect(isMarkdownFilePath("C:\\docs\\说明.txt")).toBe(false); + }); + + it("从启动参数中提取绝对 Markdown 路径", () => { + expect( + findMarkdownFileArgument( + [ + "C:\\Program Files\\md-to-pdf\\md-to-pdf.exe", + "--original-process-start-time=1", + "D:\\文档\\项目周报.md" + ], + "C:\\Windows" + ) + ).toBe(path.normalize("D:\\文档\\项目周报.md")); + }); + + it("根据第二实例的工作目录解析相对路径", () => { + expect( + findMarkdownFileArgument( + ["md-to-pdf.exe", "docs\\readme.markdown"], + "D:\\项目" + ) + ).toBe(path.resolve("D:\\项目", "docs\\readme.markdown")); + }); + + it("忽略非 Markdown 文件及 Chromium 参数", () => { + expect( + findMarkdownFileArgument( + ["md-to-pdf.exe", "--inspect", "notes.txt"], + "C:\\项目" + ) + ).toBeUndefined(); + }); + + it("统一读取 Markdown 并限制解码后的字符数", async () => { + const directory = await mkdtemp( + path.join(os.tmpdir(), "md-to-pdf-markdown-") + ); + const filePath = path.join(directory, "示例.md"); + try { + await writeFile(filePath, "# 示例", "utf8"); + await expect( + readMarkdownDocument(filePath, 20) + ).resolves.toEqual({ + markdown: "# 示例", + fileName: "示例.md" + }); + await expect( + readMarkdownDocument(filePath, 2) + ).rejects.toThrow("Markdown 文件过大"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("拒绝非 Markdown 扩展名", async () => { + await expect( + readMarkdownDocument("C:\\docs\\说明.txt", 100) + ).rejects.toThrow("只允许打开 .md 或 .markdown 文件"); + }); +}); diff --git a/apps/desktop/tests/window-state.test.ts b/apps/desktop/tests/window-state.test.ts new file mode 100644 index 0000000..343d80b --- /dev/null +++ b/apps/desktop/tests/window-state.test.ts @@ -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 }); + } + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a55c674..e70802e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -49,12 +49,15 @@ import { } from "./preview-zoom"; import { useMarkdownRender } from "./use-markdown-render"; import { - type MarkdownImageResource -} from "./application-backend"; + createMarkdownFileName, + downloadMarkdown, + ensureMarkdownFileName +} from "./document-file"; import { type ThemeSummary, useThemeResources } from "./use-theme-resources"; +import { sampleMarkdown } from "./sample-markdown"; const PrecisePdfPreview = lazy(async () => { const module = await import("./PrecisePdfPreview"); @@ -62,336 +65,20 @@ const PrecisePdfPreview = lazy(async () => { }); type PreviewMode = "quick" | "precise"; -const imageFilePattern = /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu; - -function bytesToBase64(bytes: Uint8Array) { - const parts: string[] = []; - const chunkSize = 32_768; - for (let offset = 0; offset < bytes.length; offset += chunkSize) { - parts.push( - String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) - ); - } - return window.btoa(parts.join("")); -} - -async function readImageResources(files: FileList) { - const images = Array.from(files).filter((file) => - imageFilePattern.test(file.name) - ); - return Promise.all( - images.map(async (file): Promise => ({ - path: file.webkitRelativePath || file.name, - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())) - })) - ); -} - -const sampleMarkdown = `--- -title: Markdown PDF 示例 -author: 内网文档团队 -keywords: - - Markdown - - PDF ---- - -# Markdown PDF 示例 - -这是一份使用 **Typora 风格主题** 渲染的预览文档。 - -## 常用内容 - -- [x] Markdown 实时预览 -- [x] 表格与任务列表 -- [x] 数学公式、Mermaid 与 ECharts -- [ ] Chromium PDF 导出 - -| 功能 | 当前状态 | -| --- | --- | -| HTML 预览 | 已实现 | -| PDF 下载 | 下一阶段 | - -行内公式:$E = mc^2$ - -## 网络图片 - -### 中等尺寸 - -![NASA 地球中等尺寸示例](https://images-assets.nasa.gov/image/PIA18033/PIA18033~medium.jpg) - -### 超高尺寸 - -下图用于验证图片高度超过单页内容区时,会等比例缩放并作为完整块放置, -不会跨页切割。 - -![超高网络图片分页示例](https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&q=80) - -\`\`\`mermaid -flowchart LR - A["Markdown"] --> B["统一 HTML"] - B --> C["主题 CSS"] - C --> D["网页预览"] - C --> E["PDF 导出"] -\`\`\` - -## ECharts 图表 - -### 基础柱状图 - -\`\`\`echarts -caption: 各模块文档数量 -height: 58mm -option: - xAxis: - type: category - data: [研发, 产品, 设计, 运营] - yAxis: - type: value - series: - - type: bar - data: [28, 18, 15, 22] -\`\`\` - -### Tree 树图 - -\`\`\`echarts -caption: 产品能力树 -height: 58mm -option: - series: - - type: tree - orient: LR - label: - position: left - data: - - name: 文档平台 - children: - - name: Markdown - children: - - name: Mermaid - - name: ECharts - - name: PDF - children: - - name: 快速预览 - - name: 精确预览 -\`\`\` - -### Treemap 矩形树图 - -\`\`\`echarts -caption: 模块工作量占比 -height: 58mm -option: - series: - - type: treemap - roam: false - nodeClick: false - breadcrumb: - show: false - label: - show: true - formatter: "{b}" - color: "#1f2937" - fontWeight: bold - backgroundColor: "rgba(255, 255, 255, 0.88)" - borderRadius: 3 - padding: [3, 6] - itemStyle: - borderColor: "#ffffff" - borderWidth: 2 - gapWidth: 2 - levels: - - itemStyle: - borderWidth: 0 - gapWidth: 2 - - upperLabel: - show: true - height: 28 - color: "#1f2937" - fontWeight: bold - backgroundColor: "rgba(255, 255, 255, 0.88)" - padding: [3, 6] - itemStyle: - borderColor: "#ffffff" - borderWidth: 2 - gapWidth: 2 - - label: - show: true - formatter: "{b}" - color: "#1f2937" - fontWeight: bold - backgroundColor: "rgba(255, 255, 255, 0.88)" - borderRadius: 3 - padding: [3, 6] - itemStyle: - borderColor: "#ffffff" - borderWidth: 2 - gapWidth: 2 - data: - - name: 渲染 - children: - - { name: Markdown, value: 32 } - - { name: ECharts, value: 24 } - - { name: Mermaid, value: 18 } - - name: 导出 - children: - - { name: PDF, value: 28 } - - { name: 主题, value: 12 } -\`\`\` - -### Sunburst 旭日图 - -\`\`\`echarts -caption: 文档内容层级 -height: 58mm -option: - series: - - type: sunburst - radius: [15%, 90%] - data: - - name: 技术文档 - children: - - { name: 架构, value: 18 } - - { name: 接口, value: 24 } - - name: 项目文档 - children: - - { name: 周报, value: 16 } - - { name: 计划, value: 12 } -\`\`\` - -### Graph 关系图 - -\`\`\`echarts -caption: 渲染链路关系 -height: 58mm -option: - series: - - type: graph - layout: circular - roam: false - label: - show: true - data: - - { name: Markdown, symbolSize: 42 } - - { name: HTML, symbolSize: 38 } - - { name: 预览, symbolSize: 34 } - - { name: PDF, symbolSize: 34 } - links: - - { source: Markdown, target: HTML } - - { source: HTML, target: 预览 } - - { source: HTML, target: PDF } -\`\`\` - -### Sankey 桑基图 - -\`\`\`echarts -caption: 文档流转量 -height: 58mm -option: - series: - - type: sankey - data: - - { name: Markdown } - - { name: HTML } - - { name: 快速预览 } - - { name: 精确预览 } - - { name: PDF } - links: - - { source: Markdown, target: HTML, value: 100 } - - { source: HTML, target: 快速预览, value: 55 } - - { source: HTML, target: 精确预览, value: 25 } - - { source: HTML, target: PDF, value: 20 } -\`\`\` - -### Chord 和弦图 - -\`\`\`echarts -caption: 团队协作关系 -height: 58mm -option: - series: - - type: chord - label: - show: true - data: - - { name: 研发 } - - { name: 产品 } - - { name: 设计 } - - { name: 运营 } - links: - - { source: 研发, target: 产品, value: 12 } - - { source: 产品, target: 设计, value: 8 } - - { source: 设计, target: 运营, value: 6 } - - { source: 运营, target: 研发, value: 4 } -\`\`\` - -### Funnel 漏斗图 - -\`\`\`echarts -caption: 文档发布转化 -height: 58mm -option: - series: - - type: funnel - sort: descending - label: - formatter: "{b}: {c}" - data: - - { name: 草稿, value: 100 } - - { name: 评审, value: 76 } - - { name: 定稿, value: 58 } - - { name: 发布, value: 45 } -\`\`\` - -### Gauge 仪表盘 - -\`\`\`echarts -caption: 发布完成率 -height: 58mm -option: - series: - - type: gauge - min: 0 - max: 100 - progress: - show: true - detail: - formatter: "{value}%" - data: - - { name: 完成率, value: 78 } -\`\`\` - -### PictorialBar 象形柱图 - -\`\`\`echarts -caption: 各渠道文档数量 -height: 58mm -option: - xAxis: - type: category - data: [知识库, 周报, 手册, 方案] - yAxis: - type: value - series: - - type: pictorialBar - symbol: "path://M0,10 L5,0 L10,10 Z" - symbolRepeat: true - symbolSize: [14, 10] - data: [12, 20, 16, 24] -\`\`\` - -> 预览和 PDF 将共用同一份 HTML 与 CSS。 -`; +type DocumentKind = "sample" | "new" | "file"; +const noImageResources: never[] = []; export function App() { const [markdown, setMarkdown] = useState(sampleMarkdown); const [fileName, setFileName] = useState("示例文档.md"); - const [imageResources, setImageResources] = useState< - MarkdownImageResource[] - >([]); + const [documentKind, setDocumentKind] = + useState("sample"); + const [savedMarkdown, setSavedMarkdown] = useState(sampleMarkdown); const [exportConfig, setExportConfig] = useState(loadExportConfig); const [settingsOpen, setSettingsOpen] = useState(false); + const [moreMenuOpen, setMoreMenuOpen] = useState(false); + const [themeRefreshKey, setThemeRefreshKey] = useState(0); const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false); const [status, setStatus] = useState("正在准备预览…"); const [appError, setAppError] = useState(""); @@ -412,6 +99,8 @@ export function App() { total: 0 }); const editorRef = useRef(null); + const markdownFileInputRef = useRef(null); + const moreMenuRef = useRef(null); const previewScrollRef = useRef(null); const previewFrameRef = useRef(null); const previewRequestIdRef = useRef(0); @@ -421,9 +110,93 @@ export function App() { const scrollSyncOriginRef = useRef< "editor" | "preview" | undefined >(undefined); + const documentDirty = markdown !== savedMarkdown; + const documentDirtyRef = useRef(documentDirty); + const themeRefreshTargetRef = useRef(undefined); + documentDirtyRef.current = documentDirty; + + useEffect(() => { + const bridge = window.mdToPdfDesktop; + if (!bridge) { + return; + } + let active = true; + const consumeOpenedMarkdown = async () => { + if ( + documentDirtyRef.current && + !window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?") + ) { + try { + await bridge.discardPendingMarkdown(); + } catch (reason) { + if (active) { + setAppError( + reason instanceof Error + ? reason.message + : "无法丢弃待打开的 Markdown 文件" + ); + } + } + return; + } + try { + const opened = await bridge.consumePendingMarkdown(); + if (!active || !opened) { + return; + } + setAppError(""); + setMarkdown(opened.markdown); + setFileName(opened.fileName); + setDocumentKind("file"); + setSavedMarkdown(opened.markdown); + setStatus("Markdown 与同目录图片素材已载入"); + } catch (reason) { + if (active) { + setAppError( + reason instanceof Error + ? reason.message + : "无法打开 Markdown 文件" + ); + } + } + }; + const removeListener = bridge.onMarkdownOpened(() => { + void consumeOpenedMarkdown(); + }); + void consumeOpenedMarkdown(); + return () => { + active = false; + removeListener(); + }; + }, []); + + useEffect(() => { + if (!moreMenuOpen) { + return; + } + const handlePointerDown = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !moreMenuRef.current?.contains(event.target) + ) { + setMoreMenuOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setMoreMenuOpen(false); + } + }; + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [moreMenuOpen]); const handleThemesLoaded = useCallback( - (availableThemes: ThemeSummary[]) => { + (availableThemes: ThemeSummary[], loadedRefreshKey: number) => { setExportConfig((currentConfig) => { if ( availableThemes.some( @@ -440,6 +213,10 @@ export function App() { ? { ...currentConfig, themeId: preferredTheme.id } : currentConfig; }); + if (themeRefreshTargetRef.current === loadedRefreshKey) { + themeRefreshTargetRef.current = undefined; + setStatus("主题清单已刷新"); + } }, [] ); @@ -450,7 +227,7 @@ export function App() { const { error: renderError, result } = useMarkdownRender( markdown, "zh-CN", - imageResources, + noImageResources, { onRenderStart: handleRenderStart, onStatusChange: setStatus @@ -460,7 +237,11 @@ export function App() { error: themeError, themeCss, themes - } = useThemeResources(exportConfig.themeId, handleThemesLoaded); + } = useThemeResources( + exportConfig.themeId, + handleThemesLoaded, + themeRefreshKey + ); const error = renderError || themeError || @@ -471,6 +252,13 @@ export function App() { pdfError; const themeId = exportConfig.themeId; const selectedTheme = themes.find((theme) => theme.id === themeId); + const effectiveFileName = + documentKind === "new" || !fileName + ? createMarkdownFileName(markdown) + : fileName; + const documentDisplayName = + documentKind === "new" ? "新建文档" : fileName; + const showUnsavedState = documentKind === "new" || documentDirty; const paperDimensions = getPaperDimensionsMm( exportConfig.paper.format, exportConfig.paper.orientation @@ -501,22 +289,22 @@ export function App() { result && themeCss ? createPagedDocumentPayload({ document: result, - fileName, + fileName: effectiveFileName, themeCss, exportConfig }) : undefined, - [exportConfig, fileName, result, themeCss] + [effectiveFileName, exportConfig, result, themeCss] ); const pdfRequest = useMemo( () => ({ markdown, - fileName, + fileName: effectiveFileName, language: "zh-CN", - resources: imageResources, + resources: noImageResources, exportConfig }), - [exportConfig, fileName, imageResources, markdown] + [effectiveFileName, exportConfig, markdown] ); const pdfCacheKey = useMemo( () => createPdfExportCacheKey(pdfRequest), @@ -848,6 +636,24 @@ export function App() { window.requestAnimationFrame(updatePrecisePreviewCurrentPage); }, []); + function confirmDiscardChanges() { + return ( + !documentDirty || + window.confirm("当前文档有未保存修改,是否放弃这些修改?") + ); + } + + function handleOpenMarkdown() { + if (!confirmDiscardChanges()) { + return; + } + if (window.mdToPdfDesktop) { + void handleDesktopOpenMarkdown(); + return; + } + markdownFileInputRef.current?.click(); + } + async function handleFileChange(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) { @@ -856,10 +662,12 @@ export function App() { try { setAppError(""); - setMarkdown(await file.text()); + const content = await file.text(); + setMarkdown(content); setFileName(file.name); - setImageResources([]); - setStatus("Markdown 已载入;如含相对图片,请添加素材目录"); + setDocumentKind("file"); + setSavedMarkdown(content); + setStatus("Markdown 已载入;Web 端不读取本地相对图片"); } catch { setAppError("无法读取所选 Markdown 文件"); } finally { @@ -867,25 +675,6 @@ export function App() { } } - async function handleAssetDirectoryChange( - event: ChangeEvent - ) { - const files = event.target.files; - if (!files?.length) { - return; - } - try { - setAppError(""); - const resources = await readImageResources(files); - setImageResources(resources); - setStatus(`已载入 ${resources.length} 个图片素材`); - } catch { - setAppError("无法读取所选图片素材目录"); - } finally { - event.target.value = ""; - } - } - async function handleDesktopOpenMarkdown() { try { setAppError(""); @@ -895,7 +684,8 @@ export function App() { } setMarkdown(opened.markdown); setFileName(opened.fileName); - setImageResources([]); + setDocumentKind("file"); + setSavedMarkdown(opened.markdown); setStatus("Markdown 与同目录图片素材已载入"); } catch (reason) { setAppError( @@ -904,6 +694,91 @@ export function App() { } } + async function handleNewMarkdown() { + setMoreMenuOpen(false); + if (!confirmDiscardChanges()) { + return; + } + try { + setAppError(""); + await window.mdToPdfDesktop?.startNewMarkdown(); + setMarkdown(""); + setFileName(""); + setDocumentKind("new"); + setSavedMarkdown(""); + setStatus("新建文档尚未保存,可直接编辑或导出 PDF"); + window.requestAnimationFrame(() => editorRef.current?.focus()); + } catch (reason) { + setAppError( + reason instanceof Error ? reason.message : "无法新建 Markdown" + ); + } + } + + async function handleSaveMarkdown() { + setMoreMenuOpen(false); + const suggestedName = ensureMarkdownFileName(fileName, markdown); + try { + setAppError(""); + const bridge = window.mdToPdfDesktop; + let savedFileName = suggestedName; + if (bridge) { + const saved = await bridge.saveMarkdown(suggestedName, markdown); + if (!saved) { + setStatus("已取消 Markdown 保存"); + return; + } + savedFileName = saved.fileName; + } else { + downloadMarkdown(markdown, suggestedName); + } + setFileName(savedFileName); + setDocumentKind("file"); + setSavedMarkdown(markdown); + setStatus(`Markdown 已保存:${savedFileName}`); + } catch (reason) { + setAppError( + reason instanceof Error ? reason.message : "Markdown 保存失败" + ); + } + } + + async function handleOpenThemeDirectory() { + setMoreMenuOpen(false); + try { + setAppError(""); + const themeDirectory = + await window.mdToPdfDesktop?.openThemeDirectory(); + if (themeDirectory) { + setStatus(`自定义主题目录:${themeDirectory}`); + } + } catch (reason) { + setAppError( + reason instanceof Error + ? reason.message + : "无法打开自定义主题目录" + ); + } + } + + async function handleRefreshThemes() { + setMoreMenuOpen(false); + try { + setAppError(""); + await window.mdToPdfDesktop?.refreshThemes(); + setStatus("正在刷新主题清单…"); + setThemeRefreshKey((key) => { + const nextKey = key + 1; + themeRefreshTargetRef.current = nextKey; + return nextKey; + }); + } catch (reason) { + setAppError( + reason instanceof Error ? reason.message : "无法刷新主题清单" + ); + } + } + async function generatePdf(download: boolean) { if (exportingPdf) { return; @@ -993,7 +868,14 @@ export function App() {
源文件 - {fileName} +
+ + {documentDisplayName} + + {showUnsavedState ? ( + 未保存 + ) : null} +
- {window.mdToPdfDesktop ? ( + +
- ) : ( - <> - - - - )} + {moreMenuOpen ? ( + + ) : null} +
+ diff --git a/apps/web/src/diagram-page-fit.ts b/apps/web/src/diagram-page-fit.ts index f281904..5ed3fff 100644 --- a/apps/web/src/diagram-page-fit.ts +++ b/apps/web/src/diagram-page-fit.ts @@ -30,6 +30,58 @@ export interface SvgDiagramFitOptions { const PAGE_FIT_EPSILON_PX = 1; +function parsePixelValue(value: string) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function getPrecedingMediaTitleHeight( + element: HTMLElement +) { + const title = element.previousElementSibling; + if ( + !(title instanceof HTMLElement) || + !/^H[1-6]$/.test(title.tagName) + ) { + return 0; + } + + const style = getComputedStyle(title); + title.style.setProperty("break-after", "avoid-page", "important"); + title.style.setProperty( + "page-break-after", + "avoid", + "important" + ); + return ( + title.getBoundingClientRect().height + + parsePixelValue(style.marginTop) + + parsePixelValue(style.marginBottom) + ); +} + +export function ensurePageBreakAfter(element: HTMLElement) { + const existingMarker = element.nextElementSibling; + if ( + existingMarker instanceof HTMLElement && + existingMarker.dataset.mediaPageBreak === "true" + ) { + return existingMarker; + } + const marker = element.ownerDocument.createElement("div"); + marker.dataset.mediaPageBreak = "true"; + marker.setAttribute("aria-hidden", "true"); + marker.style.setProperty("height", "0", "important"); + marker.style.setProperty("break-before", "page", "important"); + marker.style.setProperty( + "page-break-before", + "always", + "important" + ); + element.after(marker); + return marker; +} + export function parseSvgViewBox( value: string | null ): DiagramDimensions | undefined { @@ -144,9 +196,17 @@ export function fitSvgDiagramsToPage( continue; } - const availablePageContent = + const resolvedPageContent = options.resolvePageContent?.(container, pageContent) ?? pageContent; + const availablePageContent = { + width: resolvedPageContent.width, + height: Math.max( + 0, + resolvedPageContent.height - + getPrecedingMediaTitleHeight(container) + ) + }; const fit = calculateDiagramPageFit( intrinsic, availablePageContent @@ -157,6 +217,13 @@ export function fitSvgDiagramsToPage( container.dataset.pageHeightFitted = "true"; container.style.setProperty("margin-block", "0", "important"); + container.style.setProperty("break-after", "page", "important"); + container.style.setProperty( + "page-break-after", + "always", + "important" + ); + ensurePageBreakAfter(container); if (!svg.getAttribute("viewBox")) { svg.setAttribute( "viewBox", diff --git a/apps/web/src/document-file.ts b/apps/web/src/document-file.ts new file mode 100644 index 0000000..11a75e6 --- /dev/null +++ b/apps/web/src/document-file.ts @@ -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); +} diff --git a/apps/web/src/document-image-fit.ts b/apps/web/src/document-image-fit.ts index 329d5c3..0a8630b 100644 --- a/apps/web/src/document-image-fit.ts +++ b/apps/web/src/document-image-fit.ts @@ -1,7 +1,9 @@ import type { ExportConfig } from "@md-to-pdf/core"; import { calculateDiagramPageFit, - getPageContentDimensions + ensurePageBreakAfter, + getPageContentDimensions, + getPrecedingMediaTitleHeight } from "./diagram-page-fit"; function getImageBlock(image: HTMLImageElement) { @@ -23,11 +25,30 @@ function getImageBlock(image: HTMLImageElement) { return undefined; } +export function prepareDocumentImageBlocks(root: ParentNode) { + const images = Array.from( + root.querySelectorAll("img.md-document-image") + ); + let prepared = 0; + + for (const image of images) { + if (getImageBlock(image)) { + prepared += 1; + } + } + + return prepared; +} + export function fitDocumentImagesToPage( root: ParentNode, - config: ExportConfig + config: ExportConfig, + options: { prepareBlocks?: boolean } = {} ) { const pageContent = getPageContentDimensions(config); + if (options.prepareBlocks !== false) { + prepareDocumentImageBlocks(root); + } const images = Array.from( root.querySelectorAll("img.md-document-image") ); @@ -42,11 +63,17 @@ export function fitDocumentImagesToPage( const imageHeight = image.getBoundingClientRect().height; const blockHeight = block?.getBoundingClientRect().height ?? imageHeight; const nonImageHeight = Math.max(0, blockHeight - imageHeight); + const titleHeight = block + ? getPrecedingMediaTitleHeight(block) + : 0; const fit = calculateDiagramPageFit( { width, height }, { width: pageContent.width, - height: Math.max(0, pageContent.height - nonImageHeight) + height: Math.max( + 0, + pageContent.height - nonImageHeight - titleHeight + ) } ); if (!fit.scaled) { @@ -55,6 +82,13 @@ export function fitDocumentImagesToPage( if (block?.classList.contains("md-document-image-block")) { block.dataset.pageHeightFitted = "true"; block.style.setProperty("margin-block", "0", "important"); + block.style.setProperty("break-after", "page", "important"); + block.style.setProperty( + "page-break-after", + "always", + "important" + ); + ensurePageBreakAfter(block); } image.dataset.pageHeightFitted = "true"; image.style.setProperty("display", "block"); diff --git a/apps/web/src/media-page-backfill.ts b/apps/web/src/media-page-backfill.ts new file mode 100644 index 0000000..802ae5d --- /dev/null +++ b/apps/web/src/media-page-backfill.ts @@ -0,0 +1,397 @@ +export type MediaBackfillKind = "image" | "mermaid" | "echarts"; + +export interface MediaBackfillGeometry { + remainingHeight: number; + fixedHeight: number; + baselineVisualHeight: number; +} + +export interface MediaBackfillCandidate { + id: string; + scale: number; +} + +export interface MediaBackfillOptions { + minimumScale?: number; + safetyGapPx?: number; +} + +const MEDIA_BLOCK_SELECTOR = [ + ".md-document-image-block", + ".mermaid:not(.mermaid-error)", + ".md-echarts:not(.md-echarts-error)" +].join(", "); +const DEFAULT_MINIMUM_SCALE = 0.88; +const DEFAULT_SAFETY_GAP_PX = 2; +const SCALE_EPSILON = 0.001; + +function finitePositive(value: string | undefined) { + const parsed = Number.parseFloat(value ?? ""); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function getMediaKind(block: Element): MediaBackfillKind | undefined { + if (block.classList.contains("md-document-image-block")) { + return "image"; + } + if (block.classList.contains("mermaid")) { + return "mermaid"; + } + if (block.classList.contains("md-echarts")) { + return "echarts"; + } + return undefined; +} + +function getMediaVisual( + block: Element, + kind: MediaBackfillKind +): HTMLElement | SVGSVGElement | undefined { + if (kind === "image") { + return ( + block.querySelector("img.md-document-image") ?? + undefined + ); + } + if (kind === "echarts") { + return ( + block.querySelector(".md-echarts-host") ?? + undefined + ); + } + return ( + block.querySelector("svg") ?? + block.querySelector("img") ?? + undefined + ); +} + +function parsePixelValue(value: string) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getOuterBottom(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + return ( + rect.bottom + + parsePixelValue(getComputedStyle(element).marginBottom) + ); +} + +function getOuterTop(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + return ( + rect.top - + parsePixelValue(getComputedStyle(element).marginTop) + ); +} + +function getMediaGroupTop( + block: HTMLElement, + pageContentTop: number +) { + const title = block.previousElementSibling; + if ( + title instanceof HTMLElement && + /^H[1-6]$/.test(title.tagName) + ) { + return Math.max( + pageContentTop, + getOuterTop(title) + ); + } + return Math.max( + pageContentTop, + getOuterTop(block) + ); +} + +function getLastContentBottom(pageContent: HTMLElement) { + const contentRect = pageContent.getBoundingClientRect(); + const leafElements = Array.from( + pageContent.querySelectorAll("[data-ref]") + ).filter( + (element) => !element.querySelector("[data-ref]") + ); + let bottom = contentRect.top; + + for (const element of leafElements) { + const rect = element.getBoundingClientRect(); + if ( + rect.width <= 0 || + rect.height <= 0 || + rect.bottom <= contentRect.top || + rect.top >= contentRect.bottom + ) { + continue; + } + bottom = Math.max( + bottom, + Math.min(getOuterBottom(element), contentRect.bottom) + ); + } + return bottom; +} + +function hasForcedBreakBefore(block: HTMLElement) { + const title = block.previousElementSibling; + const elements = [ + block, + title instanceof HTMLElement && /^H[1-6]$/.test(title.tagName) + ? title + : undefined + ].filter((element): element is HTMLElement => Boolean(element)); + return elements.some((element) => { + const style = getComputedStyle(element); + return ( + style.breakBefore === "page" || + style.pageBreakBefore === "always" + ); + }); +} + +export function calculateMediaBackfillScale( + geometry: MediaBackfillGeometry, + options: MediaBackfillOptions = {} +) { + const minimumScale = + options.minimumScale ?? DEFAULT_MINIMUM_SCALE; + const safetyGapPx = + options.safetyGapPx ?? DEFAULT_SAFETY_GAP_PX; + if ( + !Number.isFinite(geometry.remainingHeight) || + !Number.isFinite(geometry.fixedHeight) || + !Number.isFinite(geometry.baselineVisualHeight) || + geometry.remainingHeight <= 0 || + geometry.fixedHeight < 0 || + geometry.baselineVisualHeight <= 0 || + !Number.isFinite(minimumScale) || + minimumScale <= 0 || + minimumScale >= 1 || + !Number.isFinite(safetyGapPx) || + safetyGapPx < 0 + ) { + return undefined; + } + + const availableVisualHeight = + geometry.remainingHeight - + geometry.fixedHeight - + safetyGapPx; + const scale = + availableVisualHeight / geometry.baselineVisualHeight; + if ( + scale < minimumScale || + scale >= 1 - SCALE_EPSILON + ) { + return undefined; + } + return scale; +} + +export function prepareMediaBackfillBlocks(root: ParentNode) { + const blocks = Array.from( + root.querySelectorAll(MEDIA_BLOCK_SELECTOR) + ); + let prepared = 0; + + for (const [index, block] of blocks.entries()) { + const kind = getMediaKind(block); + const visual = kind ? getMediaVisual(block, kind) : undefined; + const rect = visual?.getBoundingClientRect(); + if ( + !kind || + !visual || + !rect || + rect.width <= 0 || + rect.height <= 0 + ) { + continue; + } + + block.dataset.mediaBackfillId = `media-${index + 1}`; + block.dataset.mediaBackfillKind = kind; + block.dataset.mediaBaselineWidth = String(rect.width); + block.dataset.mediaBaselineHeight = String(rect.height); + prepared += 1; + } + + return prepared; +} + +export function getMediaBackfillIdsInDocumentOrder( + root: ParentNode +) { + return Array.from( + root.querySelectorAll( + "[data-media-backfill-id]" + ) + ).flatMap((block) => + block.dataset.mediaBackfillId + ? [block.dataset.mediaBackfillId] + : [] + ); +} + +export function findMediaBackfillCandidates( + root: ParentNode, + options: MediaBackfillOptions = {} +) { + const pages = Array.from( + root.querySelectorAll(".pagedjs_page") + ); + const candidates: MediaBackfillCandidate[] = []; + const candidateIds = new Set(); + + for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) { + const previousPage = pages[pageIndex - 1]; + const currentPage = pages[pageIndex]; + const previousContent = + previousPage?.querySelector( + ".pagedjs_page_content" + ); + const currentContent = + currentPage?.querySelector( + ".pagedjs_page_content" + ); + if (!previousContent || !currentContent) { + continue; + } + + const mediaBlocks = Array.from( + currentContent.querySelectorAll( + "[data-media-backfill-id]" + ) + ).sort( + (left, right) => + left.getBoundingClientRect().top - + right.getBoundingClientRect().top + ); + const block = mediaBlocks[0]; + if (!block) { + continue; + } + + const kind = getMediaKind(block); + const visual = kind ? getMediaVisual(block, kind) : undefined; + const id = block.dataset.mediaBackfillId; + const baselineVisualHeight = finitePositive( + block.dataset.mediaBaselineHeight + ); + if (!kind || !visual || !id || !baselineVisualHeight) { + continue; + } + + if (hasForcedBreakBefore(block)) { + continue; + } + const currentContentRect = currentContent.getBoundingClientRect(); + const visualHeight = visual.getBoundingClientRect().height; + const mediaGroupTop = getMediaGroupTop( + block, + currentContentRect.top + ); + const fixedHeight = Math.max( + 0, + getOuterBottom(block) - + mediaGroupTop - + visualHeight + ); + + const previousContentRect = + previousContent.getBoundingClientRect(); + const previousFlowBottom = + getLastContentBottom(previousContent); + const remainingHeight = Math.max( + 0, + previousContentRect.bottom - previousFlowBottom + ); + const scale = calculateMediaBackfillScale( + { + remainingHeight, + fixedHeight, + baselineVisualHeight + }, + options + ); + if (scale === undefined || candidateIds.has(id)) { + continue; + } + candidateIds.add(id); + candidates.push({ id, scale }); + } + + return candidates; +} + +function setVisualSize( + visual: HTMLElement | SVGSVGElement, + width: number, + height: number +) { + visual.style.setProperty("display", "block"); + visual.style.setProperty("width", `${width}px`, "important"); + visual.style.setProperty("height", `${height}px`, "important"); + visual.style.setProperty("max-width", "100%", "important"); + visual.style.setProperty("max-height", `${height}px`, "important"); + visual.style.setProperty("margin-inline", "auto", "important"); +} + +export function applyMediaBackfillCandidates( + source: ParentNode, + candidates: MediaBackfillCandidate[] +) { + let applied = 0; + for (const candidate of candidates) { + const block = Array.from( + source.querySelectorAll( + "[data-media-backfill-id]" + ) + ).find( + (element) => + element.dataset.mediaBackfillId === candidate.id + ); + const kind = block ? getMediaKind(block) : undefined; + const baselineWidth = finitePositive( + block?.dataset.mediaBaselineWidth + ); + const baselineHeight = finitePositive( + block?.dataset.mediaBaselineHeight + ); + const visual = + block && kind ? getMediaVisual(block, kind) : undefined; + if ( + !block || + !kind || + !visual || + !baselineWidth || + !baselineHeight || + !Number.isFinite(candidate.scale) || + candidate.scale <= 0 || + candidate.scale >= 1 + ) { + continue; + } + + const width = baselineWidth * candidate.scale; + const height = baselineHeight * candidate.scale; + block.dataset.mediaBackfilled = "true"; + block.dataset.mediaBackfillScale = String(candidate.scale); + setVisualSize(visual, width, height); + + if (kind === "image") { + visual.style.setProperty("object-fit", "contain"); + } else if (kind === "echarts") { + const svg = block.querySelector( + ".md-echarts-host svg" + ); + if (svg) { + setVisualSize(svg, width, height); + } + visual.style.removeProperty("aspect-ratio"); + } + applied += 1; + } + return applied; +} diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts index 50c7895..e73096b 100644 --- a/apps/web/src/paged-document-runtime.ts +++ b/apps/web/src/paged-document-runtime.ts @@ -7,7 +7,16 @@ import type { } from "@md-to-pdf/core"; import { Previewer } from "pagedjs"; import { fitOversizedEChartsToPage } from "./echarts-page-fit"; -import { fitDocumentImagesToPage } from "./document-image-fit"; +import { + fitDocumentImagesToPage, + prepareDocumentImageBlocks +} from "./document-image-fit"; +import { + applyMediaBackfillCandidates, + findMediaBackfillCandidates, + getMediaBackfillIdsInDocumentOrder, + prepareMediaBackfillBlocks +} from "./media-page-backfill"; import { createMermaidSiteConfig } from "./mermaid-config"; import { fitOversizedMermaidToPage, @@ -232,6 +241,31 @@ export class PagedDocumentRuntime { ); } + private async replaceEChartsSvgWithImages(container: ParentNode) { + const { freezeEChartsSvg } = await this.loadECharts(); + const figures = Array.from( + container.querySelectorAll( + ".md-echarts:not(.md-echarts-error)" + ) + ); + for (const figure of figures) { + const host = figure.querySelector( + ".md-echarts-host" + ); + const svg = host?.querySelector("svg"); + if (!host || !svg) { + continue; + } + const label = + host.getAttribute("aria-label") || "ECharts 图表"; + host.replaceChildren( + freezeEChartsSvg(host, svg, "svg-image", label) + ); + figure.dataset.echartsRendered = "svg-image"; + } + await waitForImages(container); + } + private async renderMermaid( container: DocumentFragment, payload: PagedPreviewPayload @@ -346,9 +380,12 @@ export class PagedDocumentRuntime { ); echartsMs = performance.now() - echartsStartedAt; await waitForImages(measurement.host); + prepareDocumentImageBlocks(measurement.host); + prepareMediaBackfillBlocks(measurement.host); fitDocumentImagesToPage( measurement.host, - payload.exportConfig + payload.exportConfig, + { prepareBlocks: false } ); const echartsFitStartedAt = performance.now(); @@ -356,6 +393,7 @@ export class PagedDocumentRuntime { measurement.host, payload.exportConfig ); + await this.replaceEChartsSvgWithImages(measurement.host); echartsFitMs = performance.now() - echartsFitStartedAt; @@ -388,29 +426,74 @@ export class PagedDocumentRuntime { const resourceWaitMs = performance.now() - resourceWaitStartedAt; const paginationStartedAt = performance.now(); - const previewer = new Previewer(); + const repaginationSource = content.cloneNode( + true + ) as DocumentFragment; + const stylesheets = [ + 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" + ) + ]; + let previewer = new Previewer(); this.activePreviewer = previewer; - const flow = await previewer.preview( + let 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" - ) - ], + stylesheets, this.root ); + const mediaIds = getMediaBackfillIdsInDocumentOrder( + repaginationSource + ); + let mediaCandidates = new Map( + findMediaBackfillCandidates(this.root).map((candidate) => [ + candidate.id, + candidate + ]) + ); + for (const mediaId of mediaIds) { + if (!shouldContinue()) { + break; + } + const candidate = mediaCandidates.get(mediaId); + if ( + !candidate || + applyMediaBackfillCandidates(repaginationSource, [ + candidate + ]) === 0 + ) { + continue; + } + + previewer.chunker.destroy(); + previewer.polisher.destroy(); + previewer = new Previewer(); + this.activePreviewer = previewer; + flow = await previewer.preview( + repaginationSource.cloneNode(true) as DocumentFragment, + stylesheets, + this.root + ); + mediaCandidates = new Map( + findMediaBackfillCandidates(this.root).map( + (nextCandidate) => [ + nextCandidate.id, + nextCandidate + ] + ) + ); + } const paginationMs = performance.now() - paginationStartedAt; if (!shouldContinue()) { diff --git a/apps/web/src/sample-markdown.ts b/apps/web/src/sample-markdown.ts new file mode 100644 index 0000000..aa908be --- /dev/null +++ b/apps/web/src/sample-markdown.ts @@ -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} +`; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a9cac79..7fef0ce 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -24,8 +24,7 @@ textarea { font: inherit; } -button, -.file-button { +button { min-height: 40px; padding: 0 16px; border: 1px solid #b8c2bd; @@ -102,13 +101,31 @@ button:disabled { border-left: 1px solid #d4dad7; } -.document-summary > strong { +.document-name-row { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.document-name-row > strong { overflow: hidden; font-size: 0.82rem; text-overflow: ellipsis; white-space: nowrap; } +.unsaved-badge { + flex: none; + padding: 1px 6px; + border: 1px solid #d9bd86; + border-radius: 999px; + background: #fff7e7; + color: #8a5a13; + font-size: 0.62rem; + font-weight: 800; +} + .document-summary .render-status { max-width: 100%; margin-top: 3px; @@ -138,17 +155,37 @@ h1 { gap: 10px; } -.file-button { - display: inline-flex; - align-items: center; +.more-menu { + position: relative; } -.file-button input { +.more-menu-popover { position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); + z-index: 30; + top: calc(100% + 8px); + right: 0; + display: flex; + min-width: 220px; + padding: 6px; + border: 1px solid #c8d1cc; + border-radius: 10px; + background: #fff; + box-shadow: 0 12px 30px rgb(38 58 50 / 18%); + flex-direction: column; +} + +.more-menu-popover button { + width: 100%; + min-height: 36px; + padding: 0 11px; + border: 0; + background: transparent; + text-align: left; +} + +.more-menu-popover button:hover, +.more-menu-popover button:focus-visible { + background: #edf3f0; } .editor-layout { diff --git a/apps/web/src/use-theme-resources.ts b/apps/web/src/use-theme-resources.ts index 446af27..9141c31 100644 --- a/apps/web/src/use-theme-resources.ts +++ b/apps/web/src/use-theme-resources.ts @@ -9,7 +9,11 @@ export type { ThemeSummary } from "./application-backend"; export function useThemeResources( themeId: string, - onThemesLoaded: (themes: ThemeSummary[]) => void + onThemesLoaded: ( + themes: ThemeSummary[], + refreshKey: number + ) => void, + refreshKey = 0 ) { const [themes, setThemes] = useState([]); const [themeCss, setThemeCss] = useState(""); @@ -23,7 +27,7 @@ export function useThemeResources( .then(({ themes: availableThemes }) => { setThemes(availableThemes); setCatalogError(""); - onThemesLoaded(availableThemes); + onThemesLoaded(availableThemes, refreshKey); }) .catch((reason: unknown) => { if (!controller.signal.aborted) { @@ -34,7 +38,7 @@ export function useThemeResources( }); return () => controller.abort(); - }, [onThemesLoaded]); + }, [onThemesLoaded, refreshKey]); useEffect(() => { const controller = new AbortController(); @@ -52,7 +56,7 @@ export function useThemeResources( }); return () => controller.abort(); - }, [themeId]); + }, [refreshKey, themeId]); return { error: catalogError || cssError, diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 1da88e4..153b483 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -24,6 +24,22 @@ declare global { } | undefined >; + consumePendingMarkdown(): Promise< + | { + markdown: string; + fileName: string; + } + | undefined + >; + discardPendingMarkdown(): Promise; + onMarkdownOpened(listener: () => void): () => void; + startNewMarkdown(): Promise; + saveMarkdown( + fileName: string, + markdown: string + ): Promise<{ fileName: string } | undefined>; + openThemeDirectory(): Promise; + refreshThemes(): Promise; renderMarkdown(input: { markdown: string; language: string; diff --git a/apps/web/tests/app-version.test.ts b/apps/web/tests/app-version.test.ts index 52416d0..ae1cb1f 100644 --- a/apps/web/tests/app-version.test.ts +++ b/apps/web/tests/app-version.test.ts @@ -6,7 +6,7 @@ import { describe("应用版本", () => { it("从统一构建版本生成标题徽标", () => { - expect(APP_VERSION).toBe("0.4.4"); - expect(APP_VERSION_LABEL).toBe("v0.4.4"); + expect(APP_VERSION).toBe("0.4.5"); + expect(APP_VERSION_LABEL).toBe("v0.4.5"); }); }); diff --git a/apps/web/tests/document-file.test.ts b/apps/web/tests/document-file.test.ts new file mode 100644 index 0000000..c8b65b8 --- /dev/null +++ b/apps/web/tests/document-file.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + createMarkdownFileName, + deriveMarkdownBaseName, + ensureMarkdownFileName, + legalizeMarkdownBaseName +} from "../src/document-file"; + +describe("Markdown 默认文件名", () => { + it("优先使用 Front Matter 与代码围栏之外的一级标题", () => { + expect( + deriveMarkdownBaseName(`--- +title: 元数据标题 +--- +第一行正文 + +\`\`\`markdown +# 围栏标题 +\`\`\` + +# 2026 年研发周报:第 31 周`) + ).toBe("2026 年研发周报 第 31 周"); + }); + + it("没有一级标题时使用第一行非空正文", () => { + expect( + createMarkdownFileName( + "\n\n> **本周完成** [API 重构](https://example.com)\n后续内容" + ) + ).toBe("本周完成 API 重构.md"); + }); + + it("四字符围栏不会被较短的同类围栏提前关闭", () => { + expect( + deriveMarkdownBaseName(`\`\`\`\`markdown +# 围栏内标题 +\`\`\` +# 仍在围栏内 +\`\`\`\` +# 正文标题`) + ).toBe("正文标题"); + }); + + it("清理非法字符并规避 Windows 保留名称", () => { + expect(legalizeMarkdownBaseName('季度总结:<测试> / "版本"')).toBe( + "季度总结 测试 版本" + ); + expect(legalizeMarkdownBaseName("CON")).toBe("文档 CON"); + }); + + it("空文档使用回退文件名并限制名称长度", () => { + expect(createMarkdownFileName(" \n")).toBe("未命名文档.md"); + expect(legalizeMarkdownBaseName("文".repeat(150))).toHaveLength(100); + }); + + it("保留已有 Markdown 扩展名", () => { + expect(ensureMarkdownFileName("报告.markdown", "# 报告")).toBe( + "报告.markdown" + ); + expect(ensureMarkdownFileName("报告", "# 报告")).toBe("报告.md"); + }); +}); diff --git a/apps/web/tests/echarts-page-fit.test.ts b/apps/web/tests/echarts-page-fit.test.ts index 6255e6c..6cdddb9 100644 --- a/apps/web/tests/echarts-page-fit.test.ts +++ b/apps/web/tests/echarts-page-fit.test.ts @@ -8,6 +8,7 @@ describe("ECharts 单页高度适配", () => { it("将超高 SVG 和宿主同步缩放到单页内容区", () => { const root = document.createElement("div"); root.innerHTML = ` +

大型图表

@@ -15,22 +16,43 @@ describe("ECharts 单页高度适配", () => {
`; - fitOversizedEChartsToPage(root, defaultExportConfig); - const figure = root.querySelector(".md-echarts"); + const title = root.querySelector("h2"); const host = root.querySelector( ".md-echarts-host" ); const svg = root.querySelector("svg"); + if (title) { + title.getBoundingClientRect = () => ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 700, + bottom: 40, + width: 700, + height: 40, + toJSON: () => ({}) + }); + } + fitOversizedEChartsToPage(root, defaultExportConfig); + expect(figure?.dataset.pageHeightFitted).toBe("true"); + expect(title?.style.breakAfter).toBe("avoid-page"); + expect(figure?.style.breakAfter).toBe("page"); + expect(figure?.style.pageBreakAfter).toBe("always"); + expect(figure?.nextElementSibling).toHaveProperty( + "dataset.mediaPageBreak", + "true" + ); expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo( - 1000.5748, + 960.5748, 3 ); expect(host?.style.aspectRatio).toBe(""); expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000"); expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo( - 1000.5748, + 960.5748, 3 ); }); diff --git a/apps/web/tests/media-page-backfill.test.ts b/apps/web/tests/media-page-backfill.test.ts new file mode 100644 index 0000000..60613b2 --- /dev/null +++ b/apps/web/tests/media-page-backfill.test.ts @@ -0,0 +1,329 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { + applyMediaBackfillCandidates, + calculateMediaBackfillScale, + findMediaBackfillCandidates, + getMediaBackfillIdsInDocumentOrder, + prepareMediaBackfillBlocks +} from "../src/media-page-backfill"; +import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit"; + +function rect(width: number, height: number) { + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: height, + width, + height, + toJSON: () => ({}) + }; +} + +function positionedRect( + top: number, + width: number, + height: number +) { + return { + ...rect(width, height), + y: top, + top, + bottom: top + height + }; +} + +describe("媒体分页空白回填", () => { + it("全页适配时保留前置标题高度并避免标题孤行", () => { + const root = document.createElement("div"); + root.innerHTML = ` +

媒体标题

+
+ `; + const title = root.querySelector("h2")!; + const block = root.querySelector(".mermaid")!; + title.getBoundingClientRect = () => + positionedRect(100, 600, 40); + + expect(getPrecedingMediaTitleHeight(block)).toBe(40); + expect(title.style.getPropertyValue("break-after")).toBe( + "avoid-page" + ); + expect(title.style.getPropertyPriority("break-after")).toBe( + "important" + ); + }); + + it("按限宽后的基准高度计算额外缩放率", () => { + expect( + calculateMediaBackfillScale({ + remainingHeight: 420, + fixedHeight: 30, + baselineVisualHeight: 430 + }) + ).toBeCloseTo(388 / 430, 6); + }); + + it("缩小超过阈值或无需缩小时不回填", () => { + expect( + calculateMediaBackfillScale({ + remainingHeight: 300, + fixedHeight: 30, + baselineVisualHeight: 430 + }) + ).toBeUndefined(); + expect( + calculateMediaBackfillScale({ + remainingHeight: 500, + fixedHeight: 30, + baselineVisualHeight: 430 + }) + ).toBeUndefined(); + }); + + it("记录图片限宽后的实际基准尺寸", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+ +
标题
+
+ `; + const image = root.querySelector("img")!; + image.getBoundingClientRect = () => rect(640, 480); + + expect(prepareMediaBackfillBlocks(root)).toBe(1); + const figure = root.querySelector("figure")!; + expect(figure.dataset.mediaBackfillKind).toBe("image"); + expect(figure.dataset.mediaBaselineWidth).toBe("640"); + expect(figure.dataset.mediaBaselineHeight).toBe("480"); + }); + + it("保持媒体元素在源文档中的串行处理顺序", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+
+ `; + + expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([ + "media-2", + "media-1" + ]); + }); + + it("根据上一页剩余区域生成一次回填候选", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+
上一页内容
+
+
+
+
+

图片标题

+
+ +
+
+
+ `; + const pageContents = root.querySelectorAll( + ".pagedjs_page_content" + ); + const previousFlow = + pageContents[0]!.firstElementChild as HTMLElement; + const title = root.querySelector("h2")!; + const block = root.querySelector("figure")!; + const image = root.querySelector("img")!; + pageContents[0]!.getBoundingClientRect = () => + positionedRect(100, 700, 1_000); + previousFlow.getBoundingClientRect = () => + positionedRect(100, 700, 550); + pageContents[1]!.getBoundingClientRect = () => + positionedRect(100, 700, 1_000); + title.getBoundingClientRect = () => + positionedRect(100, 600, 40); + block.getBoundingClientRect = () => + positionedRect(150, 600, 330); + image.getBoundingClientRect = () => + positionedRect(150, 600, 300); + + expect(findMediaBackfillCandidates(root)).toEqual([ + { id: "media-1", scale: 0.92 } + ]); + }); + + it("显式分页的媒体块不会回填到上一页", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+
+
+
+

+ 图片标题 +

+
+ +
+
+
+ `; + const pageContents = root.querySelectorAll( + ".pagedjs_page_content" + ); + const title = root.querySelector("h2")!; + const block = root.querySelector("figure")!; + const image = root.querySelector("img")!; + pageContents[0]!.getBoundingClientRect = () => + positionedRect(100, 700, 1_000); + pageContents[1]!.getBoundingClientRect = () => + positionedRect(100, 700, 1_000); + title.getBoundingClientRect = () => + positionedRect(100, 600, 40); + block.getBoundingClientRect = () => + positionedRect(150, 600, 330); + image.getBoundingClientRect = () => + positionedRect(150, 600, 300); + + expect(findMediaBackfillCandidates(root)).toEqual([]); + }); + + it("忽略媒体之前其他内容的显式分页规则", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+
上一页内容
+
+
+
+
+

+ 本页章节 +

+

图片标题

+
+ +
+
+
+ `; + const pageContents = root.querySelectorAll( + ".pagedjs_page_content" + ); + const previousFlow = root.querySelector( + "[data-ref='previous-content']" + )!; + const earlierHeading = root.querySelector("h1")!; + const title = root.querySelector("h2")!; + const block = root.querySelector("figure")!; + const image = root.querySelector("img")!; + pageContents[0]!.getBoundingClientRect = () => + positionedRect(100, 700, 1_000); + previousFlow.getBoundingClientRect = () => + positionedRect(100, 700, 550); + pageContents[1]!.getBoundingClientRect = () => + positionedRect(100, 700, 1_000); + earlierHeading.getBoundingClientRect = () => + positionedRect(100, 600, 40); + title.getBoundingClientRect = () => + positionedRect(150, 600, 40); + block.getBoundingClientRect = () => + positionedRect(200, 600, 330); + image.getBoundingClientRect = () => + positionedRect(200, 600, 300); + + expect(findMediaBackfillCandidates(root)).toEqual([ + { id: "media-1", scale: 0.92 } + ]); + }); + + it("将候选缩放应用到图片和 ECharts 主体", () => { + const root = document.createElement("div"); + root.innerHTML = ` +

图片标题保持原尺寸

+
+ +
+
+
+
+
+ +
+ `; + + expect( + applyMediaBackfillCandidates(root, [ + { id: "media-1", scale: 0.9 }, + { id: "media-2", scale: 0.88 }, + { id: "media-3", scale: 0.9 } + ]) + ).toBe(3); + const title = root.querySelector("h2")!; + const image = root.querySelector( + "img.md-document-image" + )!; + const host = root.querySelector(".md-echarts-host")!; + const svg = root.querySelector("svg")!; + const mermaidSvg = root.querySelector( + ".mermaid svg" + )!; + expect(title.getAttribute("style")).toBeNull(); + expect(image.style.width).toBe("720px"); + expect(image.style.height).toBe("540px"); + expect(host.style.width).toBe("616px"); + expect(host.style.height).toBe("440px"); + expect(svg.style.width).toBe("616px"); + expect(svg.style.height).toBe("440px"); + expect(mermaidSvg.style.width).toBe("540px"); + expect(mermaidSvg.style.height).toBe("360px"); + }); +}); diff --git a/apps/web/tests/sample-markdown.test.ts b/apps/web/tests/sample-markdown.test.ts new file mode 100644 index 0000000..e71895b --- /dev/null +++ b/apps/web/tests/sample-markdown.test.ts @@ -0,0 +1,81 @@ +import { inflateSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { sampleMarkdown } from "../src/sample-markdown"; + +function readPngDimensions(base64: string) { + const png = Buffer.from(base64, "base64"); + expect(png.subarray(0, 8)).toEqual( + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) + ); + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + const bitDepth = png[24] ?? 0; + const colorType = png[25] ?? 0; + const samplesPerPixel = + colorType === 0 || colorType === 3 + ? 1 + : colorType === 2 + ? 3 + : colorType === 4 + ? 2 + : 4; + const idatParts: Buffer[] = []; + let offset = 8; + while (offset + 12 <= png.length) { + const length = png.readUInt32BE(offset); + const type = png.toString("ascii", offset + 4, offset + 8); + if (type === "IDAT") { + idatParts.push( + png.subarray(offset + 8, offset + 8 + length) + ); + } + offset += 12 + length; + } + const decoded = inflateSync(Buffer.concat(idatParts)); + const rowBytes = Math.ceil( + (width * bitDepth * samplesPerPixel) / 8 + ); + expect(decoded.length).toBe((rowBytes + 1) * height); + return { width, height }; +} + +describe("默认示例 Markdown", () => { + it("仅保留一张网络图片并提供三种 Base64 图片尺寸", () => { + expect( + sampleMarkdown.match(/https?:\/\/[^\s)]+/gu) + ).toHaveLength(1); + expect( + sampleMarkdown.match(/data:image\/png;base64,/gu) + ).toHaveLength(3); + expect(sampleMarkdown).toContain("1600×900"); + expect(sampleMarkdown).toContain("900×1800"); + expect(sampleMarkdown).toContain("1200×880"); + }); + + it("所有内嵌 PNG 均可完整解压且尺寸符合描述", () => { + const images = Array.from( + sampleMarkdown.matchAll( + /data:image\/png;base64,([A-Za-z0-9+/=]+)/gu + ), + (match) => readPngDimensions(match[1] ?? "") + ); + expect(images).toEqual([ + { width: 1600, height: 900 }, + { width: 900, height: 1800 }, + { width: 1200, height: 880 } + ]); + }); + + it("覆盖 Mermaid、ECharts 分档和串行媒体场景", () => { + expect( + sampleMarkdown.match(/```mermaid/gu) + ).toHaveLength(3); + expect( + sampleMarkdown.match(/```echarts/gu) + ).toHaveLength(4); + expect(sampleMarkdown).toContain("height: 40mm"); + expect(sampleMarkdown).toContain("height: 58mm"); + expect(sampleMarkdown).toContain("height: 85mm"); + expect(sampleMarkdown).toContain("## 串行媒体块"); + }); +}); diff --git a/deploy/README.md b/deploy/README.md index 396a04c..58717c6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down 可以直接指定版本化镜像名称构建: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5' docker compose -f deploy\compose.yaml build ``` @@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build 已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层: ```powershell -$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1' +$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.5' $env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1' $env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1' docker compose -f deploy\compose.yaml build ``` -复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.1 应用代码和生产 +复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.5 应用代码和生产 依赖。正式跨机器构建仍建议使用默认完整路径。 ## 主题挂载 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 32e2dd9..7f9c81e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Markdown PDF 导出器进度 -最后更新:2026-07-27 +最后更新:2026-07-28 ## 1. 当前概况 @@ -44,6 +44,11 @@ Web/Compose 发布仍保持 `v0.4.1`。 `v0.4.4` 仅精简 Electron 桌面发行包的语言资源,保留简体中文和英文; Web/Compose 发布仍保持 `v0.4.1`。 +`v0.4.5` 同步更新 Web 与 Desktop:新增新建/保存 Markdown、桌面文件 +关联、窗口状态恢复、自定义主题目录和收纳式“更多”菜单;Web 明确取消 +本地素材目录。图片、Mermaid 与 ECharts 的分页算法统一为按文档顺序 +串行计算的媒体块回填,每个媒体元素至多重排一次,标题保持原尺寸。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -437,9 +442,37 @@ Web/Compose 发布仍保持 `v0.4.1`。 - 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持 `v0.4.1`。 +### 3.18 v0.4.5 文档工作流与媒体分页 + +- Web 与 Desktop 均支持新建 Markdown;未保存时使用“未命名文档”状态, + 不显示伪造源文件名,并允许直接导出 PDF。 +- Web 保存使用浏览器下载;Desktop 使用原生另存为。默认文件名优先取 + 第一个一级标题,没有一级标题时取首行可见文本,并清理 Windows 非法 + 字符、保留名和末尾空格或句点。 +- “打开 Markdown”继续作为顶部独立强操作;新建、保存及桌面自定义主题 + 目录收纳到“更多”菜单,Web 不再显示本地素材目录入口。 +- Windows 安装包注册 `.md` 和 `.markdown` 文件关联;系统打开文件会 + 转交给现有单实例窗口并自动加载。 +- Desktop 监听窗口位置、尺寸和最大化变化,延迟写入完整状态;退出前 + 刷新待写状态。下次启动按当前显示器工作区限制尺寸并修正坐标,避免 + 多显示器断开后窗口落在屏幕外。 +- Desktop 会建立自定义主题目录,“更多”菜单可直接在资源管理器打开; + 目录允许留空,放入合法主题后由共享主题注册器读取。 +- 图片、Mermaid 和 ECharts 统一视为带标题的媒体块。分页按 DOM 文档 + 顺序串行处理,每个媒体元素只允许一次回填或缩放重排。 +- 图片基准高度按原尺寸先收缩到内容限宽后计算;媒体块只有在上一页空白 + 与所需空间比例达到阈值时才尝试回填。缩放只作用于图片或图表内容, + 标题保持原字号和自然高度。 +- ECharts 在分页前冻结为 SVG 图片,三类媒体共享回填决策与边界检查; + 无法安全回填时仍移至下一页,保证顺序、不跨页且不覆盖后续内容。 +- 默认示例仅保留一张网络图片,并新增有效 Base64 横图、不同尺寸图片、 + Mermaid 和 ECharts,用于观察媒体分页、标题配对和渲染效果。 +- 根 README、Desktop README、四个工作区包 README、进度文档和发布 + 规范随版本同步更新。 + ## 4. 已执行验证 -2026-07-27 在当前完整工作区成功执行: +2026-07-28 在当前完整工作区成功执行: ```text npm test @@ -452,16 +485,45 @@ git diff --check - ECharts 围栏测试:36 项通过; - 共享配置测试:9 项通过; -- 渲染器测试:10 项通过; -- 应用服务测试:5 项通过; -- 前端测试:67 项通过; +- 渲染器测试:12 项通过; +- 应用服务测试:9 项通过; +- 前端测试:87 项通过; - 后端测试:29 项通过; -- 桌面端测试:7 项通过; -- 全项目共 171 项测试通过; +- 桌面端测试:20 项通过; +- 全项目共 202 项测试通过; - 全项目类型检查通过; - 生产构建通过; - `git diff --check` 通过。 +`v0.4.5` 双端与媒体分页验收结果: + +- 正式镜像 `yixiong/md-to-pdf:v0.4.5` 已完成多阶段构建,镜像内容 ID + 为 `sha256:0703c3762cd0e4e551ce44732b064a938c2095eb331a22e28861a148fe520354`; + Compose 已强制替换为正式镜像并健康运行在 `http://localhost:8080`。 +- 正式镜像 PDF 冒烟请求返回 HTTP 200,生成 1 页、96,661 字节 PDF, + Mermaid 与 ECharts 错误数均为 0。 +- 默认示例在 Web 快速预览与精确预览中均为 10 页;12 个媒体块均只 + 出现一次,全部标题正确配对,无重叠、越界、解码或图表错误。 +- 容器生成 `output/pdf/v0.4.5-media-pagination-rc.pdf`,共 10 页、 + 928,803 字节;Mermaid 与 ECharts 错误数均为 0,逐页视觉检查通过。 +- Desktop 新建、输入、默认标题文件名保存、原生 PDF 导出均通过; + `output/桌面保存验收.md` 内容正确,Desktop README 导出 PDF 为 5 页。 +- 从第二实例系统打开 `README.md` 时保持单窗口并自动加载;自定义主题 + 目录能够打开且可识别已有主题。 +- 窗口最大化变化约 2 秒后写入状态;重启恢复最大化。窗口位置与尺寸 + 测试覆盖屏幕越界修正、最小尺寸、原子写入和损坏状态回退。 +- 正式 NSIS 安装包为 `md-to-pdf-0.4.5-x86_64-Setup.exe`, + 94,998,489 字节,SHA-256 为 + `9CBD6362E15AA745185805E753D5A7749434E0E142E6CE0F66463E6A19468F1D`。 +- 免安装包为 `md-to-pdf-0.4.5-x86_64.zip`,132,477,245 字节, + SHA-256 为 + `97B2D44E72F626A3396E5AB5B330FFB7F9BECAF945A1707808C876940A82E850`。 +- 正式目录版主程序版本为 `0.4.5`,只包含 `zh-CN` 和 `en-US` 语言包, + Authenticode 状态为未签名,启动后应用与渲染进程均正常响应。 +- 本机当前用户安装已从 `0.4.3` 原位升级到 `0.4.5`;卸载注册信息、 + 桌面与开始菜单快捷方式、`.md` 和 `.markdown` 打开注册均验证通过, + 安装版应用启动正常。 + Electron 桌面端第一阶段验证结果: - Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示 @@ -678,6 +740,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果: ## 5. 当前注意事项 - `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。 +- 已确认分页预览 iframe 内点击超链接会在 iframe 内执行导航,可能将当前 + 预览替换为空白页。该问题不阻塞 `v0.4.5`,计划在 `v0.4.6` 统一处理: + 区分文档内锚点与外部链接、阻止危险协议,并为外部链接使用受控的新窗口 + 或系统浏览器打开策略。 - `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理。 - `apps/web/src/use-markdown-render.ts` 负责 Markdown 防抖请求、取消和 渲染错误状态。 @@ -720,12 +786,22 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 目录读取;远程图片仅保留有界内存缓存。 - 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF 导出,不依赖 Fastify 或本地 HTTP Server。 -- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证, - 当前 Compose 容器保持运行。 +- `v0.4.5` AIO 正式镜像已经通过 Docker Desktop 构建、PDF 冒烟和 + Compose 健康检查,当前 Compose 容器保持运行。 ## 6. 推荐接手顺序 -### 阶段一:导出配置扩展 +### 阶段一:v0.4.6 超链接导航 + +- 在快速预览 iframe 中统一拦截链接点击,避免外部页面替换分页预览; +- 文档内 `#anchor` 保持在当前预览中定位,并兼容分页后的标题锚点; +- `http:`、`https:`、`mailto:` 等允许协议通过受控新窗口或桌面系统 + 浏览器打开; +- 拒绝 `javascript:`、`file:`、未知协议及未授权本地资源; +- Web 与 Desktop 共用链接分类规则,各平台只实现打开动作适配; +- 增加快速预览、精确预览和 Desktop 的链接行为测试。 + +### 阶段二:导出配置扩展 - Front Matter 元数据覆盖; - 浏览器本地配置预设; diff --git a/package-lock.json b/package-lock.json index 21fb93c..465dcae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "md-to-pdf", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "md-to-pdf", - "version": "0.4.4", + "version": "0.4.5", "workspaces": [ "apps/*", "packages/*" @@ -21,7 +21,7 @@ }, "apps/desktop": { "name": "@md-to-pdf/desktop", - "version": "0.4.4", + "version": "0.4.5", "devDependencies": { "@md-to-pdf/application": "0.4.1", "@types/node": "^24.10.1", @@ -3486,7 +3486,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5296,6 +5295,7 @@ "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", diff --git a/package.json b/package.json index 220b7a0..ba71055 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "md-to-pdf", - "version": "0.4.4", + "version": "0.4.5", "private": true, "description": "可配置、可主题化的 Markdown PDF 导出工具", "workspaces": [ diff --git a/packages/application/README.md b/packages/application/README.md new file mode 100644 index 0000000..9fcd80c --- /dev/null +++ b/packages/application/README.md @@ -0,0 +1,46 @@ +# @md-to-pdf/application + +Web Server 与 Electron Desktop 共用的应用服务层,组合 +`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、 +主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和 +平台能力适配。 + +## 目录结构 + +```text +src/ + application-service.ts 渲染、主题和资源用例入口 + image-resources.ts 本地、Base64 与受限远程图片处理 + theme-registry.ts 内置及自定义主题扫描、校验与缓存 + index.ts 公共导出入口 +tests/ + application-service.test.ts + image-resources.test.ts +``` + +## 使用 + +```ts +import { createApplicationService } from "@md-to-pdf/application"; + +const service = createApplicationService({ + bundledRoot, + localRoot +}); + +const themes = await service.listThemes(); +const document = await service.render(request); +``` + +Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一 +服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录, +Desktop 才会以 Markdown 所在目录为边界解析相对资源。 + +## 开发与验证 + +```powershell +npm run dev -w @md-to-pdf/application +npm run test -w @md-to-pdf/application +npm run typecheck -w @md-to-pdf/application +npm run build -w @md-to-pdf/application +``` diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..ec5495c --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,47 @@ +# @md-to-pdf/core + +跨 Web、Server、Desktop 和渲染器共享的纯数据模型与校验规则。该包不 +依赖浏览器、Electron 或 Fastify,负责保证各运行端使用同一份协议。 + +## 目录结构 + +```text +src/ + document.ts Markdown 文档、分页载荷、结果与耗时模型 + export-config.ts 纸张、边距、页眉页脚、页码与图表配置 + theme.ts 主题清单、主题能力与 CSS 载荷模型 + index.ts 公共导出入口 +tests/ + document.test.ts + export-config.test.ts +``` + +## 使用 + +```ts +import { + createPagedDocumentPayload, + defaultExportConfig, + exportConfigSchema +} from "@md-to-pdf/core"; + +const config = exportConfigSchema.parse(defaultExportConfig); +const payload = createPagedDocumentPayload({ + document, + fileName: "example.md", + themeCss, + exportConfig: config +}); +``` + +新增跨端字段时,应先在这里定义类型、默认值、Zod 校验和兼容迁移,再由 +各适配层消费,避免 Web 与 Desktop 分别维护协议。 + +## 开发与验证 + +```powershell +npm run dev -w @md-to-pdf/core +npm run test -w @md-to-pdf/core +npm run typecheck -w @md-to-pdf/core +npm run build -w @md-to-pdf/core +``` diff --git a/packages/markdown-echarts/README.md b/packages/markdown-echarts/README.md index 53f6f62..2f6e866 100644 --- a/packages/markdown-echarts/README.md +++ b/packages/markdown-echarts/README.md @@ -6,6 +6,24 @@ 该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的 HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。 +## 目录结构 + +```text +src/ + markdown-it-plugin.ts ECharts YAML 围栏与错误占位 + parse-yaml.ts 受限 YAML 解析 + protocol.ts 围栏协议与基础校验 + security-policy.ts URL、函数、原型和规模安全策略 + semantic-validator.ts 系列数据与坐标系语义校验 + dom-contract.ts 宿主与浏览器运行时共用的 DOM 协议 + browser.ts 浏览器入口 + browser/ ECharts 按需加载、渲染和 SVG 冻结 + index.ts Markdown 侧公共导出 +styles/ + echarts.css 图表、标题和错误占位样式 +tests/ 解析、安全、语义、插件和真实 SVG 测试 +``` + ## 基本语法 ````markdown @@ -289,6 +307,15 @@ Chromium `page.pdf()`。 3. 注册该系列的语义验证器; 4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。 +## 开发与验证 + +```powershell +npm run dev -w @md-to-pdf/markdown-echarts +npm run test -w @md-to-pdf/markdown-echarts +npm run typecheck -w @md-to-pdf/markdown-echarts +npm run build -w @md-to-pdf/markdown-echarts +``` + 参考: - [ECharts 配置参考](https://echarts.apache.org/en/option.html) diff --git a/packages/renderer/README.md b/packages/renderer/README.md new file mode 100644 index 0000000..413c56d --- /dev/null +++ b/packages/renderer/README.md @@ -0,0 +1,46 @@ +# @md-to-pdf/renderer + +将 Markdown 转换为统一、安全的 `#write` HTML 文档。Web 预览、服务端 +PDF 和桌面端 PDF 均通过应用服务复用本包,不维护第二套 Markdown 解析 +逻辑。 + +## 目录结构 + +```text +src/ + render-markdown.ts Front Matter、Markdown 扩展、安全过滤与元数据 + index.ts 公共导出入口 +tests/ + render-markdown.test.ts +``` + +## 支持能力 + +- 标题锚点、表格、任务列表、脚注和代码高亮; +- KaTeX 数学公式; +- Mermaid 安全占位; +- `@md-to-pdf/markdown-echarts` ECharts YAML 围栏; +- 图片标题 DOM 与资源引用收集; +- `sanitize-html` 白名单过滤和统一 `
` 输出。 + +## 使用 + +```ts +import { renderMarkdown } from "@md-to-pdf/renderer"; + +const result = renderMarkdown("# 示例", { + language: "zh-CN" +}); +``` + +实际应用优先通过 `@md-to-pdf/application` 调用,以便同时完成主题和图片 +资源处理。 + +## 开发与验证 + +```powershell +npm run dev -w @md-to-pdf/renderer +npm run test -w @md-to-pdf/renderer +npm run typecheck -w @md-to-pdf/renderer +npm run build -w @md-to-pdf/renderer +```