5 Commits
Author SHA1 Message Date
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
4 changed files with 113 additions and 49 deletions
+26 -6
View File
@@ -10,10 +10,11 @@ A command-line tool for processing and cleaning scanned musical score PDFs. This
- **PNG Optimization**: Optimize PNG files using optipng (if installed) - **PNG Optimization**: Optimize PNG files using optipng (if installed)
- **Modular Processing**: Process your files step by step or all at once - **Modular Processing**: Process your files step by step or all at once
- **High Quality Output**: Preserve image quality throughout the process - **High Quality Output**: Preserve image quality throughout the process
- **Professional PDF Output**: Generate A4-sized PDFs with proper borders and centered content
## Installation ## Installation
1. Ensure you have Python 3.8+ installed 1. Ensure you have Python 3.12+ installed
2. Install uv (recommended) or pip 2. Install uv (recommended) or pip
3. Clone this repository: 3. Clone this repository:
```bash ```bash
@@ -55,15 +56,20 @@ Automatically detects and corrects page rotation by analyzing staff lines.
### Optimize Pages ### Optimize Pages
```bash ```bash
./pdf_cleaner.py optimize ./pdf_cleaner.py optimize [--level {1,2,3}]
``` ```
Trims excess white space and optionally runs PNG optimization (requires optipng). 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 ### Create Final PDF
```bash ```bash
./pdf_cleaner.py finalize output.pdf ./pdf_cleaner.py finalize output.pdf
``` ```
Combines all processed pages into a final PDF and cleans up temporary files. Combines all processed pages into a final PDF with proper A4 sizing, borders, and centered content.
### Typical Workflow ### Typical Workflow
```bash ```bash
@@ -83,7 +89,10 @@ Combines all processed pages into a final PDF and cleans up temporary files.
3. **Optimization**: 3. **Optimization**:
- Detects content boundaries and removes excess white space - Detects content boundaries and removes excess white space
- Optionally runs optipng for additional file size reduction - Optionally runs optipng for additional file size reduction
4. **Finalization**: Combines processed images back into a PDF using img2pdf 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 ## Dependencies
@@ -100,4 +109,15 @@ Contributions are welcome! Please feel free to submit a Pull Request.
## License ## License
[Insert chosen license here] This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Version History
- 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
+47 -41
View File
@@ -1,11 +1,7 @@
#!/usr/bin/env -S uv run #!/usr/bin/env -S uv run
import os import os
import shutil
import subprocess import subprocess
from pathlib import Path
from typing import List, Tuple
import click import click
import cv2 import cv2
import img2pdf import img2pdf
@@ -13,8 +9,8 @@ import numpy as np
from pdf2image import convert_from_path from pdf2image import convert_from_path
import settings import settings
from settings import OptimizationLevel
from PIL import Image from PIL import Image
import concurrent.futures
class Settings: class Settings:
TRIM_PADDING_PIXELS = 20 TRIM_PADDING_PIXELS = 20
@@ -22,8 +18,6 @@ class Settings:
OPTIPNG_OPTIMIZATION_LEVEL = 7 OPTIPNG_OPTIMIZATION_LEVEL = 7
PDF_BORDER_SIZE = 50 PDF_BORDER_SIZE = 50
settings = Settings()
def ensure_temp_dir(): def ensure_temp_dir():
"""Ensure temporary directory exists and return its path.""" """Ensure temporary directory exists and return its path."""
temp_dir = "temp_processed_images" temp_dir = "temp_processed_images"
@@ -281,7 +275,7 @@ def run_optipng(file_path: str) -> bool:
bool: True if successful, False otherwise bool: True if successful, False otherwise
""" """
try: try:
result = subprocess.run( subprocess.run(
['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', file_path], ['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', file_path],
capture_output=True, capture_output=True,
text=True, text=True,
@@ -306,13 +300,12 @@ def optimize(level):
2: Level 1 + convert to 1-bit monochrome 2: Level 1 + convert to 1-bit monochrome
3: Level 2 + optipng optimization 3: Level 2 + optipng optimization
""" """
temp_dir = ensure_temp_dir()
temp_files = get_temp_files() temp_files = get_temp_files()
if not temp_files: if not temp_files:
print("No pages found in temporary directory. Run 'extract' first.") print("No pages found in temporary directory. Run 'extract' first.")
return return
opt_level = OptimizationLevel(level) opt_level = settings.OptimizationLevel(level)
total_files = len(temp_files) total_files = len(temp_files)
successful = { successful = {
@@ -323,49 +316,62 @@ def optimize(level):
print(f"Processing {total_files} images at optimization level {level}...") print(f"Processing {total_files} images at optimization level {level}...")
# Step 1: Always trim whitespace # Step 1: Convert to monochrome if level >= 2
print("\nTrimming whitespace from images...") if int(opt_level) >= int(settings.OptimizationLevel.MONOCHROME):
for i, file_path in enumerate(temp_files, 1):
print(f"[{i}/{total_files}] Processing {os.path.basename(file_path)}...", end='', flush=True)
# 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:
print(" ")
# Step 2: Convert to monochrome if level >= 2
if int(opt_level) >= int(OptimizationLevel.MONOCHROME):
print("\nConverting to monochrome...") print("\nConverting to monochrome...")
for i, file_path in enumerate(temp_files, 1): with concurrent.futures.ThreadPoolExecutor() as executor:
print(f"[{i}/{total_files}] Converting {os.path.basename(file_path)}...", end='', flush=True) futures = {executor.submit(convert_to_monochrome, file_path, (opt_level == settings.OptimizationLevel.MONOCHROME)): file_path for file_path in temp_files}
# Only optimize if this is the final step (level 2) for future in concurrent.futures.as_completed(futures):
if convert_to_monochrome(file_path, is_final=(opt_level == OptimizationLevel.MONOCHROME)): file_path = futures[future]
successful['monochrome'] += 1 try:
print(" ") if future.result():
else: successful['monochrome'] += 1
print(" ") 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 # Step 3: Run optipng if level = 3
if opt_level == OptimizationLevel.FULL: if opt_level == settings.OptimizationLevel.FULL:
if check_optipng_installed(): if check_optipng_installed():
print("\nOptimizing PNG files with optipng...") print("\nOptimizing PNG files with optipng...")
for i, file_path in enumerate(temp_files, 1): with concurrent.futures.ThreadPoolExecutor() as executor:
print(f"[{i}/{total_files}] Optimizing {os.path.basename(file_path)}...", end='', flush=True) futures = {executor.submit(run_optipng, file_path): file_path for file_path in temp_files}
if run_optipng(file_path): for future in concurrent.futures.as_completed(futures):
successful['optipng'] += 1 file_path = futures[future]
print(" ") try:
else: if future.result():
print(" ") 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: else:
print("\nNote: optipng not found. Skipping PNG optimization.") print("\nNote: optipng not found. Skipping PNG optimization.")
# Print summary # Print summary
print("\nOptimization complete!") print("\nOptimization complete!")
print(f"Successfully trimmed: {successful['trim']}/{total_files} images") print(f"Successfully trimmed: {successful['trim']}/{total_files} images")
if int(opt_level) >= int(OptimizationLevel.MONOCHROME): if int(opt_level) >= int(settings.OptimizationLevel.MONOCHROME):
print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images") print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images")
if opt_level == OptimizationLevel.FULL and check_optipng_installed(): if opt_level == settings.OptimizationLevel.FULL and check_optipng_installed():
print(f"Successfully optimized with optipng: {successful['optipng']}/{total_files} images") print(f"Successfully optimized with optipng: {successful['optipng']}/{total_files} images")
@cli.command() @cli.command()
+6 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "notes-cleaner" name = "notes-cleaner"
version = "0.9rc1" version = "0.9rc3"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@@ -12,3 +12,8 @@ dependencies = [
"click>=8.1.7", "click>=8.1.7",
"pydantic>=2.10.3", "pydantic>=2.10.3",
] ]
[dependency-groups]
dev = [
"ruff>=0.8.1",
]
Generated
+34 -1
View File
@@ -33,19 +33,27 @@ wheels = [
[[package]] [[package]]
name = "notes-cleaner" name = "notes-cleaner"
version = "0.1.0" version = "0.9rc2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "pydantic" }, { name = "pydantic" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.1.7" }, { name = "click", specifier = ">=8.1.7" },
{ name = "pydantic", specifier = ">=2.10.3" }, { name = "pydantic", specifier = ">=2.10.3" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.8.1" }]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.10.3" version = "2.10.3"
@@ -99,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"