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.
- morph/__init__.py +0 -0
- morph/batch.py +492 -0
- morph/cli.py +703 -0
- morph/converters/__init__.py +19 -0
- morph/converters/archives.py +85 -0
- morph/converters/audio.py +58 -0
- morph/converters/csv_json.py +28 -0
- morph/converters/csv_to_xlsx.py +290 -0
- morph/converters/documents.py +252 -0
- morph/converters/ebooks.py +36 -0
- morph/converters/fonts.py +72 -0
- morph/converters/images.py +119 -0
- morph/converters/json_yaml.py +26 -0
- morph/converters/models_3d.py +149 -0
- morph/converters/ocr.py +49 -0
- morph/converters/pandas_extra.py +127 -0
- morph/converters/svg.py +63 -0
- morph/converters/video.py +153 -0
- morph/converters/vtracer_converter.py +84 -0
- morph/converters/web.py +92 -0
- morph/converters/xlsx_to_csv.py +272 -0
- morph/deps.py +228 -0
- morph/ffmpeg_utils.py +79 -0
- morph/history.py +136 -0
- morph/progress.py +78 -0
- morph/registry.py +236 -0
- morph/tui.py +792 -0
- morphconv-1.0.1.dist-info/METADATA +396 -0
- morphconv-1.0.1.dist-info/RECORD +33 -0
- morphconv-1.0.1.dist-info/WHEEL +5 -0
- morphconv-1.0.1.dist-info/entry_points.txt +2 -0
- morphconv-1.0.1.dist-info/licenses/LICENSE +21 -0
- morphconv-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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: 
|
|
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 '
|
|
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)
|