exstruct 0.3.5__tar.gz → 0.3.6__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.
- {exstruct-0.3.5 → exstruct-0.3.6}/PKG-INFO +1 -1
- {exstruct-0.3.5 → exstruct-0.3.6}/pyproject.toml +1 -1
- exstruct-0.3.6/src/exstruct/render/__init__.py +239 -0
- exstruct-0.3.5/src/exstruct/render/__init__.py +0 -114
- {exstruct-0.3.5 → exstruct-0.3.6}/LICENSE +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/README.md +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/__init__.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/cli/availability.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/cli/main.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/__init__.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/backends/__init__.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/backends/base.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/backends/com_backend.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/backends/openpyxl_backend.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/cells.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/charts.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/integrate.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/logging_utils.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/modeling.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/pipeline.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/ranges.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/shapes.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/core/workbook.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/engine.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/errors.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/io/__init__.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/io/serialize.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/models/__init__.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/models/maps.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/models/types.py +0 -0
- {exstruct-0.3.5 → exstruct-0.3.6}/src/exstruct/py.typed +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import multiprocessing as mp
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import xlwings as xw
|
|
13
|
+
|
|
14
|
+
from ..errors import MissingDependencyError, RenderError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_excel_app() -> xw.App:
|
|
20
|
+
"""Ensure Excel COM is available and return an App; otherwise raise."""
|
|
21
|
+
try:
|
|
22
|
+
app = xw.App(add_book=False, visible=False)
|
|
23
|
+
return app
|
|
24
|
+
except Exception as e:
|
|
25
|
+
raise RenderError(
|
|
26
|
+
"Excel (COM) is not available. Rendering (PDF/image) requires a desktop Excel installation."
|
|
27
|
+
) from e
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def export_pdf(excel_path: str | Path, output_pdf: str | Path) -> list[str]:
|
|
31
|
+
"""Export an Excel workbook to PDF via Excel COM and return sheet names in order."""
|
|
32
|
+
normalized_excel_path = Path(excel_path)
|
|
33
|
+
normalized_output_pdf = Path(output_pdf)
|
|
34
|
+
normalized_output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
with tempfile.TemporaryDirectory() as td:
|
|
37
|
+
temp_dir = Path(td)
|
|
38
|
+
temp_xlsx = temp_dir / "book.xlsx"
|
|
39
|
+
temp_pdf = temp_dir / "book.pdf"
|
|
40
|
+
|
|
41
|
+
app: xw.App | None = None
|
|
42
|
+
wb: xw.Book | None = None
|
|
43
|
+
try:
|
|
44
|
+
app = _require_excel_app()
|
|
45
|
+
app.display_alerts = False
|
|
46
|
+
wb = app.books.open(str(normalized_excel_path))
|
|
47
|
+
sheet_names = [s.name for s in wb.sheets]
|
|
48
|
+
wb.api.SaveAs(str(temp_xlsx))
|
|
49
|
+
wb.api.ExportAsFixedFormat(0, str(temp_pdf))
|
|
50
|
+
shutil.copy(temp_pdf, normalized_output_pdf)
|
|
51
|
+
except RenderError:
|
|
52
|
+
raise
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
raise RenderError(
|
|
55
|
+
"Failed to export PDF for "
|
|
56
|
+
f"'{normalized_excel_path}' to '{normalized_output_pdf}'."
|
|
57
|
+
) from exc
|
|
58
|
+
finally:
|
|
59
|
+
if wb is not None:
|
|
60
|
+
wb.close()
|
|
61
|
+
if app is not None:
|
|
62
|
+
app.quit()
|
|
63
|
+
if not normalized_output_pdf.exists():
|
|
64
|
+
raise RenderError(f"Failed to export PDF to '{normalized_output_pdf}'.")
|
|
65
|
+
return sheet_names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _require_pdfium() -> ModuleType:
|
|
69
|
+
"""Ensure pypdfium2 is installed; otherwise raise with guidance."""
|
|
70
|
+
try:
|
|
71
|
+
import pypdfium2 as pdfium
|
|
72
|
+
except ImportError as e:
|
|
73
|
+
raise MissingDependencyError(
|
|
74
|
+
"Image rendering requires pypdfium2. Install it via `pip install pypdfium2 pillow` or add the 'render' extra."
|
|
75
|
+
) from e
|
|
76
|
+
return cast(ModuleType, pdfium)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def export_sheet_images(
|
|
80
|
+
excel_path: str | Path, output_dir: str | Path, dpi: int = 144
|
|
81
|
+
) -> list[Path]:
|
|
82
|
+
"""Export each sheet as PNG (via PDF then pypdfium2 rasterization) and return paths in sheet order."""
|
|
83
|
+
normalized_excel_path = Path(excel_path)
|
|
84
|
+
normalized_output_dir = Path(output_dir)
|
|
85
|
+
normalized_output_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
use_subprocess = _use_render_subprocess()
|
|
87
|
+
if not use_subprocess:
|
|
88
|
+
pdfium = cast(Any, _require_pdfium())
|
|
89
|
+
else:
|
|
90
|
+
_require_pdfium()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
with tempfile.TemporaryDirectory() as td:
|
|
94
|
+
written: list[Path] = []
|
|
95
|
+
app: xw.App | None = None
|
|
96
|
+
wb: xw.Book | None = None
|
|
97
|
+
try:
|
|
98
|
+
app = _require_excel_app()
|
|
99
|
+
wb = app.books.open(str(normalized_excel_path))
|
|
100
|
+
for sheet_index, sheet in enumerate(wb.sheets):
|
|
101
|
+
sheet_name = sheet.name
|
|
102
|
+
sheet_pdf = Path(td) / f"sheet_{sheet_index + 1:02d}.pdf"
|
|
103
|
+
sheet.api.ExportAsFixedFormat(0, str(sheet_pdf))
|
|
104
|
+
safe_name = _sanitize_sheet_filename(sheet_name)
|
|
105
|
+
if use_subprocess:
|
|
106
|
+
written.extend(
|
|
107
|
+
_render_pdf_pages_subprocess(
|
|
108
|
+
sheet_pdf,
|
|
109
|
+
normalized_output_dir,
|
|
110
|
+
sheet_index,
|
|
111
|
+
safe_name,
|
|
112
|
+
dpi,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
written.extend(
|
|
117
|
+
_render_pdf_pages_in_process(
|
|
118
|
+
pdfium,
|
|
119
|
+
sheet_pdf,
|
|
120
|
+
normalized_output_dir,
|
|
121
|
+
sheet_index,
|
|
122
|
+
safe_name,
|
|
123
|
+
dpi,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return written
|
|
127
|
+
finally:
|
|
128
|
+
if wb is not None:
|
|
129
|
+
wb.close()
|
|
130
|
+
if app is not None:
|
|
131
|
+
app.quit()
|
|
132
|
+
except RenderError:
|
|
133
|
+
raise
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
raise RenderError(
|
|
136
|
+
f"Failed to export sheet images to '{normalized_output_dir}'."
|
|
137
|
+
) from exc
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sanitize_sheet_filename(name: str) -> str:
|
|
141
|
+
return "".join("_" if c in '\\/:*?"<>|' else c for c in name).strip() or "sheet"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _use_render_subprocess() -> bool:
|
|
145
|
+
"""Return True when PDF->PNG rendering should run in a subprocess."""
|
|
146
|
+
return os.getenv("EXSTRUCT_RENDER_SUBPROCESS", "1").lower() not in {"0", "false"}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _render_pdf_pages_in_process(
|
|
150
|
+
pdfium: ModuleType,
|
|
151
|
+
pdf_path: Path,
|
|
152
|
+
output_dir: Path,
|
|
153
|
+
sheet_index: int,
|
|
154
|
+
safe_name: str,
|
|
155
|
+
dpi: int,
|
|
156
|
+
) -> list[Path]:
|
|
157
|
+
"""Render PDF pages to PNGs in the current process."""
|
|
158
|
+
scale = dpi / 72.0
|
|
159
|
+
written: list[Path] = []
|
|
160
|
+
with pdfium.PdfDocument(str(pdf_path)) as pdf:
|
|
161
|
+
for page_index in range(len(pdf)):
|
|
162
|
+
page = pdf[page_index]
|
|
163
|
+
bitmap = page.render(scale=scale)
|
|
164
|
+
pil_image = bitmap.to_pil()
|
|
165
|
+
page_suffix = f"_p{page_index + 1:02d}" if page_index > 0 else ""
|
|
166
|
+
img_path = (
|
|
167
|
+
output_dir / f"{sheet_index + 1:02d}_{safe_name}{page_suffix}.png"
|
|
168
|
+
)
|
|
169
|
+
pil_image.save(img_path, format="PNG", dpi=(dpi, dpi))
|
|
170
|
+
written.append(img_path)
|
|
171
|
+
return written
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _render_pdf_pages_subprocess(
|
|
175
|
+
pdf_path: Path,
|
|
176
|
+
output_dir: Path,
|
|
177
|
+
sheet_index: int,
|
|
178
|
+
safe_name: str,
|
|
179
|
+
dpi: int,
|
|
180
|
+
) -> list[Path]:
|
|
181
|
+
"""Render PDF pages to PNGs in a subprocess for memory isolation."""
|
|
182
|
+
ctx = mp.get_context("spawn")
|
|
183
|
+
queue: mp.Queue[dict[str, list[str] | str]] = ctx.Queue()
|
|
184
|
+
process = ctx.Process(
|
|
185
|
+
target=_render_pdf_pages_worker,
|
|
186
|
+
args=(pdf_path, output_dir, sheet_index, safe_name, dpi, queue),
|
|
187
|
+
)
|
|
188
|
+
process.start()
|
|
189
|
+
process.join()
|
|
190
|
+
result = _get_subprocess_result(queue)
|
|
191
|
+
if process.exitcode != 0 or "error" in result:
|
|
192
|
+
message = result.get("error", "subprocess failed")
|
|
193
|
+
raise RenderError(f"Failed to render PDF pages: {message}")
|
|
194
|
+
paths = result.get("paths", [])
|
|
195
|
+
return [Path(path) for path in paths]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _get_subprocess_result(
|
|
199
|
+
queue: mp.Queue[dict[str, list[str] | str]],
|
|
200
|
+
) -> dict[str, list[str] | str]:
|
|
201
|
+
"""Fetch the worker result from the queue with a timeout."""
|
|
202
|
+
try:
|
|
203
|
+
return queue.get(timeout=5)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
return {"error": f"subprocess did not return results ({exc})"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _render_pdf_pages_worker(
|
|
209
|
+
pdf_path: Path,
|
|
210
|
+
output_dir: Path,
|
|
211
|
+
sheet_index: int,
|
|
212
|
+
safe_name: str,
|
|
213
|
+
dpi: int,
|
|
214
|
+
queue: mp.Queue[dict[str, list[str] | str]],
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Worker process to render PDF pages into PNG files."""
|
|
217
|
+
try:
|
|
218
|
+
import pypdfium2 as pdfium
|
|
219
|
+
|
|
220
|
+
scale = dpi / 72.0
|
|
221
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
written: list[str] = []
|
|
223
|
+
with pdfium.PdfDocument(str(pdf_path)) as pdf:
|
|
224
|
+
for page_index in range(len(pdf)):
|
|
225
|
+
page = pdf[page_index]
|
|
226
|
+
bitmap = page.render(scale=scale)
|
|
227
|
+
pil_image = bitmap.to_pil()
|
|
228
|
+
page_suffix = f"_p{page_index + 1:02d}" if page_index > 0 else ""
|
|
229
|
+
img_path = (
|
|
230
|
+
output_dir / f"{sheet_index + 1:02d}_{safe_name}{page_suffix}.png"
|
|
231
|
+
)
|
|
232
|
+
pil_image.save(img_path, format="PNG", dpi=(dpi, dpi))
|
|
233
|
+
written.append(str(img_path))
|
|
234
|
+
queue.put({"paths": written})
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
queue.put({"error": str(exc)})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = ["export_pdf", "export_sheet_images"]
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
import shutil
|
|
6
|
-
import tempfile
|
|
7
|
-
from types import ModuleType
|
|
8
|
-
from typing import Any, cast
|
|
9
|
-
|
|
10
|
-
import xlwings as xw
|
|
11
|
-
|
|
12
|
-
from ..errors import MissingDependencyError, RenderError
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _require_excel_app() -> xw.App:
|
|
18
|
-
"""Ensure Excel COM is available and return an App; otherwise raise."""
|
|
19
|
-
try:
|
|
20
|
-
app = xw.App(add_book=False, visible=False)
|
|
21
|
-
return app
|
|
22
|
-
except Exception as e:
|
|
23
|
-
raise RenderError(
|
|
24
|
-
"Excel (COM) is not available. Rendering (PDF/image) requires a desktop Excel installation."
|
|
25
|
-
) from e
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def export_pdf(excel_path: str | Path, output_pdf: str | Path) -> list[str]:
|
|
29
|
-
"""Export an Excel workbook to PDF via Excel COM and return sheet names in order."""
|
|
30
|
-
normalized_excel_path = Path(excel_path)
|
|
31
|
-
normalized_output_pdf = Path(output_pdf)
|
|
32
|
-
normalized_output_pdf.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
-
|
|
34
|
-
with tempfile.TemporaryDirectory() as td:
|
|
35
|
-
temp_dir = Path(td)
|
|
36
|
-
temp_xlsx = temp_dir / "book.xlsx"
|
|
37
|
-
temp_pdf = temp_dir / "book.pdf"
|
|
38
|
-
shutil.copy(normalized_excel_path, temp_xlsx)
|
|
39
|
-
|
|
40
|
-
app: xw.App | None = None
|
|
41
|
-
wb: xw.Book | None = None
|
|
42
|
-
try:
|
|
43
|
-
app = _require_excel_app()
|
|
44
|
-
wb = app.books.open(str(temp_xlsx))
|
|
45
|
-
sheet_names = [s.name for s in wb.sheets]
|
|
46
|
-
wb.api.ExportAsFixedFormat(0, str(temp_pdf))
|
|
47
|
-
shutil.copy(temp_pdf, normalized_output_pdf)
|
|
48
|
-
except RenderError:
|
|
49
|
-
raise
|
|
50
|
-
except Exception as exc:
|
|
51
|
-
raise RenderError(
|
|
52
|
-
"Failed to export PDF for "
|
|
53
|
-
f"'{normalized_excel_path}' to '{normalized_output_pdf}'."
|
|
54
|
-
) from exc
|
|
55
|
-
finally:
|
|
56
|
-
if wb is not None:
|
|
57
|
-
wb.close()
|
|
58
|
-
if app is not None:
|
|
59
|
-
app.quit()
|
|
60
|
-
if not normalized_output_pdf.exists():
|
|
61
|
-
raise RenderError(f"Failed to export PDF to '{normalized_output_pdf}'.")
|
|
62
|
-
return sheet_names
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _require_pdfium() -> ModuleType:
|
|
66
|
-
"""Ensure pypdfium2 is installed; otherwise raise with guidance."""
|
|
67
|
-
try:
|
|
68
|
-
import pypdfium2 as pdfium
|
|
69
|
-
except ImportError as e:
|
|
70
|
-
raise MissingDependencyError(
|
|
71
|
-
"Image rendering requires pypdfium2. Install it via `pip install pypdfium2 pillow` or add the 'render' extra."
|
|
72
|
-
) from e
|
|
73
|
-
return cast(ModuleType, pdfium)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def export_sheet_images(
|
|
77
|
-
excel_path: str | Path, output_dir: str | Path, dpi: int = 144
|
|
78
|
-
) -> list[Path]:
|
|
79
|
-
"""Export each sheet as PNG (via PDF then pypdfium2 rasterization) and return paths in sheet order."""
|
|
80
|
-
pdfium = cast(Any, _require_pdfium())
|
|
81
|
-
normalized_excel_path = Path(excel_path)
|
|
82
|
-
normalized_output_dir = Path(output_dir)
|
|
83
|
-
normalized_output_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
-
|
|
85
|
-
try:
|
|
86
|
-
with tempfile.TemporaryDirectory() as td:
|
|
87
|
-
tmp_pdf = Path(td) / "book.pdf"
|
|
88
|
-
sheet_names = export_pdf(normalized_excel_path, tmp_pdf)
|
|
89
|
-
|
|
90
|
-
scale = dpi / 72.0
|
|
91
|
-
written: list[Path] = []
|
|
92
|
-
with pdfium.PdfDocument(str(tmp_pdf)) as pdf:
|
|
93
|
-
for i, sheet_name in enumerate(sheet_names):
|
|
94
|
-
page = pdf[i]
|
|
95
|
-
bitmap = page.render(scale=scale)
|
|
96
|
-
pil_image = bitmap.to_pil()
|
|
97
|
-
safe_name = _sanitize_sheet_filename(sheet_name)
|
|
98
|
-
img_path = normalized_output_dir / f"{i + 1:02d}_{safe_name}.png"
|
|
99
|
-
pil_image.save(img_path, format="PNG", dpi=(dpi, dpi))
|
|
100
|
-
written.append(img_path)
|
|
101
|
-
return written
|
|
102
|
-
except RenderError:
|
|
103
|
-
raise
|
|
104
|
-
except Exception as exc:
|
|
105
|
-
raise RenderError(
|
|
106
|
-
f"Failed to export sheet images to '{normalized_output_dir}'."
|
|
107
|
-
) from exc
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _sanitize_sheet_filename(name: str) -> str:
|
|
111
|
-
return "".join("_" if c in '\\/:*?"<>|' else c for c in name).strip() or "sheet"
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
__all__ = ["export_pdf", "export_sheet_images"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|