16 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
6 changed files with 407 additions and 14 deletions
+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.
+30 -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)
- **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.8+ installed
1. Ensure you have Python 3.12+ installed
2. Install uv (recommended) or pip
3. Clone this repository:
```bash
@@ -55,15 +56,20 @@ Automatically detects and corrects page rotation by analyzing staff lines.
### Optimize Pages
```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
```bash
./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
```bash
@@ -83,7 +89,10 @@ Combines all processed pages into a final PDF and cleans up temporary files.
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
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
@@ -100,4 +109,19 @@ Contributions are welcome! Please feel free to submit a Pull Request.
## License
[Insert chosen license here]
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
+275 -6
View File
@@ -1,11 +1,22 @@
import os
#!/usr/bin/env -S uv run
import os
import subprocess
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
import settings
from PIL import Image
import concurrent.futures
class Settings:
TRIM_PADDING_PIXELS = 20
MONOCHROME_THRESHOLD = 127
OPTIPNG_OPTIMIZATION_LEVEL = 7
PDF_BORDER_SIZE = 50
def ensure_temp_dir():
"""Ensure temporary directory exists and return its path."""
@@ -37,12 +48,95 @@ 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, 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
"""
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."""
@@ -117,6 +211,169 @@ def deskew():
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):
@@ -129,9 +386,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...")
+10 -1
View File
@@ -1,10 +1,19 @@
[project]
name = "notes-cleaner"
version = "0.1.0"
version = "0.9rc3"
description = "Add your description here"
readme = "README.md"
license = "MIT"
authors = [
{ name = "Kessinen" }
]
requires-python = ">=3.12"
dependencies = [
"click>=8.1.7",
"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
+34 -1
View File
@@ -33,19 +33,27 @@ wheels = [
[[package]]
name = "notes-cleaner"
version = "0.1.0"
version = "0.9rc2"
source = { virtual = "." }
dependencies = [
{ name = "click" },
{ name = "pydantic" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
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]]
name = "pydantic"
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 },
]
[[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]]
name = "typing-extensions"
version = "4.12.2"