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
|
- HTML anchors for headers
|
||||||
- list random posts at the bottom
|
- list random posts at the bottom
|
||||||
- optional RSS feeds
|
- optional RSS feeds
|
||||||
|
- automatic EXIF/metadata stripping from images (privacy)
|
||||||
|
|
||||||
**Ideas**:
|
**Ideas**:
|
||||||
- custom error pages (404, etc)
|
- 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.
|
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
|
## Installation
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ HIDE_LOGO = False
|
||||||
HIDE_TITLE = True
|
HIDE_TITLE = True
|
||||||
LOGO_PATH = "/images/logo.png"
|
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
|
# Feed settings
|
||||||
ENABLE_RSS_FEED = True
|
ENABLE_RSS_FEED = True
|
||||||
RSS_FEED_PATH = "rss.xml" # Path relative to site root (e.g., "rss.xml" or "feed/rss.xml")
|
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,
|
BASE_URL, AUTHOR_NAME, AUTHOR_EMAIL, FEED_MAX_ITEMS,
|
||||||
BLOGROLL_PATH, ROOT_PAGE)
|
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:
|
class SSGGGenerator:
|
||||||
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
|
def __init__(self, items_dir='items', output_dir='output', theme=None, blog_title=None, blog_description=None):
|
||||||
self.items_dir = Path(items_dir)
|
self.items_dir = Path(items_dir)
|
||||||
|
|
@ -29,6 +39,7 @@ class SSGGGenerator:
|
||||||
self.hide_logo = HIDE_LOGO
|
self.hide_logo = HIDE_LOGO
|
||||||
self.hide_title = HIDE_TITLE
|
self.hide_title = HIDE_TITLE
|
||||||
self.logo_path = LOGO_PATH
|
self.logo_path = LOGO_PATH
|
||||||
|
self.strip_image_exif = STRIP_IMAGE_EXIF
|
||||||
|
|
||||||
# Setup Jinja2
|
# Setup Jinja2
|
||||||
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
self.env = Environment(loader=FileSystemLoader(self.templates_dir))
|
||||||
|
|
@ -383,6 +394,38 @@ class SSGGGenerator:
|
||||||
|
|
||||||
print(f"✓ Generated {output_path}")
|
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):
|
def copy_assets(self):
|
||||||
"""Copy theme assets and images to output directory"""
|
"""Copy theme assets and images to output directory"""
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -395,14 +438,43 @@ class SSGGGenerator:
|
||||||
shutil.copytree(self.assets_dir, dest_dir)
|
shutil.copytree(self.assets_dir, dest_dir)
|
||||||
print(f"✓ Copied theme assets to output")
|
print(f"✓ Copied theme assets to output")
|
||||||
|
|
||||||
# Copy images
|
# Copy images (stripping EXIF/metadata when enabled)
|
||||||
images_dir = Path('images')
|
images_dir = Path('images')
|
||||||
if images_dir.exists():
|
if images_dir.exists():
|
||||||
dest_dir = self.output_dir / 'images'
|
dest_dir = self.output_dir / 'images'
|
||||||
if dest_dir.exists():
|
if dest_dir.exists():
|
||||||
shutil.rmtree(dest_dir)
|
shutil.rmtree(dest_dir)
|
||||||
shutil.copytree(images_dir, dest_dir)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"✓ Copied images/ to output")
|
|
||||||
|
# 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.)
|
# Copy static files (GPG keys, .well-known, etc.)
|
||||||
static_dir = Path('static')
|
static_dir = Path('static')
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
jinja2>=3.0.0
|
jinja2>=3.0.0
|
||||||
markdown>=3.4.0
|
markdown>=3.4.0
|
||||||
|
pillow>=10.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue