20 Commits
Author SHA1 Message Date
Esa Kataja 35f47cb012 Update README version history for v0.9rc3 2024-12-04 00:28:19 +02:00
Esa Kataja 00ea1dbd17 Bump version to 0.9rc3 and add tag. 2024-12-04 00:23:50 +02:00
Esa Kataja b0c4810344 Clean up unused imports and variables, remove redundant code, and add ruff as a dev dependency. 2024-12-04 00:22:25 +02:00
Esa Kataja 6e083f39e8 Implement parallel processing for optimization steps using ThreadPoolExecutor. 2024-12-03 23:45:35 +02:00
Esa Kataja 825338a2a3 Swap optimization steps: convert to monochrome before trimming whitespace. Include uv.lock in the commit. 2024-12-03 23:41:40 +02:00
Esa Kataja a2f88923ab Bump version to 0.9rc2
- Improved documentation with optimization levels
- Updated version history
- Fixed aspect ratio in PDF output
2024-12-03 23:02:44 +02:00
Esa Kataja 4c9cad58ec Bump version to 0.9rc1 2024-12-03 22:58:47 +02:00
Esa Kataja 721bbeb70d Merge branch 'feature/music-score-cleanup-v2' 2024-12-03 22:54:46 +02:00
Esa Kataja a78b9c25d1 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
2024-12-03 22:53:23 +02:00
Esa Kataja 0f105880e6 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
2024-12-03 22:32:19 +02:00
Esa Kataja a979712c8c 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
2024-12-03 22:23:54 +02:00
Esa Kataja 75dc1c3e2c 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.
2024-12-03 22:06:51 +02:00
Esa Kataja 84e1a6a82e 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
2024-12-03 21:47:03 +02:00
Esa Kataja 682a1f13ee 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
2024-12-03 21:38:55 +02:00
Esa Kataja 6e1d90de2f 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
2024-12-03 21:15:34 +02:00
Esa Kataja 3d143ce5a2 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
2024-12-03 21:10:38 +02:00
Esa Kataja 7ef31b4876 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
2024-12-03 21:07:39 +02:00
Esa Kataja bad7612027 feat: Add cleanup of temporary files in finalize command
- Remove all temporary PNG files after PDF creation
- Remove temporary directory
- Update command feedback messages
2024-12-03 20:55:56 +02:00
Esa Kataja 9bacbb0dcf 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
2024-12-03 20:53:17 +02:00
Esa Kataja 0709f93226 Add Ignore png files 2024-12-03 20:52:22 +02:00
7 changed files with 657 additions and 146 deletions
+1
View File
@@ -10,3 +10,4 @@ wheels/
.venv .venv
*.pdf *.pdf
*.png
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Kessinen
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.
+127
View File
@@ -0,0 +1,127 @@
# 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
- **Professional PDF Output**: Generate A4-sized PDFs with proper borders and centered content
## Installation
1. Ensure you have Python 3.12+ installed
2. Install uv (recommended) or pip
3. Clone this repository:
```bash
git clone <repository-url>
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 [--level {1,2,3}]
```
Processes pages with different optimization levels:
- Level 1: Only trims excess white space
- Level 2: Trims white space and converts to 1-bit monochrome
- Level 3: All optimizations + PNG optimization (requires optipng)
Default level is 1 if not specified.
### Create Final PDF
```bash
./pdf_cleaner.py finalize output.pdf
```
Combines all processed pages into a final PDF with proper A4 sizing, borders, and centered content.
### 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 into a professional A4-sized PDF
- Adds configurable borders around content
- Centers content on each page while maintaining aspect ratio
## 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
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Version History
- v0.9rc3: Third release candidate
- Switched to parallel processing in optimization
- Removed unused imports and variables
- Added `ruff` as a development dependency
- v0.9rc2: Second release candidate
- Improved documentation
- Added optimization level descriptions
- Fixed aspect ratio in PDF output
- v0.9rc1: First release candidate with full functionality
- Professional PDF output with A4 sizing and borders
- Complete image processing pipeline
- Configurable settings
Regular → Executable
+388 -131
View File
@@ -1,157 +1,414 @@
#!/usr/bin/env python3 #!/usr/bin/env -S uv run
import os import os
import subprocess
import click
import cv2 import cv2
import img2pdf
import numpy as np import numpy as np
from pdf2image import convert_from_path from pdf2image import convert_from_path
import img2pdf
import settings
from PIL import Image from PIL import Image
import argparse import concurrent.futures
def deskew(image): class Settings:
"""Deskew the image using contour detection and rotation.""" TRIM_PADDING_PIXELS = 20
# Create a copy for processing while keeping original quality MONOCHROME_THRESHOLD = 127
proc_image = image.copy() OPTIPNG_OPTIMIZATION_LEVEL = 7
PDF_BORDER_SIZE = 50
# Convert to grayscale and blur def ensure_temp_dir():
gray = cv2.cvtColor(proc_image, cv2.COLOR_BGR2GRAY) """Ensure temporary directory exists and return its path."""
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
temp_dir = "temp_processed_images" temp_dir = "temp_processed_images"
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
return temp_dir
# Process each page def get_temp_files():
temp_image_paths = [] """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()
# Convert PDF to images
print(f"Extracting pages from {input_pdf}...")
pages = convert_from_path(input_pdf, dpi=400)
# Save each page
for i, page in enumerate(pages): for i, page in enumerate(pages):
print(f"Processing page {i+1}/{len(pages)}") # Convert to grayscale
page = page.convert('L')
# Ensure minimum width of 2048px output_path = os.path.join(temp_dir, f"page_{i:03d}.png")
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 # Save initial version
opencv_image = cv2.cvtColor(np.array(page), cv2.COLOR_RGB2BGR) page.save(output_path, "PNG", optimize=False)
# Process the image # Trim whitespace
deskewed = deskew(opencv_image) trim_whitespace(output_path)
cleaned = adjust_levels(deskewed)
# Convert to PIL Image # Reload the trimmed image
pil_image = Image.fromarray(cleaned) page = Image.open(output_path)
# Save temporarily as 1-bit PNG # Calculate new height maintaining aspect ratio
temp_path = os.path.join(temp_dir, f"page_{i:03d}.png") width = 2048
pil_image.save(temp_path, "PNG", optimize=False) ratio = width / page.width
temp_image_paths.append(temp_path) height = int(page.height * ratio)
# Save processed images as PDF with high quality settings # Resize using Lanczos
print("Saving cleaned PDF...") page = page.resize((width, height), Image.Resampling.LANCZOS)
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: # Save final version
f.write(img2pdf.convert(temp_image_paths, page.save(output_path, "PNG", optimize=False)
layout_fun=layout_fun, print(f"Saved page {i+1}/{len(pages)}")
with_pdfrw=True))
# Clean up temporary files print(f"Extracted {len(pages)} pages to {temp_dir}/")
for temp_path in temp_image_paths:
os.remove(temp_path)
os.rmdir(temp_dir)
print(f"Saved cleaned PDF to {output_path}") def trim_whitespace(image_path: str, is_final: bool = False) -> bool:
"""Remove white space from around the image.
def main(): Handles both RGB and RGBA images, treating transparent pixels as white.
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')
args = parser.parse_args() Args:
image_path: Path to the image file
is_final: Whether this is the final operation on the image
if not os.path.exists(args.input_pdf): Returns:
print(f"Error: Input file '{args.input_pdf}' does not exist") bool: True if successful, False otherwise
"""
try:
# Open image with PIL
image = Image.open(image_path)
# If image has transparency, flatten it first
if image.mode == 'RGBA':
# 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
image = image.convert('L')
# 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
# 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)
# 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)
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."""
temp_files = get_temp_files()
if not temp_files:
print("No pages found in temporary directory. Run 'extract' first.")
return return
process_pdf(args.input_pdf, args.output_pdf) for file_path in temp_files:
print(f"Deskewing {os.path.basename(file_path)}...")
if __name__ == "__main__": # Read image
main() 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")
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
"""
try:
# 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
image = image.convert('L')
# Convert to 1-bit using threshold
image = image.point(lambda x: 255 if x > settings.MONOCHROME_THRESHOLD else 0, '1')
# 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:
print(f"Error converting to monochrome {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:
subprocess.run(
['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', 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()
@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_files = get_temp_files()
if not temp_files:
print("No pages found in temporary directory. Run 'extract' first.")
return
opt_level = settings.OptimizationLevel(level)
total_files = len(temp_files)
successful = {
'trim': 0,
'monochrome': 0,
'optipng': 0
}
print(f"Processing {total_files} images at optimization level {level}...")
# Step 1: Convert to monochrome if level >= 2
if int(opt_level) >= int(settings.OptimizationLevel.MONOCHROME):
print("\nConverting to monochrome...")
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {executor.submit(convert_to_monochrome, file_path, (opt_level == settings.OptimizationLevel.MONOCHROME)): file_path for file_path in temp_files}
for future in concurrent.futures.as_completed(futures):
file_path = futures[future]
try:
if future.result():
successful['monochrome'] += 1
print(f"Converted {os.path.basename(file_path)}")
else:
print(f"Failed to convert {os.path.basename(file_path)}")
except Exception as exc:
print(f"{os.path.basename(file_path)} generated an exception: {exc}")
# Step 2: Always trim whitespace
print("\nTrimming whitespace from images...")
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {executor.submit(trim_whitespace, file_path, (opt_level == settings.OptimizationLevel.TRIM)): file_path for file_path in temp_files}
for future in concurrent.futures.as_completed(futures):
file_path = futures[future]
try:
if future.result():
successful['trim'] += 1
print(f"Trimmed {os.path.basename(file_path)}")
else:
print(f"Failed to trim {os.path.basename(file_path)}")
except Exception as exc:
print(f"{os.path.basename(file_path)} generated an exception: {exc}")
# Step 3: Run optipng if level = 3
if opt_level == settings.OptimizationLevel.FULL:
if check_optipng_installed():
print("\nOptimizing PNG files with optipng...")
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {executor.submit(run_optipng, file_path): file_path for file_path in temp_files}
for future in concurrent.futures.as_completed(futures):
file_path = futures[future]
try:
if future.result():
successful['optipng'] += 1
print(f"Optimized {os.path.basename(file_path)}")
else:
print(f"Failed to optimize {os.path.basename(file_path)}")
except Exception as exc:
print(f"{os.path.basename(file_path)} generated an exception: {exc}")
else:
print("\nNote: optipng not found. Skipping PNG optimization.")
# Print summary
print("\nOptimization complete!")
print(f"Successfully trimmed: {successful['trim']}/{total_files} images")
if int(opt_level) >= int(settings.OptimizationLevel.MONOCHROME):
print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images")
if opt_level == settings.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())
def finalize(output_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.")
return
print(f"Combining {len(temp_files)} pages into {output_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,
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...")
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()
+11 -1
View File
@@ -1,9 +1,19 @@
[project] [project]
name = "notes-cleaner" name = "notes-cleaner"
version = "0.1.0" version = "0.9rc3"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
license = "MIT"
authors = [
{ name = "Kessinen" }
]
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"click>=8.1.7",
"pydantic>=2.10.3", "pydantic>=2.10.3",
] ]
[dependency-groups]
dev = [
"ruff>=0.8.1",
]
+37
View File
@@ -0,0 +1,37 @@
"""Configuration settings for the PDF Musical Score Cleaner."""
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
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
# 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_'
Generated
+60 -2
View File
@@ -10,16 +10,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, { 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]] [[package]]
name = "notes-cleaner" name = "notes-cleaner"
version = "0.1.0" version = "0.9rc2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "click" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [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.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.8.1" }]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
@@ -74,6 +107,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 },
] ]
[[package]]
name = "ruff"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/d0/8ff5b189d125f4260f2255d143bf2fa413b69c2610c405ace7a0a8ec81ec/ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f", size = 3313222 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/d6/1a6314e568db88acdbb5121ed53e2c52cebf3720d3437a76f82f923bf171/ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5", size = 10532605 },
{ url = "https://files.pythonhosted.org/packages/89/a8/a957a8812e31facffb6a26a30be0b5b4af000a6e30c7d43a22a5232a3398/ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087", size = 10278243 },
{ url = "https://files.pythonhosted.org/packages/a8/23/9db40fa19c453fabf94f7a35c61c58f20e8200b4734a20839515a19da790/ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209", size = 9917739 },
{ url = "https://files.pythonhosted.org/packages/e2/a0/6ee2d949835d5701d832fc5acd05c0bfdad5e89cfdd074a171411f5ccad5/ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871", size = 10779153 },
{ url = "https://files.pythonhosted.org/packages/7a/25/9c11dca9404ef1eb24833f780146236131a3c7941de394bc356912ef1041/ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1", size = 10304387 },
{ url = "https://files.pythonhosted.org/packages/c8/b9/84c323780db1b06feae603a707d82dbbd85955c8c917738571c65d7d5aff/ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5", size = 11360351 },
{ url = "https://files.pythonhosted.org/packages/6b/e1/9d4bbb2ace7aad14ded20e4674a48cda5b902aed7a1b14e6b028067060c4/ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d", size = 12022879 },
{ url = "https://files.pythonhosted.org/packages/75/28/752ff6120c0e7f9981bc4bc275d540c7f36db1379ba9db9142f69c88db21/ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26", size = 11610354 },
{ url = "https://files.pythonhosted.org/packages/ba/8c/967b61c2cc8ebd1df877607fbe462bc1e1220b4a30ae3352648aec8c24bd/ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1", size = 12813976 },
{ url = "https://files.pythonhosted.org/packages/7f/29/e059f945d6bd2d90213387b8c360187f2fefc989ddcee6bbf3c241329b92/ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c", size = 11154564 },
{ url = "https://files.pythonhosted.org/packages/55/47/cbd05e5a62f3fb4c072bc65c1e8fd709924cad1c7ec60a1000d1e4ee8307/ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa", size = 10760604 },
{ url = "https://files.pythonhosted.org/packages/bb/ee/4c3981c47147c72647a198a94202633130cfda0fc95cd863a553b6f65c6a/ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540", size = 10391071 },
{ url = "https://files.pythonhosted.org/packages/6b/e6/083eb61300214590b188616a8ac6ae1ef5730a0974240fb4bec9c17de78b/ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9", size = 10896657 },
{ url = "https://files.pythonhosted.org/packages/77/bd/aacdb8285d10f1b943dbeb818968efca35459afc29f66ae3bd4596fbf954/ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5", size = 11228362 },
{ url = "https://files.pythonhosted.org/packages/39/72/fcb7ad41947f38b4eaa702aca0a361af0e9c2bf671d7fd964480670c297e/ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790", size = 8803476 },
{ url = "https://files.pythonhosted.org/packages/e4/ea/cae9aeb0f4822c44651c8407baacdb2e5b4dcd7b31a84e1c5df33aa2cc20/ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6", size = 9614463 },
{ url = "https://files.pythonhosted.org/packages/eb/76/fbb4bd23dfb48fa7758d35b744413b650a9fd2ddd93bca77e30376864414/ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737", size = 8959621 },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"