morphconv 1.0.1__py3-none-any.whl

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.
@@ -0,0 +1,252 @@
1
+ """
2
+ converters/documents.py — document family, powered by pandoc.
3
+
4
+ Pandoc already handles most-to-most conversion natively in a single command
5
+ (pandoc understands both the source and target format directly), so instead
6
+ of writing one function per format pair we generate every valid (src, dst)
7
+ edge from one shared FORMATS table and one shared _pandoc_convert() function.
8
+
9
+ PDF is output-only: pandoc can't reliably *read* PDFs back into structured
10
+ text, so "pdf" only ever appears as a destination here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from ..registry import ConversionResult, OptionSpec, register
21
+
22
+ # format id -> (pandoc reader name or None if input unsupported, pandoc writer name, default extension)
23
+ FORMATS: dict[str, dict] = {
24
+ "md": {"read": "markdown", "write": "markdown", "ext": "md"},
25
+ "html": {"read": "html", "write": "html", "ext": "html"},
26
+ "docx": {"read": "docx", "write": "docx", "ext": "docx"},
27
+ "odt": {"read": "odt", "write": "odt", "ext": "odt"},
28
+ "rtf": {"read": "rtf", "write": "rtf", "ext": "rtf"},
29
+ "epub": {"read": "epub", "write": "epub", "ext": "epub"},
30
+ "latex": {"read": "latex", "write": "latex", "ext": "tex"},
31
+ "rst": {"read": "rst", "write": "rst", "ext": "rst"},
32
+ "txt": {"read": "markdown", "write": "plain", "ext": "txt"},
33
+ "pdf": {"read": None, "write": "pdf", "ext": "pdf"},
34
+ "ipynb": {"read": "ipynb", "write": "ipynb", "ext": "ipynb"},
35
+ "pptx": {"read": "pptx", "write": "pptx", "ext": "pptx"},
36
+ "adoc": {"read": "asciidoc", "write": "asciidoc", "ext": "adoc"},
37
+ "org": {"read": "org", "write": "org", "ext": "org"},
38
+ "opml": {"read": "opml", "write": "opml", "ext": "opml"},
39
+ "man": {"read": "man", "write": "man", "ext": "man"},
40
+ }
41
+
42
+ # tried in order; first one found on PATH is used
43
+ _PDF_ENGINES = ["xelatex", "pdflatex", "wkhtmltopdf", "weasyprint", "tectonic"]
44
+
45
+
46
+ def _available_pdf_engines() -> list[str]:
47
+ """All PDF engines that are actually on PATH, in preference order.
48
+
49
+ Being on PATH doesn't guarantee an engine works (e.g. a LaTeX install
50
+ missing packages like lmodern.sty still puts xelatex on PATH but fails
51
+ at render time) — see _pandoc_convert's fallback loop for how that's
52
+ handled.
53
+ """
54
+ return [e for e in _PDF_ENGINES if shutil.which(e)]
55
+
56
+
57
+ def _run_pandoc(input_path: Path, output_path: Path, *, src_fmt: str, dst_fmt: str,
58
+ dst: str, engine: Optional[str], extra_args: Optional[list[str]]) -> subprocess.CompletedProcess:
59
+ cmd = ["pandoc", str(input_path), "-f", src_fmt, "-t", dst_fmt, "-o", str(output_path)]
60
+ if dst not in ("pdf", "txt"):
61
+ cmd.append("--standalone")
62
+ if engine:
63
+ cmd += ["--pdf-engine", engine]
64
+ if extra_args:
65
+ cmd += extra_args
66
+ output_path.parent.mkdir(parents=True, exist_ok=True)
67
+ return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
68
+
69
+
70
+ def _preprocess_svgs(input_path: Path, tmpdir: Path, src: str) -> Path:
71
+ if src not in ("md", "html", "txt"):
72
+ return input_path
73
+
74
+ try:
75
+ content = input_path.read_text(encoding="utf-8", errors="replace")
76
+ except Exception:
77
+ return input_path
78
+
79
+ import re
80
+ import hashlib
81
+ import urllib.request
82
+
83
+ try:
84
+ import cairosvg
85
+ # Trigger DLL loading immediately to verify if C libraries are functional
86
+ cairosvg.svg2pdf(bytestring=b"<svg></svg>")
87
+ has_cairo = True
88
+ except Exception:
89
+ has_cairo = False
90
+
91
+ converted_cache: dict[str, Path] = {}
92
+ modified = False
93
+
94
+ def get_converted_pdf(img_src: str) -> Optional[str]:
95
+ if not has_cairo:
96
+ return None
97
+
98
+ if img_src in converted_cache:
99
+ return converted_cache[img_src].as_posix()
100
+
101
+ # Generate a stable filename based on the hash of the image source
102
+ src_hash = hashlib.md5(img_src.encode("utf-8")).hexdigest()
103
+ pdf_path = tmpdir / f"svg_img_{src_hash}.pdf"
104
+
105
+ try:
106
+ is_url = img_src.startswith(("http://", "https://"))
107
+ if is_url:
108
+ req = urllib.request.Request(
109
+ img_src,
110
+ headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Antigravity/1.0'}
111
+ )
112
+ with urllib.request.urlopen(req, timeout=10) as response:
113
+ svg_bytes = response.read()
114
+ cairosvg.svg2pdf(bytestring=svg_bytes, write_to=str(pdf_path))
115
+ else:
116
+ # Local file: resolve relative to input_path's parent directory
117
+ local_path = (input_path.parent / img_src).resolve()
118
+ if not local_path.exists():
119
+ # Try directly as absolute or relative path
120
+ local_path = Path(img_src).resolve()
121
+ if local_path.exists():
122
+ cairosvg.svg2pdf(url=str(local_path), write_to=str(pdf_path))
123
+ else:
124
+ return None
125
+
126
+ if pdf_path.exists() and pdf_path.stat().st_size > 0:
127
+ converted_cache[img_src] = pdf_path
128
+ return pdf_path.as_posix()
129
+ except Exception:
130
+ pass
131
+
132
+ return None
133
+
134
+ # Replace markdown images: ![alt](img.svg)
135
+ def md_repl(match):
136
+ nonlocal modified
137
+ prefix, img_src, suffix = match.groups()
138
+ pdf_url = get_converted_pdf(img_src)
139
+ if pdf_url:
140
+ modified = True
141
+ return f"{prefix}{pdf_url}{suffix}"
142
+ else:
143
+ # Convert image inclusion to a standard hyperlink to avoid LaTeX error
144
+ modified = True
145
+ # prefix is like '![alt]('
146
+ link_prefix = prefix.lstrip("!")
147
+ return f"{link_prefix}{img_src}{suffix}"
148
+
149
+ md_pattern = re.compile(r'(!\[.*?\]\()([^)]*?\.svg(?:[?#][^)]*)?)(\))', re.IGNORECASE)
150
+ content = md_pattern.sub(md_repl, content)
151
+
152
+ # Replace HTML images: <img src="img.svg" ...>
153
+ def html_repl(match):
154
+ nonlocal modified
155
+ prefix, img_src, suffix = match.groups()
156
+ pdf_url = get_converted_pdf(img_src)
157
+ if pdf_url:
158
+ modified = True
159
+ return f"{prefix}{pdf_url}{suffix}"
160
+ else:
161
+ modified = True
162
+ return f'<a href="{img_src}">[SVG Image]</a>'
163
+
164
+ html_pattern = re.compile(r'(<img\s+[^>]*src=["\'])([^"\']+\.svg(?:[?#][^"\']*)?)(["\'])', re.IGNORECASE)
165
+ content = html_pattern.sub(html_repl, content)
166
+
167
+ if modified:
168
+ temp_input = tmpdir / f"preprocessed_{input_path.name}"
169
+ temp_input.write_text(content, encoding="utf-8")
170
+ return temp_input
171
+
172
+ return input_path
173
+
174
+
175
+ def _pandoc_convert(input_path: Path, output_path: Path, *, src: str, dst: str,
176
+ pdf_engine: Optional[str] = None, extra_args: Optional[list[str]] = None,
177
+ **_options) -> ConversionResult:
178
+ src_fmt = FORMATS[src]["read"]
179
+ dst_fmt = FORMATS[dst]["write"]
180
+
181
+ if dst != "pdf":
182
+ result = _run_pandoc(input_path, output_path, src_fmt=src_fmt, dst_fmt=dst_fmt,
183
+ dst=dst, engine=None, extra_args=extra_args)
184
+ if result.returncode != 0:
185
+ raise RuntimeError(f"pandoc failed:\n{result.stderr.strip()}")
186
+ return ConversionResult(output=output_path)
187
+
188
+ # PDF: being on PATH doesn't mean an engine actually works (e.g. a LaTeX
189
+ # install missing lmodern.sty still resolves xelatex on PATH but fails at
190
+ # render time), so if the user didn't force one, try every available
191
+ # engine in preference order and only give up once they've all failed.
192
+ candidates = [pdf_engine] if pdf_engine else _available_pdf_engines()
193
+ if not candidates:
194
+ raise RuntimeError(
195
+ "No PDF engine found. Install one of: xelatex, pdflatex, wkhtmltopdf, "
196
+ "weasyprint, tectonic (morph can install wkhtmltopdf for you)."
197
+ )
198
+
199
+ errors: list[str] = []
200
+ import tempfile
201
+ with tempfile.TemporaryDirectory() as pdftmp:
202
+ preprocessed_input = _preprocess_svgs(input_path, Path(pdftmp), src)
203
+ for engine in candidates:
204
+ result = _run_pandoc(preprocessed_input, output_path, src_fmt=src_fmt, dst_fmt=dst_fmt,
205
+ dst=dst, engine=engine, extra_args=extra_args)
206
+ if result.returncode == 0:
207
+ return ConversionResult(output=output_path, pages=_count_pdf_pages(output_path),
208
+ extra={"pdf_engine": engine})
209
+ errors.append(f" {engine}: {result.stderr.strip().splitlines()[-1] if result.stderr.strip() else 'failed'}")
210
+
211
+ raise RuntimeError(
212
+ f"All available PDF engines failed ({', '.join(candidates)}):\n" + "\n".join(errors)
213
+ )
214
+
215
+
216
+ def _count_pdf_pages(pdf_path: Path) -> Optional[int]:
217
+ try:
218
+ raw = pdf_path.read_bytes()
219
+ return raw.count(b"/Type/Page") or raw.count(b"/Type /Page") or None
220
+ except Exception:
221
+ return None
222
+
223
+
224
+ def _make_converter(src: str, dst: str):
225
+ def _convert(input_path: Path, output_path: Path, **options) -> ConversionResult:
226
+ return _pandoc_convert(input_path, output_path, src=src, dst=dst, **options)
227
+ _convert.__name__ = f"convert_{src}_to_{dst}"
228
+ return _convert
229
+
230
+
231
+ _PDF_OPTIONS = [
232
+ OptionSpec("pdf_engine", ("--pdf-engine",),
233
+ "Force a specific PDF engine (xelatex, pdflatex, wkhtmltopdf, weasyprint, tectonic). "
234
+ "Default: try each available one until one works."),
235
+ ]
236
+
237
+ # ── register every valid pandoc pair ────────────────────────────────────────
238
+ for _src, _src_info in FORMATS.items():
239
+ if _src_info["read"] is None:
240
+ continue # pdf: input not supported
241
+ for _dst, _dst_info in FORMATS.items():
242
+ if _dst == _src:
243
+ continue
244
+ register(
245
+ _src, _dst,
246
+ backend="pandoc",
247
+ requires_binary="pandoc",
248
+ family="document",
249
+ description=f"{_src} → {_dst} (pandoc)",
250
+ lossy=(_dst in ("txt", "pdf")), # plain text and pdf drop structure/aren't re-editable
251
+ options=_PDF_OPTIONS if _dst == "pdf" else [],
252
+ )(_make_converter(_src, _dst))
@@ -0,0 +1,36 @@
1
+ """
2
+ converters/ebooks.py — ebook family (epub/mobi/azw3), via calibre's
3
+ ebook-convert. epub -> pdf/docx/html/etc already exist through pandoc
4
+ (documents.py); this file fills in the ebook-reader-specific formats pandoc
5
+ doesn't understand, so mobi and azw3 join the graph and can reach everything
6
+ epub can reach, transitively, for free.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from ..registry import ConversionResult, register
15
+
16
+ FORMATS = ["epub", "mobi", "azw3"]
17
+
18
+
19
+ def _ebook_convert(input_path: Path, output_path: Path, **_options) -> ConversionResult:
20
+ output_path.parent.mkdir(parents=True, exist_ok=True)
21
+ cmd = ["ebook-convert", str(input_path), str(output_path)]
22
+ result = subprocess.run(cmd, capture_output=True, text=True)
23
+ if result.returncode != 0:
24
+ tail = "\n".join(result.stderr.strip().splitlines()[-8:] or result.stdout.strip().splitlines()[-8:])
25
+ raise RuntimeError(f"ebook-convert failed:\n{tail}")
26
+ return ConversionResult(output=output_path)
27
+
28
+
29
+ for _src in FORMATS:
30
+ for _dst in FORMATS:
31
+ if _dst == _src:
32
+ continue
33
+ register(
34
+ _src, _dst, backend="calibre", requires_binary="ebook-convert", family="ebook",
35
+ description=f"{_src} → {_dst} (calibre)",
36
+ )(_ebook_convert)
@@ -0,0 +1,72 @@
1
+ """
2
+ converters/fonts.py — font family, powered by fontTools.
3
+
4
+ ttf/otf are the "raw" outline formats; woff/woff2 are web-compression
5
+ wrappers around the same outline data. fontTools reads any of these and
6
+ re-saves with a different `flavor`, so one function covers every pair.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from fontTools.ttLib import TTFont
14
+
15
+ from ..registry import ConversionResult, register
16
+
17
+ # format id -> fontTools "flavor" (None means a bare sfnt: .ttf or .otf)
18
+ _FLAVORS: dict[str, str | None] = {"ttf": None, "otf": None, "woff": "woff", "woff2": "woff2"}
19
+ FORMATS = list(_FLAVORS.keys())
20
+
21
+
22
+ def _font_convert(input_path: Path, output_path: Path, dst: str, **_options) -> ConversionResult:
23
+ font = TTFont(input_path)
24
+
25
+ # The real constraint isn't "ttf vs otf" as a pair — it's outline format
26
+ # (glyf vs CFF/CFF2). woff/woff2 are just compression wrappers around
27
+ # whichever outline table the font already has, so a multi-hop route
28
+ # like ttf -> woff -> otf can silently smuggle a glyf-outline font into
29
+ # a .otf file without ever hitting a direct ttf->otf edge. Check the
30
+ # actual table contents at every hop that lands on a bare sfnt.
31
+ if dst == "ttf" and "glyf" not in font:
32
+ raise RuntimeError(
33
+ "Refusing to save as .ttf: this font has no 'glyf' outline table "
34
+ "(it's CFF-based, i.e. really an OTF). fontTools can't convert "
35
+ "CFF outlines to TrueType outlines by just relabeling the file."
36
+ )
37
+ if dst == "otf" and "glyf" in font and "CFF " not in font and "CFF2" not in font:
38
+ raise RuntimeError(
39
+ "Refusing to save as .otf: this font has a 'glyf' outline table "
40
+ "(it's TrueType-based, i.e. really a TTF). fontTools can't convert "
41
+ "TrueType outlines to CFF outlines by just relabeling the file."
42
+ )
43
+
44
+ font.flavor = _FLAVORS[dst]
45
+ output_path.parent.mkdir(parents=True, exist_ok=True)
46
+ font.save(str(output_path))
47
+ name_table = font.get("name")
48
+ family = None
49
+ if name_table is not None:
50
+ family = name_table.getDebugName(1)
51
+ return ConversionResult(output=output_path, extra={"family": family})
52
+
53
+
54
+ def _make_converter(dst: str):
55
+ def _convert(input_path: Path, output_path: Path, **options) -> ConversionResult:
56
+ return _font_convert(input_path, output_path, dst, **options)
57
+ return _convert
58
+
59
+
60
+ for _src in FORMATS:
61
+ for _dst in FORMATS:
62
+ if _dst == _src:
63
+ continue
64
+ # ttf <-> otf both being "no flavor" sfnt containers with genuinely
65
+ # different outline formats (glyf vs CFF) isn't a safe blind resave —
66
+ # fontTools won't convert glyph outlines between quadratic/cubic for you.
67
+ if {_src, _dst} == {"ttf", "otf"}:
68
+ continue
69
+ register(
70
+ _src, _dst, backend="fonttools", family="font",
71
+ description=f"{_src} → {_dst} (fontTools)",
72
+ )(_make_converter(_dst))
@@ -0,0 +1,119 @@
1
+ """
2
+ converters/images.py — raster image family, powered by Pillow.
3
+
4
+ Same pattern as documents.py: one shared FORMATS table + one shared convert
5
+ function, looped to register every (src, dst) pair. Pure Python, no external
6
+ binary required, so this domain works the moment `pip install morph` finishes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from PIL import Image
15
+ import pillow_heif
16
+ import pillow_avif
17
+
18
+ pillow_heif.register_heif_opener()
19
+
20
+ from ..registry import ConversionResult, OptionSpec, register
21
+
22
+ # format id -> Pillow format name
23
+ FORMATS: dict[str, str] = {
24
+ "png": "PNG", "jpg": "JPEG", "jpeg": "JPEG", "webp": "WEBP",
25
+ "bmp": "BMP", "gif": "GIF", "tiff": "TIFF", "ico": "ICO",
26
+ "heic": "HEIF", "avif": "AVIF", "pdf": "PDF", "icns": "ICNS",
27
+ }
28
+
29
+ # formats whose writer supports a quality setting
30
+ _QUALITY_FORMATS = {"JPEG", "WEBP", "HEIF", "AVIF"}
31
+
32
+ # formats that can't store an alpha channel — need RGB flattening first
33
+ _NO_ALPHA_FORMATS = {"JPEG", "BMP", "PDF"}
34
+
35
+
36
+ def _parse_resize(spec: str, size: tuple[int, int]) -> tuple[int, int]:
37
+ """'800x600' -> (800, 600); '800x' or 'x600' keeps the other dimension
38
+ proportional; a bare '50%' scales both dimensions."""
39
+ w, h = size
40
+ if spec.endswith("%"):
41
+ pct = float(spec[:-1]) / 100
42
+ return (max(1, round(w * pct)), max(1, round(h * pct)))
43
+ parts = spec.lower().split("x")
44
+ if len(parts) != 2:
45
+ raise ValueError(f"Invalid --resize value: {spec!r} (expected WIDTHxHEIGHT, e.g. 800x600)")
46
+ new_w = int(parts[0]) if parts[0] else None
47
+ new_h = int(parts[1]) if parts[1] else None
48
+ if new_w and not new_h:
49
+ new_h = round(h * (new_w / w))
50
+ elif new_h and not new_w:
51
+ new_w = round(w * (new_h / h))
52
+ if not new_w or not new_h:
53
+ raise ValueError(f"Invalid --resize value: {spec!r}")
54
+ return (new_w, new_h)
55
+
56
+
57
+ def _image_convert(input_path: Path, output_path: Path, *, dst_pil: str,
58
+ quality: Optional[int] = None, resize: Optional[str] = None,
59
+ **_options) -> ConversionResult:
60
+ img = Image.open(input_path)
61
+
62
+ if resize:
63
+ img = img.resize(_parse_resize(resize, img.size))
64
+
65
+ if dst_pil in _NO_ALPHA_FORMATS and img.mode in ("RGBA", "P", "LA"):
66
+ background = Image.new("RGB", img.size, (255, 255, 255))
67
+ rgba = img.convert("RGBA")
68
+ background.paste(rgba, mask=rgba.split()[-1])
69
+ img = background
70
+ elif dst_pil == "ICO":
71
+ img = img.convert("RGBA")
72
+
73
+ save_kwargs: dict = {}
74
+ if quality is not None and dst_pil in _QUALITY_FORMATS:
75
+ save_kwargs["quality"] = quality
76
+ if dst_pil == "ICO":
77
+ save_kwargs["sizes"] = [(s, s) for s in (16, 32, 48, 64, 128, 256) if s <= max(img.size)] or [(256, 256)]
78
+
79
+ output_path.parent.mkdir(parents=True, exist_ok=True)
80
+ img.save(output_path, format=dst_pil, **save_kwargs)
81
+ return ConversionResult(output=output_path, extra={"size": img.size})
82
+
83
+
84
+ def _make_converter(dst_pil: str):
85
+ def _convert(input_path: Path, output_path: Path, **options) -> ConversionResult:
86
+ return _image_convert(input_path, output_path, dst_pil=dst_pil, **options)
87
+ return _convert
88
+
89
+
90
+ _OPTIONS_BY_DST = {
91
+ "jpg": [
92
+ OptionSpec("quality", ("--quality",), "Output quality 1-100 (JPEG). Default: Pillow's default (~75).", type=int),
93
+ OptionSpec("resize", ("--resize",), "Resize before saving: '800x600', '800x' (keep ratio), or '50%'."),
94
+ ],
95
+ "jpeg": [
96
+ OptionSpec("quality", ("--quality",), "Output quality 1-100 (JPEG). Default: Pillow's default (~75).", type=int),
97
+ OptionSpec("resize", ("--resize",), "Resize before saving: '800x600', '800x' (keep ratio), or '50%'."),
98
+ ],
99
+ "webp": [
100
+ OptionSpec("quality", ("--quality",), "Output quality 1-100 (WEBP). Default: 80.", type=int, default=80),
101
+ OptionSpec("resize", ("--resize",), "Resize before saving: '800x600', '800x' (keep ratio), or '50%'."),
102
+ ],
103
+ }
104
+ _DEFAULT_OPTIONS = [
105
+ OptionSpec("resize", ("--resize",), "Resize before saving: '800x600', '800x' (keep ratio), or '50%'."),
106
+ ]
107
+
108
+ for _src in FORMATS:
109
+ for _dst, _dst_pil in FORMATS.items():
110
+ if _dst == _src:
111
+ continue
112
+ register(
113
+ _src, _dst,
114
+ backend="pillow",
115
+ family="image",
116
+ description=f"{_src} → {_dst} (Pillow)",
117
+ lossy=(_dst_pil in _QUALITY_FORMATS or _dst == "ico"),
118
+ options=_OPTIONS_BY_DST.get(_dst, _DEFAULT_OPTIONS),
119
+ )(_make_converter(_dst_pil))
@@ -0,0 +1,26 @@
1
+ """converters/json_yaml.py — json <-> yaml, native implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from ..registry import ConversionResult, register
11
+
12
+
13
+ @register("json", "yaml", backend="native", family="data", description="json → yaml")
14
+ def json_to_yaml(input_path: Path, output_path: Path, **options) -> ConversionResult:
15
+ data = json.loads(input_path.read_text(encoding="utf-8"))
16
+ output_path.parent.mkdir(parents=True, exist_ok=True)
17
+ output_path.write_text(yaml.dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
18
+ return ConversionResult(output=output_path)
19
+
20
+
21
+ @register("yaml", "json", backend="native", family="data", description="yaml → json")
22
+ def yaml_to_json(input_path: Path, output_path: Path, **options) -> ConversionResult:
23
+ data = yaml.safe_load(input_path.read_text(encoding="utf-8"))
24
+ output_path.parent.mkdir(parents=True, exist_ok=True)
25
+ output_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
26
+ return ConversionResult(output=output_path)
@@ -0,0 +1,149 @@
1
+ """
2
+ 3D Model conversions using the native blender python module (`bpy`).
3
+ Provides format conversions (obj, stl, fbx, gltf, blend) and headless rendering (png).
4
+ """
5
+ import sys
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ from ..registry import ConversionResult, register
11
+
12
+ def _run_bpy_script(script_content: str) -> None:
13
+ """Run a bpy script in an isolated subprocess to prevent global state corruption/memory leaks."""
14
+ code = f"""
15
+ import bpy
16
+ import sys
17
+ import os
18
+
19
+ def main():
20
+ try:
21
+ # Clear factory settings first if not loading a blend file
22
+ {script_content}
23
+ except Exception as e:
24
+ print(f"ERROR: {{e}}", file=sys.stderr)
25
+ sys.exit(1)
26
+
27
+ if __name__ == '__main__':
28
+ main()
29
+ """
30
+ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
31
+ if result.returncode != 0:
32
+ raise RuntimeError(f"3D conversion failed.\nStdout: {result.stdout}\nStderr: {result.stderr}")
33
+
34
+ def _get_import_code(filepath: Path) -> str:
35
+ ext = filepath.suffix.lower().lstrip('.')
36
+ path_str = repr(str(filepath.absolute()))
37
+
38
+ if ext == 'blend':
39
+ return f" bpy.ops.wm.open_mainfile(filepath={path_str})"
40
+ elif ext == 'obj':
41
+ return f" bpy.ops.wm.obj_import(filepath={path_str})"
42
+ elif ext == 'stl':
43
+ return f" bpy.ops.wm.stl_import(filepath={path_str})"
44
+ elif ext == 'fbx':
45
+ return f" bpy.ops.import_scene.fbx(filepath={path_str})"
46
+ elif ext in ('gltf', 'glb'):
47
+ return f" bpy.ops.import_scene.gltf(filepath={path_str})"
48
+ else:
49
+ raise ValueError(f"Unsupported 3D import format: {ext}")
50
+
51
+ def _get_export_code(filepath: Path) -> str:
52
+ ext = filepath.suffix.lower().lstrip('.')
53
+ path_str = repr(str(filepath.absolute()))
54
+
55
+ if ext == 'blend':
56
+ return f" bpy.ops.wm.save_as_mainfile(filepath={path_str})"
57
+ elif ext == 'obj':
58
+ return f" bpy.ops.wm.obj_export(filepath={path_str})"
59
+ elif ext == 'stl':
60
+ return f" bpy.ops.wm.stl_export(filepath={path_str})"
61
+ elif ext == 'fbx':
62
+ return f" bpy.ops.export_scene.fbx(filepath={path_str})"
63
+ elif ext in ('gltf', 'glb'):
64
+ return f" bpy.ops.export_scene.gltf(filepath={path_str})"
65
+ else:
66
+ raise ValueError(f"Unsupported 3D export format: {ext}")
67
+
68
+ def _3d_to_3d(input_path: Path, output_path: Path, **kwargs) -> ConversionResult:
69
+ """Converts a 3D model from one format to another."""
70
+ import_cmd = _get_import_code(input_path)
71
+ export_cmd = _get_export_code(output_path)
72
+
73
+ # If the input isn't a blend file, we should clear the default scene first
74
+ clear_scene_cmd = ""
75
+ if input_path.suffix.lower() != '.blend':
76
+ clear_scene_cmd = " bpy.ops.wm.read_factory_settings(use_empty=True)"
77
+
78
+ script = f"""
79
+ {clear_scene_cmd}
80
+ {import_cmd}
81
+ {export_cmd}
82
+ """
83
+
84
+ _run_bpy_script(script)
85
+ return ConversionResult(output_path)
86
+
87
+ def _3d_to_image(input_path: Path, output_path: Path, **kwargs) -> ConversionResult:
88
+ """Renders a 3D model into a PNG image headlessly."""
89
+ import_cmd = _get_import_code(input_path)
90
+
91
+ clear_scene_cmd = ""
92
+ if input_path.suffix.lower() != '.blend':
93
+ clear_scene_cmd = " bpy.ops.wm.read_factory_settings(use_empty=True)"
94
+
95
+ out_path_str = repr(str(output_path.absolute()))
96
+
97
+ script = f"""
98
+ {clear_scene_cmd}
99
+ {import_cmd}
100
+
101
+ # Select all objects to find bounding box center
102
+ bpy.ops.object.select_all(action='SELECT')
103
+
104
+ # Add a camera (if no camera exists)
105
+ if not any(obj.type == 'CAMERA' for obj in bpy.context.scene.objects):
106
+ bpy.ops.object.camera_add(location=(10, -10, 10))
107
+ cam = bpy.context.active_object
108
+ cam.rotation_euler = (0.9, 0, 0.785)
109
+ bpy.context.scene.camera = cam
110
+
111
+ # Add light (if no light exists)
112
+ if not any(obj.type == 'LIGHT' for obj in bpy.context.scene.objects):
113
+ bpy.ops.object.light_add(type='SUN', location=(5, 5, 5))
114
+
115
+ # Setup render settings
116
+ bpy.context.scene.render.engine = 'CYCLES'
117
+ bpy.context.scene.render.filepath = {out_path_str}
118
+ bpy.context.scene.render.resolution_x = 1024
119
+ bpy.context.scene.render.resolution_y = 1024
120
+
121
+ # Render
122
+ bpy.ops.render.render(write_still=True)
123
+ """
124
+
125
+ _run_bpy_script(script)
126
+ return ConversionResult(output_path)
127
+
128
+
129
+
130
+ # 3D to 3D conversions
131
+ formats_3d = ['obj', 'stl', 'fbx', 'gltf', 'glb', 'blend']
132
+ for in_ext in formats_3d:
133
+ for out_ext in formats_3d:
134
+ if in_ext != out_ext:
135
+ register(
136
+ in_ext, out_ext,
137
+ backend="bpy",
138
+ family="3d_model",
139
+ description=f"{in_ext} → {out_ext} (blender/bpy)"
140
+ )(_3d_to_3d)
141
+
142
+ # 3D to Image conversions
143
+ for in_ext in formats_3d:
144
+ register(
145
+ in_ext, 'png',
146
+ backend="bpy",
147
+ family="3d_model",
148
+ description=f"{in_ext} → png (blender/bpy headless render)"
149
+ )(_3d_to_image)