diff --git a/src/officecli/CommandBuilder.View.cs b/src/officecli/CommandBuilder.View.cs index a3594e02c..54e8f1cfe 100644 --- a/src/officecli/CommandBuilder.View.cs +++ b/src/officecli/CommandBuilder.View.cs @@ -24,6 +24,7 @@ private static Command BuildViewCommand(Option jsonOption) var browserOpt = new Option("--browser") { Description = "Open output in browser (html / svg modes)" }; var outOpt = new Option("--out", "-o") { Description = "Output file path (html, screenshot, pdf modes; defaults to stdout for html, a temp file for screenshot)" }; var clipOpt = new Option("--range") { Description = "Restrict output to a region. Screenshot mode: an xlsx cell range ('Sheet1!A1:C3' or '/Sheet1/A1:C3') or any element data-path ('/slide[1]/shape[@id=N]', '/body/table[1]'); the PNG is cropped to the target's bounding box. Text mode (xlsx only): a cell range or single cell — emits just those rows/cells, saving context on large sheets. Not the character-offset `range=` prop of `set` (that one formats a text span like 3:7)." }; + var rangeModeOpt = new Option("--range-mode") { Description = "Screenshot range behavior: element (tight crop, default) or pages (complete containing page(s))", DefaultValueFactory = _ => "element" }; var screenshotWidthOpt = new Option("--screenshot-width") { Description = "Screenshot viewport width (default 1600)", DefaultValueFactory = _ => 1600 }; var screenshotHeightOpt = new Option("--screenshot-height") { Description = "Screenshot viewport height (default 1200)", DefaultValueFactory = _ => 1200 }; var gridOpt = new Option("--grid") @@ -47,6 +48,7 @@ private static Command BuildViewCommand(Option jsonOption) viewCommand.Add(browserOpt); viewCommand.Add(outOpt); viewCommand.Add(clipOpt); + viewCommand.Add(rangeModeOpt); viewCommand.Add(screenshotWidthOpt); viewCommand.Add(screenshotHeightOpt); viewCommand.Add(gridOpt); @@ -68,6 +70,13 @@ private static Command BuildViewCommand(Option jsonOption) var browser = result.GetValue(browserOpt); var outArg = result.GetValue(outOpt); var clipArg = result.GetValue(clipOpt); + var rangeMode = (result.GetValue(rangeModeOpt) ?? "element").ToLowerInvariant(); + if (rangeMode is not ("element" or "pages")) + throw new CliException($"Invalid --range-mode value: {rangeMode}. Valid: element, pages") + { Code = "invalid_value", ValidValues = ["element", "pages"] }; + if (rangeMode == "pages" && string.IsNullOrEmpty(clipArg)) + throw new CliException("--range-mode pages requires --range .") + { Code = "missing_argument" }; var screenshotWidth = result.GetValue(screenshotWidthOpt); var screenshotHeight = result.GetValue(screenshotHeightOpt); // --grid has three states: absent → off (0), present with no value @@ -128,6 +137,7 @@ private static Command BuildViewCommand(Option jsonOption) if (browser) req.Args["browser"] = "true"; if (outArg != null) req.Args["out"] = outArg; if (clipArg != null) req.Args["range"] = clipArg; + if (rangeMode != "element") req.Args["range-mode"] = rangeMode; req.Args["screenshot-width"] = screenshotWidth.ToString(); req.Args["screenshot-height"] = screenshotHeight.ToString(); if (gridCols != 0) req.Args["grid"] = gridCols.ToString(); // -1 = auto @@ -455,7 +465,8 @@ private static Command BuildViewCommand(Option jsonOption) File.WriteAllText(tmpHtml, html!); var r = clipArg != null ? OfficeCli.Core.HtmlScreenshot.CaptureClipped(tmpHtml, pngPath, - OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(clipArg)) + OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(clipArg), + containingPages: rangeMode == "pages") : OfficeCli.Core.HtmlScreenshot.Capture(tmpHtml, pngPath, screenshotWidth, screenshotHeight); try { File.Delete(tmpHtml); } catch { /* ignore */ } if (!r.Ok && r.Error == "clip_target_not_found") diff --git a/src/officecli/Core/HtmlScreenshot.cs b/src/officecli/Core/HtmlScreenshot.cs index 787e77288..fe1cf926e 100644 --- a/src/officecli/Core/HtmlScreenshot.cs +++ b/src/officecli/Core/HtmlScreenshot.cs @@ -182,7 +182,7 @@ public static Result Capture(string htmlPath, string outPath, int width = 1600, /// a rendered element. /// public static Result CaptureClipped(string htmlPath, string outPath, IReadOnlyList dataPaths, - int padPx = 0, int scale = 2) + int padPx = 0, int scale = 2, bool containingPages = false) { if (FindChrome() == null) return new Result(false, "", "clip mode requires a Chrome-family browser (Chrome/Edge/Chromium)"); @@ -203,8 +203,13 @@ public static Result CaptureClipped(string htmlPath, string outPath, IReadOnlyLi "if(best)return best;if(cands.length===0)return null;" + "var el=cands[0];for(var an=el;an&&an!==document.documentElement;an=an.parentElement){" + "if(getComputedStyle(an).display==='none')an.style.display='block';}return el;}" + - "function _clipEls(){var els=[];_clipPaths.forEach(function(p){" + - "var el=_clipPick(p);if(el)els.push(el);});return els;}"; + (containingPages + ? "function _clipEls(){var els=[],seen=new Set();_clipPaths.forEach(function(p){" + + "var cands=document.querySelectorAll('[data-path=\"'+p+'\"]');cands.forEach(function(el){" + + "var page=el.closest('.page,.slide,.sheet-content')||el;" + + "if(!seen.has(page)){seen.add(page);els.push(page);}});});return els;}" + : "function _clipEls(){var els=[];_clipPaths.forEach(function(p){" + + "var el=_clipPick(p);if(el)els.push(el);});return els;}"); // The rect is reported via console.log -> --enable-logging=stderr in // the SAME chrome invocation that captures the full page: headless diff --git a/src/officecli/ResidentServer.cs b/src/officecli/ResidentServer.cs index 1659f0171..ddd9f7004 100644 --- a/src/officecli/ResidentServer.cs +++ b/src/officecli/ResidentServer.cs @@ -1559,6 +1559,11 @@ private void ExecuteView(ResidentRequest req, OutputFormat format) // --range clips a data-path region out of the HTML preview — mirrors // CommandBuilder.View.cs: native/direct-PNG backends are bypassed. var rangeArg = req.GetArgOrNull("range"); + var rangeMode = (req.GetArgOrNull("range-mode") ?? "element").ToLowerInvariant(); + if (rangeMode is not ("element" or "pages")) + throw new InvalidOperationException($"Invalid range-mode value: {rangeMode}. Valid: element, pages"); + if (rangeMode == "pages" && string.IsNullOrEmpty(rangeArg)) + throw new InvalidOperationException("range-mode=pages requires a range element path."); if (rangeArg != null) renderMode = "html"; var sw = req.GetIntArg("screenshot-width") ?? 1600; var sh = req.GetIntArg("screenshot-height") ?? 1200; @@ -1739,7 +1744,8 @@ private void ExecuteView(ResidentRequest req, OutputFormat format) File.WriteAllText(tmpHtml, html!); var rs = rangeArg != null ? OfficeCli.Core.HtmlScreenshot.CaptureClipped(tmpHtml, pngPath, - OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(rangeArg)) + OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(rangeArg), + containingPages: rangeMode == "pages") : OfficeCli.Core.HtmlScreenshot.Capture(tmpHtml, pngPath, sw, sh); try { File.Delete(tmpHtml); } catch { /* ignore */ } if (!rs.Ok)