exstruct 0.5.2__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {exstruct-0.5.2 → exstruct-0.5.3}/PKG-INFO +15 -8
  2. {exstruct-0.5.2 → exstruct-0.5.3}/README.md +14 -7
  3. {exstruct-0.5.2 → exstruct-0.5.3}/pyproject.toml +4 -4
  4. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/__init__.py +14 -0
  5. exstruct-0.5.3/src/exstruct/mcp/render_runner.py +124 -0
  6. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/server.py +104 -0
  7. exstruct-0.5.3/src/exstruct/mcp/shared/__init__.py +39 -0
  8. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/shared/a1.py +167 -82
  9. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/shared/output_path.py +47 -0
  10. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/tools.py +87 -0
  11. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/render/__init__.py +1057 -636
  12. exstruct-0.5.3/src/exstruct/render/subprocess_worker.py +180 -0
  13. exstruct-0.5.2/src/exstruct/mcp/shared/__init__.py +0 -23
  14. {exstruct-0.5.2 → exstruct-0.5.3}/LICENSE +0 -0
  15. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/__init__.py +0 -0
  16. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/cli/availability.py +0 -0
  17. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/cli/main.py +0 -0
  18. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/__init__.py +0 -0
  19. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/backends/__init__.py +0 -0
  20. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/backends/base.py +0 -0
  21. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/backends/com_backend.py +0 -0
  22. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/backends/openpyxl_backend.py +0 -0
  23. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/cells.py +0 -0
  24. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/charts.py +0 -0
  25. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/integrate.py +0 -0
  26. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/logging_utils.py +0 -0
  27. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/modeling.py +0 -0
  28. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/pipeline.py +0 -0
  29. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/ranges.py +0 -0
  30. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/shapes.py +0 -0
  31. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/core/workbook.py +0 -0
  32. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/engine.py +0 -0
  33. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/errors.py +0 -0
  34. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/io/__init__.py +0 -0
  35. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/io/serialize.py +0 -0
  36. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/chunk_reader.py +0 -0
  37. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/extract_runner.py +0 -0
  38. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/io.py +0 -0
  39. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/op_schema.py +0 -0
  40. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/__init__.py +0 -0
  41. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/chart_types.py +0 -0
  42. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/engine/__init__.py +0 -0
  43. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/engine/base.py +0 -0
  44. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/engine/openpyxl_engine.py +0 -0
  45. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/engine/xlwings_engine.py +0 -0
  46. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/internal.py +0 -0
  47. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/models.py +0 -0
  48. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/normalize.py +0 -0
  49. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/ops/__init__.py +0 -0
  50. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/ops/common.py +0 -0
  51. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/ops/openpyxl_ops.py +0 -0
  52. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/ops/xlwings_ops.py +0 -0
  53. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/runtime.py +0 -0
  54. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/service.py +0 -0
  55. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/specs.py +0 -0
  56. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch/types.py +0 -0
  57. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/patch_runner.py +0 -0
  58. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/sheet_reader.py +0 -0
  59. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/mcp/validate_input.py +0 -0
  60. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/models/__init__.py +0 -0
  61. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/models/maps.py +0 -0
  62. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/models/types.py +0 -0
  63. {exstruct-0.5.2 → exstruct-0.5.3}/src/exstruct/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: exstruct
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines
5
5
  Keywords: excel,structure,data,exstruct
6
6
  Author: harumiWeb
@@ -158,6 +158,7 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re
158
158
  Available tools:
159
159
 
160
160
  - `exstruct_extract`
161
+ - `exstruct_capture_sheet_images`
161
162
  - `exstruct_make`
162
163
  - `exstruct_patch`
163
164
  - `exstruct_read_json_chunk`
@@ -169,6 +170,12 @@ Available tools:
169
170
  Notes:
170
171
 
171
172
  - In MCP, `exstruct_extract` defaults to `options.alpha_col=true` (column keys: `A`, `B`, ...). Set `options.alpha_col=false` for legacy 0-based numeric string keys.
173
+ - `exstruct_capture_sheet_images` is COM-only (Experimental) and supports optional `sheet` / `range` targeting (`A1:B2`, `Sheet1!A1:B2`, `'Sheet 1'!A1:B2`). When `out_dir` is omitted, it creates a unique `<workbook_stem>_images` directory under MCP `--root`.
174
+ - MCP server startup sets `EXSTRUCT_RENDER_SUBPROCESS=1` by default (`setdefault`). If you prefer in-process rendering, set `EXSTRUCT_RENDER_SUBPROCESS=0` before launching the server.
175
+ - Timeout tuning for `exstruct_capture_sheet_images`: `EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC` (tool timeout), `EXSTRUCT_RENDER_SUBPROCESS_STARTUP_TIMEOUT_SEC` (worker startup), `EXSTRUCT_RENDER_SUBPROCESS_JOIN_TIMEOUT_SEC` (primary wait budget), `EXSTRUCT_RENDER_SUBPROCESS_RESULT_TIMEOUT_SEC` (post-exit grace).
176
+ - Subprocess errors are stage-aware (`stage=startup|join|result|worker`) so MCP clients can distinguish bootstrap failures vs timeout vs worker-side rendering errors.
177
+ - Trade-off of `EXSTRUCT_RENDER_SUBPROCESS=1`: subprocess startup/coordination overhead and dependency on worker-side module resolution.
178
+ - Trade-off of `EXSTRUCT_RENDER_SUBPROCESS=0`: less crash isolation and higher memory pressure risk in long-running processes.
172
179
  - Logs go to stderr (and optionally `--log-file`) to avoid contaminating stdio responses.
173
180
  - On Windows with Excel, standard/verbose can use COM for richer extraction. On non-Windows, COM is unavailable and extraction uses openpyxl-based fallbacks.
174
181
  - `exstruct_patch` supports `backend` selection:
@@ -176,13 +183,13 @@ Notes:
176
183
  - `com`: force COM (rejects `dry_run` / `return_inverse_ops` / `preflight_formula_check`)
177
184
  - `openpyxl`: force openpyxl (`.xls` is not supported)
178
185
  - `create_chart` is COM-only (`backend="openpyxl"` is not allowed for requests that include it), and it also rejects `dry_run` / `return_inverse_ops` / `preflight_formula_check`.
179
- - `create_chart` `chart_type` supports: `line`, `column`, `bar`, `area`, `pie`, `doughnut`, `scatter`, `radar` (aliases: `column_clustered`, `bar_clustered`, `xy_scatter`, `donut`).
180
- - `create_chart` `data_range` accepts either one range string or `list[str]` (multi-series), and `data_range` / `category_range` both support sheet-qualified ranges (`Sheet2!A1:B10`, `'Sales Data'!A1:B10`).
181
- - `create_chart` supports optional explicit labels: `chart_title`, `x_axis_title`, `y_axis_title`.
182
- - `create_chart` and `apply_table_style` can be combined in one request when the backend resolves to COM (`backend="com"` or `backend="auto"` with COM available).
183
- - For stable `apply_table_style` COM runs on Windows, ensure desktop Excel is installed/runnable and the target `range` is a contiguous header-included A1 range.
184
- - `exstruct_patch` error detail may include `error_code` / `failed_field` / `raw_com_message`; table-related codes include `table_style_invalid`, `list_object_add_failed`, and `com_api_missing`.
185
- - `exstruct_patch` response includes `engine` (`com` or `openpyxl`) to show the actual backend used. `restore_design_snapshot` remains openpyxl-only.
186
+ - `create_chart` `chart_type` supports: `line`, `column`, `bar`, `area`, `pie`, `doughnut`, `scatter`, `radar` (aliases: `column_clustered`, `bar_clustered`, `xy_scatter`, `donut`).
187
+ - `create_chart` `data_range` accepts either one range string or `list[str]` (multi-series), and `data_range` / `category_range` both support sheet-qualified ranges (`Sheet2!A1:B10`, `'Sales Data'!A1:B10`).
188
+ - `create_chart` supports optional explicit labels: `chart_title`, `x_axis_title`, `y_axis_title`.
189
+ - `create_chart` and `apply_table_style` can be combined in one request when the backend resolves to COM (`backend="com"` or `backend="auto"` with COM available).
190
+ - For stable `apply_table_style` COM runs on Windows, ensure desktop Excel is installed/runnable and the target `range` is a contiguous header-included A1 range.
191
+ - `exstruct_patch` error detail may include `error_code` / `failed_field` / `raw_com_message`; table-related codes include `table_style_invalid`, `list_object_add_failed`, and `com_api_missing`.
192
+ - `exstruct_patch` response includes `engine` (`com` or `openpyxl`) to show the actual backend used. `restore_design_snapshot` remains openpyxl-only.
186
193
  - Use `exstruct_make` for new workbook creation and `exstruct_patch` for editing existing workbooks.
187
194
  - `exstruct_make` creates a new workbook and applies `ops` in one call (`out_path` required, `ops` optional).
188
195
  - Supports `.xlsx` / `.xlsm` / `.xls`
@@ -93,6 +93,7 @@ exstruct-mcp --root C:\data --log-file C:\logs\exstruct-mcp.log --on-conflict re
93
93
  Available tools:
94
94
 
95
95
  - `exstruct_extract`
96
+ - `exstruct_capture_sheet_images`
96
97
  - `exstruct_make`
97
98
  - `exstruct_patch`
98
99
  - `exstruct_read_json_chunk`
@@ -104,6 +105,12 @@ Available tools:
104
105
  Notes:
105
106
 
106
107
  - In MCP, `exstruct_extract` defaults to `options.alpha_col=true` (column keys: `A`, `B`, ...). Set `options.alpha_col=false` for legacy 0-based numeric string keys.
108
+ - `exstruct_capture_sheet_images` is COM-only (Experimental) and supports optional `sheet` / `range` targeting (`A1:B2`, `Sheet1!A1:B2`, `'Sheet 1'!A1:B2`). When `out_dir` is omitted, it creates a unique `<workbook_stem>_images` directory under MCP `--root`.
109
+ - MCP server startup sets `EXSTRUCT_RENDER_SUBPROCESS=1` by default (`setdefault`). If you prefer in-process rendering, set `EXSTRUCT_RENDER_SUBPROCESS=0` before launching the server.
110
+ - Timeout tuning for `exstruct_capture_sheet_images`: `EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC` (tool timeout), `EXSTRUCT_RENDER_SUBPROCESS_STARTUP_TIMEOUT_SEC` (worker startup), `EXSTRUCT_RENDER_SUBPROCESS_JOIN_TIMEOUT_SEC` (primary wait budget), `EXSTRUCT_RENDER_SUBPROCESS_RESULT_TIMEOUT_SEC` (post-exit grace).
111
+ - Subprocess errors are stage-aware (`stage=startup|join|result|worker`) so MCP clients can distinguish bootstrap failures vs timeout vs worker-side rendering errors.
112
+ - Trade-off of `EXSTRUCT_RENDER_SUBPROCESS=1`: subprocess startup/coordination overhead and dependency on worker-side module resolution.
113
+ - Trade-off of `EXSTRUCT_RENDER_SUBPROCESS=0`: less crash isolation and higher memory pressure risk in long-running processes.
107
114
  - Logs go to stderr (and optionally `--log-file`) to avoid contaminating stdio responses.
108
115
  - On Windows with Excel, standard/verbose can use COM for richer extraction. On non-Windows, COM is unavailable and extraction uses openpyxl-based fallbacks.
109
116
  - `exstruct_patch` supports `backend` selection:
@@ -111,13 +118,13 @@ Notes:
111
118
  - `com`: force COM (rejects `dry_run` / `return_inverse_ops` / `preflight_formula_check`)
112
119
  - `openpyxl`: force openpyxl (`.xls` is not supported)
113
120
  - `create_chart` is COM-only (`backend="openpyxl"` is not allowed for requests that include it), and it also rejects `dry_run` / `return_inverse_ops` / `preflight_formula_check`.
114
- - `create_chart` `chart_type` supports: `line`, `column`, `bar`, `area`, `pie`, `doughnut`, `scatter`, `radar` (aliases: `column_clustered`, `bar_clustered`, `xy_scatter`, `donut`).
115
- - `create_chart` `data_range` accepts either one range string or `list[str]` (multi-series), and `data_range` / `category_range` both support sheet-qualified ranges (`Sheet2!A1:B10`, `'Sales Data'!A1:B10`).
116
- - `create_chart` supports optional explicit labels: `chart_title`, `x_axis_title`, `y_axis_title`.
117
- - `create_chart` and `apply_table_style` can be combined in one request when the backend resolves to COM (`backend="com"` or `backend="auto"` with COM available).
118
- - For stable `apply_table_style` COM runs on Windows, ensure desktop Excel is installed/runnable and the target `range` is a contiguous header-included A1 range.
119
- - `exstruct_patch` error detail may include `error_code` / `failed_field` / `raw_com_message`; table-related codes include `table_style_invalid`, `list_object_add_failed`, and `com_api_missing`.
120
- - `exstruct_patch` response includes `engine` (`com` or `openpyxl`) to show the actual backend used. `restore_design_snapshot` remains openpyxl-only.
121
+ - `create_chart` `chart_type` supports: `line`, `column`, `bar`, `area`, `pie`, `doughnut`, `scatter`, `radar` (aliases: `column_clustered`, `bar_clustered`, `xy_scatter`, `donut`).
122
+ - `create_chart` `data_range` accepts either one range string or `list[str]` (multi-series), and `data_range` / `category_range` both support sheet-qualified ranges (`Sheet2!A1:B10`, `'Sales Data'!A1:B10`).
123
+ - `create_chart` supports optional explicit labels: `chart_title`, `x_axis_title`, `y_axis_title`.
124
+ - `create_chart` and `apply_table_style` can be combined in one request when the backend resolves to COM (`backend="com"` or `backend="auto"` with COM available).
125
+ - For stable `apply_table_style` COM runs on Windows, ensure desktop Excel is installed/runnable and the target `range` is a contiguous header-included A1 range.
126
+ - `exstruct_patch` error detail may include `error_code` / `failed_field` / `raw_com_message`; table-related codes include `table_style_invalid`, `list_object_add_failed`, and `com_api_missing`.
127
+ - `exstruct_patch` response includes `engine` (`com` or `openpyxl`) to show the actual backend used. `restore_design_snapshot` remains openpyxl-only.
121
128
  - Use `exstruct_make` for new workbook creation and `exstruct_patch` for editing existing workbooks.
122
129
  - `exstruct_make` creates a new workbook and applies `ops` in one call (`out_path` required, `ops` optional).
123
130
  - Supports `.xlsx` / `.xlsm` / `.xls`
@@ -1,7 +1,7 @@
1
- [project]
2
- name = "exstruct"
3
- version = "0.5.2"
4
- description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines"
1
+ [project]
2
+ name = "exstruct"
3
+ version = "0.5.3"
4
+ description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
7
7
  keywords = ["excel", "structure", "data", "exstruct"]
@@ -28,6 +28,11 @@ from .patch_runner import (
28
28
  run_make,
29
29
  run_patch,
30
30
  )
31
+ from .render_runner import (
32
+ CaptureSheetImagesRequest,
33
+ CaptureSheetImagesResult,
34
+ run_capture_sheet_images,
35
+ )
31
36
  from .sheet_reader import (
32
37
  CellReadItem,
33
38
  FormulaReadItem,
@@ -42,6 +47,8 @@ from .sheet_reader import (
42
47
  read_range,
43
48
  )
44
49
  from .tools import (
50
+ CaptureSheetImagesToolInput,
51
+ CaptureSheetImagesToolOutput,
45
52
  DescribeOpToolInput,
46
53
  DescribeOpToolOutput,
47
54
  ExtractToolInput,
@@ -61,6 +68,7 @@ from .tools import (
61
68
  ReadRangeToolOutput,
62
69
  ValidateInputToolInput,
63
70
  ValidateInputToolOutput,
71
+ run_capture_sheet_images_tool,
64
72
  run_describe_op_tool,
65
73
  run_extract_tool,
66
74
  run_list_ops_tool,
@@ -79,6 +87,10 @@ from .validate_input import (
79
87
  )
80
88
 
81
89
  __all__ = [
90
+ "CaptureSheetImagesRequest",
91
+ "CaptureSheetImagesResult",
92
+ "CaptureSheetImagesToolInput",
93
+ "CaptureSheetImagesToolOutput",
82
94
  "DescribeOpToolInput",
83
95
  "DescribeOpToolOutput",
84
96
  "ExtractRequest",
@@ -133,6 +145,8 @@ __all__ = [
133
145
  "run_make",
134
146
  "run_make_tool",
135
147
  "run_patch",
148
+ "run_capture_sheet_images",
149
+ "run_capture_sheet_images_tool",
136
150
  "run_patch_tool",
137
151
  "read_cells",
138
152
  "read_formulas",
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+
8
+ from exstruct import render
9
+
10
+ from .io import PathPolicy
11
+ from .shared.a1 import resolve_sheet_and_range
12
+ from .shared.output_path import resolve_image_output_dir
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CaptureSheetImagesRequest(BaseModel):
18
+ """Input model for MCP sheet image capture."""
19
+
20
+ xlsx_path: Path
21
+ out_dir: Path | None = None
22
+ dpi: int = Field(default=144, ge=1)
23
+ sheet: str | None = None
24
+ range: str | None = None # noqa: A003
25
+
26
+ @field_validator("sheet")
27
+ @classmethod
28
+ def _validate_sheet(cls, value: str | None) -> str | None:
29
+ if value is None:
30
+ return None
31
+ candidate = value.strip()
32
+ if not candidate:
33
+ raise ValueError("sheet must not be empty when provided.")
34
+ return candidate
35
+
36
+ @field_validator("range")
37
+ @classmethod
38
+ def _validate_range(cls, value: str | None) -> str | None:
39
+ if value is None:
40
+ return None
41
+ candidate = value.strip()
42
+ if not candidate:
43
+ raise ValueError("range must not be empty when provided.")
44
+ return candidate
45
+
46
+ @model_validator(mode="after")
47
+ def _validate_sheet_range_consistency(self) -> CaptureSheetImagesRequest:
48
+ selection = resolve_sheet_and_range(self.sheet, self.range)
49
+ self.sheet = selection.sheet
50
+ self.range = selection.range_ref
51
+ return self
52
+
53
+
54
+ class CaptureSheetImagesResult(BaseModel):
55
+ """Output model for MCP sheet image capture."""
56
+
57
+ out_dir: str
58
+ image_paths: list[str] = Field(default_factory=list)
59
+ warnings: list[str] = Field(default_factory=list)
60
+
61
+
62
+ def run_capture_sheet_images(
63
+ request: CaptureSheetImagesRequest,
64
+ *,
65
+ policy: PathPolicy | None = None,
66
+ ) -> CaptureSheetImagesResult:
67
+ """Capture worksheet images using Excel COM rendering pipeline.
68
+
69
+ Args:
70
+ request: Capture request payload.
71
+ policy: Optional path access policy.
72
+
73
+ Returns:
74
+ Capture result with resolved output directory and written images.
75
+ """
76
+ resolved_input = _resolve_input_path(request.xlsx_path, policy=policy)
77
+ resolved_out_dir = resolve_image_output_dir(
78
+ resolved_input,
79
+ out_dir=request.out_dir,
80
+ policy=policy,
81
+ )
82
+ _ensure_com_available()
83
+ resolved_out_dir.mkdir(parents=True, exist_ok=True)
84
+ written_paths = render.export_sheet_images(
85
+ resolved_input,
86
+ resolved_out_dir,
87
+ dpi=request.dpi,
88
+ sheet=request.sheet,
89
+ a1_range=request.range,
90
+ )
91
+ return CaptureSheetImagesResult(
92
+ out_dir=str(resolved_out_dir),
93
+ image_paths=[str(path) for path in written_paths],
94
+ warnings=[],
95
+ )
96
+
97
+
98
+ def _resolve_input_path(path: Path, *, policy: PathPolicy | None) -> Path:
99
+ """Resolve and validate input workbook path."""
100
+ resolved = policy.ensure_allowed(path) if policy else path.resolve()
101
+ if not resolved.exists():
102
+ raise FileNotFoundError(f"Input file not found: {resolved}")
103
+ if not resolved.is_file():
104
+ raise ValueError(f"Input path is not a file: {resolved}")
105
+ return resolved
106
+
107
+
108
+ def _ensure_com_available() -> None:
109
+ """Validate Excel COM availability and raise ValueError when unavailable."""
110
+ app: object | None = None
111
+ try:
112
+ app = render._require_excel_app()
113
+ except Exception as exc: # pragma: no cover - delegated by render internals
114
+ raise ValueError(
115
+ "Excel (COM) is not available. Rendering (PDF/image) requires a desktop Excel installation."
116
+ ) from exc
117
+ finally:
118
+ if app is not None:
119
+ quit_method = getattr(app, "quit", None)
120
+ if callable(quit_method):
121
+ try:
122
+ quit_method()
123
+ except Exception as exc: # pragma: no cover - defensive probe cleanup
124
+ logger.warning("Failed to close Excel app after COM probe: %s", exc)
@@ -4,6 +4,7 @@ import argparse
4
4
  import functools
5
5
  import importlib
6
6
  import logging
7
+ import math
7
8
  import os
8
9
  from pathlib import Path
9
10
  import sys
@@ -24,6 +25,8 @@ from .patch.normalize import (
24
25
  parse_patch_op_json as _normalize_parse_patch_op_json,
25
26
  )
26
27
  from .tools import (
28
+ CaptureSheetImagesToolInput,
29
+ CaptureSheetImagesToolOutput,
27
30
  DescribeOpToolInput,
28
31
  DescribeOpToolOutput,
29
32
  ExtractToolInput,
@@ -44,6 +47,7 @@ from .tools import (
44
47
  RuntimeInfoToolOutput,
45
48
  ValidateInputToolInput,
46
49
  ValidateInputToolOutput,
50
+ run_capture_sheet_images_tool,
47
51
  run_describe_op_tool,
48
52
  run_extract_tool,
49
53
  run_list_ops_tool,
@@ -60,6 +64,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only
60
64
  from mcp.server.fastmcp import FastMCP
61
65
 
62
66
  logger = logging.getLogger(__name__)
67
+ _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS = 120.0
63
68
 
64
69
 
65
70
  class ServerConfig(BaseModel):
@@ -105,10 +110,15 @@ def run_server(config: ServerConfig) -> None:
105
110
  config: Server configuration.
106
111
  """
107
112
  os.environ.setdefault("EXSTRUCT_BORDER_CLUSTER_BACKEND", "python")
113
+ os.environ.setdefault("EXSTRUCT_RENDER_SUBPROCESS", "1")
108
114
  logger.info(
109
115
  "Border cluster backend set to %s for MCP.",
110
116
  os.getenv("EXSTRUCT_BORDER_CLUSTER_BACKEND"),
111
117
  )
118
+ logger.info(
119
+ "Render subprocess mode for MCP set to %s.",
120
+ os.getenv("EXSTRUCT_RENDER_SUBPROCESS"),
121
+ )
112
122
  _import_mcp()
113
123
  policy = PathPolicy(root=config.root, deny_globs=config.deny_globs)
114
124
  logger.info("MCP root: %s", policy.normalize_root())
@@ -302,6 +312,8 @@ def _register_tools(
302
312
  tool = app.tool(name="exstruct_extract")
303
313
  tool(_extract_tool)
304
314
 
315
+ _register_capture_sheet_images_tool(app, policy=policy)
316
+
305
317
  async def _read_json_chunk_tool( # pylint: disable=redefined-builtin
306
318
  out_path: str,
307
319
  sheet: str | None = None,
@@ -722,6 +734,98 @@ Returns:
722
734
  return f"{base_description.strip()}\n\n{build_patch_tool_mini_schema()}"
723
735
 
724
736
 
737
+ def _register_capture_sheet_images_tool(app: FastMCP, *, policy: PathPolicy) -> None:
738
+ """Register the sheet image capture MCP tool."""
739
+
740
+ async def _capture_sheet_images_tool( # pylint: disable=redefined-builtin
741
+ xlsx_path: str,
742
+ out_dir: str | None = None,
743
+ dpi: int = 144,
744
+ sheet: str | None = None,
745
+ range: str | None = None, # noqa: A002
746
+ ) -> CaptureSheetImagesToolOutput:
747
+ """Export worksheet images as PNG files (COM only).
748
+
749
+ Args:
750
+ xlsx_path: Path to the Excel workbook.
751
+ out_dir: Optional output directory for PNG files. When omitted, MCP
752
+ creates a unique directory under server `--root` using
753
+ `<workbook_stem>_images` with numeric suffixes as needed.
754
+ dpi: Rendering DPI (must be >= 1).
755
+ sheet: Optional target sheet name. Required only when `range` is
756
+ unqualified (for example, `A1:B2`).
757
+ range: Optional A1 range (`A1:B2`) with optional sheet qualifier
758
+ (`Sheet1!A1:B2`, `'Sheet 1'!A1:B2`).
759
+
760
+ Returns:
761
+ Export result payload with resolved output directory and image paths.
762
+ """
763
+ payload = CaptureSheetImagesToolInput(
764
+ xlsx_path=xlsx_path,
765
+ out_dir=out_dir,
766
+ dpi=dpi,
767
+ sheet=sheet,
768
+ range=range,
769
+ )
770
+ work = functools.partial(
771
+ run_capture_sheet_images_tool,
772
+ payload,
773
+ policy=policy,
774
+ )
775
+ timeout_seconds = _get_capture_sheet_images_timeout_seconds()
776
+ try:
777
+ with anyio.fail_after(timeout_seconds):
778
+ result = cast(
779
+ CaptureSheetImagesToolOutput,
780
+ await anyio.to_thread.run_sync(work, abandon_on_cancel=True),
781
+ )
782
+ except TimeoutError as exc:
783
+ raise TimeoutError(
784
+ "capture_sheet_images timed out after "
785
+ f"{timeout_seconds:.1f}s. "
786
+ "Adjust workbook/range size or increase "
787
+ "EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC."
788
+ ) from exc
789
+ return result
790
+
791
+ capture_sheet_images_tool = app.tool(name="exstruct_capture_sheet_images")
792
+ capture_sheet_images_tool(_capture_sheet_images_tool)
793
+
794
+
795
+ def _get_capture_sheet_images_timeout_seconds() -> float:
796
+ """Return capture timeout seconds from environment."""
797
+ raw_value = os.getenv("EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC")
798
+ if raw_value is None:
799
+ return _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS
800
+ try:
801
+ parsed = float(raw_value)
802
+ except ValueError:
803
+ logger.warning(
804
+ "Invalid EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC=%r. "
805
+ "Falling back to %.1fs.",
806
+ raw_value,
807
+ _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS,
808
+ )
809
+ return _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS
810
+ if not math.isfinite(parsed):
811
+ logger.warning(
812
+ "Non-finite EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC=%r. "
813
+ "Falling back to %.1fs.",
814
+ raw_value,
815
+ _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS,
816
+ )
817
+ return _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS
818
+ if parsed <= 0:
819
+ logger.warning(
820
+ "Non-positive EXSTRUCT_MCP_CAPTURE_SHEET_IMAGES_TIMEOUT_SEC=%r. "
821
+ "Falling back to %.1fs.",
822
+ raw_value,
823
+ _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS,
824
+ )
825
+ return _DEFAULT_CAPTURE_SHEET_IMAGES_TIMEOUT_SECONDS
826
+ return parsed
827
+
828
+
725
829
  def _register_op_schema_tools(app: FastMCP) -> None:
726
830
  """Register schema discovery tools."""
727
831
 
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from .a1 import (
4
+ QualifiedA1Range,
5
+ SheetRangeSelection,
6
+ column_index_to_label,
7
+ column_label_to_index,
8
+ normalize_range,
9
+ parse_qualified_a1_range,
10
+ parse_range_geometry,
11
+ range_cell_count,
12
+ resolve_sheet_and_range,
13
+ split_a1,
14
+ )
15
+ from .output_path import (
16
+ apply_conflict_policy,
17
+ next_available_directory,
18
+ next_available_path,
19
+ resolve_image_output_dir,
20
+ resolve_output_path,
21
+ )
22
+
23
+ __all__ = [
24
+ "QualifiedA1Range",
25
+ "SheetRangeSelection",
26
+ "apply_conflict_policy",
27
+ "column_index_to_label",
28
+ "column_label_to_index",
29
+ "next_available_path",
30
+ "next_available_directory",
31
+ "normalize_range",
32
+ "parse_qualified_a1_range",
33
+ "parse_range_geometry",
34
+ "range_cell_count",
35
+ "resolve_image_output_dir",
36
+ "resolve_sheet_and_range",
37
+ "resolve_output_path",
38
+ "split_a1",
39
+ ]