From 1ccbf3e67fcb99c4b7d107c576b6625e3f4faceb Mon Sep 17 00:00:00 2001 From: hongzuoj-pixel Date: Wed, 20 May 2026 11:11:00 +0800 Subject: [PATCH] Fix PDF and LaTeX export layout for Chinese notes --- README.md | 22 +- README.zh-CN.md | 19 +- ROADMAP.zh-CN.md | 29 +- gui/README_GUI.md | 66 ++- gui/README_GUI.zh-CN.md | 52 ++- gui/app.py | 912 ++++++++++++++++++++++++++++++++-------- slidenote/exporting.py | 112 ++++- tests/test_exports.py | 49 ++- 8 files changed, 1039 insertions(+), 222 deletions(-) diff --git a/README.md b/README.md index 9ebf77b..14aebfc 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,25 @@ Open `outputs\lecture\notes.md`. Images are bundled under `outputs\lecture\notes ## Optional GUI -SlideNote Studio is a Streamlit wrapper around the same CLI pipeline. It lets you upload PPT/PDF files, configure providers, watch progress, and view notes, coverage, usage, and cost reports. +SlideNote Studio is a Streamlit wrapper around the same CLI pipeline. It lets users upload PPT/PDF files, configure API keys in the page, run presets, monitor progress/ETA, inspect token and cost reports, review page-level source traces, and download the generated results. ```powershell python -m pip install -e ".[dev,llm,gui]" streamlit run gui/app.py ``` -The GUI passes API keys through subprocess environment variables, not command-line arguments. See [gui/README_GUI.md](gui/README_GUI.md) for details. +GUI highlights: + +- **API setup without command-line env edits:** API keys entered in the page are passed to the build subprocess through environment variables and are not written to source code or command-line arguments. +- **Speed and cost controls:** exposes `--speed-mode`, global `--concurrency`, `--llm-concurrency`, `--vision-concurrency`, `--ocr-concurrency`, `--figure-concurrency`, shared global cache, OCR/Vision target caps, and page-level `--refresh-pages`. +- **Runtime visibility:** shows readable API states (`Off`, `Missing key`, `Ready`), progress, ETA estimate, stage output, `run_summary.json`, usage files, and token/cost dashboards. +- **Doctor panel:** runs the same environment checks as `slidenote doctor` and shows missing dependencies/API setup hints in the GUI. +- **Review workspace:** provides page explorer views that link original page screenshots, parsed elements, generated notes, page modality, coverage quality, and a manual modality correction manifest. +- **Save and export UX:** supports the default `gui_runs/outputs` workspace or a custom output folder. The sidebar can request `notes.toc.md`, Word `notes.docx`, PDF `notes.pdf`, and LaTeX `notes.tex`, and the results area offers one-click downloads for generated exports and the full result ZIP. Word/LaTeX require Pandoc; PDF is generated from the Word document through LibreOffice for more reliable Chinese/CJK layout. The GUI warns before the run when Pandoc or LibreOffice is missing. + +See [gui/README_GUI.md](gui/README_GUI.md) for details. + +> GUI export note: Markdown TOC export does not need extra tools. Word and LaTeX use Pandoc, while PDF is produced by converting `notes.docx` with LibreOffice; `export_report.json` records success/failure details. PDF export prefers DOCX → LibreOffice PDF instead of Markdown → LaTeX PDF, because this is much more stable for Chinese/CJK notes. LaTeX remains available as a technical source export. ## Features @@ -85,7 +96,7 @@ The GUI passes API keys through subprocess environment variables, not command-li - Produces `content.json` as the source inventory. - Produces `notes.md` with hidden source markers by default, plus optional visible page references. - Produces `coverage.json` / `coverage.md` to flag elements that may be missing from the notes. -- Optional exports can generate `notes.toc.md`, `notes.docx`, `notes.pdf`, and `notes.tex`; Word/PDF/LaTeX require Pandoc. +- Optional exports can generate `notes.toc.md`, `notes.docx`, `notes.pdf`, and `notes.tex`; Word/LaTeX require Pandoc. PDF requires Pandoc plus LibreOffice because it is converted from notes.docx for better CJK layout. - Optional vision extraction writes OCR text and visual summaries back into the structured content. - Optional LLM generation supports OpenAI/ChatGPT, DeepSeek, Qwen, Doubao/Volcengine Ark, GLM, Gemini, and Claude. - Optional `lecture-weave` note strategy first generates detailed per-page explanations, then weaves them into coherent sections. @@ -192,7 +203,8 @@ PowerShell `$env:...="..."` values only apply to the current terminal session. F | Understand diagrams, screenshots, charts, and visual layouts | Configure a vision model API and run with `--vision auto` | | Preserve PPTX full-slide screenshots | Install LibreOffice, or install PowerPoint + `pywin32` on Windows | | Process old `.ppt` files | Recommended: install LibreOffice; fallback: manually export to PDF | -| Export Word, PDF, or LaTeX notes | Install Pandoc and run with `--export docx,pdf,latex` | +| Export Word or LaTeX notes | Install Pandoc and run with `--export docx,latex` | +| Export PDF notes | Install Pandoc + LibreOffice and run with `--export pdf`; PDF is converted from `notes.docx` for better CJK layout | ## Installation @@ -293,7 +305,7 @@ By default, `notes.md` references bundled image copies under `notes.assets/`. If `content_guard.json` is generated by default when `--content-guard auto` is enabled. Without `--use-llm`, it records a local heuristic review. With `--use-llm`, SlideNote first preselects candidate tables, formulas, definitions, conditions, OCR text, visual summaries, and non-decorative figures, then asks the text model to classify page roles and element learning roles. Only high-confidence `must_explain` items count toward `required_visible_coverage` and can trigger one natural repair pass; low-confidence items remain audit information. -Extra exports are opt-in. `--export markdown-toc` writes `notes.toc.md` without Pandoc. `--export docx,pdf,latex` uses Pandoc to write `notes.docx`, `notes.pdf`, and `notes.tex`; conversion status and any Pandoc errors are written to `export_report.json`. +Extra exports are opt-in. `--export markdown-toc` writes `notes.toc.md` without Pandoc. `--export docx,latex` uses Pandoc to write `notes.docx` and `notes.tex`; `--export pdf` first builds `notes.docx` with Pandoc and then converts it to `notes.pdf` with LibreOffice; conversion status and any Pandoc errors are written to `export_report.json`. ## Environment Check diff --git a/README.zh-CN.md b/README.zh-CN.md index 6d345cf..bc90d6e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,14 +63,25 @@ python -m slidenote build path\to\lecture.pdf --out outputs\lecture --use-llm -- ## 可选 GUI -SlideNote Studio 是套在同一条 CLI pipeline 外面的 Streamlit 图形界面。它支持上传 PPT/PDF、配置 provider、查看进度,并预览 notes、coverage、usage 和成本报告。 +SlideNote Studio 是套在同一条 CLI pipeline 外面的 Streamlit 图形界面。它支持上传 PPT/PDF、在页面内配置 API key、选择运行预设、查看进度/ETA、查看 token 与成本报告、做逐页溯源检查,并下载生成结果。 ```powershell python -m pip install -e ".[dev,llm,gui]" streamlit run gui/app.py ``` -GUI 会通过子进程环境变量传递 API key,不把 key 放进命令行参数。更多说明见 [gui/README_GUI.zh-CN.md](gui/README_GUI.zh-CN.md)。 +GUI 亮点: + +- **不用命令行配置 API:** 页面中输入的 API key 会通过子进程环境变量传给本次 build,不写入源码,也不放进命令行参数。 +- **速度与成本控制:** 暴露 `--speed-mode`、总并发 `--concurrency`、`--llm-concurrency`、`--vision-concurrency`、`--ocr-concurrency`、`--figure-concurrency`、共享全局缓存、OCR/Vision 目标上限和按页 `--refresh-pages`。 +- **运行可观测:** 用更直观的 `Off` / `Missing key` / `Ready` 显示 API 状态,并展示进度、ETA、阶段日志、`run_summary.json`、usage 文件和 token/cost dashboard。 +- **Doctor 面板:** 在 GUI 内运行与 `slidenote doctor` 同源的环境检查,并展示缺失依赖和 API 配置建议。 +- **审阅工作台:** Page explorer 可以联动查看原页截图、解析元素、生成笔记、页面类型、coverage 质量,并保存页面类型人工修正清单。 +- **保存与导出体验:** 支持默认 `gui_runs/outputs` 工作区,也支持自定义输出目录;可在侧边栏直接勾选导出 `notes.toc.md`、Word `notes.docx`、PDF `notes.pdf` 和 LaTeX `notes.tex`,生成后可一键下载单个文件或完整结果 ZIP。Word/LaTeX 需要 Pandoc;PDF 会优先通过 LibreOffice 将 notes.docx 转成 notes.pdf,以保证中文/CJK 排版更稳定。GUI 会提前提示缺失依赖。 + +更多说明见 [gui/README_GUI.zh-CN.md](gui/README_GUI.zh-CN.md)。 + +> GUI 导出说明:Markdown 目录版不需要额外依赖;Word 和 LaTeX 使用 Pandoc;PDF 会先生成 `notes.docx`,再通过 LibreOffice 转成 `notes.pdf`;`export_report.json` 会记录成功/失败原因。PDF 导出不再默认走 Markdown → LaTeX → PDF,而是优先走 DOCX → LibreOffice PDF,这样中文和图片排版更稳定;LaTeX 仍保留为技术型源码导出。 ## 功能 @@ -86,7 +97,7 @@ GUI 会通过子进程环境变量传递 API key,不把 key 放进命令行参 - 生成 `content.json` 作为原始内容清单。 - 生成 `notes.md`,默认隐藏来源标记,也可选择显示简洁页码或详细元素 ID。 - 生成 `coverage.json` / `coverage.md`,检查哪些元素没有出现在笔记中。 -- 可选导出 `notes.toc.md`、`notes.docx`、`notes.pdf` 和 `notes.tex`;Word/PDF/LaTeX 需要安装 Pandoc。 +- 可选导出 `notes.toc.md`、`notes.docx`、`notes.pdf` 和 `notes.tex`;Word/LaTeX 需要安装 Pandoc;PDF 需要 Pandoc 和 LibreOffice。 - 支持多家 LLM:ChatGPT/OpenAI、DeepSeek、通义千问、豆包、GLM、Gemini、Claude。 - 支持 `lecture-weave` 高质量笔记策略:先逐页深讲,再按章节编织成连贯笔记。 - 支持控制笔记输出语言和术语策略:英文课件可以生成中文或英文笔记,中文笔记可保留英文专业术语。 @@ -294,7 +305,7 @@ outputs/lecture/ `content_guard.json` 默认由 `--content-guard auto` 生成。不开 `--use-llm` 时,它只记录本地启发式审查;开启 `--use-llm` 后,SlideNote 会先本地预筛表格、公式、定义、条件、OCR 关键文本、视觉摘要和非装饰图片,再让文本模型判断页面角色和元素级学习角色。只有高置信 `must_explain` 元素会进入 `required_visible_coverage` 并触发最多一次自然修复;低置信元素只保留在审查报告里。 -额外导出默认关闭。`--export markdown-toc` 不需要 Pandoc,会写出带目录的 `notes.toc.md`。`--export docx,pdf,latex` 会调用 Pandoc 生成 `notes.docx`、`notes.pdf` 和 `notes.tex`;转换结果和错误摘要会写入 `export_report.json`。 +额外导出默认关闭。`--export markdown-toc` 不需要 Pandoc,会写出带目录的 `notes.toc.md`。`--export docx,pdf,latex` 会调用 Pandoc 生成 `notes.docx` 和 `notes.tex`,并用 LibreOffice 将 `notes.docx` 转为 `notes.pdf`;转换结果和错误摘要会写入 `export_report.json`。 ## 环境检测 diff --git a/ROADMAP.zh-CN.md b/ROADMAP.zh-CN.md index b5db6b9..42767cc 100644 --- a/ROADMAP.zh-CN.md +++ b/ROADMAP.zh-CN.md @@ -39,7 +39,7 @@ SlideNote 当前的核心定位仍然是: - 支持 Deck Brief 课程全景图:`--deck-brief auto` 可用 LLM 生成课程主题、核心问题、概念依赖关系和页面角色划分,作为后续笔记生成的全局导航。 - 支持 Figure Grounding 图文锚定:`--figure-grounding local|vision` 将图片锚定到页面内最近的文本或表格元素,让笔记中的图片出现在相关概念附近而非堆积在页尾。输出 `figure_grounding.json`。 - 支持 Content Guard:`--content-guard auto` 识别高置信关键学习内容,并对 required visible coverage 做一次自然修复。 -- 支持额外导出:带目录 Markdown、Word、PDF、LaTeX;Word/PDF/LaTeX 通过可选 Pandoc。 +- 支持额外导出:带目录 Markdown、Word、PDF、LaTeX;Word/LaTeX 通过 Pandoc,PDF 通过 DOCX → LibreOffice 转换以改善中文/CJK 排版。 - 支持基础 SlideNote Studio GUI 和成本报告:上传文件、配置 provider、查看进度、预览输出和 token/cost dashboard。 ## 1. 上下文策略增强 @@ -373,6 +373,9 @@ lecture-weave 第二阶段按 section 编织 基础版已经合入 SlideNote Studio:一个 Streamlit GUI,包装现有 `python -m slidenote build` pipeline,不改核心解析和生成逻辑。它已经支持上传 PPT/PDF、选择预设、填写 provider/API key、配置 OCR/Vision/LLM、设置缓存和并发、查看 `progress.json`、预览 notes/coverage/run summary,并生成 token/cost 报告。 +**2026-05 GUI 导出补齐:** Studio 现在可以在侧边栏直接选择 Markdown TOC、Word、PDF 和 LaTeX 导出,并在结果区提供 Exports 标签页、单文件下载和完整 ZIP 下载。Pandoc 或 LibreOffice 缺失时 GUI 会提前提示,不影响基础 notes.md 生成;转换状态写入 `export_report.json`。 + + 当前基础版已经实现: - 降低 API key 配置门槛。 @@ -1471,18 +1474,24 @@ CLI 对新手不够友好,尤其是多个服务商都要 key。 ### P0:近期最值得做 -已完成基础版,后续增强: +已完成或已具备基础可用能力: -- `slidenote doctor` 环境检测:已实现完整命令行诊断;后续补 GUI 一键诊断和更友好的 API key 配置引导。 -- 运行进度系统:CLI 实时进度、`progress.json`、`run_summary.json` 已完成;后续补 ETA、失败恢复和更细阶段统计。 -- 加速与成本调度:`--speed-mode`、总并发、LLM/Vision/OCR/Figure 细分并发、`--global-cache-dir`、`--refresh-pages` 和临时错误重试已完成;后续补自动限速、只重跑指定小节。 -- SlideNote Studio GUI:基础 Streamlit GUI、上传文件、运行预设、进度预览、成本报告已完成;后续补原页/元素/笔记联动视图、doctor 面板和局部 refresh。 -- 分层生成策略:Lecture-Weave(`--note-strategy lecture-weave`)已是默认策略;后续补质量评分、自动补回遗漏细节。 -- 页面类型检测与处理路由:`page_modalities.json` 已实现;后续补更准的版面分析和 GUI 手动修正。 -- 视觉目标选择:装饰图过滤、figure crop 优先级、图片学习价值排序已实现;后续补内容图分类和更强版面分析。 +- `slidenote doctor` 环境检测:已实现完整命令行诊断;SlideNote Studio 已补 GUI Doctor 面板,可在页面内查看 Python、核心依赖、可选导出工具、LLM/OCR/Vision API key readiness,并给出基础修复建议。 +- 运行进度系统:CLI 实时进度、`progress.json`、`run_summary.json` 已完成;SlideNote Studio 已补 live run 状态、elapsed、ETA 估算、运行日志和结果预览。 +- 加速与成本调度:`--speed-mode`、总并发、LLM/Vision/OCR/Figure 细分并发、`--global-cache-dir`、`--refresh-pages` 和临时错误重试已完成;SlideNote Studio 已暴露 speed mode、总并发、细分并发、cache、refresh pages、OCR/Vision target 限制和 token/cost dashboard。 +- SlideNote Studio GUI:已支持 Streamlit GUI、上传文件、运行预设、页面内 API key 配置、进度预览、成本报告、自定义保存目录、完整结果 ZIP 下载、Doctor 面板、Quality 面板、Page explorer 原页/元素/笔记联动视图,以及 page modality 手动修正 manifest。 +- 分层生成策略:Lecture-Weave(`--note-strategy lecture-weave`)已是默认策略;GUI 已暴露 note strategy、note depth、note context、section detection、deck brief、content guard 等控制项,并在 Quality 面板中展示 coverage score、missing elements 和 repair queue。 +- 页面类型检测与处理路由:`page_modalities.json` 已实现;GUI 已支持查看每页 modality,并可保存 `page_modalities.overrides.json` 作为人工修正清单。 +- 视觉目标选择:装饰图过滤、figure crop 优先级、图片学习价值排序已实现;GUI 已暴露 Vision/OCR/Figure 相关 target、detail、edge、crop 设置,并可结合 Page explorer 查看页面截图与解析元素。 -仍待实现: +仍待实现或进一步增强: +- 自动限速:根据 provider rate limit、错误类型和重试历史自动调整并发。 +- 真正的小节级局部重跑:目前 GUI 可传 `--refresh-pages` 做页级 refresh;后续补 section/chapter-level refresh 与依赖结果复用。 +- 失败恢复增强:目前支持日志、失败提示和复用输出目录局部重跑;后续补断点续跑、失败阶段回滚和任务恢复。 +- 更细阶段统计:后续在 GUI 中展示 parsing / OCR / Vision / LLM / coverage / export 的耗时占比和失败统计。 +- 质量评分自动闭环:目前 GUI 展示 coverage 与 repair queue;后续补自动补回遗漏细节、二次生成和质量评分阈值。 +- 更强版面分析与内容图分类:后续补更准的图表/流程图/表格/装饰图分类,以及 GUI 中人工选择 OCR/Vision 页和目标图。 - 课程工作区基础模型:Course / Source / Chapter。 - 多 PPT / 多 PDF 课程级整合。 - PPT 章节切分与分批输出:`split` 子命令、`--split-by section`、`section_index.json` 等。 diff --git a/gui/README_GUI.md b/gui/README_GUI.md index 6382316..43f48c3 100644 --- a/gui/README_GUI.md +++ b/gui/README_GUI.md @@ -2,26 +2,38 @@ [中文说明](README_GUI.zh-CN.md) -This GUI wraps the existing `python -m slidenote build` pipeline with a Streamlit page. It does not replace or modify the core parser/LLM pipeline. +SlideNote Studio is a Streamlit UI around the existing `python -m slidenote build` pipeline. It does not replace or modify the core parser, OCR, vision, or LLM pipeline. ## What it adds - Upload PPTX / PPT / PDF without touching the command line. -- Paste API keys inside the page for one run; keys are not written to source code. +- Paste API keys inside the page for one run; keys are passed through subprocess environment variables and are not written to source code or command-line arguments. - Choose workflow presets: - Fast API draft - Balanced study notes - Quality detailed notes - Local safe preview -- Control runtime speed: - - concurrency +- Control runtime speed and cost: + - `--speed-mode` + - global `--concurrency` - separate LLM / Vision / OCR / Figure API concurrency - - global cache - - OCR/Vision max target limits - - direct vs lecture-weave note strategy + - shared global cache + - OCR/Vision target limits + - `direct` vs `lecture-weave` - deck brief/content guard switches -- Watch `progress.json` during the run. -- View `notes.md`, `coverage.md`, `run_summary.json`, usage files, and token/cost reports. + - page-level `--refresh-pages` +- Readable runtime cards: `Off`, `Missing key`, or `Ready` for text/vision APIs, plus concurrency and cache state. +- Doctor panel that reuses `slidenote doctor` checks for Python, packages, optional tools, and API key readiness. +- Live progress with ETA estimate, stage detail, and console logs. +- Result workspace with: + - quality/coverage summary + - missing-element repair queue + - page explorer linking screenshot, parsed elements, page modality, and note block + - manual modality correction manifest (`page_modalities.overrides.json`) + - token/cost dashboard + - `notes.md`, `coverage.md`, `run_summary.json`, usage files, and raw outputs +- Save outputs to the default `gui_runs/outputs` workspace or a custom local folder. +- Download `notes.md`, `coverage.md`, `cost_report.md`, or the full result ZIP from the page. ## Install @@ -36,12 +48,29 @@ If you use LLM providers: python -m pip install -e ".[llm]" ``` +Or install everything for development: + +```bash +python -m pip install -e ".[dev,llm,gui]" +``` + ## Run ```bash streamlit run gui/app.py ``` +## First safe test + +Use **Local safe preview** first: + +- Use text LLM: off +- OCR mode: off +- Vision mode: off +- Save: Default workspace or Custom folder + +This checks local parsing and output rendering without API calls or token cost. + ## API keys The GUI passes API keys to one run through subprocess environment variables, not command-line arguments. This avoids asking users to set terminal environment variables manually while keeping keys out of process argv. @@ -50,23 +79,36 @@ Do not commit keys to GitHub. ## Speed tips -For first tests, use **Local safe preview** or **Fast API draft**. +For first API tests, use **Fast API draft** with OCR and Vision off. To reduce runtime: -1. Use `direct` note strategy instead of `lecture-weave`. +1. Use `direct` note strategy for drafts; use `lecture-weave` when you need higher quality connected notes. 2. Use `vision=auto`, not `vision=all`. 3. Use `ocr=auto`, not `ocr=all`. 4. Set `Vision max targets` and `OCR max targets` to small numbers for testing. 5. Keep cache enabled and use the shared global cache. 6. Increase concurrency to 3-6 only when the API provider rate limit allows it. +7. Use `Refresh only these pages` for small reruns instead of rerunning the whole deck. ## Token and cost report -After a successful run, the GUI generates: +After a successful run, the GUI generates and displays: - `cost_report.json` - `cost_report.md` - `cost_dashboard.html` Prices are read from `pricing.template.json`. Keep the prices updated manually using official provider pricing pages. + + +## Export Word / PDF / LaTeX + +The sidebar section **5. Exports** can request optional final artifacts: + +- `notes.toc.md`: Markdown with table of contents; Pandoc is not required. +- `notes.docx`: Word document; requires Pandoc. +- `notes.pdf`: PDF handout; generated by converting `notes.docx` with LibreOffice for better Chinese/CJK layout. Requires Pandoc plus LibreOffice. +- `notes.tex`: LaTeX source; requires Pandoc. + +After a run, the results area shows an **Exports** tab and quick download buttons for available Word / PDF / LaTeX files. Export status is written to `export_report.json`. If Pandoc or LibreOffice is missing from PATH, the GUI warns before the run while still allowing the base `notes.md` build to complete. diff --git a/gui/README_GUI.zh-CN.md b/gui/README_GUI.zh-CN.md index c14854f..3a9d67c 100644 --- a/gui/README_GUI.zh-CN.md +++ b/gui/README_GUI.zh-CN.md @@ -7,21 +7,33 @@ SlideNote Studio 是一个基于 Streamlit 的图形界面。它包装现有的 ## 它能做什么 - 不用写命令行,直接上传 PPTX / PPT / PDF。 -- 在页面里临时填写 API key;key 不会写入源码。 +- 在页面里临时填写 API key;key 会通过子进程环境变量传给本次运行,不写入源码,也不放进命令行参数。 - 选择常用工作流预设: - Fast API draft:快速 API 草稿 - Balanced study notes:平衡版学习笔记 - Quality detailed notes:高质量详细笔记 - Local safe preview:本地安全预览 - 控制运行速度和成本: - - 总并发 + - `--speed-mode` + - 总并发 `--concurrency` - LLM / Vision / OCR / Figure 四类 API 独立并发 - - 全局缓存目录 + - shared global cache - OCR / Vision 最大目标数量 - `direct` 或 `lecture-weave` 笔记策略 - deck brief / content guard 开关 -- 运行时查看 `progress.json` 进度。 -- 在页面里预览 `notes.md`、`coverage.md`、`run_summary.json`、usage 文件和 token/cost 报告。 + - 按页 `--refresh-pages` 局部刷新 +- 更直观的运行状态卡片:Text API / Vision API 会显示 `Off`、`Missing key` 或 `Ready`,不会再显示难懂的 `n...`。 +- Doctor 面板:复用 `slidenote doctor` 的环境检查,展示 Python、依赖包、可选工具和 API key readiness。 +- Live run:显示进度、ETA 估算、当前阶段和控制台日志。 +- 结果审阅工作台: + - quality / coverage 总览 + - missing element 修复队列 + - Page explorer:联动查看原页截图、解析元素、页面类型和对应笔记 + - 页面类型人工修正清单 `page_modalities.overrides.json` + - token/cost dashboard + - `notes.md`、`coverage.md`、`run_summary.json`、usage 文件和原始输出文件 +- 支持默认 `gui_runs/outputs` 工作区,也支持自定义本地输出目录。 +- 页面内一键下载 `notes.md`、`coverage.md`、`cost_report.md` 或完整结果 ZIP。 ## 安装 @@ -50,6 +62,17 @@ streamlit run gui/app.py 启动后在浏览器中打开 Streamlit 给出的本地地址,上传课件并选择运行参数即可。 +## 第一次安全测试 + +建议先用 **Local safe preview**: + +- Use text LLM: off +- OCR mode: off +- Vision mode: off +- Save: Default workspace 或 Custom folder + +这个模式只测试本地解析和输出展示,不会调用 API,也不会产生 token 成本。 + ## API key GUI 会把 API key 通过子进程环境变量传给本次运行,不会把 key 放进命令行参数里。这样用户不需要手动配置终端环境变量,也能避免 key 暴露在进程 argv 中。 @@ -58,7 +81,7 @@ GUI 会把 API key 通过子进程环境变量传给本次运行,不会把 key ## 加速建议 -第一次测试建议使用 **Local safe preview** 或 **Fast API draft**。 +第一次测试 API 建议用 **Fast API draft**,并先关闭 OCR 和 Vision。 想减少运行时间,可以优先这样调: @@ -67,14 +90,27 @@ GUI 会把 API key 通过子进程环境变量传给本次运行,不会把 key 3. 使用 `ocr=auto`,只有扫描版 PDF 或低文本页很多时再考虑 `ocr=all`。 4. 测试时把 `Vision max targets` 和 `OCR max targets` 设小一点。 5. 保持缓存开启,并使用 shared global cache。 -6. API 服务商限流允许时,把并发提高到 3 到 6;如果遇到限流,先降到 2 或 3,不要直接关闭质量阶段。 +6. API 服务商限流允许时,把并发提高到 3 到 6;如果遇到限流,先降到 2 或 3。 +7. 如果只改少数页面,用 `Refresh only these pages` 做局部重跑,不要整份课件重跑。 ## Token 和成本报告 -构建成功后,GUI 会尝试生成: +构建成功后,GUI 会尝试生成并展示: - `cost_report.json` - `cost_report.md` - `cost_dashboard.html` 成本估算会读取 `pricing.template.json`。模板里的价格需要你按官方价格页面手动更新;默认价格可能是 0,所以默认报告更适合作为统计框架,而不是准确账单。 + + +## 导出 Word / PDF / LaTeX + +GUI 侧边栏的 **5. Exports** 可以直接勾选额外导出格式: + +- `notes.toc.md`:带目录 Markdown,不需要 Pandoc。 +- `notes.docx`:Word 文档,需要 Pandoc。 +- `notes.pdf`:PDF 讲义,优先用 LibreOffice 将 `notes.docx` 转成 PDF,中文/CJK 和图片排版更稳定;需要 Pandoc + LibreOffice。 +- `notes.tex`:LaTeX 源码,需要 Pandoc。 + +导出完成后,结果区会出现 **Exports** 标签页,并在顶部下载区显示可用的 Word / PDF / LaTeX 下载按钮。所有导出状态会写入 `export_report.json`。如果 Pandoc 或 LibreOffice 不在 PATH 中,GUI 会在运行前提示安装命令,build 仍可继续生成基础 `notes.md`。 diff --git a/gui/app.py b/gui/app.py index 17055f6..a6ca27e 100644 --- a/gui/app.py +++ b/gui/app.py @@ -1,9 +1,14 @@ from __future__ import annotations +import io import json import os +import re +import shutil import subprocess import time +import zipfile +from datetime import datetime from pathlib import Path from typing import Any @@ -14,6 +19,11 @@ except Exception: # pragma: no cover - GUI fallback only write_cost_report = None +try: + from slidenote.doctor import run_doctor +except Exception: # pragma: no cover - GUI fallback only + run_doctor = None + from gui.studio_core import ( DEFAULT_MODELS, VISION_DEFAULT_MODELS, @@ -22,9 +32,9 @@ build_slidenote_command, command_for_display, discover_outputs, - masked_key_status, performance_tips, progress_percent, + provider_env_key, safe_run_name, ) @@ -102,17 +112,14 @@ }, } +MODALITY_OPTIONS = ["native_text", "mixed", "image_only", "shape_diagram", "decorative", "unknown"] + def main() -> None: st.set_page_config(page_title="SlideNote Studio", page_icon="📝", layout="wide") _style() - st.title("📝 SlideNote Studio") - st.caption("Upload slides, connect API keys in the page, run with concurrency/cache controls, and monitor tokens/costs without touching the command line.") - - RUNS_DIR.mkdir(exist_ok=True) - UPLOADS_DIR.mkdir(parents=True, exist_ok=True) - OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) - GLOBAL_CACHE_DIR.mkdir(parents=True, exist_ok=True) + _ensure_dirs() + _render_hero() with st.sidebar: st.header("1. File") @@ -138,6 +145,34 @@ def main() -> None: vision_model = st.text_input("Vision model", value=VISION_DEFAULT_MODELS.get(vision_provider, "")) vision_api_key = st.text_input("Vision API key", type="password", help="Can be the same as the text provider key if provider is the same.") + st.header("4. Save") + save_mode = st.radio( + "Output location", + ["Default workspace", "Custom folder"], + index=0, + help="Default saves under gui_runs/outputs. Custom lets you choose a parent folder on this computer.", + ) + custom_output_base_text = st.text_input( + "Custom output folder", + value=str(Path.home() / "Desktop" / "SlideNote_outputs"), + disabled=save_mode == "Default workspace", + help="Paste a local folder path, for example C:\\Users\\student\\Desktop\\SlideNote_outputs.", + ) + timestamped_subfolder = st.toggle( + "Create a timestamped subfolder", + value=True, + help="Recommended. Keeps each run separated and prevents old results from being overwritten.", + ) + + st.header("5. Exports") + st.caption("Optional final files generated from notes.md. Word/LaTeX need Pandoc; PDF is generated from Word via LibreOffice for stable Chinese/CJK layout. Markdown TOC works without Pandoc.") + export_markdown_toc = st.checkbox("Markdown with table of contents (.md)", value=True) + export_docx = st.checkbox("Word document (.docx)", value=False) + export_pdf = st.checkbox("PDF handout (.pdf)", value=False) + export_latex = st.checkbox("LaTeX source (.tex)", value=False) + export_options = _selected_export_formats(export_markdown_toc, export_docx, export_pdf, export_latex) + _render_export_readiness(export_options) + col_left, col_right = st.columns([0.95, 1.05], gap="large") with col_left: @@ -156,7 +191,14 @@ def main() -> None: figure_concurrency = st.number_input("Figure", min_value=1, max_value=20, value=int(concurrency), step=1) cache_mode = st.selectbox("LLM cache", ["on", "refresh", "off"], index=0) use_global_cache = st.toggle("Use shared global cache", value=True, help="Keeps OCR/Vision/LLM cache across different GUI runs.") - refresh_pages = st.text_input("Refresh only these pages", value="", placeholder="Example: 3,5-8", help="Bypass cache for selected slide IDs only.") + refresh_default = st.session_state.get("refresh_pages_value", "") + refresh_pages = st.text_input( + "Refresh only these pages", + value=refresh_default, + key="refresh_pages_text", + placeholder="Example: 3,5-8", + help="Bypass cache for selected slide IDs only. Use this for local page refresh after review.", + ) with st.expander("Quality and time controls", expanded=True): c1, c2 = st.columns(2) @@ -189,72 +231,74 @@ def main() -> None: with st.expander("Export and display", expanded=False): source_display = st.selectbox("Source display", ["hidden", "footnote", "inline"], index=0) screenshot_policy = st.selectbox("Screenshot policy", ["fallback", "never", "always"], index=0) - export_options = st.multiselect("Extra exports", ["markdown-toc", "docx", "pdf", "latex"], default=[]) + st.caption("Export formats are configured in the sidebar under '5. Exports'.") + st.write(", ".join(export_options) if export_options else "No extra exports selected") - if use_llm and not api_key: - st.warning("Text LLM is enabled but no API key is entered. You can still run only if the key already exists in your environment.") - if vision != "off" and not vision_api_key and vision_provider != provider: - st.info("Vision provider is different from text provider. Add a vision key or make sure it exists in environment variables.") + preview_config = StudioConfig( + input_path=ROOT / "example.pdf", + output_dir=OUTPUTS_DIR / "preview", + progress_json=OUTPUTS_DIR / "preview" / "progress.json", + speed_mode=str(speed_mode), + concurrency=int(concurrency), + llm_concurrency=int(llm_concurrency), + vision_concurrency=int(vision_concurrency), + ocr_concurrency=int(ocr_concurrency), + figure_concurrency=int(figure_concurrency), + global_cache_dir=GLOBAL_CACHE_DIR if use_global_cache else None, + refresh_pages=refresh_pages.strip() or None, + use_llm=bool(use_llm), + provider=provider, + model=model.strip() or None, + api_key=api_key or None, + base_url=base_url.strip() or None, + max_output_tokens=int(max_output_tokens), + temperature=float(temperature), + content_guard=content_guard, + note_context=note_context, + note_language=note_language, + term_policy=term_policy, + note_strategy=note_strategy, + note_depth=note_depth, + deck_brief=deck_brief, + section_detection=section_detection, + cache=cache_mode, + ocr=ocr, + ocr_provider=ocr_provider, + ocr_api_key=ocr_api_key or None, + ocr_secret_key=ocr_secret_key or None, + ocr_max_targets=int(ocr_max_targets), + ocr_max_edge=int(ocr_max_edge), + vision=vision, + vision_provider=vision_provider, + vision_model=vision_model.strip() or None, + vision_api_key=vision_api_key or (api_key if vision_provider == provider else None) or None, + vision_max_targets=int(vision_max_targets), + vision_max_edge=int(vision_max_edge), + vision_detail=vision_detail, + figure_crop=figure_crop, + figure_max_targets=int(figure_max_targets), + figure_grounding="auto", + source_display=source_display, + screenshot_policy=screenshot_policy, + export=",".join(export_options) if export_options else None, + ) with col_right: st.subheader("Connection and runtime overview") - metric_cols = st.columns(4) - metric_cols[0].metric("Text API", masked_key_status(api_key or os.getenv(f"{provider.upper()}_API_KEY"))) - metric_cols[1].metric("Vision API", masked_key_status(vision_api_key or os.getenv(f"{vision_provider.upper()}_API_KEY"))) - metric_cols[2].metric("Concurrency", concurrency) - metric_cols[3].metric("Cache", cache_mode) - - dummy_input = ROOT / "example.pdf" - dummy_out = OUTPUTS_DIR / "preview" - dummy_progress = dummy_out / "progress.json" - preview_config = StudioConfig( - input_path=dummy_input, - output_dir=dummy_out, - progress_json=dummy_progress, - speed_mode=speed_mode, - concurrency=concurrency, - llm_concurrency=int(llm_concurrency), - vision_concurrency=int(vision_concurrency), - ocr_concurrency=int(ocr_concurrency), - figure_concurrency=int(figure_concurrency), - global_cache_dir=GLOBAL_CACHE_DIR if use_global_cache else None, - refresh_pages=refresh_pages.strip() or None, - use_llm=use_llm, - provider=provider, - model=model.strip() or None, - api_key=api_key or None, - base_url=base_url.strip() or None, - max_output_tokens=int(max_output_tokens), - temperature=float(temperature), - content_guard=content_guard, - note_context=note_context, - note_language=note_language, - term_policy=term_policy, - note_strategy=note_strategy, - note_depth=note_depth, - deck_brief=deck_brief, - section_detection=section_detection, - cache=cache_mode, - ocr=ocr, - ocr_provider=ocr_provider, - ocr_api_key=ocr_api_key or None, - ocr_secret_key=ocr_secret_key or None, - ocr_max_targets=int(ocr_max_targets), - ocr_max_edge=int(ocr_max_edge), - vision=vision, - vision_provider=vision_provider, - vision_model=vision_model.strip() or None, - vision_api_key=vision_api_key or (api_key if vision_provider == provider else None) or None, - vision_max_targets=int(vision_max_targets), - vision_max_edge=int(vision_max_edge), - vision_detail=vision_detail, - figure_crop=figure_crop, - figure_max_targets=int(figure_max_targets), - figure_grounding="auto", - source_display=source_display, - screenshot_policy=screenshot_policy, - export=",".join(export_options) if export_options else None, - ) + text_status = _api_status(use_llm, api_key, provider) + vision_status = _api_status(_needs_vision_api(preview_config), preview_config.vision_api_key, vision_provider) + cache_status = _cache_status(cache_mode, use_global_cache) + concurrency_status = _concurrency_status(concurrency) + grid = st.columns(4) + with grid[0]: + _status_card("Text", *text_status, icon="✦") + with grid[1]: + _status_card("Vision", *vision_status, icon="◈") + with grid[2]: + _status_card("Workers", *concurrency_status, icon="⇄") + with grid[3]: + _status_card("Cache", *cache_status, icon="◎") + tips = performance_tips(preview_config) if tips: st.info("\n".join(f"• {tip}" for tip in tips)) @@ -264,58 +308,24 @@ def main() -> None: with st.expander("Generated command preview", expanded=False): st.code(command_for_display(build_slidenote_command(preview_config)), language="bash") + with st.expander("Doctor panel", expanded=False): + _render_doctor_panel(text_status=text_status, vision_status=vision_status) + run_clicked = st.button("🚀 Run SlideNote build", type="primary", use_container_width=True, disabled=uploaded is None) + if use_llm and not api_key and not os.getenv(provider_env_key(provider)): + st.warning("Text LLM is enabled but no API key is entered or configured in the environment.") + if _needs_vision_api(preview_config) and vision_status[0] == "Missing key": + st.warning("Vision is enabled but no vision API key was entered or found in the environment.") + if run_clicked and uploaded is not None: - input_path, output_dir, progress_json = _prepare_run_paths(uploaded) - config = StudioConfig( - input_path=input_path, - output_dir=output_dir, - progress_json=progress_json, - speed_mode=speed_mode, - concurrency=concurrency, - llm_concurrency=int(llm_concurrency), - vision_concurrency=int(vision_concurrency), - ocr_concurrency=int(ocr_concurrency), - figure_concurrency=int(figure_concurrency), - global_cache_dir=GLOBAL_CACHE_DIR if use_global_cache else None, - refresh_pages=refresh_pages.strip() or None, - use_llm=use_llm, - provider=provider, - model=model.strip() or None, - api_key=api_key or None, - base_url=base_url.strip() or None, - max_output_tokens=int(max_output_tokens), - temperature=float(temperature), - content_guard=content_guard, - note_context=note_context, - note_language=note_language, - term_policy=term_policy, - note_strategy=note_strategy, - note_depth=note_depth, - deck_brief=deck_brief, - section_detection=section_detection, - cache=cache_mode, - ocr=ocr, - ocr_provider=ocr_provider, - ocr_api_key=ocr_api_key or None, - ocr_secret_key=ocr_secret_key or None, - ocr_max_targets=int(ocr_max_targets), - ocr_max_edge=int(ocr_max_edge), - vision=vision, - vision_provider=vision_provider, - vision_model=vision_model.strip() or None, - vision_api_key=vision_api_key or (api_key if vision_provider == provider else None) or None, - vision_max_targets=int(vision_max_targets), - vision_max_edge=int(vision_max_edge), - vision_detail=vision_detail, - figure_crop=figure_crop, - figure_max_targets=int(figure_max_targets), - figure_grounding="auto", - source_display=source_display, - screenshot_policy=screenshot_policy, - export=",".join(export_options) if export_options else None, - ) + output_base = OUTPUTS_DIR if save_mode == "Default workspace" else Path(custom_output_base_text).expanduser() + try: + input_path, output_dir, progress_json = _prepare_run_paths(uploaded, output_base, timestamped_subfolder) + except Exception as exc: + st.error(f"Could not prepare output folder: {exc}") + return + config = _clone_config_for_run(preview_config, input_path=input_path, output_dir=output_dir, progress_json=progress_json) _run_build(config) st.session_state["last_output_dir"] = str(output_dir) @@ -325,11 +335,161 @@ def main() -> None: _render_results(last_output_dir) -def _prepare_run_paths(uploaded) -> tuple[Path, Path, Path]: +def _ensure_dirs() -> None: + RUNS_DIR.mkdir(exist_ok=True) + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) + GLOBAL_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + +def _render_hero() -> None: + st.markdown( + """ +
+
+
+
AI course notes · local-first workspace
+

📝 SlideNote Studio

+

Upload slides, connect API keys in the page, run with concurrency/cache controls, and monitor tokens, coverage and cost without touching the command line.

+
+ """, + unsafe_allow_html=True, + ) + + +def _selected_export_formats(markdown_toc: bool, docx: bool, pdf: bool, latex: bool) -> list[str]: + selected: list[str] = [] + if markdown_toc: + selected.append("markdown-toc") + if docx: + selected.append("docx") + if pdf: + selected.append("pdf") + if latex: + selected.append("latex") + return selected + + +def _render_export_readiness(export_options: list[str]) -> None: + needs_pandoc = any(fmt in export_options for fmt in ("docx", "pdf", "latex")) + needs_libreoffice = "pdf" in export_options + pandoc_path = shutil.which("pandoc") + libreoffice_path = _find_libreoffice() + if needs_pandoc and not pandoc_path: + st.warning("Word/LaTeX/PDF exports need Pandoc. Install Pandoc, then rerun. Markdown TOC still works.") + st.code("winget install JohnMacFarlane.Pandoc", language="powershell") + elif needs_pandoc: + st.success(f"Pandoc ready: {pandoc_path}") + else: + st.caption("No Pandoc needed for the selected exports.") + if needs_libreoffice and not libreoffice_path: + st.warning("PDF export now converts notes.docx with LibreOffice for better Chinese/CJK layout. Install LibreOffice if notes.pdf is needed.") + st.code("winget install -e --id TheDocumentFoundation.LibreOffice", language="powershell") + elif needs_libreoffice: + st.success(f"LibreOffice ready for PDF: {libreoffice_path}") + + +def _find_libreoffice() -> str | None: + for executable in ("soffice", "soffice.com", "libreoffice"): + found = shutil.which(executable) + if found: + return found + return None + + +def _api_status(enabled: bool, typed_key: str | None, provider: str) -> tuple[str, str, str]: + if not enabled: + return "Off", "Not used", "muted" + env_key = provider_env_key(provider) + if typed_key or os.getenv(env_key): + source = "page key" if typed_key else env_key + return "Ready", source, "good" + return "Missing key", env_key, "bad" + + +def _cache_status(cache_mode: str, use_global_cache: bool) -> tuple[str, str, str]: + if cache_mode == "off": + return "Off", "Disabled", "muted" + if cache_mode == "refresh": + return "Refresh", "Bypass selected", "muted" + return "On", "Shared" if use_global_cache else "Local", "good" + + +def _concurrency_status(concurrency: int) -> tuple[str, str, str]: + detail = "worker" if int(concurrency) == 1 else "workers" + return str(concurrency), detail, "good" if int(concurrency) <= 4 else "muted" + + +def _status_card(label: str, status: str, detail: str, tone: str, icon: str = "•") -> None: + st.markdown( + f""" +
+
{icon}{label}
+
{status}
+
{detail}
+
+ """, + unsafe_allow_html=True, + ) + + +def _needs_vision_api(config: StudioConfig) -> bool: + return config.vision != "off" or config.figure_crop == "vision" or config.figure_grounding == "vision" + + +def _render_doctor_panel(text_status: tuple[str, str, str], vision_status: tuple[str, str, str]) -> None: + if run_doctor is None: + st.warning("Doctor module is unavailable in this environment.") + return + report = run_doctor() + summary = report.get("summary", {}) + ready = report.get("gui", {}) + d1, d2, d3, d4 = st.columns(4) + d1.metric("Required missing", summary.get("required_missing", 0)) + d2.metric("Warnings", summary.get("warn", 0)) + d3.metric("Local parse", "Ready" if ready.get("ready_for_local_parse") else "Blocked") + d4.metric("Text API", text_status[0]) + + if summary.get("required_missing", 0): + st.error("Required dependencies are missing. Local parsing may fail.") + else: + st.success("Required local parsing dependencies look ready.") + st.caption(f"Vision API: {vision_status[0]} · PPT screenshots: {'Ready' if ready.get('ready_for_ppt_screenshots') else 'Optional tool missing'} · Exports: {'Ready' if ready.get('ready_for_exports') else 'Pandoc optional'} · PDF prefers LibreOffice") + + checks = report.get("checks", []) + if checks: + compact = [ + { + "name": item.get("name"), + "status": item.get("status"), + "category": item.get("category"), + "detail": item.get("detail"), + "fix": item.get("fix") or "", + } + for item in checks + ] + st.dataframe(compact, use_container_width=True, hide_index=True) + actions = report.get("recommended_actions") or [] + if actions: + with st.expander("Recommended fixes", expanded=False): + for action in actions: + st.markdown(f"**{action.get('title')}** — {action.get('detail')}") + if action.get("fix"): + st.code(str(action.get("fix")), language="bash") + + +def _clone_config_for_run(config: StudioConfig, input_path: Path, output_dir: Path, progress_json: Path) -> StudioConfig: + values = {field: getattr(config, field) for field in StudioConfig.__dataclass_fields__} + values.update({"input_path": input_path, "output_dir": output_dir, "progress_json": progress_json}) + return StudioConfig(**values) + + +def _prepare_run_paths(uploaded, output_base: Path, timestamped_subfolder: bool) -> tuple[Path, Path, Path]: run_name = f"{safe_run_name(uploaded.name)}_{int(time.time())}" input_path = UPLOADS_DIR / f"{run_name}{Path(uploaded.name).suffix.lower()}" input_path.write_bytes(uploaded.getbuffer()) - output_dir = OUTPUTS_DIR / run_name + output_base.mkdir(parents=True, exist_ok=True) + output_dir = output_base / run_name if timestamped_subfolder else output_base output_dir.mkdir(parents=True, exist_ok=True) progress_json = output_dir / "progress.json" return input_path, output_dir, progress_json @@ -374,9 +534,9 @@ def _run_build(config: StudioConfig) -> None: if process.returncode == 0: _generate_cost_report(config.output_dir) - st.success(f"Build finished: {config.output_dir}") + st.success(f"Build finished. Output saved to: {config.output_dir}") else: - st.error(f"Build failed with exit code {process.returncode}. Check the log above.") + st.error(f"Build failed with exit code {process.returncode}. Check the log above. You can keep the same output folder and use Refresh only these pages for a partial retry.") def _update_progress_ui(progress_path: Path, progress_bar, status_box, stage_box) -> None: @@ -388,8 +548,10 @@ def _update_progress_ui(progress_path: Path, progress_bar, status_box, stage_box progress_bar.progress(min(max(pct, 0.0), 1.0)) status = progress.get("status", "running") message = progress.get("message", "") - elapsed = progress.get("elapsed_seconds", 0) - status_box.markdown(f"**Status:** `{status}` · **Elapsed:** `{elapsed}` s · {message}") + elapsed = float(progress.get("elapsed_seconds", 0) or 0) + eta = _estimate_eta(pct, elapsed, status) + eta_text = f" · **ETA:** `{eta}`" if eta else "" + status_box.markdown(f"**Status:** `{status}` · **Elapsed:** `{elapsed:.1f}s`{eta_text} · {message}") current = progress.get("current_stage") if current: stage_box.json(current) @@ -399,6 +561,17 @@ def _update_progress_ui(progress_path: Path, progress_bar, status_box, stage_box stage_box.json(stages[-1]) +def _estimate_eta(pct: float, elapsed: float, status: str) -> str | None: + if status == "complete": + return "0s" + if status == "failed" or pct <= 0.05 or elapsed <= 0: + return None + remaining = max(elapsed / pct - elapsed, 0) + if remaining < 60: + return f"{remaining:.0f}s" + return f"{remaining / 60:.1f}m" + + def _generate_cost_report(output_dir: Path) -> None: if write_cost_report is None: return @@ -410,49 +583,320 @@ def _generate_cost_report(output_dir: Path) -> None: def _render_results(output_dir: Path) -> None: st.subheader("Results") + st.markdown(f"
Output saved to
{output_dir}
", unsafe_allow_html=True) outputs = discover_outputs(output_dir) - tab_names = ["Token & cost", "Notes", "Coverage", "Run summary", "Files"] + _render_quick_downloads(output_dir, outputs) + tab_names = ["Quality", "Page explorer", "Token & cost", "Exports", "Notes", "Coverage", "Run summary", "Files"] tabs = st.tabs(tab_names) with tabs[0]: - cost = _read_json(output_dir / "cost_report.json") - if cost: - summary = cost.get("summary", {}) - c1, c2, c3, c4, c5 = st.columns(5) - c1.metric("Estimated cost", f"{summary.get('estimated_cost', 0):.6f} {cost.get('currency', 'USD')}") - c2.metric("Calls", summary.get("calls", 0)) - c3.metric("Input tokens", f"{summary.get('input_tokens', 0):,}") - c4.metric("Output tokens", f"{summary.get('output_tokens', 0):,}") - c5.metric("Cache hits", summary.get("local_cache_hits", 0)) - stages = cost.get("stages", []) - if stages: - st.dataframe(stages, use_container_width=True) - chart_data = {stage.get("name", "stage"): stage.get("total_tokens", 0) for stage in stages} - st.bar_chart(chart_data) - cost_md = output_dir / "cost_report.md" - if cost_md.exists(): - with st.expander("cost_report.md", expanded=False): - st.markdown(cost_md.read_text(encoding="utf-8")) - else: - st.info("No cost report yet. Run a build first.") - + _render_quality_panel(output_dir) with tabs[1]: - _render_markdown_file(output_dir / "notes.md", "notes.md") + _render_page_explorer(output_dir) with tabs[2]: - _render_markdown_file(output_dir / "coverage.md", "coverage.md") + _render_cost_tab(output_dir) with tabs[3]: - for filename in ["run_summary.json", "llm_usage.json", "vision_usage.json", "ocr_usage.json", "progress.json"]: - path = output_dir / filename - if path.exists(): - with st.expander(filename, expanded=filename == "run_summary.json"): - st.json(_read_json(path) or {}) + _render_exports_tab(output_dir) with tabs[4]: + _render_markdown_file(output_dir / "notes.md", "notes.md") + with tabs[5]: + _render_markdown_file(output_dir / "coverage.md", "coverage.md") + with tabs[6]: + _render_run_summary_tab(output_dir) + with tabs[7]: st.write(f"Output directory: `{output_dir}`") for path in sorted(output_dir.glob("*")): if path.is_file(): _download_file(path) +def _render_quick_downloads(output_dir: Path, outputs: dict[str, Path]) -> None: + c1, c2, c3, c4 = st.columns(4) + if outputs.get("notes"): + c1.download_button("Download notes.md", data=outputs["notes"].read_bytes(), file_name="notes.md", mime="text/markdown", use_container_width=True) + else: + c1.button("notes.md not found", disabled=True, use_container_width=True) + if outputs.get("coverage"): + c2.download_button("Download coverage.md", data=outputs["coverage"].read_bytes(), file_name="coverage.md", mime="text/markdown", use_container_width=True) + else: + c2.button("coverage.md not found", disabled=True, use_container_width=True) + if outputs.get("cost_markdown"): + c3.download_button("Download cost_report.md", data=outputs["cost_markdown"].read_bytes(), file_name="cost_report.md", mime="text/markdown", use_container_width=True) + else: + c3.button("cost_report.md not found", disabled=True, use_container_width=True) + c4.download_button("Download all results (.zip)", data=_zip_output_dir(output_dir), file_name=f"{output_dir.name}.zip", mime="application/zip", use_container_width=True) + + exported = [("Word", outputs.get("docx")), ("PDF", outputs.get("pdf")), ("LaTeX", outputs.get("latex")), ("TOC Markdown", outputs.get("notes_toc"))] + available = [(label, path) for label, path in exported if path] + if available: + st.caption("Exported files") + cols = st.columns(min(4, len(available))) + for col, (label, path) in zip(cols, available): + col.download_button(f"Download {label}", data=path.read_bytes(), file_name=path.name, mime=_mime_for_path(path), use_container_width=True) + + +def _render_exports_tab(output_dir: Path) -> None: + outputs = discover_outputs(output_dir) + export_report = _read_json(output_dir / "export_report.json") + export_paths = [ + ("Markdown TOC", outputs.get("notes_toc"), "notes.toc.md"), + ("Word", outputs.get("docx"), "notes.docx"), + ("PDF", outputs.get("pdf"), "notes.pdf"), + ("LaTeX", outputs.get("latex"), "notes.tex"), + ] + rows = [] + for label, path, filename in export_paths: + rows.append({"format": label, "file": filename, "status": "ready" if path else "not generated"}) + st.dataframe(rows, use_container_width=True, hide_index=True) + + ready = [(label, path) for label, path, _ in export_paths if path] + if ready: + cols = st.columns(min(4, len(ready))) + for col, (label, path) in zip(cols, ready): + col.download_button(f"Download {label}", data=path.read_bytes(), file_name=path.name, mime=_mime_for_path(path), use_container_width=True) + else: + st.info("No extra export files were generated. Select export formats in the sidebar before running the build.") + + if export_report: + st.subheader("export_report.json") + st.json(export_report) + warnings = export_report.get("warnings") or [] + for warning in warnings: + st.warning(str(warning)) + else: + st.caption("export_report.json will appear when extra exports are requested.") + + +def _zip_output_dir(output_dir: Path) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(output_dir.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(output_dir).as_posix()) + buffer.seek(0) + return buffer.getvalue() + + +def _render_quality_panel(output_dir: Path) -> None: + coverage = _read_json(output_dir / "coverage.json") or {} + run_summary = _read_json(output_dir / "run_summary.json") or {} + total = int(coverage.get("total") or 0) + covered = int(coverage.get("covered") or 0) + missing = int(coverage.get("missing") or 0) + score = (covered / total * 100) if total else 100.0 + q1, q2, q3, q4 = st.columns(4) + q1.metric("Coverage score", f"{score:.1f}%") + q2.metric("Covered elements", covered) + q3.metric("Missing elements", missing) + q4.metric("Pages", (run_summary.get("counts") or {}).get("pages", "—")) + st.progress(min(max(score / 100, 0), 1)) + + if missing: + st.warning("Some elements are not visibly covered. Use the repair queue below to decide which pages need refresh.") + missing_items = _coverage_missing_items(coverage) + if missing_items: + st.dataframe(missing_items, use_container_width=True, hide_index=True) + missing_pages = sorted({str(item.get("slide_id")) for item in missing_items if item.get("slide_id")}) + if missing_pages: + pages_text = ",".join(missing_pages) + st.code(pages_text, language="text") + st.caption("Copy this into 'Refresh only these pages' to rerun only the affected pages.") + else: + st.success("No missing coverage items reported.") + + _render_stage_timings(run_summary) + + +def _coverage_missing_items(coverage: dict[str, Any]) -> list[dict[str, Any]]: + explicit = coverage.get("missing_items") or coverage.get("marker_only_items") or [] + items = explicit if isinstance(explicit, list) else [] + if not items and isinstance(coverage.get("items"), list): + items = [item for item in coverage["items"] if not item.get("covered")] + normalized = [] + for item in items[:200]: + normalized.append( + { + "slide_id": item.get("slide_id") or _slide_id_from_element(item.get("id") or item.get("element_id")), + "element_id": item.get("id") or item.get("element_id"), + "kind": item.get("kind") or item.get("type") or "", + "reason": item.get("reason") or item.get("status") or "missing", + } + ) + return normalized + + +def _slide_id_from_element(element_id: str | None) -> int | None: + if not element_id: + return None + match = re.match(r"s(\d+)_", str(element_id)) + return int(match.group(1)) if match else None + + +def _render_stage_timings(run_summary: dict[str, Any]) -> None: + timings = run_summary.get("stage_timings") or {} + rows: list[dict[str, Any]] = [] + if isinstance(timings, dict): + for key in ("slowest_stages", "stages", "items"): + value = timings.get(key) + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + rows.append(item) + if not rows: + for key, value in timings.items(): + if isinstance(value, (int, float)): + rows.append({"stage": key, "seconds": value}) + elif isinstance(value, dict): + rows.append({"stage": key, **value}) + elif isinstance(timings, list): + rows = [item for item in timings if isinstance(item, dict)] + if rows: + st.markdown("#### Stage timing") + st.dataframe(rows, use_container_width=True, hide_index=True) + + +def _render_page_explorer(output_dir: Path) -> None: + content = _read_json(output_dir / "content.json") or {} + modalities = _read_json(output_dir / "page_modalities.json") or {} + pages = content.get("pages") or [] + if not pages: + st.info("content.json not found or has no pages.") + return + page_ids = [int(page.get("slide_id", index + 1)) for index, page in enumerate(pages)] + selected_page_id = st.selectbox("Page", page_ids, format_func=lambda value: f"Page {value}") + page = next((item for item in pages if int(item.get("slide_id", 0)) == selected_page_id), pages[0]) + modality_info = _find_modality(modalities, selected_page_id) + + c1, c2 = st.columns([1.1, 1], gap="large") + with c1: + st.markdown("#### Original page") + screenshot_path = _resolve_screenshot_path(output_dir, page) + if screenshot_path and screenshot_path.exists(): + st.image(str(screenshot_path), use_container_width=True) + else: + st.info("No page screenshot found for this page.") + st.caption(f"Detected modality: `{page.get('page_modality') or modality_info.get('modality') or 'unknown'}`") + if page.get("warnings"): + st.warning("\n".join(str(item) for item in page.get("warnings", []))) + + with c2: + st.markdown("#### Elements and note block") + e1, e2, e3 = st.columns(3) + e1.metric("Text blocks", len(page.get("text_blocks") or [])) + e2.metric("Tables", len(page.get("tables") or [])) + e3.metric("Images", len(page.get("images") or [])) + note_excerpt = _note_excerpt_for_page(output_dir / "notes.md", selected_page_id) + with st.expander("Generated note for this page", expanded=True): + st.markdown(note_excerpt or "No page-specific note block found.") + with st.expander("Elements", expanded=False): + st.json(_compact_page_elements(page)) + + st.markdown("#### Manual routing correction") + current_modality = page.get("page_modality") or modality_info.get("modality") or "unknown" + index = MODALITY_OPTIONS.index(current_modality) if current_modality in MODALITY_OPTIONS else len(MODALITY_OPTIONS) - 1 + corrected = st.selectbox("Correct page modality", MODALITY_OPTIONS, index=index, key=f"modality_{selected_page_id}") + note = st.text_area("Reviewer note", value="", key=f"modality_note_{selected_page_id}", placeholder="Example: this is a scanned page; force OCR next run.") + if st.button("Save correction manifest", key=f"save_modality_{selected_page_id}"): + _save_modality_override(output_dir, selected_page_id, corrected, note) + st.success("Saved to page_modalities.overrides.json") + + +def _find_modality(modalities: dict[str, Any], slide_id: int) -> dict[str, Any]: + pages = modalities.get("pages") or [] + if isinstance(pages, list): + return next((item for item in pages if int(item.get("slide_id", 0)) == slide_id), {}) + return {} + + +def _resolve_screenshot_path(output_dir: Path, page: dict[str, Any]) -> Path | None: + raw = page.get("page_screenshot") + candidates: list[Path] = [] + if raw: + raw_path = Path(str(raw)) + candidates.extend([output_dir / raw_path, output_dir / "notes.assets" / raw_path]) + candidates.extend([output_dir / "notes.assets" / "screenshots" / raw_path.name, output_dir / "screenshots" / raw_path.name]) + slide_id = page.get("slide_id") + if slide_id: + candidates.extend([output_dir / "notes.assets" / "screenshots" / f"slide{slide_id}.png", output_dir / "screenshots" / f"slide{slide_id}.png"]) + return next((path for path in candidates if path.exists()), None) + + +def _compact_page_elements(page: dict[str, Any]) -> dict[str, Any]: + return { + "slide_id": page.get("slide_id"), + "title": page.get("title"), + "text_blocks": [{"id": item.get("id"), "content": _shorten(item.get("content", ""), 180)} for item in page.get("text_blocks") or []], + "tables": [{"id": item.get("id"), "rows": len(item.get("rows") or [])} for item in page.get("tables") or []], + "images": [ + { + "id": item.get("id"), + "path": item.get("path"), + "role": item.get("role"), + "ignored": item.get("ignored", False), + "importance_score": item.get("importance_score"), + } + for item in page.get("images") or [] + ], + } + + +def _note_excerpt_for_page(notes_path: Path, slide_id: int) -> str: + if not notes_path.exists(): + return "" + text = notes_path.read_text(encoding="utf-8", errors="replace") + pattern = re.compile(rf"(^### .*第\s*{slide_id}\s*页.*?$)(.*?)(?=^### .*第\s*\d+\s*页|\Z)", re.M | re.S) + match = pattern.search(text) + if match: + return (match.group(1) + match.group(2)).strip() + marker = f"p{slide_id}:" + blocks = [block for block in text.split("\n\n") if marker in block] + return "\n\n".join(blocks[:4]).strip() + + +def _save_modality_override(output_dir: Path, slide_id: int, modality: str, note: str) -> None: + path = output_dir / "page_modalities.overrides.json" + data = _read_json(path) or {"schema_version": 1, "pages": {}} + pages = data.setdefault("pages", {}) + pages[str(slide_id)] = {"modality": modality, "note": note, "updated_at": datetime.utcnow().isoformat(timespec="seconds") + "Z"} + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _shorten(value: str, limit: int) -> str: + text = str(value).replace("\n", " ").strip() + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def _render_cost_tab(output_dir: Path) -> None: + cost = _read_json(output_dir / "cost_report.json") + if cost: + summary = cost.get("summary", {}) + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Estimated cost", f"{summary.get('estimated_cost', 0):.6f} {cost.get('currency', 'USD')}") + c2.metric("Calls", summary.get("calls", 0)) + c3.metric("Input tokens", f"{summary.get('input_tokens', 0):,}") + c4.metric("Output tokens", f"{summary.get('output_tokens', 0):,}") + c5.metric("Cache hits", summary.get("local_cache_hits", 0)) + stages = cost.get("stages", []) + if stages: + st.dataframe(stages, use_container_width=True) + chart_data = {stage.get("name", "stage"): stage.get("total_tokens", 0) for stage in stages} + st.bar_chart(chart_data) + cost_md = output_dir / "cost_report.md" + if cost_md.exists(): + with st.expander("cost_report.md", expanded=False): + st.markdown(cost_md.read_text(encoding="utf-8")) + else: + st.info("No cost report yet. Run a build first.") + + +def _render_run_summary_tab(output_dir: Path) -> None: + for filename in ["run_summary.json", "page_modalities.json", "element_ir.json", "llm_usage.json", "vision_usage.json", "ocr_usage.json", "progress.json"]: + path = output_dir / filename + if path.exists(): + with st.expander(filename, expanded=filename == "run_summary.json"): + st.json(_read_json(path) or {}) + + def _render_markdown_file(path: Path, label: str) -> None: if not path.exists(): st.info(f"{label} not found.") @@ -463,14 +907,24 @@ def _render_markdown_file(path: Path, label: str) -> None: def _download_file(path: Path) -> None: - mime = "application/octet-stream" - if path.suffix == ".md": - mime = "text/markdown" - elif path.suffix == ".json": - mime = "application/json" - elif path.suffix == ".html": - mime = "text/html" - st.download_button(f"Download {path.name}", data=path.read_bytes(), file_name=path.name, mime=mime) + st.download_button(f"Download {path.name}", data=path.read_bytes(), file_name=path.name, mime=_mime_for_path(path)) + + +def _mime_for_path(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".md": + return "text/markdown" + if suffix == ".json": + return "application/json" + if suffix == ".html": + return "text/html" + if suffix == ".docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + if suffix == ".pdf": + return "application/pdf" + if suffix == ".tex": + return "application/x-tex" + return "application/octet-stream" def _read_json(path: Path) -> dict[str, Any] | None: @@ -486,9 +940,135 @@ def _style() -> None: st.markdown( """ """, unsafe_allow_html=True, diff --git a/slidenote/exporting.py b/slidenote/exporting.py index 6741904..6a13925 100644 --- a/slidenote/exporting.py +++ b/slidenote/exporting.py @@ -16,6 +16,7 @@ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$") _HTML_COMMENT_RE = re.compile(r"", flags=re.DOTALL) +_LIBREOFFICE_CANDIDATES = ("soffice", "soffice.com", "libreoffice") @dataclass(frozen=True) @@ -80,21 +81,51 @@ def build_export_artifacts(notes_markdown: str, output_root: Path, formats: list ) warnings.append("Pandoc was not found on PATH; docx/pdf/latex exports were not generated.") else: - for fmt in pandoc_formats: - result = _run_pandoc(pandoc, source, output_root, fmt) - results.append(result) - if result["status"] != "ok": - warnings.append(f"{fmt} export failed: {result.get('reason') or result.get('stderr') or 'unknown error'}") + docx_path: Path | None = None + docx_result: dict[str, Any] | None = None + + if "docx" in pandoc_formats or "pdf" in pandoc_formats: + docx_result = _run_pandoc(pandoc, source, output_root, "docx") + if docx_result["status"] == "ok": + docx_path = output_root / "notes.docx" + if "docx" in pandoc_formats: + results.append(docx_result) + if docx_result["status"] != "ok": + warnings.append(f"docx export failed: {docx_result.get('reason') or docx_result.get('stderr') or 'unknown error'}") + + if "pdf" in pandoc_formats: + if docx_path is None: + pdf_result = { + "format": "pdf", + "status": "failed", + "path": "notes.pdf", + "reason": "docx_required_failed", + "blocking": True, + "dependency": docx_result, + } + else: + pdf_result = _run_pdf_from_docx(docx_path, output_root) + results.append(pdf_result) + if pdf_result["status"] != "ok": + warnings.append(f"pdf export failed: {pdf_result.get('reason') or pdf_result.get('stderr') or 'unknown error'}") + + if "latex" in pandoc_formats: + latex_result = _run_pandoc(pandoc, source, output_root, "latex") + results.append(latex_result) + if latex_result["status"] != "ok": + warnings.append(f"latex export failed: {latex_result.get('reason') or latex_result.get('stderr') or 'unknown error'}") failed = sum(1 for result in results if result["status"] == "failed") skipped = sum(1 for result in results if result["status"] == "skipped") succeeded = sum(1 for result in results if result["status"] == "ok") blocking_failures = sum(1 for result in results if result["status"] == "failed" and result.get("blocking")) return { - "schema_version": 1, + "schema_version": 2, "generated_at": utc_now_iso(), "requested_formats": formats, "export_toc": export_toc, + "pdf_strategy": "docx_to_pdf_via_libreoffice", + "latex_strategy": "pandoc_ctexart_source", "summary": { "requested": len(formats), "succeeded": succeeded, @@ -219,11 +250,9 @@ def _write_pandoc_source(markdown: str, output_root: Path) -> Path: def _run_pandoc(pandoc: str, source: Path, output_root: Path, fmt: str) -> dict[str, Any]: output_name = _output_name(fmt) output_path = output_root / output_name - command = [pandoc, _relative_to_output(source, output_root), "-o", output_name] - if fmt == "pdf": - command.append("--pdf-engine=xelatex") - elif fmt == "latex": - command.append("--standalone") + command = [pandoc, "-f", "markdown-implicit_figures", _relative_to_output(source, output_root), "-o", output_name] + if fmt == "latex": + command.extend(["--standalone", "--pdf-engine=xelatex", "-V", "documentclass=ctexart", "-V", "geometry:margin=1in"]) result: dict[str, Any] = { "format": fmt, @@ -254,6 +283,67 @@ def _run_pandoc(pandoc: str, source: Path, output_root: Path, fmt: str) -> dict[ return result +def _run_pdf_from_docx(docx_path: Path, output_root: Path) -> dict[str, Any]: + output_name = "notes.pdf" + output_path = output_root / output_name + libreoffice = _find_libreoffice() + result: dict[str, Any] = { + "format": "pdf", + "path": output_name, + "source": docx_path.name, + "strategy": "docx_to_pdf_via_libreoffice", + } + if not libreoffice: + result.update( + { + "status": "failed", + "blocking": True, + "reason": "libreoffice_not_found", + "message": "PDF export uses LibreOffice to convert notes.docx for reliable Chinese/CJK layout.", + } + ) + return result + + if output_path.exists(): + output_path.unlink() + command = [libreoffice, "--headless", "--convert-to", "pdf", "--outdir", str(output_root), str(docx_path)] + result["command"] = command + try: + completed = subprocess.run( + command, + cwd=str(output_root), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except OSError as exc: + result.update({"status": "failed", "blocking": True, "reason": "libreoffice_launch_failed", "stderr": str(exc), "returncode": None}) + return result + result["returncode"] = completed.returncode + if completed.returncode == 0 and output_path.exists(): + result["status"] = "ok" + return result + result.update( + { + "status": "failed", + "blocking": True, + "reason": "libreoffice_pdf_failed" if completed.returncode else "libreoffice_output_missing", + "stderr": _summarize_stream(completed.stderr), + "stdout": _summarize_stream(completed.stdout), + } + ) + return result + + +def _find_libreoffice() -> str | None: + for executable in _LIBREOFFICE_CANDIDATES: + found = shutil.which(executable) + if found: + return found + return None + + def _output_name(fmt: str) -> str: return {"docx": "notes.docx", "pdf": "notes.pdf", "latex": "notes.tex"}[fmt] diff --git a/tests/test_exports.py b/tests/test_exports.py index 0a90462..f4f41ed 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -57,13 +57,24 @@ def test_markdown_toc_export_does_not_need_pandoc(tmp_path, monkeypatch): assert not (tmp_path / "notes.docx").exists() -def test_pandoc_exports_record_success(tmp_path, monkeypatch): - def fake_run(command, cwd=None, text=None, stdout=None, stderr=None, check=None): - output = tmp_path / command[command.index("-o") + 1] - output.write_bytes(b"exported") - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") +def test_pandoc_exports_record_success_and_pdf_uses_libreoffice(tmp_path, monkeypatch): + commands = [] + + def fake_which(name): + return {"pandoc": "pandoc", "soffice": "soffice"}.get(name) - monkeypatch.setattr("slidenote.exporting.shutil.which", lambda name: "pandoc") + def fake_run(command, cwd=None, text=None, stdout=None, stderr=None, check=None): + commands.append(command) + if command[0] == "pandoc": + output = tmp_path / command[command.index("-o") + 1] + output.write_bytes(b"exported") + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if command[0] == "soffice": + (tmp_path / "notes.pdf").write_bytes(b"pdf") + return subprocess.CompletedProcess(command, 0, stdout="convert ok", stderr="") + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr("slidenote.exporting.shutil.which", fake_which) monkeypatch.setattr("slidenote.exporting.subprocess.run", fake_run) report = build_export_artifacts("# Lecture\n\n## Topic\n", tmp_path, ["docx", "pdf", "latex"]) @@ -73,6 +84,32 @@ def fake_run(command, cwd=None, text=None, stdout=None, stderr=None, check=None) assert (tmp_path / "notes.docx").exists() assert (tmp_path / "notes.pdf").exists() assert (tmp_path / "notes.tex").exists() + assert any(command[0] == "soffice" and "--convert-to" in command for command in commands) + latex_command = next(command for command in commands if command[0] == "pandoc" and command[command.index("-o") + 1] == "notes.tex") + assert "markdown-implicit_figures" in latex_command + assert "documentclass=ctexart" in latex_command + + +def test_pdf_export_requires_libreoffice_even_when_docx_can_be_built(tmp_path, monkeypatch): + def fake_which(name): + return "pandoc" if name == "pandoc" else None + + def fake_run(command, cwd=None, text=None, stdout=None, stderr=None, check=None): + output = tmp_path / command[command.index("-o") + 1] + output.write_bytes(b"docx") + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr("slidenote.exporting.shutil.which", fake_which) + monkeypatch.setattr("slidenote.exporting.subprocess.run", fake_run) + + report = build_export_artifacts("# Lecture\n\n## Topic\n", tmp_path, ["pdf"]) + + assert report["summary"]["failed"] == 1 + assert report["summary"]["blocking_failures"] == 1 + assert report["results"][0]["format"] == "pdf" + assert report["results"][0]["reason"] == "libreoffice_not_found" + assert (tmp_path / "notes.docx").exists() + assert not (tmp_path / "notes.pdf").exists() def test_pandoc_missing_marks_requested_formats_failed(tmp_path, monkeypatch):