From 83cedbc5f3d9e91d76d83a36cc3c3af7fd4cce08 Mon Sep 17 00:00:00 2001 From: Muse Date: Fri, 13 Nov 2026 22:58:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(stage5):=20=E6=96=87=E6=A1=A3=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E7=B3=BB=E7=BB=9F/=E8=87=AA=E5=AE=9A=E4=B9=89=20CSS/?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20HTML/=E6=89=93=E5=8D=B0/=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=20HTML=20=E4=B8=8E=20Word?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/09-stage5-report.md | 36 ++ package-lock.json | 805 +++++++++++++++++++++++++++++++- package.json | 17 +- src/App.tsx | 177 ++++++- src/components/Stage5Modals.tsx | 112 +++++ src/services/exportHtml.ts | 230 +++++++++ src/services/platform.ts | 54 ++- src/styles.css | 106 +++++ src/themes.ts | 23 + tests/stage5.test.ts | 54 +++ 11 files changed, 1604 insertions(+), 14 deletions(-) create mode 100644 docs/09-stage5-report.md create mode 100644 src/components/Stage5Modals.tsx create mode 100644 src/services/exportHtml.ts create mode 100644 src/themes.ts create mode 100644 tests/stage5.test.ts diff --git a/README.md b/README.md index b3f4443..3adad46 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。 ## 当前进度 -**阶段 0–4 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** -阶段 4:SQLite 落库、自动保存、崩溃恢复、外部修改检测、工作区文件管理,报告见 `docs/08-stage4-report.md`。 +**阶段 0–5 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** +阶段 5:7 套文档主题、自定义 CSS、导出 HTML、打印/PDF、导入 HTML/Word,报告见 `docs/09-stage5-report.md`。 阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。 在 Windows 上运行桌面版: diff --git a/docs/09-stage5-report.md b/docs/09-stage5-report.md new file mode 100644 index 0000000..f528853 --- /dev/null +++ b/docs/09-stage5-report.md @@ -0,0 +1,36 @@ +# 阶段 5 · 主题系统与导入导出报告(实测记录) + +> 日期:2026-11-13 · 范围(需求 §23 阶段 5):内置主题、自定义 CSS、导出 HTML/PDF、导入导出与打印。 + +## 1. 本阶段实现内容 + +- **文档主题系统**(`src/themes.ts` + styles.css 变量层):7 套内置主题——默认、GitHub、Notion、简约蓝、深色护眼(自带深色纸面)、专注写作(衬线窄栏)、经典纸张。主题只作用于编辑区排版(字体/行距/标题/链接/引用/代码底/纸面色),经 CSS 变量切换,与 UI 框架明暗相互独立。新增顶部「主题」菜单可直接切换(带勾选态)。 +- **当前文档临时主题**(需求 §14.3):外观弹窗里可为当前标签单独指定主题,覆盖全局、仅本会话有效,不写进文档文件。 +- **自定义 CSS**(需求 §14.2):外观弹窗内编辑、即时注入生效、随偏好落库;弹窗同时补齐了阶段 4 遗留的自动保存间隔设置(开关 + 10–600 秒)。 +- **导出 HTML**(`src/services/exportHtml.ts`):独立管线 unified/remark(gfm+math)→ rehype → rehype-katex 公式渲染 → 代码高亮 → Mermaid 渲染 SVG → 独立 HTML 文档(内联与主题同源的导出样式、`` 取文档名)。图片两模式:保持相对路径 / 内嵌 base64 单文件(Rust 新增 `read_file_base64`,base64 实现已用标准向量实跑验证;相对路径按文档目录解析,含 `..` 归一化)。 + - **高亮引擎决策**:Shiki 默认的 Oniguruma WASM 加载依赖 fetch/stream,在 jsdom 实测直接失败(真实 WebView 亦有同类风险),已换 **shiki/core + JavaScript 正则引擎**(无 WASM、处处可跑),24 种常用语言,导出测试断言了着色 span 真实产出。 +- **复制为 HTML**:ClipboardItem 同时写 text/html 与 text/plain(降级 writeText),可直接粘贴到公众号/网页富文本编辑器。 +- **打印 / 导出 PDF**:Ctrl+P 与文件菜单;打印样式表隐藏全部框架(标题栏/菜单/标签/侧栏/状态栏/弹窗),只输出文档区并设定 @page 边距;PDF 经系统打印对话框「另存为 PDF」完成。 +- **导入**(文件菜单):HTML 与 Word(.docx) 转 Markdown 新建标签——HTML 走 turndown(ATX 标题/围栏代码块),DOCX 走 mammoth 先转 HTML 再 turndown;两个库均懒加载。Web 降级模式同样可导入。 + +## 2. 构建与测试结果(实跑) + +| 项 | 结果 | +|---|---| +| 类型检查 `tsc -b` | ✅ 0 错误 | +| 自动化测试 | ✅ **53/53**(11 文件;本阶段新增 5:主题注册表、导出管线结构/公式/表格、Shiki 真高亮、图片内嵌替换、HTML 导入) | +| Rust | ✅ base64 函数标准向量全过(空/1/2/3 字节/中文/高位字节);`read_file_base64` 命令包装随 Windows 构建验 | +| 生产构建 | ✅ 通过(导出/导入相关库全部懒加载分包,不进首屏) | +| dev 冒烟 | ✅ HTTP 200 | + +## 3. 已知问题与边界 + +1. **导出 Word 未做**:DOCX 生成需要 pandoc 级别的转换器,本阶段只做了导入方向;导出 Word 留待后续评估(可考虑集成 pandoc 二进制或 docx 生成库,排版保真另说)。EPUB 导入/导出同样未做。 +2. 导出 HTML 的 KaTeX 样式走 CDN 链接:联网时公式显示完美;完全离线时公式退化为 MathML 原生渲染(Chromium/WebView2 可显示,样式略简)。把 KaTeX 字体内嵌进导出文件可做到全离线,但单文件体积会涨约 300KB,未默认启用。 +3. Mermaid 导出 SVG 在 jsdom 无法真验(同阶段 3 边界),失败时自动保留代码块原文,不丢内容。 +4. 打印效果(分页、页眉页脚)取决于 WebView2 打印对话框与驱动,真机验;打印样式已按类名核对过框架元素(titlebar/menubar/tabbar/sidebar/statusbar 均实存)。 +5. 文档临时主题与自定义 CSS 是会话/偏好级能力:临时主题不跨重启(按需求 §14.3 的「临时」定位),自定义 CSS 跨重启保留(在偏好里)。 + +## 4. 下一阶段(阶段 6)预告 + +编辑体验与性能:专注模式、打字机模式、文档内查找替换、全文搜索(工作区)、快捷键自定义中心、多窗口/多开、性能优化与大文档实测(100KB/1MB/5MB 打开与输入延迟,WebView2 真机口径)。 diff --git a/package-lock.json b/package-lock.json index f2f010a..2949138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,27 @@ "@tauri-apps/plugin-dialog": "^2.2.0", "codemirror": "6.0.2", "katex": "0.16.11", + "mammoth": "1.11.0", "mermaid": "12.3.0", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "rehype-katex": "7.0.1", + "rehype-stringify": "10.0.1", + "remark-gfm": "4.0.1", + "remark-math": "6.0.0", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "shiki": "4.6.0", + "turndown": "7.2.2", + "unified": "11.0.5", + "unist-util-visit": "5.1.0" }, "devDependencies": { "@tauri-apps/cli": "^2.2.0", + "@types/hast": "3.0.5", "@types/react": "18.3.11", "@types/react-dom": "18.3.1", + "@types/turndown": "5.0.6", "@vitejs/plugin-react": "4.3.3", "jsdom": "25.0.1", "typescript": "5.6.3", @@ -1997,6 +2010,12 @@ "nanoid": "^6.0.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@ocavue/utils": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/@ocavue/utils/-/utils-1.12.0.tgz", @@ -2370,6 +2389,106 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.6.0.tgz", + "integrity": "sha512-bfd4OKgvoyXYiYmMRuleA656P4V7M/Du9lcTl38Ngm6KszMj7bq4J1IGZwSYR4c95Tv5UQALtYNWUwaI9UT3xw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.6.0", + "@shikijs/types": "4.6.0", + "@shikijs/vscode-textmate": "^10.1.0", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.6.0.tgz", + "integrity": "sha512-K0oqxFoL+kEMdz89mHLFO15Tsbv3kFZ6WR1cXZKMvLSc+oqaM00A6pyga1qSeXB22dP0tTiSApRcGBaKWsEccg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.6.0", + "@shikijs/vscode-textmate": "^10.1.0", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.6.0.tgz", + "integrity": "sha512-aggd6Q7SuJOXNnsUbfG2XesxBJKBMAw/PDTUo9PyRjMaaJJ2dNrI+q0C+25ARXAK9yLHAlwxc9oRT2u68YKZ7w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.6.0", + "@shikijs/vscode-textmate": "^10.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.6.0.tgz", + "integrity": "sha512-1yMEX3oCVV54LUq/ovfpRkDQoYDkcvjSWFFa/YLUDMiSoRzMi2uxLJYbv41ahT8Lu9lFMaa7hecd3Q39trqUyg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.6.0.tgz", + "integrity": "sha512-c54+TQEo9ToqgfL/AU8FpjnV09+GA9Oa7M5ROkkXteZfp5XzpAJGXjjvZQFQioJOwm/w/6Z3lIzQstAkl8coag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.6.0", + "@shikijs/vscode-textmate": "^10.1.0", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.6.0.tgz", + "integrity": "sha512-qHAVAs80RiGD+IRuH9G+JPDOEc9A/kHsng4EzCUCwB9bLzW6Z0y9WETh2YyCcADJBmfrIaJ1Jp7KBNP6FHkgtQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.6.0.tgz", + "integrity": "sha512-+ENG1adsKt/X7cRYw1Nl7BEpBmPOjmQ7bR/eGmHuMlT8gtOFf3KEi5l31DJYyAWhW6Y6bjP1plU7FmaxWGENFw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.1.0", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.1.0.tgz", + "integrity": "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==", + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.12.4", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.4.tgz", @@ -3006,12 +3125,25 @@ "license": "MIT", "optional": true }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@upsetjs/venn.js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", @@ -3303,6 +3435,15 @@ "integrity": "sha512-ue9fgHmVNPlP7iW3+cxoBX/Cnlw53dHlVSw0JKGY6z1lObg8pHoj928M2nzG4/yVsMZ3NaRZ2s2z61uC16NDwQ==", "license": "MIT" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3313,6 +3454,15 @@ "node": ">= 14" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3340,6 +3490,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.37", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.37.tgz", @@ -3353,6 +3523,12 @@ "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.29.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", @@ -3469,6 +3645,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3532,6 +3728,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -3548,6 +3754,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -4207,6 +4419,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.2.tgz", + "integrity": "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==", + "license": "BSD-2-Clause" + }, "node_modules/dompurify": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", @@ -4216,6 +4434,15 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4564,6 +4791,178 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.1.0.tgz", + "integrity": "sha512-873p9AuH93gBnATbH8rU3YvjTKxyrIeWd/M9tiLMzn3G82Os0xhQurctfnbYUSrDXoyjbJGpJ2qmTpefMw7ZaQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^8.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/entities": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.1.tgz", + "integrity": "sha512-EcR/6WDcU7b2N3vQSxKALCk9lXGtUMQxXio008OEMBE0kavu/7JLfBEeFPzi43nMqVDSbEn3QCK+BMCc9zO//w==", + "license": "MIT", + "dependencies": { + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.4.tgz", + "integrity": "sha512-Yq7XlXor0Cpb71sI2E0zpv8QxQ5a6rfhDEETk4bcgrFX2SvJAY2PHocWwFtL6U9qyfiyByW3wZ6sZEfP2kW81g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.6.tgz", + "integrity": "sha512-1SZUxCN3TtJAE7e2BQN2MBHsoZ1gnmi9GoCP5Di59CSWptIezSAW09yDZyVbanb78V9eCwrKqdQsjtfVTxYHWA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.3.tgz", + "integrity": "sha512-5yrlWOvGFs6CqHriUpSsNrjzT/Tdo+IOQudbznxOn8K5qblp18MJyzOg8IgDOKQ8MYgQcJA7SusuCjSpDr8mSA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.3.tgz", + "integrity": "sha512-1ALZXSmCGmW3h8AMz4R4+QKSKFi7sNgXLVwmwB8FYVtl1CwQ3WDkSAEY/d5tp1g/vbQgq2s1WlHgfO3jbw1TsA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -4577,6 +4976,16 @@ "node": ">=18" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4617,6 +5026,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -4627,6 +5042,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -4655,6 +5076,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4728,6 +5155,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/katex": { "version": "0.16.11", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", @@ -4755,6 +5194,15 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -4783,6 +5231,17 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4809,6 +5268,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/markdown-table": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.1.0.tgz", @@ -5032,6 +5515,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.2.tgz", + "integrity": "sha512-l2pnmOuqs+SGLVpTVXnRLQu5v1AzvDe181404fbXzzqObVxrAnnVxapAP27pUwzLBL/ABIkgUhGlPVU7QSvIFg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-to-markdown": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.3.tgz", @@ -5793,6 +6297,29 @@ "dev": true, "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", @@ -5805,6 +6332,12 @@ "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5824,6 +6357,15 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -5909,6 +6451,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/prosemirror-changeset": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.6.tgz", @@ -6121,6 +6679,79 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", @@ -6202,6 +6833,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-stringify": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", @@ -6300,6 +6948,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6338,6 +6992,31 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shiki": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.6.0.tgz", + "integrity": "sha512-imjLsVHgPuWhvpU3P+zOekvMYqnel7QmKsFC4Sn0fCuAvaaalBsxwKEsrU8Ko5BdFnZA6Go/8a0RzahS634UgA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.6.0", + "@shikijs/engine-javascript": "4.6.0", + "@shikijs/engine-oniguruma": "4.6.0", + "@shikijs/langs": "4.6.0", + "@shikijs/themes": "4.6.0", + "@shikijs/types": "4.6.0", + "@shikijs/vscode-textmate": "^10.1.0", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -6354,6 +7033,22 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6368,6 +7063,29 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/style-mod": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", @@ -6477,6 +7195,16 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -6502,6 +7230,15 @@ "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", "license": "0BSD" }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -6516,6 +7253,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6548,6 +7291,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-remove-position": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", @@ -6576,14 +7332,14 @@ } }, "node_modules/unist-util-visit": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.2.0.tgz", - "integrity": "sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.1.0" + "unist-util-visit-parents": "^6.0.0" }, "funding": { "type": "opencollective", @@ -6635,6 +7391,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6649,6 +7411,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.4.tgz", + "integrity": "sha512-vEvxatYZCIpNvzGk2UD+u4I/sZkSKoOsdRCbkOIsN73QpJbriJ1qvvBAL1PeI0y55Leoqf7pORbaSPpG/smmOg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.4.tgz", @@ -6852,6 +7628,16 @@ "node": ">=18" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -6949,6 +7735,15 @@ "node": ">=18" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 4c00e61..32b0a59 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,18 @@ "@codemirror/theme-one-dark": "6.1.3", "@milkdown/plugin-math": "7.5.9", "katex": "0.16.11", - "mermaid": "12.3.0" + "mermaid": "12.3.0", + "unified": "11.0.5", + "remark-parse": "11.0.0", + "remark-gfm": "4.0.1", + "remark-math": "6.0.0", + "remark-rehype": "11.1.2", + "rehype-stringify": "10.0.1", + "rehype-katex": "7.0.1", + "unist-util-visit": "5.1.0", + "shiki": "4.6.0", + "turndown": "7.2.2", + "mammoth": "1.11.0" }, "devDependencies": { "@tauri-apps/cli": "^2.2.0", @@ -21,7 +32,9 @@ "jsdom": "25.0.1", "typescript": "5.6.3", "vite": "5.4.11", - "vitest": "2.1.5" + "vitest": "2.1.5", + "@types/turndown": "5.0.6", + "@types/hast": "3.0.5" }, "name": "new-markdown", "private": true, diff --git a/src/App.tsx b/src/App.tsx index cc9e8a2..16fa857 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,10 +22,14 @@ import { pickAndReadWebFile, downloadTextFile, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry, + pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, } from './services/platform' import * as store from './services/store' import { recoveryKey, planRecovery } from './utils/recovery' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' +import { AppearanceModal, ExportModal } from './components/Stage5Modals' +import { DOC_THEMES } from './themes' +import { markdownToExportHtml, htmlToMarkdown, docxToMarkdown, type ImageMode } from './services/exportHtml' const WELCOME_MD = `# 欢迎使用 new-markdown @@ -47,9 +51,9 @@ const UI_KEY = 'new-markdown:ui' function loadRecent(): string[] { try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] } } -interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number } +interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string } function loadUi(): UiState { - const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30 } + const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '' } try { return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') } } catch { return defaults } @@ -86,6 +90,9 @@ export default function App() { const [promptSpec, setPromptSpec] = useState<{ title: string; label: string; initial: string; submit: (v: string) => void } | null>(null) const [confirmSpec, setConfirmSpec] = useState<{ title: string; message: string; confirmLabel: string; onOk: () => void } | null>(null) const [treeRefresh, setTreeRefresh] = useState(0) + const [docOverrides, setDocOverrides] = useState<Record<string, string>>({}) + const [appearanceOpen, setAppearanceOpen] = useState(false) + const [exportOpen, setExportOpen] = useState(false) /** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */ const baselinesRef = useRef(new Map<string, { mtimeMs: number; size: number }>()) const conflictSeenRef = useRef(new Set<string>()) @@ -110,6 +117,21 @@ export default function App() { store.saveUiPref(JSON.stringify(ui)) }, [ui]) + const effectiveDocTheme = (tab ? docOverrides[tab.id] : undefined) ?? ui.docTheme + useEffect(() => { + document.documentElement.dataset.docTheme = effectiveDocTheme + }, [effectiveDocTheme]) + + useEffect(() => { + let el = document.getElementById('new-markdown-custom-css') as HTMLStyleElement | null + if (!el) { + el = document.createElement('style') + el.id = 'new-markdown-custom-css' + document.head.appendChild(el) + } + el.textContent = ui.customCss + }, [ui.customCss]) + const pushRecent = useCallback((path: string) => { setRecent((prev) => [path, ...prev.filter((p) => p !== path)].slice(0, 10)) store.addRecent(path, baseName(path)) @@ -486,6 +508,7 @@ export default function App() { else if (key === 'w' && tab) { e.preventDefault(); closeTab(tab.id) } else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) } else if (key === 'k' && !e.shiftKey && !inTextField) { e.preventDefault(); setModal('link') } + else if (key === 'p' && !e.shiftKey) { e.preventDefault(); printRef.current() } else if (key === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) } else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() } else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) } @@ -562,6 +585,107 @@ export default function App() { } catch (e) { setError(String(e)) } }, [tab, withEditor]) + // ——— 阶段 5:导出 / 打印 / 导入 ——— + const resolveExportImage = useCallback((docPath: string | null) => { + if (!docPath) return undefined + const dir = docPath.replace(/[\\/][^\\/]*$/, '') + return async (src: string): Promise<string | null> => { + try { + const isAbsolute = /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('/') || src.startsWith('\\') + const raw = isAbsolute ? src : `${dir}/${src}` + const drive = raw.match(/^[a-zA-Z]:/)?.[0] ?? '' + const segments: string[] = [] + for (const part of raw.slice(drive.length).replace(/\\/g, '/').split('/')) { + if (part === '..') segments.pop() + else if (part !== '.' && part !== '') segments.push(part) + } + const joined = drive + '/' + segments.join('/') + const ext = (src.split('.').pop() ?? '').toLowerCase() + const mime = ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' + : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'application/octet-stream' + const base64 = await readFileBase64(joined) + return `data:${mime};base64,${base64}` + } catch { + return null + } + } + }, []) + + const buildExportHtml = useCallback(async (target: DocTab, imageMode: ImageMode): Promise<string> => { + return markdownToExportHtml(target.content, { + title: target.title, + themeId: docOverrides[target.id] ?? ui.docTheme, + imageMode, + resolveImage: tauriMode ? resolveExportImage(target.path) : undefined, + }) + }, [docOverrides, ui.docTheme, tauriMode, resolveExportImage]) + + const exportHtmlFile = useCallback(async (imageMode: ImageMode) => { + if (!tab) return + setExportOpen(false) + try { + const html = await buildExportHtml(tab, imageMode) + if (tauriMode) { + const target = await pickSaveHtmlFile(`${tab.title}.html`) + if (!target) return + await writeTextFile(target, html, false) + flash(`已导出 HTML:${baseName(target)}`) + } else { + downloadTextFile(`${tab.title}.html`, html, 'text/html;charset=utf-8') + flash('已导出 HTML(浏览器下载)') + } + } catch (e) { setError(String(e)) } + }, [tab, buildExportHtml, tauriMode, flash]) + + const copyHtml = useCallback(async () => { + if (!tab) return + setExportOpen(false) + try { + const html = await buildExportHtml(tab, 'embed') + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([tab.content], { type: 'text/plain' }), + }), + ]) + } catch { + await navigator.clipboard.writeText(html) + } + flash('已复制为 HTML,可粘贴到富文本编辑器') + } catch (e) { setError(String(e)) } + }, [tab, buildExportHtml, flash]) + + const printDoc = useCallback(() => { + if (!tab) return + if (sourceMode) toggleSourceMode() + flash('在打印对话框中可选择「另存为 PDF」导出 PDF') + window.setTimeout(() => window.print(), 60) + }, [tab, sourceMode, toggleSourceMode, flash]) + const printRef = useRef(printDoc) + printRef.current = printDoc + + const importFileFlow = useCallback(async () => { + try { + let name = '' + let markdown = '' + if (tauriMode) { + const path = await pickImportFile() + if (!path) return + name = baseName(path).replace(/\.(html?|docx)$/i, '') + if (/\.docx$/i.test(path)) markdown = await docxToMarkdown(await readBinaryFile(path)) + else markdown = await htmlToMarkdown((await readTextFile(path)).content) + } else { + const picked = await pickAndReadWebImport() + if (!picked) return + name = picked.name.replace(/\.(html?|docx)$/i, '') + markdown = picked.buffer ? await docxToMarkdown(picked.buffer) : await htmlToMarkdown(picked.text ?? '') + } + dispatch({ type: 'open', tab: createTab({ title: name || '导入文档', content: markdown }) }) + flash(`已导入《${name}》并转换为 Markdown`) + } catch (e) { setError(String(e)) } + }, [tauriMode, flash]) + const outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab]) const menus: MenuDef[] = useMemo(() => [ @@ -577,6 +701,10 @@ export default function App() { { label: '保存', shortcut: 'Ctrl+S', disabled: !tab, onClick: () => tab && void saveTab(tab, false) }, { label: '另存为…', shortcut: 'Ctrl+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) }, { label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) }, + { divider: true }, + { label: '导入 HTML / Word…', onClick: () => void importFileFlow() }, + { label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) }, + { label: '打印 / 导出 PDF…', shortcut: 'Ctrl+P', disabled: !tab, onClick: printDoc }, ], }, { @@ -638,6 +766,17 @@ export default function App() { { label: '恢复默认缩放', onClick: () => setZoom(1) }, ], }, + { + label: '主题', + items: [ + ...DOC_THEMES.map((t) => ({ + label: t.name, checked: ui.docTheme === t.id, + onClick: () => setUi((u) => ({ ...u, docTheme: t.id })), + })), + { divider: true }, + { label: '外观与自定义 CSS…', onClick: () => setAppearanceOpen(true) }, + ], + }, { label: '帮助', items: [ @@ -645,7 +784,7 @@ export default function App() { { label: '关于 new-markdown', onClick: () => setModal('about') }, ], }, - ], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard]) + ], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard, importFileFlow, printDoc]) const pendingTitles = pendingClose ? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、') @@ -743,6 +882,38 @@ export default function App() { tauriMode={tauriMode} zoom={zoom} /> + {appearanceOpen && ( + <AppearanceModal + docTheme={ui.docTheme} + override={tab ? (docOverrides[tab.id] ?? null) : null} + customCss={ui.customCss} + autosave={ui.autosave} + autosaveSeconds={ui.autosaveSeconds} + hasTab={Boolean(tab)} + onDocTheme={(id) => setUi((u) => ({ ...u, docTheme: id }))} + onOverride={(id) => { + if (!tab) return + setDocOverrides((prev) => { + const next = { ...prev } + if (id) next[tab.id] = id + else delete next[tab.id] + return next + }) + }} + onCustomCss={(css) => setUi((u) => ({ ...u, customCss: css }))} + onAutosave={(patch) => setUi((u) => ({ ...u, ...patch }))} + onClose={() => setAppearanceOpen(false)} + /> + )} + {exportOpen && tab && ( + <ExportModal + title={tab.title} + onExportHtml={(mode) => void exportHtmlFile(mode)} + onCopyHtml={() => void copyHtml()} + onPrint={() => { setExportOpen(false); printDoc() }} + onClose={() => setExportOpen(false)} + /> + )} {notice && <div className="app-notice">{notice}</div>} {recoveryOffer.length > 0 && !recoveryDismissed && ( <RecoveryModal diff --git a/src/components/Stage5Modals.tsx b/src/components/Stage5Modals.tsx new file mode 100644 index 0000000..b54cadb --- /dev/null +++ b/src/components/Stage5Modals.tsx @@ -0,0 +1,112 @@ +import { DOC_THEMES } from '../themes' + +/** 外观与设置(阶段 5):文档主题、当前文档临时主题、自定义 CSS、自动保存间隔。 */ +export function AppearanceModal({ docTheme, override, customCss, autosave, autosaveSeconds, hasTab, onDocTheme, onOverride, onCustomCss, onAutosave, onClose }: { + docTheme: string + override: string | null + customCss: string + autosave: boolean + autosaveSeconds: number + hasTab: boolean + onDocTheme: (id: string) => void + onOverride: (id: string | null) => void + onCustomCss: (css: string) => void + onAutosave: (patch: { autosave?: boolean; autosaveSeconds?: number }) => void + onClose: () => void +}) { + return ( + <div className="modal-backdrop"> + <div className="modal" style={{ width: 560 }}> + <h3>外观与设置</h3> + <div className="setting-label">文档主题(作用于编辑区排版)</div> + <div className="theme-grid"> + {DOC_THEMES.map((t) => ( + <button + key={t.id} + className={`theme-card${docTheme === t.id ? ' active' : ''}`} + onClick={() => onDocTheme(t.id)} + > + <strong>{t.name}</strong> + <span>{t.description}</span> + </button> + ))} + </div> + <label className="modal-field"> + <span>当前文档临时主题{hasTab ? '' : '(未打开文档)'}</span> + <select + className="input" + disabled={!hasTab} + value={override ?? ''} + onChange={(e) => onOverride(e.target.value === '' ? null : e.target.value)} + > + <option value="">跟随全局主题</option> + {DOC_THEMES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)} + </select> + </label> + <label className="modal-field"> + <span>自定义 CSS(即时生效;建议以 .milkdown 开头限定作用范围)</span> + <textarea + className="input css-input" + rows={6} + spellCheck={false} + value={customCss} + placeholder={'.milkdown .ProseMirror h1 { color: #b3261e; }'} + onChange={(e) => onCustomCss(e.target.value)} + /> + </label> + <div className="setting-row"> + <label> + <input + type="checkbox" + checked={autosave} + onChange={(e) => onAutosave({ autosave: e.target.checked })} + /> + 自动保存(仅已落盘文档) + </label> + <label> + 间隔 + <input + className="input seconds-input" + type="number" + min={10} + max={600} + value={autosaveSeconds} + onChange={(e) => onAutosave({ autosaveSeconds: Math.min(600, Math.max(10, Number(e.target.value) || 30)) })} + /> + 秒 + </label> + </div> + <div className="modal-actions"> + <button className="primary" onClick={onClose}>完成</button> + </div> + </div> + </div> + ) +} + +/** 输出(阶段 5):导出 HTML(两种图片模式)、复制为 HTML、打印/导出 PDF。 */ +export function ExportModal({ title, onExportHtml, onCopyHtml, onPrint, onClose }: { + title: string + onExportHtml: (imageMode: 'relative' | 'embed') => void + onCopyHtml: () => void + onPrint: () => void + onClose: () => void +}) { + return ( + <div className="modal-backdrop"> + <div className="modal"> + <h3>导出《{title}》</h3> + <div className="modal-actions column left"> + <button onClick={() => onExportHtml('relative')}>导出 HTML(图片保持相对路径)</button> + <button onClick={() => onExportHtml('embed')}>导出 HTML(图片内嵌,单文件)</button> + <button onClick={onCopyHtml}>复制为 HTML(粘贴到公众号/网页编辑器)</button> + <button onClick={onPrint}>打印 / 导出 PDF…</button> + </div> + <p className="modal-note">导出 HTML 为独立文件:公式以 KaTeX 样式呈现(样式表走 CDN,离线时公式退化为 MathML 显示),Mermaid 图表渲染为 SVG,代码块带语法高亮。</p> + <div className="modal-actions"> + <button onClick={onClose}>取消</button> + </div> + </div> + </div> + ) +} diff --git a/src/services/exportHtml.ts b/src/services/exportHtml.ts new file mode 100644 index 0000000..b14eb3d --- /dev/null +++ b/src/services/exportHtml.ts @@ -0,0 +1,230 @@ +// Markdown → 独立 HTML 导出(阶段 5)。 +// 管线:remark(gfm + math)→ rehype → rehype-katex 公式渲染 → Shiki 代码高亮(懒加载)→ +// Mermaid 代码块替换为 SVG(懒加载)→ 图片按模式处理(保持相对路径 / 内嵌 base64)。 +// 本模块只做纯转换;文件选择与落盘在 App 层。 +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import remarkRehype from 'remark-rehype' +import rehypeKatex from 'rehype-katex' +import rehypeStringify from 'rehype-stringify' +import { visit } from 'unist-util-visit' +import type { Root, Element, Text } from 'hast' + +export type ImageMode = 'relative' | 'embed' + +export interface ExportOptions { + /** 文档标题(<title> 与文件名来源) */ + title: string + /** 文档主题 id(决定导出样式变体) */ + themeId: string + imageMode: ImageMode + /** embed 模式下把相对图片地址读成 data URL(读不到时返回 null 保持原样) */ + resolveImage?: (src: string) => Promise<string | null> + /** 是否做 Shiki 高亮(测试可关) */ + highlight?: boolean + /** 是否渲染 Mermaid 为 SVG(测试可关) */ + renderMermaid?: boolean +} + +interface CodeJob { + parent: Element | Root + index: number + lang: string + code: string +} + +/** 给 <pre><code class="language-x"> 做 Shiki 高亮与 Mermaid 替换的 rehype 后处理。 */ +async function postProcess(tree: Root, opts: ExportOptions): Promise<void> { + const jobs: CodeJob[] = [] + const images: { node: Element }[] = [] + visit(tree, 'element', (node: Element, index, parent) => { + if (node.tagName === 'img') images.push({ node }) + if (node.tagName !== 'pre' || index == null || !parent) return + const codeEl = node.children.find( + (c): c is Element => c.type === 'element' && c.tagName === 'code', + ) + if (!codeEl) return + const classes = (codeEl.properties?.className as string[] | undefined) ?? [] + const langClass = classes.find((c) => c.startsWith('language-')) + if (!langClass) return + const lang = langClass.slice('language-'.length) + const code = codeEl.children + .filter((c): c is Text => c.type === 'text') + .map((c) => c.value) + .join('') + jobs.push({ parent: parent as Element | Root, index: (parent as Element).children.indexOf(node), lang, code }) + }) + + // 图片内嵌 + if (opts.imageMode === 'embed' && opts.resolveImage) { + for (const { node } of images) { + const src = node.properties?.src + if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) { + const data = await opts.resolveImage(src) + if (data) node.properties.src = data + } + } + } + + // 代码块替换(从后往前,避免索引漂移) + const mermaidJobs = jobs.filter((j) => j.lang === 'mermaid') + const codeJobs = jobs.filter((j) => j.lang !== 'mermaid') + let highlighter: Awaited<ReturnType<typeof loadHighlighter>> | null = null + if (opts.highlight !== false && codeJobs.length > 0) { + try { + highlighter = await loadHighlighter() + } catch { + highlighter = null // 高亮不可用时保留普通代码块,不阻塞导出 + } + } + let mermaidApi: (typeof import('mermaid'))['default'] | null = null + if (opts.renderMermaid !== false && mermaidJobs.length > 0) { + try { + mermaidApi = (await import('mermaid')).default + mermaidApi.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict' }) + } catch { + mermaidApi = null + } + } + const all = [...jobs].sort((a, b) => b.index - a.index) + for (const job of all) { + let rawHtml: string | null = null + if (job.lang === 'mermaid' && mermaidApi) { + try { + const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}`, job.code) + rawHtml = `<div class="mermaid-diagram">${svg}</div>` + } catch { + rawHtml = null + } + } else if (job.lang !== 'mermaid' && highlighter) { + try { + rawHtml = highlighter.codeToHtml(job.code, { + lang: job.lang, + theme: 'github-light', + }) + } catch { + rawHtml = null // 未知语言保留原样 + } + } + if (rawHtml) { + const rawNode = { type: 'raw', value: rawHtml } as unknown as Element + ;(job.parent as Element).children.splice(job.index, 1, rawNode) + } + } +} + +function hashCode(text: string): number { + let h = 0 + for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0 + return h +} + +let highlighterPromise: Promise<import('shiki').HighlighterCore> | null = null + +const EXPORT_LANGS = [ + 'javascript', 'typescript', 'tsx', 'jsx', 'json', 'html', 'css', 'markdown', + 'bash', 'shell', 'powershell', 'python', 'java', 'go', 'rust', 'c', 'cpp', + 'csharp', 'sql', 'yaml', 'xml', 'diff', 'ini', +] as const + +/** + * Shiki 高亮器:用 core + JavaScript 正则引擎(不用 Oniguruma WASM—— + * WASM 加载依赖 fetch/stream,在部分 WebView/测试环境会失败,JS 引擎 everywhere 可用)。 + */ +function loadHighlighter(): Promise<import('shiki').HighlighterCore> { + if (!highlighterPromise) { + highlighterPromise = (async () => { + const { createHighlighterCore } = await import('shiki/core') + const { createJavaScriptRegexEngine } = await import('shiki/engine/javascript') + const theme = (await import('@shikijs/themes/github-light')).default + const langs = await Promise.all( + EXPORT_LANGS.map(async (name) => (await import(`@shikijs/langs/${name}`)).default), + ) + return createHighlighterCore({ + themes: [theme], + langs, + engine: createJavaScriptRegexEngine(), + }) + })() + } + return highlighterPromise +} + +/** 导出用样式:与应用内文档主题同源的精简版(按 themeId 给纸面/字体变体)。 */ +export function exportCss(themeId: string): string { + const base = ` +body { margin: 0; background: #fff; } +main { max-width: 820px; margin: 0 auto; padding: 42px 26px 70px; font-family: var(--f); color: var(--ink); line-height: 1.75; font-size: 16.5px; } +h1 { font-size: 1.9em; border-bottom: 1px solid var(--hb); padding-bottom: 8px; line-height: 1.3; } +h2 { font-size: 1.5em; } h3 { font-size: 1.25em; } +a { color: var(--link); } +code { background: var(--cb); border-radius: 6px; padding: 2px 5px; font-family: "Cascadia Code", Consolas, monospace; font-size: 0.88em; } +pre { background: #f6f8fa; border-radius: 8px; padding: 14px 16px; overflow-x: auto; } +pre code { background: transparent; padding: 0; } +blockquote { border-left: 3px solid var(--hb); margin-left: 0; padding-left: 14px; color: #667085; } +table { border-collapse: collapse; margin: 12px 0; } +th, td { border: 1px solid #d8dee4; padding: 6px 12px; } +img { max-width: 100%; } +.mermaid-diagram { text-align: center; } +.mermaid-diagram svg { max-width: 100%; height: auto; } +` + const variants: Record<string, string> = { + default: `:root{--f:"Segoe UI","Microsoft YaHei UI",sans-serif;--ink:#1d2433;--link:#43536a;--cb:#eef1f5;--hb:#dde2ea;}`, + github: `:root{--f:-apple-system,"Segoe UI",sans-serif;--ink:#1f2328;--link:#0969da;--cb:#eff1f3;--hb:#d8dee4;}`, + notion: `:root{--f:ui-sans-serif,"Segoe UI",sans-serif;--ink:#37352f;--link:#37352f;--cb:#f1f1ef;--hb:#e9e9e7;} body{background:#fbfbfa;}`, + 'simple-blue': `:root{--f:"Segoe UI",sans-serif;--ink:#1d2433;--link:#2563eb;--cb:#eef3fd;--hb:#dbe7fb;} h1,h2{color:#1d4ed8;}`, + 'dark-eye': `:root{--f:"Segoe UI",sans-serif;--ink:#c9d1c0;--link:#9ec49a;--cb:#2a322a;--hb:#39412f;} body{background:#20261f;} pre{background:#262d24;}`, + focus: `:root{--f:Georgia,"Songti SC",SimSun,serif;--ink:#26221c;--link:#6b4f2a;--cb:#f3efe6;--hb:transparent;} main{max-width:720px;line-height:1.95;}`, + paper: `:root{--f:Georgia,"Songti SC",SimSun,serif;--ink:#433422;--link:#8a5a2b;--cb:#efe6d0;--hb:#e0d3ae;} body{background:#f7f1e3;}`, + } + return (variants[themeId] ?? variants.default) + base +} + +export async function markdownToExportHtml(markdown: string, opts: ExportOptions): Promise<string> { + const processor = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMath) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeKatex) + const tree = (await processor.run(processor.parse(markdown))) as unknown as Root + await postProcess(tree, opts) + const body = await unified() + .use(rehypeStringify, { allowDangerousHtml: true }) + .stringify(tree as never) + const katexCssLink = '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">' + return [ + '<!DOCTYPE html>', + '<html lang="zh-CN">', + '<head>', + '<meta charset="utf-8">', + `<title>${escapeHtml(opts.title)}`, + katexCssLink, + ``, + '', + '', + `
${String(body)}
`, + '', + '', + ].join('\n') +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +} + +/** HTML → Markdown(导入用,懒加载 turndown)。 */ +export async function htmlToMarkdown(html: string): Promise { + const TurndownService = (await import('turndown')).default + const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' }) + return service.turndown(html) +} + +/** DOCX → Markdown(mammoth 先转 HTML 再走 turndown,懒加载)。 */ +export async function docxToMarkdown(arrayBuffer: ArrayBuffer): Promise { + const mammoth = await import('mammoth') + const result = await mammoth.convertToHtml({ arrayBuffer }) + return htmlToMarkdown(result.value) +} diff --git a/src/services/platform.ts b/src/services/platform.ts index 3970b87..88019c7 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -130,8 +130,8 @@ export function pickAndReadWebFile(): Promise<{ name: string; content: string } } /** Web 降级:把内容作为下载交付(等价于另存为)。 */ -export function downloadTextFile(name: string, content: string): void { - const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }) +export function downloadTextFile(name: string, content: string, mime = 'text/markdown;charset=utf-8'): void { + const blob = new Blob([content], { type: mime }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url @@ -224,3 +224,53 @@ export async function dbRecoverySave(entry: RecoveryEntry): Promise { export async function dbRecoveryDelete(docKey: string): Promise { return invoke('db_recovery_delete', { docKey }) } + +/** 导出 HTML 时另存路径选择。 */ +export async function pickSaveHtmlFile(defaultName: string): Promise { + if (!isTauri()) return null + const { save } = await import('@tauri-apps/plugin-dialog') + const picked = await save({ defaultPath: defaultName, filters: [{ name: 'HTML', extensions: ['html', 'htm'] }] }) + return typeof picked === 'string' ? picked : null +} + +/** 导入文件选择(HTML / Word)。 */ +export async function pickImportFile(): Promise { + if (!isTauri()) return null + const { open } = await import('@tauri-apps/plugin-dialog') + const picked = await open({ + multiple: false, directory: false, + filters: [{ name: '可导入文件', extensions: ['html', 'htm', 'docx'] }], + }) + return typeof picked === 'string' ? picked : null +} + +/** 读文件并返回 base64(导出图片内嵌用)。 */ +export async function readFileBase64(path: string): Promise { + return invoke('read_file_base64', { path }) +} + +/** 读二进制文件(DOCX 导入用,Web 降级不可用时抛错)。 */ +export async function readBinaryFile(path: string): Promise { + const base64 = await readFileBase64(path) + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +/** Web 降级:选 HTML/DOCX 文件并读入(导入用)。 */ +export function pickAndReadWebImport(): Promise<{ name: string; text?: string; buffer?: ArrayBuffer } | null> { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.html,.htm,.docx' + input.onchange = async () => { + const file = input.files?.[0] + if (!file) return resolve(null) + if (/\.docx$/i.test(file.name)) resolve({ name: file.name, buffer: await file.arrayBuffer() }) + else resolve({ name: file.name, text: await file.text() }) + } + input.oncancel = () => resolve(null) + input.click() + }) +} diff --git a/src/styles.css b/src/styles.css index 344f0bf..15345f1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -160,3 +160,109 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius .tree-context-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; } .tree-context-menu button:hover { background: var(--accent-weak); } .tree-context-menu button.danger { color: #b3261e; } + +/* ——— 阶段 5:文档主题(变量层,[data-doc-theme] 挂在 html 上) ——— */ +:root { + --doc-font: "Segoe UI", "Microsoft YaHei UI", sans-serif; + --doc-h-font: var(--doc-font); + --doc-lh: 1.78; + --doc-link: var(--accent); + --doc-quote-border: var(--border); + --doc-quote-color: var(--text-dim); + --doc-paper: var(--bg-editor); + --doc-ink: var(--text); + --doc-code-bg: var(--code-bg); + --doc-h-border: var(--border); +} +:root[data-doc-theme='github'] { + --doc-font: -apple-system, "Segoe UI", "Microsoft YaHei UI", sans-serif; + --doc-lh: 1.62; + --doc-link: #0969da; + --doc-code-bg: #f6f8fa; + --doc-h-border: #d8dee4; +} +:root[data-doc-theme='notion'] { + --doc-font: ui-sans-serif, "Segoe UI", "Microsoft YaHei UI", sans-serif; + --doc-lh: 1.66; + --doc-paper: #fbfbfa; + --doc-quote-border: #e3e2de; + --doc-code-bg: #f7f6f3; + --doc-h-border: transparent; +} +:root[data-doc-theme='simple-blue'] { + --doc-lh: 1.74; + --doc-link: #2563eb; + --doc-h-border: #dbe7fb; + --doc-quote-border: #bcd2f7; +} +:root[data-doc-theme='dark-eye'] { + --doc-lh: 1.8; + --doc-paper: #20261f; + --doc-ink: #c9d1c0; + --doc-link: #9ec49a; + --doc-code-bg: #2a322a; + --doc-quote-border: #43503f; + --doc-quote-color: #9aa78f; + --doc-h-border: #39412f; +} +:root[data-doc-theme='focus'] { + --doc-font: Georgia, "Times New Roman", "Songti SC", SimSun, serif; + --doc-h-font: Georgia, "Songti SC", SimSun, serif; + --doc-lh: 1.95; + --doc-h-border: transparent; +} +:root[data-doc-theme='paper'] { + --doc-font: Georgia, "Songti SC", SimSun, serif; + --doc-lh: 1.85; + --doc-paper: #f7f1e3; + --doc-ink: #433422; + --doc-link: #8a5a2b; + --doc-code-bg: #efe6d0; + --doc-quote-border: #d9c9a3; + --doc-quote-color: #7a6748; + --doc-h-border: #e0d3ae; +} +.milkdown .ProseMirror { + font-family: var(--doc-font); + line-height: var(--doc-lh); + color: var(--doc-ink); +} +.editor-scroll { background: var(--doc-paper); } +.milkdown .ProseMirror h1, .milkdown .ProseMirror h2, .milkdown .ProseMirror h3, +.milkdown .ProseMirror h4, .milkdown .ProseMirror h5, .milkdown .ProseMirror h6 { + font-family: var(--doc-h-font); + border-bottom-color: var(--doc-h-border); +} +.milkdown .ProseMirror a { color: var(--doc-link); } +.milkdown .ProseMirror code { background: var(--doc-code-bg); } +.milkdown .ProseMirror blockquote { border-left-color: var(--doc-quote-border); color: var(--doc-quote-color); } +:root[data-doc-theme='github'] .milkdown .ProseMirror h1 { font-size: 1.9em; } +:root[data-doc-theme='github'] .milkdown .ProseMirror h2 { border-bottom: 1px solid var(--doc-h-border); padding-bottom: 6px; } +:root[data-doc-theme='notion'] .milkdown .ProseMirror h1 { font-size: 1.65em; } +:root[data-doc-theme='notion'] .milkdown .ProseMirror h2 { font-size: 1.38em; } +:root[data-doc-theme='simple-blue'] .milkdown .ProseMirror h1, +:root[data-doc-theme='simple-blue'] .milkdown .ProseMirror h2 { color: #1d4ed8; } +:root[data-doc-theme='focus'] .editor-pane-wrap, :root[data-doc-theme='focus'] .source-cm { max-width: 760px; margin: 0 auto; } +:root[data-doc-theme='dark-eye'] .milkdown .ProseMirror table th, +:root[data-doc-theme='dark-eye'] .milkdown .ProseMirror table td { border-color: #39412f; } +:root[data-doc-theme='dark-eye'] .mermaid-block, :root[data-doc-theme='paper'] .mermaid-block { border-color: var(--doc-h-border); } + +/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */ +@media print { + .titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu, + .table-toolbar, .tree-context-menu { display: none !important; } + .app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; } + .editor-scroll { background: #fff !important; } + .milkdown .ProseMirror { font-size: 11.5pt; } + @page { margin: 18mm 16mm; } +} +.setting-label { font-size: 13px; font-weight: 600; margin: 6px 0 8px; } +.theme-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; } +.theme-card { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; text-align: left; border: 1px solid var(--border); background: var(--bg); border-radius: 8px; padding: 9px 11px; cursor: pointer; } +.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.theme-card span { color: var(--text-dim); font-size: 12px; line-height: 1.45; } +.css-input { font-family: "Cascadia Code", Consolas, monospace; font-size: 12.5px; resize: vertical; } +.setting-row { display: flex; gap: 22px; align-items: center; margin-top: 12px; font-size: 13px; } +.seconds-input { width: 72px; margin: 0 6px; } +.modal-actions.left { align-items: stretch; } +.modal-actions.left button { text-align: left; } diff --git a/src/themes.ts b/src/themes.ts new file mode 100644 index 0000000..f77164e --- /dev/null +++ b/src/themes.ts @@ -0,0 +1,23 @@ +// 文档主题注册表(阶段 5):主题只影响编辑区排版(字体/字号/行距/标题/链接/引用/代码底色/纸面色), +// 具体数值在 styles.css 里以 [data-doc-theme=''] 变量与规则落地;UI 框架的明暗仍由 ui.theme 独立控制。 +export interface DocTheme { + id: string + name: string + description: string + /** 该主题自带深色纸面(编辑区强制深色,不随 UI 明暗) */ + darkPaper?: boolean +} + +export const DOC_THEMES: DocTheme[] = [ + { id: 'default', name: '默认', description: '石板中性、适度行距,当前默认排版' }, + { id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接' }, + { id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔' }, + { id: 'simple-blue', name: '简约蓝', description: '蓝色标题与链接,清爽的轻量排版' }, + { id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', darkPaper: true }, + { id: 'focus', name: '专注写作', description: '衬线正文、加宽行距、窄栏,适合长文写作' }, + { id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感' }, +] + +export function docThemeById(id: string): DocTheme { + return DOC_THEMES.find((t) => t.id === id) ?? DOC_THEMES[0] +} diff --git a/tests/stage5.test.ts b/tests/stage5.test.ts new file mode 100644 index 0000000..686b3c9 --- /dev/null +++ b/tests/stage5.test.ts @@ -0,0 +1,54 @@ +// 阶段 5 验收:主题注册表完整性、导出 HTML 管线(结构/表格/公式/代码)、HTML 导入转换。 +import { describe, it, expect } from 'vitest' +import { DOC_THEMES, docThemeById } from '../src/themes' +import { markdownToExportHtml, htmlToMarkdown, exportCss } from '../src/services/exportHtml' + +describe('文档主题注册表', () => { + it('7 套主题 id 唯一且可查', () => { + expect(DOC_THEMES).toHaveLength(7) + expect(new Set(DOC_THEMES.map((t) => t.id)).size).toBe(7) + expect(docThemeById('paper').name).toBe('经典纸张') + expect(docThemeById('不存在').id).toBe('default') + for (const t of DOC_THEMES) expect(exportCss(t.id)).toContain('--f:') + }) +}) + +describe('导出 HTML 管线', () => { + it('标题/表格/行内公式/代码块都转成对应 HTML(关高亮与 Mermaid 时走纯管线)', async () => { + const md = '# 导出标题\n\n正文含 $E = mc^2$ 公式。\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\n```ts\nconst v = 1;\n```\n' + const html = await markdownToExportHtml(md, { + title: '导出标题', themeId: 'github', imageMode: 'relative', highlight: false, renderMermaid: false, + }) + expect(html).toContain('') + expect(html).toContain('

导出标题

') + expect(html).toContain('') + expect(html).toContain('katex') // 公式经 rehype-katex 渲染(含 MathML) + expect(html).toContain('const v = 1;') + expect(html).toContain('导出标题') + }) + + it('Shiki 高亮开启时代码块带着色 span', async () => { + const html = await markdownToExportHtml('```ts\nconst v: number = 42;\n```\n', { + title: 't', themeId: 'default', imageMode: 'relative', renderMermaid: false, + }) + expect(html).toContain('style="color:') + expect(html).toContain('42') + }) + + it('图片内嵌模式用 resolveImage 替换为 data URL', async () => { + const html = await markdownToExportHtml('![示意图](文章.assets/a.png)\n', { + title: 't', themeId: 'default', imageMode: 'embed', highlight: false, renderMermaid: false, + resolveImage: async (src) => (src.endsWith('a.png') ? 'data:image/png;base64,QUJD' : null), + }) + expect(html).toContain('src="data:image/png;base64,QUJD"') + }) +}) + +describe('导入转换', () => { + it('HTML 转 Markdown 保留标题与列表', async () => { + const md = await htmlToMarkdown('

标题

  • 第一项
  • 第二项

正文加粗

') + expect(md).toContain('# 标题') + expect(md).toMatch(/-\s+第一项/) + expect(md).toContain('**加粗**') + }) +})