From bb94bf0b91e42805031ae080c5b9d94acbe1999d Mon Sep 17 00:00:00 2001 From: Stu Leak Date: Tue, 4 Nov 2025 04:24:17 -0500 Subject: [PATCH] Initial commit: img2pdf v1.0.0 --- LICENSE | 21 +++++++++++ PKGBUILD | 28 +++++++++++++++ README.md | 32 +++++++++++++++++ pyproject.toml | 27 ++++++++++++++ src/__init__.py | 19 ++++++++++ src/__main__.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++++ src/config.py | 73 +++++++++++++++++++++++++++++++++++++ src/converter.py | 56 +++++++++++++++++++++++++++++ src/utils.py | 79 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 428 insertions(+) create mode 100644 LICENSE create mode 100644 PKGBUILD create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/__main__.py create mode 100644 src/config.py create mode 100644 src/converter.py create mode 100644 src/utils.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42e0adc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Stu Leak / Leak Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..0b732b8 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,28 @@ +# Maintainer: Stu Leak +# Leak Technologies internal packaging + +pkgname=img2pdf +pkgver=1.0.0 +pkgrel=1 +pkgdesc="Convert a single image or a folder of images to a PDF (sorted by filename)." +arch=('any') +url="https://git.leaktechnologies.dev/Leak-Technologies/img2pdf" +license=('MIT') +depends=('python' 'python-pillow') +makedepends=('python-build' 'python-installer' 'python-setuptools' 'python-wheel') +source=("$pkgname-$pkgver.tar.gz::$url/-/archive/v$pkgver/$pkgname-v$pkgver.tar.gz") +sha256sums=('SKIP') + +build() { + cd "$srcdir/$pkgname-v$pkgver" + python -m build --wheel --no-isolation +} + +package() { + cd "$srcdir/$pkgname-v$pkgver" + python -m installer --destdir="$pkgdir" dist/*.whl +} + +# Usage: +# makepkg -si +# img2pdf ./images -o output.pdf diff --git a/README.md b/README.md new file mode 100644 index 0000000..10c92f0 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Leak Technologies — img2pdf + +**Version:** 1.0.0 +**Author:** Stu Leak () +**License:** MIT + +--- + +## Overview + +`img2pdf` is a lightweight, production-ready command-line utility that converts a single image or a folder of images into a PDF file. +Files are automatically sorted by filename (numerically and alphabetically), ensuring consistent page order. + +Built for Arch Linux systems, fully AUR-ready, and modular enough for GUI integration later. + +--- + +## Features + +- Converts individual images or entire folders +- Auto-sorts images (natural order) +- Supports JPG, PNG, BMP, TIFF, WEBP +- Clean, colorized CLI output +- Safe overwrite handling (`--overwrite`) +- Optional quiet mode for scripting (`--quiet`) + +--- + +## Usage + +```bash +img2pdf [-o output.pdf] [--overwrite] [--quiet] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f120cab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "img2pdf" +version = "1.0.0" +description = "Convert a single image or folder of images into a PDF file (sorted by filename)." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } + +authors = [ + { name = "Stu Leak", email = "leaktechnologies@proton.me" } +] + +dependencies = [ + "pillow>=10.0.0" +] + +urls = { + "Homepage" = "https://git.leaktechnologies.dev/Leak-Technologies/img2pdf", + "Issues" = "https://git.leaktechnologies.dev/Leak-Technologies/img2pdf/issues" +} + +[project.scripts] +img2pdf = "src.__main__:main" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..1ae7163 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,19 @@ +""" +Leak Technologies - img2pdf +--------------------------- +Modular package initializer for img2pdf. + +Provides a simple image-to-PDF conversion interface for importers and external use. + +Example: + from src.converter import ImageToPDFConverter +""" + +from .converter import ImageToPDFConverter +from .config import ( + APP_NAME, + APP_VERSION, + APP_AUTHOR, + APP_DESCRIPTION, + SUPPORTED_EXTENSIONS, +) diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..cdd63cb --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Leak Technologies - img2pdf +--------------------------- +Production-grade CLI tool to convert one or more images into a single PDF file. +Images are automatically sorted by filename before conversion. + +Author: Stu Leak +Repository: https://git.leaktechnologies.dev/Leak-Technologies/img2pdf +""" + +import argparse +import sys +from pathlib import Path + +from src.converter import ImageToPDFConverter +from src.config import Colours, banner, DEFAULT_OUTPUT_NAME + + +# ------------------------------------------------------------ +# ARGUMENT PARSER +# ------------------------------------------------------------ + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="img2pdf", + description="Convert a single image or a folder of images into a PDF (sorted by filename)." + ) + parser.add_argument( + "input", + help="Path to an image file or a directory containing images" + ) + parser.add_argument( + "-o", "--output", + default=DEFAULT_OUTPUT_NAME, + help=f"Output PDF filename (default: {DEFAULT_OUTPUT_NAME})" + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite the output file if it already exists" + ) + parser.add_argument( + "-q", "--quiet", + action="store_true", + help="Suppress banner output (for scripting)" + ) + return parser.parse_args() + + +# ------------------------------------------------------------ +# MAIN LOGIC +# ------------------------------------------------------------ + +def main() -> None: + args = parse_args() + + if not args.quiet: + print(banner(), "\n") + + input_path = Path(args.input) + output_pdf = Path(args.output) + + if not input_path.exists(): + print(f"{Colours.RED}❌ Error:{Colours.RESET} Input path does not exist — {input_path}") + sys.exit(1) + + converter = ImageToPDFConverter() + + try: + converter.convert(input_path, output_pdf, overwrite=args.overwrite) + print(f"{Colours.GREEN}✅ Done:{Colours.RESET} {output_pdf}") + sys.exit(0) + + except FileExistsError as e: + print(f"{Colours.YELLOW}⚠️ {e}{Colours.RESET}") + sys.exit(1) + + except FileNotFoundError as e: + print(f"{Colours.RED}❌ {e}{Colours.RESET}") + sys.exit(1) + + except ValueError as e: + print(f"{Colours.RED}❌ Invalid input: {e}{Colours.RESET}") + sys.exit(1) + + except Exception as e: + print(f"{Colours.RED}❌ Unexpected error: {e}{Colours.RESET}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..04575bd --- /dev/null +++ b/src/config.py @@ -0,0 +1,73 @@ +""" +Leak Technologies - img2pdf Configuration +---------------------------------------- +Centralized configuration constants for the img2pdf tool. + +This file defines: +- Application metadata +- Supported image formats +- Default behavior settings +- CLI color codes for consistent output styling +""" + +# ------------------------------------------------------------ +# APPLICATION METADATA +# ------------------------------------------------------------ + +APP_NAME = "img2pdf" +APP_VERSION = "1.0.0" +APP_AUTHOR = "Stu Leak" +APP_EMAIL = "leaktechnologies@proton.me" +APP_LICENSE = "MIT" +APP_REPOSITORY = "https://git.leaktechnologies.dev/Leak-Technologies/img2pdf" +APP_DESCRIPTION = "Convert one or more images into a single PDF, sorted by filename." + + +# ------------------------------------------------------------ +# SUPPORTED FORMATS +# ------------------------------------------------------------ + +SUPPORTED_EXTENSIONS = ( + ".jpg", + ".jpeg", + ".png", + ".bmp", + ".tiff", + ".webp", +) + +# ------------------------------------------------------------ +# DEFAULT SETTINGS +# ------------------------------------------------------------ + +DEFAULT_OUTPUT_NAME = "output.pdf" +ALLOW_OVERWRITE_DEFAULT = False + + +# ------------------------------------------------------------ +# TERMINAL COLORS (ANSI) +# ------------------------------------------------------------ + +class Colours: + RESET = "\033[0m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + WHITE = "\033[37m" + BOLD = "\033[1m" + + +# ------------------------------------------------------------ +# UTILITY FUNCTIONS +# ------------------------------------------------------------ + +def banner() -> str: + """Return a simple version banner for CLI output.""" + return ( + f"{Colours.CYAN}{Colours.BOLD}{APP_NAME}{Colours.RESET} " + f"v{APP_VERSION} — Leak Technologies\n" + f"{Colours.YELLOW}{APP_DESCRIPTION}{Colours.RESET}" + ) diff --git a/src/converter.py b/src/converter.py new file mode 100644 index 0000000..4cb728e --- /dev/null +++ b/src/converter.py @@ -0,0 +1,56 @@ +""" +img2pdf.converter +----------------- +Handles image-to-PDF conversion logic. + +Responsible for: +- Loading and validating image paths +- Sorting images by filename +- Generating and saving the PDF file +""" + +from pathlib import Path +from PIL import Image +from src.utils import get_sorted_images, ensure_overwrite_safe + + +class ImageToPDFConverter: + SUPPORTED_FORMATS = (".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".webp") + + def convert(self, input_path: Path, output_pdf: Path, overwrite: bool = False): + """Convert a single image or a folder of images to a PDF.""" + + # Ensure overwriting is handled safely + ensure_overwrite_safe(output_pdf, overwrite) + + if input_path.is_file(): + if not self._is_supported(input_path): + raise ValueError(f"Unsupported image format: {input_path.suffix}") + self._save_single_image(input_path, output_pdf) + print(f"✅ Created PDF from single image: {output_pdf}") + return + + if input_path.is_dir(): + image_files = get_sorted_images(input_path, self.SUPPORTED_FORMATS) + if not image_files: + raise FileNotFoundError("No valid image files found in directory.") + self._save_multiple_images(image_files, output_pdf) + print(f"✅ Created PDF with {len(image_files)} pages: {output_pdf}") + return + + raise FileNotFoundError("Input path must be a valid file or directory.") + + # -------------------- + # Internal Methods + # -------------------- + def _is_supported(self, path: Path) -> bool: + return path.suffix.lower() in self.SUPPORTED_FORMATS + + def _save_single_image(self, path: Path, output_pdf: Path): + img = Image.open(path).convert("RGB") + img.save(output_pdf) + + def _save_multiple_images(self, files: list[Path], output_pdf: Path): + images = [Image.open(f).convert("RGB") for f in files] + first, rest = images[0], images[1:] + first.save(output_pdf, save_all=True, append_images=rest) diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..fe9a1ed --- /dev/null +++ b/src/utils.py @@ -0,0 +1,79 @@ +""" +img2pdf.utils +------------- +Utility functions for the img2pdf tool. + +Handles: +- Safe overwrite checks +- Image file filtering and sorting +- General path validation +""" + +import os +from pathlib import Path + + +# ------------------------------------------------------------ +# IMAGE FILE DISCOVERY +# ------------------------------------------------------------ + +def get_sorted_images(directory: Path, valid_extensions: tuple[str, ...]) -> list[Path]: + """ + Return a sorted list of image paths within the given directory, + filtered by supported extensions and sorted naturally by filename. + + Example: + 1.jpg, 2.jpg, 10.jpg will be sorted as [1, 2, 10] + """ + if not directory.is_dir(): + raise NotADirectoryError(f"Not a directory: {directory}") + + # Filter and sort by name + files = [ + f for f in directory.iterdir() + if f.suffix.lower() in valid_extensions and f.is_file() + ] + files.sort(key=lambda x: _natural_sort_key(x.name)) + return files + + +def _natural_sort_key(name: str): + """ + Sort key that ensures numeric filenames sort correctly + (e.g., 2.png before 10.png). + """ + import re + return [int(text) if text.isdigit() else text.lower() for text in re.split(r"([0-9]+)", name)] + + +# ------------------------------------------------------------ +# OVERWRITE PROTECTION +# ------------------------------------------------------------ + +def ensure_overwrite_safe(path: Path, overwrite: bool = False): + """ + Ensure it’s safe to write to a file. If overwrite=False and the file exists, + raises FileExistsError. + """ + if path.exists() and not overwrite: + raise FileExistsError(f"Output file '{path}' already exists. Use --overwrite to replace it.") + # Create directories if needed + path.parent.mkdir(parents=True, exist_ok=True) + + +# ------------------------------------------------------------ +# GENERAL UTILITIES +# ------------------------------------------------------------ + +def human_readable_size(num_bytes: int) -> str: + """Return a human-readable file size (e.g., 12.4 MB).""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if num_bytes < 1024.0: + return f"{num_bytes:.1f} {unit}" + num_bytes /= 1024.0 + return f"{num_bytes:.1f} PB" + + +def validate_image_file(path: Path, valid_extensions: tuple[str, ...]) -> bool: + """Check if a given file path is a valid supported image.""" + return path.is_file() and path.suffix.lower() in valid_extensions