diff --git a/docs/README.md b/docs/README.md index 803e82c6..4c78ce6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,11 +15,13 @@ This directory contains repository-level documentation for maintainers and contr ## Slide Export Documentation - [Slidev Standalone Acceptance, 2026-06-18](./maintainer/slidev-standalone-acceptance-2026-06-18.md) - Real `architecture.zh-CN.md` strict native standalone acceptance +- [Slidev Editable PPTX Acceptance, 2026-06-21](./maintainer/slidev-editable-pptx-acceptance-2026-06-21.md) - Real `architecture.zh-CN.md` editable-text PPTX acceptance - [Slidev Export Workflow Verification](./maintainer/slidev-export-workflow.md) - Maintainer gate for UI-equivalent export workflow - [Standalone Bundle Fix](./STANDALONE_BUNDLE_FIX.md) - Earlier export transformation bug fix (2026-06-16) - [Single-File Bundler](./SINGLE_FILE_BUNDLER.md) - Architecture and implementation overview - [Slidev Solution Summary](./SLIDEV_SOLUTION.md) - Current standalone-first export truth - [Slidev HTML Fix](./SLIDEV_HTML_FIX.md) - Original problem analysis and testing +- [Slidev Editable PPTX Progress](./brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.md) - Reference-project comparison and next implementation direction ## Engineering Planning Docs diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 83574031..3613b2de 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -15,9 +15,11 @@ ## Slide Export 文档 - [Slidev Standalone 验收记录,2026-06-18](./maintainer/slidev-standalone-acceptance-2026-06-18.zh-CN.md) - 真实 `architecture.zh-CN.md` strict native standalone 验收 +- [Slidev 可编辑 PPTX 验收记录,2026-06-21](./maintainer/slidev-editable-pptx-acceptance-2026-06-21.zh-CN.md) - 真实 `architecture.zh-CN.md` 可编辑文本 PPTX 验收 - [Slidev 导出工作流验证](./maintainer/slidev-export-workflow.zh-CN.md) - UI 等价导出工作流的维护者 gate - [Slidev Solution Summary](./SLIDEV_SOLUTION.md) - 当前 standalone 优先、server-script 兼容的方案真值 - [Slidev HTML Fix](./SLIDEV_HTML_FIX.md) - 原始问题分析与测试记录 +- [Slidev 可编辑 PPTX 推进记录](./brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.zh-CN.md) - 参考项目对比与后续实现方向 ## 工程规划文档 diff --git a/docs/SLIDEV_SOLUTION.md b/docs/SLIDEV_SOLUTION.md index af6bf246..c969acbd 100644 --- a/docs/SLIDEV_SOLUTION.md +++ b/docs/SLIDEV_SOLUTION.md @@ -16,7 +16,7 @@ The maintained workflow is: 6. HTML output directories are recreated before build to avoid stale assets. 7. HTML export attempts native standalone first, records the actual HTML mode, and falls back to server-script-compatible HTML only when native standalone sanity checks find real missing slide loader bindings. 8. Browser rendering is verified with Playwright across the full deck by default. -9. When the Playwright runtime is available, `exportSlidesCommand()` and the maintainer verifier both run the same `convergeSlidevDeckLayout()` loop before final `HTML`/`PDF`/`PNG`/`MP4` export. +9. When the Playwright runtime is available, `exportSlidesCommand()` and the maintainer verifier both run the same `convergeSlidevDeckLayout()` loop before final `HTML`/`PDF`/`PNG`/`PPTX`/`MP4` export. The canonical maintainer workflow is documented in: @@ -115,6 +115,46 @@ npm install -D https://github.com/Jacobinwwey/slidev/releases/download/notemd-st Validation on 2026-06-21 proved that the release asset packs as `@slidev/cli@52.16.0`, installs into a clean npm project, exposes the `slidev` binary, and includes `build --help` support for `--standalone-bundle`. +## Editable PPTX Export + +NoteMD now supports `pptx` as an export format in addition to HTML, PDF, PNG, and MP4. + +The PPTX path is intentionally not a screenshot-only export. It follows the stronger `oh-my-ppt` architecture shape instead of the `huashu-design` source-template-only shape: + +1. converge the Slidev deck through the same rendered HTML workflow used by normal export; +2. open the final HTML in Playwright; +3. navigate each Slidev route (`#/1`, `#/2`, ...); +4. extract visible text boxes from the rendered DOM as editable PowerPoint text frames; +5. capture each rendered slide as a visual fallback image layer for complex CSS, Mermaid, SVG, canvas, icons, and layout effects; +6. write a clean-room PresentationML `.pptx` package with `fflate`; +7. write a sidecar `.pptx.report.json` with editability metrics. + +This is not the same as running arbitrary visual HTML through a strict HTML-to-PPTX template converter. The `huashu-design` approach is valuable when the source HTML is authored under PPTX constraints from the start: text must live in paragraph/headline tags, backgrounds must be on container elements, gradients and `background-image` are constrained, and dimensions must match the PowerPoint layout. Slidev output is a SPA with Vue, transforms, Mermaid SVG, and complex CSS, so forcing that route onto generated Slidev HTML would be brittle. NoteMD therefore uses a browser-observed extraction path and reports the parts that remain image fallback. + +Expected PPTX outputs: + +```text +docs/export/.pptx +docs/export/.pptx.report.json +``` + +Maintainer command: + +```bash +npm run verify:slidev-export -- --format pptx --source architecture.zh-CN.md --sample-slides all --timeout-ms 240000 --no-screenshots --json +``` + +PPTX pass conditions add: + +1. `environment.capabilities.pptx = true` +2. `pptxInspection.isZip = true` +3. `pptxInspection.slideCount` matches the rendered deck slide count +4. `pptxInspection.textRunCount > 0` +5. `pptxInspection.slidesWithoutEditableText = []` for decks that contain text on every slide +6. the sidecar report has `editableTextSlideCount`, `textBoxCount`, `imageFallbackCount`, and `pagesWithoutEditableText` + +The current implementation deliberately treats Mermaid/SVG/canvas and complex decoration as image fallback. That preserves source Mermaid content and visual fidelity, but those graphic parts are not editable PowerPoint shapes. The user-facing contract is therefore "editable text over a visual fallback layer", not "perfectly editable reconstruction of every Slidev/Vue/CSS object". + ## Real Verification Command Run: @@ -162,8 +202,10 @@ Real maintained baseline as of 2026-06-21: 15. the local Slidev fork's standalone bundler now preserves first-slide loader bindings when stubbing Vite preload helpers; NoteMD's strict standalone gate remains fail-closed and continues to report real `loaderGaps` 16. HTML export syncs the prepared deck's explicit local file references into the final `-slides/` output directory, and prepared decks default to `fonts.provider: none` unless the source already declares top-level `fonts:` 17. the environment-check UI links to the fork release page and copies an `npm install -D @slidev/theme-default` command; it must not suggest branch source links or generic official `@slidev/cli` installs for the standalone-required NoteMD path +18. editable PPTX export runs after the same rendered convergence gate, writes a PresentationML package with real `` text nodes, and emits a JSON report instead of pretending the whole deck is natively editable +19. the real `architecture.zh-CN.md` PPTX run on 2026-06-21 returned `ok = true`, produced `27` slides, `236` slide XML text runs, `27` visual fallback images, and no slides without editable text -Dedicated standalone acceptance evidence is tracked in `docs/maintainer/slidev-standalone-acceptance-2026-06-18.*`. The latest real Stage 15 acceptance archive is `/home/jacob/slidev-export-review/2026-06-21-stage15-final-rerun/`; generated HTML, screenshots, and install-smoke scratch files remain outside the repo so the main branch does not gain one-off export artifacts. +Dedicated standalone acceptance evidence is tracked in `docs/maintainer/slidev-standalone-acceptance-2026-06-18.*`. Editable PPTX acceptance evidence is tracked in `docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.*`. The latest real Stage 15 acceptance archive is `/home/jacob/slidev-export-review/2026-06-21-stage15-final-rerun/`; the current real editable PPTX archive is `/home/jacob/slidev-export-review/2026-06-21-editable-pptx-real/`. Generated HTML, screenshots, PPTX files, and install-smoke scratch files remain outside the commit so the main branch does not gain one-off export artifacts. ## Current Rendered Layout Model @@ -256,7 +298,8 @@ Current gap: 1. richer custom/component-heavy Slidev layouts beyond the current supported structural set still fall back to conservative/manual-review behavior, especially when there is no stable owner, the component/prose order is ambiguous, or a single non-Mermaid component surface cannot be structurally split or transformed within the font floor; component/table/directive/fence/image boundaries now have explicit blocking coverage; 2. standalone export now has a strict native gate and the real architecture fixture passes it, but correctness still depends on post-build sanity detection; server-script fallback remains a compatibility lane for future bad bundles, not evidence that native standalone passed; 3. full-deck Playwright verification is now more correct, but noticeably slower, so future work should improve patch convergence instead of weakening the audit back to representative sampling; -4. the Obsidian CLI can dispatch `notemd:export-slides`, but it does not expose an export-complete handshake, so host-command smoke is still weaker than the maintainer verifier. +4. the Obsidian CLI can dispatch `notemd:export-slides`, but it does not expose an export-complete handshake, so host-command smoke is still weaker than the maintainer verifier; +5. editable PPTX is currently a pragmatic extraction layer: text is editable, visual fidelity is preserved by slide-level image fallback, but tables, Mermaid, SVG, canvas, and Vue component internals are not yet reconstructed as editable Office-native objects. ## Next-Level Layout Quality Direction diff --git a/docs/SLIDEV_SOLUTION.zh-CN.md b/docs/SLIDEV_SOLUTION.zh-CN.md index 2b5bba01..5fe7d54f 100644 --- a/docs/SLIDEV_SOLUTION.zh-CN.md +++ b/docs/SLIDEV_SOLUTION.zh-CN.md @@ -14,7 +14,7 @@ NoteMD 不再把直接运行 `slidev build` 视为 UI 导出按钮已经可用 6. 每次构建前重建输出目录,避免旧 chunk 或旧 deck 污染结果。 7. HTML 默认尝试原生 standalone;当 sanity check 发现 loader binding 缺失时,才进入 server-script 兼容 fallback。 8. Playwright 默认审计完整 deck,而不是只抽样几页。 -9. 产品导出路径和维护者 verifier 共享 `convergeSlidevDeckLayout()` 渲染收敛循环。 +9. 产品导出路径和维护者 verifier 共享 `convergeSlidevDeckLayout()` 渲染收敛循环,再进入最终 `HTML` / `PDF` / `PNG` / `PPTX` / `MP4` 导出。 维护者工作流见: @@ -138,6 +138,46 @@ npm install -D https://github.com/Jacobinwwey/slidev/releases/download/notemd-st 2026-06-21 的实机烟测已经证明该 release asset 会被 npm 识别为 `@slidev/cli@52.16.0`,能安装到干净 npm 项目,暴露 `slidev` binary,且 `slidev build --help` 包含 `--standalone-bundle`。 +## 可编辑 PPTX 导出 + +NoteMD 现在新增 `pptx` 导出格式。这里的 PPTX 不是把每页截图塞进 PowerPoint 的假导出,而是“可编辑文本 + 视觉 fallback 图层”的 PresentationML 输出。 + +本实现更接近 `oh-my-ppt` 的浏览器反抽取路线,而不是直接采用 `huashu-design` 的源 HTML 模板约束路线: + +1. 先让 Slidev deck 经过现有 rendered layout convergence; +2. 用 Playwright 打开最终 HTML; +3. 逐页访问 Slidev route(`#/1`、`#/2` 等); +4. 从当前可见 slide root 抽取真实可见文本框,并写成 PowerPoint 可编辑 text frame; +5. 将整页渲染结果作为视觉 fallback 图片层,用来保留复杂 CSS、Mermaid、SVG、canvas、icon 和 layout 效果; +6. 使用 clean-room PresentationML writer 与 `fflate` 生成 `.pptx`; +7. 额外写出 `.pptx.report.json`,明确记录可编辑覆盖率和 fallback 数量。 + +`huashu-design` 的 `HTML Slides -> 可编辑 PPTX` 方案对“从第一行就按 PPTX 约束写 HTML”的项目很有价值:文本必须用段落/标题标签承载,背景应在容器上,渐变和 `background-image` 受限,画布尺寸要匹配 PowerPoint layout。但 Slidev 输出是 Vue SPA,含 transforms、Mermaid SVG、复杂 CSS 和组件状态;把这类 HTML 事后强行套入严格模板转换,稳定性很差。NoteMD 因此采用浏览器观测后的抽取路线,并用报告说明哪些内容仍是图片 fallback。 + +预期输出: + +```text +docs/export/.pptx +docs/export/.pptx.report.json +``` + +维护者命令: + +```bash +npm run verify:slidev-export -- --format pptx --source architecture.zh-CN.md --sample-slides all --timeout-ms 240000 --no-screenshots --json +``` + +PPTX 额外通过条件: + +1. `environment.capabilities.pptx = true` +2. `pptxInspection.isZip = true` +3. `pptxInspection.slideCount` 与渲染 deck 页数一致 +4. `pptxInspection.textRunCount > 0` +5. 对每页都有文本的 deck,`pptxInspection.slidesWithoutEditableText = []` +6. sidecar report 包含 `editableTextSlideCount`、`textBoxCount`、`imageFallbackCount` 与 `pagesWithoutEditableText` + +当前实现有意把 Mermaid/SVG/canvas 和复杂装饰保留为图片 fallback。这能保持源 Mermaid 内容不被改写,也能最大化视觉一致性,但这些图形部分不是 PowerPoint 原生可编辑对象。因此用户合同应写清楚:这是“可编辑文本叠加在视觉 fallback 层上”,不是“把每个 Slidev/Vue/CSS 对象完美重建为 Office 原生可编辑元素”。 + ## 真实验证命令 默认真实源文件: @@ -174,6 +214,7 @@ npm run verify:slidev-export 4. `mermaidSourcePreservation.passed = true`,源文件和导出 deck 均为 `3` 个 Mermaid fence,`changedFenceIndexes = []`。 5. HTML 输出为 native standalone,`actualMode = "standalone"`,`requiresLocalServer = false`,`standaloneAttempt.accepted = true`。 6. 环境检测 UI 链接到 fork release 页面,并复制 `npm install -D @slidev/theme-default`;不能把 branch 源码链接或官方通用 `@slidev/cli` 安装当作 NoteMD standalone 必需路径。 +7. `docs/architecture.zh-CN.md` 的真实 PPTX 验证返回 `ok: true`,输出 27 页、236 个 slide XML `` 文本节点、27 个视觉 fallback 图片,且没有缺少可编辑文本的页面。 ## 当前渲染收敛模型 @@ -238,6 +279,8 @@ Stage 13/14 把这类“不应自动修”的边界纳入生产 fixture runner 20. Stage 14 真实 `architecture.zh-CN.md` strict standalone 输出归档到 `/home/jacob/slidev-export-review/2026-06-20-stage14-real/`;其中 `architecture.zh-CN.stage14.slidev.md` 是可直接审查的输出 deck,`architecture.zh-CN-slides/index-standalone.html` 是 native standalone 输出。报告为 `ok = true`,使用本地 Slidev fork 与 52 个 skill references,3 个 Mermaid fence 均保持 `changedFenceIndexes = []`,`hardOverflowCount = 0`,`lowEffectiveFontCount = 0`。 21. Stage 15 真实 `architecture.zh-CN.md` strict standalone 输出归档到 `/home/jacob/slidev-export-review/2026-06-21-stage15-final-rerun/`;其中 `architecture.stage15.slidev.zh-CN.md` 是可直接审查的输出 deck,`architecture.zh-CN-slides/index-standalone.html` 是 native standalone 输出。报告为 `ok = true`,使用本地 Slidev fork 与 52 个 skill references,3 个 Mermaid fence 均保持 `changedFenceIndexes = []`,`hardOverflowCount = 0`,`lowEffectiveFontCount = 0`,且 native standalone 被接受。repo 内保留可审查的 Markdown deck:`docs/slidev/architecture.stage15.slidev.zh-CN.md`;生成 HTML/assets/screenshots 不进入提交。 22. Slidev fork release asset `slidev-cli-notemd-standalone-v52.16.0-1.tgz` 已在 GitHub release `notemd-standalone-v52.16.0-1` 下发布,并通过 `npm pack --dry-run` 与干净 npm 项目安装烟测;这是 UI 安装命令的稳定分发边界。 +23. 可编辑 PPTX 导出已经接入 UI 格式选择、环境检测、产品导出命令和维护者 verifier;该路径复用渲染收敛后的 HTML,再生成 PresentationML `.pptx` 和可编辑性报告。 +24. 2026-06-21 的真实 PPTX 验收归档在 `/home/jacob/slidev-export-review/2026-06-21-editable-pptx-real/`,包含 `architecture.zh-CN.pptx`、`architecture.zh-CN.pptx.report.json`、`architecture.zh-CN.slidev.md`、standalone HTML 目录与 `acceptance-summary.json`。 当前仍存在的边界: @@ -246,6 +289,7 @@ Stage 13/14 把这类“不应自动修”的边界纳入生产 fixture runner 3. 全 deck Playwright 审计更正确但更慢,后续应优化收敛效率,而不是削弱审计范围。 4. Obsidian CLI 可以派发 `notemd:export-slides`,但缺少导出完成握手,所以宿主命令烟测弱于 verifier。 5. Mermaid `manual-review` 是源图保持约束下的透明证据,不是 hard gate failure,也不是自动拆图许可。 +6. PPTX 当前是务实的可编辑文本抽取层:文本可编辑,视觉通过 slide-level image fallback 保真;表格、Mermaid、SVG、canvas 与 Vue component 内部尚未重建成 Office 原生可编辑对象。 ## Next-level 布局质量路线 diff --git a/docs/brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.md b/docs/brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.md new file mode 100644 index 00000000..ae2029a8 --- /dev/null +++ b/docs/brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.md @@ -0,0 +1,94 @@ +# Slidev Editable PPTX Progress And Next Direction, 2026-06-21 + +## Problem Statement + +The requested feature is not "export a `.pptx` file". A PPTX containing one screenshot per slide is easy, but it does not satisfy the editable-PPTX requirement. The useful target is an export path where PowerPoint users can edit slide text directly while still preserving the rendered Slidev visual result. + +## Reference Project Comparison + +`huashu-design` is strong when the source HTML is authored specifically for PPTX conversion. Its constraints are reasonable for that domain: text should be wrapped in paragraph/headline tags, visual containers should own backgrounds and borders, gradients/background images are constrained, and the canvas should match `LAYOUT_WIDE`. That model is not a good direct fit for generated Slidev HTML, because Slidev is a routed Vue SPA with transforms, Mermaid SVG, canvas/SVG/icons, generated CSS, and runtime state. + +`oh-my-ppt` is the better architectural reference for NoteMD because it observes rendered browser state and extracts text, images, tables, formulas, paint order, and fallback imagery before writing OOXML. The key idea worth adopting is not Electron itself, but the pipeline shape: + +1. render the HTML in a real browser; +2. extract editable primitives where confidence is high; +3. rasterize complex primitives where Office-native reconstruction is fragile; +4. write an OOXML package; +5. report warnings and editability coverage. + +NoteMD ports that shape clean-room with Playwright and a small PresentationML writer instead of copying Apache-2.0 source or bringing Electron assumptions into an Obsidian plugin. + +## Landed Implementation + +Current implementation adds: + +1. `pptx` to the export format type, UI selector, settings dropdown, environment capability matrix, and maintainer verifier. +2. `src/slideExport/pptxDomExtractor.ts` to select the current visible Slidev page and extract text boxes from computed DOM geometry. +3. `src/slideExport/pptxExporter.ts` to consume the converged HTML export, iterate `#/1..n`, capture a visual fallback layer, and write a sidecar report. +4. `src/slideExport/pptxWriter.ts` to write a clean-room PresentationML zip using `fflate`. +5. `scripts/verify-slidev-export-workflow.cjs --format pptx` to inspect the resulting PPTX as a zip and prove editable text nodes exist. +6. `src/tests/pptxWriter.test.ts` plus existing UI/environment tests updated for PPTX. + +The implementation deliberately places PPTX export after `convergeSlidevDeckLayout()`. This avoids creating a new un-audited path and keeps PPTX tied to the same rendered fit fixes as HTML/PDF/PNG/MP4. + +## Real Acceptance + +Real command: + +```bash +runuser -u jacob -- env HOME=/home/jacob PLAYWRIGHT_BROWSERS_PATH=/home/jacob/.cache/ms-playwright bash -lc 'cd /home/jacob/obsidian-NotEMD && npm run verify:slidev-export -- --vault docs --source architecture.zh-CN.md --format pptx --sample-slides all --timeout-ms 240000 --no-screenshots --json' +``` + +Result: + +1. `ok = true` +2. `slideCount = 27` +3. `pptxInspection.textRunCount = 236` +4. `pptxInspection.pictureCount = 27` +5. `pptxInspection.slidesWithoutEditableText = []` +6. sidecar `textBoxCount = 223` +7. sidecar `editableTextSlideCount = 27` +8. sidecar `imageFallbackCount = 27` + +Archive: + +```text +/home/jacob/slidev-export-review/2026-06-21-editable-pptx-real/ +``` + +## Release Link Decision + +The environment-check UI must continue pointing users at an npm-installable GitHub release asset: + +```text +https://github.com/Jacobinwwey/slidev/releases/download/notemd-standalone-v52.16.0-1/slidev-cli-notemd-standalone-v52.16.0-1.tgz +``` + +A GitHub branch, tree, or blob URL is the wrong install surface. It is not a stable package boundary, can drift, and can fail under `npm install`. Current live verification found the release tag and asset in `Jacobinwwey/slidev`, and `npm pack --dry-run` resolved it as `@slidev/cli@52.16.0`. + +New Slidev fork work should only update NoteMD's install URL after a new fork release is actually cut and smoke-tested as an npm package. A branch containing PR work is a staging surface, not a user installation surface. + +## Current Limits + +The first implementation is intentionally conservative: + +1. Text is editable. +2. Whole-slide visual fallback preserves complex visuals. +3. Mermaid/SVG/canvas are not converted into Office-native editable vector objects. +4. Tables are currently captured visually, not reconstructed as editable PowerPoint tables. +5. Code blocks are extracted as text when visible DOM text is selected, but syntax-highlighted run fidelity is not yet modeled. +6. Animations and click steps are not represented as PowerPoint animations. + +Those are not regressions; they are explicit boundaries. Overstating editability would be worse than shipping an honest report-driven first slice. + +## Next Direction + +The next level should be incremental and report-driven: + +1. add table extraction only when row/column geometry can be reconstructed confidently; +2. add code-block extraction as a single monospace text frame before attempting per-token styling; +3. add shape extraction for high-confidence solid-color rectangles/lines only; +4. keep Mermaid source untouched and continue using image fallback unless a separate explicit user option requests experimental vector reconstruction; +5. keep `pptxInspection` and sidecar metrics as the acceptance gate. + +Avoid adding a second HTML-to-PPTX route that bypasses rendered convergence. That would create a separate quality gate and make PPTX results drift from the HTML export path that users already rely on. diff --git a/docs/brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.zh-CN.md b/docs/brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.zh-CN.md new file mode 100644 index 00000000..8fbfcb49 --- /dev/null +++ b/docs/brainstorms/2026-06-21-slidev-editable-pptx-progress-and-next-direction.zh-CN.md @@ -0,0 +1,94 @@ +# Slidev 可编辑 PPTX 推进记录与后续方向,2026-06-21 + +## 问题定义 + +本需求不是“生成一个 `.pptx` 文件”。每页一张截图的 PPTX 很容易做,但不满足“可编辑 PPTX”的要求。真正有价值的目标是:PowerPoint 用户能直接编辑 slide 文本,同时 Slidev 渲染出的复杂视觉结果仍然被保留。 + +## 参考项目对比 + +`huashu-design` 适合从源头就按 PPTX 转换约束写 HTML 的场景。它的约束在该领域是合理的:文本应由段落/标题标签承载,背景和边框归容器所有,渐变与 background-image 受限,画布尺寸要匹配 `LAYOUT_WIDE`。但这不适合直接套在生成后的 Slidev HTML 上,因为 Slidev 是 routed Vue SPA,包含 transforms、Mermaid SVG、canvas/SVG/icon、生成 CSS 与运行时状态。 + +`oh-my-ppt` 更适合作为 NoteMD 的架构参考,因为它先观察真实浏览器渲染状态,再抽取 text、image、table、formula、paint order 与 fallback image,最后写 OOXML。值得采用的是 pipeline 形态,而不是 Electron 本身: + +1. 在真实浏览器里渲染 HTML; +2. 对高置信元素抽取可编辑 primitive; +3. 对 Office 原生重建不稳的复杂元素做 raster fallback; +4. 写 OOXML package; +5. 输出 warnings 与 editability coverage。 + +NoteMD 当前采用 clean-room Playwright + 小型 PresentationML writer 实现这条路线,避免直接复制 Apache-2.0 源码,也避免把 Electron 假设带入 Obsidian 插件。 + +## 已落地实现 + +当前实现新增: + +1. 将 `pptx` 加入导出格式类型、UI 选择框、设置页、环境能力矩阵与维护者 verifier。 +2. `src/slideExport/pptxDomExtractor.ts`:选择当前可见的 Slidev 页面,并基于 computed DOM geometry 抽取文本框。 +3. `src/slideExport/pptxExporter.ts`:消费收敛后的 HTML,逐页访问 `#/1..n`,捕获视觉 fallback,并写 sidecar report。 +4. `src/slideExport/pptxWriter.ts`:使用 `fflate` 写 clean-room PresentationML zip。 +5. `scripts/verify-slidev-export-workflow.cjs --format pptx`:把 PPTX 当 zip 解开,验证 slide XML 中存在可编辑文本节点。 +6. 新增 `src/tests/pptxWriter.test.ts`,并更新 UI / environment 相关测试。 + +PPTX 导出被放在 `convergeSlidevDeckLayout()` 之后,这是刻意的。这样不会引入第二条未审计路径,PPTX 与 HTML/PDF/PNG/MP4 共享同一套 rendered fit 修复。 + +## 真实验收 + +真实命令: + +```bash +runuser -u jacob -- env HOME=/home/jacob PLAYWRIGHT_BROWSERS_PATH=/home/jacob/.cache/ms-playwright bash -lc 'cd /home/jacob/obsidian-NotEMD && npm run verify:slidev-export -- --vault docs --source architecture.zh-CN.md --format pptx --sample-slides all --timeout-ms 240000 --no-screenshots --json' +``` + +结果: + +1. `ok = true` +2. `slideCount = 27` +3. `pptxInspection.textRunCount = 236` +4. `pptxInspection.pictureCount = 27` +5. `pptxInspection.slidesWithoutEditableText = []` +6. sidecar `textBoxCount = 223` +7. sidecar `editableTextSlideCount = 27` +8. sidecar `imageFallbackCount = 27` + +归档: + +```text +/home/jacob/slidev-export-review/2026-06-21-editable-pptx-real/ +``` + +## Release 链接决策 + +环境检测 UI 必须继续指向 npm 可安装的 GitHub release asset: + +```text +https://github.com/Jacobinwwey/slidev/releases/download/notemd-standalone-v52.16.0-1/slidev-cli-notemd-standalone-v52.16.0-1.tgz +``` + +GitHub branch、tree 或 blob URL 都不是正确安装面。它们不是稳定 package 边界,会随分支漂移,也可能直接在 `npm install` 下失败。当前实机核验已经确认 `Jacobinwwey/slidev` 中存在该 release tag 和 asset,且 `npm pack --dry-run` 能识别为 `@slidev/cli@52.16.0`。 + +后续 Slidev fork 有新改动时,只有在 fork 仓库真正切出新 release 并完成 npm package 烟测后,NoteMD 才应该更新 UI 安装 URL。包含 PR 工作的分支只是 staging surface,不是用户安装 surface。 + +## 当前边界 + +第一版实现有意保守: + +1. 文本可编辑。 +2. 整页视觉 fallback 保留复杂视觉。 +3. Mermaid/SVG/canvas 不会被转换成 Office 原生可编辑 vector object。 +4. 表格当前视觉保留,尚未重建为 PowerPoint 可编辑表格。 +5. 代码块在 DOM 文本被选中时会以文本方式进入 PPTX,但尚未建模 syntax highlight run fidelity。 +6. 动画和 click steps 尚未转换为 PowerPoint animations。 + +这些不是回归,而是明确边界。把可编辑性夸大成无法验证的承诺,比交付一个诚实的 report-driven 第一版更糟。 + +## 后续方向 + +下一阶段应保持增量、报告驱动: + +1. 只有在 row/column geometry 能可靠重建时才添加 table extraction; +2. 代码块先按单个等宽 text frame 抽取,再考虑逐 token 样式; +3. shape extraction 只从高置信纯色矩形/线条开始; +4. Mermaid 源内容继续不动,默认保持 image fallback;除非未来提供明确 experimental vector reconstruction 选项; +5. 继续用 `pptxInspection` 与 sidecar metrics 做验收门。 + +不要新增一条绕过 rendered convergence 的 HTML-to-PPTX 路线。那会制造第二套质量门,并让 PPTX 结果偏离用户已经依赖的 HTML 导出路径。 diff --git a/docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.md b/docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.md new file mode 100644 index 00000000..d688fe5a --- /dev/null +++ b/docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.md @@ -0,0 +1,83 @@ +# Slidev Editable PPTX Acceptance, 2026-06-21 + +Language: **English** | [简体中文](./slidev-editable-pptx-acceptance-2026-06-21.zh-CN.md) + +This record covers the first real NoteMD `HTML Slides -> editable PPTX` acceptance run against `docs/architecture.zh-CN.md`. + +## Scope + +The accepted contract is not screenshot-only PPTX. It is: + +1. render and converge the Slidev deck through the same HTML workflow used by the UI export path; +2. extract visible rendered text into editable PowerPoint text frames; +3. preserve complex Slidev/Mermaid/CSS visuals through a slide-level image fallback layer; +4. generate a real `.pptx` zip with PresentationML slide XML and `` text nodes; +5. write a sidecar JSON report so editability is measurable rather than implied. + +## Command + +Run as Jacob with Jacob's Playwright browser cache: + +```bash +runuser -u jacob -- env HOME=/home/jacob PLAYWRIGHT_BROWSERS_PATH=/home/jacob/.cache/ms-playwright bash -lc 'cd /home/jacob/obsidian-NotEMD && npm run verify:slidev-export -- --vault docs --source architecture.zh-CN.md --format pptx --sample-slides all --timeout-ms 240000 --no-screenshots --json' +``` + +## Result + +The run returned `ok: true`. + +Key evidence: + +1. `environment.capabilities.pptx = true` +2. `environment.slidev.version = 52.16.0 (/home/jacob/slidev/packages/slidev/bin/slidev.mjs)` +3. `slideSource.skillRootPath = /home/jacob/slidev/skills/slidev` +4. `slideSource.skillReferenceCount = 52` +5. `htmlExport.actualMode = standalone` +6. `htmlExport.requiresLocalServer = false` +7. `mermaidSourcePreservation.passed = true` +8. `layoutAuditSummary.overflowCount = 0` +9. `layoutAuditSummary.lowEffectiveFontCount = 0` +10. `pptxInspection.isZip = true` +11. `pptxInspection.slideCount = 27` +12. `pptxInspection.mediaCount = 27` +13. `pptxInspection.textRunCount = 236` +14. `pptxInspection.pictureCount = 27` +15. `pptxInspection.slidesWithoutEditableText = []` + +The sidecar report at the time of acceptance recorded: + +```json +{ + "slideCount": 27, + "textBoxCount": 223, + "editableTextSlideCount": 27, + "pagesWithoutEditableText": [], + "backgroundImageSlideCount": 27, + "imageFallbackCount": 27, + "warnings": [] +} +``` + +## Output Archive + +Generated files are intentionally not committed to `main`. The inspectable acceptance archive is: + +```text +/home/jacob/slidev-export-review/2026-06-21-editable-pptx-real/ +``` + +Archive contents: + +```text +acceptance-summary.json +architecture.zh-CN.pptx +architecture.zh-CN.pptx.report.json +architecture.zh-CN.slidev.md +architecture.zh-CN-slides/ +``` + +## Interpretation + +This acceptance proves direct PPTX export is wired into the production-equivalent NoteMD workflow and produces editable PowerPoint text. It does not claim every Slidev object is Office-native editable. Mermaid, SVG, canvas, and complex Vue/CSS surfaces are preserved as image fallback by design. + +The next useful upgrades are table reconstruction, code-block text extraction with monospace runs, and selective shape extraction. Those should be gated by the same sidecar report instead of weakening the editable contract into an unmeasured claim. diff --git a/docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.zh-CN.md b/docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.zh-CN.md new file mode 100644 index 00000000..76331683 --- /dev/null +++ b/docs/maintainer/slidev-editable-pptx-acceptance-2026-06-21.zh-CN.md @@ -0,0 +1,83 @@ +# Slidev 可编辑 PPTX 验收记录,2026-06-21 + +语言: [English](./slidev-editable-pptx-acceptance-2026-06-21.md) | **简体中文** + +本文记录首次基于真实 `docs/architecture.zh-CN.md` 的 NoteMD `HTML Slides -> 可编辑 PPTX` 验收。 + +## 范围 + +本次验收的合同不是“截图式 PPTX”。正确合同是: + +1. 先让 Slidev deck 经过与 UI 导出路径一致的 HTML 渲染收敛; +2. 把真实可见文本抽取为 PowerPoint 可编辑 text frame; +3. 将复杂 Slidev/Mermaid/CSS 视觉保留为 slide-level image fallback; +4. 生成真正的 `.pptx` zip,其中 slide XML 含 `` 文本节点; +5. 写出 sidecar JSON report,用数据说明可编辑覆盖率,而不是口头暗示。 + +## 命令 + +以 Jacob 用户运行,并使用 Jacob 的 Playwright browser cache: + +```bash +runuser -u jacob -- env HOME=/home/jacob PLAYWRIGHT_BROWSERS_PATH=/home/jacob/.cache/ms-playwright bash -lc 'cd /home/jacob/obsidian-NotEMD && npm run verify:slidev-export -- --vault docs --source architecture.zh-CN.md --format pptx --sample-slides all --timeout-ms 240000 --no-screenshots --json' +``` + +## 结果 + +本次运行返回 `ok: true`。 + +关键证据: + +1. `environment.capabilities.pptx = true` +2. `environment.slidev.version = 52.16.0 (/home/jacob/slidev/packages/slidev/bin/slidev.mjs)` +3. `slideSource.skillRootPath = /home/jacob/slidev/skills/slidev` +4. `slideSource.skillReferenceCount = 52` +5. `htmlExport.actualMode = standalone` +6. `htmlExport.requiresLocalServer = false` +7. `mermaidSourcePreservation.passed = true` +8. `layoutAuditSummary.overflowCount = 0` +9. `layoutAuditSummary.lowEffectiveFontCount = 0` +10. `pptxInspection.isZip = true` +11. `pptxInspection.slideCount = 27` +12. `pptxInspection.mediaCount = 27` +13. `pptxInspection.textRunCount = 236` +14. `pptxInspection.pictureCount = 27` +15. `pptxInspection.slidesWithoutEditableText = []` + +验收时 sidecar report 记录: + +```json +{ + "slideCount": 27, + "textBoxCount": 223, + "editableTextSlideCount": 27, + "pagesWithoutEditableText": [], + "backgroundImageSlideCount": 27, + "imageFallbackCount": 27, + "warnings": [] +} +``` + +## 输出归档 + +生成文件不提交到 `main`。可检查的验收归档在: + +```text +/home/jacob/slidev-export-review/2026-06-21-editable-pptx-real/ +``` + +归档内容: + +```text +acceptance-summary.json +architecture.zh-CN.pptx +architecture.zh-CN.pptx.report.json +architecture.zh-CN.slidev.md +architecture.zh-CN-slides/ +``` + +## 解释 + +本次验收证明直接 PPTX 导出已经接入生产等价的 NoteMD 工作流,并且会输出 PowerPoint 可编辑文本。它不声称每个 Slidev 对象都已经被重建为 Office 原生可编辑对象。Mermaid、SVG、canvas 和复杂 Vue/CSS surface 当前按设计保留为图片 fallback。 + +后续更有价值的升级是表格重建、代码块以等宽 text runs 抽取、以及选择性 shape extraction。这些升级仍应由 sidecar report 量化,而不是把“可编辑”退化成无法验证的口号。 diff --git a/docs/maintainer/slidev-export-workflow.md b/docs/maintainer/slidev-export-workflow.md index 041e3e89..41dd075e 100644 --- a/docs/maintainer/slidev-export-workflow.md +++ b/docs/maintainer/slidev-export-workflow.md @@ -43,6 +43,21 @@ or docs/export/architecture.zh-CN-slides/index.html when standalone falls back docs/export/architecture.zh-CN-slides/slide-*-workflow.png ``` +For editable PPTX: + +```bash +npm run verify:slidev-export -- --format pptx --source architecture.zh-CN.md --sample-slides all --timeout-ms 240000 --no-screenshots --json +``` + +The PPTX run writes: + +```text +docs/export/architecture.zh-CN.pptx +docs/export/architecture.zh-CN.pptx.report.json +``` + +The verifier also opens the `.pptx` as a zip and checks slide XML for editable `` text nodes. Treat image-only PPTX output as a failure for this path. + For a quieter machine-readable run: ```bash @@ -135,6 +150,11 @@ Treat the command as passing only when the final JSON report has: 19. existing local `` wrappers, including non-slot single-surface wrappers, must not be compounded with later whole-slide `zoom`. 20. mixed component/prose slides must not retain whole-slide zoom when a safe component/prose boundary exists; if the boundary is unsafe, the patcher should block whole-slide zoom and surface blocked/manual-review instead of shrinking prose. 21. expected-failure fixtures should not be treated as normal regressions just because verifier `ok = false`; they must prove the intended failure reason, native standalone environment, artifact visibility, and source preservation. The default success suite must still contain only `ok = true` converging fixtures. +22. for PPTX closure, `environment.capabilities.pptx: true` +23. for PPTX closure, `pptxInspection.isZip: true` +24. for PPTX closure, `pptxInspection.textRunCount > 0` +25. for PPTX closure, `pptxInspection.slidesWithoutEditableText` is empty when every source slide contains text +26. for PPTX closure, the sidecar report records `textBoxCount`, `editableTextSlideCount`, `imageFallbackCount`, and `pagesWithoutEditableText` If any check fails, fix the NoteMD workflow before relying on the exported files. diff --git a/docs/maintainer/slidev-export-workflow.zh-CN.md b/docs/maintainer/slidev-export-workflow.zh-CN.md index 19dc38a1..04bd84b5 100644 --- a/docs/maintainer/slidev-export-workflow.zh-CN.md +++ b/docs/maintainer/slidev-export-workflow.zh-CN.md @@ -43,6 +43,21 @@ docs/export/architecture.zh-CN-slides/index-standalone.html docs/export/architecture.zh-CN-slides/slide-*-workflow.png ``` +验证可编辑 PPTX: + +```bash +npm run verify:slidev-export -- --format pptx --source architecture.zh-CN.md --sample-slides all --timeout-ms 240000 --no-screenshots --json +``` + +PPTX 路径会写出: + +```text +docs/export/architecture.zh-CN.pptx +docs/export/architecture.zh-CN.pptx.report.json +``` + +verifier 会把 `.pptx` 当作 zip 打开,并检查 slide XML 中是否存在可编辑文本节点 ``。如果只是图片式 PPTX,这条路径应视为失败。 + 如果只需要机器可读的轻量结果: ```bash @@ -135,6 +150,11 @@ docs/maintainer/slidev-standalone-acceptance-2026-06-18.zh-CN.md 19. 已有局部 `` 的页面,包括非 slot 的 single-surface wrapper,不能在后续 retry 中再叠加整页 `zoom`。 20. mixed component/prose 页在存在安全 component/prose 边界时不能保留整页 `zoom`;如果边界不安全,patcher 应阻断整页 `zoom` 并暴露 blocked/manual-review,而不是缩小正文。 21. expected-failure fixture 不能因为 verifier `ok = false` 就算失败;它必须证明失败原因、standalone 环境和源内容保持都符合预期。默认成功 suite 仍必须只包含 `ok = true` 的可收敛 fixtures。 +22. PPTX 收口时,`environment.capabilities.pptx: true` +23. PPTX 收口时,`pptxInspection.isZip: true` +24. PPTX 收口时,`pptxInspection.textRunCount > 0` +25. PPTX 收口时,若源 deck 每页都有文本,`pptxInspection.slidesWithoutEditableText` 必须为空 +26. PPTX 收口时,sidecar report 必须记录 `textBoxCount`、`editableTextSlideCount`、`imageFallbackCount` 与 `pagesWithoutEditableText` 任一条件失败,都应先修 NoteMD 工作流,再相信导出文件。 diff --git a/package-lock.json b/package-lock.json index 58d56237..3a01436b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "acorn": "^8.17.0", + "fflate": "^0.8.3", "mermaid": "11.4.1", "minisearch": "^7.2.0", "vega": "^6.2.0", @@ -4170,6 +4171,12 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/package.json b/package.json index 7646fd00..cfda16f2 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "acorn": "^8.17.0", + "fflate": "^0.8.3", "mermaid": "11.4.1", "minisearch": "^7.2.0", "vega": "^6.2.0", diff --git a/scripts/verify-slidev-export-workflow.cjs b/scripts/verify-slidev-export-workflow.cjs index 1301733b..ad74c58b 100644 --- a/scripts/verify-slidev-export-workflow.cjs +++ b/scripts/verify-slidev-export-workflow.cjs @@ -80,7 +80,7 @@ function parseArgs(argv) { } } - if (!['html', 'pdf', 'png', 'mp4'].includes(args.format)) { + if (!['html', 'pdf', 'png', 'pptx', 'mp4'].includes(args.format)) { throw new Error(`Unsupported --format ${args.format}`); } if (!['standalone', 'server-script'].includes(args.htmlMode)) { @@ -103,7 +103,7 @@ function printHelp() { 'Options:', ' --vault Vault root, default: docs', ' --source Vault-relative source Markdown, default: architecture.zh-CN.md', - ' --format Export format, default: html', + ' --format Export format, default: html', ' --html-mode HTML mode, default: standalone', ' --output-subfolder Vault-relative output folder, default: export', ' --theme Slidev theme, default: default', @@ -122,6 +122,7 @@ async function bundleSlideExportModules() { "export { probeEnvironment } from './src/slideExport/environmentProber';", "export { prepareSlidevExportSource } from './src/slideExport/slidevSourcePreparer';", "export { exportSlidevHtml, exportSlidevHtmlWithOutcome, exportSlidevPdf, exportSlidevPng } from './src/slideExport/slidevExporter';", + "export { exportSlidevPptxFromHtml } from './src/slideExport/pptxExporter';", "export { convergeSlidevDeckLayout } from './src/slideExport/slidevLayoutWorkflow';", "export { exportVideoMp4 } from './src/slideExport/videoExporter';", "export { analyzeRenderedSlideMeasurement, summarizeLayoutAudits, patchDeckWithLayoutAudit, countSlideDeckSlides } from './src/slideExport/slidevLayoutAudit';", @@ -658,6 +659,50 @@ function checkIgnored(pathsToCheck) { .filter(Boolean); } +function inspectPptx(pptxPath) { + if (!pptxPath || !fs.existsSync(pptxPath)) { + return null; + } + try { + const { strFromU8, unzipSync } = require('fflate'); + const entries = unzipSync(new Uint8Array(fs.readFileSync(pptxPath))); + const names = Object.keys(entries); + const slideXmlPaths = names + .filter(name => /^ppt\/slides\/slide\d+\.xml$/.test(name)) + .sort((left, right) => { + const leftNumber = Number(left.match(/slide(\d+)\.xml$/)?.[1] || 0); + const rightNumber = Number(right.match(/slide(\d+)\.xml$/)?.[1] || 0); + return leftNumber - rightNumber; + }); + const slideTextRuns = slideXmlPaths.map(name => { + const xml = strFromU8(entries[name]); + const textRunCount = (xml.match(//g) || []).length; + const pictureCount = (xml.match(//g) || []).length; + return { + path: name, + textRunCount, + pictureCount, + hasEditableText: textRunCount > 0, + }; + }); + return { + isZip: true, + entryCount: names.length, + slideCount: slideXmlPaths.length, + mediaCount: names.filter(name => /^ppt\/media\/image\d+\.(png|jpg|jpeg)$/.test(name)).length, + textRunCount: slideTextRuns.reduce((total, slide) => total + slide.textRunCount, 0), + pictureCount: slideTextRuns.reduce((total, slide) => total + slide.pictureCount, 0), + slidesWithoutEditableText: slideTextRuns.filter(slide => !slide.hasEditableText).map(slide => slide.path), + slideTextRuns, + }; + } catch (error) { + return { + isZip: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + function printProgress(enabled, phase, detail) { if (!enabled) return; console.log(detail ? `${phase}: ${detail}` : phase); @@ -694,10 +739,22 @@ async function main() { let exportPath = layoutConvergence?.exportPath; let htmlExport = layoutConvergence?.htmlExport ?? null; let htmlExportHistory = layoutConvergence?.htmlExportHistory ?? []; + let pptxReportPath = null; if (args.format === 'pdf') { exportPath = await slideExport.exportSlidevPdf(app, slideSource, config, onProgress); } else if (args.format === 'png') { exportPath = await slideExport.exportSlidevPng(app, slideSource, config, onProgress); + } else if (args.format === 'pptx') { + if (!exportPath) { + htmlExport = await slideExport.exportSlidevHtmlWithOutcome(app, slideSource, config, onProgress); + htmlExportHistory = [htmlExport]; + exportPath = htmlExport.path; + } + const pptxResult = await slideExport.exportSlidevPptxFromHtml(app, slideSource, config, exportPath, onProgress); + exportPath = pptxResult.path; + htmlExport = layoutConvergence?.htmlExport ?? htmlExport; + htmlExportHistory = layoutConvergence?.htmlExportHistory ?? htmlExportHistory; + pptxReportPath = path.join(vaultRoot, pptxResult.reportPath); } else if (args.format === 'mp4') { const pngDirectory = await slideExport.exportSlidevPng(app, slideSource, config, onProgress); exportPath = await slideExport.exportVideoMp4(app, pngDirectory, slideSource.outputBasename, config, onProgress); @@ -716,10 +773,12 @@ async function main() { let layoutAuditSummary = layoutConvergence?.layoutAuditSummary ?? slideExport.summarizeLayoutAudits([], 0); let layoutPatchAttempts = layoutConvergence?.layoutPatchAttempts ?? []; let auditedSlides = layoutConvergence?.auditedSlides ?? []; + const pptxInspection = args.format === 'pptx' ? inspectPptx(absoluteExportPath) : null; const ignoredOutputs = checkIgnored([ absoluteDeckPath, absoluteExportPath, + pptxReportPath, htmlExport?.standaloneAttempt?.preservedFailurePath ? path.join(vaultRoot, htmlExport.standaloneAttempt.preservedFailurePath) : null, @@ -748,6 +807,7 @@ async function main() { && environment.capabilities[args.format] === true && fs.existsSync(absoluteExportPath) && ignoredOutputs.length === 0 + && (args.format !== 'pptx' || Boolean(pptxInspection?.isZip && pptxInspection.textRunCount > 0)) && !hasLayoutFailures && (!deckSummary || (!deckSummary.containsKnownStaleText && !deckSummary.containsMissingTheme)) && (!mermaidSourcePreservation || mermaidSourcePreservation.passed) @@ -776,7 +836,9 @@ async function main() { bytes: fs.existsSync(absoluteExportPath) && fs.statSync(absoluteExportPath).isFile() ? fs.statSync(absoluteExportPath).size : null, + pptxReportPath, }, + pptxInspection, htmlExport, htmlExportHistory, standaloneGate, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0a731114..6a1274ac 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -968,7 +968,7 @@ export const STRINGS_EN = { enableName: 'Enable slide export', enableDesc: 'Allow exporting Markdown files as slide presentations using Slidev. Desktop only — requires Node.js 20+.', defaultFormatName: 'Default export format', - defaultFormatDesc: 'MP4 requires ffmpeg in addition to Slidev and Playwright.', + defaultFormatDesc: 'PPTX/PDF/PNG require Playwright; MP4 also requires ffmpeg.', htmlModeName: 'HTML export mode', htmlModeDesc: 'Standalone: Single-file bundle (2-5MB) that works by double-clicking, no setup needed. Server-script: Smaller files (~900KB) but requires running a local server script.', htmlModeStandalone: 'Standalone (recommended)', @@ -1039,6 +1039,7 @@ export const STRINGS_EN = { exportingPngSequence: 'Exporting PNG sequence...', convertingToVideo: 'Converting to video...', exportSuccess: 'Export complete: {path}', + pptxReportOutputLog: 'PPTX editability report: {path}', exportComplete: 'Slide export finished', exportFailed: 'Export failed', formatNotSupported: '{format} export not available', diff --git a/src/i18n/locales/zh_cn.ts b/src/i18n/locales/zh_cn.ts index bf47076b..ae2a7303 100644 --- a/src/i18n/locales/zh_cn.ts +++ b/src/i18n/locales/zh_cn.ts @@ -959,7 +959,7 @@ export const STRINGS_ZH_CN: DeepPartial = { enableName: '启用演示导出', enableDesc: '允许使用 Slidev 将 Markdown 文件导出为演示文稿。仅桌面端 — 需要 Node.js 20+。', defaultFormatName: '默认导出格式', - defaultFormatDesc: 'MP4 除了 Slidev 和 Playwright 外还需要 ffmpeg。', + defaultFormatDesc: 'PPTX/PDF/PNG 需要 Playwright;MP4 还需要 ffmpeg。', htmlModeName: 'HTML 导出模式', htmlModeDesc: '独立模式:单文件打包(2-5MB),双击即可查看,无需配置。服务器脚本模式:文件较小(约900KB),但需运行本地服务器脚本。', htmlModeStandalone: '独立模式(推荐)', @@ -1030,6 +1030,7 @@ export const STRINGS_ZH_CN: DeepPartial = { exportingPngSequence: '正在导出 PNG 序列…', convertingToVideo: '正在转换为视频…', exportSuccess: '导出完成:{path}', + pptxReportOutputLog: 'PPTX 可编辑性报告:{path}', exportComplete: '演示导出完成', exportFailed: '导出失败', formatNotSupported: '不支持 {format} 导出', diff --git a/src/main.ts b/src/main.ts index badac44a..7293c51a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2940,7 +2940,7 @@ export default class NotemdPlugin extends Plugin { logSlideExportProgress: (phase: string, detail?: string) => void ) => Promise ): Promise { - const { probeEnvironment, convergeSlidevDeckLayout, exportSlidevPdf, exportSlidevPng, exportVideoMp4, getVaultBasePath } = await import('./slideExport'); + const { probeEnvironment, convergeSlidevDeckLayout, exportSlidevPdf, exportSlidevPng, exportSlidevPptxFromHtml, exportVideoMp4, getVaultBasePath } = await import('./slideExport'); const uiStrings = this.getUiStrings(); const config = this.buildSlideExportConfig(); const activeReporter = reporter ?? await this.getSidebarReporter(); @@ -3010,6 +3010,18 @@ export default class NotemdPlugin extends Plugin { activeReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath)); activeReporter.updateStatus(uiStrings.slideExport.exportSuccess.replace('{path}', outputPath), 100); new Notice(uiStrings.slideExport.exportComplete); + } else if (config.format === 'pptx') { + const result = await exportSlidevPptxFromHtml( + this.app, + slideSource, + config, + layoutConvergence.exportPath, + logSlideExportProgress + ); + activeReporter.log(uiStrings.slideExport.exportSuccess.replace('{path}', result.path)); + activeReporter.log(formatI18n(uiStrings.slideExport.pptxReportOutputLog, { path: result.reportPath })); + activeReporter.updateStatus(uiStrings.slideExport.exportSuccess.replace('{path}', result.path), 100); + new Notice(uiStrings.slideExport.exportComplete); } else if (config.format === 'mp4') { activeReporter.log(uiStrings.slideExport.exportingPngSequence); const pngDir = await exportSlidevPng( diff --git a/src/slideExport/EnvironmentProbeModal.ts b/src/slideExport/EnvironmentProbeModal.ts index 26bf8c7b..46b10c9d 100644 --- a/src/slideExport/EnvironmentProbeModal.ts +++ b/src/slideExport/EnvironmentProbeModal.ts @@ -115,6 +115,7 @@ export class EnvironmentProbeModal extends Modal { { key: 'html', label: 'HTML (playable slides)', available: this.report.capabilities.html }, { key: 'pdf', label: 'PDF', available: this.report.capabilities.pdf }, { key: 'png', label: 'PNG (image sequence)', available: this.report.capabilities.png }, + { key: 'pptx', label: 'PPTX (editable text)', available: this.report.capabilities.pptx }, { key: 'mp4', label: 'MP4 (video)', available: this.report.capabilities.mp4 }, ]; diff --git a/src/slideExport/environmentProber.ts b/src/slideExport/environmentProber.ts index 98eaa806..7c4b3556 100644 --- a/src/slideExport/environmentProber.ts +++ b/src/slideExport/environmentProber.ts @@ -105,6 +105,7 @@ function computeCapabilities( html: hasBase, pdf: hasBase && playwright.installed, png: hasBase && playwright.installed, + pptx: hasBase && playwright.installed, mp4: hasBase && playwright.installed && ffmpeg.installed, }; } @@ -121,7 +122,7 @@ export async function probeEnvironment(searchRoots: string[] = []): Promise { + const raw = await page.evaluate((currentSlide: number): RawSlideExtraction => { + const slideWidthIn = 13.3333333333; + const slideHeightIn = 7.5; + const warnings: string[] = []; + const visibleSlideRoot = (): Element => { + const candidates = Array.from(document.querySelectorAll('.slidev-page, .slidev-layout, .slidev-slide-content, #app')); + let best: { element: Element; area: number } | null = null; + for (const candidate of candidates) { + const style = window.getComputedStyle(candidate); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.04) { + continue; + } + const rect = candidate.getBoundingClientRect(); + if (rect.width < 2 || rect.height < 2) continue; + if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= window.innerWidth || rect.top >= window.innerHeight) continue; + const area = rect.width * rect.height; + if (!best || area > best.area) { + best = { element: candidate, area }; + } + } + return best?.element || document.body; + }; + const root = visibleSlideRoot(); + + const rootRect = root.getBoundingClientRect(); + const rootLeft = rootRect.left || 0; + const rootTop = rootRect.top || 0; + const rootWidth = rootRect.width || window.innerWidth || 1280; + const rootHeight = rootRect.height || window.innerHeight || 720; + const pxToInX = (value: number): number => (value - rootLeft) / rootWidth * slideWidthIn; + const pxToInY = (value: number): number => (value - rootTop) / rootHeight * slideHeightIn; + const sizeToInX = (value: number): number => value / rootWidth * slideWidthIn; + const sizeToInY = (value: number): number => value / rootHeight * slideHeightIn; + const pxToPt = (value: number): number => value * 72 / 96; + const normalize = (value: string): string => value + .replace(/\r\n?/g, '\n') + .replace(/[\u200b-\u200d\ufeff]/g, '') + .replace(/[^\S\n]+/g, ' ') + .trim(); + const rgbToHex = (value: string): string => { + const source = String(value || '').trim(); + if (!source || source === 'transparent') return ''; + if (source.startsWith('#')) { + const raw = source.slice(1).toUpperCase(); + return raw.length === 3 ? raw.split('').map(part => part + part).join('') : raw; + } + const match = source.match(/rgba?\(\s*(\d+(?:\.\d+)?)(?:\s*,\s*|\s+)(\d+(?:\.\d+)?)(?:\s*,\s*|\s+)(\d+(?:\.\d+)?)(?:\s*(?:,|\/)\s*(\d+(?:\.\d+)?%?))?/i); + if (!match) return ''; + const alphaRaw = match[4]; + const alpha = alphaRaw === undefined + ? 1 + : alphaRaw.endsWith('%') + ? Number.parseFloat(alphaRaw) / 100 + : Number(alphaRaw); + if (alpha <= 0.02) return ''; + return [match[1], match[2], match[3]] + .map(part => Math.max(0, Math.min(255, Math.round(Number(part) || 0))).toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); + }; + const sanitizeFontFace = (value: string): string => { + const font = String(value || '') + .split(',') + .map(part => part.trim().replace(/^["']|["']$/g, '')) + .find(Boolean); + return font || 'Aptos'; + }; + const directText = (element: Element): boolean => Array.from(element.childNodes).some(node => ( + node.nodeType === Node.TEXT_NODE && normalize(node.textContent || '').length > 0 + )); + const hasCandidateDescendant = (element: Element): boolean => Boolean(element.querySelector( + 'h1,h2,h3,h4,h5,h6,p,li,td,th,blockquote,pre,figcaption' + )); + const selected = new Set(); + const baseCandidates = Array.from(root.querySelectorAll( + 'h1,h2,h3,h4,h5,h6,p,li,td,th,blockquote,pre,figcaption' + )); + const fallbackCandidates = Array.from(root.querySelectorAll('div,span,a,code')).filter(element => ( + directText(element) && !hasCandidateDescendant(element) + )); + const allCandidates = [...baseCandidates, ...fallbackCandidates]; + const hasSelectedAncestor = (element: Element): boolean => { + let parent = element.parentElement; + while (parent && parent !== root) { + if (selected.has(parent)) return true; + parent = parent.parentElement; + } + return false; + }; + const isVisible = (element: Element, style: CSSStyleDeclaration, rect: DOMRect): boolean => { + if (element.closest('script,style,noscript,svg')) return false; + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity || '1') < 0.04) return false; + if (rect.width < 2 || rect.height < 2) return false; + if (rect.right < rootLeft || rect.bottom < rootTop) return false; + if (rect.left > rootLeft + rootWidth || rect.top > rootTop + rootHeight) return false; + return true; + }; + const alignFor = (style: CSSStyleDeclaration): SlidevPptxTextAlign => { + if (style.textAlign === 'center') return 'center'; + if (style.textAlign === 'right' || style.textAlign === 'end') return 'right'; + if (style.textAlign === 'justify') return 'justify'; + return 'left'; + }; + const effectiveColor = (style: CSSStyleDeclaration): string => { + const webkitFill = (style as any).webkitTextFillColor || style.getPropertyValue('-webkit-text-fill-color') || ''; + return rgbToHex(webkitFill) || rgbToHex(style.color) || '111827'; + }; + const textBoxes: RawSlideTextBox[] = []; + let order = 10; + + for (const element of allCandidates) { + if (selected.has(element) || hasSelectedAncestor(element)) continue; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (!isVisible(element, style, rect)) continue; + const tagName = element.tagName.toUpperCase(); + const rawText = tagName === 'PRE' + ? element.textContent || '' + : (element instanceof HTMLElement ? element.innerText : element.textContent) || ''; + const text = normalize(rawText); + if (!text) continue; + selected.add(element); + const fontSizePx = Number.parseFloat(style.fontSize || '16') || 16; + const fontWeight = Number.parseInt(style.fontWeight || '400', 10); + const listStyle = style.listStyleType || ''; + textBoxes.push({ + text, + x: pxToInX(rect.left), + y: pxToInY(rect.top), + w: sizeToInX(rect.width), + h: sizeToInY(rect.height), + fontSize: pxToPt(fontSizePx), + fontFace: sanitizeFontFace(style.fontFamily), + color: effectiveColor(style), + bold: Number.isFinite(fontWeight) ? fontWeight >= 600 : /bold/i.test(style.fontWeight), + italic: style.fontStyle === 'italic' || style.fontStyle === 'oblique', + underline: style.textDecorationLine.includes('underline'), + align: alignFor(style), + bullet: tagName === 'LI' && listStyle !== 'none', + order, + }); + element.setAttribute('data-notemd-pptx-hidden-text', '1'); + order += 10; + } + + const hideStyle = document.createElement('style'); + hideStyle.id = 'notemd-pptx-hide-text'; + hideStyle.textContent = [ + '[data-notemd-pptx-hidden-text="1"],', + '[data-notemd-pptx-hidden-text="1"] * {', + 'color: transparent !important;', + '-webkit-text-fill-color: transparent !important;', + 'text-shadow: none !important;', + 'text-decoration-color: transparent !important;', + '}', + '[data-notemd-pptx-hidden-text="1"]::marker { color: transparent !important; }', + ].join('\n'); + document.head.appendChild(hideStyle); + + if (textBoxes.length === 0) { + warnings.push(`Slide ${currentSlide} has no extracted editable text.`); + } + + const heading = textBoxes.find(item => item.text.length > 0)?.text || `Slide ${currentSlide}`; + const bodyBg = rgbToHex(window.getComputedStyle(document.body).backgroundColor); + const rootBg = rgbToHex(window.getComputedStyle(root).backgroundColor); + + return { + title: heading.slice(0, 120), + backgroundColor: rootBg || bodyBg || 'FFFFFF', + texts: textBoxes, + warnings, + }; + }, slideNumber) as RawSlideExtraction; + + const texts = raw.texts + .map(normalizeTextBox) + .filter((textBox): textBox is SlidevPptxTextBox => textBox !== null); + return { + slideNumber, + title: raw.title || `Slide ${slideNumber}`, + backgroundColor: normalizeHexColor(raw.backgroundColor, 'FFFFFF'), + texts, + warnings: Array.isArray(raw.warnings) ? raw.warnings : [], + }; +} diff --git a/src/slideExport/pptxExporter.ts b/src/slideExport/pptxExporter.ts new file mode 100644 index 00000000..db212e11 --- /dev/null +++ b/src/slideExport/pptxExporter.ts @@ -0,0 +1,213 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { pathToFileURL } from 'url'; +import type { App } from 'obsidian'; +import { startLocalServer, stopLocalServer } from './localServer'; +import { countSlideDeckSlides } from './slidevLayoutAudit'; +import { extractSlidevPptxSlideFromPage } from './pptxDomExtractor'; +import { + PPTX_SLIDE_HEIGHT_IN, + PPTX_SLIDE_WIDTH_IN, + type SlidevPptxDocument, + type SlidevPptxExportReport, + type SlidevPptxExportResult, + type SlidevPptxImage, + type SlidevPptxSlide, +} from './pptxModel'; +import { writePptxDocument } from './pptxWriter'; +import { getVaultBasePath, resolvePlaywrightBrowsersPath, safeRequire } from './platformUtils'; +import type { ExportProgressCallback, SlideExportConfig, SlidevExportSource } from './types'; + +type PlaywrightRuntime = { + chromium?: { + launch(options: { headless: boolean }): Promise; + }; +}; + +function resolvePlaywrightRuntime(): PlaywrightRuntime | null { + const playwrightBrowsersPath = resolvePlaywrightBrowsersPath(); + if (playwrightBrowsersPath && !process.env.PLAYWRIGHT_BROWSERS_PATH) { + process.env.PLAYWRIGHT_BROWSERS_PATH = playwrightBrowsersPath; + } + + const playwright = safeRequire('playwright') as PlaywrightRuntime | null; + return playwright?.chromium ? playwright : null; +} + +function resolveSlideCount(source: SlidevExportSource, vaultRoot: string): { slideCount: number; deckPath: string | null } { + const deckPath = source.preparedDeckPath + ? join(vaultRoot, source.preparedDeckPath) + : join(vaultRoot, source.inputFilePath); + if (!existsSync(deckPath)) { + return { slideCount: 1, deckPath: null }; + } + const deckMarkdown = readFileSync(deckPath, 'utf8'); + return { + slideCount: Math.max(1, countSlideDeckSlides(deckMarkdown)), + deckPath, + }; +} + +async function openSlideForPptxExport(page: any, targetUrl: string, timeoutMs: number): Promise { + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 60_000) }); + if (typeof page.waitForLoadState === 'function') { + await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => undefined); + } + if (typeof page.waitForFunction === 'function') { + await page.waitForFunction( + () => { + const root = document.querySelector('.slidev-page, .slidev-layout, .slidev-slide-content, #app'); + return Boolean(root && (root.textContent || '').trim().length > 0); + }, + null, + { timeout: 20_000 }, + ).catch(() => undefined); + } + await page.waitForTimeout(500); +} + +async function captureSlideBackground(page: any, slideNumber: number): Promise { + const clip = await page.evaluate(() => { + const candidates = Array.from(document.querySelectorAll('.slidev-page, .slidev-layout, .slidev-slide-content, #app')); + let best: { x: number; y: number; width: number; height: number; area: number } | null = null; + for (const candidate of candidates) { + const style = window.getComputedStyle(candidate); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') < 0.04) { + continue; + } + const rect = candidate.getBoundingClientRect(); + if (rect.width < 2 || rect.height < 2) continue; + const left = Math.max(0, rect.left); + const top = Math.max(0, rect.top); + const right = Math.min(window.innerWidth, rect.right); + const bottom = Math.min(window.innerHeight, rect.bottom); + if (right <= left || bottom <= top) continue; + const area = (right - left) * (bottom - top); + if (!best || area > best.area) { + best = { x: left, y: top, width: right - left, height: bottom - top, area }; + } + } + return best; + }) as { x: number; y: number; width: number; height: number; area: number } | null; + const screenshot = clip + ? await page.screenshot({ type: 'png', clip: { x: clip.x, y: clip.y, width: clip.width, height: clip.height } }) + : await page.screenshot({ type: 'png', fullPage: false }); + return { + data: new Uint8Array(screenshot), + mimeType: 'image/png', + x: 0, + y: 0, + w: PPTX_SLIDE_WIDTH_IN, + h: PPTX_SLIDE_HEIGHT_IN, + name: `Slide ${slideNumber} visual fallback`, + order: 0, + }; +} + +async function extractSlidesFromHtml( + htmlPath: string, + slideCount: number, + config: SlideExportConfig, + onProgress?: ExportProgressCallback, +): Promise { + const playwright = resolvePlaywrightRuntime(); + if (!playwright?.chromium) { + throw new Error('Playwright runtime is unavailable; PPTX export requires Playwright Chromium.'); + } + + const browser = await playwright.chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + let serverDirectory: string | null = null; + let baseUrl: string | null = null; + + try { + if (htmlPath.endsWith('/index.html')) { + serverDirectory = dirname(htmlPath); + const port = await startLocalServer(serverDirectory); + baseUrl = `http://localhost:${port}/index.html`; + } + + const slides: SlidevPptxSlide[] = []; + for (let slideNumber = 1; slideNumber <= slideCount; slideNumber += 1) { + onProgress?.('pptx-export', `Extracting editable slide ${slideNumber}/${slideCount}...`); + const targetUrl = baseUrl + ? `${baseUrl}#/${slideNumber}` + : `${pathToFileURL(htmlPath).toString()}#/${slideNumber}`; + await openSlideForPptxExport(page, targetUrl, config.timeoutMs); + const slide = await extractSlidevPptxSlideFromPage(page, slideNumber); + slide.backgroundImage = await captureSlideBackground(page, slideNumber); + slides.push(slide); + } + return slides; + } finally { + await browser.close(); + if (serverDirectory) { + stopLocalServer(serverDirectory); + } + } +} + +function buildReport( + htmlPath: string, + deckPath: string | null, + pptxPath: string, + reportPath: string, + slides: SlidevPptxSlide[], +): SlidevPptxExportReport { + const pagesWithoutEditableText = slides + .filter(slide => slide.texts.length === 0) + .map(slide => slide.slideNumber); + const warnings = slides.flatMap(slide => slide.warnings); + return { + formatVersion: 1, + source: { + htmlPath, + deckPath, + }, + output: { + pptxPath, + reportPath, + }, + slideCount: slides.length, + textBoxCount: slides.reduce((total, slide) => total + slide.texts.length, 0), + editableTextSlideCount: slides.length - pagesWithoutEditableText.length, + pagesWithoutEditableText, + backgroundImageSlideCount: slides.filter(slide => Boolean(slide.backgroundImage)).length, + imageFallbackCount: slides.filter(slide => Boolean(slide.backgroundImage)).length, + warnings, + }; +} + +export async function exportSlidevPptxFromHtml( + app: App, + source: SlidevExportSource, + config: SlideExportConfig, + htmlExportPath: string, + onProgress?: ExportProgressCallback, +): Promise { + onProgress?.('pptx-export', 'Creating editable PPTX from rendered Slidev HTML...'); + const vaultRoot = getVaultBasePath(app); + if (!vaultRoot) throw new Error('Vault root path unavailable'); + + const absoluteHtmlPath = join(vaultRoot, htmlExportPath); + const { slideCount, deckPath } = resolveSlideCount(source, vaultRoot); + const slides = await extractSlidesFromHtml(absoluteHtmlPath, slideCount, config, onProgress); + const outputPath = join(vaultRoot, config.outputSubfolder, `${source.outputBasename}.pptx`); + const reportPath = join(vaultRoot, config.outputSubfolder, `${source.outputBasename}.pptx.report.json`); + const document: SlidevPptxDocument = { + title: source.sourceLabel || source.outputBasename, + author: 'NoteMD', + slides, + }; + + writePptxDocument(outputPath, document); + const report = buildReport(absoluteHtmlPath, deckPath, outputPath, reportPath, slides); + writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf8'); + onProgress?.('pptx-export', `PPTX export complete with ${report.textBoxCount} editable text boxes.`); + + return { + path: `${config.outputSubfolder}/${source.outputBasename}.pptx`, + reportPath: `${config.outputSubfolder}/${source.outputBasename}.pptx.report.json`, + report, + }; +} diff --git a/src/slideExport/pptxModel.ts b/src/slideExport/pptxModel.ts new file mode 100644 index 00000000..5702c066 --- /dev/null +++ b/src/slideExport/pptxModel.ts @@ -0,0 +1,74 @@ +export const PPTX_SLIDE_WIDTH_IN = 13.3333333333; +export const PPTX_SLIDE_HEIGHT_IN = 7.5; +export const PPTX_SLIDE_WIDTH_EMU = 12192000; +export const PPTX_SLIDE_HEIGHT_EMU = 6858000; + +export type SlidevPptxTextAlign = 'left' | 'center' | 'right' | 'justify'; + +export interface SlidevPptxTextBox { + text: string; + x: number; + y: number; + w: number; + h: number; + fontSize: number; + fontFace: string; + color: string; + bold: boolean; + italic: boolean; + underline: boolean; + align: SlidevPptxTextAlign; + bullet: boolean; + order: number; +} + +export interface SlidevPptxImage { + data: Uint8Array; + mimeType: 'image/png' | 'image/jpeg'; + x: number; + y: number; + w: number; + h: number; + name: string; + order: number; +} + +export interface SlidevPptxSlide { + slideNumber: number; + title: string; + backgroundColor: string; + backgroundImage?: SlidevPptxImage; + texts: SlidevPptxTextBox[]; + warnings: string[]; +} + +export interface SlidevPptxDocument { + title: string; + author?: string; + slides: SlidevPptxSlide[]; +} + +export interface SlidevPptxExportReport { + formatVersion: 1; + source: { + htmlPath: string; + deckPath: string | null; + }; + output: { + pptxPath: string; + reportPath: string; + }; + slideCount: number; + textBoxCount: number; + editableTextSlideCount: number; + pagesWithoutEditableText: number[]; + backgroundImageSlideCount: number; + imageFallbackCount: number; + warnings: string[]; +} + +export interface SlidevPptxExportResult { + path: string; + reportPath: string; + report: SlidevPptxExportReport; +} diff --git a/src/slideExport/pptxWriter.ts b/src/slideExport/pptxWriter.ts new file mode 100644 index 00000000..700cac20 --- /dev/null +++ b/src/slideExport/pptxWriter.ts @@ -0,0 +1,405 @@ +import { strToU8, zipSync } from 'fflate'; +import { + PPTX_SLIDE_HEIGHT_EMU, + PPTX_SLIDE_WIDTH_EMU, + type SlidevPptxDocument, + type SlidevPptxImage, + type SlidevPptxSlide, + type SlidevPptxTextAlign, + type SlidevPptxTextBox, +} from './pptxModel'; +import { safeRequire } from './platformUtils'; + +const EMU_PER_INCH = 914400; + +interface SlideImageRelationship { + image: SlidevPptxImage; + relationshipId: string; + mediaPath: string; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function escapeXmlAttribute(value: string): string { + return escapeXml(value).replace(/"/g, '"').replace(/'/g, '''); +} + +function inchesToEmu(value: number): number { + return Math.round(Math.max(0, value) * EMU_PER_INCH); +} + +function clampHexColor(value: string, fallback: string): string { + const normalized = value.trim().replace(/^#/, '').toUpperCase(); + return /^[0-9A-F]{6}$/.test(normalized) ? normalized : fallback; +} + +function extensionForImage(image: SlidevPptxImage): 'png' | 'jpg' { + return image.mimeType === 'image/jpeg' ? 'jpg' : 'png'; +} + +function alignToOoxml(value: SlidevPptxTextAlign): string { + switch (value) { + case 'center': + return 'ctr'; + case 'right': + return 'r'; + case 'justify': + return 'just'; + case 'left': + default: + return 'l'; + } +} + +function buildTextRun(text: string, textBox: SlidevPptxTextBox): string { + const color = clampHexColor(textBox.color, '111827'); + const size = Math.max(600, Math.min(14400, Math.round(textBox.fontSize * 100))); + const bold = textBox.bold ? ' b="1"' : ''; + const italic = textBox.italic ? ' i="1"' : ''; + const underline = textBox.underline ? ' u="sng"' : ''; + const fontFace = escapeXmlAttribute(textBox.fontFace || 'Aptos'); + const eastAsiaFont = /[\u3400-\u9fff\uf900-\ufaff]/.test(text) ? 'Microsoft YaHei' : fontFace; + + return [ + '', + ``, + ``, + ``, + ``, + '', + '', + `${escapeXml(text)}`, + '', + ].join(''); +} + +function buildTextParagraphs(textBox: SlidevPptxTextBox): string { + const paragraphs = textBox.text + .replace(/\r\n?/g, '\n') + .split('\n') + .map(line => line.trimEnd()) + .filter((line, index, lines) => line.length > 0 || lines.length === 1); + const align = alignToOoxml(textBox.align); + const bullet = textBox.bullet ? '' : ''; + const size = Math.max(600, Math.min(14400, Math.round(textBox.fontSize * 100))); + + return paragraphs.map(line => [ + '', + `${bullet}`, + buildTextRun(line || ' ', textBox), + ``, + '', + ].join('')).join(''); +} + +function buildTextShape(textBox: SlidevPptxTextBox, shapeId: number): string { + const x = inchesToEmu(textBox.x); + const y = inchesToEmu(textBox.y); + const w = inchesToEmu(textBox.w); + const h = inchesToEmu(textBox.h); + const name = escapeXmlAttribute(`Editable Text ${shapeId}`); + + return [ + '', + '', + ``, + '', + '', + '', + '', + ``, + '', + '', + '', + '', + '', + '', + '', + buildTextParagraphs(textBox), + '', + '', + ].join(''); +} + +function buildPicture(image: SlidevPptxImage, shapeId: number, relationshipId: string): string { + const x = inchesToEmu(image.x); + const y = inchesToEmu(image.y); + const w = inchesToEmu(image.w); + const h = inchesToEmu(image.h); + const name = escapeXmlAttribute(image.name || `Image ${shapeId}`); + + return [ + '', + '', + ``, + '', + '', + '', + '', + ``, + '', + '', + '', + ``, + '', + '', + '', + ].join(''); +} + +function buildSlideXml(slide: SlidevPptxSlide, imageRelationships: SlideImageRelationship[]): string { + const backgroundColor = clampHexColor(slide.backgroundColor, 'FFFFFF'); + const items: Array<{ order: number; xml: string }> = []; + let shapeId = 2; + + for (const relationship of imageRelationships) { + items.push({ + order: relationship.image.order, + xml: buildPicture(relationship.image, shapeId, relationship.relationshipId), + }); + shapeId += 1; + } + + for (const text of slide.texts) { + items.push({ + order: text.order, + xml: buildTextShape(text, shapeId), + }); + shapeId += 1; + } + + items.sort((left, right) => left.order - right.order); + + return [ + '', + '', + '', + ``, + '', + '', + '', + items.map(item => item.xml).join(''), + '', + '', + '', + '', + ].join(''); +} + +function buildSlideRelationships(imageRelationships: SlideImageRelationship[]): string { + const imageRels = imageRelationships.map(relationship => ( + `` + )); + return [ + '', + '', + '', + imageRels.join(''), + '', + ].join(''); +} + +function buildPresentationXml(slideCount: number): string { + const slideIds = Array.from({ length: slideCount }, (_unused, index) => ( + `` + )).join(''); + + return [ + '', + '', + '', + `${slideIds}`, + ``, + '', + '', + '', + ].join(''); +} + +function buildPresentationRelationships(slideCount: number): string { + const slideRels = Array.from({ length: slideCount }, (_unused, index) => ( + `` + )).join(''); + return [ + '', + '', + '', + slideRels, + '', + ].join(''); +} + +function buildContentTypes(slideCount: number, imageExtensions: Set): string { + const imageDefaults = Array.from(imageExtensions).sort().map(extension => { + const contentType = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`; + return ``; + }).join(''); + const slideOverrides = Array.from({ length: slideCount }, (_unused, index) => ( + `` + )).join(''); + + return [ + '', + '', + '', + '', + imageDefaults, + '', + '', + '', + '', + '', + '', + slideOverrides, + '', + ].join(''); +} + +function buildRootRelationships(): string { + return [ + '', + '', + '', + '', + '', + '', + ].join(''); +} + +function buildCoreProperties(document: SlidevPptxDocument): string { + const now = new Date().toISOString(); + return [ + '', + '', + `${escapeXml(document.title)}`, + `${escapeXml(document.author || 'NoteMD')}`, + `${escapeXml(document.author || 'NoteMD')}`, + `${now}`, + `${now}`, + '', + ].join(''); +} + +function buildAppProperties(document: SlidevPptxDocument): string { + return [ + '', + '', + 'NoteMD', + `${document.slides.length}`, + 'On-screen Show (16:9)', + '', + ].join(''); +} + +function buildSlideMaster(): string { + return [ + '', + '', + '', + '', + '', + '', + '', + ].join(''); +} + +function buildSlideMasterRelationships(): string { + return [ + '', + '', + '', + '', + '', + ].join(''); +} + +function buildSlideLayout(): string { + return [ + '', + '', + '', + '', + '', + ].join(''); +} + +function buildSlideLayoutRelationships(): string { + return [ + '', + '', + '', + '', + ].join(''); +} + +function buildTheme(): string { + return [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join(''); +} + +export function writePptxDocument(outputPath: string, document: SlidevPptxDocument): void { + const fs: any = safeRequire('fs'); + const path: any = safeRequire('path'); + if (!fs?.writeFileSync || !path?.dirname) { + throw new Error('Filesystem APIs are unavailable; PPTX export requires desktop Obsidian.'); + } + + const files: Record = {}; + const imageExtensions = new Set(); + let mediaIndex = 1; + + const addText = (zipPath: string, xml: string): void => { + files[zipPath] = strToU8(xml); + }; + + for (let slideIndex = 0; slideIndex < document.slides.length; slideIndex += 1) { + const slide = document.slides[slideIndex]; + const images = slide.backgroundImage ? [slide.backgroundImage] : []; + const imageRelationships: SlideImageRelationship[] = images.map((image, imageIndex) => { + const extension = extensionForImage(image); + imageExtensions.add(extension); + const mediaPath = `media/image${mediaIndex}.${extension}`; + files[`ppt/${mediaPath}`] = image.data; + mediaIndex += 1; + return { + image, + relationshipId: `rId${imageIndex + 2}`, + mediaPath, + }; + }); + const slideNumber = slideIndex + 1; + addText(`ppt/slides/slide${slideNumber}.xml`, buildSlideXml(slide, imageRelationships)); + addText(`ppt/slides/_rels/slide${slideNumber}.xml.rels`, buildSlideRelationships(imageRelationships)); + } + + addText('[Content_Types].xml', buildContentTypes(document.slides.length, imageExtensions)); + addText('_rels/.rels', buildRootRelationships()); + addText('docProps/core.xml', buildCoreProperties(document)); + addText('docProps/app.xml', buildAppProperties(document)); + addText('ppt/presentation.xml', buildPresentationXml(document.slides.length)); + addText('ppt/_rels/presentation.xml.rels', buildPresentationRelationships(document.slides.length)); + addText('ppt/slideMasters/slideMaster1.xml', buildSlideMaster()); + addText('ppt/slideMasters/_rels/slideMaster1.xml.rels', buildSlideMasterRelationships()); + addText('ppt/slideLayouts/slideLayout1.xml', buildSlideLayout()); + addText('ppt/slideLayouts/_rels/slideLayout1.xml.rels', buildSlideLayoutRelationships()); + addText('ppt/theme/theme1.xml', buildTheme()); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + const zipped = zipSync(files, { level: 6 }); + fs.writeFileSync(outputPath, Buffer.from(zipped)); +} diff --git a/src/slideExport/types.ts b/src/slideExport/types.ts index 0f81808f..35d56673 100644 --- a/src/slideExport/types.ts +++ b/src/slideExport/types.ts @@ -4,7 +4,7 @@ * Types for environment probing, export configuration, and command execution. */ -export type SlideExportFormat = 'html' | 'pdf' | 'png' | 'mp4'; +export type SlideExportFormat = 'html' | 'pdf' | 'png' | 'pptx' | 'mp4'; export interface ProbeResult { tool: 'node' | 'slidev' | 'playwright' | 'ffmpeg'; @@ -17,6 +17,7 @@ export interface ExportCapabilities { html: boolean; pdf: boolean; png: boolean; + pptx: boolean; mp4: boolean; } diff --git a/src/tests/pptxWriter.test.ts b/src/tests/pptxWriter.test.ts new file mode 100644 index 00000000..75bbd937 --- /dev/null +++ b/src/tests/pptxWriter.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { strFromU8, unzipSync } from 'fflate'; +import { PPTX_SLIDE_HEIGHT_IN, PPTX_SLIDE_WIDTH_IN, type SlidevPptxDocument } from '../slideExport/pptxModel'; +import { writePptxDocument } from '../slideExport/pptxWriter'; + +jest.mock('obsidian', () => ({ + Platform: { isDesktopApp: true }, +})); + +describe('pptxWriter', () => { + test('writes a PPTX zip with editable text runs and a visual fallback image', () => { + const directory = mkdtempSync(join(tmpdir(), 'notemd-pptx-')); + try { + const outputPath = join(directory, 'deck.pptx'); + const document: SlidevPptxDocument = { + title: 'Editable deck', + author: 'NoteMD', + slides: [{ + slideNumber: 1, + title: 'Architecture', + backgroundColor: 'FFFFFF', + backgroundImage: { + data: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + mimeType: 'image/png', + x: 0, + y: 0, + w: PPTX_SLIDE_WIDTH_IN, + h: PPTX_SLIDE_HEIGHT_IN, + name: 'Fallback', + order: 0, + }, + texts: [{ + text: '可编辑 PPTX\nArchitecture', + x: 1, + y: 1, + w: 5, + h: 1, + fontSize: 24, + fontFace: 'Aptos', + color: '111827', + bold: true, + italic: false, + underline: false, + align: 'left', + bullet: false, + order: 10, + }], + warnings: [], + }], + }; + + writePptxDocument(outputPath, document); + + const entries = unzipSync(new Uint8Array(readFileSync(outputPath))); + expect(entries['ppt/presentation.xml']).toBeDefined(); + expect(entries['ppt/slides/slide1.xml']).toBeDefined(); + expect(entries['ppt/media/image1.png']).toBeDefined(); + const slideXml = strFromU8(entries['ppt/slides/slide1.xml']); + expect(slideXml).toContain('可编辑 PPTX'); + expect(slideXml).toContain('Architecture'); + expect(slideXml).toContain(''); + expect(slideXml).toContain(''); + expect(slideXml).toContain(''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/tests/sidebarDomButtonClicks.test.ts b/src/tests/sidebarDomButtonClicks.test.ts index 81fc70ba..b6665776 100644 --- a/src/tests/sidebarDomButtonClicks.test.ts +++ b/src/tests/sidebarDomButtonClicks.test.ts @@ -23,7 +23,7 @@ type MockPlugin = { customWorkflowButtonsDsl: string; customWorkflowErrorStrategy: 'stop_on_error' | 'continue_on_error'; enableApiErrorDebugMode: boolean; - slideExportDefaultFormat: 'html' | 'pdf' | 'png' | 'mp4'; + slideExportDefaultFormat: 'html' | 'pdf' | 'png' | 'pptx' | 'mp4'; }; saveSettings: jest.Mock, []>; getIsBusy: jest.Mock; @@ -327,7 +327,7 @@ describe('NotemdSidebarView DOM button wiring', () => { slidev: { tool: 'slidev', installed: false, version: null, error: 'Not available via npx slidev' }, playwright: { tool: 'playwright', installed: false, version: null, error: 'Playwright chromium not installed' }, ffmpeg: { tool: 'ffmpeg', installed: false, version: null, error: 'ffmpeg not found in PATH' }, - capabilities: { html: false, pdf: false, png: false, mp4: false } + capabilities: { html: false, pdf: false, png: false, pptx: false, mp4: false } }); }); @@ -511,10 +511,10 @@ describe('NotemdSidebarView DOM button wiring', () => { expect(selector?.tag).toBe('select'); expect(selector?.value).toBe('html'); - selector!.value = 'pdf'; + selector!.value = 'pptx'; await selector!.onchange?.(); - expect(plugin.settings.slideExportDefaultFormat).toBe('pdf'); + expect(plugin.settings.slideExportDefaultFormat).toBe('pptx'); expect(plugin.saveSettings).toHaveBeenCalledTimes(1); }); diff --git a/src/tests/slideExportComprehensive.test.ts b/src/tests/slideExportComprehensive.test.ts index 6ab23a0d..153eec57 100644 --- a/src/tests/slideExportComprehensive.test.ts +++ b/src/tests/slideExportComprehensive.test.ts @@ -797,6 +797,7 @@ describe('Integration — Probe to PDF Export', () => { const report = await probeEnvironment(['/vault']); expect(report.playwright.installed).toBe(true); expect(report.capabilities.pdf).toBe(true); + expect(report.capabilities.pptx).toBe(true); const app = createMockApp(); const source = createMockSlidevSource('slides', 'slides.md'); @@ -843,6 +844,7 @@ describe('Integration — Probe to PNG to MP4 Chain', () => { const report = await probeEnvironment(['/vault']); expect(report.capabilities.png).toBe(true); + expect(report.capabilities.pptx).toBe(true); expect(report.capabilities.mp4).toBe(true); const app = createMockApp(); diff --git a/src/types.ts b/src/types.ts index 5785a5a9..965928a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -217,7 +217,7 @@ export interface NotemdSettings { // Slide Export Settings enableSlideExport: boolean; - slideExportDefaultFormat: 'html' | 'pdf' | 'png' | 'mp4'; + slideExportDefaultFormat: 'html' | 'pdf' | 'png' | 'pptx' | 'mp4'; slideExportOutputSubfolder: string; slideExportWithClicks: boolean; slideExportFfmpegFps: number; diff --git a/src/ui/NotemdSettingTab.ts b/src/ui/NotemdSettingTab.ts index 2291a760..19232451 100644 --- a/src/ui/NotemdSettingTab.ts +++ b/src/ui/NotemdSettingTab.ts @@ -3255,6 +3255,7 @@ export class NotemdSettingTab extends PluginSettingTab { .addOption('html', 'HTML') .addOption('pdf', 'PDF') .addOption('png', 'PNG') + .addOption('pptx', 'PPTX') .addOption('mp4', 'MP4') .setValue(this.plugin.settings.slideExportDefaultFormat) .onChange(async (value) => { diff --git a/src/ui/NotemdSidebarView.ts b/src/ui/NotemdSidebarView.ts index bb51ffac..c7ad0e8f 100644 --- a/src/ui/NotemdSidebarView.ts +++ b/src/ui/NotemdSidebarView.ts @@ -58,7 +58,7 @@ interface ApiActivityRequestRecord { const API_ACTIVITY_RECENT_LIMIT = 6; const API_ACTIVITY_HISTORY_LIMIT = 20; const API_ACTIVITY_VISIBLE_HISTORY_LIMIT = 6; -const SLIDE_EXPORT_FORMATS: Array = ['html', 'pdf', 'png', 'mp4']; +const SLIDE_EXPORT_FORMATS: Array = ['html', 'pdf', 'png', 'pptx', 'mp4']; const ACTION_CATEGORY_CONFIG: Record = { core: { openByDefault: true }, @@ -1489,6 +1489,7 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter { html: 'HTML', pdf: 'PDF', png: 'PNG', + pptx: 'PPTX', mp4: 'MP4' }; for (const format of SLIDE_EXPORT_FORMATS) {