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.
Files changed (51) hide show
  1. {morphconv-1.0.3 → morphconv-1.0.4}/PKG-INFO +26 -6
  2. {morphconv-1.0.3 → morphconv-1.0.4}/README.md +11 -5
  3. {morphconv-1.0.3 → morphconv-1.0.4}/morph/cli.py +161 -2
  4. morphconv-1.0.4/morph/converters/archives.py +188 -0
  5. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/images.py +69 -0
  6. morphconv-1.0.4/morph/converters/legacy_office.py +153 -0
  7. morphconv-1.0.4/morph/converters/notebooks.py +165 -0
  8. morphconv-1.0.4/morph/converters/pdf.py +576 -0
  9. morphconv-1.0.4/morph/converters/qrcode.py +172 -0
  10. morphconv-1.0.4/morph/converters/raw_photo.py +145 -0
  11. morphconv-1.0.4/morph/converters/subtitles.py +145 -0
  12. morphconv-1.0.4/morph/converters/toml.py +72 -0
  13. morphconv-1.0.4/morph/converters/weasyprint_pdf.py +92 -0
  14. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/web.py +75 -14
  15. {morphconv-1.0.3 → morphconv-1.0.4}/morph/deps.py +14 -0
  16. {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/PKG-INFO +26 -6
  17. {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/SOURCES.txt +8 -0
  18. {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/requires.txt +14 -0
  19. {morphconv-1.0.3 → morphconv-1.0.4}/pyproject.toml +15 -1
  20. morphconv-1.0.3/morph/converters/archives.py +0 -85
  21. {morphconv-1.0.3 → morphconv-1.0.4}/LICENSE +0 -0
  22. {morphconv-1.0.3 → morphconv-1.0.4}/morph/__init__.py +0 -0
  23. {morphconv-1.0.3 → morphconv-1.0.4}/morph/batch.py +0 -0
  24. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/__init__.py +0 -0
  25. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/audio.py +0 -0
  26. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/csv_json.py +0 -0
  27. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/csv_to_xlsx.py +0 -0
  28. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/documents.py +0 -0
  29. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/ebooks.py +0 -0
  30. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/fonts.py +0 -0
  31. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/json_yaml.py +0 -0
  32. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/models_3d.py +0 -0
  33. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/ocr.py +0 -0
  34. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/pandas_extra.py +0 -0
  35. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/svg.py +0 -0
  36. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/video.py +0 -0
  37. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/vtracer_converter.py +0 -0
  38. {morphconv-1.0.3 → morphconv-1.0.4}/morph/converters/xlsx_to_csv.py +0 -0
  39. {morphconv-1.0.3 → morphconv-1.0.4}/morph/ffmpeg_utils.py +0 -0
  40. {morphconv-1.0.3 → morphconv-1.0.4}/morph/history.py +0 -0
  41. {morphconv-1.0.3 → morphconv-1.0.4}/morph/progress.py +0 -0
  42. {morphconv-1.0.3 → morphconv-1.0.4}/morph/registry.py +0 -0
  43. {morphconv-1.0.3 → morphconv-1.0.4}/morph/tui.py +0 -0
  44. {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/dependency_links.txt +0 -0
  45. {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/entry_points.txt +0 -0
  46. {morphconv-1.0.3 → morphconv-1.0.4}/morphconv.egg-info/top_level.txt +0 -0
  47. {morphconv-1.0.3 → morphconv-1.0.4}/setup.cfg +0 -0
  48. {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_cli.py +0 -0
  49. {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_converters.py +0 -0
  50. {morphconv-1.0.3 → morphconv-1.0.4}/tests/test_registry.py +0 -0
  51. {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
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` / `openpyxl`) |
253
- | **Documents** | `md`, `html`, `docx`, `odt`, `rtf`, `epub`, `latex`, `rst`, `txt`, `ipynb`, `pptx`, `adoc`, `org`, `opml`, `man`, `pdf`* | `pandoc` |
254
- | **Images (Raster)** | `png`, `jpg/jpeg`, `webp`, `bmp`, `gif`, `tiff`, `ico`, `heic`, `avif`, `icns`, `pdf` | `Pillow` / `cairosvg` |
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: PDF is an output-only format for documents. Extracting structured text back out of a PDF is handled via the OCR pipeline.*
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` / `openpyxl`) |
209
- | **Documents** | `md`, `html`, `docx`, `odt`, `rtf`, `epub`, `latex`, `rst`, `txt`, `ipynb`, `pptx`, `adoc`, `org`, `opml`, `man`, `pdf`* | `pandoc` |
210
- | **Images (Raster)** | `png`, `jpg/jpeg`, `webp`, `bmp`, `gif`, `tiff`, `ico`, `heic`, `avif`, `icns`, `pdf` | `Pillow` / `cairosvg` |
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: PDF is an output-only format for documents. Extracting structured text back out of a PDF is handled via the OCR pipeline.*
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)