Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions crates/docx-handler/src/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
)
})?;

Expand Down
5 changes: 3 additions & 2 deletions crates/docx-handler/src/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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())
})
Expand Down Expand Up @@ -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") {
Expand Down
6 changes: 6 additions & 0 deletions crates/handler-common/src/output_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ pub struct ViewOptions {
pub cols: Option<Vec<String>>,
/// Page filter string (e.g. "1", "2-5", "1,3,5"). Parsed by each handler.
pub page: Option<String>,
/// Element path or cell range to restrict output (e.g. "/slide[1]/shape[@id=2]" or "Sheet1!A1:C3")
pub range: Option<String>,
/// Grid columns for thumbnail contact sheet (0 = off)
pub grid: u32,
/// Rendering backend: "auto", "native", "html"
pub render: String,
}

/// Options for raw commands.
Expand Down
145 changes: 143 additions & 2 deletions crates/officecli/src/commands/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ pub struct ViewCommand {
/// Limit number of results (for issues mode)
#[arg(long)]
pub limit: Option<usize>,

/// 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<String>,

/// Zoom factor for --range screenshots (e.g. "2x")
#[arg(long)]
pub zoom: Option<String>,

/// Padding in pixels around cropped element
#[arg(long, default_value_t = 0)]
pub padding: u32,
}

pub fn handle_view(cmd: ViewCommand, format: OutputFormat) -> Result<String, HandlerError> {
Expand All @@ -76,6 +88,13 @@ pub fn handle_view(cmd: ViewCommand, format: OutputFormat) -> Result<String, Han
return handle_view_pdf(&cmd, format);
}

// If --range is specified and mode is screenshot, crop to element bbox
if let Some(ref range) = cmd.range {
if matches!(cmd.mode.as_str(), "screenshot" | "p") {
return handle_screenshot_with_range(handler.as_ref(), &cmd, range);
}
}

let opts = ViewOptions {
start_line: cmd.start_line,
end_line: cmd.end_line,
Expand All @@ -85,6 +104,9 @@ pub fn handle_view(cmd: ViewCommand, format: OutputFormat) -> Result<String, Han
.as_ref()
.map(|c| c.split(',').map(|s| s.to_string()).collect()),
page: cmd.page.clone(),
range: cmd.range.clone(),
grid: cmd.grid,
render: cmd.render.clone(),
};

let browser = cmd.browser;
Expand Down Expand Up @@ -224,10 +246,24 @@ fn handle_screenshot(
.as_ref()
.map(|c| c.split(',').map(|s| s.to_string()).collect()),
page: cmd.page.clone(),
range: cmd.range.clone(),
grid: cmd.grid,
render: cmd.render.clone(),
};

// Step 1: Render HTML
let html = handler.view_as_html(opts)?;
// Step 1: Render HTML (with optional grid tiling)
let html = if cmd.grid > 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();
Expand Down Expand Up @@ -332,3 +368,108 @@ fn open_path_in_browser(path: &std::path::Path) {
.spawn();
}
}

/// Handle `view -m screenshot --range <path>` — crop screenshot to a single element's bbox.
fn handle_screenshot_with_range(
handler: &dyn handler_common::DocumentHandler,
cmd: &ViewCommand,
range: &str,
) -> Result<String, HandlerError> {
// 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#"<div style="width:{}px;height:{}px;overflow:hidden;position:relative;">
<div style="position:absolute;left:-{}px;top:-{}px;transform:scale({});transform-origin:top left;">
{}
</div>
</div>"#,
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<String, HandlerError> {
let cols = grid_cols.max(1);
let mut table = String::from("<table style=\"border-collapse:collapse;width:100%;\">");
for i in 0..page_count {
if i % cols == 0 {
table.push_str("<tr>");
}
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!(
"<td style=\"border:1px solid #ccc;vertical-align:top;padding:4px;\"><div class=\"tile\">{}</div></td>",
page_html
));
if i % cols == cols - 1 || i == page_count - 1 {
table.push_str("</tr>");
}
}
table.push_str("</table>");
Ok(table)
}

/// Parse a zoom string like "2x" or "3.0" into a float multiplier.
fn parse_zoom(zoom: &Option<String>) -> Option<f32> {
zoom.as_ref().and_then(|z| {
if let Some(v) = z.strip_suffix('x') {
v.parse::<f32>().ok()
} else {
z.parse::<f32>().ok()
}
})
}
13 changes: 13 additions & 0 deletions crates/officecli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,19 @@ fn execute_tool(name: &str, params: &HashMap<String, Value>) -> Result<Value, St
.and_then(|v| v.as_str())
.map(|c| c.split(',').map(|s| s.to_string()).collect()),
page: None,
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(),
};

let result = match mode {
Expand Down
13 changes: 13 additions & 0 deletions crates/officecli/src/resident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,19 @@ fn view_opts_from_params(params: &HashMap<String, serde_json::Value>) -> 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(),
}
}

Expand Down
9 changes: 9 additions & 0 deletions crates/officecli/src/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u32>().ok())
.unwrap_or(0),
render: params
.get("render")
.cloned()
.unwrap_or_else(|| "auto".to_string()),
};

let op = match mode.as_str() {
Expand Down
2 changes: 1 addition & 1 deletion crates/pptx-handler/src/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ fn add_picture(
) -> Result<String, HandlerError> {
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.
Expand Down
2 changes: 1 addition & 1 deletion crates/xlsx-handler/src/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down