feat: ADD optional exif data removable in files #18

This commit is contained in:
Caffeine Fueled 2026-07-04 07:59:35 +02:00
parent 5b6edac236
commit 7a8ecafc3a
Signed by: cf7
GPG key ID: CA295D643074C68C
4 changed files with 99 additions and 3 deletions

View file

@ -28,6 +28,7 @@ Show cases:
- HTML anchors for headers
- list random posts at the bottom
- optional RSS feeds
- automatic EXIF/metadata stripping from images (privacy)
**Ideas**:
- custom error pages (404, etc)
@ -75,6 +76,25 @@ EXCLUDE_SECTIONS_FROM_MAIN = ['draft', 'private']
This is useful for draft posts or topic-specific content you want separated from the main index.
### Images
Images placed in `images/` are copied to `output/images/`. By default picopaper
strips metadata (EXIF, GPS location, XMP, PNG text chunks) from them during the
build so nothing like camera model or GPS coordinates is published. The source
files in `images/` are never modified — only the copies in `output/`.
JPEGs are re-encoded with their original quantization tables (`quality="keep"`),
so there is no quality loss. Animated images (GIF/WebP) and unsupported formats
(e.g. SVG) are copied as-is.
**Configuration in `config.py`:**
```python
STRIP_IMAGE_EXIF = True # set to False to copy images byte-for-byte
```
Metadata stripping uses [Pillow](https://python-pillow.org/). If Pillow is not
installed, picopaper prints a warning and copies images unchanged.
---
## Installation

View file

@ -28,6 +28,9 @@ HIDE_LOGO = False
HIDE_TITLE = True
LOGO_PATH = "/images/logo.png"
# Image settings
STRIP_IMAGE_EXIF = True # Remove EXIF/GPS/XMP/text metadata from images/ when building (requires pillow)
# Feed settings
ENABLE_RSS_FEED = True
RSS_FEED_PATH = "rss.xml" # Path relative to site root (e.g., "rss.xml" or "feed/rss.xml")

View file

@ -12,6 +12,16 @@ from config import (BLOG_TITLE, BLOG_DESCRIPTION, THEME, EXCLUDE_SECTIONS_FROM_M
BASE_URL, AUTHOR_NAME, AUTHOR_EMAIL, FEED_MAX_ITEMS,
BLOGROLL_PATH, ROOT_PAGE)
# STRIP_IMAGE_EXIF is a newer setting; older config.py files may not define it.
# Default to enabled so the privacy feature works without a config update.
try:
from config import STRIP_IMAGE_EXIF
except ImportError:
STRIP_IMAGE_EXIF = True
# Raster image formats whose metadata (EXIF/GPS/XMP/text chunks) is stripped on copy
STRIP_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.tif', '.bmp'}
class SSGGGenerator:
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
self.items_dir = Path(items_dir)
@ -29,6 +39,7 @@ class SSGGGenerator:
self.hide_logo = HIDE_LOGO
self.hide_title = HIDE_TITLE
self.logo_path = LOGO_PATH
self.strip_image_exif = STRIP_IMAGE_EXIF
# Setup Jinja2
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
@ -383,6 +394,38 @@ class SSGGGenerator:
print(f"✓ Generated {output_path}")
def strip_image_metadata(self, src_path, dest_path):
"""Re-encode an image to dest_path without any metadata (EXIF/GPS/XMP/text).
Pillow does not carry metadata across a save unless it is passed
explicitly, so re-saving produces a clean image. Returns True on
success, or False if the image should be copied verbatim instead
(unsupported/animated) or re-encoding failed.
"""
from PIL import Image
try:
with Image.open(src_path) as img:
img.load()
# Animated images (GIF/WebP/APNG) are copied as-is to avoid
# dropping frames; they don't carry camera EXIF/GPS anyway.
if getattr(img, 'is_animated', False):
return False
save_kwargs = {}
if img.format == 'JPEG':
# 'keep' reuses the original quantization tables, so there
# is no generational quality loss from re-encoding.
save_kwargs['quality'] = 'keep'
# Saving without exif=/pnginfo= drops all metadata.
img.save(dest_path, format=img.format, **save_kwargs)
return True
except Exception as e:
print(f" ⚠ Could not strip metadata from {src_path.name} ({e}); copying as-is")
return False
def copy_assets(self):
"""Copy theme assets and images to output directory"""
import shutil
@ -395,13 +438,42 @@ class SSGGGenerator:
shutil.copytree(self.assets_dir, dest_dir)
print(f"✓ Copied theme assets to output")
# Copy images
# Copy images (stripping EXIF/metadata when enabled)
images_dir = Path('images')
if images_dir.exists():
dest_dir = self.output_dir / 'images'
if dest_dir.exists():
shutil.rmtree(dest_dir)
shutil.copytree(images_dir, dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
# Only strip if enabled and Pillow is importable; otherwise copy as-is.
pillow_available = self.strip_image_exif
if self.strip_image_exif:
try:
import PIL # noqa: F401
except ImportError:
pillow_available = False
print(" ⚠ STRIP_IMAGE_EXIF is enabled but Pillow is not installed; "
"copying images without stripping (pip install pillow)")
stripped = 0
for item in images_dir.rglob('*'):
if not item.is_file():
continue
rel_path = item.relative_to(images_dir)
dest_path = dest_dir / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
if (pillow_available
and item.suffix.lower() in STRIP_IMAGE_EXTENSIONS
and self.strip_image_metadata(item, dest_path)):
stripped += 1
else:
shutil.copy2(item, dest_path)
if pillow_available:
print(f"✓ Copied images/ to output ({stripped} stripped of EXIF/metadata)")
else:
print(f"✓ Copied images/ to output")
# Copy static files (GPG keys, .well-known, etc.)

View file

@ -1,2 +1,3 @@
jinja2>=3.0.0
markdown>=3.4.0
pillow>=10.0.0