tinyfy 0.1.0__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.
- tinyfy/__init__.py +1 -0
- tinyfy/__main__.py +3 -0
- tinyfy/cli.py +224 -0
- tinyfy/common.py +53 -0
- tinyfy/handlers/__init__.py +27 -0
- tinyfy/handlers/generic.py +133 -0
- tinyfy/handlers/image.py +61 -0
- tinyfy/handlers/media.py +65 -0
- tinyfy/handlers/office.py +38 -0
- tinyfy/handlers/pdf.py +24 -0
- tinyfy-0.1.0.dist-info/METADATA +100 -0
- tinyfy-0.1.0.dist-info/RECORD +16 -0
- tinyfy-0.1.0.dist-info/WHEEL +5 -0
- tinyfy-0.1.0.dist-info/entry_points.txt +3 -0
- tinyfy-0.1.0.dist-info/licenses/LICENSE +21 -0
- tinyfy-0.1.0.dist-info/top_level.txt +1 -0
tinyfy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tinyfy/__main__.py
ADDED
tinyfy/cli.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""tinyfy — compress any file, locally. Installed as `tiny` (and `tinyfy`).
|
|
2
|
+
|
|
3
|
+
tiny photo.jpg video.mov report.pdf
|
|
4
|
+
tiny -l high --max-dim 1920 photos/ --each
|
|
5
|
+
tiny project/ -f zst # pack a folder -> project.tar.zst
|
|
6
|
+
tiny report.pdf -o out/small.pdf # write to an exact file
|
|
7
|
+
tiny -x project.tar.zst # extract
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from tinyfy import __version__
|
|
22
|
+
from tinyfy.common import LEVELS, Options, Skip, human, path_size
|
|
23
|
+
from tinyfy.handlers import generic, pick
|
|
24
|
+
|
|
25
|
+
FORMATS = ("xz", "gz", "zst", "zip")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Task:
|
|
30
|
+
src: Path
|
|
31
|
+
out_dir: Path
|
|
32
|
+
mirror: bool = False # always leave a file in out_dir (copy the original if it can't be shrunk)
|
|
33
|
+
smart_only: bool = False # --each: never turn unknown files into .xz
|
|
34
|
+
out_file: Path | None = None # -o points at an exact file
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Result:
|
|
39
|
+
src: Path
|
|
40
|
+
status: str # ok | copied | skip | error
|
|
41
|
+
before: int = 0
|
|
42
|
+
after: int = 0
|
|
43
|
+
out: Path | None = None
|
|
44
|
+
note: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prog_name() -> str:
|
|
48
|
+
name = Path(sys.argv[0]).stem
|
|
49
|
+
return name if name in ("tiny", "tinyfy") else "tiny"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
p = argparse.ArgumentParser(
|
|
54
|
+
prog=_prog_name(),
|
|
55
|
+
description="Compress any file: images, PDFs, video, audio, Office documents, folders and everything else.",
|
|
56
|
+
epilog=__doc__.split("\n", 2)[2],
|
|
57
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
58
|
+
)
|
|
59
|
+
p.add_argument("paths", nargs="+", type=Path, help="files or folders to compress")
|
|
60
|
+
p.add_argument("-l", "--level", choices=LEVELS, default="medium",
|
|
61
|
+
help="compression level: low (best quality), medium (default), high (smallest)")
|
|
62
|
+
p.add_argument("-o", "--output",
|
|
63
|
+
help="output file (e.g. out/small.pdf) or output folder (e.g. out/ or an existing folder); "
|
|
64
|
+
"default: next to the original with a .min suffix")
|
|
65
|
+
p.add_argument("-f", "--format", dest="fmt", choices=FORMATS,
|
|
66
|
+
help="lossless format for other files/folders "
|
|
67
|
+
"(default: taken from the -o extension if given, otherwise xz)")
|
|
68
|
+
p.add_argument("--lossless", action="store_true",
|
|
69
|
+
help="lossless only: keep every byte, output is .xz/.zst/...")
|
|
70
|
+
p.add_argument("--each", action="store_true",
|
|
71
|
+
help="for folders: compress each file inside instead of packing, into <name>.min/")
|
|
72
|
+
p.add_argument("--max-dim", type=int, metavar="PX", help="downscale images/videos so the longest side is <= PX")
|
|
73
|
+
p.add_argument("--codec", dest="video_codec", choices=("h264", "h265"), default="h264",
|
|
74
|
+
help="video codec (h265 is smaller but slower and less compatible)")
|
|
75
|
+
p.add_argument("--in-place", action="store_true", help="replace the original (only when the result is smaller)")
|
|
76
|
+
p.add_argument("--force", action="store_true", help="keep the result even if it is not smaller")
|
|
77
|
+
p.add_argument("-j", "--jobs", type=int, default=min(4, os.cpu_count() or 1), help="files to process in parallel")
|
|
78
|
+
p.add_argument("-x", "--extract", action="store_true", help="extract .xz/.gz/.zst/.bz2/.zip/.tar.* files")
|
|
79
|
+
p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
80
|
+
return p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def output_file(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Path | None:
|
|
84
|
+
"""-o is a file if it has an extension, doesn't end with '/' and isn't an existing folder."""
|
|
85
|
+
raw = args.output
|
|
86
|
+
args.output = Path(raw) if raw else None
|
|
87
|
+
out = args.output
|
|
88
|
+
if out is None or raw.endswith(("/", os.sep)) or out.is_dir() or not out.suffix:
|
|
89
|
+
return None
|
|
90
|
+
if len(args.paths) != 1 or args.each:
|
|
91
|
+
parser.error("when -o is a file, exactly one input is allowed (and no --each)")
|
|
92
|
+
if args.in_place:
|
|
93
|
+
parser.error("-o cannot be combined with --in-place")
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: list[str] | None = None) -> int:
|
|
98
|
+
parser = build_parser()
|
|
99
|
+
args = parser.parse_args(argv)
|
|
100
|
+
out_file = output_file(args, parser)
|
|
101
|
+
if args.extract:
|
|
102
|
+
return _extract(args, out_file)
|
|
103
|
+
|
|
104
|
+
fmt = args.fmt
|
|
105
|
+
if fmt is None and out_file is not None:
|
|
106
|
+
# -o project.tar.zst / log.gz -> take the format from the extension
|
|
107
|
+
fmt = {".tgz": "gz", ".tzst": "zst"}.get(out_file.suffix.lower(), out_file.suffix.lower().lstrip("."))
|
|
108
|
+
opts = Options(level=args.level, max_dim=args.max_dim, video_codec=args.video_codec,
|
|
109
|
+
fmt=fmt if fmt in FORMATS else "xz", lossless=args.lossless)
|
|
110
|
+
tasks: list[Task] = []
|
|
111
|
+
missing = False
|
|
112
|
+
for p in args.paths:
|
|
113
|
+
if not p.exists():
|
|
114
|
+
print(f"✗ not found: {p}", file=sys.stderr)
|
|
115
|
+
missing = True
|
|
116
|
+
continue
|
|
117
|
+
if p.is_dir() and args.each:
|
|
118
|
+
root = args.output or p.parent / f"{p.name}.min"
|
|
119
|
+
if args.in_place:
|
|
120
|
+
root = p
|
|
121
|
+
for f in sorted(p.rglob("*")):
|
|
122
|
+
if f.is_file():
|
|
123
|
+
tasks.append(Task(f, root / f.relative_to(p).parent, mirror=not args.in_place, smart_only=True))
|
|
124
|
+
else:
|
|
125
|
+
tasks.append(Task(p, args.output or p.parent, out_file=out_file))
|
|
126
|
+
|
|
127
|
+
if not tasks:
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
def work(t: Task) -> Result:
|
|
131
|
+
r = process(t, opts, in_place=args.in_place and not t.src.is_dir(), force=args.force)
|
|
132
|
+
report(r)
|
|
133
|
+
return r
|
|
134
|
+
|
|
135
|
+
with ThreadPoolExecutor(max(1, args.jobs)) as pool:
|
|
136
|
+
results = list(pool.map(work, tasks))
|
|
137
|
+
summary(results)
|
|
138
|
+
return 1 if missing or any(r.status == "error" for r in results) else 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def process(t: Task, opts: Options, in_place: bool, force: bool) -> Result:
|
|
142
|
+
src = t.src
|
|
143
|
+
before = path_size(src)
|
|
144
|
+
res = Result(src, "error", before=before)
|
|
145
|
+
try:
|
|
146
|
+
handler = pick(src, opts, smart_only=t.smart_only)
|
|
147
|
+
if handler is None:
|
|
148
|
+
raise Skip("no dedicated compressor for this type")
|
|
149
|
+
with tempfile.TemporaryDirectory(prefix="tinyfy-") as tmp:
|
|
150
|
+
produced = handler(src, Path(tmp), opts)
|
|
151
|
+
after = produced.stat().st_size
|
|
152
|
+
if after >= before and not force:
|
|
153
|
+
raise Skip(f"result is not smaller ({human(after)})")
|
|
154
|
+
dest = t.out_file or _destination(src, produced.name, t.out_dir, in_place)
|
|
155
|
+
if dest.is_dir():
|
|
156
|
+
raise IsADirectoryError(f"{dest} is a folder, refusing to overwrite it")
|
|
157
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
shutil.move(produced, dest) # overwrites a previous result
|
|
159
|
+
if in_place and dest != src:
|
|
160
|
+
src.unlink() # extension changed (e.g. .mov -> .mp4): remove the original
|
|
161
|
+
res.status, res.after, res.out = "ok", after, dest
|
|
162
|
+
except Skip as e:
|
|
163
|
+
res.status, res.after, res.note = "skip", before, str(e)
|
|
164
|
+
if t.mirror:
|
|
165
|
+
dest = t.out_dir / src.name
|
|
166
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
shutil.copy2(src, dest)
|
|
168
|
+
res.status, res.out = "copied", dest
|
|
169
|
+
except Exception as e: # noqa: BLE001 — one bad file must not stop the whole run
|
|
170
|
+
res.note = f"{type(e).__name__}: {e}"
|
|
171
|
+
return res
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _destination(src: Path, name: str, out_dir: Path, in_place: bool) -> Path:
|
|
175
|
+
if in_place:
|
|
176
|
+
return src.with_name(name)
|
|
177
|
+
dest = out_dir / name
|
|
178
|
+
if dest.resolve() == src.resolve():
|
|
179
|
+
# same folder, same name -> insert .min before the extension
|
|
180
|
+
stem, dot, ext = name.rpartition(".")
|
|
181
|
+
dest = out_dir / (f"{stem}.min.{ext}" if dot else f"{name}.min")
|
|
182
|
+
return dest
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def report(r: Result) -> None:
|
|
186
|
+
if r.status == "ok":
|
|
187
|
+
saved = 100 * (1 - r.after / r.before) if r.before else 0
|
|
188
|
+
print(f"✓ {r.src} {human(r.before)} → {human(r.after)} (-{saved:.0f}%) ⇒ {r.out}")
|
|
189
|
+
elif r.status == "copied":
|
|
190
|
+
print(f"= {r.src} copied unchanged ({r.note})")
|
|
191
|
+
elif r.status == "skip":
|
|
192
|
+
print(f"- {r.src} skipped: {r.note}")
|
|
193
|
+
else:
|
|
194
|
+
print(f"✗ {r.src} error: {r.note}", file=sys.stderr)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def summary(results: list[Result]) -> None:
|
|
198
|
+
if len(results) < 2:
|
|
199
|
+
return
|
|
200
|
+
before = sum(r.before for r in results if r.status in ("ok", "copied"))
|
|
201
|
+
after = sum(r.after for r in results if r.status in ("ok", "copied"))
|
|
202
|
+
counts = {s: sum(r.status == s for r in results) for s in ("ok", "copied", "skip", "error")}
|
|
203
|
+
saved = 100 * (1 - after / before) if before else 0
|
|
204
|
+
print(f"\nTotal: {counts['ok']} compressed, {counts['copied'] + counts['skip']} unchanged/skipped, "
|
|
205
|
+
f"{counts['error']} failed — {human(before)} → {human(after)} (-{saved:.0f}%)")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _extract(args: argparse.Namespace, out_file: Path | None) -> int:
|
|
209
|
+
code = 0
|
|
210
|
+
for p in args.paths:
|
|
211
|
+
try:
|
|
212
|
+
if out_file:
|
|
213
|
+
out = generic.extract(p, out_file.parent, out_file)
|
|
214
|
+
else:
|
|
215
|
+
out = generic.extract(p, args.output or p.parent)
|
|
216
|
+
print(f"✓ {p} ⇒ {out}")
|
|
217
|
+
except Exception as e: # noqa: BLE001
|
|
218
|
+
print(f"✗ {p} error: {e}", file=sys.stderr)
|
|
219
|
+
code = 1
|
|
220
|
+
return code
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
raise SystemExit(main())
|
tinyfy/common.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared types and helpers used by the CLI and all handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
LEVELS = ("low", "medium", "high")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Options:
|
|
15
|
+
level: str = "medium" # low = best quality, high = smallest
|
|
16
|
+
max_dim: int | None = None # longest side (px) for images/videos
|
|
17
|
+
video_codec: str = "h264" # h264 | h265
|
|
18
|
+
fmt: str = "xz" # lossless format: xz | gz | zst | zip
|
|
19
|
+
lossless: bool = False # lossless compression only
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Skip(Exception):
|
|
23
|
+
"""The file should not or cannot be compressed (already compressed, missing tool, ...)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def need_tool(name: str, *alternatives: str) -> str:
|
|
27
|
+
"""Find an external program, trying alternative names (e.g. Ghostscript is gswin64c on Windows)."""
|
|
28
|
+
for candidate in (name, *alternatives):
|
|
29
|
+
path = shutil.which(candidate)
|
|
30
|
+
if path:
|
|
31
|
+
return path
|
|
32
|
+
raise Skip(f"install '{name}' to compress this file type")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(cmd: list[str]) -> None:
|
|
36
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
37
|
+
if proc.returncode != 0:
|
|
38
|
+
msg = (proc.stderr or proc.stdout).strip().splitlines()
|
|
39
|
+
raise RuntimeError(msg[-1] if msg else f"{Path(cmd[0]).name} failed (exit code {proc.returncode})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def path_size(path: Path) -> int:
|
|
43
|
+
if path.is_dir():
|
|
44
|
+
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
45
|
+
return path.stat().st_size
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def human(n: float) -> str:
|
|
49
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
50
|
+
if abs(n) < 1024:
|
|
51
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
52
|
+
n /= 1024
|
|
53
|
+
return f"{n:.1f} TB"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from tinyfy.common import Options
|
|
7
|
+
from tinyfy.handlers import generic, image, media, office, pdf
|
|
8
|
+
|
|
9
|
+
Handler = Callable[[Path, Path, Options], Path]
|
|
10
|
+
|
|
11
|
+
_SMART: list[tuple[set[str], Handler]] = [
|
|
12
|
+
(image.EXTS, image.run),
|
|
13
|
+
(pdf.EXTS, pdf.run),
|
|
14
|
+
(media.EXTS, media.run),
|
|
15
|
+
(office.EXTS, office.run),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pick(src: Path, opts: Options, smart_only: bool = False) -> Handler | None:
|
|
20
|
+
"""Pick a handler by extension. smart_only: return None instead of falling back to lossless."""
|
|
21
|
+
if src.is_dir() or opts.lossless:
|
|
22
|
+
return generic.run
|
|
23
|
+
ext = src.suffix.lower()
|
|
24
|
+
for exts, handler in _SMART:
|
|
25
|
+
if ext in exts:
|
|
26
|
+
return handler
|
|
27
|
+
return None if smart_only else generic.run
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Lossless compression for any file/folder: xz, gz, zst, zip (folders -> tar.*)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import lzma
|
|
7
|
+
import shutil
|
|
8
|
+
import tarfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from tinyfy.common import Options, Skip, need_tool, run as run_cmd
|
|
13
|
+
|
|
14
|
+
# Already compressed; compressing again gains almost nothing
|
|
15
|
+
ALREADY_COMPRESSED = {
|
|
16
|
+
".zip", ".gz", ".tgz", ".xz", ".txz", ".bz2", ".zst", ".7z", ".rar", ".lz4", ".br", ".lzma",
|
|
17
|
+
".gif", ".heic", ".heif", ".avif", ".jxl", ".apk", ".jar", ".dmg", ".whl",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
XZ_PRESET = {"low": 3, "medium": 6, "high": 9 | lzma.PRESET_EXTREME}
|
|
21
|
+
GZ_LEVEL = {"low": 6, "medium": 9, "high": 9}
|
|
22
|
+
ZST_LEVEL = {"low": 3, "medium": 12, "high": 19}
|
|
23
|
+
ZIP_LEVEL = {"low": 6, "medium": 9, "high": 9}
|
|
24
|
+
|
|
25
|
+
CHUNK = 1 << 20
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(src: Path, tmp: Path, opts: Options) -> Path:
|
|
29
|
+
if src.is_dir():
|
|
30
|
+
return _dir(src, tmp, opts)
|
|
31
|
+
if src.suffix.lower() in ALREADY_COMPRESSED:
|
|
32
|
+
raise Skip("already compressed")
|
|
33
|
+
return _file(src, tmp, opts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _file(src: Path, tmp: Path, opts: Options) -> Path:
|
|
37
|
+
fmt, lvl = opts.fmt, opts.level
|
|
38
|
+
out = tmp / f"{src.name}.{fmt}"
|
|
39
|
+
if fmt == "xz":
|
|
40
|
+
with src.open("rb") as fi, lzma.open(out, "wb", preset=XZ_PRESET[lvl]) as fo:
|
|
41
|
+
shutil.copyfileobj(fi, fo, CHUNK)
|
|
42
|
+
elif fmt == "gz":
|
|
43
|
+
with src.open("rb") as fi, gzip.open(out, "wb", compresslevel=GZ_LEVEL[lvl]) as fo:
|
|
44
|
+
shutil.copyfileobj(fi, fo, CHUNK)
|
|
45
|
+
elif fmt == "zst":
|
|
46
|
+
_zstd(src, out, lvl)
|
|
47
|
+
elif fmt == "zip":
|
|
48
|
+
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=ZIP_LEVEL[lvl]) as z:
|
|
49
|
+
z.write(src, src.name)
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(fmt)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dir(src: Path, tmp: Path, opts: Options) -> Path:
|
|
56
|
+
fmt, lvl = opts.fmt, opts.level
|
|
57
|
+
if fmt == "zip":
|
|
58
|
+
out = tmp / f"{src.name}.zip"
|
|
59
|
+
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=ZIP_LEVEL[lvl]) as z:
|
|
60
|
+
for f in sorted(src.rglob("*")):
|
|
61
|
+
z.write(f, f.relative_to(src.parent))
|
|
62
|
+
return out
|
|
63
|
+
out = tmp / f"{src.name}.tar.{fmt}"
|
|
64
|
+
if fmt == "xz":
|
|
65
|
+
with tarfile.open(out, "w:xz", preset=XZ_PRESET[lvl]) as t:
|
|
66
|
+
t.add(src, src.name)
|
|
67
|
+
elif fmt == "gz":
|
|
68
|
+
with tarfile.open(out, "w:gz", compresslevel=GZ_LEVEL[lvl]) as t:
|
|
69
|
+
t.add(src, src.name)
|
|
70
|
+
elif fmt == "zst":
|
|
71
|
+
plain = tmp / f"{src.name}.tar"
|
|
72
|
+
with tarfile.open(plain, "w") as t:
|
|
73
|
+
t.add(src, src.name)
|
|
74
|
+
_zstd(plain, out, lvl)
|
|
75
|
+
plain.unlink()
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(fmt)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _zstd(src: Path, out: Path, lvl: str) -> None:
|
|
82
|
+
zstd = need_tool("zstd")
|
|
83
|
+
run_cmd([zstd, "-q", "-f", "-T0", "--long=27", f"-{ZST_LEVEL[lvl]}", str(src), "-o", str(out)])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------- extraction ----------
|
|
87
|
+
|
|
88
|
+
def extract(src: Path, dest: Path, out_file: Path | None = None) -> Path:
|
|
89
|
+
"""Extract into folder dest (overwriting existing files). out_file: exact target for .xz/.gz/.zst/.bz2."""
|
|
90
|
+
name = src.name.lower()
|
|
91
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
is_archive = name.endswith((".tar.zst", ".tzst")) or tarfile.is_tarfile(src) or zipfile.is_zipfile(src)
|
|
93
|
+
if out_file and is_archive:
|
|
94
|
+
raise Skip("this archive holds multiple files, -o must be a folder")
|
|
95
|
+
if name.endswith((".tar.zst", ".tzst")):
|
|
96
|
+
zstd = need_tool("zstd")
|
|
97
|
+
plain = dest / (src.name.rsplit(".", 1)[0] + ".tmp.tar")
|
|
98
|
+
run_cmd([zstd, "-d", "-q", "-f", "--long=31", str(src), "-o", str(plain)])
|
|
99
|
+
try:
|
|
100
|
+
_untar(plain, dest)
|
|
101
|
+
finally:
|
|
102
|
+
plain.unlink()
|
|
103
|
+
return dest
|
|
104
|
+
if tarfile.is_tarfile(src):
|
|
105
|
+
_untar(src, dest)
|
|
106
|
+
return dest
|
|
107
|
+
if zipfile.is_zipfile(src):
|
|
108
|
+
with zipfile.ZipFile(src) as z:
|
|
109
|
+
z.extractall(dest)
|
|
110
|
+
return dest
|
|
111
|
+
out = out_file or dest / src.name.rsplit(".", 1)[0]
|
|
112
|
+
if out.is_dir():
|
|
113
|
+
raise IsADirectoryError(f"{out} is a folder, refusing to overwrite it")
|
|
114
|
+
if name.endswith(".zst"):
|
|
115
|
+
run_cmd([need_tool("zstd"), "-d", "-q", "-f", "--long=31", str(src), "-o", str(out)])
|
|
116
|
+
return out
|
|
117
|
+
opener = {".xz": lzma.open, ".lzma": lzma.open, ".gz": gzip.open}.get(src.suffix.lower())
|
|
118
|
+
if opener is None:
|
|
119
|
+
import bz2
|
|
120
|
+
if src.suffix.lower() != ".bz2":
|
|
121
|
+
raise Skip("unrecognized compression format")
|
|
122
|
+
opener = bz2.open
|
|
123
|
+
with opener(src, "rb") as fi, out.open("wb") as fo:
|
|
124
|
+
shutil.copyfileobj(fi, fo, CHUNK)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _untar(src: Path, dest: Path) -> None:
|
|
129
|
+
with tarfile.open(src) as t:
|
|
130
|
+
try:
|
|
131
|
+
t.extractall(dest, filter="data")
|
|
132
|
+
except TypeError: # Python < 3.10.12 has no filter argument
|
|
133
|
+
t.extractall(dest)
|
tinyfy/handlers/image.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Images: re-encode JPEG/WebP by quality, optimize PNG (lossy at high), BMP -> PNG, TIFF -> deflate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tinyfy.common import Options, Skip
|
|
9
|
+
|
|
10
|
+
EXTS = {".jpg", ".jpeg", ".jfif", ".png", ".webp", ".bmp", ".tif", ".tiff"}
|
|
11
|
+
|
|
12
|
+
QUALITY = {"low": 85, "medium": 75, "high": 60}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pil():
|
|
16
|
+
try:
|
|
17
|
+
from PIL import Image, ImageOps
|
|
18
|
+
except ImportError:
|
|
19
|
+
raise Skip("install Pillow (pip install Pillow) to compress images")
|
|
20
|
+
return Image, ImageOps
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compress_bytes(data: bytes, ext: str, opts: Options) -> bytes:
|
|
24
|
+
"""Compress an image in memory, keeping its format. Used for single files and images inside Office files."""
|
|
25
|
+
Image, ImageOps = _pil()
|
|
26
|
+
ext = ext.lower()
|
|
27
|
+
with Image.open(io.BytesIO(data)) as src:
|
|
28
|
+
if getattr(src, "n_frames", 1) > 1:
|
|
29
|
+
raise Skip("animated/multi-page image")
|
|
30
|
+
icc = src.info.get("icc_profile")
|
|
31
|
+
im = ImageOps.exif_transpose(src)
|
|
32
|
+
if opts.max_dim:
|
|
33
|
+
im.thumbnail((opts.max_dim, opts.max_dim), Image.LANCZOS)
|
|
34
|
+
|
|
35
|
+
out = io.BytesIO()
|
|
36
|
+
q = QUALITY[opts.level]
|
|
37
|
+
if ext in (".jpg", ".jpeg", ".jfif"):
|
|
38
|
+
if im.mode not in ("RGB", "L"):
|
|
39
|
+
im = im.convert("RGB")
|
|
40
|
+
im.save(out, "JPEG", quality=q, optimize=True, progressive=True, icc_profile=icc)
|
|
41
|
+
elif ext == ".webp":
|
|
42
|
+
im.save(out, "WEBP", quality=q, method=6, icc_profile=icc)
|
|
43
|
+
elif ext in (".png", ".bmp"):
|
|
44
|
+
if opts.level == "high" and im.mode not in ("P", "L", "1"):
|
|
45
|
+
if im.mode not in ("RGB", "RGBA"):
|
|
46
|
+
im = im.convert("RGBA")
|
|
47
|
+
im = im.quantize(256, method=Image.Quantize.FASTOCTREE)
|
|
48
|
+
im.save(out, "PNG", optimize=True, icc_profile=icc)
|
|
49
|
+
elif ext in (".tif", ".tiff"):
|
|
50
|
+
im.save(out, "TIFF", compression="tiff_adobe_deflate", icc_profile=icc)
|
|
51
|
+
else:
|
|
52
|
+
raise Skip(f"unsupported image type {ext}")
|
|
53
|
+
return out.getvalue()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(src: Path, tmp: Path, opts: Options) -> Path:
|
|
57
|
+
ext = src.suffix.lower()
|
|
58
|
+
# BMP is uncompressed -> save as PNG (lossless at low/medium)
|
|
59
|
+
out = tmp / (src.stem + ".png" if ext == ".bmp" else src.name)
|
|
60
|
+
out.write_bytes(compress_bytes(src.read_bytes(), ext, opts))
|
|
61
|
+
return out
|
tinyfy/handlers/media.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Video and audio through ffmpeg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tinyfy.common import Options, need_tool, run as run_cmd
|
|
8
|
+
|
|
9
|
+
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp", ".ts"}
|
|
10
|
+
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".ogg", ".opus", ".wma"}
|
|
11
|
+
EXTS = VIDEO_EXTS | AUDIO_EXTS
|
|
12
|
+
|
|
13
|
+
CRF = {
|
|
14
|
+
"h264": {"low": 23, "medium": 26, "high": 30},
|
|
15
|
+
"h265": {"low": 26, "medium": 28, "high": 32},
|
|
16
|
+
}
|
|
17
|
+
VIDEO_AUDIO_BITRATE = {"low": "160k", "medium": "128k", "high": "96k"}
|
|
18
|
+
AUDIO_BITRATE = {"low": "192k", "medium": "128k", "high": "96k"}
|
|
19
|
+
OPUS_BITRATE = {"low": "128k", "medium": "96k", "high": "64k"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ffmpeg(args: list[str]) -> None:
|
|
23
|
+
run_cmd([need_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", *args])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(src: Path, tmp: Path, opts: Options) -> Path:
|
|
27
|
+
if src.suffix.lower() in VIDEO_EXTS:
|
|
28
|
+
return _video(src, tmp, opts)
|
|
29
|
+
return _audio(src, tmp, opts)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _video(src: Path, tmp: Path, opts: Options) -> Path:
|
|
33
|
+
out = tmp / (src.stem + ".mp4")
|
|
34
|
+
# yuv420p needs even dimensions
|
|
35
|
+
vf = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
36
|
+
if opts.max_dim:
|
|
37
|
+
d = opts.max_dim
|
|
38
|
+
vf = f"scale='min(iw,{d})':'min(ih,{d})':force_original_aspect_ratio=decrease," + vf
|
|
39
|
+
codec = ["-c:v", "libx264", "-preset", "slow"]
|
|
40
|
+
if opts.video_codec == "h265":
|
|
41
|
+
codec = ["-c:v", "libx265", "-preset", "medium", "-tag:v", "hvc1", "-x265-params", "log-level=error"]
|
|
42
|
+
_ffmpeg([
|
|
43
|
+
"-i", str(src),
|
|
44
|
+
"-map", "0:v:0", "-map", "0:a?", "-map_metadata", "0",
|
|
45
|
+
*codec, "-crf", str(CRF[opts.video_codec][opts.level]), "-pix_fmt", "yuv420p", "-vf", vf,
|
|
46
|
+
"-c:a", "aac", "-b:a", VIDEO_AUDIO_BITRATE[opts.level],
|
|
47
|
+
"-movflags", "+faststart", str(out),
|
|
48
|
+
])
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _audio(src: Path, tmp: Path, opts: Options) -> Path:
|
|
53
|
+
ext = src.suffix.lower()
|
|
54
|
+
lvl = opts.level
|
|
55
|
+
if ext in (".wav", ".aiff", ".aif", ".flac") and lvl == "low":
|
|
56
|
+
out, codec = tmp / (src.stem + ".flac"), ["-c:a", "flac", "-compression_level", "12"]
|
|
57
|
+
elif ext in (".m4a", ".aac"):
|
|
58
|
+
out, codec = tmp / (src.stem + ".m4a"), ["-c:a", "aac", "-b:a", AUDIO_BITRATE[lvl]]
|
|
59
|
+
elif ext in (".ogg", ".opus"):
|
|
60
|
+
out, codec = tmp / src.name, ["-c:a", "libopus", "-b:a", OPUS_BITRATE[lvl]]
|
|
61
|
+
else:
|
|
62
|
+
# mp3 is the most compatible; wav/flac at medium/high also become mp3
|
|
63
|
+
out, codec = tmp / (src.stem + ".mp3"), ["-c:a", "libmp3lame", "-b:a", AUDIO_BITRATE[lvl]]
|
|
64
|
+
_ffmpeg(["-i", str(src), "-map", "0:a:0", "-map_metadata", "0", *codec, str(out)])
|
|
65
|
+
return out
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Office/ODF/EPUB (zip containers): recompress embedded images and re-zip at maximum level."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
from tinyfy.common import Options, Skip
|
|
9
|
+
from tinyfy.handlers import image
|
|
10
|
+
|
|
11
|
+
EXTS = {".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp", ".epub"}
|
|
12
|
+
|
|
13
|
+
MIN_IMAGE_BYTES = 20 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(src: Path, tmp: Path, opts: Options) -> Path:
|
|
17
|
+
out = tmp / src.name
|
|
18
|
+
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zout:
|
|
19
|
+
infos = zin.infolist()
|
|
20
|
+
# ODF/EPUB require 'mimetype' to be the first entry, stored uncompressed
|
|
21
|
+
infos.sort(key=lambda i: i.filename != "mimetype")
|
|
22
|
+
for info in infos:
|
|
23
|
+
data = zin.read(info)
|
|
24
|
+
if info.filename == "mimetype":
|
|
25
|
+
zout.writestr(info, data, compress_type=zipfile.ZIP_STORED)
|
|
26
|
+
continue
|
|
27
|
+
ext = PurePosixPath(info.filename).suffix.lower()
|
|
28
|
+
if ext in image.EXTS and ext not in (".bmp", ".tif", ".tiff") and len(data) >= MIN_IMAGE_BYTES:
|
|
29
|
+
try:
|
|
30
|
+
smaller = image.compress_bytes(data, ext, opts)
|
|
31
|
+
if len(smaller) < len(data):
|
|
32
|
+
data = smaller
|
|
33
|
+
except Skip:
|
|
34
|
+
pass
|
|
35
|
+
except Exception:
|
|
36
|
+
pass # unreadable image: keep it as is
|
|
37
|
+
zout.writestr(info.filename, data, compress_type=zipfile.ZIP_DEFLATED)
|
|
38
|
+
return out
|
tinyfy/handlers/pdf.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""PDF: rewrite through Ghostscript, downsampling embedded images by level."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tinyfy.common import Options, need_tool, run as run_cmd
|
|
8
|
+
|
|
9
|
+
EXTS = {".pdf"}
|
|
10
|
+
|
|
11
|
+
PRESET = {"low": "/printer", "medium": "/ebook", "high": "/screen"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(src: Path, tmp: Path, opts: Options) -> Path:
|
|
15
|
+
gs = need_tool("gs", "gswin64c", "gswin32c")
|
|
16
|
+
out = tmp / src.name
|
|
17
|
+
run_cmd([
|
|
18
|
+
gs, "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.5",
|
|
19
|
+
f"-dPDFSETTINGS={PRESET[opts.level]}",
|
|
20
|
+
"-dDetectDuplicateImages=true", "-dCompressFonts=true", "-dSubsetFonts=true",
|
|
21
|
+
"-dNOPAUSE", "-dBATCH", "-dQUIET", "-dSAFER",
|
|
22
|
+
f"-sOutputFile={out}", str(src),
|
|
23
|
+
])
|
|
24
|
+
return out
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tinyfy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compress any file locally: images, PDFs, video, audio, Office documents, folders and more
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/khanhle-dev/tinyfy
|
|
7
|
+
Project-URL: Source, https://github.com/khanhle-dev/tinyfy
|
|
8
|
+
Project-URL: Issues, https://github.com/khanhle-dev/tinyfy/issues
|
|
9
|
+
Keywords: compress,compression,image,pdf,video,ffmpeg,ghostscript,cli
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: System :: Archiving :: Compression
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: Pillow>=9.1
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# tinyfy
|
|
26
|
+
|
|
27
|
+
Compress any file from the command line, entirely on your machine. Each file type gets the method that suits it best:
|
|
28
|
+
|
|
29
|
+
| Type | Extensions | How |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| Images | jpg, png, webp, bmp, tiff | Re-encode with Pillow, optimize PNG (palette reduction at `high`), BMP → PNG |
|
|
32
|
+
| PDF | pdf | Ghostscript (`/printer`, `/ebook`, `/screen`) |
|
|
33
|
+
| Video | mp4, mov, mkv, avi, webm, ... | ffmpeg → MP4 H.264 (or H.265 with `--codec h265`) |
|
|
34
|
+
| Audio | mp3, wav, flac, m4a, ogg, ... | ffmpeg (wav/flac → FLAC at `low`, MP3 otherwise) |
|
|
35
|
+
| Office | docx, xlsx, pptx, odt, epub, ... | Recompress embedded images and re-zip at maximum level |
|
|
36
|
+
| Everything else | any file or folder | Lossless: xz (default), gz, zst, zip; folders → `tar.*` |
|
|
37
|
+
|
|
38
|
+
If the compressed result is **not smaller** than the original, the original is kept (unless you pass `--force`).
|
|
39
|
+
Originals are never overwritten unless you pass `--in-place`.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pipx install tinyfy # recommended
|
|
45
|
+
pip install tinyfy # or inside a virtualenv
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This installs the `tiny` command (`tinyfy` also works as an alias).
|
|
49
|
+
|
|
50
|
+
Requires Python 3.10+. Images and lossless compression work out of the box. Other formats need these programs on your `PATH`:
|
|
51
|
+
|
|
52
|
+
| Program | Needed for | Debian/Ubuntu | macOS | Windows |
|
|
53
|
+
| --- | --- | --- | --- | --- |
|
|
54
|
+
| ffmpeg | video, audio | `sudo apt install ffmpeg` | `brew install ffmpeg` | `winget install Gyan.FFmpeg` |
|
|
55
|
+
| Ghostscript | PDF | `sudo apt install ghostscript` | `brew install ghostscript` | installer from [ghostscript.com](https://ghostscript.com/releases/gsdnld.html) |
|
|
56
|
+
| zstd | `-f zst` | `sudo apt install zstd` | `brew install zstd` | release from [facebook/zstd](https://github.com/facebook/zstd/releases) |
|
|
57
|
+
|
|
58
|
+
If a program is missing, files that need it are skipped with a message; everything else still runs.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
tiny photo.jpg video.mov report.pdf # → photo.min.jpg, video.mp4, report.min.pdf
|
|
64
|
+
tiny -l high --max-dim 1920 *.jpg -o small/ # stronger compression, downscale, save into small/
|
|
65
|
+
tiny report.pdf -o out/small.pdf # write to an exact file
|
|
66
|
+
tiny project/ # pack a folder → project.tar.xz
|
|
67
|
+
tiny project/ -o project.tar.zst # format taken from the extension
|
|
68
|
+
tiny photos/ --each # compress each file → photos.min/ (same structure)
|
|
69
|
+
tiny report.docx --lossless # keep every byte → report.docx.xz
|
|
70
|
+
tiny -x project.tar.zst # extract
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Options
|
|
74
|
+
|
|
75
|
+
| Option | Description |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `-l, --level low\|medium\|high` | `low` keeps the best quality, `high` gives the smallest files. Default: `medium`. |
|
|
78
|
+
| `-o PATH` | A path with an extension (`out/small.pdf`, `project.tar.zst`) is written as that exact file. A path ending in `/`, or an existing folder, is used as the output folder. |
|
|
79
|
+
| `-f xz\|gz\|zst\|zip` | Lossless format for other files and folders. Defaults to the `-o` extension, otherwise `xz`. |
|
|
80
|
+
| `--lossless` | Use lossless compression for every file, so the original can be restored byte for byte. |
|
|
81
|
+
| `--each` | For folders: compress each file inside instead of packing them into one archive. Files without a dedicated compressor are copied unchanged. |
|
|
82
|
+
| `--max-dim PX` | Downscale images and videos so the longest side is at most `PX`. |
|
|
83
|
+
| `--codec h264\|h265` | Video codec. H.265 is smaller but slower to encode and less widely supported. |
|
|
84
|
+
| `--in-place` | Replace the original when the result is smaller. |
|
|
85
|
+
| `--force` | Keep the result even when it is not smaller. |
|
|
86
|
+
| `-j N` | Number of files processed in parallel. |
|
|
87
|
+
| `-x, --extract` | Extract `.xz`, `.gz`, `.zst`, `.bz2`, `.zip` and `.tar.*` files. |
|
|
88
|
+
|
|
89
|
+
Re-running a command overwrites the previous output.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python3 -m venv .venv && .venv/bin/pip install -e .
|
|
95
|
+
.venv/bin/python -m unittest discover tests
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
tinyfy/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
tinyfy/__main__.py,sha256=WcYZdNF2k1mDP7E0omUDLbKbzUPuFjrJ0dapwJwSznA,54
|
|
3
|
+
tinyfy/cli.py,sha256=4yFDxgdLECUR0zCNu2FpchfDU3AzH3bQcJgtOqfNIBM,9395
|
|
4
|
+
tinyfy/common.py,sha256=VYkxkF2p65TOcXCOx5-qf9s7tKOELxUy_UB3VgnZMRs,1748
|
|
5
|
+
tinyfy/handlers/__init__.py,sha256=1t_-rVU3AjyFqknqtt1bJWj0p8NSaCW1HM9jjlS-7Gc,803
|
|
6
|
+
tinyfy/handlers/generic.py,sha256=gn-vp0FtUk9S8hKHn4gCZxcTMllX3V1qwcf5PrS4_rI,4762
|
|
7
|
+
tinyfy/handlers/image.py,sha256=qmt1bH7Ob4E5oxsZOWRF1ZsxrE8H8pPKf3CylaBkE8Y,2334
|
|
8
|
+
tinyfy/handlers/media.py,sha256=0rAMYMkY0cgum7Cj8itaxcS0DBta8u5J18PnhPJ9sVM,2654
|
|
9
|
+
tinyfy/handlers/office.py,sha256=RVPLGkR8k_hvRtMhqr1F8aAzITnQUFYZhvtkc-K_cjk,1524
|
|
10
|
+
tinyfy/handlers/pdf.py,sha256=OV_H7MQLAEwMioWweip1SMFOhuwkGnVyyNh2uzMY0eA,744
|
|
11
|
+
tinyfy-0.1.0.dist-info/licenses/LICENSE,sha256=H3Yr_8eANaPDcTjGbL7xXzW3VTuJTZG0bekjXjLHzkY,1071
|
|
12
|
+
tinyfy-0.1.0.dist-info/METADATA,sha256=2egKDClocqgNnD8A6R1tK3KlSMsrb93sSJ7lBPSw8tg,4915
|
|
13
|
+
tinyfy-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
tinyfy-0.1.0.dist-info/entry_points.txt,sha256=hurkb74K60_I16X2hfQHl1GGKkib6UvBh1DF4FFBVP0,66
|
|
15
|
+
tinyfy-0.1.0.dist-info/top_level.txt,sha256=SRWK7J4GDdvRCxDTIPK10Jy2KgcikaAt9ImQsbzx8QM,7
|
|
16
|
+
tinyfy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tinyfy authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tinyfy
|