From 7a8ecafc3a7a3d2384436d4bb2718a5a44cf61e9 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sat, 4 Jul 2026 07:59:35 +0200 Subject: [PATCH] feat: ADD optional exif data removable in files #18 --- README.md | 20 +++++++++++++ config.py | 3 ++ picopaper.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 1 + 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index db60989..ea16cc5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.py b/config.py index 17af7af..b5e2c4d 100644 --- a/config.py +++ b/config.py @@ -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") diff --git a/picopaper.py b/picopaper.py index 8069f6e..47b43ef 100644 --- a/picopaper.py +++ b/picopaper.py @@ -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,14 +438,43 @@ 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) - print(f"✓ Copied images/ to output") + 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.) static_dir = Path('static') diff --git a/requirements.txt b/requirements.txt index ea5dec4..bfa5ffa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ jinja2>=3.0.0 markdown>=3.4.0 +pillow>=10.0.0