diff --git a/CHANGELOG.md b/CHANGELOG.md index e8004b8a..b1c9bae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +0.7.1 (2024-04-28) +================== + + - [#167] Offset the line to fit the glyph with negative side bearing + +[#167]: https://github.com/embedded-graphics/embedded-text/pull/167 + 0.7.0 (2023-11-03) ================== diff --git a/src/rendering/line.rs b/src/rendering/line.rs index b05a7f06..380aa264 100644 --- a/src/rendering/line.rs +++ b/src/rendering/line.rs @@ -8,7 +8,7 @@ use crate::{ line_iter::{ElementHandler, LineElementParser, LineEndType}, }, style::TextBoxStyle, - utils::str_width, + utils::{str_width, str_width_and_left_offset}, }; use embedded_graphics::{ draw_target::DrawTarget, @@ -105,6 +105,10 @@ where str_width(self.text_renderer, st) } + fn measure_width_and_left_offset(&self, st: &str) -> (u32, u32) { + str_width_and_left_offset(self.text_renderer, st) + } + fn whitespace(&mut self, st: &str, _space_count: u32, width: u32) -> Result<(), Self::Error> { if width > 0 { self.text_renderer diff --git a/src/rendering/line_iter.rs b/src/rendering/line_iter.rs index a13e1ce4..451c8ffc 100644 --- a/src/rendering/line_iter.rs +++ b/src/rendering/line_iter.rs @@ -46,6 +46,9 @@ pub trait ElementHandler { /// Returns the width of the given string in pixels. fn measure(&self, st: &str) -> u32; + /// Returns the left offset in pixels. + fn measure_width_and_left_offset(&self, _st: &str) -> (u32, u32); + /// A whitespace block with the given width. fn whitespace(&mut self, _st: &str, _space_count: u32, _width: u32) -> Result<(), Self::Error> { Ok(()) @@ -136,13 +139,12 @@ where handler: &E, w: &'a str, ) -> (&'a str, &'a str) { - let mut width = 0; for (idx, c) in w.char_indices() { - let char_width = handler.measure(unsafe { + let width = handler.measure(unsafe { // SAFETY: we are working on character boundaries - w.get_unchecked(idx..idx + c.len_utf8()) + w.get_unchecked(0..idx + c.len_utf8()) }); - if !self.cursor.fits_in_line(width + char_width) { + if !self.cursor.fits_in_line(width) { unsafe { if w.is_char_boundary(idx) { return w.split_at(idx); @@ -151,7 +153,6 @@ where } } } - width += char_width; } (w, "") @@ -321,7 +322,18 @@ where } Token::Word(w) => { - let width = handler.measure(w); + let width = if self.empty { + // If this is the first word on the line, offset the line by + // the word's left negative boundary to make sure it is not clipped. + let (width, offset) = handler.measure_width_and_left_offset(w); + if offset > 0 && self.move_cursor_forward(offset).is_ok() { + handler.whitespace("", 0, offset).ok(); + }; + width + } else { + handler.measure(w) + }; + let (word, remainder) = if self.move_cursor_forward(width).is_ok() { // We can move the cursor here since `process_word()` // doesn't depend on it. @@ -452,6 +464,7 @@ where #[cfg(test)] pub(crate) mod test { + use core::fmt::Debug; use std::convert::Infallible; use super::*; @@ -459,7 +472,10 @@ pub(crate) mod test { plugin::{NoPlugin, PluginMarker as Plugin, PluginWrapper}, rendering::{cursor::Cursor, space_config::SpaceConfig}, style::TabSize, - utils::{str_width, test::size_for}, + utils::{ + str_width, str_width_and_left_offset, + test::{size_for, TestFont}, + }, }; use embedded_graphics::{ geometry::{Point, Size}, @@ -511,6 +527,10 @@ pub(crate) mod test { str_width(&self.style, st) } + fn measure_width_and_left_offset(&self, st: &str) -> (u32, u32) { + str_width_and_left_offset(&self.style, st) + } + fn whitespace(&mut self, _string: &str, count: u32, width: u32) -> Result<(), Self::Error> { self.elements .push(RenderElement::Space(count, (width > 0) as bool)); @@ -781,4 +801,47 @@ pub(crate) mod test { assert_line_elements(&mut parser, 2, &[RenderElement::string("So", 12)], &mw); } + + #[test] + fn negative_left_side_bearing_of_the_first_glyph_sets_left_offset() { + let text = "just a jet"; + let mut parser = Parser::parse(text); + let plugin = PluginWrapper::new(NoPlugin::::new()); + let style = TestFont::new(BinaryColor::On.into(), BinaryColor::Off.into()); + + let size = style + .measure_string(text, Point::zero(), embedded_graphics::text::Baseline::Top) + .bounding_box + .size; + let config = SpaceConfig::new(str_width(&style, " "), None); + let cursor = Cursor::new( + Rectangle::new(Point::zero(), size), + style.line_height(), + LineHeight::Percent(100), + TabSize::Spaces(4).into_pixels(&style), + ) + .line(); + + let text_box_style = TextBoxStyle::default(); + + let mut handler = TestElementHandler::new(style); + let mut line1 = + LineElementParser::new(&mut parser, &plugin, cursor, config, &text_box_style); + + line1.process(&mut handler).unwrap(); + + // 'j' occupies 1 pixel because of the negative left side bearing -2 (its width is 3 pixels) + // each additional glyph in a word occupies 5 pixels (4 for the glyph and 1 for letter spacing) + assert_eq!( + handler.elements, + &[ + RenderElement::Space(0, true), // 2 pixels, to compensate for the negative left side bearing + RenderElement::string("just", 16), // 1 for j + 5 for each additional glyph + RenderElement::Space(1, true), + RenderElement::string("a", 4), + RenderElement::Space(1, true), + RenderElement::string("jet", 11), // 1 for j + 5 for each additional glyph + ] + ); + } } diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index b59df776..16d10d8c 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -185,7 +185,7 @@ pub mod test { use crate::{ alignment::HorizontalAlignment, style::{HeightMode, TextBoxStyle, TextBoxStyleBuilder, VerticalOverdraw}, - utils::test::size_for, + utils::test::{size_for, TestFont}, TextBox, }; @@ -414,4 +414,142 @@ pub mod test { "............ ", ]); } + + #[test] + fn rendering_justified_text_with_negative_left_side_bearing() { + let mut display: MockDisplay = MockDisplay::new(); + display.set_allow_overdraw(true); + + let text = "j000 0 j00 00j00 0"; + let character_style = TestFont::new(BinaryColor::On, BinaryColor::Off); + let size = Size::new(50, 0); + + TextBox::with_textbox_style( + text, + Rectangle::new(Point::zero(), size), + character_style, + TextBoxStyleBuilder::new() + .alignment(HorizontalAlignment::Justified) + .height_mode(HeightMode::FitToText) + .build(), + ) + .draw(&mut display) + .unwrap(); + + display.assert_pattern(&[ + "..#.####.####.####.........####........#.####.####", + "....#..#.#..#.#..#.........#..#..........#..#.#..#", + "..#.#..#.#..#.#..#.........#..#........#.#..#.#..#", + "..#.#..#.#..#.#..#.........#..#........#.#..#.#..#", + "..#.#..#.#..#.#..#.........#..#........#.#..#.#..#", + "..#.#..#.#..#.#..#.........#..#........#.#..#.#..#", + "..#.####.####.####.........####........#.####.####", + "..#....................................#..........", + "..#....................................#..........", + "##...................................##...........", + "####.####.#.####.####....#### ", + "#..#.#..#...#..#.#..#....#..# ", + "#..#.#..#.#.#..#.#..#....#..# ", + "#..#.#..#.#.#..#.#..#....#..# ", + "#..#.#..#.#.#..#.#..#....#..# ", + "#..#.#..#.#.#..#.#..#....#..# ", + "####.####.#.####.####....#### ", + "..........#.................. ", + "..........#.................. ", + "........##................... ", + ]); + } + + #[test] + fn correctly_breaks_long_words_for_monospace_fonts() { + let mut display: MockDisplay = MockDisplay::new(); + display.set_allow_overdraw(true); + + let text = "000000000000000000"; + let character_style = MonoTextStyleBuilder::new() + .font(&FONT_6X10) + .text_color(BinaryColor::On) + .background_color(BinaryColor::Off) + .build(); + + TextBox::with_textbox_style( + text, + Rectangle::new(Point::zero(), size_for(&FONT_6X10, 10, 2)), + character_style, + TextBoxStyleBuilder::new() + .alignment(HorizontalAlignment::Left) + .height_mode(HeightMode::FitToText) + .build(), + ) + .draw(&mut display) + .unwrap(); + + display.assert_pattern(&[ + "............................................................", + "..#.....#.....#.....#.....#.....#.....#.....#.....#.....#...", + ".#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#..", + "#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.", + "#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.", + "#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.", + ".#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#..", + "..#.....#.....#.....#.....#.....#.....#.....#.....#.....#...", + "............................................................", + "............................................................", + "................................................ ", + "..#.....#.....#.....#.....#.....#.....#.....#... ", + ".#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#.. ", + "#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#. ", + "#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#. ", + "#...#.#...#.#...#.#...#.#...#.#...#.#...#.#...#. ", + ".#.#...#.#...#.#...#.#...#.#...#.#...#.#...#.#.. ", + "..#.....#.....#.....#.....#.....#.....#.....#... ", + "................................................ ", + "................................................ ", + ]); + } + + #[test] + fn correctly_breaks_long_words_for_fonts_with_letter_spacing() { + let mut display: MockDisplay = MockDisplay::new(); + display.set_allow_overdraw(true); + + let text = "000000000000000000"; + let character_style = TestFont::new(BinaryColor::On, BinaryColor::Off); + let size = Size::new(49, 0); + + TextBox::with_textbox_style( + text, + Rectangle::new(Point::zero(), size), + character_style, + TextBoxStyleBuilder::new() + .alignment(HorizontalAlignment::Left) + .height_mode(HeightMode::FitToText) + .build(), + ) + .draw(&mut display) + .unwrap(); + + display.assert_pattern(&[ + "####.####.####.####.####.####.####.####.####.####", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..#", + "####.####.####.####.####.####.####.####.####.####", + ".................................................", + ".................................................", + ".................................................", + "####.####.####.####.####.####.####.#### ", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..# ", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..# ", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..# ", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..# ", + "#..#.#..#.#..#.#..#.#..#.#..#.#..#.#..# ", + "####.####.####.####.####.####.####.#### ", + "....................................... ", + "....................................... ", + "....................................... ", + ]); + } } diff --git a/src/style/mod.rs b/src/style/mod.rs index 1ff35b0f..1bc05e3a 100644 --- a/src/style/mod.rs +++ b/src/style/mod.rs @@ -192,7 +192,7 @@ use crate::{ line_iter::{ElementHandler, LineElementParser, LineEndType}, space_config::SpaceConfig, }, - utils::str_width, + utils::{str_width, str_width_and_left_offset}, }; use embedded_graphics::text::{renderer::TextRenderer, LineHeight}; @@ -385,6 +385,10 @@ impl<'a, S: TextRenderer> ElementHandler for MeasureLineElementHandler<'a, S> { str_width(self.style, st) } + fn measure_width_and_left_offset(&self, st: &str) -> (u32, u32) { + str_width_and_left_offset(self.style, st) + } + fn whitespace(&mut self, _st: &str, count: u32, width: u32) -> Result<(), Self::Error> { self.cursor += width; self.pos = self.pos.max(self.cursor); diff --git a/src/utils.rs b/src/utils.rs index 16ca47f2..9d5f8a50 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,12 +13,35 @@ pub fn str_width(renderer: &impl TextRenderer, s: &str) -> u32 { .x as u32 } +/// Measure the width of a piece of string and the offset between +/// the left edge of the bounding box and the left edge of the text. +/// +/// The offset is particularly useful when the first glyph on +/// the line has a negative left side bearing. +pub fn str_width_and_left_offset(renderer: &impl TextRenderer, s: &str) -> (u32, u32) { + let tm = renderer.measure_string(s, Point::zero(), Baseline::Top); + ( + tm.next_position.x as u32, + tm.bounding_box.top_left.x.min(0).abs() as u32, + ) +} + #[cfg(test)] pub mod test { + use az::SaturatingAs; use embedded_graphics::{ + draw_target::DrawTarget, + geometry::Point, + mock_display::MockDisplay, mono_font::{ascii::FONT_6X9, MonoFont, MonoTextStyle}, - pixelcolor::BinaryColor, + pixelcolor::{BinaryColor, PixelColor}, prelude::Size, + primitives::{Line, PrimitiveStyle, Rectangle, StyledDrawable}, + text::{ + renderer::{CharacterStyle, TextMetrics, TextRenderer}, + Baseline, + }, + Drawable, Pixel, }; use super::str_width; @@ -27,6 +50,207 @@ pub mod test { font.character_size.x_axis() * chars + font.character_size.y_axis() * lines } + /// A font where each glyph is 4x10 pixels, except for the + /// glyph 'j' that is 3x10 with a negative left side bearing of 2 pixels + #[derive(Copy, Clone)] + pub struct TestFont { + text_color: C, + background_color: C, + letter_spacing: u32, + line_height: u32, + } + + enum LineElement { + Char(char), + Spacing, + Done, + } + + fn left_side_bearing(c: char) -> i32 { + match c { + 'j' => -2, + _ => 0, + } + } + + fn char_width(c: char) -> u32 { + match c { + 'j' => 3, + _ => 4, + } + } + + impl TestFont { + pub fn new(text_color: C, background_color: C) -> Self { + Self { + text_color, + background_color, + letter_spacing: 1, + line_height: 10, + } + } + + fn line_elements<'t>( + &self, + mut position: Point, + text: &'t str, + ) -> impl Iterator + 't +where { + let mut chars = text.chars(); + let mut next_char = chars.next(); + let mut spacing = next_char.map(left_side_bearing); + let letter_spacing = self.letter_spacing as i32; + + core::iter::from_fn(move || { + if let Some(offset) = spacing { + let p = position; + position.x += offset; + spacing = None; + Some((p, LineElement::Spacing)) + } else if let Some(c) = next_char { + let p = position; + position.x += char_width(c) as i32; + next_char = chars.next(); + spacing = next_char.map(|c| letter_spacing + left_side_bearing(c)); + Some((p, LineElement::Char(c))) + } else { + Some((position, LineElement::Done)) + } + }) + } + } + + impl CharacterStyle for TestFont + where + C: PixelColor, + { + type Color = C; + } + + impl TextRenderer for TestFont + where + C: PixelColor, + { + type Color = C; + + fn draw_string( + &self, + text: &str, + position: Point, + _baseline: Baseline, + target: &mut D, + ) -> Result + where + D: DrawTarget, + { + let style = PrimitiveStyle::with_stroke(self.text_color, 1); + let bg_style = PrimitiveStyle::with_fill(self.background_color); + for (p, element) in self.line_elements(position, text) { + match element { + LineElement::Char(c) => { + // fill the background rectangle, + // taking into account the left side bearing + Rectangle::new( + p + Point::new(-left_side_bearing(c), 0), + Size::new( + (char_width(c) as i32 + left_side_bearing(c)) as u32, + self.line_height, + ), + ) + .draw_styled(&bg_style, target)?; + match c { + 'j' => { + Pixel(p + Point::new(2, 0), self.text_color).draw(target)?; + Line::new(p + Point::new(2, 2), p + Point::new(2, 8)) + .draw_styled(&style, target)?; + Line::new(p + Point::new(0, 9), p + Point::new(1, 9)) + .draw_styled(&style, target)?; + } + _ => Rectangle::new(p, Size::new(4, 7)).draw_styled(&style, target)?, + } + } + LineElement::Spacing => { + // fill a rectangle for letter spacing + Rectangle::new(p, Size::new(self.letter_spacing, self.line_height)) + .draw_styled(&bg_style, target)? + } + LineElement::Done => return Ok(p), + } + } + Ok(position) + } + + fn draw_whitespace( + &self, + width: u32, + position: Point, + _baseline: Baseline, + target: &mut D, + ) -> Result + where + D: DrawTarget, + { + let bg_style = PrimitiveStyle::with_fill(self.background_color); + Rectangle::new(position, Size::new(width, self.line_height)) + .draw_styled(&bg_style, target)?; + return Ok(Point::new(position.x + width as i32, position.y)); + } + + fn measure_string(&self, text: &str, position: Point, _baseline: Baseline) -> TextMetrics { + // the bounding box position can be to the left of the position, + // when the first character has a negative left side bearing + // e.g. letter 'j' + let mut bb_left = position.x; + let mut bb_right = position.x; + for (p, element) in self.line_elements(position, text) { + bb_left = bb_left.min(p.x); + bb_right = bb_right.max(p.x); + if let LineElement::Done = element { + break; + } + } + let bb_width = bb_right - position.x; + let bb_size = Size::new(bb_width.saturating_as(), self.line_height()); + TextMetrics { + bounding_box: Rectangle::new(Point::new(bb_left, position.y), bb_size), + next_position: position + bb_size.x_axis(), + } + } + + fn line_height(&self) -> u32 { + self.line_height + } + } + + #[test] + fn glyph_j_has_negative_left_side_bearing() { + let font = TestFont::new(BinaryColor::On, BinaryColor::Off); + let mut display: MockDisplay = MockDisplay::new(); + display.set_allow_overdraw(true); + + font.draw_string("j0j", Point::new(2, 0), Baseline::Top, &mut display) + .unwrap(); + + // The background of the first 'j' is only drawn behind the stem, + // because the tail of the 'j' is pulled to the left by the negative + // left side bearing of 2 pixels. + assert_eq!( + display, + MockDisplay::from_pattern(&[ + " #.####.#", + " ..#..#..", + " #.#..#.#", + " #.#..#.#", + " #.#..#.#", + " #.#..#.#", + " #.####.#", + " #......#", + " #......#", + "##.....##.", + ]) + ); + } + #[test] fn width_of_nbsp_is_single_space() { let renderer = MonoTextStyle::new(&FONT_6X9, BinaryColor::On);