diff --git a/src/drawing/mod.rs b/src/drawing/mod.rs index a56aa0fa..386b00f3 100644 --- a/src/drawing/mod.rs +++ b/src/drawing/mod.rs @@ -33,7 +33,7 @@ pub use self::rect::{ }; mod text; -pub use self::text::{draw_text, draw_text_mut, text_size}; +pub use self::text::{draw_text, draw_text_mut, text_size, text_size_info, TextSizeInfo}; mod fill; pub use self::fill::{flood_fill, flood_fill_mut}; diff --git a/src/drawing/text.rs b/src/drawing/text.rs index 13319afb..ce9067e3 100644 --- a/src/drawing/text.rs +++ b/src/drawing/text.rs @@ -4,20 +4,35 @@ use std::f32; use crate::definitions::{Clamp, Image}; use crate::drawing::Canvas; use crate::pixelops::weighted_sum; - -use ab_glyph::{point, Font, GlyphId, OutlinedGlyph, PxScale, Rect, ScaleFont}; +use crate::rect::Rect; + +use ab_glyph::{point, Font, GlyphId, OutlinedGlyph, PxScale, ScaleFont}; + +/// The size a text will take up when rendered with the given font and scale. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextSizeInfo { + /// The bounding box of all the pixels of the text. Might be [`None`] if the text is empty or only contains + /// whitespace. + /// + /// As some fonts have glyphs that extend above or below the line, this is not the same as the `outline_bounds`. + /// Coordinates might be negative or exceed the [`outline_bounds`]. + pub px_bounds: Option, + + /// The logical bounding box of the text. Might be [`None`] if the text is empty. + /// + /// Can be used to position other text relative to this text. + pub outline_bounds: Option, +} fn layout_glyphs( scale: impl Into + Copy, font: &impl Font, text: &str, - mut f: impl FnMut(OutlinedGlyph, Rect), -) -> (u32, u32) { - if text.is_empty() { - return (0, 0); - } + mut f: impl FnMut(OutlinedGlyph, ab_glyph::Rect), +) -> TextSizeInfo { let font = font.as_scaled(scale); + let mut px_bounds: Option = None; let mut w = 0.0; let mut prev: Option = None; @@ -31,20 +46,50 @@ fn layout_glyphs( } prev = Some(glyph_id); let bb = g.px_bounds(); + + if let Some(current) = px_bounds { + px_bounds = Some(current.union(bb.into())) + } else { + px_bounds = Some(bb.into()); + } + f(g, bb); } } - let w = w.ceil(); - let h = font.height().ceil(); - assert!(w >= 0.0); - assert!(h >= 0.0); - (1 + w as u32, h as u32) + let outline_width = w.ceil() as u32; + let outline_height = font.height().ceil() as u32; + let outline_bounds = if outline_height > 0 && outline_width > 0 { + Some(Rect::at(0, 0).of_size(outline_width, outline_height)) + } else { + None + }; + + TextSizeInfo { + px_bounds, + outline_bounds, + } +} + +/// Get the sizing info of the given text, rendered with the given font and scale. +pub fn text_size_info( + scale: impl Into + Copy, + font: &impl Font, + text: &str, +) -> TextSizeInfo { + layout_glyphs(scale, font, text, |_, _| {}) } /// Get the width and height of the given text, rendered with the given font and scale. pub fn text_size(scale: impl Into + Copy, font: &impl Font, text: &str) -> (u32, u32) { - layout_glyphs(scale, font, text, |_, _| {}) + let info = text_size_info(scale, font, text); + + let (width, height) = info + .outline_bounds + .map(|b| (b.width(), b.height())) + .unwrap_or_else(|| (0, 0)); + + (height, width) } /// Draws colored text on an image. @@ -81,14 +126,15 @@ pub fn draw_text_mut( scale: impl Into + Copy, font: &impl Font, text: &str, -) where +) -> TextSizeInfo +where C: Canvas, ::Subpixel: Into + Clamp, { let image_width = canvas.width() as i32; let image_height = canvas.height() as i32; - layout_glyphs(scale, font, text, |g, bb| { + let info = layout_glyphs(scale, font, text, |g, bb| { let x_shift = x + bb.min.x.round() as i32; let y_shift = y + bb.min.y.round() as i32; g.draw(|gx, gy, gv| { @@ -105,16 +151,18 @@ pub fn draw_text_mut( } }) }); + + TextSizeInfo { + px_bounds: info.px_bounds.map(|b| b.translate(x, y)), + outline_bounds: info.outline_bounds.map(|b| b.translate(x, y)), + } } #[cfg(not(miri))] #[cfg(test)] mod proptests { use super::*; - use crate::{ - proptest_utils::arbitrary_image_with, - rect::{Rect, Region}, - }; + use crate::{proptest_utils::arbitrary_image_with, rect::Region}; use ab_glyph::FontRef; use image::Luma; use proptest::prelude::*; @@ -123,36 +171,37 @@ mod proptests { proptest! { #[test] - fn proptest_text_size( - img in arbitrary_image_with::>(Just(0), 0..=100, 0..=100), + fn proptest_text_size_info( + mut img in arbitrary_image_with::>(Just(0), 0..=100, 0..=100), x in 0..100, y in 0..100, scale in 0.0..100f32, - ref text in "[0-9a-zA-Z]*", + ref text in "[0-9a-zA-Z ]*", ) { let font = FontRef::try_from_slice(FONT_BYTES).unwrap(); let background = Luma([0]); let text_color = Luma([255u8]); - let img = draw_text(&img, text_color, x, y, scale, &font, text); + let draw_info = draw_text_mut(&mut img, text_color, x, y, scale, &font, text); + let size_info = text_size_info(scale, &font, text); + + let expected_draw_info = TextSizeInfo { + px_bounds: size_info.px_bounds.map(|r| r.translate(x, y)), + outline_bounds: size_info.outline_bounds.map(|r| r.translate(x, y)), + }; + assert_eq!(draw_info, expected_draw_info); - let (text_w, text_h) = text_size(scale, &font, text); if text.is_empty() { return Ok(()); } - let first_char = text.chars().next().unwrap(); - let first_x_bearing = - font.as_scaled(scale).h_side_bearing(font.glyph_id(first_char)); - let rect = if first_x_bearing < 0.0 { - let x_shift = first_x_bearing.abs().ceil() as i32; - Rect::at(x - x_shift, y).of_size(text_w + x_shift as u32, text_h) - } else { - Rect::at(x, y).of_size(text_w, text_h) + let Some(px_bounds) = draw_info.px_bounds else { + return Ok(()); }; + for (px, py, &p) in img.enumerate_pixels() { - if !rect.contains(px as i32, py as i32) { - assert_eq!(p, background, "pixel_position: {:?}, rect: {:?}", (px, py), rect); + if !px_bounds.contains(px as i32, py as i32) { + assert_eq!(p, background, "pixel_position: {:?}, rect: {:?}", (px, py), px_bounds); } } } diff --git a/src/morphology.rs b/src/morphology.rs index badb6832..03d3f92b 100644 --- a/src/morphology.rs +++ b/src/morphology.rs @@ -624,7 +624,7 @@ fn mask_reduce u8>( /// 255; /// 255; /// 255 -/// ), +/// ), /// 0, 1 /// ); /// let column_dilated = gray_image!( @@ -701,7 +701,7 @@ pub fn grayscale_dilate(image: &GrayImage, mask: &Mask) -> GrayImage { /// 255; /// 255; /// 255 -/// ), +/// ), /// 0, 1 /// ); /// let column_eroded = gray_image!( diff --git a/src/rect.rs b/src/rect.rs index 4c644036..1971086b 100644 --- a/src/rect.rs +++ b/src/rect.rs @@ -121,6 +121,64 @@ impl Rect { height: (bottom - top) as u32 + 1, }) } + + /// Returns the union of self and other, which is the smallest rectangle that contains both. + /// + /// # Examples + /// ``` + /// use imageproc::rect::Rect; + /// + /// // Unioning a rectangle with itself + /// let r = Rect::at(4, 5).of_size(6, 7); + /// assert_eq!(r.union(r), r); + /// + /// // Unioning overlapping but non-equal rectangles + /// let r = Rect::at(0, 0).of_size(5, 5); + /// let s = Rect::at(1, 4).of_size(10, 12); + /// let i = Rect::at(0, 0).of_size(11, 16); + /// assert_eq!(r.union(s), i); + /// + /// // Unioning disjoint rectangles + /// let r = Rect::at(0, 0).of_size(5, 5); + /// let s = Rect::at(10, 10).of_size(100, 12); + /// let i = Rect::at(0, 0).of_size(110, 22); + /// assert_eq!(r.union(s), i); + /// ``` + pub fn union(&self, other: Rect) -> Rect { + let left = cmp::min(self.left, other.left); + let top = cmp::min(self.top, other.top); + let right = cmp::max(self.right(), other.right()); + let bottom = cmp::max(self.bottom(), other.bottom()); + + Rect { + left, + top, + width: (right - left) as u32 + 1, + height: (bottom - top) as u32 + 1, + } + } + + /// Translate a rectangle by the given amount. + /// + /// # Examples + /// ``` + /// use imageproc::rect::Rect; + /// + /// let r = Rect::at(4, 5).of_size(6, 7); + /// assert_eq!(r.translate(0, 0), r); + /// + /// let r = Rect::at(4, 5).of_size(6, 7); + /// let expected = Rect::at(6, 8).of_size(6, 7); + /// assert_eq!(r.translate(2, 3), expected); + /// ``` + pub fn translate(&self, dx: i32, dy: i32) -> Rect { + Rect { + left: self.left + dx, + top: self.top + dy, + width: self.width, + height: self.height, + } + } } impl Region for Rect { @@ -138,6 +196,13 @@ impl Region for Rect { } } +impl From for Rect { + fn from(value: ab_glyph::Rect) -> Self { + Rect::at(value.min.x.floor() as i32, value.min.y.floor() as i32) + .of_size(value.width().ceil() as u32, value.height().ceil() as u32) + } +} + /// Position of the top left of a rectangle. /// Only used when building a [`Rect`]. #[derive(Copy, Clone, Debug, PartialEq, Eq)] diff --git a/tests/data/truth/text.png b/tests/data/truth/text.png index 03d164a4..372749f1 100644 Binary files a/tests/data/truth/text.png and b/tests/data/truth/text.png differ diff --git a/tests/regression.rs b/tests/regression.rs index f171fc79..458beacb 100644 --- a/tests/regression.rs +++ b/tests/regression.rs @@ -23,11 +23,10 @@ use image::{ use imageproc::contrast::ThresholdType; use imageproc::definitions::Image; -use imageproc::drawing::text_size; use imageproc::filter::bilateral::GaussianEuclideanColorDistance; use imageproc::filter::bilateral_filter; use imageproc::kernel::{self}; -use imageproc::rect::{Rect, Region}; +use imageproc::rect::Region; use imageproc::{ definitions::{Clamp, HasBlack, HasWhite}, edges::canny, @@ -837,11 +836,11 @@ fn test_draw_text() { let text = "Hello world!"; let scale = 30.0; let (x, y) = (50, 100); - imageproc::drawing::draw_text_mut(&mut img, Luma::white(), x, y, scale, &font, text); + let size_info = + imageproc::drawing::draw_text_mut(&mut img, Luma::white(), x, y, scale, &font, text); compare_to_truth_image(&img, "text.png"); - let (text_w, text_h) = text_size(scale, &font, text); - let rect = Rect::at(x, y).of_size(text_w, text_h); + let rect = size_info.px_bounds.expect("Text should have a size"); for (px, py, &p) in img.enumerate_pixels() { if !rect.contains(px as i32, py as i32) { assert_eq!(p, background);