1use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
19use image::{DynamicImage, Rgba, RgbaImage};
20
21pub const CORNERS: [&str; 4] = ["bottom-right", "bottom-left", "top-right", "top-left"];
22
23const FONT_CANDIDATES: [&str; 10] = [
27 "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
28 "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
29 "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
30 "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
31 "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf",
32 "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
33 "/System/Library/Fonts/Helvetica.ttc",
34 "C:\\Windows\\Fonts\\arialbd.ttf",
35 "C:\\Windows\\Fonts\\segoeuib.ttf",
36 "C:\\Windows\\Fonts\\arial.ttf",
37];
38
39pub struct Options<'a> {
40 pub text: &'a str,
41 pub subtext: Option<&'a str>,
42 pub logo: Option<&'a Path>,
43 pub corner: &'a str,
44 pub scale: f32,
45 pub opacity: f32,
46 pub font: Option<&'a Path>,
47 pub quality: u8,
48}
49
50impl Default for Options<'_> {
51 fn default() -> Self {
52 Options {
53 text: "",
54 subtext: None,
55 logo: None,
56 corner: "bottom-right",
57 scale: 0.05,
58 opacity: 0.85,
59 font: None,
60 quality: 94,
61 }
62 }
63}
64
65fn load_font(explicit: Option<&Path>) -> Result<FontVec, String> {
66 if let Some(path) = explicit {
67 let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
68 return FontVec::try_from_vec(bytes)
69 .map_err(|_| format!("{}: not a font this tool can read", path.display()));
70 }
71 for candidate in FONT_CANDIDATES {
72 if let Ok(bytes) = std::fs::read(candidate) {
73 if let Ok(font) = FontVec::try_from_vec(bytes) {
74 return Ok(font);
75 }
76 }
77 }
78 Err("no system font found for the stamp text.\n \
79 Point at one yourself: --font /path/to/font.ttf\n \
80 Debian/Ubuntu: sudo apt install fonts-dejavu-core"
81 .into())
82}
83
84fn text_width(font: &FontVec, scale: PxScale, text: &str) -> f32 {
85 let scaled = font.as_scaled(scale);
86 let mut width = 0.0;
87 let mut previous: Option<char> = None;
88 for c in text.chars() {
89 let id = font.glyph_id(c);
90 width += scaled.h_advance(id);
91 if let Some(prev) = previous {
92 width += scaled.kern(font.glyph_id(prev), id);
93 }
94 previous = Some(c);
95 }
96 width
97}
98
99fn draw_text(
100 canvas: &mut RgbaImage,
101 font: &FontVec,
102 scale: PxScale,
103 x: f32,
104 y: f32,
105 text: &str,
106 alpha: u8,
107) {
108 let scaled = font.as_scaled(scale);
109 let mut caret = x;
110 let baseline = y + scaled.ascent();
111 let mut previous: Option<char> = None;
112 for c in text.chars() {
113 let id = font.glyph_id(c);
114 if let Some(prev) = previous {
115 caret += scaled.kern(font.glyph_id(prev), id);
116 }
117 let glyph = id.with_scale_and_position(scale, ab_glyph::point(caret, baseline));
118 if let Some(outline) = font.outline_glyph(glyph) {
119 let bounds = outline.px_bounds();
120 outline.draw(|gx, gy, coverage| {
121 let px = bounds.min.x as i32 + gx as i32;
122 let py = bounds.min.y as i32 + gy as i32;
123 if px < 0 || py < 0 {
124 return;
125 }
126 let (px, py) = (px as u32, py as u32);
127 if px >= canvas.width() || py >= canvas.height() {
128 return;
129 }
130 let a = (coverage * alpha as f32) as u8;
131 if a == 0 {
132 return;
133 }
134 blend(canvas, px, py, Rgba([255, 255, 255, a]));
135 });
136 }
137 caret += scaled.h_advance(id);
138 previous = Some(c);
139 }
140}
141
142fn blend(canvas: &mut RgbaImage, x: u32, y: u32, top: Rgba<u8>) {
143 let under = *canvas.get_pixel(x, y);
144 let ta = top[3] as f32 / 255.0;
145 let ua = under[3] as f32 / 255.0;
146 let out_a = ta + ua * (1.0 - ta);
147 if out_a <= 0.0 {
148 canvas.put_pixel(x, y, Rgba([0, 0, 0, 0]));
149 return;
150 }
151 let mut out = [0u8; 4];
152 for i in 0..3 {
153 let c = (top[i] as f32 * ta + under[i] as f32 * ua * (1.0 - ta)) / out_a;
154 out[i] = c.round().clamp(0.0, 255.0) as u8;
155 }
156 out[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
157 canvas.put_pixel(x, y, Rgba(out));
158}
159
160fn rounded_rect(canvas: &mut RgbaImage, radius: i32, fill: Rgba<u8>) {
161 let (w, h) = (canvas.width() as i32, canvas.height() as i32);
162 for y in 0..h {
163 for x in 0..w {
164 let cx = if x < radius {
166 radius
167 } else if x >= w - radius {
168 w - 1 - radius
169 } else {
170 x
171 };
172 let cy = if y < radius {
173 radius
174 } else if y >= h - radius {
175 h - 1 - radius
176 } else {
177 y
178 };
179 let (dx, dy) = ((x - cx) as f32, (y - cy) as f32);
180 if dx * dx + dy * dy <= (radius as f32) * (radius as f32) {
181 canvas.put_pixel(x as u32, y as u32, fill);
182 }
183 }
184 }
185}
186
187pub fn stamp(src: &Path, dst: &Path, options: &Options) -> Result<PathBuf, String> {
189 if !CORNERS.contains(&options.corner) {
190 return Err(format!("corner must be one of {}", CORNERS.join(", ")));
191 }
192 if !(options.scale > 0.0 && options.scale <= 1.0) {
193 return Err("scale must be greater than 0 and at most 1".into());
194 }
195 if !(options.opacity > 0.0 && options.opacity <= 1.0) {
196 return Err("opacity must be greater than 0 and at most 1".into());
197 }
198 if !(1..=100).contains(&options.quality) {
199 return Err("quality must be between 1 and 100".into());
200 }
201 let ext = dst
202 .extension()
203 .and_then(|e| e.to_str())
204 .unwrap_or("")
205 .to_ascii_lowercase();
206 if !matches!(ext.as_str(), "jpg" | "jpeg" | "png") {
207 return Err("visible stamping supports JPEG and PNG only".into());
208 }
209 if options.text.is_empty() && options.logo.is_none() {
210 return Err("a visible stamp needs text or a logo".into());
211 }
212 if let Some(logo) = options.logo {
213 if !logo.is_file() {
214 return Err(format!("logo not found: {}", logo.display()));
215 }
216 }
217
218 let source = image::open(src).map_err(|e| format!("{}: {e}", src.display()))?;
219 let source_has_alpha = source.color().has_alpha();
220 let mut canvas = source.to_rgba8();
221 let (w, h) = (canvas.width(), canvas.height());
222
223 let unit = (w.min(h) as f32 * options.scale).max(20.0) as u32;
224 let pad = ((unit as f32 * 0.30) as u32).max(6);
225 let text_px = unit as f32 * 0.50;
226 let sub_px = unit as f32 * 0.30;
227 let font = load_font(options.font)?;
228 let main_scale = PxScale::from(text_px);
229 let sub_scale = PxScale::from(sub_px);
230
231 let logo_image = match options.logo {
232 Some(path) => {
233 let logo = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
234 let ratio = unit as f32 / logo.height().max(1) as f32;
235 let width = ((logo.width() as f32 * ratio) as u32).max(1);
236 Some(
237 logo.resize_exact(width, unit, image::imageops::FilterType::Lanczos3)
238 .to_rgba8(),
239 )
240 }
241 None => None,
242 };
243
244 let tw = text_width(&font, main_scale, options.text);
245 let sw = options
246 .subtext
247 .map_or(0.0, |s| text_width(&font, sub_scale, s));
248 let text_w = tw.max(sw) as u32;
249 let gap = if logo_image.is_some() {
250 (unit as f32 * 0.30) as u32
251 } else {
252 0
253 };
254 let lw = logo_image.as_ref().map_or(0, |l| l.width());
255
256 let plate_w = pad * 2 + lw + gap + text_w;
257 let plate_h = pad * 2 + unit;
258 let mut plate = RgbaImage::from_pixel(plate_w.max(1), plate_h.max(1), Rgba([0, 0, 0, 0]));
259 let radius = (plate_h / 4).min(plate_w / 4) as i32;
263 rounded_rect(&mut plate, radius, Rgba([0, 0, 0, (255.0 * 0.42) as u8]));
264
265 if let Some(logo) = &logo_image {
266 for (lx, ly, px) in logo.enumerate_pixels() {
267 let (x, y) = (pad + lx, pad + ly);
268 if x < plate.width() && y < plate.height() {
269 blend(&mut plate, x, y, *px);
270 }
271 }
272 }
273 let tx = (pad + lw + gap) as f32;
274 match options.subtext {
275 Some(sub) => {
276 draw_text(
277 &mut plate,
278 &font,
279 main_scale,
280 tx,
281 pad as f32 + unit as f32 * 0.06,
282 options.text,
283 255,
284 );
285 draw_text(
286 &mut plate,
287 &font,
288 sub_scale,
289 tx,
290 pad as f32 + unit as f32 * 0.56,
291 sub,
292 190,
293 );
294 }
295 None => draw_text(
296 &mut plate,
297 &font,
298 main_scale,
299 tx,
300 pad as f32 + unit as f32 * 0.26,
301 options.text,
302 255,
303 ),
304 }
305
306 if options.opacity < 1.0 {
307 for px in plate.pixels_mut() {
308 px[3] = (px[3] as f32 * options.opacity) as u8;
309 }
310 }
311
312 let inset = ((w.min(h) as f32 * 0.022) as u32).max(8).min(w.min(h) / 2);
318 let available_w = w.saturating_sub(2 * inset).max(1);
319 let available_h = h.saturating_sub(2 * inset).max(1);
320 let fit = 1.0f32
321 .min(available_w as f32 / plate.width() as f32)
322 .min(available_h as f32 / plate.height() as f32);
323 let plate = if fit < 1.0 {
324 DynamicImage::ImageRgba8(plate)
325 .resize_exact(
326 ((plate_w as f32 * fit) as u32).max(1),
327 ((plate_h as f32 * fit) as u32).max(1),
328 image::imageops::FilterType::Lanczos3,
329 )
330 .to_rgba8()
331 } else {
332 plate
333 };
334
335 let x = if options.corner.contains("right") {
336 w.saturating_sub(plate.width() + inset)
337 } else {
338 inset
339 };
340 let y = if options.corner.contains("bottom") {
341 h.saturating_sub(plate.height() + inset)
342 } else {
343 inset
344 };
345 for (px, py, pixel) in plate.enumerate_pixels() {
346 let (cx, cy) = (x + px, y + py);
347 if cx < w && cy < h {
348 blend(&mut canvas, cx, cy, *pixel);
349 }
350 }
351
352 if ext == "png" {
353 if source_has_alpha {
354 canvas
355 .save(dst)
356 .map_err(|e| format!("{}: {e}", dst.display()))?;
357 } else {
358 DynamicImage::ImageRgba8(canvas)
359 .to_rgb8()
360 .save(dst)
361 .map_err(|e| format!("{}: {e}", dst.display()))?;
362 }
363 } else {
364 let rgb = DynamicImage::ImageRgba8(canvas).to_rgb8();
365 let file = std::fs::File::create(dst).map_err(|e| format!("{}: {e}", dst.display()))?;
366 let mut writer = std::io::BufWriter::new(file);
367 let mut encoder =
368 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut writer, options.quality);
369 encoder
370 .encode(
371 rgb.as_raw(),
372 rgb.width(),
373 rgb.height(),
374 image::ExtendedColorType::Rgb8,
375 )
376 .map_err(|e| format!("{}: {e}", dst.display()))?;
377 }
378
379 carry_metadata(src, dst);
380 Ok(dst.to_path_buf())
381}
382
383fn carry_metadata(src: &Path, dst: &Path) {
387 if crate::exif::which("exiftool").is_none() {
388 eprintln!(
389 " exiftool is not installed, so the stamped copy carries no EXIF, ICC or GPS from \
390 the original."
391 );
392 return;
393 }
394 let out = Command::new("exiftool")
395 .args(["-TagsFromFile"])
396 .arg(src)
397 .args(["-all:all", "-overwrite_original", "-q", "-P"])
398 .arg(dst)
399 .output();
400 match out {
401 Ok(out) if out.status.success() => {}
402 Ok(out) => eprintln!(
403 " the stamp was written but its metadata did not copy across: {}",
404 String::from_utf8_lossy(&out.stderr).trim()
405 ),
406 Err(e) => eprintln!(" the stamp was written but exiftool could not run: {e}"),
407 }
408}