Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: ci

on:
push:
pull_request:

jobs:
checks:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.x"]

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Upgrade pip
run: python -m pip install --upgrade pip

- name: Install package and development tools
run: python -m pip install -e ".[dev]"

- name: Check formatting
run: python -m black --check main.py cli tests

- name: Run tests
run: python -m pytest -q

- name: Check version command
run: python main.py version
46 changes: 31 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,34 @@ Fast, simple image format conversion for files. Built on Pillow
- Optional removal of source files after successful conversion.

## Setup

For normal CLI usage:

```bash
pip install -r requirements.txt
pipx install .
imc --help
```

For local development:

```bash
python -m pip install -e ".[dev]"
python main.py --help
```

## Quick Start
```bash
python main.py list-formats
python main.py convert image.webp --to png
python main.py convert images/ --to jpg --recursive --output out
python main.py convert image.png --to webp --remove-source
imc list-formats
imc convert image.webp --to png
imc convert images/ --to jpg --recursive --output out
imc convert image.png --to webp --remove-source
```

## Commands
| Command | Description | Example |
| --- | --- | --- |
| `list-formats` | List all supported extensions. | `python main.py list-formats` |
| `convert` | Convert files or directories to a target format. | `python main.py convert images/ --to png` |
| `list-formats` | List readable and writable extensions. | `imc list-formats` |
| `convert` | Convert files or directories to a target format. | `imc convert images/ --to png` |

## Flags (convert)
| Flag | Alias | Type | Default | Description |
Expand All @@ -39,28 +50,33 @@ python main.py convert image.png --to webp --remove-source

## Output Rules
- If `--output` is omitted, converted files are saved next to their sources.
- If `--output` is a directory, outputs go inside it.
- If converting a directory with `--recursive`, the folder structure is preserved inside the output directory.
- If multiple inputs are provided, `--output` must be a directory.
- If a folder input is provided, `--output` must be a directory.
- For multiple inputs or any folder input, `--output` is always treated as an output directory.
- For one explicit file input, an existing `--output` directory receives the converted file.
- For one explicit file input, a non-existing `--output` path with a suffix is treated as the output file path.
- Recursive folder conversion preserves paths relative to the input folder inside the output directory.
- When recursively converting multiple folder inputs to one output directory, each input folder name is preserved under the output directory.

## Usage Examples
Convert a single file to PNG:
```bash
python main.py convert image.webp --to png
imc convert image.webp --to png
```

Convert a folder recursively and delete originals:
```bash
python main.py convert photos/ --to jpg --recursive --remove-source
imc convert photos/ --to jpg --recursive --remove-source
```

Save outputs to a different directory:
```bash
python main.py convert photos/ --to webp --recursive --output converted/
imc convert photos/ --to webp --recursive --output converted/
```

Set quality for JPEG/WEBP/AVIF:
```bash
python main.py convert image.png --to jpg --quality 85
imc convert image.png --to jpg --quality 85
```

## Notes
- Metadata such as EXIF, ICC profiles, orientation tags and DPI is not preserved.
- When converting transparent images to JPEG, transparency is discarded by converting the image to RGB.
66 changes: 51 additions & 15 deletions cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,80 +4,116 @@
from pathlib import Path

from cli.convert import ConvertOptions, convert_paths, parse_quality
from cli.utils import available_extensions, normalize_extension
from cli.utils import normalize_extension, readable_extensions, writable_extensions
from importlib.metadata import version, PackageNotFoundError


def parse_quality_arg(value: str) -> int:
try:
return parse_quality(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid quality: {exc}") from exc


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="converter",
description="Convert images between formats using Pillow.",
)
subparsers = parser.add_subparsers(dest="command", required=True)

convert_parser = subparsers.add_parser("convert", help="Convert files or directories")
convert_parser = subparsers.add_parser(
"convert", help="Convert files or directories"
)
convert_parser.add_argument("input", nargs="+", help="File or directory path(s)")
convert_parser.add_argument("--to", "-t", required=True, help="Target extension (png, jpg, webp, ...)")
convert_parser.add_argument(
"--to", "-t", required=True, help="Target extension (png, jpg, webp, ...)"
)
convert_parser.add_argument("--output", "-o", help="Output directory or file path")
convert_parser.add_argument("--recursive", "-r", action="store_true", help="Convert directories recursively")
convert_parser.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
convert_parser.add_argument(
"--recursive", "-r", action="store_true", help="Convert directories recursively"
)
convert_parser.add_argument(
"--overwrite", action="store_true", help="Overwrite existing files"
)
convert_parser.add_argument(
"--remove-source",
action="store_true",
help="Remove source files after successful conversion",
)
convert_parser.add_argument("--quality", help="Quality for JPEG/WEBP/AVIF (1-100)")
convert_parser.add_argument(
"--quality", type=parse_quality_arg, help="Quality for JPEG/WEBP/AVIF (1-100)"
)

subparsers.add_parser("list-formats", help="List supported extensions")

subparsers.add_parser("version", help="Show version")

return parser


def _handle_list_formats() -> int:
extensions = sorted(available_extensions().keys())
print("Supported extensions:")
print(" ".join(extensions))
readable = sorted(readable_extensions().keys())
writable = sorted(writable_extensions().keys())
print("Readable:")
print(" ".join(readable))
print("Writable:")
print(" ".join(writable))
return 0


def _output_is_file(inputs: list[Path], output: Path | None) -> bool:
if output is None:
return False
if len(inputs) != 1 or inputs[0].is_dir():
return False
if output.exists():
return output.is_file()
return bool(output.suffix)


def _handle_convert(args: argparse.Namespace) -> int:
target_extension = normalize_extension(args.to)
output = Path(args.output).expanduser() if args.output else None
quality = parse_quality(args.quality)
quality = args.quality
inputs = [Path(value).expanduser() for value in args.input]

if output is not None and len(inputs) > 1 and output.suffix:
raise SystemExit("--output must be a directory when converting multiple inputs")
if output is not None and output.suffix and any(path.is_dir() for path in inputs):
raise SystemExit("--output must be a directory when converting a folder")
options = ConvertOptions(
target_extension=target_extension,
output=output,
recursive=args.recursive,
overwrite=args.overwrite,
remove_source=args.remove_source,
quality=quality,
output_is_file=_output_is_file(inputs, output),
)
convert_paths(inputs, options)
return 0


def get_version() -> str:
try:
return version("imc-image-converter")
except PackageNotFoundError:
return "0.0.0"


def _handle_version() -> int:
print(get_version())
return 0


def main() -> None:
parser = _build_parser()
args = parser.parse_args()

if args.command == "list-formats":
raise SystemExit(_handle_list_formats())
if args.command == "convert":
raise SystemExit(_handle_convert(args))
try:
raise SystemExit(_handle_convert(args))
except (ValueError, FileExistsError) as exc:
parser.error(str(exc))
if args.command == "version":
raise SystemExit(_handle_version())
raise SystemExit(1)
Loading
Loading