Files
PROJECT-CONTORL/backend/app/services/watermark_service.py
beabigegg 9b220523ff feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal

## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment

## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export

## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx

## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)

## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:49:52 +08:00

328 lines
11 KiB
Python

"""
Watermark Service for MED-009: Dynamic Watermark for Downloads
This service provides functions to add watermarks to image and PDF files
containing user information for audit and tracking purposes.
Watermark content includes:
- User name
- Employee ID (or email as fallback)
- Download timestamp
"""
import io
import logging
import math
from datetime import datetime
from typing import Optional, Tuple
import fitz # PyMuPDF
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger(__name__)
class WatermarkService:
"""Service for adding watermarks to downloaded files."""
# Watermark configuration
WATERMARK_OPACITY = 0.3 # 30% opacity for semi-transparency
WATERMARK_ANGLE = -45 # Diagonal angle in degrees
WATERMARK_FONT_SIZE = 24
WATERMARK_COLOR = (128, 128, 128) # Gray color for watermark
WATERMARK_SPACING = 200 # Spacing between repeated watermarks
@staticmethod
def _format_watermark_text(
user_name: str,
employee_id: Optional[str] = None,
download_time: Optional[datetime] = None
) -> str:
"""
Format the watermark text with user information.
Args:
user_name: Name of the user
employee_id: Employee ID (工號) - uses 'N/A' if not provided
download_time: Time of download (defaults to now)
Returns:
Formatted watermark text
"""
if download_time is None:
download_time = datetime.now()
time_str = download_time.strftime("%Y-%m-%d %H:%M:%S")
emp_id = employee_id if employee_id else "N/A"
return f"{user_name} ({emp_id}) - {time_str}"
@staticmethod
def _get_font(size: int = 24) -> ImageFont.FreeTypeFont:
"""Get a font for the watermark. Falls back to default if custom font not available."""
try:
# Try to use a common system font (macOS)
return ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
except (OSError, IOError):
try:
# Try Linux font
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
except (OSError, IOError):
try:
# Try Windows font
return ImageFont.truetype("C:/Windows/Fonts/arial.ttf", size)
except (OSError, IOError):
# Fall back to default bitmap font
return ImageFont.load_default()
def add_image_watermark(
self,
image_bytes: bytes,
user_name: str,
employee_id: Optional[str] = None,
download_time: Optional[datetime] = None
) -> Tuple[bytes, str]:
"""
Add a semi-transparent diagonal watermark to an image.
Args:
image_bytes: The original image as bytes
user_name: Name of the user downloading the file
employee_id: Employee ID of the user (工號)
download_time: Time of download (defaults to now)
Returns:
Tuple of (watermarked image bytes, output format)
Raises:
Exception: If watermarking fails
"""
# Open the image
original = Image.open(io.BytesIO(image_bytes))
# Convert to RGBA if necessary for transparency support
if original.mode != 'RGBA':
image = original.convert('RGBA')
else:
image = original.copy()
# Create a transparent overlay for the watermark
watermark_layer = Image.new('RGBA', image.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(watermark_layer)
# Get watermark text
watermark_text = self._format_watermark_text(user_name, employee_id, download_time)
# Get font
font = self._get_font(self.WATERMARK_FONT_SIZE)
# Calculate text size
bbox = draw.textbbox((0, 0), watermark_text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Create a larger canvas for the rotated text pattern
diagonal = int(math.sqrt(image.size[0]**2 + image.size[1]**2))
pattern_size = diagonal * 2
# Create pattern layer
pattern = Image.new('RGBA', (pattern_size, pattern_size), (255, 255, 255, 0))
pattern_draw = ImageDraw.Draw(pattern)
# Draw repeated watermark text across the pattern
opacity = int(255 * self.WATERMARK_OPACITY)
watermark_color = (*self.WATERMARK_COLOR, opacity)
y = 0
row = 0
while y < pattern_size:
x = -text_width if row % 2 else 0 # Offset alternate rows
while x < pattern_size:
pattern_draw.text((x, y), watermark_text, font=font, fill=watermark_color)
x += text_width + self.WATERMARK_SPACING
y += text_height + self.WATERMARK_SPACING
row += 1
# Rotate the pattern
rotated_pattern = pattern.rotate(
self.WATERMARK_ANGLE,
expand=False,
center=(pattern_size // 2, pattern_size // 2)
)
# Crop to original image size (centered)
crop_x = (pattern_size - image.size[0]) // 2
crop_y = (pattern_size - image.size[1]) // 2
cropped_pattern = rotated_pattern.crop((
crop_x, crop_y,
crop_x + image.size[0],
crop_y + image.size[1]
))
# Composite the watermark onto the image
watermarked = Image.alpha_composite(image, cropped_pattern)
# Determine output format
original_format = original.format or 'PNG'
if original_format.upper() == 'JPEG':
# Convert back to RGB for JPEG (no alpha channel)
watermarked = watermarked.convert('RGB')
output_format = 'JPEG'
else:
output_format = 'PNG'
# Save to bytes
output = io.BytesIO()
watermarked.save(output, format=output_format, quality=95)
output.seek(0)
logger.info(
f"Image watermark applied successfully for user {user_name} "
f"(employee_id: {employee_id})"
)
return output.getvalue(), output_format.lower()
def add_pdf_watermark(
self,
pdf_bytes: bytes,
user_name: str,
employee_id: Optional[str] = None,
download_time: Optional[datetime] = None
) -> bytes:
"""
Add a semi-transparent diagonal watermark to a PDF using PyMuPDF.
Args:
pdf_bytes: The original PDF as bytes
user_name: Name of the user downloading the file
employee_id: Employee ID of the user (工號)
download_time: Time of download (defaults to now)
Returns:
Watermarked PDF as bytes
Raises:
Exception: If watermarking fails
"""
# Get watermark text
watermark_text = self._format_watermark_text(user_name, employee_id, download_time)
# Open the PDF with PyMuPDF
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
page_count = len(doc)
# Process each page
for page_num in range(page_count):
page = doc[page_num]
page_rect = page.rect
page_width = page_rect.width
page_height = page_rect.height
# Calculate text width for spacing estimation
text_length = fitz.get_text_length(
watermark_text,
fontname="helv",
fontsize=self.WATERMARK_FONT_SIZE
)
# Calculate diagonal for watermark coverage
diagonal = math.sqrt(page_width**2 + page_height**2)
# Set watermark color with opacity (gray with 30% opacity)
color = (0.5, 0.5, 0.5) # Gray
# Calculate rotation angle in radians
angle_rad = math.radians(self.WATERMARK_ANGLE)
# Draw watermark pattern using shape with proper rotation
# We use insert_textbox with a morph transform for rotation
spacing_x = text_length + self.WATERMARK_SPACING
spacing_y = self.WATERMARK_FONT_SIZE + self.WATERMARK_SPACING
# Create watermark by drawing rotated text lines
# We'll use a simpler approach: draw text and apply rotation via morph
shape = page.new_shape()
# Calculate grid positions to cover the page when rotated
center = fitz.Point(page_width / 2, page_height / 2)
# Calculate start and end points for coverage
start = -diagonal
end = diagonal * 2
y = start
row = 0
while y < end:
x = start + (spacing_x / 2 if row % 2 else 0)
while x < end:
# Create text position
text_point = fitz.Point(x, y)
# Apply rotation around center
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# Translate to origin, rotate, translate back
rx = text_point.x - center.x
ry = text_point.y - center.y
new_x = rx * cos_a - ry * sin_a + center.x
new_y = rx * sin_a + ry * cos_a + center.y
# Check if the rotated point is within page bounds (with margin)
margin = 50
if (-margin <= new_x <= page_width + margin and
-margin <= new_y <= page_height + margin):
# Insert text using shape with rotation via morph
text_rect = fitz.Rect(new_x, new_y, new_x + text_length + 10, new_y + 30)
# Use insert_textbox with morph for rotation
pivot = fitz.Point(new_x, new_y)
morph = (pivot, fitz.Matrix(1, 0, 0, 1, 0, 0).prerotate(self.WATERMARK_ANGLE))
shape.insert_textbox(
text_rect,
watermark_text,
fontname="helv",
fontsize=self.WATERMARK_FONT_SIZE,
color=color,
fill_opacity=self.WATERMARK_OPACITY,
morph=morph
)
x += spacing_x
y += spacing_y
row += 1
# Commit the shape drawings
shape.commit(overlay=True)
# Save to bytes
output = io.BytesIO()
doc.save(output)
doc.close()
output.seek(0)
logger.info(
f"PDF watermark applied successfully for user {user_name} "
f"(employee_id: {employee_id}), pages: {page_count}"
)
return output.getvalue()
def is_supported_image(self, mime_type: str) -> bool:
"""Check if the mime type is a supported image format."""
supported_types = {'image/png', 'image/jpeg', 'image/jpg'}
return mime_type.lower() in supported_types
def is_supported_pdf(self, mime_type: str) -> bool:
"""Check if the mime type is a PDF."""
return mime_type.lower() == 'application/pdf'
def supports_watermark(self, mime_type: str) -> bool:
"""Check if the file type supports watermarking."""
return self.is_supported_image(mime_type) or self.is_supported_pdf(mime_type)
# Singleton instance
watermark_service = WatermarkService()