From 60d5ce6e22cae2410f19d8e41db8f7acecdac3a5 Mon Sep 17 00:00:00 2001 From: Pilsertech Date: Sun, 19 Jul 2026 14:30:43 +0300 Subject: [PATCH 1/2] feat: screenshot range crop, grid contact sheet, render backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot Range Crop (--range): - New --range, --zoom, --padding flags to crop screenshots to element bbox - handle_screenshot_with_range: verify element exists → get bbox from text offset map → crop viewport Grid Contact Sheet (--grid): - Multi-page tiled thumbnail view using grid columns - Works with docx (pages), pptx (slides), xlsx (sheets) Render Backend Selection (--render): - Choose rendering path: auto, native, html (docx only) Infrastructure: - ViewOptions: added range, grid, render fields - MCP/resident/watch servers: thread new params --- crates/handler-common/src/output_format.rs | 6 + crates/officecli/src/commands/view.rs | 145 ++++++++++++++++++++- crates/officecli/src/mcp.rs | 13 ++ crates/officecli/src/resident.rs | 13 ++ crates/officecli/src/watch.rs | 9 ++ 5 files changed, 184 insertions(+), 2 deletions(-) diff --git a/crates/handler-common/src/output_format.rs b/crates/handler-common/src/output_format.rs index 0ebc518..5201c74 100644 --- a/crates/handler-common/src/output_format.rs +++ b/crates/handler-common/src/output_format.rs @@ -17,6 +17,12 @@ pub struct ViewOptions { pub cols: Option>, /// Page filter string (e.g. "1", "2-5", "1,3,5"). Parsed by each handler. pub page: Option, + /// Element path or cell range to restrict output (e.g. "/slide[1]/shape[@id=2]" or "Sheet1!A1:C3") + pub range: Option, + /// Grid columns for thumbnail contact sheet (0 = off) + pub grid: u32, + /// Rendering backend: "auto", "native", "html" + pub render: String, } /// Options for raw commands. diff --git a/crates/officecli/src/commands/view.rs b/crates/officecli/src/commands/view.rs index 03b7e3a..7837544 100644 --- a/crates/officecli/src/commands/view.rs +++ b/crates/officecli/src/commands/view.rs @@ -66,6 +66,18 @@ pub struct ViewCommand { /// Limit number of results (for issues mode) #[arg(long)] pub limit: Option, + + /// Restrict output to a region (element path like "/slide[1]/shape[@id=2]" or xlsx cell range "Sheet1!A1:C3") + #[arg(long)] + pub range: Option, + + /// Zoom factor for --range screenshots (e.g. "2x") + #[arg(long)] + pub zoom: Option, + + /// Padding in pixels around cropped element + #[arg(long, default_value_t = 0)] + pub padding: u32, } pub fn handle_view(cmd: ViewCommand, format: OutputFormat) -> Result { @@ -76,6 +88,13 @@ pub fn handle_view(cmd: ViewCommand, format: OutputFormat) -> Result Result 0 { + let stats = handler.view_as_stats_json()?; + let page_count = stats + .get("pages") + .or(stats.get("slides")) + .or(stats.get("sheets")) + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32; + wrap_in_grid(handler, page_count, cmd.grid)? + } else { + handler.view_as_html(opts)? + }; // Step 2: Write to temp file let temp_dir = std::env::temp_dir(); @@ -332,3 +368,108 @@ fn open_path_in_browser(path: &std::path::Path) { .spawn(); } } + +/// Handle `view -m screenshot --range ` — crop screenshot to a single element's bbox. +fn handle_screenshot_with_range( + handler: &dyn handler_common::DocumentHandler, + cmd: &ViewCommand, + range: &str, +) -> Result { + // 1. Verify element exists + let _node = handler.get(range, 0)?; + + // 2. Get text offset map for bbox + let text_map = handler.extract_text_with_offsets()?; + let spans = text_map.spans_for_path(range); + + // 3. Find first span with bbox + let bbox = spans + .iter() + .find_map(|s| s.bbox.as_ref()) + .ok_or_else(|| HandlerError::OperationFailed("no bbox data for range".into()))?; + + // 4. Calculate zoom + let zoom = parse_zoom(&cmd.zoom).unwrap_or(1.0); + + // 5. Generate HTML for just this element + let html = handler.view_as_html(ViewOptions::default())?; + + let crop_w = (bbox.width * zoom + cmd.padding as f32 * 2.0) as u32; + let crop_h = (bbox.height * zoom + cmd.padding as f32 * 2.0) as u32; + + let cropped_html = format!( + r#"
+
+ {} +
+
"#, + crop_w, crop_h, bbox.x, bbox.y, zoom, html + ); + + // 6. Write to temp and capture screenshot + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let temp_dir = std::env::temp_dir(); + let html_path = temp_dir.join(format!("officecli_range_{}.html", timestamp)); + std::fs::write(&html_path, &cropped_html)?; + + let out_path = cmd.out.clone().unwrap_or_else(|| { + let stem = std::path::Path::new(&cmd.file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("range"); + temp_dir + .join(format!("officecli_range_{}.png", stem)) + .to_string_lossy() + .to_string() + }); + + let result = crate::screenshot::capture(&html_path.to_string_lossy(), &out_path, crop_w, crop_h) + .map_err(|e| HandlerError::OperationFailed(e))?; + + let _ = std::fs::remove_file(&html_path); + + Ok(format!("Range screenshot saved: {}", result.output_path)) +} + +/// Wrap multiple pages/slides into a grid layout for --grid screenshots. +fn wrap_in_grid( + handler: &dyn handler_common::DocumentHandler, + page_count: u32, + grid_cols: u32, +) -> Result { + let cols = grid_cols.max(1); + let mut table = String::from(""); + for i in 0..page_count { + if i % cols == 0 { + table.push_str(""); + } + let page_opts = ViewOptions { + page: Some((i + 1).to_string()), + ..Default::default() + }; + let page_html = handler.view_as_html(page_opts)?; + table.push_str(&format!( + "", + page_html + )); + if i % cols == cols - 1 || i == page_count - 1 { + table.push_str(""); + } + } + table.push_str("
{}
"); + Ok(table) +} + +/// Parse a zoom string like "2x" or "3.0" into a float multiplier. +fn parse_zoom(zoom: &Option) -> Option { + zoom.as_ref().and_then(|z| { + if let Some(v) = z.strip_suffix('x') { + v.parse::().ok() + } else { + z.parse::().ok() + } + }) +} diff --git a/crates/officecli/src/mcp.rs b/crates/officecli/src/mcp.rs index 203b105..587ca0d 100644 --- a/crates/officecli/src/mcp.rs +++ b/crates/officecli/src/mcp.rs @@ -360,6 +360,19 @@ fn execute_tool(name: &str, params: &HashMap) -> Result) -> ViewOpt .get("page") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + range: params + .get("range") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + grid: params + .get("grid") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + render: params + .get("render") + .and_then(|v| v.as_str()) + .unwrap_or("auto") + .to_string(), } } diff --git a/crates/officecli/src/watch.rs b/crates/officecli/src/watch.rs index 483d1f0..6f2e22a 100644 --- a/crates/officecli/src/watch.rs +++ b/crates/officecli/src/watch.rs @@ -1033,6 +1033,15 @@ async fn handle_view( .get("cols") .map(|c| c.split(',').map(|s| s.to_string()).collect()), page: params.get("page").cloned(), + range: params.get("range").cloned(), + grid: params + .get("grid") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0), + render: params + .get("render") + .cloned() + .unwrap_or_else(|| "auto".to_string()), }; let op = match mode.as_str() { From e2af1ff1602211155ef3d87247a9212bd800b024 Mon Sep 17 00:00:00 2001 From: Pilsertech Date: Sun, 19 Jul 2026 15:44:03 +0300 Subject: [PATCH 2/2] fix: image file property, table border all, xlsx set text, multi-row table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Image add: accept 'file' property (not just 'src'/'path') — fixes 0-byte media - Table border: recognize 'all' shorthand (was falling through to empty) - Xlsx set cell: accept 'text' alias alongside 'value' - Multi-row table: fix rNcN text so each row gets unique content (was all rows same) --- crates/docx-handler/src/add.rs | 8 +++++--- crates/docx-handler/src/mutations.rs | 5 +++-- crates/pptx-handler/src/add.rs | 2 +- crates/xlsx-handler/src/mutations.rs | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/docx-handler/src/add.rs b/crates/docx-handler/src/add.rs index 0471aa2..409fa3f 100644 --- a/crates/docx-handler/src/add.rs +++ b/crates/docx-handler/src/add.rs @@ -1868,10 +1868,10 @@ fn add_table( // Build table grid let mut rows_nodes = Vec::new(); - for _ in 0..rows { + for row in 0..rows { let mut cells = Vec::new(); for col_idx in 0..cols { - let text = properties.get(&format!("r{}c{}", col_idx + 1, 1)).cloned(); + let text = properties.get(&format!("r{}c{}", row + 1, col_idx + 1)).cloned(); let mut cell = WordNode::new(WordElementType::TableCell); if let Some(text) = text { let para = WordNode::new(WordElementType::Paragraph) @@ -2140,9 +2140,11 @@ fn add_image( let _src = properties .get("src") .or_else(|| properties.get("path")) + .or_else(|| properties.get("file")) .ok_or_else(|| { HandlerError::InvalidArgument( - "image requires 'src' or 'path' property pointing to image file".to_string(), + "image requires 'src', 'path', or 'file' property pointing to image file" + .to_string(), ) })?; diff --git a/crates/docx-handler/src/mutations.rs b/crates/docx-handler/src/mutations.rs index 88482c9..889f32a 100644 --- a/crates/docx-handler/src/mutations.rs +++ b/crates/docx-handler/src/mutations.rs @@ -899,7 +899,7 @@ pub fn build_table_borders(value: &str) -> WordNode { .with_attribute("color", "auto"), ); } - } else if value.starts_with("all=") || value == "single" || value == "thin" { + } else if value.starts_with("all=") || value == "all" || value == "single" || value == "thin" { let style = value.strip_prefix("all=").unwrap_or("single"); for border_name in ["top", "bottom", "left", "right", "insideH", "insideV"] { children.push( @@ -2882,6 +2882,7 @@ pub fn add_image_part_aware( properties .get("src") .or_else(|| properties.get("path")) + .or_else(|| properties.get("file")) .and_then(|p| Path::new(p).extension()) .and_then(|e| e.to_str()) }) @@ -2917,7 +2918,7 @@ pub fn add_image_part_aware( let media_path = format!("word/media/image{}.{}", image_idx, ext_norm); // Write image binary — priority: src file > payloadBase64 > payloadHex > empty stub. - let bytes_written = if let Some(src) = properties.get("src").or_else(|| properties.get("path")) + let bytes_written = if let Some(src) = properties.get("src").or_else(|| properties.get("path")).or_else(|| properties.get("file")) { std::fs::read(src).ok() } else if let Some(b64) = properties.get("payloadBase64") { diff --git a/crates/pptx-handler/src/add.rs b/crates/pptx-handler/src/add.rs index 79e21b6..20211bf 100644 --- a/crates/pptx-handler/src/add.rs +++ b/crates/pptx-handler/src/add.rs @@ -639,7 +639,7 @@ fn add_picture( ) -> Result { use std::path::Path; - let src = properties.get("src").or_else(|| properties.get("path")); + let src = properties.get("src").or_else(|| properties.get("path")).or_else(|| properties.get("file")); // Resolve image extension — explicit property takes priority, then derive // from `src` filename extension. Default to png. diff --git a/crates/xlsx-handler/src/mutations.rs b/crates/xlsx-handler/src/mutations.rs index c1241e9..97eb1fd 100644 --- a/crates/xlsx-handler/src/mutations.rs +++ b/crates/xlsx-handler/src/mutations.rs @@ -373,7 +373,7 @@ pub fn set_cell_properties( for (key, value) in properties { match key.as_str() { - "value" => { + "value" | "text" => { modified_xml = set_cell_value( &modified_xml, &cell_ref_str,