feat: ADD optional exif data removable in files #18
This commit is contained in:
parent
5b6edac236
commit
7a8ecafc3a
4 changed files with 99 additions and 3 deletions
20
README.md
20
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
78
picopaper.py
78
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')
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
jinja2>=3.0.0
|
||||
markdown>=3.4.0
|
||||
pillow>=10.0.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue