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/__tests__/file-handlers.test.ts b/src/main/ipc/__tests__/file-handlers.test.ts new file mode 100644 index 00000000..e78224e2 --- /dev/null +++ b/src/main/ipc/__tests__/file-handlers.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { handle, prepareOfficeContentPreview, preparePptxPreview } = vi.hoisted( + () => ({ + handle: vi.fn(), + prepareOfficeContentPreview: vi.fn(), + preparePptxPreview: vi.fn(), + }), +); + +vi.mock("electron", () => ({ + ipcMain: { handle }, +})); + +vi.mock("../../core/agent/tools/trash", () => ({ + emptyTrash: vi.fn(), + listTrash: vi.fn(), + restoreFromTrash: vi.fn(), +})); + +vi.mock("../../native", () => ({ + directoryStats: vi.fn(), + searchFiles: vi.fn(), +})); + +vi.mock("../../office-preview/content", () => ({ + prepareOfficeContentPreview, +})); + +vi.mock("../../presentation/pptx", () => ({ + preparePptxPreview, +})); + +import { registerFileHandlers } from "../file-handlers"; + +describe("fs:prepareOfficePreview", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("preserves a fulfilled PPTX content parse error alongside visual slides", async () => { + preparePptxPreview.mockResolvedValue({ + cacheHit: false, + cacheKey: "visual-key", + rendererVersion: "pptx-svg@test", + slides: [ + { + hidden: false, + index: 1, + notes: null, + previewPath: "/cache/slide-1.svg", + }, + ], + sourceMtimeMs: 10, + sourceSize: 20, + }); + prepareOfficeContentPreview.mockResolvedValue({ + cacheHit: false, + cacheKey: "content-key", + contentPreviewPath: "/cache/content.json", + preview: { + kind: "unsupported", + message: "PowerPoint content preview failed: broken archive", + reason: "parse-error", + }, + sourceMtimeMs: 10, + sourceSize: 20, + }); + registerFileHandlers(); + const registration = handle.mock.calls.find( + ([channel]) => channel === "fs:prepareOfficePreview", + ); + const handler = registration?.[1] as ( + event: unknown, + filePath: string, + ) => Promise<{ + contentPreviewError?: string; + contentPreview?: { + kind: string; + slides: Array<{ text: string }>; + }; + }>; + + const result = await handler({}, "/workspace/deck.pptx"); + + expect(result.contentPreview?.slides[0].text).toBe(""); + expect(result.contentPreviewError).toBe( + "PowerPoint content preview failed: broken archive", + ); + }); +}); diff --git a/src/main/ipc/file-handlers.ts b/src/main/ipc/file-handlers.ts index 2062778b..dea6ecd3 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,62 @@ 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); + } else if (contentResult.value.preview.kind === "unsupported") { + result.contentPreviewError = contentResult.value.preview.message; + } + return result; }; export const registerFileHandlers = () => { @@ -249,40 +238,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..a3db873b --- /dev/null +++ b/src/main/presentation/__tests__/pptx.test.ts @@ -0,0 +1,236 @@ +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("inlines PPTX picture media into the cached SVG", async () => { + const renderer = makeRenderer(); + renderer.getSlideCount = vi.fn().mockReturnValue(1); + renderer.renderSlideSvg = vi.fn().mockReturnValue(` + + + + + + `); + + const preview = await preparePptxPreview(sourcePath, { + cacheRoot: join(root, "cache"), + createRenderer: vi.fn().mockResolvedValue(renderer), + extractArchive: vi.fn().mockResolvedValue({ + binaryFiles: new Map([ + [ + "ppt/media/screenshot.png", + new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), + ], + ]), + textFiles: new Map([ + [ + "ppt/slides/_rels/slide1.xml.rels", + ` + + `, + ], + ]), + }), + }); + + const rendered = await readFile(preview.slides[0].previewPath, "utf8"); + expect(rendered).toContain('href="data:image/jpeg;base64,/9j/4A=="'); + expect(rendered).toMatch( + /]*href="data:image\/jpeg;base64,\/9j\/4A=="/, + ); + expect(rendered).not.toContain('fill="#dddddd"'); + }); + + 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.sourceRevision).toMatch(/^[a-f0-9]{64}$/); + 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 atomically overwrites the source PPTX", async () => { + const renderer = makeRenderer(); + + const result = await editPptxPresentation( + { + edits: [ + { + objectId: "slide:2/shape:0/text:0:0", + text: "Updated launch", + }, + ], + sourcePath, + }, + { + createRenderer: vi.fn().mockResolvedValue(renderer), + }, + ); + + expect(renderer.updateShapeText).toHaveBeenCalledWith( + 1, + 0, + 0, + 0, + "Updated launch", + ); + expect(result).toEqual({ + editedSlides: [2], + outputPath: sourcePath, + slideCount: 2, + }); + await expect(readFile(sourcePath, "utf8")).resolves.toBe("edited-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", + }, + ], + 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 new file mode 100644 index 00000000..fac7ba2b --- /dev/null +++ b/src/main/presentation/pptx.ts @@ -0,0 +1,607 @@ +import { Buffer } from "node:buffer"; +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, posix, resolve } from "node:path"; + +import { DOMParser } from "linkedom"; + +const PPTX_RENDERER_VERSION = "pptx-svg@0.6.4+filework-preview-v2"; +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[]; + sourceRevision: string; +} + +export interface PresentationTextEdit { + objectId: string; + text: string; +} + +export interface EditPresentationRequest { + sourcePath: string; + sourceRevision?: string; + edits: PresentationTextEdit[]; +} + +export interface EditPresentationResult { + editedSlides: number[]; + outputPath: string; + slideCount: number; +} + +interface RendererOptions { + createRenderer?: RendererFactory; +} + +interface PreviewOptions extends RendererOptions { + cacheRoot: string; + extractArchive?: (buffer: ArrayBuffer) => Promise<{ + binaryFiles: Map; + textFiles: Map; + }>; +} + +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, + 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(); + await renderer.loadPptx(toArrayBuffer(bytes)); + return { + bytes, + renderer, + sourceRevision: fingerprint.cacheKey, + }; +}; + +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 IMAGE_MIME_BY_EXTENSION: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +}; + +const detectRasterImageMime = (media: Uint8Array): string | null => { + if ( + media.length >= 8 && + media[0] === 0x89 && + media[1] === 0x50 && + media[2] === 0x4e && + media[3] === 0x47 && + media[4] === 0x0d && + media[5] === 0x0a && + media[6] === 0x1a && + media[7] === 0x0a + ) { + return "image/png"; + } + if ( + media.length >= 3 && + media[0] === 0xff && + media[1] === 0xd8 && + media[2] === 0xff + ) { + return "image/jpeg"; + } + if ( + media.length >= 6 && + media[0] === 0x47 && + media[1] === 0x49 && + media[2] === 0x46 && + media[3] === 0x38 && + (media[4] === 0x37 || media[4] === 0x39) && + media[5] === 0x61 + ) { + return "image/gif"; + } + if ( + media.length >= 12 && + media[0] === 0x52 && + media[1] === 0x49 && + media[2] === 0x46 && + media[3] === 0x46 && + media[8] === 0x57 && + media[9] === 0x45 && + media[10] === 0x42 && + media[11] === 0x50 + ) { + return "image/webp"; + } + return null; +}; + +const resolveArchiveTarget = (sourceEntry: string, target: string) => { + const normalized = target.startsWith("/") + ? posix.normalize(target.slice(1)) + : posix.normalize(posix.join(posix.dirname(sourceEntry), target)); + if ( + normalized.startsWith("../") || + normalized.includes("/../") || + !normalized.startsWith("ppt/media/") + ) { + return null; + } + return normalized; +}; + +const inlinePptxPictures = ( + svg: string, + slideIndex: number, + archive: { + binaryFiles: Map; + textFiles: Map; + }, +): string => { + const slideEntry = `ppt/slides/slide${slideIndex + 1}.xml`; + const relationshipsEntry = `ppt/slides/_rels/slide${slideIndex + 1}.xml.rels`; + const relationshipsXml = archive.textFiles.get(relationshipsEntry); + if (!relationshipsXml) return svg; + + const relationshipsDocument = new DOMParser().parseFromString( + relationshipsXml, + "text/xml", + ); + type XmlElement = { + getAttribute(name: string): string | null; + querySelector(selector: string): XmlElement | null; + replaceWith(element: XmlElement): void; + setAttribute(name: string, value: string): void; + }; + const mediaByRelationshipId = new Map(); + const relationships = Array.from( + relationshipsDocument.querySelectorAll("Relationship"), + ) as unknown as XmlElement[]; + for (const relationship of relationships) { + const id = relationship.getAttribute("Id"); + const target = relationship.getAttribute("Target"); + const type = relationship.getAttribute("Type"); + if ( + !id || + !target || + !type?.endsWith("/image") || + relationship.getAttribute("TargetMode") === "External" + ) { + continue; + } + const mediaEntry = resolveArchiveTarget(slideEntry, target); + if (!mediaEntry) continue; + const media = archive.binaryFiles.get(mediaEntry); + const mime = media + ? (detectRasterImageMime(media) ?? + IMAGE_MIME_BY_EXTENSION[extname(mediaEntry).toLowerCase()]) + : null; + if (!mime || !media) continue; + mediaByRelationshipId.set( + id, + `data:${mime};base64,${Buffer.from(media).toString("base64")}`, + ); + } + if (mediaByRelationshipId.size === 0) return svg; + + const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml"); + let changed = false; + const pictures = Array.from( + svgDocument.querySelectorAll( + 'g[data-ooxml-shape-type="picture"][data-ooxml-blip-rid]', + ), + ) as unknown as XmlElement[]; + for (const picture of pictures) { + const relationshipId = picture.getAttribute("data-ooxml-blip-rid"); + const dataUri = relationshipId + ? mediaByRelationshipId.get(relationshipId) + : null; + const placeholder = picture.querySelector("rect"); + if (!dataUri || !placeholder) continue; + + const image = svgDocument.createElementNS( + "http://www.w3.org/2000/svg", + "image", + undefined, + ) as unknown as XmlElement; + for (const attribute of ["x", "y", "width", "height"]) { + const value = placeholder.getAttribute(attribute); + if (value !== null) image.setAttribute(attribute, value); + } + image.setAttribute("href", dataUri); + image.setAttribute("preserveAspectRatio", "none"); + placeholder.replaceWith(image); + changed = true; + } + return changed ? svgDocument.toString() : 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[] = []; + let archivePromise: + | Promise<{ + binaryFiles: Map; + textFiles: Map; + }> + | undefined; + for ( + let slideIndex = 0; + slideIndex < renderer.getSlideCount(); + slideIndex++ + ) { + const previewPath = join(cacheDir, `slide-${slideIndex + 1}.svg`); + const rawSvg = renderSlide(renderer, slideIndex); + let previewSvg = rawSvg; + if (rawSvg.includes('data-ooxml-shape-type="picture"')) { + archivePromise ??= + options.extractArchive?.(toArrayBuffer(sourceBytes)) ?? + import("pptx-svg").then(({ extractZip }) => + extractZip(toArrayBuffer(sourceBytes)), + ); + previewSvg = inlinePptxPictures(rawSvg, slideIndex, await archivePromise); + } + await writeAtomically(previewPath, previewSvg); + 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, sourceRevision } = 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, sourceRevision }; +}; + +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]), + }; +}; + +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.sourcePath); + assertPptxPath(outputPath, "Presentation output"); + + const { renderer } = await loadRenderer( + request.sourcePath, + options.createRenderer ?? defaultRendererFactory, + request.sourceRevision, + ); + 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-editor.test.ts b/src/main/skills/__tests__/pptx-editor.test.ts new file mode 100644 index 00000000..b6745af4 --- /dev/null +++ b/src/main/skills/__tests__/pptx-editor.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { inspectPptxPresentation, validateFile } = vi.hoisted(() => ({ + inspectPptxPresentation: vi.fn(), + validateFile: vi.fn().mockResolvedValue({ valid: true }), +})); + +vi.mock("../../presentation/pptx", () => ({ + editPptxPresentation: vi.fn(), + inspectPptxPresentation, +})); + +vi.mock("../file-skill-utils", () => ({ + validateFile, +})); + +import { pptxEditor } from "../pptx-editor"; + +describe("pptxEditor search", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("matches text independently of the process locale", async () => { + inspectPptxPresentation.mockResolvedValue({ + slideCount: 1, + slides: [ + { + hidden: false, + index: 1, + notes: [], + objects: [ + { + geometry: "rect", + objectId: "slide:1/shape:0", + shapeIndex: 0, + textRuns: [ + { + objectId: "slide:1/shape:0/text:0:0", + paragraphIndex: 0, + runIndex: 0, + text: "INDIGO", + }, + ], + type: "autoshape", + }, + ], + }, + ], + sourceRevision: "revision-1", + }); + const nativeToLocaleLowerCase = String.prototype.toLocaleLowerCase; + vi.spyOn(String.prototype, "toLocaleLowerCase").mockImplementation( + function (this: string) { + return nativeToLocaleLowerCase.call(this, "tr"); + }, + ); + const inspectTool = pptxEditor.tools?.inspectPptxObjects as unknown as { + execute: (input: { + path: string; + search: string; + }) => Promise<{ slides: Array<{ objects: unknown[] }> }>; + }; + + const result = await inspectTool.execute({ + path: "/workspace/deck.pptx", + search: "indigo", + }); + + expect(result.slides).toHaveLength(1); + expect(result.slides[0].objects).toHaveLength(1); + }); +}); diff --git a/src/main/skills/__tests__/pptx-processor.test.ts b/src/main/skills/__tests__/pptx-processor.test.ts index 69644764..f44808f2 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,50 @@ 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", + ]); + }); + + 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"); + }); + + it("edits the source PPTX in place without exposing a copy output path", () => { + const editTool = pptxEditor.tools?.editPptxText; + const inputSchema = editTool?.inputSchema as + | { + safeParse: ( + input: unknown, + ) => + | { data: Record; success: true } + | { success: false }; + } + | undefined; + const parsed = inputSchema?.safeParse({ + edits: [ + { + objectId: "slide:1/shape:0/text:0:0", + text: "Updated", + }, + ], + outputPath: "/workspace/deck-edited.pptx", + path: "/workspace/deck.pptx", + }); + + expect(parsed?.success).toBe(true); + if (!parsed?.success) return; + expect(parsed.data).not.toHaveProperty("outputPath"); + expect(pptxEditor.systemPrompt).toContain( + "Overwrite the source presentation", + ); + expect(pptxEditor.systemPrompt).not.toContain("new .pptx copy"); + }); +}); 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..777c40ef --- /dev/null +++ b/src/main/skills/pptx-editor.ts @@ -0,0 +1,162 @@ +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().toLowerCase(); + 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.toLowerCase().includes(normalizedSearch), + ), + ) + : item.objects, + })) + .filter( + (item) => + !normalizedSearch || + item.objects.length > 0 || + item.notes.some((note) => + note.toLowerCase().includes(normalizedSearch), + ), + ); + return { + slideCount: presentation.slideCount, + slides, + sourceRevision: presentation.sourceRevision, + }; + } 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 文件的绝对路径"), + sourceRevision: z + .string() + .optional() + .describe( + "可选的源文件修订标识;从本地选择上下文或 inspectPptxObjects 返回值读取,用于阻止过期对象 ID 写入", + ), + edits: z + .array( + z.object({ + objectId: z + .string() + .describe("inspectPptxObjects 返回的文本 objectId"), + text: z.string().describe("替换后的完整文本 run 内容"), + }), + ) + .min(1) + .max(100), + }), + execute: async ({ + path, + sourceRevision, + edits, + }: { + path: string; + sourceRevision?: 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, + sourcePath: path, + sourceRevision, + }); + } 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. 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. Overwrite the source presentation in place. Do not create a renamed copy. +5. Report the source 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/App.tsx b/src/renderer/App.tsx index cbececb8..87016385 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -118,6 +118,7 @@ export const App = () => { const [dockOpen, setDockOpen] = useState(false); const [dockTab, setDockTab] = useState("preview"); const [dockFilePath, setDockFilePath] = useState(null); + const [dockFileRevision, setDockFileRevision] = useState(0); const [dockWidth, setDockWidth] = useState(getInitialDockWidth); const [browserUrl, setBrowserUrl] = useState(null); const [diffBaseBranch, setDiffBaseBranch] = useState(null); @@ -149,6 +150,7 @@ export const App = () => { }, []); const openFileInDock = useCallback((path: string) => { setDockFilePath(path); + setDockFileRevision((revision) => revision + 1); setDockTab("preview"); setDockOpen(true); }, []); @@ -622,6 +624,7 @@ export const App = () => { railWidth={railWidth} railCollapsed={railCollapsed} filePath={dockFilePath} + fileRevision={dockFileRevision} url={browserUrl} subagentSel={dockSubagent} onSelectSubagentChild={(childTaskId) => diff --git a/src/renderer/__tests__/App.automations.test.tsx b/src/renderer/__tests__/App.automations.test.tsx index 21f79b43..fc73e273 100644 --- a/src/renderer/__tests__/App.automations.test.tsx +++ b/src/renderer/__tests__/App.automations.test.tsx @@ -31,9 +31,16 @@ vi.mock("../components/command/CommandPalette", () => ({ vi.mock("../components/dock/ContextDock", () => ({ ContextDock: (props: { activeTab: string; + filePath?: string | null; + fileRevision?: number; onAutomationRunDetailsOpenedAsChat?: () => void; }) => ( -
+