Search code examples
user-interfacerustfonts

How do I determine the pixel width of a string of text?


I am writing a function that takes a text string, a .ttf font file path, a fontsize int value, and a linewidthinpixels int value.

The objective is to write a <text> tag to an SVG, but if the string would exceed the linewidthinpixels, then the function will write multiple <text> tags (with appropriate dy attributes) such that the final result would be a multiline string.

The problem I'm facing is finding a crate that will parse the TTF file, and then provide me the pixel length of a chunk of text.

I've tried looking at the ttf-parser library, with the following sample code, but to no avail (the output crashes when coming across a space character, and also the width it calculates give huge pixel values which I think is not correct.

I know there are layout libraries like cosmic-text, but I have not been able for the life of me understand how I would go about using them to measure the dimensions of a simple text string, let alone implement layouting and wrapping.

use ttf_parser as ttf;
use anyhow::{anyhow, Context};

// Okay this one tries using the ttf_parser crate
pub fn get_text_width(
    text: &str,
    font_size: f32,
    font_family: &str,
    ttf_path: &str,
) -> Result<f32, anyhow::Error> {
    // okay we need to use the ttf-parser lib to calculate the width of the text
    // and in this will output the width in pixels of the text
    // so we can, in other code, figure out if we need to wrap text into a newline

    // Loads the font file as bytes
    let font_data = std::fs::read(ttf_path).context("Failed to read ttf file")?;

    // Parses the font file
    let mut face = ttf::Face::parse(&font_data, 0).context("Failed to parse ttf file into face")?;

    // https://fonts.google.com/knowledge/glossary/em
    // An em is a unit of measurement, relative to the size of the font; therefore, in a typeface set at a font-size of 16px, one em is 16px.
    let units_per_em = face.units_per_em();

    let scale = font_size / units_per_em as f32;

    let mut delta_x = 0_i16; // this is supposed to be the total horizontal space occupied by the string, in pixels
    let char = text.chars().next().unwrap();
    let glyph_id = face.glyph_index(char).unwrap();

    let horizontal_advance = face.glyph_hor_advance(glyph_id).unwrap();
    let bounding_box = face.glyph_bounding_box(glyph_id).unwrap();

    for char in text.chars() {
        println!("char: {}", char);
        let glyph_id = face.glyph_index(char).unwrap();
        println!("Glyph id: {}", glyph_id.0);
        let bounding_box = face.glyph_bounding_box(glyph_id).context(format!("Failed to get bounding box for glyph: {char}"))?;
        // Something's wrong here, the numbers are large. So this can't be in pixels, right?
        println!("Bounding box: {}", bounding_box.width());
        println!("Horizontal advance: {}", face.glyph_hor_advance(glyph_id).unwrap());
        delta_x += bounding_box.width();
    }

    let to_return = delta_x * scale as i16;
    Ok(to_return as f32)
}

Solution

  • Based on the question, I figured that a fairly complete and flexible solution is wanted, that also covers a lot of edge cases and text layout.

    Simple sample

    Below is a simple example using cosmic-text that mostly matches the signature of the function provided in the question:

    use cosmic_text::{
        Attrs, Buffer, Color, Edit, Editor, Family, FontSystem, Metrics, Shaping, SwashCache,
    };
    
    fn main() {
        let width = get_text_width("Hello World!", 32.0, Some("Comic Neue"));
        println!("{:#?}", width);
    }
    
    /// Gets the width of the given `text` in pixels.
    /// 
    /// Arguments:
    /// 
    /// * `text`: The text that should be measured.
    /// * `font_size`: The font size in pixels.
    /// * `font_family`: The optional font family.
    /// 
    /// Returns:
    /// 
    /// The width of the text in pixels, or [None] if the given `text`
    /// does not have a width.
    pub fn get_text_width(text: &str, font_size: f32, font_family: Option<&str>) -> Option<f32> {
        if text == "" {
            return None;
        }
    
        let black = Color::rgb(0, 0, 0);
    
        let mut font_system = FontSystem::new();
        let mut swash_cache = SwashCache::new();
    
        // The line height does not matter here.
        let metrics = Metrics::new(font_size, font_size);
        let mut editor = Editor::new(Buffer::new_empty(metrics.scale(1f32)));
        let mut editor = editor.borrow_with(&mut font_system);
        editor.with_buffer_mut(|buffer| buffer.set_size(f32::INFINITY, f32::INFINITY));
        editor.with_buffer_mut(|buffer| {
            let mut attrs = Attrs::new();
    
            if let Some(font_family) = font_family {
                attrs = attrs.family(Family::Name(font_family));
            }
    
            let spans: &[(&str, Attrs)] = &[(text, attrs)];
    
            buffer.set_rich_text(spans.iter().copied(), attrs, Shaping::Advanced);
        });
    
        let mut width: Option<f32> = None;
        editor.draw(&mut swash_cache, black, black, black, |x, _, w, _, _| {
            width = if let Some(width) = width {
                Some(width.max(x as f32 + w as f32))
            } else {
                Some(w as f32 - x as f32)
            };
        });
    
        width
    }
    

    This function does not support measuring the height of the text or text layout such as bold text, text sections with different font sizes, linebreaks, line height or text wrapping. If any of these are required, it should be possible to easily adapt the method above based on the complex sample provided below.

    Complex sample

    Below is a convoluted example using cosmic-text that can handle:

    • linebreaks
    • emojis
    • bold and italic
    • exotic unicode characters
    • font shaping
    • simple wrapping (when constrained by an area)
    • whitespace 😉

    It is heavily based on the rich-text example provided by cosmic-text.

    use std::{num::NonZeroU32, rc::Rc, slice};
    use cosmic_text::{
        Action, Attrs, BorrowedWithFontSystem, Buffer, Color, Edit, Editor, FontSystem, Metrics,
        Motion, Shaping, Style, SwashCache, Weight,
    };
    use tiny_skia::{Paint, PathBuilder, PixmapMut, Rect, Stroke, Transform};
    use winit::{
        dpi::PhysicalPosition,
        event::{ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
        event_loop::{ControlFlow, EventLoop},
        keyboard::{Key, NamedKey},
        window::WindowBuilder,
    };
    
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct Vec2 {
        x: u32,
        y: u32,
    }
    
    #[derive(Debug, Clone, Copy, PartialEq)]
    pub struct BoundingBox {
        pub max: Vec2,
        pub min: Vec2,
    }
    
    impl BoundingBox {
        pub fn new(min: Vec2, max: Vec2) -> Self {
            Self { max, min }
        }
    
        pub fn from_rect(rect: &Rect) -> Self {
            Self {
                max: Vec2 {
                    x: (rect.x() + rect.width()) as u32,
                    y: (rect.y() + rect.height()) as u32,
                },
                min: Vec2 {
                    x: rect.x() as u32,
                    y: rect.y() as u32,
                },
            }
        }
    
        pub fn combine(&self, other: &BoundingBox) -> BoundingBox {
            let min = Vec2 {
                x: self.min.x.min(other.min.x),
                y: self.min.y.min(other.min.y),
            };
            let max = Vec2 {
                x: self.max.x.max(other.max.x),
                y: self.max.y.max(other.max.y),
            };
            BoundingBox::new(min, max)
        }
    }
    
    fn set_buffer_text<'a>(buffer: &mut BorrowedWithFontSystem<'a, Buffer>) {
        let attrs = Attrs::new();
    
        let spans: &[(&str, Attrs)] = &[
            ("B", attrs.weight(Weight::BOLD)),
            ("old ", attrs),
            ("I", attrs.style(Style::Italic)),
            ("talic ", attrs),
            ("f", attrs),
            ("i ", attrs),
            ("f", attrs.weight(Weight::BOLD)),
            ("i ", attrs),
            ("f", attrs.style(Style::Italic)),
            ("i \n", attrs),
            ("Sans-Serif Normal¿\n", attrs),
            (
                "生活,삶,जिंदगी 😀 FPS\n",
                attrs.color(Color::rgb(0xFF, 0x00, 0x00)),
            ),
        ];
    
        buffer.set_rich_text(spans.iter().copied(), attrs, Shaping::Advanced);
    }
    
    fn main() {
        let event_loop = EventLoop::new().unwrap();
        let window = Rc::new(WindowBuilder::new().build(&event_loop).unwrap());
        let context = softbuffer::Context::new(window.clone()).unwrap();
        let mut surface = softbuffer::Surface::new(&context, window.clone()).unwrap();
        let mut font_system = FontSystem::new();
        let mut swash_cache = SwashCache::new();
    
        let mut display_scale = window.scale_factor() as f32;
        let metrics = Metrics::new(32.0, 44.0);
        let mut editor = Editor::new(Buffer::new_empty(metrics.scale(display_scale)));
        let mut editor = editor.borrow_with(&mut font_system);
        editor.with_buffer_mut(|buffer| {
            buffer.set_size(
                window.inner_size().width as f32,
                window.inner_size().height as f32,
            )
        });
        editor.with_buffer_mut(|buffer| set_buffer_text(buffer));
    
        let mut ctrl_pressed = false;
        let mut mouse_x = 0.0;
        let mut mouse_y = 0.0;
        let mut mouse_left = ElementState::Released;
        let mut unapplied_scroll_delta = 0.0;
    
        let bg_color = tiny_skia::Color::from_rgba8(0x34, 0x34, 0x34, 0xFF);
        let font_color = Color::rgb(0xFF, 0xFF, 0xFF);
        let cursor_color = Color::rgb(0xFF, 0xFF, 0xFF);
        let selection_color = Color::rgba(0xFF, 0xFF, 0xFF, 0x33);
    
        event_loop
            .run(|event, elwt| {
                elwt.set_control_flow(ControlFlow::Wait);
    
                match event {
                    Event::WindowEvent {
                        window_id: _,
                        event,
                    } => {
                        match event {
                            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
                                display_scale = scale_factor as f32;
                                editor.with_buffer_mut(|buffer| {
                                    buffer.set_metrics(metrics.scale(display_scale))
                                });
    
                                window.request_redraw();
                            }
                            WindowEvent::RedrawRequested => {
                                let (width, height) = {
                                    let size = window.inner_size();
                                    (size.width, size.height)
                                };
    
                                surface
                                    .resize(
                                        NonZeroU32::new(width).unwrap(),
                                        NonZeroU32::new(height).unwrap(),
                                    )
                                    .unwrap();
    
                                let mut surface_buffer = surface.buffer_mut().unwrap();
                                let surface_buffer_u8 = unsafe {
                                    slice::from_raw_parts_mut(
                                        surface_buffer.as_mut_ptr() as *mut u8,
                                        surface_buffer.len() * 4,
                                    )
                                };
                                let mut pixmap =
                                    PixmapMut::from_bytes(surface_buffer_u8, width, height).unwrap();
                                pixmap.fill(bg_color);
    
                                editor.with_buffer_mut(|buffer| {
                                    buffer.set_size(width as f32, height as f32)
                                });
    
                                let mut paint = Paint::default();
                                paint.anti_alias = false;
    
                                let mut outline = Paint::default();
                                outline.anti_alias = false;
                                outline.set_color_rgba8(255, 0, 0, 80);
    
                                editor.shape_as_needed(true);
    
                                let mut bbox: Option<BoundingBox> = None;
    
                                editor.draw(
                                    &mut swash_cache,
                                    font_color,
                                    cursor_color,
                                    selection_color,
                                    |x, y, w, h, color| {
                                        let char_bbox =
                                            Rect::from_xywh(x as f32, y as f32, w as f32, h as f32)
                                                .unwrap();
    
                                        // Actually apply the bounding box of each character to the "global" bounding box.
                                        bbox = if let Some(bbox) = bbox {
                                            Some(bbox.combine(&BoundingBox::from_rect(&char_bbox)))
                                        } else {
                                            Some(BoundingBox::from_rect(&char_bbox))
                                        };
    
                                        paint.set_color_rgba8(
                                            color.b(),
                                            color.g(),
                                            color.r(),
                                            color.a(),
                                        );
    
                                        pixmap.fill_rect(
                                            char_bbox,
                                            &outline,
                                            Transform::identity(),
                                            None,
                                        );
    
                                        pixmap.fill_rect(
                                            char_bbox,
                                            &paint,
                                            Transform::identity(),
                                            None,
                                        );
                                    },
                                );
    
                                // Here is the current bbox of the text.
                                println!("{:#?}", bbox);
    
                                // Let's also draw a box around the text in order to prove that it is correct.
                                if let Some(bbox) = bbox {
                                    let mut paint = Paint::default();
                                    paint.set_color_rgba8(0, 0, 255, 255);
                                    paint.anti_alias = true;
    
                                    let path = {
                                        let mut pb = PathBuilder::new();
                                        pb.move_to(bbox.min.x as f32, bbox.min.y as f32);
                                        pb.line_to(bbox.min.x as f32, bbox.max.y as f32);
                                        pb.line_to(bbox.max.x as f32, bbox.max.y as f32);
                                        pb.line_to(bbox.max.x as f32, bbox.min.y as f32);
                                        pb.line_to(bbox.min.x as f32, bbox.min.y as f32);
                                        pb.finish().unwrap()
                                    };
    
                                    let mut stroke = Stroke::default();
                                    stroke.width = 1.0;
    
                                    pixmap.stroke_path(
                                        &path,
                                        &paint,
                                        &stroke,
                                        Transform::identity(),
                                        None,
                                    );
                                }
    
                                surface_buffer.present().unwrap();
                            }
                            WindowEvent::ModifiersChanged(modifiers) => {
                                ctrl_pressed = modifiers.state().control_key()
                            }
                            WindowEvent::KeyboardInput { event, .. } => {
                                let KeyEvent {
                                    logical_key, state, ..
                                } = event;
    
                                if state.is_pressed() {
                                    match logical_key {
                                        Key::Named(NamedKey::ArrowLeft) => {
                                            editor.action(Action::Motion(Motion::Left))
                                        }
                                        Key::Named(NamedKey::ArrowRight) => {
                                            editor.action(Action::Motion(Motion::Right))
                                        }
                                        Key::Named(NamedKey::ArrowUp) => {
                                            editor.action(Action::Motion(Motion::Up))
                                        }
                                        Key::Named(NamedKey::ArrowDown) => {
                                            editor.action(Action::Motion(Motion::Down))
                                        }
                                        Key::Named(NamedKey::Home) => {
                                            editor.action(Action::Motion(Motion::Home))
                                        }
                                        Key::Named(NamedKey::End) => {
                                            editor.action(Action::Motion(Motion::End))
                                        }
                                        Key::Named(NamedKey::PageUp) => {
                                            editor.action(Action::Motion(Motion::PageUp))
                                        }
                                        Key::Named(NamedKey::PageDown) => {
                                            editor.action(Action::Motion(Motion::PageDown))
                                        }
                                        Key::Named(NamedKey::Escape) => editor.action(Action::Escape),
                                        Key::Named(NamedKey::Enter) => editor.action(Action::Enter),
                                        Key::Named(NamedKey::Backspace) => {
                                            editor.action(Action::Backspace)
                                        }
                                        Key::Named(NamedKey::Delete) => editor.action(Action::Delete),
                                        Key::Named(key) => {
                                            if let Some(text) = key.to_text() {
                                                for c in text.chars() {
                                                    editor.action(Action::Insert(c));
                                                }
                                            }
                                        }
                                        Key::Character(text) => {
                                            if !ctrl_pressed {
                                                for c in text.chars() {
                                                    editor.action(Action::Insert(c));
                                                }
                                            }
                                        }
                                        _ => {}
                                    }
                                    window.request_redraw();
                                }
                            }
                            WindowEvent::CursorMoved {
                                device_id: _,
                                position,
                            } => {
                                mouse_x = position.x;
                                mouse_y = position.y;
    
                                if mouse_left.is_pressed() {
                                    editor.action(Action::Drag {
                                        x: position.x as i32,
                                        y: position.y as i32,
                                    });
    
                                    if mouse_y <= 5.0 {
                                        editor.action(Action::Scroll { lines: -1 });
                                    } else if mouse_y - 5.0 >= window.inner_size().height as f64 {
                                        editor.action(Action::Scroll { lines: 1 });
                                    }
    
                                    window.request_redraw();
                                }
                            }
                            WindowEvent::MouseInput {
                                device_id: _,
                                state,
                                button,
                            } => {
                                if button == MouseButton::Left {
                                    if state == ElementState::Pressed
                                        && mouse_left == ElementState::Released
                                    {
                                        editor.action(Action::Click {
                                            x: mouse_x /*- line_x*/ as i32,
                                            y: mouse_y as i32,
                                        });
                                        window.request_redraw();
                                    }
                                    mouse_left = state;
                                }
                            }
                            WindowEvent::MouseWheel {
                                device_id: _,
                                delta,
                                phase: _,
                            } => {
                                let line_delta = match delta {
                                    MouseScrollDelta::LineDelta(_x, y) => y as i32,
                                    MouseScrollDelta::PixelDelta(PhysicalPosition { x: _, y }) => {
                                        unapplied_scroll_delta += y;
                                        let line_delta = (unapplied_scroll_delta / 20.0).floor();
                                        unapplied_scroll_delta -= line_delta * 20.0;
                                        line_delta as i32
                                    }
                                };
                                if line_delta != 0 {
                                    editor.action(Action::Scroll { lines: -line_delta });
                                }
                                window.request_redraw();
                            }
                            WindowEvent::CloseRequested => {
                                //TODO: just close one window
                                elwt.exit();
                            }
                            _ => {}
                        }
                    }
                    _ => {}
                }
            })
            .unwrap();
    }
    

    This sample uses the following dependencies (mostly for actually drawing the result of the bounding box computation):

    cosmic-text = "0.11"
    softbuffer = "0.4"
    tiny-skia = "0.11"
    winit = "0.29"
    

    With the sample above it is possible to enter text directly into the window and see the bounding box update in realtime.

    I would advise running the sample with cargo run --release, as it runs quite slow otherwise.

    Here is the resulting output:

    Bounding Box around rich text