From 0709f932267aed40dd025c3df26d66b6e9431542 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 20:52:22 +0200 Subject: [PATCH 01/12] Add Ignore png files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d981b0e..30a083b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ wheels/ # Virtual environments .venv -*.pdf \ No newline at end of file +*.pdf +*.png \ No newline at end of file From 9bacbb0dcfe4cb83118d0a373f92cb965d4cbf3a Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 20:53:17 +0200 Subject: [PATCH 02/12] feat: Improved deskew to focus on horizontal lines - Modified deskew function to better detect staff lines - Added morphological operations to enhance horizontal lines - Used probabilistic Hough transform for more precise line detection - Added .png to gitignore --- pdf_cleaner.py | 268 +++++++++++++++++++++++-------------------------- pyproject.toml | 1 + uv.lock | 27 ++++- 3 files changed, 152 insertions(+), 144 deletions(-) mode change 100644 => 100755 pdf_cleaner.py diff --git a/pdf_cleaner.py b/pdf_cleaner.py old mode 100644 new mode 100755 index 1c4e60a..57a2a74 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -1,157 +1,139 @@ -#!/usr/bin/env python3 import os +#!/usr/bin/env -S uv run +import click import cv2 import numpy as np +from PIL import Image from pdf2image import convert_from_path import img2pdf -from PIL import Image -import argparse -def deskew(image): - """Deskew the image using contour detection and rotation.""" - # Create a copy for processing while keeping original quality - proc_image = image.copy() - - # Convert to grayscale and blur - gray = cv2.cvtColor(proc_image, cv2.COLOR_BGR2GRAY) - blur = cv2.GaussianBlur(gray, (9, 9), 0) - - # Threshold the image - thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] - - # Find all contours - contours, _ = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) - - if not contours: - return image - - # Find largest contour - contour = max(contours, key=cv2.contourArea) - - # Find minimum area rectangle - rect = cv2.minAreaRect(contour) - angle = rect[-1] - - # Adjust angle to be between -45 and 45 degrees - while angle < -45: - angle += 90 - while angle > 45: - angle -= 90 - - # Only rotate if the angle is significant enough - if abs(angle) < 0.5: # Skip tiny rotations - return image - - # Rotate the image - (h, w) = image.shape[:2] - center = (w // 2, h // 2) - M = cv2.getRotationMatrix2D(center, angle, 1.0) - rotated = cv2.warpAffine(image, M, (w, h), - flags=cv2.INTER_CUBIC, - borderMode=cv2.BORDER_REPLICATE) - - return rotated - -def adjust_levels(image): - """Adjust image levels for better contrast and clarity.""" - # Convert to grayscale if not already - if len(image.shape) == 3: - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - else: - gray = image - - # Apply bilateral filter to preserve edges while reducing noise - denoised = cv2.bilateralFilter(gray, 9, 75, 75) - - # Create a background mask using morphological operations - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) - background = cv2.morphologyEx(denoised, cv2.MORPH_DILATE, kernel) - - # Subtract background to normalize lighting - normalized = cv2.subtract(background, denoised) - - # Apply Gaussian blur to reduce noise while preserving edges - blurred = cv2.GaussianBlur(normalized, (3, 3), 0) - - # Use Otsu's thresholding for optimal binary threshold - _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) - - # Always ensure background is white and text is black - if cv2.countNonZero(binary) < binary.size / 2: - binary = cv2.bitwise_not(binary) - - return binary - -def process_pdf(input_path, output_path): - """Process a PDF file and save the cleaned version.""" - print(f"Processing {input_path}...") - - # Convert PDF to images with high DPI to ensure minimum width of 2048px - pages = convert_from_path(input_path, dpi=300) - - # Create temporary directory for processed images +def ensure_temp_dir(): + """Ensure temporary directory exists and return its path.""" temp_dir = "temp_processed_images" os.makedirs(temp_dir, exist_ok=True) - - # Process each page - temp_image_paths = [] - for i, page in enumerate(pages): - print(f"Processing page {i+1}/{len(pages)}") - - # Ensure minimum width of 2048px - width, height = page.size - scale = max(1, 2048 / width) - if scale > 1: - new_width = int(width * scale) - new_height = int(height * scale) - page = page.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # Convert PIL Image to OpenCV format - opencv_image = cv2.cvtColor(np.array(page), cv2.COLOR_RGB2BGR) - - # Process the image - deskewed = deskew(opencv_image) - cleaned = adjust_levels(deskewed) - - # Convert to PIL Image - pil_image = Image.fromarray(cleaned) - - # Save temporarily as 1-bit PNG - temp_path = os.path.join(temp_dir, f"page_{i:03d}.png") - pil_image.save(temp_path, "PNG", optimize=False) - temp_image_paths.append(temp_path) - - # Save processed images as PDF with high quality settings - print("Saving cleaned PDF...") - a4_width_mm = 210 - a4_height_mm = 297 - layout_fun = img2pdf.get_layout_fun((img2pdf.mm_to_pt(a4_width_mm), - img2pdf.mm_to_pt(a4_height_mm))) - - with open(output_path, "wb") as f: - f.write(img2pdf.convert(temp_image_paths, - layout_fun=layout_fun, - with_pdfrw=True)) - - # Clean up temporary files - for temp_path in temp_image_paths: - os.remove(temp_path) - os.rmdir(temp_dir) - - print(f"Saved cleaned PDF to {output_path}") + return temp_dir -def main(): - parser = argparse.ArgumentParser(description='Clean and straighten image-based PDFs') - parser.add_argument('input_pdf', help='Path to the input PDF file') - parser.add_argument('output_pdf', help='Path for the output PDF file') +def get_temp_files(): + """Get list of temporary PNG files in order.""" + temp_dir = ensure_temp_dir() + files = [f for f in os.listdir(temp_dir) if f.endswith('.png')] + files.sort() # Ensure correct page order + return [os.path.join(temp_dir, f) for f in files] + +@click.group() +def cli(): + """PDF cleaning toolbox for musical scores.""" + pass + +@cli.command() +@click.argument('input_pdf', type=click.Path(exists=True)) +def extract(input_pdf): + """Extract pages from PDF to temporary directory.""" + temp_dir = ensure_temp_dir() - args = parser.parse_args() + # Convert PDF to images + print(f"Extracting pages from {input_pdf}...") + pages = convert_from_path(input_pdf, dpi=400) - if not os.path.exists(args.input_pdf): - print(f"Error: Input file '{args.input_pdf}' does not exist") + # Save each page + for i, page in enumerate(pages): + output_path = os.path.join(temp_dir, f"page_{i:03d}.png") + page.save(output_path, "PNG", optimize=False) + print(f"Saved page {i+1}/{len(pages)}") + + print(f"Extracted {len(pages)} pages to {temp_dir}/") + +@cli.command() +def deskew(): + """Deskew all pages in temporary directory.""" + temp_files = get_temp_files() + if not temp_files: + print("No pages found in temporary directory. Run 'extract' first.") return - process_pdf(args.input_pdf, args.output_pdf) + for file_path in temp_files: + print(f"Deskewing {os.path.basename(file_path)}...") + + # Read image + image = cv2.imread(file_path) + + # Convert to grayscale + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # Apply threshold to get binary image + _, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) + + # Create a rectangular kernel that's wider than it is tall + # This helps detect horizontal lines + kernel_length = np.array(binary).shape[1]//80 + horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_length, 1)) + + # Detect horizontal lines + horizontal_lines = cv2.erode(binary, horizontal_kernel, iterations=3) + horizontal_lines = cv2.dilate(horizontal_lines, horizontal_kernel, iterations=3) + + # Use probabilistic Hough transform to detect line segments + line_segments = cv2.HoughLinesP( + cv2.bitwise_not(horizontal_lines), + rho=1, + theta=np.pi/180, + threshold=100, + minLineLength=binary.shape[1]//4, # Lines must be at least 1/4 of image width + maxLineGap=20 + ) + + if line_segments is not None and len(line_segments) > 0: + # Calculate angles of detected line segments + angles = [] + for line in line_segments: + x1, y1, x2, y2 = line[0] + if x2 - x1 == 0: # Avoid division by zero + continue + angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) + # Only consider angles that are close to horizontal + if abs(angle) < 20: + angles.append(angle) + + if angles: + # Use median angle to avoid outliers + median_angle = np.median(angles) + + # Only rotate if the angle is significant but not too large + if 0.5 < abs(median_angle) < 20: + height, width = image.shape[:2] + center = (width/2, height/2) + rotation_matrix = cv2.getRotationMatrix2D(center, median_angle, 1.0) + rotated = cv2.warpAffine(image, rotation_matrix, (width, height), + flags=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE) + + # Save rotated image + cv2.imwrite(file_path, rotated) + print(f" Rotated by {median_angle:.2f} degrees") + else: + print(" No significant rotation needed") + else: + print(" No valid horizontal lines found") + else: + print(" No line segments detected") -if __name__ == "__main__": - main() +@cli.command() +@click.argument('output_pdf', type=click.Path()) +def finalize(output_pdf): + """Combine processed pages into final PDF.""" + temp_files = get_temp_files() + if not temp_files: + print("No pages found in temporary directory. Run 'extract' first.") + return + + print(f"Combining {len(temp_files)} pages into {output_pdf}...") + + # Convert to PDF + with open(output_pdf, "wb") as f: + f.write(img2pdf.convert(temp_files)) + + print("PDF created successfully!") + print("Note: Temporary files were kept for further processing if needed.") + +if __name__ == '__main__': + cli() diff --git a/pyproject.toml b/pyproject.toml index 4585c71..716c825 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,5 +5,6 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "click>=8.1.7", "pydantic>=2.10.3", ] diff --git a/uv.lock b/uv.lock index 0c4effc..d9bf036 100644 --- a/uv.lock +++ b/uv.lock @@ -10,16 +10,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + [[package]] name = "notes-cleaner" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "click" }, { name = "pydantic" }, ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.10.3" }] +requires-dist = [ + { name = "click", specifier = ">=8.1.7" }, + { name = "pydantic", specifier = ">=2.10.3" }, +] [[package]] name = "pydantic" From bad7612027f013e1c5054cdd564e4cf31bcbe05f Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 20:55:56 +0200 Subject: [PATCH 03/12] feat: Add cleanup of temporary files in finalize command - Remove all temporary PNG files after PDF creation - Remove temporary directory - Update command feedback messages --- pdf_cleaner.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 57a2a74..3bb3463 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -120,7 +120,8 @@ def deskew(): @cli.command() @click.argument('output_pdf', type=click.Path()) def finalize(output_pdf): - """Combine processed pages into final PDF.""" + """Combine processed pages into final PDF and clean up.""" + temp_dir = ensure_temp_dir() temp_files = get_temp_files() if not temp_files: print("No pages found in temporary directory. Run 'extract' first.") @@ -132,8 +133,13 @@ def finalize(output_pdf): with open(output_pdf, "wb") as f: f.write(img2pdf.convert(temp_files)) - print("PDF created successfully!") - print("Note: Temporary files were kept for further processing if needed.") + # Clean up temporary files + print("Cleaning up temporary files...") + for file_path in temp_files: + os.remove(file_path) + os.rmdir(temp_dir) + + print("PDF created successfully and temporary files removed!") if __name__ == '__main__': cli() From 7ef31b48768baf9ec9c66e74ac8ecf40e83c002a Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 21:07:39 +0200 Subject: [PATCH 04/12] docs: Add comprehensive README - Add project description and features - Include installation instructions with uv sync - Add usage examples for all commands - Document how each processing step works - Add package installation instructions for different distros --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/README.md b/README.md index e69de29..32a1664 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,103 @@ +# PDF Musical Score Cleaner + +A command-line tool for processing and cleaning scanned musical score PDFs. This tool helps you extract, deskew, optimize, and recompile PDF files while maintaining high quality and readability of musical notation. + +## Features + +- **Page Extraction**: Extract individual pages from PDF files +- **Deskewing**: Automatically correct page rotation using staff line detection +- **White Space Trimming**: Remove excess white space around the musical content +- **PNG Optimization**: Optimize PNG files using optipng (if installed) +- **Modular Processing**: Process your files step by step or all at once +- **High Quality Output**: Preserve image quality throughout the process + +## Installation + +1. Ensure you have Python 3.8+ installed +2. Install uv (recommended) or pip +3. Clone this repository: + ```bash + git clone + cd notes_cleaner + ``` +4. Install dependencies: + ```bash + uv sync + ``` + +5. (Optional) Install optipng for additional PNG optimization: + ```bash + # Ubuntu/Debian + sudo apt-get install optipng + + # macOS + brew install optipng + + # Arch Linux + sudo pacman -Sy optipng + ``` + +## Usage + +The tool provides several commands that can be run independently: + +### Extract Pages +```bash +./pdf_cleaner.py extract input.pdf +``` +Extracts all pages from the input PDF to a temporary directory. + +### Deskew Pages +```bash +./pdf_cleaner.py deskew +``` +Automatically detects and corrects page rotation by analyzing staff lines. + +### Optimize Pages +```bash +./pdf_cleaner.py optimize +``` +Trims excess white space and optionally runs PNG optimization (requires optipng). + +### Create Final PDF +```bash +./pdf_cleaner.py finalize output.pdf +``` +Combines all processed pages into a final PDF and cleans up temporary files. + +### Typical Workflow +```bash +./pdf_cleaner.py extract input.pdf # Extract pages +./pdf_cleaner.py deskew # Correct rotation +./pdf_cleaner.py optimize # Remove white space and optimize +./pdf_cleaner.py finalize output.pdf # Create final PDF +``` + +## How It Works + +1. **Extraction**: Uses pdf2image to convert PDF pages to high-quality PNG images +2. **Deskewing**: + - Applies morphological operations to enhance horizontal lines + - Uses Hough transform to detect staff lines + - Calculates and corrects rotation based on detected lines +3. **Optimization**: + - Detects content boundaries and removes excess white space + - Optionally runs optipng for additional file size reduction +4. **Finalization**: Combines processed images back into a PDF using img2pdf + +## Dependencies + +- click: Command line interface +- opencv-python: Image processing and deskewing +- numpy: Numerical operations +- pdf2image: PDF to image conversion +- img2pdf: Image to PDF conversion +- optipng (optional): PNG file optimization + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +[Insert chosen license here] \ No newline at end of file From 3d143ce5a29cf941842c700f82523a5012deec9f Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 21:10:38 +0200 Subject: [PATCH 05/12] feat: Add optimize command - Add whitespace trimming functionality - Add optional PNG optimization with optipng - Add progress tracking and error handling - Improve imports organization - Add proper docstrings and return values --- pdf_cleaner.py | 132 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 4 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 3bb3463..168094e 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -1,11 +1,16 @@ -import os #!/usr/bin/env -S uv run + +import os +import shutil +import subprocess +from pathlib import Path +from typing import List, Tuple + import click import cv2 -import numpy as np -from PIL import Image -from pdf2image import convert_from_path import img2pdf +import numpy as np +from pdf2image import convert_from_path def ensure_temp_dir(): """Ensure temporary directory exists and return its path.""" @@ -117,6 +122,125 @@ def deskew(): else: print(" No line segments detected") +def trim_whitespace(image_path: str) -> bool: + """Remove white space from around the image. + + Returns: + bool: True if successful, False otherwise + """ + try: + # Read the image + img = cv2.imread(image_path) + if img is None: + print(f"Warning: Could not read image {image_path}") + return False + + # Convert to grayscale + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Threshold the image + _, thresh = cv2.threshold(gray, 250, 255, cv2.THRESH_BINARY_INV) + + # Find non-zero points + coords = cv2.findNonZero(thresh) + if coords is None: + print(f"Warning: No content found in {image_path}") + return False + + # Get bounding rectangle + x, y, w, h = cv2.boundingRect(coords) + + # Add small padding (20 pixels) + padding = 20 + height, width = img.shape[:2] + x = max(0, x - padding) + y = max(0, y - padding) + w = min(width - x, w + 2 * padding) + h = min(height - y, h + 2 * padding) + + # Crop the image + cropped = img[y:y+h, x:x+w] + + # Save the cropped image + cv2.imwrite(image_path, cropped) + return True + except Exception as e: + print(f"Error processing {image_path}: {str(e)}") + return False + +def check_optipng_installed() -> bool: + """Check if optipng is installed.""" + try: + result = subprocess.run(['optipng', '-v'], capture_output=True, text=True) + return result.returncode == 0 + except FileNotFoundError: + return False + +def run_optipng(file_path: str) -> bool: + """Run optipng on a file with error handling. + + Returns: + bool: True if successful, False otherwise + """ + try: + result = subprocess.run( + ['optipng', '-o7', file_path], + capture_output=True, + text=True, + check=True + ) + return True + except subprocess.CalledProcessError as e: + print(f"Error optimizing {file_path}: {e.stderr}") + return False + except Exception as e: + print(f"Unexpected error optimizing {file_path}: {str(e)}") + return False + +@cli.command() +def optimize(): + """Optimize images by trimming whitespace and optionally running optipng.""" + temp_dir = ensure_temp_dir() + temp_files = get_temp_files() + if not temp_files: + print("No pages found in temporary directory. Run 'extract' first.") + return + + total_files = len(temp_files) + successful_trims = 0 + successful_opts = 0 + + print(f"Processing {total_files} images...") + + # Trim whitespace + print("\nTrimming whitespace from images...") + for i, file_path in enumerate(temp_files, 1): + print(f"[{i}/{total_files}] Processing {os.path.basename(file_path)}...", end='', flush=True) + if trim_whitespace(file_path): + successful_trims += 1 + print(" ") + else: + print(" ") + + # Optimize with optipng if available + if check_optipng_installed(): + print("\nOptimizing PNG files with optipng...") + for i, file_path in enumerate(temp_files, 1): + print(f"[{i}/{total_files}] Optimizing {os.path.basename(file_path)}...", end='', flush=True) + if run_optipng(file_path): + successful_opts += 1 + print(" ") + else: + print(" ") + else: + print("\nNote: optipng not found. Skipping PNG optimization.") + + # Print summary + print("\nOptimization complete!") + print(f"Successfully trimmed: {successful_trims}/{total_files} images") + if check_optipng_installed(): + print(f"Successfully optimized: {successful_opts}/{total_files} images") + @cli.command() @click.argument('output_pdf', type=click.Path()) def finalize(output_pdf): From 6e1d90de2fda843957d78b27134fa3252e3a85c3 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 21:15:34 +0200 Subject: [PATCH 06/12] feat: Add settings module - Add configuration for parallel processing - Add optimization settings (optipng level, trim padding) - Support environment variable overrides - Add temporary directory prefix setting --- settings.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 settings.py diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..044e084 --- /dev/null +++ b/settings.py @@ -0,0 +1,18 @@ +"""Configuration settings for the PDF Musical Score Cleaner.""" + +import os +import multiprocessing + +# Number of parallel processes to use for operations that support parallelization +# Defaults to number of CPU cores - 1, but never less than 1 +DEFAULT_PARALLEL_PROCESSES = max(1, multiprocessing.cpu_count() - 1) + +# Can be overridden by environment variable +PARALLEL_PROCESSES = int(os.getenv('NOTES_CLEANER_PARALLEL_PROCESSES', DEFAULT_PARALLEL_PROCESSES)) + +# Optimization settings +OPTIPNG_OPTIMIZATION_LEVEL = int(os.getenv('NOTES_CLEANER_OPTIPNG_LEVEL', '7')) # 0-7, higher = better compression but slower +TRIM_PADDING_PIXELS = int(os.getenv('NOTES_CLEANER_TRIM_PADDING', '20')) # Padding around content after trimming + +# Temporary directory settings +TEMP_DIR_PREFIX = 'notes_cleaner_' From 682a1f13eeb3c96e127c332a84ca8ecf782ac474 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 21:38:55 +0200 Subject: [PATCH 07/12] fix: Improve transparency handling and optimization levels - Handle transparency in whitespace trimming - Use IntEnum for optimization levels - Add proper level comparisons - Treat transparent pixels as white for boundary detection - Preserve transparency in output - Fix black artifacts in transparent areas --- pdf_cleaner.py | 130 +++++++++++++++++++++++++++++++++++++++---------- settings.py | 16 ++++++ 2 files changed, 121 insertions(+), 25 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 168094e..2ca1494 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -12,6 +12,16 @@ import img2pdf import numpy as np from pdf2image import convert_from_path +import settings +from settings import OptimizationLevel + +class Settings: + TRIM_PADDING_PIXELS = 20 + MONOCHROME_THRESHOLD = 127 + OPTIPNG_OPTIMIZATION_LEVEL = 7 + +settings = Settings() + def ensure_temp_dir(): """Ensure temporary directory exists and return its path.""" temp_dir = "temp_processed_images" @@ -125,16 +135,30 @@ def deskew(): def trim_whitespace(image_path: str) -> bool: """Remove white space from around the image. + Handles both RGB and RGBA images, treating transparent pixels as white. + Returns: bool: True if successful, False otherwise """ try: - # Read the image - img = cv2.imread(image_path) + # Read the image with alpha channel + img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED) if img is None: print(f"Warning: Could not read image {image_path}") return False + # Convert to grayscale, handling transparency + if img.shape[-1] == 4: # RGBA + # Create a white background + white_background = np.ones_like(img, dtype=np.uint8) * 255 + + # Extract alpha channel and create mask + alpha = img[:, :, 3] + alpha_mask = alpha[:, :, np.newaxis] / 255.0 + + # Blend image with white background based on alpha + img = (img[:, :, :3] * alpha_mask + white_background[:, :, :3] * (1 - alpha_mask)).astype(np.uint8) + # Convert to grayscale gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) @@ -150,8 +174,8 @@ def trim_whitespace(image_path: str) -> bool: # Get bounding rectangle x, y, w, h = cv2.boundingRect(coords) - # Add small padding (20 pixels) - padding = 20 + # Add padding + padding = settings.TRIM_PADDING_PIXELS height, width = img.shape[:2] x = max(0, x - padding) y = max(0, y - padding) @@ -159,15 +183,44 @@ def trim_whitespace(image_path: str) -> bool: h = min(height - y, h + 2 * padding) # Crop the image - cropped = img[y:y+h, x:x+w] + if img.shape[-1] == 4: # If original was RGBA + cropped = img[:, :, :4][y:y+h, x:x+w] # Keep alpha channel + else: + cropped = img[y:y+h, x:x+w] - # Save the cropped image + # Save the cropped image with transparency preserved cv2.imwrite(image_path, cropped) return True except Exception as e: print(f"Error processing {image_path}: {str(e)}") return False +def convert_to_monochrome(image_path: str) -> bool: + """Convert image to 1-bit monochrome. + + Returns: + bool: True if successful, False otherwise + """ + try: + # Read the image + img = cv2.imread(image_path) + if img is None: + print(f"Warning: Could not read image {image_path}") + return False + + # Convert to grayscale + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Apply threshold + _, mono = cv2.threshold(gray, settings.MONOCHROME_THRESHOLD, 255, cv2.THRESH_BINARY) + + # Save the monochrome image + cv2.imwrite(image_path, mono) + return True + except Exception as e: + print(f"Error converting to monochrome {image_path}: {str(e)}") + return False + def check_optipng_installed() -> bool: """Check if optipng is installed.""" try: @@ -184,7 +237,7 @@ def run_optipng(file_path: str) -> bool: """ try: result = subprocess.run( - ['optipng', '-o7', file_path], + ['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', file_path], capture_output=True, text=True, check=True @@ -198,48 +251,75 @@ def run_optipng(file_path: str) -> bool: return False @cli.command() -def optimize(): - """Optimize images by trimming whitespace and optionally running optipng.""" +@click.option('--level', type=click.IntRange(1, 3), default=1, + help='Optimization level: 1=trim, 2=monochrome, 3=full with optipng') +def optimize(level): + """Optimize images with specified level of processing. + + Optimization Levels: + 1: Only trim whitespace + 2: Level 1 + convert to 1-bit monochrome + 3: Level 2 + optipng optimization + """ temp_dir = ensure_temp_dir() temp_files = get_temp_files() if not temp_files: print("No pages found in temporary directory. Run 'extract' first.") return + opt_level = OptimizationLevel(level) + total_files = len(temp_files) - successful_trims = 0 - successful_opts = 0 + successful = { + 'trim': 0, + 'monochrome': 0, + 'optipng': 0 + } - print(f"Processing {total_files} images...") + print(f"Processing {total_files} images at optimization level {level}...") - # Trim whitespace + # Step 1: Always trim whitespace print("\nTrimming whitespace from images...") for i, file_path in enumerate(temp_files, 1): print(f"[{i}/{total_files}] Processing {os.path.basename(file_path)}...", end='', flush=True) if trim_whitespace(file_path): - successful_trims += 1 + successful['trim'] += 1 print(" ") else: print(" ") - # Optimize with optipng if available - if check_optipng_installed(): - print("\nOptimizing PNG files with optipng...") + # Step 2: Convert to monochrome if level >= 2 + if int(opt_level) >= int(OptimizationLevel.MONOCHROME): + print("\nConverting to monochrome...") for i, file_path in enumerate(temp_files, 1): - print(f"[{i}/{total_files}] Optimizing {os.path.basename(file_path)}...", end='', flush=True) - if run_optipng(file_path): - successful_opts += 1 + print(f"[{i}/{total_files}] Converting {os.path.basename(file_path)}...", end='', flush=True) + if convert_to_monochrome(file_path): + successful['monochrome'] += 1 print(" ") else: print(" ") - else: - print("\nNote: optipng not found. Skipping PNG optimization.") + + # Step 3: Run optipng if level = 3 + if opt_level == OptimizationLevel.FULL: + if check_optipng_installed(): + print("\nOptimizing PNG files with optipng...") + for i, file_path in enumerate(temp_files, 1): + print(f"[{i}/{total_files}] Optimizing {os.path.basename(file_path)}...", end='', flush=True) + if run_optipng(file_path): + successful['optipng'] += 1 + print(" ") + else: + print(" ") + else: + print("\nNote: optipng not found. Skipping PNG optimization.") # Print summary print("\nOptimization complete!") - print(f"Successfully trimmed: {successful_trims}/{total_files} images") - if check_optipng_installed(): - print(f"Successfully optimized: {successful_opts}/{total_files} images") + print(f"Successfully trimmed: {successful['trim']}/{total_files} images") + if int(opt_level) >= int(OptimizationLevel.MONOCHROME): + print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images") + if opt_level == OptimizationLevel.FULL and check_optipng_installed(): + print(f"Successfully optimized with optipng: {successful['optipng']}/{total_files} images") @cli.command() @click.argument('output_pdf', type=click.Path()) diff --git a/settings.py b/settings.py index 044e084..a7785d4 100644 --- a/settings.py +++ b/settings.py @@ -2,6 +2,19 @@ import os import multiprocessing +from enum import IntEnum + +class OptimizationLevel(IntEnum): + """Optimization levels for image processing. + + Levels: + 1: Only trim whitespace + 2: Trim + convert to 1-bit monochrome + 3: All optimizations + optipng + """ + TRIM = 1 # Only trim whitespace + MONOCHROME = 2 # Trim + convert to 1-bit monochrome + FULL = 3 # All optimizations + optipng # Number of parallel processes to use for operations that support parallelization # Defaults to number of CPU cores - 1, but never less than 1 @@ -14,5 +27,8 @@ PARALLEL_PROCESSES = int(os.getenv('NOTES_CLEANER_PARALLEL_PROCESSES', DEFAULT_P OPTIPNG_OPTIMIZATION_LEVEL = int(os.getenv('NOTES_CLEANER_OPTIPNG_LEVEL', '7')) # 0-7, higher = better compression but slower TRIM_PADDING_PIXELS = int(os.getenv('NOTES_CLEANER_TRIM_PADDING', '20')) # Padding around content after trimming +# Image processing settings +MONOCHROME_THRESHOLD = int(os.getenv('NOTES_CLEANER_MONO_THRESHOLD', '200')) # 0-255, higher = more white + # Temporary directory settings TEMP_DIR_PREFIX = 'notes_cleaner_' From 84e1a6a82e6ea7893132d1f74debfe2947bbd2b6 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 21:47:03 +0200 Subject: [PATCH 08/12] refactor: consolidate image trimming and standardize processing - Replace OpenCV-based trim_whitespace with PIL implementation - Remove redundant trim_whitespace_pil function - Add initial trim step to extract command - Standardize image processing to 2048px wide grayscale using Lanczos --- pdf_cleaner.py | 131 +++++++++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 63 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 2ca1494..d115c2d 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -14,6 +14,7 @@ from pdf2image import convert_from_path import settings from settings import OptimizationLevel +from PIL import Image class Settings: TRIM_PADDING_PIXELS = 20 @@ -52,12 +53,79 @@ def extract(input_pdf): # Save each page for i, page in enumerate(pages): + # Convert to grayscale + page = page.convert('L') + output_path = os.path.join(temp_dir, f"page_{i:03d}.png") + + # Save initial version + page.save(output_path, "PNG", optimize=False) + + # Trim whitespace + trim_whitespace(output_path) + + # Reload the trimmed image + page = Image.open(output_path) + + # Calculate new height maintaining aspect ratio + width = 2048 + ratio = width / page.width + height = int(page.height * ratio) + + # Resize using Lanczos + page = page.resize((width, height), Image.Resampling.LANCZOS) + + # Save final version page.save(output_path, "PNG", optimize=False) print(f"Saved page {i+1}/{len(pages)}") print(f"Extracted {len(pages)} pages to {temp_dir}/") +def trim_whitespace(image_path: str) -> bool: + """Remove white space from around the image. + + Handles both RGB and RGBA images, treating transparent pixels as white. + + Args: + image_path: Path to the image file + + Returns: + bool: True if successful, False otherwise + """ + try: + # Open image with PIL + image = Image.open(image_path) + + # Convert to grayscale if not already + if image.mode != 'L': + image = image.convert('L') + + # Get the bounding box of non-white pixels + bbox = image.getbbox() + if not bbox: + print(f"Warning: No content found in {image_path}") + return False + + # Add padding + padding = settings.TRIM_PADDING_PIXELS + width, height = image.size + x1, y1, x2, y2 = bbox + x1 = max(0, x1 - padding) + y1 = max(0, y1 - padding) + x2 = min(width, x2 + padding) + y2 = min(height, y2 + padding) + + # Crop the image + image = image.crop((x1, y1, x2, y2)) + + # Save the image + image.save(image_path, "PNG", optimize=False) + return True + + except Exception as e: + print(f"Error processing {image_path}: {str(e)}") + return False + @cli.command() def deskew(): """Deskew all pages in temporary directory.""" @@ -132,69 +200,6 @@ def deskew(): else: print(" No line segments detected") -def trim_whitespace(image_path: str) -> bool: - """Remove white space from around the image. - - Handles both RGB and RGBA images, treating transparent pixels as white. - - Returns: - bool: True if successful, False otherwise - """ - try: - # Read the image with alpha channel - img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED) - if img is None: - print(f"Warning: Could not read image {image_path}") - return False - - # Convert to grayscale, handling transparency - if img.shape[-1] == 4: # RGBA - # Create a white background - white_background = np.ones_like(img, dtype=np.uint8) * 255 - - # Extract alpha channel and create mask - alpha = img[:, :, 3] - alpha_mask = alpha[:, :, np.newaxis] / 255.0 - - # Blend image with white background based on alpha - img = (img[:, :, :3] * alpha_mask + white_background[:, :, :3] * (1 - alpha_mask)).astype(np.uint8) - - # Convert to grayscale - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - - # Threshold the image - _, thresh = cv2.threshold(gray, 250, 255, cv2.THRESH_BINARY_INV) - - # Find non-zero points - coords = cv2.findNonZero(thresh) - if coords is None: - print(f"Warning: No content found in {image_path}") - return False - - # Get bounding rectangle - x, y, w, h = cv2.boundingRect(coords) - - # Add padding - padding = settings.TRIM_PADDING_PIXELS - height, width = img.shape[:2] - x = max(0, x - padding) - y = max(0, y - padding) - w = min(width - x, w + 2 * padding) - h = min(height - y, h + 2 * padding) - - # Crop the image - if img.shape[-1] == 4: # If original was RGBA - cropped = img[:, :, :4][y:y+h, x:x+w] # Keep alpha channel - else: - cropped = img[y:y+h, x:x+w] - - # Save the cropped image with transparency preserved - cv2.imwrite(image_path, cropped) - return True - except Exception as e: - print(f"Error processing {image_path}: {str(e)}") - return False - def convert_to_monochrome(image_path: str) -> bool: """Convert image to 1-bit monochrome. From 75dc1c3e2c3c5055ec9dfdd65615a5abb89cac9d Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 22:06:51 +0200 Subject: [PATCH 09/12] fix: properly handle transparency in trim_whitespace Simplify transparency handling by flattening image with white background for bounds detection while preserving original transparency in the final crop. This fixes the issue where transparent areas were being treated as black during trimming. --- pdf_cleaner.py | 59 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index d115c2d..95d9d1a 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -96,12 +96,18 @@ def trim_whitespace(image_path: str) -> bool: # Open image with PIL image = Image.open(image_path) - # Convert to grayscale if not already - if image.mode != 'L': - image = image.convert('L') + # Create flattened version with white background for finding bounds + flattened = Image.new('RGB', image.size, 'white') + if image.mode == 'RGBA': + flattened.paste(image, mask=image.split()[3]) + else: + flattened.paste(image) + + # Convert to grayscale for finding bounds + flattened = flattened.convert('L') # Get the bounding box of non-white pixels - bbox = image.getbbox() + bbox = flattened.getbbox() if not bbox: print(f"Warning: No content found in {image_path}") return False @@ -115,11 +121,11 @@ def trim_whitespace(image_path: str) -> bool: x2 = min(width, x2 + padding) y2 = min(height, y2 + padding) - # Crop the image - image = image.crop((x1, y1, x2, y2)) + # Crop the original image (preserving transparency) + cropped = image.crop((x1, y1, x2, y2)) - # Save the image - image.save(image_path, "PNG", optimize=False) + # Save the cropped image + cropped.save(image_path, "PNG", optimize=False) return True except Exception as e: @@ -203,25 +209,44 @@ def deskew(): def convert_to_monochrome(image_path: str) -> bool: """Convert image to 1-bit monochrome. + Handles RGBA images by converting transparent pixels to white before thresholding. + Returns: bool: True if successful, False otherwise """ try: - # Read the image - img = cv2.imread(image_path) - if img is None: - print(f"Warning: Could not read image {image_path}") - return False + # Open image with PIL + image = Image.open(image_path) + + # Convert to RGBA if not already + if image.mode != 'RGBA': + image = image.convert('RGBA') + + # Get the image data as a list of pixels + data = image.getdata() + + # Create new image data, replacing transparent pixels with white + new_data = [] + for item in data: + # If pixel is transparent (alpha < 128), make it white + if item[3] < 128: + new_data.append((255, 255, 255, 255)) + else: + new_data.append(item) + + # Create new image with modified data + image.putdata(new_data) # Convert to grayscale - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + image = image.convert('L') - # Apply threshold - _, mono = cv2.threshold(gray, settings.MONOCHROME_THRESHOLD, 255, cv2.THRESH_BINARY) + # Convert to 1-bit using threshold + image = image.point(lambda x: 255 if x > settings.MONOCHROME_THRESHOLD else 0, '1') # Save the monochrome image - cv2.imwrite(image_path, mono) + image.save(image_path, "PNG", optimize=False) return True + except Exception as e: print(f"Error converting to monochrome {image_path}: {str(e)}") return False From a979712c8c70555243a0e48673f7478ff5f7880f Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 22:23:54 +0200 Subject: [PATCH 10/12] perf: optimize PNG compression only on final operations - Add is_final parameter to trim_whitespace and convert_to_monochrome - Only optimize PNGs on the final operation of each level - Remove optimization from extract command - Level 1: optimize in trim_whitespace - Level 2: optimize in convert_to_monochrome - Level 3: defer to optipng --- pdf_cleaner.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 95d9d1a..2123a26 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -81,13 +81,14 @@ def extract(input_pdf): print(f"Extracted {len(pages)} pages to {temp_dir}/") -def trim_whitespace(image_path: str) -> bool: +def trim_whitespace(image_path: str, is_final: bool = False) -> bool: """Remove white space from around the image. Handles both RGB and RGBA images, treating transparent pixels as white. Args: image_path: Path to the image file + is_final: Whether this is the final operation on the image Returns: bool: True if successful, False otherwise @@ -124,8 +125,8 @@ def trim_whitespace(image_path: str) -> bool: # Crop the original image (preserving transparency) cropped = image.crop((x1, y1, x2, y2)) - # Save the cropped image - cropped.save(image_path, "PNG", optimize=False) + # Save the cropped image, optimizing only if this is the final operation + cropped.save(image_path, "PNG", optimize=is_final) return True except Exception as e: @@ -206,11 +207,15 @@ def deskew(): else: print(" No line segments detected") -def convert_to_monochrome(image_path: str) -> bool: +def convert_to_monochrome(image_path: str, is_final: bool = False) -> bool: """Convert image to 1-bit monochrome. Handles RGBA images by converting transparent pixels to white before thresholding. + Args: + image_path: Path to the image file + is_final: Whether this is the final operation on the image + Returns: bool: True if successful, False otherwise """ @@ -243,8 +248,8 @@ def convert_to_monochrome(image_path: str) -> bool: # Convert to 1-bit using threshold image = image.point(lambda x: 255 if x > settings.MONOCHROME_THRESHOLD else 0, '1') - # Save the monochrome image - image.save(image_path, "PNG", optimize=False) + # Save the monochrome image, optimizing only if this is the final operation + image.save(image_path, "PNG", optimize=is_final) return True except Exception as e: @@ -312,7 +317,8 @@ def optimize(level): print("\nTrimming whitespace from images...") for i, file_path in enumerate(temp_files, 1): print(f"[{i}/{total_files}] Processing {os.path.basename(file_path)}...", end='', flush=True) - if trim_whitespace(file_path): + # Only optimize if this is the final step (level 1) + if trim_whitespace(file_path, is_final=(opt_level == OptimizationLevel.TRIM)): successful['trim'] += 1 print(" ") else: @@ -323,7 +329,8 @@ def optimize(level): print("\nConverting to monochrome...") for i, file_path in enumerate(temp_files, 1): print(f"[{i}/{total_files}] Converting {os.path.basename(file_path)}...", end='', flush=True) - if convert_to_monochrome(file_path): + # Only optimize if this is the final step (level 2) + if convert_to_monochrome(file_path, is_final=(opt_level == OptimizationLevel.MONOCHROME)): successful['monochrome'] += 1 print(" ") else: From 0f105880e6e9883ed3c910970eb54d863c3428da Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 22:32:19 +0200 Subject: [PATCH 11/12] fix: improve content detection in trim_whitespace - Add thresholding to better separate content from background - Invert image so content becomes white for getbbox detection - Fix issue where white and transparent areas weren't being trimmed --- pdf_cleaner.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 2123a26..0f7c1a0 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -97,18 +97,26 @@ def trim_whitespace(image_path: str, is_final: bool = False) -> bool: # Open image with PIL image = Image.open(image_path) - # Create flattened version with white background for finding bounds - flattened = Image.new('RGB', image.size, 'white') + # If image has transparency, flatten it first if image.mode == 'RGBA': - flattened.paste(image, mask=image.split()[3]) - else: - flattened.paste(image) + # Create a white background + background = Image.new('RGB', image.size, 'white') + # Paste using alpha channel as mask + background.paste(image, mask=image.split()[3]) + image = background - # Convert to grayscale for finding bounds - flattened = flattened.convert('L') + # Convert to grayscale + image = image.convert('L') - # Get the bounding box of non-white pixels - bbox = flattened.getbbox() + # Threshold to make all light pixels white and everything else black + # This helps with finding content bounds + image = image.point(lambda x: 255 if x > 250 else 0) + + # Invert so content is white on black background + image = Image.eval(image, lambda x: 255 - x) + + # Get the bounding box of content (now white pixels) + bbox = image.getbbox() if not bbox: print(f"Warning: No content found in {image_path}") return False @@ -122,8 +130,9 @@ def trim_whitespace(image_path: str, is_final: bool = False) -> bool: x2 = min(width, x2 + padding) y2 = min(height, y2 + padding) - # Crop the original image (preserving transparency) - cropped = image.crop((x1, y1, x2, y2)) + # Open original image again and crop it using the bounds + original = Image.open(image_path) + cropped = original.crop((x1, y1, x2, y2)) # Save the cropped image, optimizing only if this is the final operation cropped.save(image_path, "PNG", optimize=is_final) From a78b9c25d1bbe1490855501430264a49a45632bc Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 3 Dec 2024 22:53:23 +0200 Subject: [PATCH 12/12] Improve PDF generation with proper A4 sizing and borders - Move PDF border size to settings.py - Use standard A4 dimensions (210x297mm) - Fix aspect ratio preservation in layout function - Use img2pdf's built-in layout function with proper border specification --- pdf_cleaner.py | 17 +++++++++++++++-- settings.py | 3 +++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pdf_cleaner.py b/pdf_cleaner.py index 0f7c1a0..ceaa31f 100755 --- a/pdf_cleaner.py +++ b/pdf_cleaner.py @@ -20,6 +20,7 @@ class Settings: TRIM_PADDING_PIXELS = 20 MONOCHROME_THRESHOLD = 127 OPTIPNG_OPTIMIZATION_LEVEL = 7 + PDF_BORDER_SIZE = 50 settings = Settings() @@ -379,9 +380,21 @@ def finalize(output_pdf): print(f"Combining {len(temp_files)} pages into {output_pdf}...") - # Convert to PDF + # A4 size in millimeters + A4_WIDTH_MM = 210 + A4_HEIGHT_MM = 297 + + # Convert to PDF with border with open(output_pdf, "wb") as f: - f.write(img2pdf.convert(temp_files)) + f.write(img2pdf.convert( + temp_files, + with_pdfrw=True, + layout_fun=img2pdf.get_layout_fun( + pagesize=(img2pdf.mm_to_pt(A4_WIDTH_MM), img2pdf.mm_to_pt(A4_HEIGHT_MM)), + border=(settings.PDF_BORDER_SIZE,) * 4, # Same border size for all sides (top, right, bottom, left) + fit=img2pdf.FitMode.into + ) + )) # Clean up temporary files print("Cleaning up temporary files...") diff --git a/settings.py b/settings.py index a7785d4..6d40474 100644 --- a/settings.py +++ b/settings.py @@ -23,6 +23,9 @@ DEFAULT_PARALLEL_PROCESSES = max(1, multiprocessing.cpu_count() - 1) # Can be overridden by environment variable PARALLEL_PROCESSES = int(os.getenv('NOTES_CLEANER_PARALLEL_PROCESSES', DEFAULT_PARALLEL_PROCESSES)) +# Border size in pixels for the final PDF output +PDF_BORDER_SIZE = 50 + # Optimization settings OPTIPNG_OPTIMIZATION_LEVEL = int(os.getenv('NOTES_CLEANER_OPTIPNG_LEVEL', '7')) # 0-7, higher = better compression but slower TRIM_PADDING_PIXELS = int(os.getenv('NOTES_CLEANER_TRIM_PADDING', '20')) # Padding around content after trimming