Skip to content

Commit c1eae48

Browse files
feat(images): add watermark as footer strip below plot
- Footer is a white strip added below the image - No overlap with axis labels - Vertical padding is ~1/4 of font height - Updated tests for new behavior
1 parent dfbcf54 commit c1eae48

2 files changed

Lines changed: 40 additions & 34 deletions

File tree

core/images.py

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -83,26 +83,24 @@ def add_watermark(
8383
font_size: int | None = None,
8484
padding: int | None = None,
8585
) -> None:
86-
"""Add pyplots.ai branded watermark to an image.
86+
"""Add pyplots.ai branded watermark footer to an image.
8787
88-
Adds pyplots.ai (in brand colors) to bottom-right and spec_id to bottom-left.
89-
Uses JetBrains Mono Bold font with gray shadow for readability.
88+
Adds a white footer strip below the image with spec_id (left) and pyplots.ai (right).
89+
Uses JetBrains Mono Bold font and brand colors.
9090
Font size and padding scale automatically based on image width.
9191
9292
Args:
9393
input_path: Path to the source image.
9494
output_path: Path where the watermarked image will be saved.
9595
spec_id: Spec ID for bottom-left corner (e.g., "scatter-basic").
96-
font_size: Size of the watermark font in pixels. If None, auto-scales (~1% of width).
97-
padding: Padding from the image edge in pixels. If None, auto-scales (~0.5% of width).
96+
font_size: Size of the watermark font in pixels. If None, auto-scales (~1.35% of width).
97+
padding: Padding from the image edge in pixels. If None, auto-scales (~0.8% of width).
9898
9999
Raises:
100100
FileNotFoundError: If input_path does not exist.
101101
PIL.UnidentifiedImageError: If input is not a valid image.
102102
"""
103-
img = Image.open(input_path).convert("RGBA")
104-
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
105-
draw = ImageDraw.Draw(overlay)
103+
img = Image.open(input_path).convert("RGB")
106104

107105
# Auto-scale font size and padding based on image width
108106
if font_size is None:
@@ -111,44 +109,49 @@ def add_watermark(
111109
padding = max(15, int(img.width * 0.008)) # ~0.8% of width, min 15px
112110

113111
font = _get_font(font_size)
114-
alpha = int(255 * 0.95)
115112

116-
# Brand colors
117-
py_color = _hex_to_rgba(PYPLOTS_BLUE, alpha)
118-
plots_color = _hex_to_rgba(PYPLOTS_YELLOW, alpha)
119-
ai_color = _hex_to_rgba(PYPLOTS_DARK, alpha)
120-
shadow_color = (50, 50, 50, 150) # Gray shadow
113+
# Measure text height for footer
114+
temp_draw = ImageDraw.Draw(img)
115+
text_h = temp_draw.textbbox((0, 0), "py", font=font)[3]
121116

122-
# Measure text dimensions
117+
# Footer height: text + minimal padding (~half font height total padding)
118+
footer_padding_v = max(4, text_h // 4) # Small vertical padding
119+
footer_height = text_h + footer_padding_v * 2
120+
121+
# Create new image with footer
122+
new_height = img.height + footer_height
123+
result = Image.new("RGB", (img.width, new_height), (255, 255, 255))
124+
result.paste(img, (0, 0))
125+
126+
# Draw on footer
127+
draw = ImageDraw.Draw(result)
128+
129+
# Brand colors (RGB, no alpha needed on white background)
130+
py_color = tuple(int(PYPLOTS_BLUE.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
131+
plots_color = tuple(int(PYPLOTS_YELLOW.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
132+
ai_color = tuple(int(PYPLOTS_DARK.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
133+
134+
# Measure text widths
123135
py_w = draw.textbbox((0, 0), "py", font=font)[2]
124136
plots_w = draw.textbbox((0, 0), "plots", font=font)[2]
125137
ai_w = draw.textbbox((0, 0), ".ai", font=font)[2]
126138
url_w = py_w + plots_w + ai_w
127-
text_h = draw.textbbox((0, 0), "py", font=font)[3]
128139

129-
# Position: bottom with padding
130-
y = img.height - text_h - padding
140+
# Position: centered vertically in footer
141+
y = img.height + footer_padding_v
131142
url_x = img.width - url_w - padding
132143
spec_x = padding
133-
shadow_offset = 2
134-
135-
# Draw pyplots.ai with shadow (right side)
136-
# Shadow first
137-
draw.text((url_x + shadow_offset, y + shadow_offset), "py", font=font, fill=shadow_color)
138-
draw.text((url_x + py_w + shadow_offset, y + shadow_offset), "plots", font=font, fill=shadow_color)
139-
draw.text((url_x + py_w + plots_w + shadow_offset, y + shadow_offset), ".ai", font=font, fill=shadow_color)
140-
# Colored text
144+
145+
# Draw pyplots.ai (right side)
141146
draw.text((url_x, y), "py", font=font, fill=py_color)
142147
draw.text((url_x + py_w, y), "plots", font=font, fill=plots_color)
143148
draw.text((url_x + py_w + plots_w, y), ".ai", font=font, fill=ai_color)
144149

145-
# Draw spec_id with shadow (left side)
150+
# Draw spec_id (left side)
146151
if spec_id:
147-
draw.text((spec_x + shadow_offset, y + shadow_offset), spec_id, font=font, fill=shadow_color)
148152
draw.text((spec_x, y), spec_id, font=font, fill=py_color)
149153

150-
result = Image.alpha_composite(img, overlay)
151-
result.convert("RGB").save(output_path, optimize=True)
154+
result.save(output_path, optimize=True)
152155

153156

154157
def optimize_png(input_path: str | Path, output_path: str | Path | None = None, quality: int = 80) -> int:

tests/unit/core/test_images.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,17 @@ class TestAddWatermark:
6464
"""Tests for add_watermark function."""
6565

6666
def test_adds_watermark_to_image(self, sample_image: Path, tmp_path: Path) -> None:
67-
"""Watermark should be added to the image."""
67+
"""Watermark should be added to the image as a footer."""
6868
output_path = tmp_path / "watermarked.png"
6969
add_watermark(sample_image, output_path)
7070

7171
assert output_path.exists()
7272

73-
# Verify the image was created and has same dimensions
73+
# Verify the image was created with footer (taller than original)
7474
result_img = Image.open(output_path)
7575
original_img = Image.open(sample_image)
76-
assert result_img.size == original_img.size
76+
assert result_img.width == original_img.width
77+
assert result_img.height > original_img.height # Footer adds height
7778

7879
def test_with_spec_id_basic(self, sample_image: Path, tmp_path: Path) -> None:
7980
"""Function should accept spec_id for left watermark."""
@@ -105,7 +106,9 @@ def test_creates_watermarked_image_and_thumbnail(self, sample_image: Path, tmp_p
105106
assert thumb_path.exists()
106107
assert result["output"] == str(output_path)
107108
assert result["thumbnail"] == str(thumb_path)
108-
assert result["thumb_size"] == (600, 450)
109+
# Thumbnail width should be 600, height varies due to footer
110+
assert result["thumb_size"][0] == 600
111+
assert result["thumb_size"][1] > 450 # Original 450 + footer
109112

110113
def test_without_thumbnail(self, sample_image: Path, tmp_path: Path) -> None:
111114
"""Should work without creating a thumbnail."""

0 commit comments

Comments
 (0)