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
30 changes: 18 additions & 12 deletions src/officecli/CommandBuilder.View.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,9 @@ private static Command BuildViewCommand(Option<bool> jsonOption)
? Math.Max(1, (int)Math.Round(screenshotWidth * (double)nativeH / nativeW))
: screenshotHeight;
}
if (renderMode != "html" && OperatingSystem.IsWindows())
Exception? nativeFailure = null;
bool nativeAttempted = renderMode != "html" && OperatingSystem.IsWindows();
if (nativeAttempted)
{
try
{
Expand All @@ -276,11 +278,11 @@ private static Command BuildViewCommand(Option<bool> jsonOption)
directPng = OfficeCli.Core.PowerPointPngBackend.Render(file.FullName, pStart ?? 1, pEnd ?? pStart ?? 1, exportW, exportH);
}
}
catch { directPng = null; }
catch (Exception e) { nativeFailure = e; directPng = null; }
}
if (renderMode == "native" && directPng == null)
throw new OfficeCli.Core.CliException("--render native requires Windows with Microsoft PowerPoint installed.")
{ Code = "native_unavailable", Suggestion = "Use --render html or --render auto." };
throw OfficeCli.Core.NativeRenderDiagnostics.Create(
"Microsoft PowerPoint", nativeAttempted, nativeFailure);

if (directPng == null)
{
Expand Down Expand Up @@ -352,14 +354,16 @@ private static Command BuildViewCommand(Option<bool> jsonOption)
if (over > 1.0) { vpW /= over; cellW /= over; cellH /= over; vpH /= over; }

// Native-first: render each real-Word page and tile (Windows + Word).
if (renderMode != "html" && OperatingSystem.IsWindows())
Exception? nativeFailure = null;
bool nativeAttempted = renderMode != "html" && OperatingSystem.IsWindows();
if (nativeAttempted)
{
try { directPng = OfficeCli.Core.WordPdfBackend.RenderGrid(file.FullName, $"1-{pageCount}", (int)Math.Round(cellW), (int)Math.Round(cellH), docGridCols, gap, pad); }
catch { directPng = null; }
catch (Exception e) { nativeFailure = e; directPng = null; }
}
if (renderMode == "native" && directPng == null)
throw new OfficeCli.Core.CliException("--render native requires Windows with Microsoft Word installed.")
{ Code = "native_unavailable", Suggestion = "Use --render html or --render auto." };
throw OfficeCli.Core.NativeRenderDiagnostics.Create(
"Microsoft Word", nativeAttempted, nativeFailure);
if (directPng == null)
{
// HTML fallback: layoutGrid tiles in-browser; size the viewport
Expand All @@ -377,16 +381,18 @@ private static Command BuildViewCommand(Option<bool> jsonOption)
var effectiveFilter = clipArg != null
? pageFilter
: (string.IsNullOrEmpty(pageFilter) ? "1" : pageFilter);
if (renderMode != "html" && OperatingSystem.IsWindows())
Exception? nativeFailure = null;
bool nativeAttempted = renderMode != "html" && OperatingSystem.IsWindows();
if (nativeAttempted)
{
// effectiveFilter is only null under --range, which forces
// renderMode=html — this native branch is then unreachable.
try { directPng = OfficeCli.Core.WordPdfBackend.Render(file.FullName, effectiveFilter!); }
catch { directPng = null; }
catch (Exception e) { nativeFailure = e; directPng = null; }
}
if (renderMode == "native" && directPng == null)
throw new OfficeCli.Core.CliException("--render native requires Windows with Microsoft Word installed.")
{ Code = "native_unavailable", Suggestion = "Use --render html or --render auto." };
throw OfficeCli.Core.NativeRenderDiagnostics.Create(
"Microsoft Word", nativeAttempted, nativeFailure);
if (directPng == null)
{
html = RenderViaRegistry(wordHandler, "docx",
Expand Down
71 changes: 71 additions & 0 deletions src/officecli/Core/NativeRenderDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0

using System.Runtime.InteropServices;

namespace OfficeCli.Core;

/// <summary>
/// Converts native Office rendering failures into actionable CLI diagnostics.
/// Auto rendering still falls back silently; this is used only when the caller
/// explicitly requested the native backend.
/// </summary>
internal static class NativeRenderDiagnostics
{
const int ClassNotRegistered = unchecked((int)0x80040154);

internal static CliException Create(
string application,
bool attempted,
Exception? failure)
{
if (!attempted)
{
return new CliException($"--render native requires Windows with {application} installed.")
{
Code = "native_unavailable",
Suggestion = "Use --render html or --render auto."
};
}

if (failure == null)
{
return new CliException($"{application} native render did not produce an image.")
{
Code = "native_render_failed",
Suggestion = "Verify the document and page range, or use --render html or --render auto."
};
}

var detail = Describe(failure);
if (IsUnavailable(failure))
{
return new CliException($"{application} is unavailable: {detail}", failure)
{
Code = "native_unavailable",
Suggestion = $"Install or repair {application}, or use --render html or --render auto."
};
}

return new CliException($"{application} native render failed: {detail}", failure)
{
Code = "native_render_failed",
Suggestion = "Verify that the document opens in Office, or use --render html or --render auto."
};
}

static bool IsUnavailable(Exception failure)
=> failure is COMException { HResult: ClassNotRegistered }
|| failure.Message.StartsWith("app_not_authentic:", StringComparison.Ordinal)
|| failure.Message.StartsWith("word_not_authentic:", StringComparison.Ordinal);

static string Describe(Exception failure)
{
var message = string.IsNullOrWhiteSpace(failure.Message)
? failure.GetType().Name
: failure.Message.Trim();
if (failure is ExternalException && !message.Contains("0x", StringComparison.OrdinalIgnoreCase))
message += $" (HRESULT 0x{failure.HResult:X8})";
return message;
}
}
22 changes: 12 additions & 10 deletions src/officecli/Core/PowerPointPngBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
Expand All @@ -14,9 +15,9 @@ namespace OfficeCli.Core;
/// OS-native PNG rendering for .pptx on Windows: drives the installed
/// presentation application through its automation interface to export each
/// requested slide straight to a PNG, then stitches a multi-slide range
/// vertically. Returns null on any failure so the caller falls back to the
/// HTML screenshot path. The COM/IDispatch plumbing and the PNG stitch are
/// shared with <see cref="WordPdfBackend"/>.
/// vertically. Failures are surfaced to the caller; auto mode catches them and
/// falls back to the HTML screenshot path. The COM/IDispatch plumbing and the
/// PNG stitch are shared with <see cref="WordPdfBackend"/>.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class PowerPointPngBackend
Expand All @@ -25,8 +26,7 @@ internal static class PowerPointPngBackend

/// Render slides [startSlide..endSlide] (1-based, inclusive) to a single PNG
/// at width×height pixels. A range is stitched top-to-bottom. Runs on a
/// dedicated STA thread; returns null if the app is unavailable or any step
/// fails or exceeds the timeout.
/// dedicated STA thread and preserves failures from that thread.
public static byte[]? Render(string pptx, int startSlide, int endSlide, int width, int height, int timeoutMs = 60000)
{
// Keep within the multi-image LLM ceiling, same 1920 long-edge cap as the HTML path.
Expand All @@ -45,16 +45,17 @@ internal static class PowerPointPngBackend
th.SetApartmentState(ApartmentState.STA);
th.IsBackground = true;
th.Start();
if (!th.Join(timeoutMs + 30000)) return null;
if (error != null) return null;
if (!th.Join(timeoutMs + 30000))
throw new TimeoutException("PowerPoint native rendering timed out.");
if (error != null) ExceptionDispatchInfo.Capture(error).Throw();
return result;
}

/// Render slides [startSlide..endSlide] (1-based; endSlide <= 0 means "to the
/// last slide") into an N-column thumbnail grid. Each slide is exported at
/// cellW×cellH and tiled with the given gap/padding (pixels) on a white
/// background. Cells are scaled down if the composed image would exceed the
/// 1920 long-edge ceiling. Returns null on failure.
/// 1920 long-edge ceiling. Failures are surfaced to the caller.
public static byte[]? RenderGrid(string pptx, int startSlide, int endSlide, int cellW, int cellH, int cols, int gap, int pad, int timeoutMs = 120000)
{
byte[]? result = null;
Expand All @@ -69,8 +70,9 @@ internal static class PowerPointPngBackend
th.SetApartmentState(ApartmentState.STA);
th.IsBackground = true;
th.Start();
if (!th.Join(timeoutMs + 30000)) return null;
if (error != null) return null;
if (!th.Join(timeoutMs + 30000))
throw new TimeoutException("PowerPoint native grid rendering timed out.");
if (error != null) ExceptionDispatchInfo.Capture(error).Throw();
return result;
}

Expand Down
19 changes: 12 additions & 7 deletions src/officecli/Core/WordPdfBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
Expand Down Expand Up @@ -660,8 +661,9 @@ public static bool RefreshFields(string docx, int timeoutMs = 180000)
th.SetApartmentState(ApartmentState.STA);
th.IsBackground = true;
th.Start();
if (!th.Join(timeoutMs + 30000)) return null;
if (error != null) return null;
if (!th.Join(timeoutMs + 30000))
throw new TimeoutException("Word native rendering timed out.");
if (error != null) ExceptionDispatchInfo.Capture(error).Throw();
return result;
}

Expand All @@ -670,13 +672,14 @@ public static bool RefreshFields(string docx, int timeoutMs = 180000)
/// each to <paramref name="cellW"/>×<paramref name="cellH"/>, and tile them
/// into a <paramref name="cols"/>-column contact sheet. The docx analogue of
/// PowerPointPngBackend.RenderGrid (which exports each slide at cell size via
/// PowerPoint). Returns null on non-Windows, missing/inauthentic Word, or any
/// failure — caller falls back to the HTML grid. cellW/cellH are the FINAL
/// (already 1920-capped) cell size, so the stitched image needs no further cap.
/// PowerPoint). Failures are surfaced to the caller; auto mode catches them
/// and falls back to the HTML grid. cellW/cellH are the FINAL (already
/// 1920-capped) cell size, so the stitched image needs no further cap.
/// </summary>
public static byte[]? RenderGrid(string docx, string pageFilter, int cellW, int cellH, int cols, int gap, int pad, int timeoutMs = 60000)
{
byte[]? result = null;
Exception? error = null;
var th = new Thread(() =>
{
string? pdf = null;
Expand All @@ -701,13 +704,15 @@ public static bool RefreshFields(string docx, int timeoutMs = 180000)
}
finally { Marshal.Release(factory); }
}
catch { result = null; }
catch (Exception e) { error = e; }
finally { if (pdf != null) try { File.Delete(pdf); } catch { } }
});
th.SetApartmentState(ApartmentState.STA);
th.IsBackground = true;
th.Start();
if (!th.Join(timeoutMs + 30000)) return null;
if (!th.Join(timeoutMs + 30000))
throw new TimeoutException("Word native grid rendering timed out.");
if (error != null) ExceptionDispatchInfo.Capture(error).Throw();
return result;
}
}
31 changes: 19 additions & 12 deletions src/officecli/ResidentServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1597,7 +1597,9 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
int pptGridCols = gridCols < 0
? OfficeCli.Core.HtmlScreenshot.AutoGridColumns((pEnd ?? pptShotHandler.GetSlideCount()) - (pStart ?? 1) + 1, nativeW, nativeH)
: gridCols;
if (renderMode != "html" && OperatingSystem.IsWindows())
Exception? nativeFailure = null;
bool nativeAttempted = renderMode != "html" && OperatingSystem.IsWindows();
if (nativeAttempted)
{
// A read-only handler holds only read access with FileShare.ReadWrite,
// so the app can open the file for read concurrently — no dispose
Expand All @@ -1620,7 +1622,7 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
? OfficeCli.Core.PowerPointPngBackend.RenderGrid(_filePath, ps, gEnd, gCellW, gCellH, pptGridCols, gGap, gPad)
: OfficeCli.Core.PowerPointPngBackend.Render(_filePath, ps, pEnd ?? ps, exportW, exportH);
}
catch { directPng = null; }
catch (Exception e) { nativeFailure = e; directPng = null; }
if (_editable)
{
_handler = OfficeCli.Handlers.DocumentHandlerFactory.Open(_filePath, _editable);
Expand All @@ -1629,8 +1631,8 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
}
if (renderMode == "native" && directPng == null)
{
Console.Error.WriteLine("--render native requires Windows with Microsoft PowerPoint installed.");
return;
throw OfficeCli.Core.NativeRenderDiagnostics.Create(
"Microsoft PowerPoint", nativeAttempted, nativeFailure);
}
if (directPng == null)
{
Expand Down Expand Up @@ -1675,11 +1677,13 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
// Native-first on Windows: release an editable write lock (blocks
// Word) before rendering, then reopen — same dance as the single-page
// branch below.
if (renderMode != "html" && OperatingSystem.IsWindows())
Exception? nativeFailure = null;
bool nativeAttempted = renderMode != "html" && OperatingSystem.IsWindows();
if (nativeAttempted)
{
if (_editable) _handler.Dispose();
try { directPng = OfficeCli.Core.WordPdfBackend.RenderGrid(_filePath, $"1-{gPageCount}", (int)Math.Round(gCellW), (int)Math.Round(gCellH), gCols, gGap, gPad); }
catch { directPng = null; }
catch (Exception e) { nativeFailure = e; directPng = null; }
if (_editable)
{
_handler = OfficeCli.Handlers.DocumentHandlerFactory.Open(_filePath, _editable);
Expand All @@ -1688,8 +1692,8 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
}
if (renderMode == "native" && directPng == null)
{
Console.Error.WriteLine("--render native requires Windows with Microsoft Word installed.");
return;
throw OfficeCli.Core.NativeRenderDiagnostics.Create(
"Microsoft Word", nativeAttempted, nativeFailure);
}
if (directPng == null)
{
Expand All @@ -1704,14 +1708,17 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
var effectiveFilter = rangeArg != null
? pageFilter
: (string.IsNullOrEmpty(pageFilter) ? "1" : pageFilter);
if (renderMode != "html" && OperatingSystem.IsWindows())
Exception? nativeFailure = null;
bool nativeAttempted = renderMode != "html" && OperatingSystem.IsWindows();
if (nativeAttempted)
{
// See the pptx branch: only an editable handler must be released
// (its write handle blocks Word); a read-only handler coexists.
if (_editable) _handler.Dispose();
// effectiveFilter is only null under --range, which forces
// renderMode=html — this native branch is then unreachable.
try { directPng = OfficeCli.Core.WordPdfBackend.Render(_filePath, effectiveFilter!); } catch { directPng = null; }
try { directPng = OfficeCli.Core.WordPdfBackend.Render(_filePath, effectiveFilter!); }
catch (Exception e) { nativeFailure = e; directPng = null; }
if (_editable)
{
_handler = OfficeCli.Handlers.DocumentHandlerFactory.Open(_filePath, _editable);
Expand All @@ -1720,8 +1727,8 @@ private void ExecuteView(ResidentRequest req, OutputFormat format)
}
if (renderMode == "native" && directPng == null)
{
Console.Error.WriteLine("--render native requires Windows with Microsoft Word installed.");
return;
throw OfficeCli.Core.NativeRenderDiagnostics.Create(
"Microsoft Word", nativeAttempted, nativeFailure);
}
if (directPng == null) html = CommandBuilder.RenderViaRegistry(wordShotHandler, "docx",
new OfficeCli.Core.Rendering.RenderOptions { PageFilter = effectiveFilter })!;
Expand Down