morphconv 1.0.3__tar.gz → 1.0.4__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.
- {morphconv-1.0.3 → morphconv-1.0.4}/PKG-INFO +26 -6
- {morphconv-1.0.3 → morphconv-1.0.4}/README.md +11 -5
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/cli.py +161 -2
- morphconv-1.0.4/morph/converters/archives.py +188 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/images.py +69 -0
- morphconv-1.0.4/morph/converters/legacy_office.py +153 -0
- morphconv-1.0.4/morph/converters/notebooks.py +165 -0
- morphconv-1.0.4/morph/converters/pdf.py +576 -0
- morphconv-1.0.4/morph/converters/qrcode.py +172 -0
- morphconv-1.0.4/morph/converters/raw_photo.py +145 -0
- morphconv-1.0.4/morph/converters/subtitles.py +145 -0
- morphconv-1.0.4/morph/converters/toml.py +72 -0
- morphconv-1.0.4/morph/converters/weasyprint_pdf.py +92 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/web.py +75 -14
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/deps.py +14 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/PKG-INFO +26 -6
- {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/SOURCES.txt +8 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/requires.txt +14 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/pyproject.toml +15 -1
- morphconv-1.0.3/morph/converters/archives.py +0 -85
- {morphconv-1.0.3 → morphconv-1.0.4}/LICENSE +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/__init__.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/batch.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/__init__.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/audio.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/csv_json.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/csv_to_xlsx.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/documents.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/ebooks.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/fonts.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/json_yaml.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/models_3d.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/ocr.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/pandas_extra.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/svg.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/video.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/vtracer_converter.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/xlsx_to_csv.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/ffmpeg_utils.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/history.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/progress.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/registry.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morph/tui.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/dependency_links.txt +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/entry_points.txt +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/top_level.txt +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/setup.cfg +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_cli.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_converters.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_registry.py +0 -0
- {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: morphconv
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: morph — convert anything to anything, from the CLI
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -30,6 +30,20 @@ Requires-Dist: pytesseract>=0.3.10
|
|
|
30
30
|
Requires-Dist: trafilatura>=1.8.0
|
|
31
31
|
Requires-Dist: vtracer>=0.6.0
|
|
32
32
|
Requires-Dist: yt-dlp>=2024.03.10
|
|
33
|
+
Requires-Dist: pymupdf>=1.24.0
|
|
34
|
+
Requires-Dist: pdfplumber>=0.11.0
|
|
35
|
+
Requires-Dist: pdf2docx>=0.5.8
|
|
36
|
+
Requires-Dist: py7zr>=0.21.0
|
|
37
|
+
Requires-Dist: rarfile>=4.1
|
|
38
|
+
Requires-Dist: pysubs2>=1.6.0
|
|
39
|
+
Requires-Dist: tomlkit>=0.12.0
|
|
40
|
+
Requires-Dist: nbconvert>=7.0.0
|
|
41
|
+
Requires-Dist: img2pdf>=0.5.0
|
|
42
|
+
Requires-Dist: rawpy>=0.19.0
|
|
43
|
+
Requires-Dist: qrcode[pil]>=7.4.0
|
|
44
|
+
Requires-Dist: pyzbar>=0.1.9
|
|
45
|
+
Requires-Dist: markdownify>=0.13.0
|
|
46
|
+
Requires-Dist: weasyprint>=62.0
|
|
33
47
|
Provides-Extra: dev
|
|
34
48
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
35
49
|
Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
|
|
@@ -62,6 +76,7 @@ morph report.docx report.pdf
|
|
|
62
76
|
morph data.csv data.xlsx --table-style TableStyleMedium2 --header-bg 2E7D32
|
|
63
77
|
morph logo.png logo.svg --mode spline
|
|
64
78
|
morph batch '*.mp4' mp3 --workers 4
|
|
79
|
+
morph pack '*.png' photos/ doc.pdf archive.zip
|
|
65
80
|
```
|
|
66
81
|
|
|
67
82
|
</div>
|
|
@@ -85,6 +100,7 @@ Whether you are rendering a 3D `.blend` file to a `.png`, scraping a Wikipedia a
|
|
|
85
100
|
- **Output Verification.** Pass the `--verify` flag to calculate and display the exact SHA-256 checksum of the resulting file right alongside the success message.
|
|
86
101
|
- **Transparent Remote Downloads.** Pass any `http://` link and `morph` handles the rest. It uses `trafilatura` to scrape web articles into clean Markdown, or `yt-dlp` to natively download high-quality media (`morph <url> --audio`).
|
|
87
102
|
- **Batch Processing Engine.** `morph batch '*.mp4' mp3` converts files concurrently with a live progress table, smart skip logic (`--newer-only`, `--skip-existing`), and output structure mirroring.
|
|
103
|
+
- **Pack Command.** Run `morph pack *.png output.zip` to collect any number of files, globs, or directories into a single archive (zip/7z/tar.gz), with auto-stripped common paths.
|
|
88
104
|
- **Interactive TUI.** Type `morph` with no arguments to launch a stunning, keyboard-driven Textual UI. Navigate your filesystem, preview conversion options, and watch live progress bars without touching your mouse.
|
|
89
105
|
- **Automated Dependency Management.** Missing `ffmpeg` or `pandoc`? `morph` detects your OS and package manager (brew/apt/winget/etc.), shows you the exact install command, and asks before running it. Massive dependencies (like 3D rendering engines) are cleanly isolated as optional extras!
|
|
90
106
|
- **Pluggable Architecture.** Drop a Python script in `morph/converters/`, add a `@register` decorator, and it's instantly live in the routing graph.
|
|
@@ -249,20 +265,24 @@ During batch conversions, `morph` displays a live status table:
|
|
|
249
265
|
|
|
250
266
|
| Domain | Formats | Backend |
|
|
251
267
|
|---|---|---|
|
|
252
|
-
| **Data** | `csv`, `xlsx`, `json`, `yaml`, `parquet`, `feather`, `ods`, `xml`, `html`, `sqlite` | Native (`pandas` / `
|
|
253
|
-
| **Documents** | `md`, `html`, `docx`, `odt`, `rtf`, `epub`, `latex`, `rst`, `txt`, `
|
|
254
|
-
| **
|
|
268
|
+
| **Data** | `csv`, `xlsx`, `json`, `yaml`, `toml`, `parquet`, `feather`, `ods`, `xml`, `html`, `sqlite` | Native (`pandas` / `tomlkit`) |
|
|
269
|
+
| **Documents** | `md`, `html`, `docx`, `odt`, `rtf`, `epub`, `latex`, `rst`, `txt`, `pptx`, `adoc`, `org`, `opml`, `man` | `pandoc` |
|
|
270
|
+
| **Legacy Office** | `doc`, `xls`, `ppt` | `LibreOffice (soffice)` |
|
|
271
|
+
| **Notebooks** | `ipynb` | `nbconvert` (executes & renders outputs) |
|
|
272
|
+
| **PDF** | `pdf` (real text, layout, tables, generation) | `pymupdf` / `pdfplumber` / `pdf2docx` |
|
|
273
|
+
| **Images (Raster)** | `png`, `jpg/jpeg`, `webp`, `bmp`, `gif`, `tiff`, `ico`, `heic`, `avif`, `icns` | `Pillow` / `cairosvg` |
|
|
255
274
|
| **Vectorization** | `png`/`jpg`/etc. → `svg` | `vtracer` |
|
|
256
275
|
| **3D Models** | `obj`, `stl`, `fbx`, `gltf`, `glb`, `blend`, `any → png` (Render) | `bpy` (Blender) |
|
|
257
276
|
| **Web Extraction** | `url` → `md`, `txt`, `xml`, `html` | `trafilatura` / `crawl4ai` |
|
|
258
277
|
| **Audio** | `mp3`, `wav`, `flac`, `ogg`, `aac`, `m4a`, `opus`, `wma` | `ffmpeg` |
|
|
259
278
|
| **Video** | `mp4`, `mkv`, `mov`, `webm`, `avi`, `flv`, `wmv`, `mpeg` | `ffmpeg` |
|
|
279
|
+
| **Subtitles** | `srt`, `vtt`, `ass`, `ssa`, `sub`, `sami` | `pysubs2` |
|
|
260
280
|
| **Ebooks** | `epub`, `mobi`, `azw3` | `ebook-convert` |
|
|
261
281
|
| **Fonts** | `ttf`, `otf`, `woff`, `woff2` | `fontTools` |
|
|
262
282
|
| **OCR** | `png`, `jpg`, `webp`, `pdf`, etc. → `txt` | `tesseract` |
|
|
263
|
-
| **Archives** | `zip`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz` | Python stdlib |
|
|
283
|
+
| **Archives** | `zip`, `7z`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`, `rar`* | Python stdlib / `py7zr` / `rarfile` |
|
|
264
284
|
|
|
265
|
-
*\* Note:
|
|
285
|
+
*\* Note: RAR is an extract-only format (RAR creation requires proprietary tools).*
|
|
266
286
|
|
|
267
287
|
---
|
|
268
288
|
|
|
@@ -18,6 +18,7 @@ morph report.docx report.pdf
|
|
|
18
18
|
morph data.csv data.xlsx --table-style TableStyleMedium2 --header-bg 2E7D32
|
|
19
19
|
morph logo.png logo.svg --mode spline
|
|
20
20
|
morph batch '*.mp4' mp3 --workers 4
|
|
21
|
+
morph pack '*.png' photos/ doc.pdf archive.zip
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
</div>
|
|
@@ -41,6 +42,7 @@ Whether you are rendering a 3D `.blend` file to a `.png`, scraping a Wikipedia a
|
|
|
41
42
|
- **Output Verification.** Pass the `--verify` flag to calculate and display the exact SHA-256 checksum of the resulting file right alongside the success message.
|
|
42
43
|
- **Transparent Remote Downloads.** Pass any `http://` link and `morph` handles the rest. It uses `trafilatura` to scrape web articles into clean Markdown, or `yt-dlp` to natively download high-quality media (`morph <url> --audio`).
|
|
43
44
|
- **Batch Processing Engine.** `morph batch '*.mp4' mp3` converts files concurrently with a live progress table, smart skip logic (`--newer-only`, `--skip-existing`), and output structure mirroring.
|
|
45
|
+
- **Pack Command.** Run `morph pack *.png output.zip` to collect any number of files, globs, or directories into a single archive (zip/7z/tar.gz), with auto-stripped common paths.
|
|
44
46
|
- **Interactive TUI.** Type `morph` with no arguments to launch a stunning, keyboard-driven Textual UI. Navigate your filesystem, preview conversion options, and watch live progress bars without touching your mouse.
|
|
45
47
|
- **Automated Dependency Management.** Missing `ffmpeg` or `pandoc`? `morph` detects your OS and package manager (brew/apt/winget/etc.), shows you the exact install command, and asks before running it. Massive dependencies (like 3D rendering engines) are cleanly isolated as optional extras!
|
|
46
48
|
- **Pluggable Architecture.** Drop a Python script in `morph/converters/`, add a `@register` decorator, and it's instantly live in the routing graph.
|
|
@@ -205,20 +207,24 @@ During batch conversions, `morph` displays a live status table:
|
|
|
205
207
|
|
|
206
208
|
| Domain | Formats | Backend |
|
|
207
209
|
|---|---|---|
|
|
208
|
-
| **Data** | `csv`, `xlsx`, `json`, `yaml`, `parquet`, `feather`, `ods`, `xml`, `html`, `sqlite` | Native (`pandas` / `
|
|
209
|
-
| **Documents** | `md`, `html`, `docx`, `odt`, `rtf`, `epub`, `latex`, `rst`, `txt`, `
|
|
210
|
-
| **
|
|
210
|
+
| **Data** | `csv`, `xlsx`, `json`, `yaml`, `toml`, `parquet`, `feather`, `ods`, `xml`, `html`, `sqlite` | Native (`pandas` / `tomlkit`) |
|
|
211
|
+
| **Documents** | `md`, `html`, `docx`, `odt`, `rtf`, `epub`, `latex`, `rst`, `txt`, `pptx`, `adoc`, `org`, `opml`, `man` | `pandoc` |
|
|
212
|
+
| **Legacy Office** | `doc`, `xls`, `ppt` | `LibreOffice (soffice)` |
|
|
213
|
+
| **Notebooks** | `ipynb` | `nbconvert` (executes & renders outputs) |
|
|
214
|
+
| **PDF** | `pdf` (real text, layout, tables, generation) | `pymupdf` / `pdfplumber` / `pdf2docx` |
|
|
215
|
+
| **Images (Raster)** | `png`, `jpg/jpeg`, `webp`, `bmp`, `gif`, `tiff`, `ico`, `heic`, `avif`, `icns` | `Pillow` / `cairosvg` |
|
|
211
216
|
| **Vectorization** | `png`/`jpg`/etc. → `svg` | `vtracer` |
|
|
212
217
|
| **3D Models** | `obj`, `stl`, `fbx`, `gltf`, `glb`, `blend`, `any → png` (Render) | `bpy` (Blender) |
|
|
213
218
|
| **Web Extraction** | `url` → `md`, `txt`, `xml`, `html` | `trafilatura` / `crawl4ai` |
|
|
214
219
|
| **Audio** | `mp3`, `wav`, `flac`, `ogg`, `aac`, `m4a`, `opus`, `wma` | `ffmpeg` |
|
|
215
220
|
| **Video** | `mp4`, `mkv`, `mov`, `webm`, `avi`, `flv`, `wmv`, `mpeg` | `ffmpeg` |
|
|
221
|
+
| **Subtitles** | `srt`, `vtt`, `ass`, `ssa`, `sub`, `sami` | `pysubs2` |
|
|
216
222
|
| **Ebooks** | `epub`, `mobi`, `azw3` | `ebook-convert` |
|
|
217
223
|
| **Fonts** | `ttf`, `otf`, `woff`, `woff2` | `fontTools` |
|
|
218
224
|
| **OCR** | `png`, `jpg`, `webp`, `pdf`, etc. → `txt` | `tesseract` |
|
|
219
|
-
| **Archives** | `zip`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz` | Python stdlib |
|
|
225
|
+
| **Archives** | `zip`, `7z`, `tar`, `tar.gz`, `tar.bz2`, `tar.xz`, `rar`* | Python stdlib / `py7zr` / `rarfile` |
|
|
220
226
|
|
|
221
|
-
*\* Note:
|
|
227
|
+
*\* Note: RAR is an extract-only format (RAR creation requires proprietary tools).*
|
|
222
228
|
|
|
223
229
|
---
|
|
224
230
|
|
|
@@ -6,13 +6,15 @@ morph — convert anything to anything, from the CLI.
|
|
|
6
6
|
morph data.csv data.xlsx --table-style TableStyleMedium2 --header-bg 2E7D32
|
|
7
7
|
morph data.csv data.xlsx --help (shows flags for THIS pair only)
|
|
8
8
|
morph batch '*.mp4' mp3 --workers 4
|
|
9
|
+
morph pack '*.png' photos/ report.docx archive.zip
|
|
10
|
+
morph pack project/ backup.tar.gz --recursive
|
|
9
11
|
morph formats docx
|
|
10
12
|
morph history
|
|
11
13
|
morph deps
|
|
12
14
|
morph (no args -> launches the interactive TUI)
|
|
13
15
|
|
|
14
16
|
There is no "convert" subcommand — morph already means convert. Anything
|
|
15
|
-
that isn't a recognized subcommand (formats, deps, batch, history) is
|
|
17
|
+
that isn't a recognized subcommand (formats, deps, batch, history, pack) is
|
|
16
18
|
treated as a conversion job and routed through the engine.
|
|
17
19
|
"""
|
|
18
20
|
|
|
@@ -85,7 +87,7 @@ class MorphGroup(TyperGroup):
|
|
|
85
87
|
def resolve_command(self, ctx, args):
|
|
86
88
|
# We explicitly allow "config" alongside whatever is in self.commands
|
|
87
89
|
# just in case Typer hasn't fully populated the commands dict yet.
|
|
88
|
-
known_commands = list(self.commands.keys()) + ["config", "init"]
|
|
90
|
+
known_commands = list(self.commands.keys()) + ["config", "init", "pack"]
|
|
89
91
|
if args and args[0] not in known_commands and not args[0].startswith("-"):
|
|
90
92
|
args = ["run", *args]
|
|
91
93
|
return super().resolve_command(ctx, args)
|
|
@@ -552,6 +554,163 @@ def batch_cmd(
|
|
|
552
554
|
raise typer.Exit(0 if result.failed == 0 else 1)
|
|
553
555
|
|
|
554
556
|
|
|
557
|
+
|
|
558
|
+
# ── pack ──────────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
@app.command(
|
|
561
|
+
"pack",
|
|
562
|
+
help="Pack any files or globs into a single archive (zip, 7z, tar.gz, tar.bz2, tar.xz).",
|
|
563
|
+
)
|
|
564
|
+
def pack_cmd(
|
|
565
|
+
inputs: List[str] = typer.Argument(..., help="Files, globs, or directories to pack. Last argument is the output archive path."),
|
|
566
|
+
recursive: bool = typer.Option(False, "-r", "--recursive", help="Recurse into directories."),
|
|
567
|
+
strip_top_level: bool = typer.Option(False, "--strip-top-level", help="Drop a single shared top-level directory from paths inside the archive."),
|
|
568
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Suppress output."),
|
|
569
|
+
) -> None:
|
|
570
|
+
import glob as _glob
|
|
571
|
+
import zipfile
|
|
572
|
+
import tarfile
|
|
573
|
+
import os
|
|
574
|
+
|
|
575
|
+
if not quiet:
|
|
576
|
+
console.print("\n[bold cyan]⚡ MORPH[/bold cyan] [dim]— Pack anything.[/dim]\n")
|
|
577
|
+
|
|
578
|
+
if len(inputs) < 2:
|
|
579
|
+
err_console.print("[error]✗ Provide at least one input and an output archive path.[/error]")
|
|
580
|
+
err_console.print(" [muted]Example: morph pack *.png photos.zip[/muted]")
|
|
581
|
+
err_console.print(" [muted]Example: morph pack docs/ report.tar.gz --recursive[/muted]")
|
|
582
|
+
raise typer.Exit(1)
|
|
583
|
+
|
|
584
|
+
*patterns, output_str = inputs
|
|
585
|
+
output_path = Path(output_str)
|
|
586
|
+
|
|
587
|
+
# Determine archive format from extension
|
|
588
|
+
name_lower = output_path.name.lower()
|
|
589
|
+
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
|
590
|
+
fmt = "tar.gz"
|
|
591
|
+
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
|
|
592
|
+
fmt = "tar.bz2"
|
|
593
|
+
elif name_lower.endswith(".tar.xz") or name_lower.endswith(".txz"):
|
|
594
|
+
fmt = "tar.xz"
|
|
595
|
+
elif name_lower.endswith(".tar"):
|
|
596
|
+
fmt = "tar"
|
|
597
|
+
elif name_lower.endswith(".7z"):
|
|
598
|
+
fmt = "7z"
|
|
599
|
+
elif name_lower.endswith(".zip"):
|
|
600
|
+
fmt = "zip"
|
|
601
|
+
else:
|
|
602
|
+
err_console.print(
|
|
603
|
+
f"[error]✗ Cannot determine archive format from:[/error] [bold]{output_path.name}[/bold]\n"
|
|
604
|
+
" [muted]Supported: .zip .7z .tar .tar.gz .tar.bz2 .tar.xz[/muted]"
|
|
605
|
+
)
|
|
606
|
+
raise typer.Exit(1)
|
|
607
|
+
|
|
608
|
+
# Collect all matching files, preserving relative paths
|
|
609
|
+
collected: list[Path] = []
|
|
610
|
+
seen: set[Path] = set()
|
|
611
|
+
|
|
612
|
+
for pattern in patterns:
|
|
613
|
+
p = Path(pattern)
|
|
614
|
+
if p.is_dir():
|
|
615
|
+
glob_pat = "**/*" if recursive else "*"
|
|
616
|
+
matches = [f for f in p.glob(glob_pat) if f.is_file()]
|
|
617
|
+
else:
|
|
618
|
+
# Expand shell-style globs
|
|
619
|
+
expanded = [Path(m) for m in _glob.glob(pattern, recursive=recursive)]
|
|
620
|
+
if not expanded:
|
|
621
|
+
err_console.print(f"[warning]⚠ No files matched:[/warning] {pattern}")
|
|
622
|
+
continue
|
|
623
|
+
matches = []
|
|
624
|
+
for m in expanded:
|
|
625
|
+
if m.is_dir():
|
|
626
|
+
if recursive:
|
|
627
|
+
matches.extend(f for f in m.rglob("*") if f.is_file())
|
|
628
|
+
else:
|
|
629
|
+
err_console.print(f"[warning]⚠ Skipping directory (use --recursive):[/warning] {m}")
|
|
630
|
+
else:
|
|
631
|
+
matches.append(m)
|
|
632
|
+
|
|
633
|
+
for f in matches:
|
|
634
|
+
resolved = f.resolve()
|
|
635
|
+
if resolved not in seen:
|
|
636
|
+
seen.add(resolved)
|
|
637
|
+
collected.append(f)
|
|
638
|
+
|
|
639
|
+
if not collected:
|
|
640
|
+
err_console.print("[error]✗ No files matched the given patterns.[/error]")
|
|
641
|
+
raise typer.Exit(1)
|
|
642
|
+
|
|
643
|
+
# Compute archive-internal paths: strip common root so paths are relative
|
|
644
|
+
abs_collected = [f.resolve() for f in collected]
|
|
645
|
+
if len(abs_collected) == 1:
|
|
646
|
+
common = abs_collected[0].parent
|
|
647
|
+
else:
|
|
648
|
+
# Find longest common ancestor directory
|
|
649
|
+
from pathlib import PurePath
|
|
650
|
+
parts_list = [p.parts for p in abs_collected]
|
|
651
|
+
common_parts = []
|
|
652
|
+
for parts in zip(*parts_list):
|
|
653
|
+
if len(set(parts)) == 1:
|
|
654
|
+
common_parts.append(parts[0])
|
|
655
|
+
else:
|
|
656
|
+
break
|
|
657
|
+
common = Path(*common_parts) if common_parts else Path("/")
|
|
658
|
+
|
|
659
|
+
def arcname(f: Path) -> str:
|
|
660
|
+
try:
|
|
661
|
+
rel = f.resolve().relative_to(common)
|
|
662
|
+
except ValueError:
|
|
663
|
+
rel = Path(f.name)
|
|
664
|
+
return str(rel)
|
|
665
|
+
|
|
666
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
667
|
+
|
|
668
|
+
if not quiet:
|
|
669
|
+
console.print(Panel.fit(
|
|
670
|
+
f"Packing [bold]{len(collected)}[/bold] file(s) into [bold]{output_path.name}[/bold] [muted]({fmt})[/muted]",
|
|
671
|
+
title="morph pack", border_style="cyan",
|
|
672
|
+
))
|
|
673
|
+
|
|
674
|
+
t_start = time.perf_counter()
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
if fmt == "zip":
|
|
678
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
679
|
+
for f in abs_collected:
|
|
680
|
+
zf.write(f, arcname(f))
|
|
681
|
+
|
|
682
|
+
elif fmt in ("tar", "tar.gz", "tar.bz2", "tar.xz"):
|
|
683
|
+
_modes = {"tar": "w:", "tar.gz": "w:gz", "tar.bz2": "w:bz2", "tar.xz": "w:xz"}
|
|
684
|
+
with tarfile.open(output_path, _modes[fmt]) as tf:
|
|
685
|
+
for f in abs_collected:
|
|
686
|
+
tf.add(f, arcname=arcname(f))
|
|
687
|
+
|
|
688
|
+
elif fmt == "7z":
|
|
689
|
+
try:
|
|
690
|
+
import py7zr
|
|
691
|
+
except ImportError:
|
|
692
|
+
err_console.print("[error]✗ py7zr is required for .7z packing.[/error]")
|
|
693
|
+
err_console.print(" [muted]Install: pip install py7zr[/muted]")
|
|
694
|
+
raise typer.Exit(1)
|
|
695
|
+
with py7zr.SevenZipFile(output_path, mode="w") as sz:
|
|
696
|
+
for f in abs_collected:
|
|
697
|
+
sz.write(f, arcname(f))
|
|
698
|
+
|
|
699
|
+
except Exception as exc:
|
|
700
|
+
err_console.print(f"[error]✗ Pack failed:[/error] {exc}")
|
|
701
|
+
raise typer.Exit(1)
|
|
702
|
+
|
|
703
|
+
elapsed = time.perf_counter() - t_start
|
|
704
|
+
size_kb = output_path.stat().st_size / 1024
|
|
705
|
+
|
|
706
|
+
if not quiet:
|
|
707
|
+
console.print(
|
|
708
|
+
f"[success]✓ Done![/success] → [accent]{output_path}[/accent]"
|
|
709
|
+
f" ({len(collected)} files, {size_kb:.1f} KB, {elapsed:.2f}s)"
|
|
710
|
+
)
|
|
711
|
+
console.print("\n[dim]Built with 💖 by [link=https://hariharen.site]Hariharen[/link][/dim]\n")
|
|
712
|
+
|
|
713
|
+
|
|
555
714
|
# ── formats ───────────────────────────────────────────────────────────────────
|
|
556
715
|
|
|
557
716
|
@app.command("formats", help="List formats morph can convert, optionally filtered by source format.")
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
converters/archives.py — archive family (zip <-> tar <-> 7z, rar extract-only).
|
|
3
|
+
|
|
4
|
+
Conversion means: extract every member from the source archive, then repack
|
|
5
|
+
them into the destination format, preserving relative paths.
|
|
6
|
+
|
|
7
|
+
Supported formats and their backing libraries:
|
|
8
|
+
zip — stdlib zipfile (read + write)
|
|
9
|
+
tar.* — stdlib tarfile (read + write)
|
|
10
|
+
7z — py7zr (optional) (read + write)
|
|
11
|
+
rar — rarfile (optional) (read only — RAR creation is proprietary)
|
|
12
|
+
|
|
13
|
+
py7zr and rarfile are optional; a clear error with install hint is raised if
|
|
14
|
+
the user tries a conversion that needs one and it isn't installed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import tarfile
|
|
20
|
+
import tempfile
|
|
21
|
+
import zipfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ..registry import ConversionResult, OptionSpec, register
|
|
25
|
+
|
|
26
|
+
# ── format tables ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
# tarfile compression mode suffix (empty string = uncompressed tar)
|
|
29
|
+
_TAR_MODES = {"tar": "", "tar.gz": "gz", "tar.bz2": "bz2", "tar.xz": "xz"}
|
|
30
|
+
|
|
31
|
+
# Formats that can be both read and written (appear as src AND dst)
|
|
32
|
+
RW_FORMATS = ["zip", *_TAR_MODES.keys(), "7z"]
|
|
33
|
+
|
|
34
|
+
# Formats that can only be read (appear as src only — never a pack target)
|
|
35
|
+
RO_FORMATS = ["rar"]
|
|
36
|
+
|
|
37
|
+
ALL_SRC_FORMATS = RW_FORMATS + RO_FORMATS
|
|
38
|
+
|
|
39
|
+
OPTIONS = [
|
|
40
|
+
OptionSpec("strip_top_level", ("--strip-top-level",),
|
|
41
|
+
"Drop a single shared top-level folder when repacking, if every member has one.",
|
|
42
|
+
default=False, action="store_true"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── optional library helpers ───────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def _require_py7zr():
|
|
49
|
+
try:
|
|
50
|
+
import py7zr
|
|
51
|
+
return py7zr
|
|
52
|
+
except ImportError:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"py7zr is required for 7z archive support.\n"
|
|
55
|
+
"Install it with: pip install py7zr"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _require_rarfile():
|
|
60
|
+
try:
|
|
61
|
+
import rarfile
|
|
62
|
+
return rarfile
|
|
63
|
+
except ImportError:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
"rarfile is required for RAR archive extraction.\n"
|
|
66
|
+
"Install it with: pip install rarfile\n"
|
|
67
|
+
"Note: unrar or bsdtar must also be on your PATH."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── extract ───────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def _extract(input_path: Path, dst_dir: Path, fmt: str) -> None:
|
|
74
|
+
if fmt == "zip":
|
|
75
|
+
with zipfile.ZipFile(input_path) as zf:
|
|
76
|
+
zf.extractall(dst_dir)
|
|
77
|
+
elif fmt in _TAR_MODES:
|
|
78
|
+
mode = f"r:{_TAR_MODES[fmt]}" if _TAR_MODES[fmt] else "r:"
|
|
79
|
+
with tarfile.open(input_path, mode) as tf:
|
|
80
|
+
tf.extractall(dst_dir, filter="data")
|
|
81
|
+
elif fmt == "7z":
|
|
82
|
+
py7zr = _require_py7zr()
|
|
83
|
+
with py7zr.SevenZipFile(input_path, mode="r") as sz:
|
|
84
|
+
sz.extractall(path=dst_dir)
|
|
85
|
+
elif fmt == "rar":
|
|
86
|
+
rarfile = _require_rarfile()
|
|
87
|
+
with rarfile.RarFile(input_path) as rf:
|
|
88
|
+
rf.extractall(path=dst_dir)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f"Unsupported source archive format: {fmt!r}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── strip top-level ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def _maybe_strip_top_level(root: Path) -> Path:
|
|
96
|
+
entries = list(root.iterdir())
|
|
97
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
98
|
+
return entries[0]
|
|
99
|
+
return root
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── pack ──────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def _pack(src_dir: Path, output_path: Path, fmt: str) -> int:
|
|
105
|
+
count = 0
|
|
106
|
+
if fmt == "zip":
|
|
107
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
108
|
+
for f in src_dir.rglob("*"):
|
|
109
|
+
if f.is_file():
|
|
110
|
+
zf.write(f, f.relative_to(src_dir))
|
|
111
|
+
count += 1
|
|
112
|
+
elif fmt in _TAR_MODES:
|
|
113
|
+
mode = f"w:{_TAR_MODES[fmt]}" if _TAR_MODES[fmt] else "w"
|
|
114
|
+
with tarfile.open(output_path, mode) as tf:
|
|
115
|
+
for f in src_dir.rglob("*"):
|
|
116
|
+
if f.is_file():
|
|
117
|
+
tf.add(f, f.relative_to(src_dir))
|
|
118
|
+
count += 1
|
|
119
|
+
elif fmt == "7z":
|
|
120
|
+
py7zr = _require_py7zr()
|
|
121
|
+
with py7zr.SevenZipFile(output_path, mode="w") as sz:
|
|
122
|
+
for f in src_dir.rglob("*"):
|
|
123
|
+
if f.is_file():
|
|
124
|
+
sz.write(f, f.relative_to(src_dir))
|
|
125
|
+
count += 1
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError(f"Unsupported destination archive format: {fmt!r}")
|
|
128
|
+
return count
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── converter factory ─────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _make_converter(src_fmt: str, dst_fmt: str):
|
|
134
|
+
def _convert(
|
|
135
|
+
input_path: Path,
|
|
136
|
+
output_path: Path,
|
|
137
|
+
*,
|
|
138
|
+
strip_top_level: bool = False,
|
|
139
|
+
**_options,
|
|
140
|
+
) -> ConversionResult:
|
|
141
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
142
|
+
tmp_path = Path(tmp)
|
|
143
|
+
_extract(input_path, tmp_path, src_fmt)
|
|
144
|
+
pack_root = _maybe_strip_top_level(tmp_path) if strip_top_level else tmp_path
|
|
145
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
n_files = _pack(pack_root, output_path, dst_fmt)
|
|
147
|
+
return ConversionResult(output=output_path, extra={"files": n_files})
|
|
148
|
+
_convert.__name__ = f"convert_{src_fmt}_to_{dst_fmt}"
|
|
149
|
+
return _convert
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── registration ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
# Backend label per source format
|
|
155
|
+
_SRC_BACKEND = {
|
|
156
|
+
"zip": "stdlib",
|
|
157
|
+
"tar": "stdlib",
|
|
158
|
+
"tar.gz": "stdlib",
|
|
159
|
+
"tar.bz2": "stdlib",
|
|
160
|
+
"tar.xz": "stdlib",
|
|
161
|
+
"7z": "py7zr",
|
|
162
|
+
"rar": "rarfile",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Backend label per destination format
|
|
166
|
+
_DST_BACKEND = {
|
|
167
|
+
"zip": "stdlib",
|
|
168
|
+
"tar": "stdlib",
|
|
169
|
+
"tar.gz": "stdlib",
|
|
170
|
+
"tar.bz2": "stdlib",
|
|
171
|
+
"tar.xz": "stdlib",
|
|
172
|
+
"7z": "py7zr",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for _src in ALL_SRC_FORMATS:
|
|
176
|
+
for _dst in RW_FORMATS: # RAR is extract-only; never a destination
|
|
177
|
+
if _dst == _src:
|
|
178
|
+
continue
|
|
179
|
+
_sb = _SRC_BACKEND[_src]
|
|
180
|
+
_db = _DST_BACKEND[_dst]
|
|
181
|
+
_backend = f"{_sb}+{_db}" if _sb != _db else _sb
|
|
182
|
+
register(
|
|
183
|
+
_src, _dst,
|
|
184
|
+
backend=_backend,
|
|
185
|
+
family="archive",
|
|
186
|
+
description=f"{_src} -> {_dst}",
|
|
187
|
+
options=OPTIONS,
|
|
188
|
+
)(_make_converter(_src, _dst))
|
|
@@ -119,3 +119,72 @@ for _src in FORMATS:
|
|
|
119
119
|
lossy=(_dst_pil in _QUALITY_FORMATS or _dst == "ico"),
|
|
120
120
|
options=_OPTIONS_BY_DST.get(_dst, _DEFAULT_OPTIONS),
|
|
121
121
|
)(_make_converter(_dst_pil))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── img2pdf: lossless image → PDF (overwrites Pillow's re-encoding routes) ──
|
|
125
|
+
#
|
|
126
|
+
# Pillow's PDF writer transcodes — a JPEG saved to PDF becomes a re-encoded
|
|
127
|
+
# JPEG, losing quality. img2pdf embeds the original image bytes directly,
|
|
128
|
+
# producing smaller files with zero quality loss. Supported source types:
|
|
129
|
+
# JPEG/JPG — embedded as-is (best case)
|
|
130
|
+
# PNG — embedded as-is if no alpha; RGB-flattened then embedded
|
|
131
|
+
# TIFF — embedded as-is
|
|
132
|
+
# WEBP/BMP/GIF/etc. — converted to PNG internally by img2pdf, then embedded
|
|
133
|
+
#
|
|
134
|
+
# img2pdf does NOT support alpha channels, so we pre-flatten RGBA sources.
|
|
135
|
+
|
|
136
|
+
def _require_img2pdf():
|
|
137
|
+
try:
|
|
138
|
+
import img2pdf
|
|
139
|
+
return img2pdf
|
|
140
|
+
except ImportError:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
"img2pdf is required for lossless image-to-PDF conversion.\n"
|
|
143
|
+
"Install it with: pip install img2pdf"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _img_to_pdf(input_path: Path, output_path: Path, **_options) -> ConversionResult:
|
|
148
|
+
"""Embed image bytes directly into PDF without transcoding."""
|
|
149
|
+
img2pdf = _require_img2pdf()
|
|
150
|
+
|
|
151
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
# Check for alpha channel — img2pdf requires opaque images
|
|
154
|
+
with Image.open(input_path) as img:
|
|
155
|
+
has_alpha = img.mode in ("RGBA", "LA", "P") and "transparency" in img.info or img.mode in ("RGBA", "LA")
|
|
156
|
+
|
|
157
|
+
if has_alpha:
|
|
158
|
+
# Flatten alpha onto white background, save to temp PNG, then embed
|
|
159
|
+
import tempfile
|
|
160
|
+
with Image.open(input_path) as img:
|
|
161
|
+
background = Image.new("RGB", img.size, (255, 255, 255))
|
|
162
|
+
if img.mode in ("RGBA", "LA"):
|
|
163
|
+
background.paste(img, mask=img.split()[-1])
|
|
164
|
+
else:
|
|
165
|
+
background.paste(img.convert("RGBA"), mask=img.convert("RGBA").split()[-1])
|
|
166
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
167
|
+
tmp_path = Path(tmp.name)
|
|
168
|
+
background.save(tmp_path, format="PNG")
|
|
169
|
+
try:
|
|
170
|
+
pdf_bytes = img2pdf.convert(str(tmp_path))
|
|
171
|
+
finally:
|
|
172
|
+
tmp_path.unlink(missing_ok=True)
|
|
173
|
+
else:
|
|
174
|
+
pdf_bytes = img2pdf.convert(str(input_path))
|
|
175
|
+
|
|
176
|
+
output_path.write_bytes(pdf_bytes)
|
|
177
|
+
return ConversionResult(output=output_path)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Formats img2pdf handles (all raster sources except pdf itself)
|
|
181
|
+
_IMG2PDF_SRCS = [s for s in FORMATS if s != "pdf"]
|
|
182
|
+
|
|
183
|
+
for _src in _IMG2PDF_SRCS:
|
|
184
|
+
register(
|
|
185
|
+
_src, "pdf",
|
|
186
|
+
backend="img2pdf",
|
|
187
|
+
family="image",
|
|
188
|
+
description=f"{_src} -> pdf (lossless, img2pdf)",
|
|
189
|
+
lossy=False, # img2pdf embeds original bytes — truly lossless
|
|
190
|
+
)(_img_to_pdf)
|