From f58b839170228b369886ef1fdacff7ae1da08153 Mon Sep 17 00:00:00 2001 From: Kilian Date: Fri, 24 Jul 2026 17:50:42 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20PPTX=20=E9=A2=84=E8=A7=88=E4=B8=8E=E7=BC=96?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动细节: - 引入 pptx-svg WASM,在本地将 PPTX 渲染为逐页 SVG 并按文件指纹缓存 - 新增演示文稿对象检查与稳定对象 ID,支持精确修改文本 run 后导出新 PPTX - 新增 pptx-editor 任务技能并接入内置技能注册 - 更新文件预览面板,支持幻灯片全页预览、缩放及视觉/内容视图切换 - 移除 LibreOffice、soffice、Quick Look 和 Office 转 PDF 的原生及 TypeScript 链路 - 旧版 .ppt 改为提示另存为 .pptx,不再调用外部转换器 - 补充 PPTX 缓存、对象检查、编辑导出、预览界面和旧链路移除测试 --- .../2026-06-29-office-preview-pipeline.md | 71 -- native/filework-native/src/lib.rs | 1 - native/filework-native/src/office_preview.rs | 990 ------------------ package.json | 1 + pnpm-lock.yaml | 9 + src/main/ipc/file-handlers.ts | 171 ++- src/main/native/__tests__/exports.test.ts | 10 +- src/main/native/index.ts | 48 - .../office-preview/__tests__/content.test.ts | 38 +- .../office-preview/__tests__/paths.test.ts | 36 - src/main/office-preview/content.ts | 87 +- src/main/office-preview/paths.ts | 50 - src/main/presentation/__tests__/pptx.test.ts | 162 +++ src/main/presentation/pptx.ts | 424 ++++++++ .../skills/__tests__/pptx-processor.test.ts | 11 + src/main/skills/index.ts | 2 + src/main/skills/pptx-editor.ts | 159 +++ .../file-preview/FilePreviewPanel.tsx | 182 ++-- .../FilePreviewPanel.office.test.tsx | 94 +- src/renderer/i18n/en/index.ts | 2 - src/renderer/i18n/i18n-types.ts | 8 - src/renderer/i18n/ja/index.ts | 2 - src/renderer/i18n/zh-CN/index.ts | 2 - src/shared/office-preview.ts | 12 +- 24 files changed, 1031 insertions(+), 1541 deletions(-) delete mode 100644 docs/plans/2026-06-29-office-preview-pipeline.md delete mode 100644 native/filework-native/src/office_preview.rs delete mode 100644 src/main/office-preview/__tests__/paths.test.ts delete mode 100644 src/main/office-preview/paths.ts create mode 100644 src/main/presentation/__tests__/pptx.test.ts create mode 100644 src/main/presentation/pptx.ts create mode 100644 src/main/skills/pptx-editor.ts diff --git a/docs/plans/2026-06-29-office-preview-pipeline.md b/docs/plans/2026-06-29-office-preview-pipeline.md deleted file mode 100644 index d1ce6ccd..00000000 --- a/docs/plans/2026-06-29-office-preview-pipeline.md +++ /dev/null @@ -1,71 +0,0 @@ -# Office Preview Pipeline Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a local Office preview pipeline that converts Office files to cached PDFs, creates first-page thumbnails, and reuses the existing Electron PDF viewer. - -**Architecture:** Add a Rust napi task in `@filework/native` that owns Office preview conversion orchestration: file fingerprinting, cache key creation, isolated LibreOffice profile/work directories, timeout handling, and a serialized conversion queue. Expose a narrow main-process IPC method that supplies the cache root and lets the renderer request a prepared PDF path for Office files. - -**Tech Stack:** Rust + napi-rs for native orchestration, LibreOffice headless for Office-to-PDF conversion, optional PDF thumbnail command/Quick Look for PNG thumbnails, Electron IPC, React file preview components, Vitest and Cargo tests. - ---- - -### Task 1: Rust Office Preview Orchestrator - -**Files:** -- Create: `native/filework-native/src/office_preview.rs` -- Modify: `native/filework-native/src/lib.rs` -- Test: `native/filework-native/src/office_preview.rs` - -**Steps:** -1. Write failing Rust tests for cache keys, fake LibreOffice conversion, thumbnail output, timeout failure, and serialized conversion misses. -2. Run `cargo test office_preview --manifest-path native/filework-native/Cargo.toml` and confirm the tests fail because the feature is missing. -3. Implement minimal Rust logic: - - Stat and hash the source file. - - Resolve LibreOffice path from options, env, PATH, or macOS default. - - Read converter version with `--version`. - - Build cache key from canonical path, mtime, size, file hash, and converter version. - - Use a global mutex to serialize conversion cache misses. - - Use a per-job temp directory and `-env:UserInstallation=file://...` for LibreOffice isolation. - - Kill the converter when timeout elapses. - - Atomically publish `preview.pdf` and optional `thumbnail.png`. -4. Re-run the focused Cargo tests until green. - -### Task 2: Native TypeScript Bridge and IPC - -**Files:** -- Modify: `src/main/native/index.ts` -- Modify: `src/main/ipc/file-handlers.ts` -- Modify: `src/preload/index.ts` -- Test: focused Vitest for helper behavior if extracted. - -**Steps:** -1. Add TypeScript interfaces for `OfficePreviewOptions` and `OfficePreviewResult`. -2. Export `prepareOfficePreview` from `src/main/native/index.ts`. -3. Add `fs:prepareOfficePreview` IPC handler that supplies `~/.filework/previews/office` as cache root and calls native. -4. Expose `window.filework.prepareOfficePreview(path)`. - -### Task 3: Renderer Preview Integration - -**Files:** -- Modify: `src/renderer/components/file-preview/FilePreviewPanel.tsx` -- Modify: `src/renderer/components/file-preview/PdfViewer.tsx` if needed. -- Test: `src/renderer/components/file-preview/__tests__/FilePreviewPanel.office.test.tsx` - -**Steps:** -1. Write failing renderer test for `.docx` requesting Office preparation and rendering the returned PDF path through `PdfViewer`. -2. Add Office extension detection for `doc`, `docx`, `xls`, `xlsx`, `ppt`, `pptx`, and common macro/template variants. -3. Add loading/error handling that mirrors existing text preview behavior. -4. Keep actual preview rendering in `PdfViewer` via `local-file://`. - -### Task 4: Verification - -**Files:** -- Affected source and test files. - -**Steps:** -1. Run focused Cargo and Vitest checks. -2. Run `pnpm lint`. -3. Run `pnpm typecheck`. -4. Run `pnpm test` if core logic breadth requires it. -5. Run `pnpm build` because main and renderer are modified. diff --git a/native/filework-native/src/lib.rs b/native/filework-native/src/lib.rs index 7cbd631f..4937567b 100644 --- a/native/filework-native/src/lib.rs +++ b/native/filework-native/src/lib.rs @@ -1,5 +1,4 @@ mod dedup; -pub mod office_preview; mod scan; mod search; mod stats; diff --git a/native/filework-native/src/office_preview.rs b/native/filework-native/src/office_preview.rs deleted file mode 100644 index e26a7d39..00000000 --- a/native/filework-native/src/office_preview.rs +++ /dev/null @@ -1,990 +0,0 @@ -use napi::bindgen_prelude::*; -use napi_derive::napi; -use std::env; -use std::ffi::OsString; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::Mutex; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -const DEFAULT_TIMEOUT_MS: u64 = 60_000; -const DEFAULT_THUMBNAIL_SIZE: u32 = 640; -static OFFICE_PREVIEW_QUEUE: Mutex<()> = Mutex::new(()); - -#[derive(Clone, Debug)] -#[napi(object)] -pub struct OfficePreviewRequest { - pub source_path: String, - pub cache_root: String, - pub libre_office_path: Option, - pub quick_look_path: Option, - pub thumbnailer_path: Option, - pub timeout_ms: Option, - pub thumbnail_size: Option, -} - -#[derive(Clone, Debug)] -#[napi(object)] -pub struct OfficePreviewResult { - pub cache_key: String, - pub preview_kind: String, - pub preview_path: String, - pub pdf_path: Option, - pub thumbnail_path: Option, - pub source_mtime_ms: f64, - pub source_size: f64, - pub converter_version: String, - pub cache_hit: bool, -} - -#[derive(Clone, Debug)] -pub struct OfficePreviewFingerprint { - pub cache_key: String, - pub source_mtime_ms: f64, - pub source_size: u64, -} - -pub struct PrepareOfficePreviewTask { - request: OfficePreviewRequest, -} - -impl Task for PrepareOfficePreviewTask { - type Output = OfficePreviewResult; - type JsValue = OfficePreviewResult; - - fn compute(&mut self) -> Result { - prepare_office_preview(self.request.clone()) - .map_err(|msg| Error::new(Status::GenericFailure, msg)) - } - - fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { - Ok(output) - } -} - -#[napi(ts_return_type = "Promise")] -pub fn prepare_office_preview_native( - request: OfficePreviewRequest, -) -> AsyncTask { - AsyncTask::new(PrepareOfficePreviewTask { request }) -} - -pub fn prepare_office_preview( - request: OfficePreviewRequest, -) -> std::result::Result { - let source_path = PathBuf::from(&request.source_path); - let cache_root = PathBuf::from(&request.cache_root); - let timeout_ms = u64::from(request.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS as u32)); - let thumbnail_size = request.thumbnail_size.unwrap_or(DEFAULT_THUMBNAIL_SIZE); - fs::create_dir_all(&cache_root) - .map_err(|e| format!("Failed to create Office preview cache root: {}", e))?; - - let (converter_path, converter_version) = - match resolve_libre_office_path(request.libre_office_path.as_deref()) - .and_then(|path| read_converter_version(&path).map(|version| (path, version))) - { - Ok(resolved) => resolved, - Err(converter_error) => { - return prepare_quick_look_preview( - &request, - &source_path, - &cache_root, - timeout_ms, - thumbnail_size, - ) - .map_err(|fallback_error| { - format!("{converter_error}. Quick Look fallback failed: {fallback_error}") - }); - } - }; - let fingerprint = build_office_preview_fingerprint(&source_path, &converter_version)?; - let cache_dir = cache_root.join(&fingerprint.cache_key); - let pdf_path = cache_dir.join("preview.pdf"); - let thumbnail_path = cache_dir.join("thumbnail.png"); - - if pdf_path.is_file() { - let thumbnail = ensure_thumbnail( - &thumbnail_path, - &pdf_path, - request.thumbnailer_path.as_deref(), - thumbnail_size, - timeout_ms, - )?; - return Ok(result_from_paths( - fingerprint, - pdf_path, - thumbnail, - converter_version, - true, - )); - } - - let queue_guard = OFFICE_PREVIEW_QUEUE - .lock() - .map_err(|_| "Office preview conversion queue is poisoned".to_string())?; - - if pdf_path.is_file() { - let thumbnail = ensure_thumbnail( - &thumbnail_path, - &pdf_path, - request.thumbnailer_path.as_deref(), - thumbnail_size, - timeout_ms, - )?; - return Ok(result_from_paths( - fingerprint, - pdf_path, - thumbnail, - converter_version, - true, - )); - } - - fs::create_dir_all(&cache_dir) - .map_err(|e| format!("Failed to create Office preview cache dir: {}", e))?; - - let job_dir = make_job_dir(&cache_root, &fingerprint.cache_key)?; - let convert_result = convert_with_libreoffice( - &converter_path, - &source_path, - &job_dir, - &pdf_path, - timeout_ms, - ); - let cleanup_result = fs::remove_dir_all(&job_dir); - if let Err(err) = convert_result { - let _ = cleanup_result; - drop(queue_guard); - return prepare_quick_look_preview( - &request, - &source_path, - &cache_root, - timeout_ms, - thumbnail_size, - ) - .map_err(|fallback_error| format!("{err}. Quick Look fallback failed: {fallback_error}")); - } - if let Err(err) = cleanup_result { - return Err(format!("Failed to clean Office preview temp dir: {}", err)); - } - - let thumbnail = ensure_thumbnail( - &thumbnail_path, - &pdf_path, - request.thumbnailer_path.as_deref(), - thumbnail_size, - timeout_ms, - )?; - - Ok(result_from_paths( - fingerprint, - pdf_path, - thumbnail, - converter_version, - false, - )) -} - -fn prepare_quick_look_preview( - request: &OfficePreviewRequest, - source_path: &Path, - cache_root: &Path, - timeout_ms: u64, - thumbnail_size: u32, -) -> std::result::Result { - let qlmanage = resolve_quick_look_path(request.quick_look_path.as_deref())?; - let converter_version = "Quick Look thumbnail".to_string(); - let fingerprint = build_office_preview_fingerprint(source_path, &converter_version)?; - let cache_dir = cache_root.join(&fingerprint.cache_key); - let thumbnail_path = cache_dir.join("thumbnail.png"); - - if thumbnail_path.is_file() { - return Ok(result_from_image_path( - fingerprint, - thumbnail_path, - converter_version, - true, - )); - } - - let _queue_guard = OFFICE_PREVIEW_QUEUE - .lock() - .map_err(|_| "Office preview conversion queue is poisoned".to_string())?; - - if thumbnail_path.is_file() { - return Ok(result_from_image_path( - fingerprint, - thumbnail_path, - converter_version, - true, - )); - } - - fs::create_dir_all(&cache_dir) - .map_err(|e| format!("Failed to create Office preview cache dir: {}", e))?; - generate_quick_look_thumbnail( - &qlmanage, - source_path, - &thumbnail_path, - thumbnail_size, - timeout_ms, - )?; - - Ok(result_from_image_path( - fingerprint, - thumbnail_path, - converter_version, - false, - )) -} - -pub fn build_office_preview_fingerprint( - source_path: &Path, - converter_version: &str, -) -> std::result::Result { - let metadata = fs::metadata(source_path).map_err(|e| tag_io_error(&e, source_path))?; - if !metadata.is_file() { - return Err(format!( - "Office preview source is not a file: {}", - source_path.display() - )); - } - let source_size = metadata.len(); - let source_mtime_ms = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| { - duration.as_secs() as f64 * 1_000.0 + duration.subsec_nanos() as f64 / 1_000_000.0 - }) - .unwrap_or(0.0); - let file_hash = hash_file_hex(source_path)?; - let canonical_path = fs::canonicalize(source_path) - .unwrap_or_else(|_| source_path.to_path_buf()) - .to_string_lossy() - .into_owned(); - let material = format!( - "office-preview-v1\0{}\0{}\0{}\0{}\0{}", - canonical_path, source_mtime_ms, source_size, file_hash, converter_version - ); - let cache_key = blake3::hash(material.as_bytes()).to_hex().to_string(); - - Ok(OfficePreviewFingerprint { - cache_key, - source_mtime_ms, - source_size, - }) -} - -fn tag_io_error(err: &io::Error, path: &Path) -> String { - match err.kind() { - io::ErrorKind::NotFound => format!("[FS_NOT_FOUND] {}", path.display()), - io::ErrorKind::PermissionDenied => format!("[FS_PERMISSION_DENIED] {}", path.display()), - _ => err.to_string(), - } -} - -fn hash_file_hex(path: &Path) -> std::result::Result { - let mut hasher = blake3::Hasher::new(); - let mut file = fs::File::open(path).map_err(|e| tag_io_error(&e, path))?; - io::copy(&mut file, &mut hasher).map_err(|e| tag_io_error(&e, path))?; - Ok(hasher.finalize().to_hex().to_string()) -} - -fn resolve_libre_office_path(explicit: Option<&str>) -> std::result::Result { - if let Some(path) = explicit { - return Ok(PathBuf::from(path)); - } - if let Ok(path) = env::var("FILEWORK_LIBREOFFICE_PATH") { - if !path.trim().is_empty() { - return Ok(PathBuf::from(path)); - } - } - for candidate in ["soffice", "libreoffice"] { - if let Some(path) = find_on_path(candidate) { - return Ok(path); - } - } - let macos_default = PathBuf::from("/Applications/LibreOffice.app/Contents/MacOS/soffice"); - if macos_default.exists() { - return Ok(macos_default); - } - Err( - "LibreOffice headless converter not found. Install LibreOffice or set FILEWORK_LIBREOFFICE_PATH." - .to_string(), - ) -} - -fn resolve_quick_look_path(explicit: Option<&str>) -> std::result::Result { - if let Some(path) = explicit { - return Ok(PathBuf::from(path)); - } - if let Ok(path) = env::var("FILEWORK_QUICKLOOK_PATH") { - if !path.trim().is_empty() { - return Ok(PathBuf::from(path)); - } - } - let macos_default = PathBuf::from("/usr/bin/qlmanage"); - if macos_default.is_file() { - return Ok(macos_default); - } - Err("Quick Look thumbnail generator not found at /usr/bin/qlmanage.".to_string()) -} - -fn find_on_path(command: &str) -> Option { - let path_var = env::var_os("PATH")?; - env::split_paths(&path_var) - .map(|dir| dir.join(command)) - .find(|path| path.is_file()) -} - -fn read_converter_version(path: &Path) -> std::result::Result { - let output = Command::new(path) - .arg("--version") - .stdin(Stdio::null()) - .output() - .map_err(|e| format!("Failed to run LibreOffice version check: {}", e))?; - if !output.status.success() { - return Err(format!( - "LibreOffice version check failed with status {}", - output.status - )); - } - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return Ok(stdout); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stderr.is_empty() { - return Ok(stderr); - } - Ok("LibreOffice unknown".to_string()) -} - -fn make_job_dir(cache_root: &Path, cache_key: &str) -> std::result::Result { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let job_dir = cache_root.join(format!( - ".tmp-{}-{}-{}", - cache_key, - std::process::id(), - nonce - )); - fs::create_dir_all(&job_dir) - .map_err(|e| format!("Failed to create Office preview temp dir: {}", e))?; - Ok(job_dir) -} - -fn convert_with_libreoffice( - converter_path: &Path, - source_path: &Path, - job_dir: &Path, - pdf_path: &Path, - timeout_ms: u64, -) -> std::result::Result<(), String> { - let work_dir = job_dir.join("work"); - let profile_dir = job_dir.join("profile"); - fs::create_dir_all(&work_dir) - .map_err(|e| format!("Failed to create Office preview work dir: {}", e))?; - fs::create_dir_all(&profile_dir) - .map_err(|e| format!("Failed to create Office preview profile dir: {}", e))?; - - let profile_arg = format!("-env:UserInstallation={}", file_url(&profile_dir)); - let args = vec![ - OsString::from("--headless"), - OsString::from("--nologo"), - OsString::from("--nolockcheck"), - OsString::from("--nodefault"), - OsString::from("--nofirststartwizard"), - OsString::from("--norestore"), - OsString::from(profile_arg), - OsString::from("--convert-to"), - OsString::from("pdf"), - OsString::from("--outdir"), - work_dir.as_os_str().to_os_string(), - source_path.as_os_str().to_os_string(), - ]; - run_command_with_timeout( - converter_path, - &args, - timeout_ms, - "Office preview conversion", - )?; - - let expected_pdf = work_dir.join(format!( - "{}.pdf", - source_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("preview") - )); - let converted_pdf = if expected_pdf.is_file() { - expected_pdf - } else { - first_pdf_in_dir(&work_dir).ok_or_else(|| { - format!( - "LibreOffice conversion finished but did not produce a PDF in {}", - work_dir.display() - ) - })? - }; - fs::rename(&converted_pdf, pdf_path) - .or_else(|_| { - fs::copy(&converted_pdf, pdf_path)?; - fs::remove_file(&converted_pdf) - }) - .map_err(|e| format!("Failed to publish Office preview PDF: {}", e)) -} - -fn file_url(path: &Path) -> String { - let raw = path.to_string_lossy(); - let escaped = raw - .replace('%', "%25") - .replace(' ', "%20") - .replace('#', "%23") - .replace('?', "%3F"); - format!("file://{}", escaped) -} - -fn first_pdf_in_dir(dir: &Path) -> Option { - fs::read_dir(dir) - .ok()? - .filter_map(|entry| entry.ok()) - .find_map(|entry| { - let path = entry.path(); - let is_pdf = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("pdf")) - .unwrap_or(false); - if is_pdf { - Some(path) - } else { - None - } - }) -} - -fn ensure_thumbnail( - thumbnail_path: &Path, - pdf_path: &Path, - explicit_thumbnailer: Option<&str>, - thumbnail_size: u32, - timeout_ms: u64, -) -> std::result::Result, String> { - if thumbnail_path.is_file() { - return Ok(Some(thumbnail_path.to_path_buf())); - } - if let Some(thumbnailer) = explicit_thumbnailer { - let args = vec![ - pdf_path.as_os_str().to_os_string(), - thumbnail_path.as_os_str().to_os_string(), - OsString::from(thumbnail_size.to_string()), - ]; - run_command_with_timeout( - Path::new(thumbnailer), - &args, - timeout_ms, - "Office preview thumbnail generation", - )?; - return if thumbnail_path.is_file() { - Ok(Some(thumbnail_path.to_path_buf())) - } else { - Err("Office preview thumbnailer finished without producing thumbnail.png".to_string()) - }; - } - if try_qlmanage_thumbnail(pdf_path, thumbnail_path, thumbnail_size, timeout_ms) { - return Ok(Some(thumbnail_path.to_path_buf())); - } - if try_pdftoppm_thumbnail(pdf_path, thumbnail_path, thumbnail_size, timeout_ms) { - return Ok(Some(thumbnail_path.to_path_buf())); - } - Ok(None) -} - -fn try_qlmanage_thumbnail( - pdf_path: &Path, - thumbnail_path: &Path, - thumbnail_size: u32, - timeout_ms: u64, -) -> bool { - let qlmanage = PathBuf::from("/usr/bin/qlmanage"); - if !qlmanage.is_file() { - return false; - } - generate_quick_look_thumbnail( - &qlmanage, - pdf_path, - thumbnail_path, - thumbnail_size, - timeout_ms, - ) - .is_ok() -} - -fn generate_quick_look_thumbnail( - qlmanage: &Path, - source_path: &Path, - thumbnail_path: &Path, - thumbnail_size: u32, - timeout_ms: u64, -) -> std::result::Result<(), String> { - let Some(out_dir) = thumbnail_path.parent() else { - return Err("Office preview thumbnail path has no parent directory".to_string()); - }; - let ql_dir = out_dir.join(".ql-thumbnail"); - fs::create_dir_all(&ql_dir) - .map_err(|e| format!("Failed to create Quick Look thumbnail dir: {}", e))?; - let args = vec![ - OsString::from("-t"), - OsString::from("-s"), - OsString::from(thumbnail_size.to_string()), - OsString::from("-o"), - ql_dir.as_os_str().to_os_string(), - source_path.as_os_str().to_os_string(), - ]; - if let Err(err) = run_command_with_timeout(qlmanage, &args, timeout_ms, "Quick Look thumbnail") - { - let _ = fs::remove_dir_all(&ql_dir); - return Err(err); - } - let generated = fs::read_dir(&ql_dir).ok().and_then(|entries| { - entries.filter_map(|entry| entry.ok()).find_map(|entry| { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) == Some("png") { - Some(path) - } else { - None - } - }) - }); - let Some(generated) = generated else { - let _ = fs::remove_dir_all(&ql_dir); - return Err("Quick Look finished without producing a PNG thumbnail".to_string()); - }; - fs::rename(&generated, thumbnail_path) - .or_else(|_| { - fs::copy(&generated, thumbnail_path)?; - fs::remove_file(&generated) - }) - .map_err(|e| format!("Failed to publish Quick Look thumbnail: {}", e))?; - let _ = fs::remove_dir_all(&ql_dir); - if thumbnail_path.is_file() { - Ok(()) - } else { - Err("Quick Look thumbnail was not written".to_string()) - } -} - -fn try_pdftoppm_thumbnail( - pdf_path: &Path, - thumbnail_path: &Path, - thumbnail_size: u32, - timeout_ms: u64, -) -> bool { - let Some(pdftoppm) = find_on_path("pdftoppm") else { - return false; - }; - let Some(out_dir) = thumbnail_path.parent() else { - return false; - }; - let prefix = out_dir.join("thumbnail-work"); - let generated = out_dir.join("thumbnail-work.png"); - let args = vec![ - OsString::from("-f"), - OsString::from("1"), - OsString::from("-singlefile"), - OsString::from("-png"), - OsString::from("-scale-to"), - OsString::from(thumbnail_size.to_string()), - pdf_path.as_os_str().to_os_string(), - prefix.as_os_str().to_os_string(), - ]; - if run_command_with_timeout(&pdftoppm, &args, timeout_ms, "pdftoppm thumbnail").is_err() { - return false; - } - fs::rename(&generated, thumbnail_path).is_ok() && thumbnail_path.is_file() -} - -fn run_command_with_timeout( - program: &Path, - args: &[OsString], - timeout_ms: u64, - label: &str, -) -> std::result::Result<(), String> { - let mut child = Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to start {}: {}", label, e))?; - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - loop { - match child.try_wait() { - Ok(Some(status)) => { - return if status.success() { - Ok(()) - } else { - Err(format!("{} failed with status {}", label, status)) - }; - } - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return Err(format!("{} timed out after {}ms", label, timeout_ms)); - } - thread::sleep(Duration::from_millis(20)); - } - Err(err) => return Err(format!("Failed while waiting for {}: {}", label, err)), - } - } -} - -fn result_from_paths( - fingerprint: OfficePreviewFingerprint, - pdf_path: PathBuf, - thumbnail_path: Option, - converter_version: String, - cache_hit: bool, -) -> OfficePreviewResult { - let pdf_path = pdf_path.to_string_lossy().into_owned(); - OfficePreviewResult { - cache_key: fingerprint.cache_key, - preview_kind: "pdf".to_string(), - preview_path: pdf_path.clone(), - pdf_path: Some(pdf_path), - thumbnail_path: thumbnail_path.map(|p| p.to_string_lossy().into_owned()), - source_mtime_ms: fingerprint.source_mtime_ms, - source_size: fingerprint.source_size as f64, - converter_version, - cache_hit, - } -} - -fn result_from_image_path( - fingerprint: OfficePreviewFingerprint, - image_path: PathBuf, - converter_version: String, - cache_hit: bool, -) -> OfficePreviewResult { - let image_path = image_path.to_string_lossy().into_owned(); - OfficePreviewResult { - cache_key: fingerprint.cache_key, - preview_kind: "image".to_string(), - preview_path: image_path.clone(), - pdf_path: None, - thumbnail_path: Some(image_path), - source_mtime_ms: fingerprint.source_mtime_ms, - source_size: fingerprint.source_size as f64, - converter_version, - cache_hit, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::io; - use std::path::{Path, PathBuf}; - use std::thread; - use tempfile::tempdir; - - #[cfg(unix)] - fn write_executable(path: &Path, body: &str) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - - fs::write(path, body)?; - let mut perms = fs::metadata(path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms) - } - - fn request( - source_path: PathBuf, - cache_root: PathBuf, - office_path: PathBuf, - ) -> OfficePreviewRequest { - OfficePreviewRequest { - source_path: source_path.to_string_lossy().into_owned(), - cache_root: cache_root.to_string_lossy().into_owned(), - libre_office_path: Some(office_path.to_string_lossy().into_owned()), - quick_look_path: None, - thumbnailer_path: None, - timeout_ms: Some(3_000), - thumbnail_size: Some(320), - } - } - - #[test] - fn cache_key_changes_when_converter_version_changes() { - let dir = tempdir().unwrap(); - let source = dir.path().join("budget.xlsx"); - fs::write(&source, b"sheet-data").unwrap(); - - let first = build_office_preview_fingerprint(&source, "LibreOffice 24.2").unwrap(); - let second = build_office_preview_fingerprint(&source, "LibreOffice 25.0").unwrap(); - - assert_ne!(first.cache_key, second.cache_key); - assert_eq!(first.source_size, 10); - assert!(first.source_mtime_ms > 0.0); - } - - #[cfg(unix)] - #[test] - fn converts_office_to_cached_pdf_and_thumbnail_with_isolated_profile() { - let dir = tempdir().unwrap(); - let source = dir.path().join("Deck File.pptx"); - let cache = dir.path().join("cache"); - let office = dir.path().join("fake-soffice"); - let thumb = dir.path().join("fake-thumb"); - let args_log = dir.path().join("args.log"); - fs::write(&source, b"slides").unwrap(); - write_executable( - &office, - &format!( - r#"#!/bin/sh -if [ "$1" = "--version" ]; then - echo "LibreOffice 24.2.1" - exit 0 -fi -printf '%s\n' "$@" > '{}' -outdir="" -input="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--outdir" ]; then - shift - outdir="$1" - else - input="$1" - fi - shift -done -base="$(basename "$input")" -stem="${{base%.*}}" -printf 'PDF:%s' "$input" > "$outdir/$stem.pdf" -"#, - args_log.display(), - ), - ) - .unwrap(); - write_executable( - &thumb, - r#"#!/bin/sh -printf 'PNG:%s:%s' "$1" "$3" > "$2" -"#, - ) - .unwrap(); - - let mut req = request(source, cache, office); - req.thumbnailer_path = Some(thumb.to_string_lossy().into_owned()); - - let first = prepare_office_preview(req.clone()).unwrap(); - assert!(!first.cache_hit); - assert!(Path::new(first.pdf_path.as_ref().unwrap()).exists()); - assert!(Path::new(first.thumbnail_path.as_ref().unwrap()).exists()); - assert_eq!(first.converter_version, "LibreOffice 24.2.1"); - - let args = fs::read_to_string(args_log).unwrap(); - assert!(args.contains("--headless")); - assert!(args.contains("--convert-to")); - assert!(args.contains("-env:UserInstallation=file://")); - - let second = prepare_office_preview(req).unwrap(); - assert!(second.cache_hit); - assert_eq!(second.pdf_path, first.pdf_path); - assert_eq!(second.thumbnail_path, first.thumbnail_path); - } - - #[cfg(unix)] - #[test] - fn falls_back_to_quick_look_image_preview_when_libreoffice_is_unavailable() { - let dir = tempdir().unwrap(); - let source = dir.path().join("Deck File.pptx"); - let cache = dir.path().join("cache"); - let missing_office = dir.path().join("missing-soffice"); - let qlmanage = dir.path().join("fake-qlmanage"); - fs::write(&source, b"slides").unwrap(); - write_executable( - &qlmanage, - r#"#!/bin/sh -outdir="" -input="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "-o" ]; then - shift - outdir="$1" - else - input="$1" - fi - shift -done -base="$(basename "$input")" -printf 'PNG:%s' "$input" > "$outdir/$base.png" -"#, - ) - .unwrap(); - - let mut req = request(source, cache, missing_office); - req.quick_look_path = Some(qlmanage.to_string_lossy().into_owned()); - - let first = prepare_office_preview(req.clone()).unwrap(); - assert!(!first.cache_hit); - assert_eq!(first.preview_kind, "image"); - assert!(first.pdf_path.is_none()); - assert!(Path::new(&first.preview_path).exists()); - assert_eq!(first.thumbnail_path.as_ref(), Some(&first.preview_path)); - assert_eq!(first.converter_version, "Quick Look thumbnail"); - - let second = prepare_office_preview(req).unwrap(); - assert!(second.cache_hit); - assert_eq!(second.preview_path, first.preview_path); - } - - #[cfg(unix)] - #[test] - fn falls_back_to_quick_look_image_preview_when_libreoffice_conversion_fails() { - let dir = tempdir().unwrap(); - let source = dir.path().join("Broken Deck.pptx"); - let cache = dir.path().join("cache"); - let office = dir.path().join("failing-soffice"); - let qlmanage = dir.path().join("fake-qlmanage"); - fs::write(&source, b"slides").unwrap(); - write_executable( - &office, - r#"#!/bin/sh -if [ "$1" = "--version" ]; then - echo "LibreOffice 24.2.1" - exit 0 -fi -echo "conversion failed" >&2 -exit 2 -"#, - ) - .unwrap(); - write_executable( - &qlmanage, - r#"#!/bin/sh -outdir="" -input="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "-o" ]; then - shift - outdir="$1" - else - input="$1" - fi - shift -done -base="$(basename "$input")" -printf 'PNG:%s' "$input" > "$outdir/$base.png" -"#, - ) - .unwrap(); - - let mut req = request(source, cache, office); - req.quick_look_path = Some(qlmanage.to_string_lossy().into_owned()); - - let result = prepare_office_preview(req).unwrap(); - assert_eq!(result.preview_kind, "image"); - assert!(result.pdf_path.is_none()); - assert!(Path::new(&result.preview_path).exists()); - assert_eq!(result.converter_version, "Quick Look thumbnail"); - } - - #[cfg(unix)] - #[test] - fn kills_converter_when_timeout_elapses() { - let dir = tempdir().unwrap(); - let source = dir.path().join("slow.docx"); - let cache = dir.path().join("cache"); - let office = dir.path().join("slow-soffice"); - fs::write(&source, b"document").unwrap(); - write_executable( - &office, - r#"#!/bin/sh -if [ "$1" = "--version" ]; then - echo "LibreOffice slow" - exit 0 -fi -sleep 2 -"#, - ) - .unwrap(); - - let mut req = request(source, cache, office); - req.timeout_ms = Some(100); - - let err = prepare_office_preview(req).unwrap_err(); - assert!(err.contains("timed out"), "got: {err}"); - } - - #[cfg(unix)] - #[test] - fn serializes_conversion_cache_misses() { - let dir = tempdir().unwrap(); - let cache = dir.path().join("cache"); - let office = dir.path().join("queued-soffice"); - let state = dir.path().join("state"); - fs::create_dir(&state).unwrap(); - let collision = state.join("collision"); - let running = state.join("running"); - let source_a = dir.path().join("a.docx"); - let source_b = dir.path().join("b.docx"); - fs::write(&source_a, b"a").unwrap(); - fs::write(&source_b, b"b").unwrap(); - write_executable( - &office, - &format!( - r#"#!/bin/sh -if [ "$1" = "--version" ]; then - echo "LibreOffice queued" - exit 0 -fi -if [ -e '{}' ]; then - echo collision > '{}' - exit 7 -fi -touch '{}' -sleep 0.2 -outdir="" -input="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--outdir" ]; then - shift - outdir="$1" - else - input="$1" - fi - shift -done -base="$(basename "$input")" -stem="${{base%.*}}" -printf 'PDF:%s' "$input" > "$outdir/$stem.pdf" -rm -f '{}' -"#, - running.display(), - collision.display(), - running.display(), - running.display(), - ), - ) - .unwrap(); - - let req_a = request(source_a, cache.clone(), office.clone()); - let req_b = request(source_b, cache, office); - let a = thread::spawn(move || prepare_office_preview(req_a)); - let b = thread::spawn(move || prepare_office_preview(req_b)); - - assert!(a.join().unwrap().is_ok()); - assert!(b.join().unwrap().is_ok()); - assert!(!collision.exists()); - } -} diff --git a/package.json b/package.json index 80c3afca..6b6a0686 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "lucide-react": "^0.577.0", "mammoth": "^1.11.0", "pdf-parse": "^2.4.5", + "pptx-svg": "0.6.4", "radix-ui": "^1.6.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9312b99..a31b87a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: pdf-parse: specifier: ^2.4.5 version: 2.4.5 + pptx-svg: + specifier: 0.6.4 + version: 0.6.4 radix-ui: specifier: ^1.6.0 version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4613,6 +4616,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + pptx-svg@0.6.4: + resolution: {integrity: sha512-ykjLDvqFuVx55O7jKf+G8xetRGo7G7lUGE6NX8AJ2VrXRqdZ/UP4CSQKPaY1BC8TRNLFMMKmIbi6hYvTSqnC8Q==} + engines: {node: '>=22'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -10375,6 +10382,8 @@ snapshots: powershell-utils@0.1.0: {} + pptx-svg@0.6.4: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 diff --git a/src/main/ipc/file-handlers.ts b/src/main/ipc/file-handlers.ts index 2062778b..31d226d1 100644 --- a/src/main/ipc/file-handlers.ts +++ b/src/main/ipc/file-handlers.ts @@ -13,25 +13,26 @@ import { } from "../core/agent/tools/trash"; import { directoryStats, - type NativeOfficePreviewResult, type NativeSearchOptions, - prepareOfficePreview, searchFiles, } from "../native"; import { type OfficeContentPreviewResult, prepareOfficeContentPreview, } from "../office-preview/content"; -import { resolveLibreOfficePath } from "../office-preview/paths"; +import { + type PptxPreviewResult, + preparePptxPreview, +} from "../presentation/pptx"; // 文件预览的读取上限:超过则只读前 N 字节并标记 truncated, // 避免把几百 MB 的文件整体读入内存、序列化过 IPC 拖垮渲染进程。 const MAX_PREVIEW_BYTES = 10 * 1024 * 1024; // 10 MB -const OFFICE_PREVIEW_CACHE_ROOT = join( +const PRESENTATION_PREVIEW_CACHE_ROOT = join( homedir(), ".filework", "previews", - "office", + "presentations", ); const OFFICE_CONTENT_PREVIEW_CACHE_ROOT = join( homedir(), @@ -85,74 +86,60 @@ const hasUsableOfficeContent = ( ): preview is Exclude => preview.kind !== "unsupported"; -const attachOfficeContentPreview = ( - result: OfficePreviewResult, - settled: PromiseSettledResult, -): OfficePreviewResult => { - if (settled.status === "rejected") { - return { - ...result, - contentPreviewError: getErrorMessage(settled.reason), - }; - } - if (!hasUsableOfficeContent(settled.value.preview)) { - return { - ...result, - contentPreviewError: settled.value.preview.message, - }; - } - return { - ...result, - contentPreview: settled.value.preview, - contentPreviewCacheHit: settled.value.cacheHit, - contentPreviewPath: settled.value.contentPreviewPath, - }; -}; - -const visualOfficeResult = ( - visual: NativeOfficePreviewResult, -): OfficePreviewResult => ({ - ...visual, - visualPreviewUnavailable: visual.previewKind === "image", -}); - const contentOnlyOfficeResult = ( content: OfficeContentPreviewResult, - visualError: unknown, ): OfficePreviewResult => ({ cacheKey: content.cacheKey, previewKind: "content", - previewPath: content.contentPreviewPath, sourceMtimeMs: content.sourceMtimeMs, sourceSize: content.sourceSize, - converterVersion: "Content extraction", cacheHit: content.cacheHit, contentPreview: content.preview, - contentPreviewCacheHit: content.cacheHit, contentPreviewPath: content.contentPreviewPath, - visualPreviewUnavailable: true, - visualPreviewError: getErrorMessage(visualError), }); -const buildOfficePreviewFailureMessage = ( - visualError: unknown, +const presentationOfficeResult = ( + visual: PptxPreviewResult, contentResult: PromiseSettledResult, -): string => { - const visualMessage = getErrorMessage(visualError); - const installHint = - "Install LibreOffice or set FILEWORK_LIBREOFFICE_PATH for full Office PDF preview."; - const contentMessage = - contentResult.status === "rejected" - ? `Content fallback failed: ${getErrorMessage(contentResult.reason)}` - : contentResult.value.preview.kind === "unsupported" - ? `Content fallback unavailable: ${contentResult.value.preview.message}` - : "Content fallback was available but no visual preview could be prepared."; - - if (visualMessage.includes("LibreOffice headless converter not found")) { - return `LibreOffice headless converter not found. ${installHint} ${contentMessage}`; +): OfficePreviewResult => { + const extracted = + contentResult.status === "fulfilled" && + contentResult.value.preview.kind === "presentation" + ? contentResult.value + : null; + const extractedSlides = + extracted?.preview.kind === "presentation" + ? new Map(extracted.preview.slides.map((slide) => [slide.index, slide])) + : new Map(); + const slides = visual.slides.map((slide) => { + const content = extractedSlides.get(slide.index); + return { + ...slide, + notes: content?.notes ?? slide.notes, + text: content?.text ?? "", + }; + }); + const contentPreview: OfficeContentPreview = { + kind: "presentation", + slideCount: slides.length, + slides, + }; + const result: OfficePreviewResult = { + cacheHit: visual.cacheHit && (extracted?.cacheHit ?? true), + cacheKey: visual.cacheKey, + contentPreview, + previewKind: "presentation", + rendererVersion: visual.rendererVersion, + sourceMtimeMs: visual.sourceMtimeMs, + sourceSize: visual.sourceSize, + }; + if (extracted) { + result.contentPreviewPath = extracted.contentPreviewPath; } - - return `${visualMessage}. ${installHint} ${contentMessage}`; + if (contentResult.status === "rejected") { + result.contentPreviewError = getErrorMessage(contentResult.reason); + } + return result; }; export const registerFileHandlers = () => { @@ -249,40 +236,52 @@ export const registerFileHandlers = () => { searchFiles(rootPath, query, options), ); - // Office 预览:不在工作区生成派生文件。native 负责转换队列、隔离目录、 - // 超时和按 source fingerprint + converter version 的缓存。 + // Office 预览:不在工作区生成派生文件。PPTX 通过本地 Wasm 导入为 + // 结构化模型并逐页渲染 SVG;其他 Office 文件走本地内容解析。 ipcMain.handle( "fs:prepareOfficePreview", async (_event, filePath: string) => { - const libreOfficePath = await resolveLibreOfficePath(); - const [visual, content] = await Promise.allSettled([ - prepareOfficePreview(filePath, { - cacheRoot: OFFICE_PREVIEW_CACHE_ROOT, - libreOfficePath, - timeoutMs: 60_000, - thumbnailSize: 640, - }), - prepareOfficeContentPreview(filePath, { - cacheRoot: OFFICE_CONTENT_PREVIEW_CACHE_ROOT, - libreOfficePath, - }), - ]); - - if (visual.status === "fulfilled") { - return attachOfficeContentPreview( - visualOfficeResult(visual.value), - content, + if (extname(filePath).toLowerCase() === ".pptx") { + const [visual, content] = await Promise.allSettled([ + preparePptxPreview(filePath, { + cacheRoot: PRESENTATION_PREVIEW_CACHE_ROOT, + }), + prepareOfficeContentPreview(filePath, { + cacheRoot: OFFICE_CONTENT_PREVIEW_CACHE_ROOT, + }), + ]); + if (visual.status === "fulfilled") { + return presentationOfficeResult(visual.value, content); + } + if ( + content.status === "fulfilled" && + hasUsableOfficeContent(content.value.preview) + ) { + return { + ...contentOnlyOfficeResult(content.value), + contentPreviewError: getErrorMessage(visual.reason), + }; + } + throw new Error( + `PPTX preview failed: ${getErrorMessage( + visual.reason, + )}; content extraction failed: ${ + content.status === "rejected" + ? getErrorMessage(content.reason) + : content.value.preview.kind === "unsupported" + ? content.value.preview.message + : "no usable presentation content was returned" + }`, ); } - if ( - content.status === "fulfilled" && - hasUsableOfficeContent(content.value.preview) - ) { - return contentOnlyOfficeResult(content.value, visual.reason); + const content = await prepareOfficeContentPreview(filePath, { + cacheRoot: OFFICE_CONTENT_PREVIEW_CACHE_ROOT, + }); + if (!hasUsableOfficeContent(content.preview)) { + throw new Error(content.preview.message); } - - throw new Error(buildOfficePreviewFailureMessage(visual.reason, content)); + return contentOnlyOfficeResult(content); }, ); diff --git a/src/main/native/__tests__/exports.test.ts b/src/main/native/__tests__/exports.test.ts index 6b930627..a039a9b9 100644 --- a/src/main/native/__tests__/exports.test.ts +++ b/src/main/native/__tests__/exports.test.ts @@ -5,17 +5,17 @@ import { describe, expect, it } from "vitest"; import { assertNativeModuleShape } from ".."; describe("@filework/native exports", () => { - it("exposes the Office preview preparation entrypoint", () => { + it("does not expose the removed external Office conversion entrypoint", () => { const requireNative = createRequire(import.meta.url); const nativeModule = requireNative("@filework/native") as Record< string, unknown >; - expect(nativeModule.prepareOfficePreviewNative).toBeTypeOf("function"); + expect(nativeModule.prepareOfficePreviewNative).toBeUndefined(); }); - it("reports stale native bindings with a rebuild instruction", () => { + it("accepts native bindings without the removed Office converter", () => { expect(() => assertNativeModuleShape({ directoryStats: () => undefined, @@ -23,9 +23,7 @@ describe("@filework/native exports", () => { scanDirectoryLevel: () => undefined, searchFiles: () => undefined, }), - ).toThrow( - /prepareOfficePreviewNative.*pnpm --filter @filework\/native run build/s, - ); + ).not.toThrow(); }); it("rebuilds native bindings before app dev and production builds", () => { diff --git a/src/main/native/index.ts b/src/main/native/index.ts index 9a988577..d94c5935 100644 --- a/src/main/native/index.ts +++ b/src/main/native/index.ts @@ -64,39 +64,6 @@ export interface NativeSearchResult { truncated: boolean; } -/** native Office 预览生成的输入选项。 */ -export interface NativeOfficePreviewOptions { - /** 预览缓存根目录,通常位于应用数据目录而非工作区。 */ - cacheRoot: string; - /** 可选 LibreOffice/soffice 可执行文件路径;未传时 native 自行解析。 */ - libreOfficePath?: string; - /** 可选 macOS Quick Look 可执行文件路径;未传时 native 使用 /usr/bin/qlmanage。 */ - quickLookPath?: string; - /** 可选测试/自定义缩略图命令: 。 */ - thumbnailerPath?: string; - /** 单次外部转换命令超时。默认 60s。 */ - timeoutMs?: number; - /** 缩略图最大边长。默认 640。 */ - thumbnailSize?: number; -} - -interface NativeOfficePreviewRequest extends NativeOfficePreviewOptions { - sourcePath: string; -} - -/** native Office 预览生成结果。 */ -export interface NativeOfficePreviewResult { - cacheKey: string; - previewKind: "pdf" | "image"; - previewPath: string; - pdfPath?: string; - thumbnailPath?: string; - sourceMtimeMs: number; - sourceSize: number; - converterVersion: string; - cacheHit: boolean; -} - export interface NativeModule { findDuplicates( rootPath: string, @@ -109,9 +76,6 @@ export interface NativeModule { query: string, options?: NativeSearchOptions | null, ): Promise; - prepareOfficePreviewNative( - request: NativeOfficePreviewRequest, - ): Promise; } const REQUIRED_NATIVE_EXPORTS = [ @@ -119,7 +83,6 @@ const REQUIRED_NATIVE_EXPORTS = [ "directoryStats", "scanDirectoryLevel", "searchFiles", - "prepareOfficePreviewNative", ] as const; export function assertNativeModuleShape( @@ -205,14 +168,3 @@ export function searchFiles( ): Promise { return loadNative().searchFiles(rootPath, query, options ?? null); } - -/** - * 用 native (Rust) 准备 Office 文件预览:计算缓存指纹、串行调度 - * LibreOffice headless 转 PDF、生成可选缩略图,并返回缓存产物路径。 - */ -export function prepareOfficePreview( - sourcePath: string, - options: NativeOfficePreviewOptions, -): Promise { - return loadNative().prepareOfficePreviewNative({ sourcePath, ...options }); -} diff --git a/src/main/office-preview/__tests__/content.test.ts b/src/main/office-preview/__tests__/content.test.ts index 31df9867..2c02d9c2 100644 --- a/src/main/office-preview/__tests__/content.test.ts +++ b/src/main/office-preview/__tests__/content.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -163,42 +163,20 @@ describe("Office content preview", () => { ]); }); - it("converts legacy PPT files to PPTX for full slide content fallback", async () => { - const deckPath = join(root, "converted.pptx"); + it("does not invoke an external converter for legacy PPT files", async () => { const legacyPath = join(root, "legacy.ppt"); - const fakeLibreOfficePath = join(root, "fake-soffice"); - await writeMinimalPptx(deckPath); await writeFile(legacyPath, "legacy ppt bytes"); - await writeFile( - fakeLibreOfficePath, - `#!/usr/bin/env node -const fs = require("node:fs"); -const path = require("node:path"); -const args = process.argv.slice(2); -if (args.includes("--version")) { - console.log("LibreOffice 24.2"); - process.exit(0); -} -const outdir = args[args.indexOf("--outdir") + 1]; -const input = args[args.length - 1]; -const output = path.join(outdir, path.basename(input, path.extname(input)) + ".pptx"); -fs.copyFileSync(${JSON.stringify(deckPath)}, output); -`, - ); - await chmod(fakeLibreOfficePath, 0o755); const result = await prepareOfficeContentPreview(legacyPath, { cacheRoot, - libreOfficePath: fakeLibreOfficePath, }); - expect(result.preview.kind).toBe("presentation"); - if (result.preview.kind !== "presentation") return; - expect(result.preview.slideCount).toBe(2); - expect(result.preview.slides.map((slide) => slide.text)).toEqual([ - "Roadmap\nFirst milestone", - "Launch & Learn", - ]); + expect(result.preview).toEqual({ + kind: "unsupported", + message: + "Legacy PowerPoint files are not supported; save the deck as .pptx.", + reason: "unsupported-format", + }); }); it("extracts DOCX text and HTML for document fallback preview", async () => { diff --git a/src/main/office-preview/__tests__/paths.test.ts b/src/main/office-preview/__tests__/paths.test.ts deleted file mode 100644 index ce35896f..00000000 --- a/src/main/office-preview/__tests__/paths.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveLibreOfficePath } from "../paths"; - -describe("Office preview path resolution", () => { - it("uses FILEWORK_LIBREOFFICE_PATH when it is set", async () => { - await expect( - resolveLibreOfficePath({ - env: { FILEWORK_LIBREOFFICE_PATH: "/custom/soffice" }, - exists: async () => false, - pathValue: "", - }), - ).resolves.toBe("/custom/soffice"); - }); - - it("finds soffice on PATH", async () => { - await expect( - resolveLibreOfficePath({ - env: {}, - exists: async (path) => path === "/opt/bin/soffice", - pathValue: "/opt/bin:/usr/bin", - }), - ).resolves.toBe("/opt/bin/soffice"); - }); - - it("checks common macOS install locations when app PATH is minimal", async () => { - await expect( - resolveLibreOfficePath({ - commonPaths: ["/Applications/LibreOffice.app/Contents/MacOS/soffice"], - env: {}, - exists: async (path) => - path === "/Applications/LibreOffice.app/Contents/MacOS/soffice", - pathValue: "/usr/bin", - }), - ).resolves.toBe("/Applications/LibreOffice.app/Contents/MacOS/soffice"); - }); -}); diff --git a/src/main/office-preview/content.ts b/src/main/office-preview/content.ts index 2d1412a8..6b6fdb47 100644 --- a/src/main/office-preview/content.ts +++ b/src/main/office-preview/content.ts @@ -2,12 +2,9 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, - mkdtemp, - readdir, readFile, realpath, rename, - rm, stat, writeFile, } from "node:fs/promises"; @@ -27,11 +24,9 @@ import { listSlideAndNotesPaths, parsePptxMetaXml, } from "../skills/pptx-processor"; -import { resolveLibreOfficePath } from "./paths"; -const CONTENT_PREVIEW_CACHE_VERSION = "office-content-preview-v1"; +const CONTENT_PREVIEW_CACHE_VERSION = "office-content-preview-v2"; const DEFAULT_TEXTUTIL_TIMEOUT_MS = 15_000; -const DEFAULT_LIBREOFFICE_TIMEOUT_MS = 60_000; const TEXTUTIL_MAX_BUFFER = 20 * 1024 * 1024; const SPREADSHEET_EXTENSIONS = new Set([ @@ -71,8 +66,6 @@ type JSZipModule = { interface OfficeContentPreviewOptions { cacheRoot: string; - libreOfficePath?: string; - libreOfficeTimeoutMs?: number; textutilPath?: string; textutilTimeoutMs?: number; } @@ -342,67 +335,6 @@ const readPresentationPreview = async ( }; }; -const firstPptxInDir = async (dir: string): Promise => { - const entries = await readdir(dir); - const name = entries.find((entry) => entry.toLowerCase().endsWith(".pptx")); - return name ? join(dir, name) : null; -}; - -const convertWithLibreOffice = async ( - sourcePath: string, - outputFormat: "pptx", - outputDir: string, - options: OfficeContentPreviewOptions, -) => { - const libreOfficePath = - options.libreOfficePath ?? (await resolveLibreOfficePath()); - if (!libreOfficePath) { - throw new Error( - "LibreOffice headless converter not found. Install LibreOffice or set FILEWORK_LIBREOFFICE_PATH.", - ); - } - - await execFileAsync( - libreOfficePath, - [ - "--headless", - "--nologo", - "--nolockcheck", - "--nodefault", - "--nofirststartwizard", - "--norestore", - "--convert-to", - outputFormat, - "--outdir", - outputDir, - sourcePath, - ], - { - timeout: options.libreOfficeTimeoutMs ?? DEFAULT_LIBREOFFICE_TIMEOUT_MS, - maxBuffer: TEXTUTIL_MAX_BUFFER, - }, - ); -}; - -const readLegacyPresentationPreview = async ( - sourcePath: string, - options: OfficeContentPreviewOptions, -): Promise => { - const tempDir = await mkdtemp(join(options.cacheRoot, ".pptx-convert-")); - try { - await convertWithLibreOffice(sourcePath, "pptx", tempDir, options); - const pptxPath = await firstPptxInDir(tempDir); - if (!pptxPath) { - throw new Error( - `LibreOffice conversion finished but did not produce a PPTX in ${tempDir}`, - ); - } - return await readPresentationPreview(pptxPath); - } finally { - await rm(tempDir, { force: true, recursive: true }); - } -}; - const normalizeCellValue = (value: unknown): string => { if (value === null || value === undefined) return ""; if (value instanceof Date) return value.toISOString(); @@ -482,17 +414,10 @@ const generateContentPreview = async ( } if (LEGACY_PRESENTATION_EXTENSIONS.has(fingerprint.extension)) { - try { - return await readLegacyPresentationPreview( - fingerprint.sourcePath, - options, - ); - } catch (error) { - return unsupportedPreview( - "parse-error", - `Legacy PowerPoint content preview failed: ${asErrorMessage(error)}`, - ); - } + return unsupportedPreview( + "unsupported-format", + "Legacy PowerPoint files are not supported; save the deck as .pptx.", + ); } if ( @@ -504,7 +429,7 @@ const generateContentPreview = async ( return unsupportedPreview( "unsupported-format", - "This Office format needs LibreOffice for full PDF preview.", + "This Office format does not have a local structured preview.", ); }; diff --git a/src/main/office-preview/paths.ts b/src/main/office-preview/paths.ts deleted file mode 100644 index 430ee200..00000000 --- a/src/main/office-preview/paths.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { access } from "node:fs/promises"; -import { delimiter, join } from "node:path"; - -const COMMON_LIBRE_OFFICE_PATHS = [ - "/Applications/LibreOffice.app/Contents/MacOS/soffice", - "/opt/homebrew/bin/soffice", - "/usr/local/bin/soffice", - "/opt/homebrew/bin/libreoffice", - "/usr/local/bin/libreoffice", -]; - -interface ResolveLibreOfficePathOptions { - env?: Record; - pathValue?: string; - commonPaths?: string[]; - exists?: (path: string) => Promise; -} - -const fileExists = async (path: string): Promise => { - try { - await access(path); - return true; - } catch { - return false; - } -}; - -export const resolveLibreOfficePath = async ( - options: ResolveLibreOfficePathOptions = {}, -): Promise => { - const env = options.env ?? process.env; - const explicit = env.FILEWORK_LIBREOFFICE_PATH?.trim(); - if (explicit) return explicit; - - const exists = options.exists ?? fileExists; - const pathValue = options.pathValue ?? env.PATH ?? ""; - for (const dir of pathValue.split(delimiter)) { - if (!dir) continue; - for (const command of ["soffice", "libreoffice"]) { - const candidate = join(dir, command); - if (await exists(candidate)) return candidate; - } - } - - for (const candidate of options.commonPaths ?? COMMON_LIBRE_OFFICE_PATHS) { - if (await exists(candidate)) return candidate; - } - - return undefined; -}; diff --git a/src/main/presentation/__tests__/pptx.test.ts b/src/main/presentation/__tests__/pptx.test.ts new file mode 100644 index 00000000..5b963701 --- /dev/null +++ b/src/main/presentation/__tests__/pptx.test.ts @@ -0,0 +1,162 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + editPptxPresentation, + inspectPptxPresentation, + type PptxRendererAdapter, + preparePptxPreview, +} from "../pptx"; + +const slideSvg = (text: string) => + ` + + + + ${text.slice(0, 6)} + ${text.slice(6)} + + + + `; + +const makeRenderer = () => { + const renderer: PptxRendererAdapter = { + exportPptx: vi + .fn() + .mockResolvedValue(new TextEncoder().encode("edited-pptx").buffer), + getSlideCount: vi.fn().mockReturnValue(2), + getSlideNotes: vi + .fn() + .mockImplementation((index: number) => + index === 1 ? ["Speaker note"] : [], + ), + init: vi.fn().mockResolvedValue(undefined), + isSlideHidden: vi.fn().mockImplementation((index: number) => index === 1), + loadPptx: vi.fn().mockResolvedValue({ slideCount: 2 }), + renderSlideSvg: vi + .fn() + .mockImplementation((index: number) => + slideSvg(index === 0 ? "Hello world" : "Launch plan"), + ), + updateShapeText: vi.fn().mockReturnValue("Updated"), + }; + return renderer; +}; + +describe("PPTX presentation model", () => { + let root: string; + let sourcePath: string; + + beforeEach(async () => { + root = join( + tmpdir(), + `filework-pptx-model-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`, + ); + await mkdir(root, { recursive: true }); + sourcePath = join(root, "deck.pptx"); + await writeFile(sourcePath, "source-pptx"); + }); + + it("renders every slide to cached SVG without an external Office process", async () => { + const renderer = makeRenderer(); + const createRenderer = vi.fn().mockResolvedValue(renderer); + + const first = await preparePptxPreview(sourcePath, { + cacheRoot: join(root, "cache"), + createRenderer, + }); + const second = await preparePptxPreview(sourcePath, { + cacheRoot: join(root, "cache"), + createRenderer, + }); + + expect(first.cacheHit).toBe(false); + expect(second.cacheHit).toBe(true); + expect(first.slides).toEqual([ + { + hidden: false, + index: 1, + notes: null, + previewPath: expect.stringMatching(/slide-1\.svg$/), + }, + { + hidden: true, + index: 2, + notes: "Speaker note", + previewPath: expect.stringMatching(/slide-2\.svg$/), + }, + ]); + const rendered = await readFile(first.slides[0].previewPath, "utf8"); + expect(rendered).toContain(">Hello "); + expect(rendered).toContain(">world"); + expect(renderer.renderSlideSvg).toHaveBeenCalledTimes(2); + expect(createRenderer).toHaveBeenCalledTimes(1); + }); + + it("inspects rendered objects through stable text-run ids", async () => { + const renderer = makeRenderer(); + + const result = await inspectPptxPresentation(sourcePath, { + createRenderer: vi.fn().mockResolvedValue(renderer), + }); + + expect(result.slideCount).toBe(2); + expect(result.slides[0].objects).toEqual([ + { + geometry: "rect", + objectId: "slide:1/shape:0", + shapeIndex: 0, + textRuns: [ + { + objectId: "slide:1/shape:0/text:0:0", + paragraphIndex: 0, + runIndex: 0, + text: "Hello world", + }, + ], + type: "autoshape", + }, + ]); + }); + + it("edits an exact text object and exports a new PPTX copy", async () => { + const renderer = makeRenderer(); + const outputPath = join(root, "deck-edited.pptx"); + + const result = await editPptxPresentation( + { + edits: [ + { + objectId: "slide:2/shape:0/text:0:0", + text: "Updated launch", + }, + ], + outputPath, + sourcePath, + }, + { + createRenderer: vi.fn().mockResolvedValue(renderer), + }, + ); + + expect(renderer.updateShapeText).toHaveBeenCalledWith( + 1, + 0, + 0, + 0, + "Updated launch", + ); + expect(result).toEqual({ + editedSlides: [2], + outputPath, + slideCount: 2, + }); + await expect(readFile(outputPath, "utf8")).resolves.toBe("edited-pptx"); + await expect(readFile(sourcePath, "utf8")).resolves.toBe("source-pptx"); + }); +}); diff --git a/src/main/presentation/pptx.ts b/src/main/presentation/pptx.ts new file mode 100644 index 00000000..2293e4e9 --- /dev/null +++ b/src/main/presentation/pptx.ts @@ -0,0 +1,424 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + mkdir, + readFile, + realpath, + rename, + stat, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { basename, dirname, extname, join, resolve } from "node:path"; + +import { DOMParser } from "linkedom"; + +const PPTX_RENDERER_VERSION = "pptx-svg@0.6.4"; +const requirePptxSvg = createRequire(import.meta.url); + +export interface PptxRendererAdapter { + init(): Promise; + loadPptx(buffer: ArrayBuffer): Promise<{ slideCount: number }>; + getSlideCount(): number; + isSlideHidden(slideIndex: number): boolean; + renderSlideSvg(slideIndex: number): string; + getSlideNotes(slideIndex: number): string[]; + updateShapeText( + slideIndex: number, + shapeIndex: number, + paragraphIndex: number, + runIndex: number, + text: string, + ): string; + exportPptx(): Promise; +} + +type RendererFactory = () => Promise; + +export interface PptxPreviewSlide { + index: number; + hidden: boolean; + notes: string | null; + previewPath: string; +} + +export interface PptxPreviewResult { + cacheKey: string; + cacheHit: boolean; + rendererVersion: string; + slides: PptxPreviewSlide[]; + sourceMtimeMs: number; + sourceSize: number; +} + +export interface PresentationTextRun { + objectId: string; + paragraphIndex: number; + runIndex: number; + text: string; +} + +export interface PresentationObject { + geometry: string | null; + objectId: string; + shapeIndex: number; + textRuns: PresentationTextRun[]; + type: string | null; +} + +export interface InspectedPresentationSlide { + hidden: boolean; + index: number; + notes: string[]; + objects: PresentationObject[]; +} + +export interface InspectedPresentation { + slideCount: number; + slides: InspectedPresentationSlide[]; +} + +export interface PresentationTextEdit { + objectId: string; + text: string; +} + +export interface EditPresentationRequest { + sourcePath: string; + outputPath?: string; + edits: PresentationTextEdit[]; +} + +export interface EditPresentationResult { + editedSlides: number[]; + outputPath: string; + slideCount: number; +} + +interface RendererOptions { + createRenderer?: RendererFactory; +} + +interface PreviewOptions extends RendererOptions { + cacheRoot: string; +} + +const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + +const defaultRendererFactory: RendererFactory = async () => { + const [{ PptxRenderer }, wasmBytes] = await Promise.all([ + import("pptx-svg"), + readFile(requirePptxSvg.resolve("pptx-svg/wasm")), + ]); + const renderer = new PptxRenderer({ logLevel: "error" }); + return { + exportPptx: () => renderer.exportPptx(), + getSlideCount: () => renderer.getSlideCount(), + getSlideNotes: (slideIndex) => renderer.getSlideNotes(slideIndex), + init: () => renderer.init(toArrayBuffer(wasmBytes)), + isSlideHidden: (slideIndex) => renderer.isSlideHidden(slideIndex), + loadPptx: (buffer) => renderer.loadPptx(buffer), + renderSlideSvg: (slideIndex) => renderer.renderSlideSvg(slideIndex), + updateShapeText: (slideIndex, shapeIndex, paragraphIndex, runIndex, text) => + renderer.updateShapeText( + slideIndex, + shapeIndex, + paragraphIndex, + runIndex, + text, + ), + }; +}; + +const assertPptxPath = (filePath: string, label: string) => { + if (extname(filePath).toLowerCase() !== ".pptx") { + throw new Error(`${label} must use the .pptx extension: ${filePath}`); + } +}; + +const loadRenderer = async ( + sourcePath: string, + createRenderer: RendererFactory = defaultRendererFactory, +) => { + assertPptxPath(sourcePath, "Presentation"); + const renderer = await createRenderer(); + await renderer.init(); + const bytes = await readFile(sourcePath); + await renderer.loadPptx(toArrayBuffer(bytes)); + return { bytes, renderer }; +}; + +const renderSlide = ( + renderer: PptxRendererAdapter, + slideIndex: number, +): string => { + const svg = renderer.renderSlideSvg(slideIndex); + if (svg.startsWith("ERROR:")) { + throw new Error(`PPTX slide ${slideIndex + 1} render failed: ${svg}`); + } + return svg; +}; + +const writeAtomically = async ( + filePath: string, + content: string | Uint8Array, +) => { + await mkdir(dirname(filePath), { recursive: true }); + const tempPath = join( + dirname(filePath), + `.${basename(filePath)}.${randomUUID()}.tmp`, + ); + await writeFile(tempPath, content); + await rename(tempPath, filePath); +}; + +const readCachedPreview = async ( + manifestPath: string, +): Promise => { + try { + const cached = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as PptxPreviewResult; + if ( + cached.rendererVersion !== PPTX_RENDERER_VERSION || + !Array.isArray(cached.slides) + ) { + return null; + } + await Promise.all(cached.slides.map((slide) => stat(slide.previewPath))); + return { ...cached, cacheHit: true }; + } catch { + return null; + } +}; + +const buildFingerprint = async (sourcePath: string, bytes: Uint8Array) => { + const sourceStat = await stat(sourcePath); + const canonicalPath = await realpath(sourcePath).catch(() => + resolve(sourcePath), + ); + const sourceHash = createHash("sha256").update(bytes).digest("hex"); + const cacheKey = createHash("sha256") + .update( + [ + "filework-pptx-preview-v1", + canonicalPath, + String(sourceStat.mtimeMs), + String(sourceStat.size), + sourceHash, + PPTX_RENDERER_VERSION, + ].join("\0"), + ) + .digest("hex"); + return { + cacheKey, + sourceMtimeMs: sourceStat.mtimeMs, + sourceSize: sourceStat.size, + }; +}; + +export const preparePptxPreview = async ( + sourcePath: string, + options: PreviewOptions, +): Promise => { + assertPptxPath(sourcePath, "Presentation"); + const sourceBytes = await readFile(sourcePath); + const fingerprint = await buildFingerprint(sourcePath, sourceBytes); + const cacheDir = join(options.cacheRoot, fingerprint.cacheKey); + const manifestPath = join(cacheDir, "preview.json"); + const cached = await readCachedPreview(manifestPath); + if (cached) return cached; + + const renderer = await (options.createRenderer ?? defaultRendererFactory)(); + await renderer.init(); + await renderer.loadPptx(toArrayBuffer(sourceBytes)); + + const slides: PptxPreviewSlide[] = []; + for ( + let slideIndex = 0; + slideIndex < renderer.getSlideCount(); + slideIndex++ + ) { + const previewPath = join(cacheDir, `slide-${slideIndex + 1}.svg`); + await writeAtomically(previewPath, renderSlide(renderer, slideIndex)); + const notes = renderer.getSlideNotes(slideIndex); + slides.push({ + hidden: renderer.isSlideHidden(slideIndex), + index: slideIndex + 1, + notes: notes.length > 0 ? notes.join("\n") : null, + previewPath, + }); + } + + const result: PptxPreviewResult = { + ...fingerprint, + cacheHit: false, + rendererVersion: PPTX_RENDERER_VERSION, + slides, + }; + await writeAtomically(manifestPath, JSON.stringify(result)); + return result; +}; + +const parsePresentationObjects = ( + svg: string, + slideNumber: number, +): PresentationObject[] => { + type SvgElement = { + getAttribute(name: string): string | null; + querySelectorAll(selector: string): ArrayLike; + textContent: string | null; + }; + const document = new DOMParser().parseFromString(svg, "image/svg+xml"); + if (!document) return []; + + const shapes = Array.from( + document.querySelectorAll("g[data-ooxml-shape-idx]"), + ) as unknown as SvgElement[]; + return shapes.map((shape) => { + const shapeIndex = Number.parseInt( + shape.getAttribute("data-ooxml-shape-idx") ?? "", + 10, + ); + const runs = new Map(); + for (const paragraph of Array.from( + shape.querySelectorAll("tspan[data-ooxml-para-idx]"), + )) { + const paragraphIndex = Number.parseInt( + paragraph.getAttribute("data-ooxml-para-idx") ?? "", + 10, + ); + for (const run of Array.from( + paragraph.querySelectorAll("tspan[data-ooxml-run-idx]"), + )) { + const runIndex = Number.parseInt( + run.getAttribute("data-ooxml-run-idx") ?? "", + 10, + ); + if ( + !Number.isInteger(shapeIndex) || + !Number.isInteger(paragraphIndex) || + !Number.isInteger(runIndex) + ) { + continue; + } + const objectId = `slide:${slideNumber}/shape:${shapeIndex}/text:${paragraphIndex}:${runIndex}`; + const previous = runs.get(objectId); + runs.set(objectId, { + objectId, + paragraphIndex, + runIndex, + text: `${previous?.text ?? ""}${run.textContent ?? ""}`, + }); + } + } + return { + geometry: shape.getAttribute("data-ooxml-geom"), + objectId: `slide:${slideNumber}/shape:${shapeIndex}`, + shapeIndex, + textRuns: Array.from(runs.values()), + type: shape.getAttribute("data-ooxml-shape-type"), + }; + }); +}; + +export const inspectPptxPresentation = async ( + sourcePath: string, + options: RendererOptions = {}, +): Promise => { + const { renderer } = await loadRenderer( + sourcePath, + options.createRenderer ?? defaultRendererFactory, + ); + const slides: InspectedPresentationSlide[] = []; + for ( + let slideIndex = 0; + slideIndex < renderer.getSlideCount(); + slideIndex++ + ) { + slides.push({ + hidden: renderer.isSlideHidden(slideIndex), + index: slideIndex + 1, + notes: renderer.getSlideNotes(slideIndex), + objects: parsePresentationObjects( + renderSlide(renderer, slideIndex), + slideIndex + 1, + ), + }); + } + return { slideCount: renderer.getSlideCount(), slides }; +}; + +const parseTextObjectId = (objectId: string) => { + const match = objectId.match(/^slide:(\d+)\/shape:(\d+)\/text:(\d+):(\d+)$/); + if (!match) { + throw new Error(`Invalid presentation text object id: ${objectId}`); + } + return { + paragraphIndex: Number(match[3]), + runIndex: Number(match[4]), + shapeIndex: Number(match[2]), + slideNumber: Number(match[1]), + }; +}; + +const defaultEditedPath = (sourcePath: string) => + join( + dirname(sourcePath), + `${basename(sourcePath, extname(sourcePath))}-edited.pptx`, + ); + +export const editPptxPresentation = async ( + request: EditPresentationRequest, + options: RendererOptions = {}, +): Promise => { + if (request.edits.length === 0) { + throw new Error("At least one presentation text edit is required."); + } + const outputPath = resolve( + request.outputPath ?? defaultEditedPath(request.sourcePath), + ); + assertPptxPath(outputPath, "Presentation output"); + if (resolve(request.sourcePath) === outputPath) { + throw new Error("Presentation edits must be exported to a new .pptx file."); + } + + const { renderer } = await loadRenderer( + request.sourcePath, + options.createRenderer ?? defaultRendererFactory, + ); + const editedSlides = new Set(); + for (const edit of request.edits) { + const target = parseTextObjectId(edit.objectId); + if ( + target.slideNumber < 1 || + target.slideNumber > renderer.getSlideCount() + ) { + throw new Error(`Presentation slide is out of range: ${edit.objectId}`); + } + const response = renderer.updateShapeText( + target.slideNumber - 1, + target.shapeIndex, + target.paragraphIndex, + target.runIndex, + edit.text, + ); + if (response.startsWith("ERROR:")) { + throw new Error( + `Presentation edit failed for ${edit.objectId}: ${response}`, + ); + } + editedSlides.add(target.slideNumber); + } + + const exported = new Uint8Array(await renderer.exportPptx()); + await writeAtomically(outputPath, exported); + return { + editedSlides: Array.from(editedSlides).sort((a, b) => a - b), + outputPath, + slideCount: renderer.getSlideCount(), + }; +}; diff --git a/src/main/skills/__tests__/pptx-processor.test.ts b/src/main/skills/__tests__/pptx-processor.test.ts index 69644764..fbd66cd5 100644 --- a/src/main/skills/__tests__/pptx-processor.test.ts +++ b/src/main/skills/__tests__/pptx-processor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { pptxEditor } from "../pptx-editor"; import { decodeXmlEntities, extractSlideText, @@ -228,3 +229,13 @@ describe("pptxProcessor skill spec", () => { expect(pptxProcessor.systemPrompt.length).toBeGreaterThan(100); }); }); + +describe("pptxEditor skill spec", () => { + it("is a mutating task skill with inspect and edit tools", () => { + expect(pptxEditor.category).toBe("task"); + expect(Object.keys(pptxEditor.tools ?? {}).sort()).toEqual([ + "editPptxText", + "inspectPptxObjects", + ]); + }); +}); diff --git a/src/main/skills/index.ts b/src/main/skills/index.ts index e874b259..ff8e9050 100644 --- a/src/main/skills/index.ts +++ b/src/main/skills/index.ts @@ -5,6 +5,7 @@ import { docxProcessor } from "./docx-processor"; import { duplicateFinder } from "./duplicate-finder"; import { fileOrganizer } from "./file-organizer"; import { pdfProcessor } from "./pdf-processor"; +import { pptxEditor } from "./pptx-editor"; import { pptxProcessor } from "./pptx-processor"; import { projectScaffolder } from "./project-scaffolder"; import { reportGenerator } from "./report-generator"; @@ -25,6 +26,7 @@ export const skills: Skill[] = [ xlsxProcessor, docxProcessor, pptxProcessor, + pptxEditor, ]; /** 预注册了内置 skill 的 SkillRegistry 单例 */ diff --git a/src/main/skills/pptx-editor.ts b/src/main/skills/pptx-editor.ts new file mode 100644 index 00000000..6b77949f --- /dev/null +++ b/src/main/skills/pptx-editor.ts @@ -0,0 +1,159 @@ +import type { Tool } from "ai"; +import { z } from "zod/v4"; + +import { + editPptxPresentation, + inspectPptxPresentation, +} from "../presentation/pptx"; +import { validateFile } from "./file-skill-utils"; +import type { Skill } from "./types"; + +const inspectPptxObjectsTool: Tool = { + description: + "检查 PPTX 的幻灯片、形状和文本 run,返回可用于精确编辑的稳定 objectId", + inputSchema: z.object({ + path: z.string().describe("PPTX 文件的绝对路径"), + search: z + .string() + .optional() + .describe("可选文本搜索;只返回包含该文本的对象"), + slide: z + .number() + .int() + .positive() + .optional() + .describe("可选的幻灯片页码,从 1 开始"), + }), + execute: async ({ + path, + search, + slide, + }: { + path: string; + search?: string; + slide?: number; + }) => { + const validation = await validateFile(path, [".pptx"]); + if (!validation.valid) return { error: validation.error }; + try { + const presentation = await inspectPptxPresentation(path); + const normalizedSearch = search?.trim().toLocaleLowerCase(); + const slides = presentation.slides + .filter((item) => slide === undefined || item.index === slide) + .map((item) => ({ + ...item, + objects: normalizedSearch + ? item.objects.filter((object) => + object.textRuns.some((run) => + run.text.toLocaleLowerCase().includes(normalizedSearch), + ), + ) + : item.objects, + })) + .filter( + (item) => + !normalizedSearch || + item.objects.length > 0 || + item.notes.some((note) => + note.toLocaleLowerCase().includes(normalizedSearch), + ), + ); + return { + slideCount: presentation.slideCount, + slides, + }; + } catch (error) { + return { + error: `PPTX 对象检查失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + }, +}; + +const editPptxTextTool: Tool = { + description: + "按 inspectPptxObjects 返回的稳定 objectId 修改 PPTX 文本,并导出为新的可编辑 PPTX 文件", + inputSchema: z.object({ + path: z.string().describe("源 PPTX 文件的绝对路径"), + outputPath: z + .string() + .optional() + .describe("输出 PPTX 的绝对路径;默认在源文件旁生成 *-edited.pptx"), + edits: z + .array( + z.object({ + objectId: z + .string() + .describe("inspectPptxObjects 返回的文本 objectId"), + text: z.string().describe("替换后的完整文本 run 内容"), + }), + ) + .min(1) + .max(100), + }), + execute: async ({ + path, + outputPath, + edits, + }: { + path: string; + outputPath?: string; + edits: Array<{ objectId: string; text: string }>; + }) => { + const validation = await validateFile(path, [".pptx"]); + if (!validation.valid) return { error: validation.error }; + try { + return await editPptxPresentation({ + edits, + outputPath, + sourcePath: path, + }); + } catch (error) { + return { + error: `PPTX 编辑失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + }, +}; + +export const pptxEditor: Skill = { + id: "pptx-editor", + name: "PowerPoint 演示文稿编辑", + description: + "检查 PowerPoint 对象并对文本进行精确、可回写的本地编辑,保留原文件并导出副本", + category: "task", + keywords: [ + "编辑pptx", + "修改pptx", + "替换幻灯片文字", + "编辑powerpoint", + "edit pptx", + "update presentation", + "replace slide text", + ], + suggestions: [ + "把这个 PPTX 里的旧产品名替换为新产品名", + "检查并修改第 3 页的标题", + ], + tools: { + editPptxText: editPptxTextTool, + inspectPptxObjects: inspectPptxObjectsTool, + }, + systemPrompt: `You are executing a POWERPOINT EDITING task with a local structured PPTX model. + +## Execution Steps +1. Always call \`inspectPptxObjects\` first and locate exact text object IDs. +2. Apply the smallest focused set of edits with \`editPptxText\`. +3. Export to a new .pptx copy. Never overwrite the source presentation. +4. Report the output path and edited slide numbers. + +## Rules +- Never guess an object ID. +- Text object IDs are stable only for the inspected source revision. +- Preserve unrelated shapes, layouts, masters, media, notes, comments, transitions, and animations through round-trip export. +- If a requested object is not represented as editable text, explain that limitation instead of replacing the whole slide with an image.`, +}; diff --git a/src/renderer/components/file-preview/FilePreviewPanel.tsx b/src/renderer/components/file-preview/FilePreviewPanel.tsx index 8a86b288..7cdb8b6a 100644 --- a/src/renderer/components/file-preview/FilePreviewPanel.tsx +++ b/src/renderer/components/file-preview/FilePreviewPanel.tsx @@ -9,7 +9,10 @@ import { ZoomOut, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -import type { OfficeContentPreview } from "../../../shared/office-preview"; +import type { + OfficeContentPreview, + OfficePresentationContentPreview, +} from "../../../shared/office-preview"; import { useI18nContext } from "../../i18n/i18n-react"; import { localFileUrl } from "../../lib/local-file-url"; import { cn } from "../../lib/utils"; @@ -135,12 +138,6 @@ interface OfficeContentLabels { speakerNotes: string; } -const OfficeFallbackNotice = ({ message }: { message: string }) => ( -
- {message} -
-); - const OfficeContentPreviewPane = ({ preview, labels, @@ -304,6 +301,55 @@ const OfficeContentPreviewPane = ({ ); }; +const OfficePresentationPreviewPane = ({ + labels, + preview, + zoom, +}: { + labels: OfficeContentLabels; + preview: OfficePresentationContentPreview; + zoom: number; +}) => { + const visualSlides = preview.slides.filter((slide) => slide.previewPath); + if (visualSlides.length === 0) { + return ( +
+ {labels.emptyOfficeContent} +
+ ); + } + + return ( +
+
+ {visualSlides.map((slide) => ( +
+
+ {labels.slide(slide.index)} +
+ {labels.slide(slide.index)} +
+ ))} +
+
+ ); +}; + interface FilePreviewPanelProps { filePath: string; } @@ -315,7 +361,6 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { const [truncated, setTruncated] = useState(false); const [truncatedTotal, setTruncatedTotal] = useState(0); const [imageSrc, setImageSrc] = useState(null); - const [officePdfPath, setOfficePdfPath] = useState(null); const [officePreview, setOfficePreview] = useState > | null>(null); @@ -360,7 +405,6 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { setTruncated(false); setTruncatedTotal(0); setImageSrc(null); - setOfficePdfPath(null); setOfficePreview(null); setOfficeView("visual"); setMdView("rendered"); @@ -382,12 +426,12 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { if (!cancelled) { const hasContent = hasUsableOfficeContent(result.contentPreview); setOfficePreview(result); - if (result.pdfPath) { - setOfficePdfPath(result.pdfPath); + if ( + result.previewKind === "presentation" && + result.contentPreview?.kind === "presentation" && + result.contentPreview.slides.some((slide) => slide.previewPath) + ) { setOfficeView("visual"); - } else if (result.previewKind === "image") { - setImageSrc(localFileUrl(result.previewPath)); - setOfficeView(hasContent ? "content" : "visual"); } else if (result.previewKind === "content" && hasContent) { setOfficeView("content"); } else { @@ -435,8 +479,11 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { ) ? officePreview.contentPreview : null; - const hasOfficeVisual = Boolean(officePdfPath || imageSrc); - const showOfficeNotice = Boolean(officePreview?.visualPreviewUnavailable); + const officePresentationPreview = + officeContentPreview?.kind === "presentation" ? officeContentPreview : null; + const hasOfficeVisual = Boolean( + officePresentationPreview?.slides.some((slide) => slide.previewPath), + ); const officeContentLabels: OfficeContentLabels = { emptyOfficeContent: LL.preview_emptyOfficeContent(), emptySheet: LL.preview_emptySheet(), @@ -601,69 +648,85 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { )} + {!isLoading && !error && isImage && imageSrc && ( +
+
+ + + +
+
+ {fileName} setError(LL.preview_readImageError())} + /> +
+
+ )} + + {!isLoading && isPdf && } + {!isLoading && !error && - (isImage || (isOffice && officeView === "visual")) && - imageSrc && ( -
- {isOffice && showOfficeNotice && ( - - )} -
+ isOffice && + officeView === "visual" && + officePresentationPreview && ( +
+
-
- {fileName} setError(LL.preview_readImageError())} - /> -
-
- )} - - {!isLoading && isPdf && } - - {!isLoading && - !error && - isOffice && - officeView === "visual" && - officePdfPath && ( -
- {showOfficeNotice && ( - - )}
- +
)} @@ -674,11 +737,6 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { officeView === "content" && officeContentPreview && (
- {showOfficeNotice && ( - - )}
{ preview_truncated: (size: string) => `Truncated ${size}`, preview_emptyOfficeContent: () => "No extracted Office content", preview_emptySheet: () => "Empty sheet", - preview_officePdfUnavailable: () => "Full PDF preview unavailable", preview_slide: (index: number) => `Slide ${index}`, preview_speakerNotes: () => "Speaker notes", preview_unsupported: () => "Preview not supported for", @@ -80,13 +79,14 @@ describe("FilePreviewPanel Office preview", () => { const previewResult = { cacheHit: false, cacheKey: "cache-key", - converterVersion: "LibreOffice 24.2", - pdfPath: "/tmp/filework-preview/preview.pdf", - previewKind: "pdf", - previewPath: "/tmp/filework-preview/preview.pdf", + contentPreview: { + kind: "document", + source: "mammoth", + text: "Project brief", + }, + previewKind: "content", sourceMtimeMs: 1, sourceSize: 10, - thumbnailPath: "/tmp/filework-preview/thumbnail.png", }; prepareOfficePreview = vi.fn().mockResolvedValue(previewResult); Object.assign(window, { @@ -115,7 +115,7 @@ describe("FilePreviewPanel Office preview", () => { root = null; }); - it("prepares Office files as cached PDFs and renders them with the PDF viewer", async () => { + it("renders non-presentation Office files from local structured content", async () => { act(() => { root?.render(); }); @@ -123,58 +123,38 @@ describe("FilePreviewPanel Office preview", () => { expect(prepareOfficePreview).toHaveBeenCalledWith("/workspace/report.docx"); await flushPreview(); - expect(container.innerHTML).toContain( - 'data-pdf-viewer-path="/tmp/filework-preview/preview.pdf"', - ); - expect(container.textContent).not.toContain("Preview not supported"); - }); - - it("renders Quick Look image previews when Office PDF conversion is unavailable", async () => { - prepareOfficePreview.mockResolvedValue({ - cacheHit: false, - cacheKey: "quick-look-cache-key", - converterVersion: "Quick Look thumbnail", - pdfPath: undefined, - previewKind: "image", - previewPath: "/tmp/filework-preview/thumbnail.png", - sourceMtimeMs: 1, - sourceSize: 10, - thumbnailPath: "/tmp/filework-preview/thumbnail.png", - }); - - act(() => { - root?.render(); - }); - - await flushPreview(); - - expect(container.innerHTML).toContain( - "local-file://open?path=%2Ftmp%2Ffilework-preview%2Fthumbnail.png", - ); + expect(container.textContent).toContain("Project brief"); expect(container.innerHTML).not.toContain("data-pdf-viewer-path"); - expect(container.textContent).not.toContain("Failed to read file"); + expect(container.textContent).not.toContain("Preview not supported"); }); - it("renders all extracted PPTX slides when only a Quick Look thumbnail is available", async () => { + it("renders every PPTX slide from the local SVG presentation model", async () => { prepareOfficePreview.mockResolvedValue({ cacheHit: false, - cacheKey: "quick-look-cache-key", + cacheKey: "presentation-cache-key", contentPreview: { kind: "presentation", slideCount: 2, slides: [ - { index: 1, notes: null, text: "Roadmap\nFirst milestone" }, - { index: 2, notes: "Speaker note", text: "Launch & Learn" }, + { + hidden: false, + index: 1, + notes: null, + previewPath: "/tmp/filework-preview/slide-1.svg", + text: "Roadmap\nFirst milestone", + }, + { + hidden: false, + index: 2, + notes: "Speaker note", + previewPath: "/tmp/filework-preview/slide-2.svg", + text: "Launch & Learn", + }, ], }, - converterVersion: "Quick Look thumbnail", - pdfPath: undefined, - previewKind: "image", - previewPath: "/tmp/filework-preview/thumbnail.png", + previewKind: "presentation", sourceMtimeMs: 1, sourceSize: 10, - thumbnailPath: "/tmp/filework-preview/thumbnail.png", - visualPreviewUnavailable: true, }); act(() => { @@ -183,13 +163,16 @@ describe("FilePreviewPanel Office preview", () => { await flushPreview(); - expect(container.innerHTML).toContain('data-office-slide="1"'); - expect(container.innerHTML).toContain('data-office-slide="2"'); - expect(container.textContent).toContain("Roadmap"); - expect(container.textContent).toContain("First milestone"); - expect(container.textContent).toContain("Launch & Learn"); - expect(container.textContent).toContain("Speaker note"); - expect(container.textContent).toContain("Full PDF preview unavailable"); + expect(container.innerHTML).toContain('data-presentation-slide="1"'); + expect(container.innerHTML).toContain('data-presentation-slide="2"'); + expect(container.innerHTML).toContain( + "local-file://open?path=%2Ftmp%2Ffilework-preview%2Fslide-1.svg", + ); + expect(container.innerHTML).toContain( + "local-file://open?path=%2Ftmp%2Ffilework-preview%2Fslide-2.svg", + ); + expect(container.innerHTML).not.toContain("data-pdf-viewer-path"); + expect(container.textContent).not.toContain("Full PDF preview unavailable"); }); it("renders every Excel sheet and switches between sheet tabs", async () => { @@ -224,14 +207,9 @@ describe("FilePreviewPanel Office preview", () => { }, ], }, - converterVersion: "Content extraction", - pdfPath: undefined, previewKind: "content", - previewPath: "/tmp/filework-preview/content.json", sourceMtimeMs: 1, sourceSize: 10, - thumbnailPath: undefined, - visualPreviewUnavailable: true, }); act(() => { diff --git a/src/renderer/i18n/en/index.ts b/src/renderer/i18n/en/index.ts index 6cba46af..1d0f2000 100644 --- a/src/renderer/i18n/en/index.ts +++ b/src/renderer/i18n/en/index.ts @@ -704,8 +704,6 @@ const en: BaseTranslation = { preview_viewVisual: "Visual", preview_viewContent: "Content", preview_openInBrowser: "Open in browser", - preview_officePdfUnavailable: - "Full Office PDF preview is unavailable. Install LibreOffice or set FILEWORK_LIBREOFFICE_PATH for page-accurate preview.", preview_emptyOfficeContent: "No extracted Office content", preview_emptySheet: "Empty sheet", preview_slide: "Slide {0:number}", diff --git a/src/renderer/i18n/i18n-types.ts b/src/renderer/i18n/i18n-types.ts index 6a3293a8..f02b5f94 100644 --- a/src/renderer/i18n/i18n-types.ts +++ b/src/renderer/i18n/i18n-types.ts @@ -2522,10 +2522,6 @@ type RootTranslation = { * O​p​e​n​ ​i​n​ ​b​r​o​w​s​e​r */ preview_openInBrowser: string - /** - * F​u​l​l​ ​O​f​f​i​c​e​ ​P​D​F​ ​p​r​e​v​i​e​w​ ​i​s​ ​u​n​a​v​a​i​l​a​b​l​e​.​ ​I​n​s​t​a​l​l​ ​L​i​b​r​e​O​f​f​i​c​e​ ​o​r​ ​s​e​t​ ​F​I​L​E​W​O​R​K​_​L​I​B​R​E​O​F​F​I​C​E​_​P​A​T​H​ ​f​o​r​ ​p​a​g​e​-​a​c​c​u​r​a​t​e​ ​p​r​e​v​i​e​w​. - */ - preview_officePdfUnavailable: string /** * N​o​ ​e​x​t​r​a​c​t​e​d​ ​O​f​f​i​c​e​ ​c​o​n​t​e​n​t */ @@ -5646,10 +5642,6 @@ export type TranslationFunctions = { * Open in browser */ preview_openInBrowser: () => LocalizedString - /** - * Full Office PDF preview is unavailable. Install LibreOffice or set FILEWORK_LIBREOFFICE_PATH for page-accurate preview. - */ - preview_officePdfUnavailable: () => LocalizedString /** * No extracted Office content */ diff --git a/src/renderer/i18n/ja/index.ts b/src/renderer/i18n/ja/index.ts index b052fc80..78e50fe3 100644 --- a/src/renderer/i18n/ja/index.ts +++ b/src/renderer/i18n/ja/index.ts @@ -693,8 +693,6 @@ const ja: Translation = { preview_viewVisual: "表示", preview_viewContent: "内容", preview_openInBrowser: "ブラウザでプレビュー", - preview_officePdfUnavailable: - "完全な Office PDF プレビューは利用できません。ページ精度の高いプレビューには LibreOffice をインストールするか FILEWORK_LIBREOFFICE_PATH を設定してください。", preview_emptyOfficeContent: "抽出された Office 内容がありません", preview_emptySheet: "空のシート", preview_slide: "スライド {0}", diff --git a/src/renderer/i18n/zh-CN/index.ts b/src/renderer/i18n/zh-CN/index.ts index 62ed139f..adb95f39 100644 --- a/src/renderer/i18n/zh-CN/index.ts +++ b/src/renderer/i18n/zh-CN/index.ts @@ -668,8 +668,6 @@ const zhCN: Translation = { preview_viewVisual: "可视预览", preview_viewContent: "内容", preview_openInBrowser: "在浏览器中预览", - preview_officePdfUnavailable: - "完整 Office PDF 预览不可用。安装 LibreOffice 或设置 FILEWORK_LIBREOFFICE_PATH 后可获得逐页精确预览。", preview_emptyOfficeContent: "未提取到 Office 内容", preview_emptySheet: "空工作表", preview_slide: "幻灯片 {0}", diff --git a/src/shared/office-preview.ts b/src/shared/office-preview.ts index eb6a4d48..a05ab19e 100644 --- a/src/shared/office-preview.ts +++ b/src/shared/office-preview.ts @@ -1,4 +1,4 @@ -export type OfficePreviewKind = "pdf" | "image" | "content"; +export type OfficePreviewKind = "presentation" | "content"; export interface OfficeDocumentContentPreview { kind: "document"; @@ -12,6 +12,8 @@ export interface OfficePresentationSlidePreview { index: number; text: string; notes: string | null; + previewPath?: string; + hidden?: boolean; } export interface OfficePresentationContentPreview { @@ -50,17 +52,11 @@ export type OfficeContentPreview = export interface OfficePreviewResult { cacheKey: string; previewKind: OfficePreviewKind; - previewPath: string; - pdfPath?: string; - thumbnailPath?: string; sourceMtimeMs: number; sourceSize: number; - converterVersion: string; + rendererVersion?: string; cacheHit: boolean; contentPreview?: OfficeContentPreview; - contentPreviewCacheHit?: boolean; contentPreviewPath?: string; contentPreviewError?: string; - visualPreviewUnavailable?: boolean; - visualPreviewError?: string; } From a4564c2a429f9e6e30a7557123c729a00206d6c3 Mon Sep 17 00:00:00 2001 From: Kilian Date: Fri, 24 Jul 2026 18:27:07 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=80=89=E6=8B=A9=20PPTX=20=E5=85=83=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动细节: - 将幻灯片 SVG 安全清洗后以内联方式渲染,保留本地对象元数据 - 支持鼠标或键盘选择形状与文本片段,并显示选中高亮 - 将选中对象、源文件路径与修订标识注入 pptx-editor 输入 - 增加 sourceRevision 校验,阻止过期对象 ID 写入已变化的 PPTX - 补充 SVG 安全、选择交互、聊天注入和过期修订回归测试 --- src/main/presentation/__tests__/pptx.test.ts | 30 +++ src/main/presentation/pptx.ts | 25 ++- .../skills/__tests__/pptx-processor.test.ts | 6 + src/main/skills/pptx-editor.ts | 19 +- src/renderer/components/chat/ChatPanel.tsx | 17 ++ .../__tests__/ChatPanel.rendering.test.tsx | 31 +++ .../file-preview/FilePreviewPanel.tsx | 200 ++++++++++++++++-- .../FilePreviewPanel.office.test.tsx | 67 +++++- src/renderer/global.css | 17 ++ src/renderer/i18n/en/index.ts | 3 + src/renderer/i18n/i18n-types.ts | 16 ++ src/renderer/i18n/ja/index.ts | 3 + src/renderer/i18n/zh-CN/index.ts | 2 + .../lib/__tests__/pptx-selection.test.ts | 78 +++++++ src/renderer/lib/pptx-selection.ts | 175 +++++++++++++++ 15 files changed, 659 insertions(+), 30 deletions(-) create mode 100644 src/renderer/lib/__tests__/pptx-selection.test.ts create mode 100644 src/renderer/lib/pptx-selection.ts diff --git a/src/main/presentation/__tests__/pptx.test.ts b/src/main/presentation/__tests__/pptx.test.ts index 5b963701..93205994 100644 --- a/src/main/presentation/__tests__/pptx.test.ts +++ b/src/main/presentation/__tests__/pptx.test.ts @@ -106,6 +106,7 @@ describe("PPTX presentation model", () => { }); expect(result.slideCount).toBe(2); + expect(result.sourceRevision).toMatch(/^[a-f0-9]{64}$/); expect(result.slides[0].objects).toEqual([ { geometry: "rect", @@ -159,4 +160,33 @@ describe("PPTX presentation model", () => { await expect(readFile(outputPath, "utf8")).resolves.toBe("edited-pptx"); await expect(readFile(sourcePath, "utf8")).resolves.toBe("source-pptx"); }); + + it("rejects an edit anchored to a stale source revision", async () => { + const renderer = makeRenderer(); + const preview = await preparePptxPreview(sourcePath, { + cacheRoot: join(root, "cache"), + createRenderer: vi.fn().mockResolvedValue(renderer), + }); + await writeFile(sourcePath, "externally-modified-pptx"); + + await expect( + editPptxPresentation( + { + edits: [ + { + objectId: "slide:1/shape:0/text:0:0", + text: "Must not apply", + }, + ], + outputPath: join(root, "stale-edit.pptx"), + sourcePath, + sourceRevision: preview.cacheKey, + }, + { + createRenderer: vi.fn().mockResolvedValue(renderer), + }, + ), + ).rejects.toThrow("source revision"); + expect(renderer.updateShapeText).not.toHaveBeenCalled(); + }); }); diff --git a/src/main/presentation/pptx.ts b/src/main/presentation/pptx.ts index 2293e4e9..bd96318e 100644 --- a/src/main/presentation/pptx.ts +++ b/src/main/presentation/pptx.ts @@ -75,6 +75,7 @@ export interface InspectedPresentationSlide { export interface InspectedPresentation { slideCount: number; slides: InspectedPresentationSlide[]; + sourceRevision: string; } export interface PresentationTextEdit { @@ -84,6 +85,7 @@ export interface PresentationTextEdit { export interface EditPresentationRequest { sourcePath: string; + sourceRevision?: string; outputPath?: string; edits: PresentationTextEdit[]; } @@ -142,13 +144,27 @@ const assertPptxPath = (filePath: string, label: string) => { const loadRenderer = async ( sourcePath: string, createRenderer: RendererFactory = defaultRendererFactory, + expectedSourceRevision?: string, ) => { assertPptxPath(sourcePath, "Presentation"); + const bytes = await readFile(sourcePath); + const fingerprint = await buildFingerprint(sourcePath, bytes); + if ( + expectedSourceRevision && + expectedSourceRevision !== fingerprint.cacheKey + ) { + throw new Error( + "Presentation source revision changed after selection; inspect the PPTX again before editing.", + ); + } const renderer = await createRenderer(); await renderer.init(); - const bytes = await readFile(sourcePath); await renderer.loadPptx(toArrayBuffer(bytes)); - return { bytes, renderer }; + return { + bytes, + renderer, + sourceRevision: fingerprint.cacheKey, + }; }; const renderSlide = ( @@ -329,7 +345,7 @@ export const inspectPptxPresentation = async ( sourcePath: string, options: RendererOptions = {}, ): Promise => { - const { renderer } = await loadRenderer( + const { renderer, sourceRevision } = await loadRenderer( sourcePath, options.createRenderer ?? defaultRendererFactory, ); @@ -349,7 +365,7 @@ export const inspectPptxPresentation = async ( ), }); } - return { slideCount: renderer.getSlideCount(), slides }; + return { slideCount: renderer.getSlideCount(), slides, sourceRevision }; }; const parseTextObjectId = (objectId: string) => { @@ -389,6 +405,7 @@ export const editPptxPresentation = async ( const { renderer } = await loadRenderer( request.sourcePath, options.createRenderer ?? defaultRendererFactory, + request.sourceRevision, ); const editedSlides = new Set(); for (const edit of request.edits) { diff --git a/src/main/skills/__tests__/pptx-processor.test.ts b/src/main/skills/__tests__/pptx-processor.test.ts index fbd66cd5..8dead758 100644 --- a/src/main/skills/__tests__/pptx-processor.test.ts +++ b/src/main/skills/__tests__/pptx-processor.test.ts @@ -238,4 +238,10 @@ describe("pptxEditor skill spec", () => { "inspectPptxObjects", ]); }); + + it("treats a local PPTX selection as an anchored object, not a guessed target", () => { + expect(pptxEditor.systemPrompt).toContain(""); + expect(pptxEditor.systemPrompt).toContain("sourceRevision"); + expect(pptxEditor.systemPrompt).toContain("validate"); + }); }); diff --git a/src/main/skills/pptx-editor.ts b/src/main/skills/pptx-editor.ts index 6b77949f..5850e8cc 100644 --- a/src/main/skills/pptx-editor.ts +++ b/src/main/skills/pptx-editor.ts @@ -61,6 +61,7 @@ const inspectPptxObjectsTool: Tool = { return { slideCount: presentation.slideCount, slides, + sourceRevision: presentation.sourceRevision, }; } catch (error) { return { @@ -77,6 +78,12 @@ const editPptxTextTool: Tool = { "按 inspectPptxObjects 返回的稳定 objectId 修改 PPTX 文本,并导出为新的可编辑 PPTX 文件", inputSchema: z.object({ path: z.string().describe("源 PPTX 文件的绝对路径"), + sourceRevision: z + .string() + .optional() + .describe( + "可选的源文件修订标识;从本地选择上下文或 inspectPptxObjects 返回值读取,用于阻止过期对象 ID 写入", + ), outputPath: z .string() .optional() @@ -95,10 +102,12 @@ const editPptxTextTool: Tool = { }), execute: async ({ path, + sourceRevision, outputPath, edits, }: { path: string; + sourceRevision?: string; outputPath?: string; edits: Array<{ objectId: string; text: string }>; }) => { @@ -109,6 +118,7 @@ const editPptxTextTool: Tool = { edits, outputPath, sourcePath: path, + sourceRevision, }); } catch (error) { return { @@ -146,10 +156,11 @@ export const pptxEditor: Skill = { systemPrompt: `You are executing a POWERPOINT EDITING task with a local structured PPTX model. ## Execution Steps -1. Always call \`inspectPptxObjects\` first and locate exact text object IDs. -2. Apply the smallest focused set of edits with \`editPptxText\`. -3. Export to a new .pptx copy. Never overwrite the source presentation. -4. Report the output path and edited slide numbers. +1. If the prompt contains a \`\` JSON block, treat its path, sourceRevision, slide and objectId as a local UI anchor. +2. Always call \`inspectPptxObjects\` first and validate that the anchored objectId still exists in the selected source revision. +3. Pass sourceRevision to \`editPptxText\` and apply the smallest focused set of edits. +4. Export to a new .pptx copy. Never overwrite the source presentation. +5. Report the output path and edited slide numbers. ## Rules - Never guess an object ID. diff --git a/src/renderer/components/chat/ChatPanel.tsx b/src/renderer/components/chat/ChatPanel.tsx index 7ec37a9b..bab8d6fc 100644 --- a/src/renderer/components/chat/ChatPanel.tsx +++ b/src/renderer/components/chat/ChatPanel.tsx @@ -29,6 +29,11 @@ import { import { getContextWindowForModelId } from "../../../shared/model-context-window"; import { useI18nContext } from "../../i18n/i18n-react"; import type { TranslationFunctions } from "../../i18n/i18n-types"; +import { + isPptxObjectSelection, + mergePptxSelectionIntoPrompt, + PPTX_SELECTION_EVENT, +} from "../../lib/pptx-selection"; import { cn } from "../../lib/utils"; import { Confirmation, @@ -829,6 +834,18 @@ export const ChatPanel = ({ const liveProviderStepInputTokensRef = useRef(0); const seenProviderStepKeysRef = useRef>(new Set()); + useEffect(() => { + const handlePptxSelection = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!isPptxObjectSelection(detail)) return; + chat.setInput(mergePptxSelectionIntoPrompt(chat.input, detail)); + }; + window.addEventListener(PPTX_SELECTION_EVENT, handlePptxSelection); + return () => { + window.removeEventListener(PPTX_SELECTION_EVENT, handlePptxSelection); + }; + }, [chat.input, chat.setInput]); + useEffect(() => { let cancelled = false; const activeSessionId = chat.activeSessionId ?? null; diff --git a/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx b/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx index 8e5a2c79..4b86604c 100644 --- a/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx +++ b/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx @@ -384,6 +384,37 @@ describe("ChatPanel message rendering", () => { ).not.toBeNull(); }); + it("adds a local PPTX selection to the composer without invoking a cloud document session", async () => { + const setInput = vi.fn(); + chatState.value = createChatState([], { setInput }); + + await act(async () => { + root?.render(); + }); + + await act(async () => { + window.dispatchEvent( + new window.CustomEvent("filework:pptx-selection", { + detail: { + editableText: true, + objectId: "slide:2/shape:7/text:0:0", + objectType: "text", + shapeIndex: 7, + slideIndex: 2, + sourcePath: "/workspace/deck.pptx", + sourceRevision: "revision-a", + text: "Old title", + }, + }), + ); + }); + + expect(setInput).toHaveBeenCalledTimes(1); + expect(setInput.mock.calls[0]?.[0]).toContain("/pptx-editor"); + expect(setInput.mock.calls[0]?.[0]).toContain("slide:2/shape:7/text:0:0"); + expect(setInput.mock.calls[0]?.[0]).toContain("/workspace/deck.pptx"); + }); + it("localizes the free-text clarification response", async () => { const assistant: ChatMessage = { id: "assistant-clarification", diff --git a/src/renderer/components/file-preview/FilePreviewPanel.tsx b/src/renderer/components/file-preview/FilePreviewPanel.tsx index 7cdb8b6a..57fbd606 100644 --- a/src/renderer/components/file-preview/FilePreviewPanel.tsx +++ b/src/renderer/components/file-preview/FilePreviewPanel.tsx @@ -5,16 +5,22 @@ import { FileWarning, Globe, Loader2, + MousePointer2, ZoomIn, ZoomOut, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { OfficeContentPreview, OfficePresentationContentPreview, } from "../../../shared/office-preview"; import { useI18nContext } from "../../i18n/i18n-react"; import { localFileUrl } from "../../lib/local-file-url"; +import { + PPTX_SELECTION_EVENT, + type PptxObjectSelection, + prepareInteractivePresentationSvg, +} from "../../lib/pptx-selection"; import { cn } from "../../lib/utils"; import { CodeViewer, @@ -134,6 +140,8 @@ const hasUsableOfficeContent = ( interface OfficeContentLabels { emptyOfficeContent: string; emptySheet: string; + selectElement: string; + selectedElement: string; slide: (index: number) => string; speakerNotes: string; } @@ -304,13 +312,18 @@ const OfficeContentPreviewPane = ({ const OfficePresentationPreviewPane = ({ labels, preview, + sourcePath, + sourceRevision, zoom, }: { labels: OfficeContentLabels; preview: OfficePresentationContentPreview; + sourcePath: string; + sourceRevision: string; zoom: number; }) => { const visualSlides = preview.slides.filter((slide) => slide.previewPath); + const [selection, setSelection] = useState(null); if (visualSlides.length === 0) { return (
@@ -319,37 +332,190 @@ const OfficePresentationPreviewPane = ({ ); } + const selectObject = (nextSelection: PptxObjectSelection) => { + setSelection(nextSelection); + window.dispatchEvent( + new window.CustomEvent(PPTX_SELECTION_EVENT, { + detail: nextSelection, + }), + ); + }; + return (
+
+ + {selection ? ( + + {labels.selectedElement}: {labels.slide(selection.slideIndex)} ·{" "} + {selection.objectType} + {selection.text ? ` · ${selection.text}` : ""} + + ) : ( + {labels.selectElement} + )} +
{visualSlides.map((slide) => ( -
-
- {labels.slide(slide.index)} -
- {labels.slide(slide.index)} -
+ label={labels.slide(slide.index)} + onSelect={selectObject} + selectedObjectId={selection?.objectId ?? null} + slide={slide} + sourcePath={sourcePath} + sourceRevision={sourceRevision} + /> ))}
); }; +const InteractivePresentationSlide = ({ + label, + onSelect, + selectedObjectId, + slide, + sourcePath, + sourceRevision, +}: { + label: string; + onSelect: (selection: PptxObjectSelection) => void; + selectedObjectId: string | null; + slide: OfficePresentationContentPreview["slides"][number]; + sourcePath: string; + sourceRevision: string; +}) => { + const containerRef = useRef(null); + const [svg, setSvg] = useState(null); + const [inlineFailed, setInlineFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setSvg(null); + setInlineFailed(false); + if (!slide.previewPath) return; + window.filework + .readFile(slide.previewPath) + .then((rawSvg) => { + if (cancelled) return; + const prepared = prepareInteractivePresentationSvg( + String(rawSvg), + slide.index, + ); + if (!prepared) { + setInlineFailed(true); + return; + } + setSvg(prepared.svg); + }) + .catch(() => { + if (!cancelled) setInlineFailed(true); + }); + return () => { + cancelled = true; + }; + }, [slide.index, slide.previewPath]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + for (const shape of Array.from( + container.querySelectorAll("[data-presentation-object-id]"), + )) { + const shapeId = shape.getAttribute("data-presentation-object-id"); + const isSelected = Boolean( + selectedObjectId && + (selectedObjectId === shapeId || + selectedObjectId.startsWith(`${shapeId}/text:`)), + ); + if (isSelected) { + shape.setAttribute("data-presentation-selected", "true"); + } else { + shape.removeAttribute("data-presentation-selected"); + } + } + }, [selectedObjectId]); + + const publishSelection = (target: EventTarget | null) => { + if (!(target instanceof window.Element)) return; + const textRun = target.closest("[data-presentation-text-object-id]"); + const shape = target.closest("[data-presentation-object-id]"); + if (!shape) return; + const shapeIndex = Number(shape.getAttribute("data-ooxml-shape-idx")); + const shapeObjectId = shape.getAttribute("data-presentation-object-id"); + const textObjectId = textRun?.getAttribute( + "data-presentation-text-object-id", + ); + if (!Number.isSafeInteger(shapeIndex) || !shapeObjectId) return; + onSelect({ + editableText: Boolean(textObjectId), + objectId: textObjectId ?? shapeObjectId, + objectType: + textObjectId !== null && textObjectId !== undefined + ? "text" + : (shape.getAttribute("data-ooxml-shape-type") ?? "shape"), + shapeIndex, + slideIndex: slide.index, + sourcePath, + sourceRevision, + text: (textRun?.textContent ?? shape.textContent ?? "") + .replace(/\s+/g, " ") + .trim(), + }); + }; + + return ( +
+
+ {label} +
+ {svg ? ( +
{ + event.preventDefault(); + publishSelection(event.target); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + publishSelection(event.target); + }} + // biome-ignore lint/security/noDangerouslySetInnerHtml: presentation SVG is parsed and sanitized before rendering. + dangerouslySetInnerHTML={{ __html: svg }} + /> + ) : inlineFailed && slide.previewPath ? ( + {label} + ) : ( +
+ +
+ )} +
+ ); +}; + interface FilePreviewPanelProps { filePath: string; } @@ -487,6 +653,8 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => { const officeContentLabels: OfficeContentLabels = { emptyOfficeContent: LL.preview_emptyOfficeContent(), emptySheet: LL.preview_emptySheet(), + selectElement: LL.preview_selectPptxElement(), + selectedElement: LL.preview_selectedPptxElement(), slide: (index) => LL.preview_slide(index), speakerNotes: LL.preview_speakerNotes(), }; @@ -725,6 +893,8 @@ export const FilePreviewPanel = ({ filePath }: FilePreviewPanelProps) => {
diff --git a/src/renderer/components/file-preview/__tests__/FilePreviewPanel.office.test.tsx b/src/renderer/components/file-preview/__tests__/FilePreviewPanel.office.test.tsx index 99355775..06337efa 100644 --- a/src/renderer/components/file-preview/__tests__/FilePreviewPanel.office.test.tsx +++ b/src/renderer/components/file-preview/__tests__/FilePreviewPanel.office.test.tsx @@ -14,6 +14,8 @@ vi.mock("../../../i18n/i18n-react", () => { preview_openInBrowser: () => "Open in browser", preview_readFileError: () => "Failed to read file", preview_readImageError: () => "Failed to read image", + preview_selectPptxElement: () => "Select a slide element", + preview_selectedPptxElement: () => "Selected", preview_truncated: (size: string) => `Truncated ${size}`, preview_emptyOfficeContent: () => "No extracted Office content", preview_emptySheet: () => "Empty sheet", @@ -50,13 +52,15 @@ describe("FilePreviewPanel Office preview", () => { let root: Root | null = null; let container: HTMLElement; let prepareOfficePreview: ReturnType; + let readFile: ReturnType; let consoleError: ReturnType; const flushPreview = async () => { - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setImmediate(resolve)); + for (let index = 0; index < 3; index++) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setImmediate(resolve)); + } act(() => {}); }; @@ -89,9 +93,24 @@ describe("FilePreviewPanel Office preview", () => { sourceSize: 10, }; prepareOfficePreview = vi.fn().mockResolvedValue(previewResult); + readFile = vi.fn((path: string) => + Promise.resolve( + ` + + + + + ${path.includes("slide-2") ? "Launch & Learn" : "Roadmap"} + + + + `, + ), + ); Object.assign(window, { filework: { prepareOfficePreview, + readFile, readFilePreview: vi.fn(), }, }); @@ -128,7 +147,7 @@ describe("FilePreviewPanel Office preview", () => { expect(container.textContent).not.toContain("Preview not supported"); }); - it("renders every PPTX slide from the local SVG presentation model", async () => { + it("renders selectable inline PPTX slides and publishes the selected text object", async () => { prepareOfficePreview.mockResolvedValue({ cacheHit: false, cacheKey: "presentation-cache-key", @@ -166,13 +185,47 @@ describe("FilePreviewPanel Office preview", () => { expect(container.innerHTML).toContain('data-presentation-slide="1"'); expect(container.innerHTML).toContain('data-presentation-slide="2"'); expect(container.innerHTML).toContain( - "local-file://open?path=%2Ftmp%2Ffilework-preview%2Fslide-1.svg", + 'data-presentation-object-id="slide:1/shape:3"', ); expect(container.innerHTML).toContain( - "local-file://open?path=%2Ftmp%2Ffilework-preview%2Fslide-2.svg", + 'data-presentation-text-object-id="slide:2/shape:7/text:0:0"', ); expect(container.innerHTML).not.toContain("data-pdf-viewer-path"); expect(container.textContent).not.toContain("Full PDF preview unavailable"); + + let selected: + | { + objectId?: string; + sourcePath?: string; + sourceRevision?: string; + } + | undefined; + window.addEventListener("filework:pptx-selection", (event) => { + selected = ( + event as CustomEvent<{ + objectId?: string; + sourcePath?: string; + sourceRevision?: string; + }> + ).detail; + }); + const run = container.querySelector( + '[data-presentation-text-object-id="slide:2/shape:7/text:0:0"]', + ) as HTMLElement; + act(() => { + run.dispatchEvent(new window.Event("click", { bubbles: true })); + }); + + expect(selected).toMatchObject({ + objectId: "slide:2/shape:7/text:0:0", + sourcePath: "/workspace/deck.pptx", + sourceRevision: "presentation-cache-key", + }); + expect( + container + .querySelector('[data-presentation-object-id="slide:2/shape:7"]') + ?.getAttribute("data-presentation-selected"), + ).toBe("true"); }); it("renders every Excel sheet and switches between sheet tabs", async () => { diff --git a/src/renderer/global.css b/src/renderer/global.css index 530d2697..0faeb162 100644 --- a/src/renderer/global.css +++ b/src/renderer/global.css @@ -4,6 +4,23 @@ @source "../../../node_modules/streamdown/dist/*.js"; +.presentation-slide-svg svg { + display: block; + height: auto; + width: 100%; +} + +.presentation-slide-svg [data-presentation-selectable="true"] { + cursor: pointer; +} + +.presentation-slide-svg [data-presentation-selected="true"] { + filter: drop-shadow(0 0 2px var(--color-primary)) + drop-shadow( + 0 0 4px color-mix(in srgb, var(--color-primary) 65%, transparent) + ); +} + /* ════════════════════════════════════════════════════════════════ Neutral Agent Console —— Geist-like neutral surfaces + blue focus. Color is reserved for state, focus, and links. diff --git a/src/renderer/i18n/en/index.ts b/src/renderer/i18n/en/index.ts index 1d0f2000..3e073b9e 100644 --- a/src/renderer/i18n/en/index.ts +++ b/src/renderer/i18n/en/index.ts @@ -706,6 +706,9 @@ const en: BaseTranslation = { preview_openInBrowser: "Open in browser", preview_emptyOfficeContent: "No extracted Office content", preview_emptySheet: "Empty sheet", + preview_selectPptxElement: + "Select an element in a slide to add it to the local editing prompt", + preview_selectedPptxElement: "Selected", preview_slide: "Slide {0:number}", preview_speakerNotes: "Speaker notes", preview_fullscreen: "Fullscreen", diff --git a/src/renderer/i18n/i18n-types.ts b/src/renderer/i18n/i18n-types.ts index f02b5f94..b4abbb79 100644 --- a/src/renderer/i18n/i18n-types.ts +++ b/src/renderer/i18n/i18n-types.ts @@ -2530,6 +2530,14 @@ type RootTranslation = { * E​m​p​t​y​ ​s​h​e​e​t */ preview_emptySheet: string + /** + * S​e​l​e​c​t​ ​a​n​ ​e​l​e​m​e​n​t​ ​i​n​ ​a​ ​s​l​i​d​e​ ​t​o​ ​a​d​d​ ​i​t​ ​t​o​ ​t​h​e​ ​l​o​c​a​l​ ​e​d​i​t​i​n​g​ ​p​r​o​m​p​t + */ + preview_selectPptxElement: string + /** + * S​e​l​e​c​t​e​d + */ + preview_selectedPptxElement: string /** * S​l​i​d​e​ ​{​0​} * @param {number} 0 @@ -5650,6 +5658,14 @@ export type TranslationFunctions = { * Empty sheet */ preview_emptySheet: () => LocalizedString + /** + * Select an element in a slide to add it to the local editing prompt + */ + preview_selectPptxElement: () => LocalizedString + /** + * Selected + */ + preview_selectedPptxElement: () => LocalizedString /** * Slide {0} */ diff --git a/src/renderer/i18n/ja/index.ts b/src/renderer/i18n/ja/index.ts index 78e50fe3..9108dcc2 100644 --- a/src/renderer/i18n/ja/index.ts +++ b/src/renderer/i18n/ja/index.ts @@ -695,6 +695,9 @@ const ja: Translation = { preview_openInBrowser: "ブラウザでプレビュー", preview_emptyOfficeContent: "抽出された Office 内容がありません", preview_emptySheet: "空のシート", + preview_selectPptxElement: + "スライド内の要素を選択してローカル編集プロンプトに追加", + preview_selectedPptxElement: "選択済み", preview_slide: "スライド {0}", preview_speakerNotes: "発表者ノート", preview_fullscreen: "全画面", diff --git a/src/renderer/i18n/zh-CN/index.ts b/src/renderer/i18n/zh-CN/index.ts index adb95f39..da3d951d 100644 --- a/src/renderer/i18n/zh-CN/index.ts +++ b/src/renderer/i18n/zh-CN/index.ts @@ -670,6 +670,8 @@ const zhCN: Translation = { preview_openInBrowser: "在浏览器中预览", preview_emptyOfficeContent: "未提取到 Office 内容", preview_emptySheet: "空工作表", + preview_selectPptxElement: "点击幻灯片中的元素,将其添加到本地编辑输入框", + preview_selectedPptxElement: "已选择", preview_slide: "幻灯片 {0}", preview_speakerNotes: "演讲者备注", preview_fullscreen: "全屏", diff --git a/src/renderer/lib/__tests__/pptx-selection.test.ts b/src/renderer/lib/__tests__/pptx-selection.test.ts new file mode 100644 index 00000000..7812a909 --- /dev/null +++ b/src/renderer/lib/__tests__/pptx-selection.test.ts @@ -0,0 +1,78 @@ +import { parseHTML } from "linkedom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + mergePptxSelectionIntoPrompt, + prepareInteractivePresentationSvg, +} from "../pptx-selection"; + +describe("PPTX local element selection", () => { + beforeEach(() => { + const { window } = parseHTML(""); + vi.stubGlobal("window", window); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sanitizes SVG and decorates shapes and text runs with local object IDs", () => { + const result = prepareInteractivePresentationSvg( + ` + + + + + + + Revenue + + + + `, + 3, + ); + + expect(result).not.toBeNull(); + expect(result?.objectCount).toBe(1); + expect(result?.svg).toContain( + 'data-presentation-object-id="slide:3/shape:4"', + ); + expect(result?.svg).toContain( + 'data-presentation-text-object-id="slide:3/shape:4/text:1:2"', + ); + expect(result?.svg).not.toContain(" { + const first = mergePptxSelectionIntoPrompt("把它改成蓝色", { + editableText: true, + objectId: "slide:1/shape:2/text:0:0", + objectType: "text", + shapeIndex: 2, + slideIndex: 1, + sourcePath: "/workspace/deck.pptx", + sourceRevision: "revision-a", + text: "Old title", + }); + const second = mergePptxSelectionIntoPrompt(first, { + editableText: true, + objectId: "slide:2/shape:5/text:0:0", + objectType: "text", + shapeIndex: 5, + slideIndex: 2, + sourcePath: "/workspace/deck.pptx", + sourceRevision: "revision-a", + text: "Other title", + }); + + expect(second.match(/\/pptx-editor/g)).toHaveLength(1); + expect(second).toMatch(/^\/pptx-editor /); + expect(second).toContain("slide:2/shape:5/text:0:0"); + expect(second).not.toContain("slide:1/shape:2/text:0:0"); + expect(second).toContain("把它改成蓝色"); + }); +}); diff --git a/src/renderer/lib/pptx-selection.ts b/src/renderer/lib/pptx-selection.ts new file mode 100644 index 00000000..05bcd276 --- /dev/null +++ b/src/renderer/lib/pptx-selection.ts @@ -0,0 +1,175 @@ +export const PPTX_SELECTION_EVENT = "filework:pptx-selection"; + +const SELECTION_BLOCK_START = ""; +const SELECTION_BLOCK_END = ""; +const MAX_SELECTION_TEXT_LENGTH = 2_000; + +export interface PptxObjectSelection { + editableText: boolean; + objectId: string; + objectType: string; + shapeIndex: number; + slideIndex: number; + sourcePath: string; + sourceRevision: string; + text: string; +} + +export interface PreparedPresentationSvg { + objectCount: number; + svg: string; +} + +const isSafeSvgReference = (value: string): boolean => { + const normalized = value.trim(); + return ( + normalized.startsWith("#") || + /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(normalized) + ); +}; + +const hasUnsafeCss = (value: string): boolean => + /expression\s*\(|@import|url\s*\(\s*["']?(?!#)/i.test(value); + +const sanitizeElementAttributes = (element: Element) => { + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name.toLowerCase(); + const value = attribute.value; + if ( + name.startsWith("on") || + name === "src" || + name === "action" || + name === "formaction" || + name === "target" + ) { + element.removeAttribute(attribute.name); + continue; + } + if ( + hasUnsafeCss(value) || + /^\s*(?:https?:|file:|javascript:|data:text\/html)/i.test(value) + ) { + element.removeAttribute(attribute.name); + continue; + } + if ( + (name === "href" || name === "xlink:href") && + !isSafeSvgReference(value) + ) { + element.removeAttribute(attribute.name); + } + } +}; + +const parseIndex = (value: string | null): number | null => { + if (value === null || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +}; + +export const prepareInteractivePresentationSvg = ( + rawSvg: string, + slideIndex: number, +): PreparedPresentationSvg | null => { + const document = new window.DOMParser().parseFromString( + rawSvg, + "image/svg+xml", + ); + const root = document.documentElement; + if (root.tagName.toLowerCase() !== "svg") return null; + + for (const unsafe of Array.from( + root.querySelectorAll( + "script, foreignObject, iframe, object, embed, audio, video, canvas, link, style, animate, animateMotion, animateTransform, set, discard", + ), + )) { + unsafe.remove(); + } + sanitizeElementAttributes(root); + for (const element of Array.from(root.querySelectorAll("*"))) { + sanitizeElementAttributes(element); + } + + let objectCount = 0; + for (const shape of Array.from( + root.querySelectorAll("g[data-ooxml-shape-idx]"), + )) { + const shapeIndex = parseIndex(shape.getAttribute("data-ooxml-shape-idx")); + if (shapeIndex === null) continue; + const objectId = `slide:${slideIndex}/shape:${shapeIndex}`; + shape.setAttribute("data-presentation-object-id", objectId); + shape.setAttribute("data-presentation-selectable", "true"); + shape.setAttribute("role", "button"); + shape.setAttribute("tabindex", "0"); + objectCount++; + + for (const run of Array.from( + shape.querySelectorAll("tspan[data-ooxml-run-idx]"), + )) { + const paragraph = run.closest("tspan[data-ooxml-para-idx]"); + const paragraphIndex = parseIndex( + paragraph?.getAttribute("data-ooxml-para-idx") ?? null, + ); + const runIndex = parseIndex(run.getAttribute("data-ooxml-run-idx")); + if (paragraphIndex === null || runIndex === null) continue; + run.setAttribute( + "data-presentation-text-object-id", + `${objectId}/text:${paragraphIndex}:${runIndex}`, + ); + } + } + + return { + objectCount, + svg: root.outerHTML, + }; +}; + +export const isPptxObjectSelection = ( + value: unknown, +): value is PptxObjectSelection => { + if (!value || typeof value !== "object") return false; + const selection = value as Partial; + return ( + typeof selection.editableText === "boolean" && + typeof selection.objectId === "string" && + selection.objectId.length > 0 && + typeof selection.objectType === "string" && + Number.isSafeInteger(selection.shapeIndex) && + Number.isSafeInteger(selection.slideIndex) && + typeof selection.sourcePath === "string" && + selection.sourcePath.toLowerCase().endsWith(".pptx") && + typeof selection.sourceRevision === "string" && + selection.sourceRevision.length > 0 && + typeof selection.text === "string" + ); +}; + +const selectionBlock = (selection: PptxObjectSelection): string => + [ + SELECTION_BLOCK_START, + JSON.stringify( + { + ...selection, + text: selection.text.slice(0, MAX_SELECTION_TEXT_LENGTH), + }, + null, + 2, + ), + SELECTION_BLOCK_END, + ].join("\n"); + +export const mergePptxSelectionIntoPrompt = ( + currentPrompt: string, + selection: PptxObjectSelection, +): string => { + const withoutSelection = currentPrompt + .replace(/[\s\S]*?<\/pptx-selection>\s*/g, "") + .replace(/^\/pptx-editor\b\s*/i, "") + .trim(); + const instruction = + withoutSelection || "请描述要如何修改选中的 PowerPoint 元素:"; + return [`/pptx-editor ${selectionBlock(selection)}`, "", instruction].join( + "\n", + ); +}; From 20e516e722c40ea6b36ef44209269f8851aa1ebb Mon Sep 17 00:00:00 2001 From: Kilian Date: Mon, 27 Jul 2026 10:44:45 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20PPT=20?= =?UTF-8?q?=E5=85=83=E7=B4=A0=E5=B0=B1=E5=9C=B0=20Chat=20=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动细节: - 选中幻灯片元素后在元素附近显示轻量输入框 - 根据元素位置自动选择上下方并处理左右边缘,缩放时保持输入框可读尺寸 - 支持 Enter 发送、Shift+Enter 换行、Esc 或关闭按钮取消 - 就地请求直接提交到当前 Chat 会话,不再覆盖底部输入框草稿 - 会话执行中拒绝重复提交并保留就地输入内容 - 补充浮层定位、选择上下文和当前会话提交回归测试及多语言文案 --- src/renderer/components/chat/ChatPanel.tsx | 31 ++- .../__tests__/ChatPanel.rendering.test.tsx | 39 +++- .../file-preview/FilePreviewPanel.tsx | 216 ++++++++++++++++-- .../FilePreviewPanel.office.test.tsx | 113 +++++++++ src/renderer/i18n/en/index.ts | 4 +- src/renderer/i18n/i18n-types.ts | 8 + src/renderer/i18n/ja/index.ts | 4 +- src/renderer/i18n/zh-CN/index.ts | 3 +- src/renderer/lib/pptx-selection.ts | 18 ++ 9 files changed, 399 insertions(+), 37 deletions(-) diff --git a/src/renderer/components/chat/ChatPanel.tsx b/src/renderer/components/chat/ChatPanel.tsx index bab8d6fc..43d9d4d2 100644 --- a/src/renderer/components/chat/ChatPanel.tsx +++ b/src/renderer/components/chat/ChatPanel.tsx @@ -30,9 +30,9 @@ import { getContextWindowForModelId } from "../../../shared/model-context-window import { useI18nContext } from "../../i18n/i18n-react"; import type { TranslationFunctions } from "../../i18n/i18n-types"; import { - isPptxObjectSelection, + isPptxInlineChatSubmit, mergePptxSelectionIntoPrompt, - PPTX_SELECTION_EVENT, + PPTX_INLINE_CHAT_SUBMIT_EVENT, } from "../../lib/pptx-selection"; import { cn } from "../../lib/utils"; import { @@ -835,16 +835,31 @@ export const ChatPanel = ({ const seenProviderStepKeysRef = useRef>(new Set()); useEffect(() => { - const handlePptxSelection = (event: Event) => { + const handlePptxInlineChatSubmit = (event: Event) => { const detail = (event as CustomEvent).detail; - if (!isPptxObjectSelection(detail)) return; - chat.setInput(mergePptxSelectionIntoPrompt(chat.input, detail)); + if (!isPptxInlineChatSubmit(detail)) return; + if (chat.isLoading) { + event.preventDefault(); + return; + } + void chat.handleSubmit({ + text: mergePptxSelectionIntoPrompt( + detail.instruction, + detail.selection, + ), + }); }; - window.addEventListener(PPTX_SELECTION_EVENT, handlePptxSelection); + window.addEventListener( + PPTX_INLINE_CHAT_SUBMIT_EVENT, + handlePptxInlineChatSubmit, + ); return () => { - window.removeEventListener(PPTX_SELECTION_EVENT, handlePptxSelection); + window.removeEventListener( + PPTX_INLINE_CHAT_SUBMIT_EVENT, + handlePptxInlineChatSubmit, + ); }; - }, [chat.input, chat.setInput]); + }, [chat.handleSubmit, chat.isLoading]); useEffect(() => { let cancelled = false; diff --git a/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx b/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx index 4b86604c..198cbe4a 100644 --- a/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx +++ b/src/renderer/components/chat/__tests__/ChatPanel.rendering.test.tsx @@ -384,9 +384,10 @@ describe("ChatPanel message rendering", () => { ).not.toBeNull(); }); - it("adds a local PPTX selection to the composer without invoking a cloud document session", async () => { + it("submits an inline PPTX request to the active conversation without replacing the composer", async () => { + const handleSubmit = vi.fn(); const setInput = vi.fn(); - chatState.value = createChatState([], { setInput }); + chatState.value = createChatState([], { handleSubmit, setInput }); await act(async () => { root?.render(); @@ -409,10 +410,36 @@ describe("ChatPanel message rendering", () => { ); }); - expect(setInput).toHaveBeenCalledTimes(1); - expect(setInput.mock.calls[0]?.[0]).toContain("/pptx-editor"); - expect(setInput.mock.calls[0]?.[0]).toContain("slide:2/shape:7/text:0:0"); - expect(setInput.mock.calls[0]?.[0]).toContain("/workspace/deck.pptx"); + expect(setInput).not.toHaveBeenCalled(); + + await act(async () => { + window.dispatchEvent( + new window.CustomEvent("filework:pptx-inline-chat-submit", { + detail: { + instruction: "Make this title shorter", + selection: { + editableText: true, + objectId: "slide:2/shape:7/text:0:0", + objectType: "text", + shapeIndex: 7, + slideIndex: 2, + sourcePath: "/workspace/deck.pptx", + sourceRevision: "revision-a", + text: "Old title", + }, + }, + }), + ); + }); + + expect(handleSubmit).toHaveBeenCalledTimes(1); + expect(handleSubmit.mock.calls[0]?.[0]?.text).toContain("/pptx-editor"); + expect(handleSubmit.mock.calls[0]?.[0]?.text).toContain( + "slide:2/shape:7/text:0:0", + ); + expect(handleSubmit.mock.calls[0]?.[0]?.text).toContain( + "Make this title shorter", + ); }); it("localizes the free-text clarification response", async () => { diff --git a/src/renderer/components/file-preview/FilePreviewPanel.tsx b/src/renderer/components/file-preview/FilePreviewPanel.tsx index 57fbd606..69730734 100644 --- a/src/renderer/components/file-preview/FilePreviewPanel.tsx +++ b/src/renderer/components/file-preview/FilePreviewPanel.tsx @@ -1,4 +1,5 @@ import { + ArrowUp, Code2, Eye, FileText, @@ -6,6 +7,7 @@ import { Globe, Loader2, MousePointer2, + X, ZoomIn, ZoomOut, } from "lucide-react"; @@ -17,6 +19,7 @@ import type { import { useI18nContext } from "../../i18n/i18n-react"; import { localFileUrl } from "../../lib/local-file-url"; import { + PPTX_INLINE_CHAT_SUBMIT_EVENT, PPTX_SELECTION_EVENT, type PptxObjectSelection, prepareInteractivePresentationSvg, @@ -138,9 +141,12 @@ const hasUsableOfficeContent = ( Boolean(preview && preview.kind !== "unsupported"); interface OfficeContentLabels { + close: string; emptyOfficeContent: string; emptySheet: string; + inlinePrompt: string; selectElement: string; + send: string; selectedElement: string; slide: (index: number) => string; speakerNotes: string; @@ -369,11 +375,14 @@ const OfficePresentationPreviewPane = ({ setSelection(null)} onSelect={selectObject} selectedObjectId={selection?.objectId ?? null} slide={slide} sourcePath={sourcePath} sourceRevision={sourceRevision} + zoom={zoom} /> ))}
@@ -381,29 +390,86 @@ const OfficePresentationPreviewPane = ({ ); }; +interface PresentationInlineChatAnchor { + left: number; + placement: "above" | "below"; + top: number; + width: number; +} + +const resolveInlineChatAnchor = ( + shape: Element, + surface: HTMLElement, + zoom: number, +): PresentationInlineChatAnchor => { + const surfaceRect = surface.getBoundingClientRect(); + const shapeRect = shape.getBoundingClientRect(); + const scale = Math.max(zoom, 0.1); + const composerWidth = Math.max( + Math.min(320, surfaceRect.width - 16), + Math.min(160, surfaceRect.width), + ); + const halfComposerWidth = composerWidth / 2; + const minimumCenter = halfComposerWidth + 8; + const maximumCenter = surfaceRect.width - halfComposerWidth - 8; + const shapeCenter = shapeRect.left + shapeRect.width / 2 - surfaceRect.left; + const clampedCenter = + maximumCenter >= minimumCenter + ? Math.min(Math.max(shapeCenter, minimumCenter), maximumCenter) + : surfaceRect.width / 2; + const placement = + surfaceRect.bottom - shapeRect.bottom >= 88 + ? ("below" as const) + : ("above" as const); + + return { + left: clampedCenter / scale, + placement, + top: + placement === "below" + ? (shapeRect.bottom - surfaceRect.top + 8) / scale + : (shapeRect.top - surfaceRect.top - 8) / scale, + width: composerWidth, + }; +}; + const InteractivePresentationSlide = ({ label, + labels, + onDismissSelection, onSelect, selectedObjectId, slide, sourcePath, sourceRevision, + zoom, }: { label: string; + labels: OfficeContentLabels; + onDismissSelection: () => void; onSelect: (selection: PptxObjectSelection) => void; selectedObjectId: string | null; slide: OfficePresentationContentPreview["slides"][number]; sourcePath: string; sourceRevision: string; + zoom: number; }) => { const containerRef = useRef(null); + const inlineInputRef = useRef(null); const [svg, setSvg] = useState(null); const [inlineFailed, setInlineFailed] = useState(false); + const [inlineChat, setInlineChat] = useState<{ + anchor: PresentationInlineChatAnchor; + selection: PptxObjectSelection; + } | null>(null); + const [canSubmitInlineChat, setCanSubmitInlineChat] = useState(false); useEffect(() => { let cancelled = false; setSvg(null); setInlineFailed(false); + setInlineChat(null); + setCanSubmitInlineChat(false); if (!slide.previewPath) return; window.filework .readFile(slide.previewPath) @@ -427,6 +493,17 @@ const InteractivePresentationSlide = ({ }; }, [slide.index, slide.previewPath]); + useEffect(() => { + if (inlineChat && inlineChat.selection.objectId !== selectedObjectId) { + setInlineChat(null); + setCanSubmitInlineChat(false); + } + }, [inlineChat, selectedObjectId]); + + useEffect(() => { + if (inlineChat) inlineInputRef.current?.focus(); + }, [inlineChat]); + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -458,7 +535,7 @@ const InteractivePresentationSlide = ({ "data-presentation-text-object-id", ); if (!Number.isSafeInteger(shapeIndex) || !shapeObjectId) return; - onSelect({ + const nextSelection: PptxObjectSelection = { editableText: Boolean(textObjectId), objectId: textObjectId ?? shapeObjectId, objectType: @@ -472,7 +549,18 @@ const InteractivePresentationSlide = ({ text: (textRun?.textContent ?? shape.textContent ?? "") .replace(/\s+/g, " ") .trim(), - }); + }; + const surface = containerRef.current; + if (surface) { + if (inlineChat?.selection.objectId !== nextSelection.objectId) { + setCanSubmitInlineChat(false); + } + setInlineChat({ + anchor: resolveInlineChatAnchor(shape, surface, zoom), + selection: nextSelection, + }); + } + onSelect(nextSelection); }; return ( @@ -484,22 +572,111 @@ const InteractivePresentationSlide = ({ {label} {svg ? ( -
{ - event.preventDefault(); - publishSelection(event.target); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - publishSelection(event.target); - }} - // biome-ignore lint/security/noDangerouslySetInnerHtml: presentation SVG is parsed and sanitized before rendering. - dangerouslySetInnerHTML={{ __html: svg }} - /> +
+
{ + event.preventDefault(); + publishSelection(event.target); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + publishSelection(event.target); + }} + // biome-ignore lint/security/noDangerouslySetInnerHtml: presentation SVG is parsed and sanitized before rendering. + dangerouslySetInnerHTML={{ __html: svg }} + /> + {inlineChat && ( +
{ + event.preventDefault(); + const instruction = inlineInputRef.current?.value.trim() ?? ""; + if (!instruction) return; + const accepted = window.dispatchEvent( + new window.CustomEvent(PPTX_INLINE_CHAT_SUBMIT_EVENT, { + bubbles: false, + cancelable: true, + detail: { + instruction, + selection: inlineChat.selection, + }, + }), + ); + if (accepted) { + setCanSubmitInlineChat(false); + setInlineChat(null); + } + }} + > +