Skip to content

Commit 02e75d4

Browse files
authored
Merge pull request #106 from weeklydevchat/feature/script-to-optimize-image
Add image optimization script
2 parents 628ee8e + 14be4f5 commit 02e75d4

7 files changed

Lines changed: 202 additions & 2 deletions

File tree

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Ensure consistent line endings
2+
* text=auto
3+
4+
# Scripts must use LF
5+
*.py text eol=lf
6+
*.sh text eol=lf

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.14

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Weekly Dev Chat website — MkDocs Material static site. Read `mkdocs.yml` and r
77
- Posts are for Tuesdays (the weekly chat day). Use next Tuesday's date unless told otherwise.
88
- End post body with this paragraph (before the image):
99
`Everyone and anyone is welcome to [join](https://weeklydevchat.com/join/) as long as you are kind, supportive, and respectful of others. Zoom link will be posted at 12pm MDT.`
10-
- Place images in the same directory as the post's `index.md`.
10+
- Place images in the same directory as the post's `index.md`. Run `python3 scripts/optimize_image.py <image>` to convert to WebP and resize for the web.
1111
- Use `./create_post.sh` to scaffold a new post (calculates next Tuesday automatically).
1212
- **Multiple posts on the same date:** If a date folder already has an `index.md`, prefix the filename with a number and dash (e.g., `0-index.md`). The newest/latest post should use the lowest number so it appears first on the homepage. The original `index.md` keeps its name.
1313

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,43 @@ These scripts will:
118118

119119
4. Add any images to the same directory as the post
120120

121+
5. **Optimize images** for the web before committing (see [Optimizing Images](#optimizing-images) below)
122+
123+
### Optimizing Images
124+
125+
After adding an image to a post, run the optimization script to convert it to WebP and resize it for the web:
126+
127+
```bash
128+
python3 scripts/optimize_image.py docs/posts/YYYY/MM/DD/your_image.png
129+
```
130+
131+
This will create an optimized `.webp` file alongside the original. Update your post's markdown to reference the new `.webp` file, then delete the original if no longer needed.
132+
133+
**Options:**
134+
135+
| Flag | Default | Description |
136+
|------|---------|-------------|
137+
| `--quality` | 80 | WebP quality (1–100) |
138+
| `--max-width` | 1200 | Max width in pixels (won't upscale) |
139+
140+
**Examples:**
141+
142+
```bash
143+
# Optimize a single image with defaults
144+
python3 scripts/optimize_image.py docs/posts/2026/04/14/photo.png
145+
146+
# Batch optimize multiple images
147+
python3 scripts/optimize_image.py docs/posts/2026/04/14/*.png docs/posts/2026/04/14/*.jpg
148+
149+
# Custom quality and max width
150+
python3 scripts/optimize_image.py --quality 90 --max-width 1600 image.jpeg
151+
```
152+
153+
> **Note:** This script requires [Pillow](https://pillow.readthedocs.io/). Install dev dependencies with:
154+
> ```bash
155+
> pip install -r requirements-dev.txt
156+
> ```
157+
121158
### Categories and Tags
122159
123160
Categories are broad topic groupings and tags are specific topic labels for filtering. **Please use existing categories and tags when possible** to keep the taxonomy consistent. New ones can be added when truly needed.
@@ -145,8 +182,11 @@ This script requires `pyyaml`, which is included in `requirements.txt`. It is al
145182
├── docker-compose.yml # Docker development environment
146183
├── create_post.sh # Bash script to create blog posts
147184
├── create_post.ps1 # PowerShell script to create blog posts
185+
├── requirements-dev.in # Dev-only dependency pins (e.g. Pillow)
186+
├── requirements-dev.txt # Compiled dev dependencies
148187
├── scripts/
149-
│ └── find_tags_categories.py # List all existing tags and categories
188+
│ ├── find_tags_categories.py # List all existing tags and categories
189+
│ └── optimize_image.py # Optimize images for the web (PNG/JPEG → WebP)
150190
├── .github/
151191
│ ├── dependabot.yml # Dependabot configuration
152192
│ └── workflows/
@@ -176,6 +216,9 @@ mkdocs serve -a 0.0.0.0:8000 # Start server accessible on network
176216
mkdocs build # Build static site to site/ directory
177217
mkdocs build --clean # Clean build
178218

219+
# Optimize an image for the web
220+
python3 scripts/optimize_image.py docs/posts/YYYY/MM/DD/image.png
221+
179222
# Help
180223
mkdocs -h # Show help
181224

requirements-dev.in

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Dev-only dependencies (not needed for production builds)
2+
# Regenerate with: uv pip compile requirements-dev.in --output-file requirements-dev.txt
3+
4+
Pillow>=10.0.0

requirements-dev.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# This file was autogenerated by uv via the following command:
2+
# uv pip compile requirements-dev.in --output-file requirements-dev.txt
3+
pillow==11.3.0
4+
# via -r requirements-dev.in

scripts/optimize_image.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Optimize images for the Weekly Dev Chat website.
4+
5+
Converts images to WebP format, resizes to a max width, and reports savings.
6+
The original file is preserved; the optimized WebP is written alongside it.
7+
8+
Usage:
9+
python scripts/optimize_image.py image1.png image2.jpg
10+
python scripts/optimize_image.py --quality 85 --max-width 1600 image.png
11+
"""
12+
13+
import argparse
14+
import sys
15+
16+
if sys.version_info < (3, 14):
17+
print(
18+
f"Error: Python 3.14 or later is required (running {sys.version}).",
19+
file=sys.stderr,
20+
)
21+
sys.exit(1)
22+
23+
from pathlib import Path
24+
25+
try:
26+
from PIL import Image, ImageOps
27+
except ImportError:
28+
print(
29+
"Error: Pillow is not installed.\n"
30+
"Install dev dependencies with:\n"
31+
" pip install -r requirements-dev.txt\n"
32+
"Or install Pillow directly:\n"
33+
" pip install Pillow",
34+
file=sys.stderr,
35+
)
36+
sys.exit(1)
37+
38+
SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif"}
39+
40+
41+
def _quality_int(value: str) -> int:
42+
"""Argparse type for --quality: integer in range 1–100."""
43+
v = int(value)
44+
if not (1 <= v <= 100):
45+
raise argparse.ArgumentTypeError(f"quality must be between 1 and 100 (got {v})")
46+
return v
47+
48+
49+
def _positive_int(value: str) -> int:
50+
"""Argparse type for --max-width: integer greater than 0."""
51+
v = int(value)
52+
if v <= 0:
53+
raise argparse.ArgumentTypeError(f"max-width must be greater than 0 (got {v})")
54+
return v
55+
56+
57+
def optimize_image(input_path: Path, *, quality: int, max_width: int) -> Path | None:
58+
"""Optimize a single image. Returns the output path, or None on error."""
59+
if input_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
60+
print(f" Skipping {input_path.name}: unsupported format")
61+
return None
62+
63+
try:
64+
img = Image.open(input_path)
65+
except Exception as e:
66+
print(f" Error opening {input_path.name}: {e}", file=sys.stderr)
67+
return None
68+
69+
# Apply EXIF orientation so JPEGs rotated via metadata are correctly oriented
70+
img = ImageOps.exif_transpose(img)
71+
72+
# Convert palette/RGBA images appropriately for WebP
73+
if img.mode in ("P", "PA"):
74+
img = img.convert("RGBA")
75+
elif img.mode not in ("RGB", "RGBA"):
76+
img = img.convert("RGB")
77+
78+
# Resize if wider than max_width, preserving aspect ratio
79+
if img.width > max_width:
80+
ratio = max_width / img.width
81+
new_height = round(img.height * ratio)
82+
img = img.resize((max_width, new_height), Image.LANCZOS)
83+
84+
output_path = input_path.with_suffix(".webp")
85+
img.save(output_path, "WEBP", quality=quality)
86+
87+
original_size = input_path.stat().st_size
88+
optimized_size = output_path.stat().st_size
89+
change_pct = (optimized_size / original_size - 1) * 100 if original_size > 0 else 0
90+
91+
print(f" {input_path.name}")
92+
print(f" {original_size:,} bytes → {optimized_size:,} bytes ({change_pct:+.1f}%)")
93+
print(f" Saved to {output_path.name} ({img.width}x{img.height})")
94+
95+
return output_path
96+
97+
98+
def main() -> None:
99+
parser = argparse.ArgumentParser(
100+
description="Optimize images for the Weekly Dev Chat website.",
101+
)
102+
parser.add_argument(
103+
"images",
104+
nargs="+",
105+
type=Path,
106+
help="One or more image file paths to optimize.",
107+
)
108+
parser.add_argument(
109+
"--quality",
110+
type=_quality_int,
111+
default=80,
112+
help="WebP quality (1-100, default: 80).",
113+
)
114+
parser.add_argument(
115+
"--max-width",
116+
type=_positive_int,
117+
default=1200,
118+
help="Max image width in pixels (default: 1200). Images smaller than this are not upscaled.",
119+
)
120+
args = parser.parse_args()
121+
122+
successes = 0
123+
failures = 0
124+
125+
for image_path in args.images:
126+
if not image_path.is_file():
127+
print(f" Warning: {image_path} not found, skipping.", file=sys.stderr)
128+
failures += 1
129+
continue
130+
131+
result = optimize_image(image_path, quality=args.quality, max_width=args.max_width)
132+
if result:
133+
successes += 1
134+
else:
135+
failures += 1
136+
137+
print(f"\nDone: {successes} optimized, {failures} skipped/failed.")
138+
sys.exit(1 if failures > 0 and successes == 0 else 0)
139+
140+
141+
if __name__ == "__main__":
142+
main()

0 commit comments

Comments
 (0)