Skip to main content

snitch/
stamp.rs

1//! Composite a visible mark into the pixels.
2//!
3//! THIS RE-ENCODES. It is the only operation here that costs image quality, and the CLI says so.
4//! Quality defaults to 94, which is visually lossless for a photograph at normal viewing sizes.
5//!
6//! A visible mark survives a screenshot because it is part of the pixels, but platforms may crop,
7//! resize or soften it.
8//!
9//! THE RE-ENCODE MUST CARRY THE METADATA ACROSS, and this is not a nicety. The encoder writes a
10//! fresh file, so without this the stamp silently destroys the camera block, the colour profile
11//! and the GPS. That made `credit --stamp --keep-gps` a lie: it kept nothing, because the stamp
12//! had already thrown the EXIF away before the metadata step ran. Found by using the tool rather
13//! than by testing its parts, and the copy-back below is the fix.
14
15use 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
23/// Where a usable font tends to live. The Python shipped Pillow's bundled face; embedding a TTF
24/// here would add most of a megabyte to every binary, so the font is found at runtime and the
25/// error names the fix when it cannot be.
26const 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            // Inside the straight part, or inside one of the four corner discs.
165            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
187/// Draw the mark and write `dst`. Returns the path written.
188pub 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    // The radius has to fit BOTH dimensions. A quarter of the height alone, on a plate narrower
260    // than twice that, makes the left and right corner centres cross over and the rounding comes
261    // out lopsided. A logo one pixel wide is enough to do it.
262    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    // The floor of 8 is for ordinary photographs, where 2.2% would be too tight to read. On an
313    // image smaller than 16 pixels it is larger than the image, and the two corners then disagree:
314    // `bottom-right` saturates to 0 and draws, `top-left` places the plate outside the canvas and
315    // draws nothing while still reporting success. Clamping to half the shorter side makes both
316    // corners behave the same way.
317    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
383/// Copy the source's metadata onto the re-encoded file. Failure is reported, never silent, but it
384/// does not undo the stamp: the user has a stamped image either way and deserves to know if the
385/// camera block did not come with it.
386fn 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}