Skip to content
Snippets Groups Projects
Unverified Commit 6ff0d1dc authored by MrGVSV's avatar MrGVSV Committed by GitHub
Browse files

Merge pull request #105 from StarArawn/text-layout-helpers

kayak_font: Add `Grapheme` and `RowCol`
parents efbc8b61 e3519011
No related branches found
No related tags found
No related merge requests found
......@@ -4,9 +4,10 @@ use std::collections::HashMap;
use bevy::{prelude::Handle, reflect::TypeUuid, render::texture::Image};
use unicode_segmentation::UnicodeSegmentation;
use crate::layout::{Alignment, Line, TextLayout};
use crate::utility::{BreakableWord, MISSING, SPACE};
use crate::{utility, Glyph, GlyphRect, Sdf, TextProperties};
use crate::{
utility, Alignment, Glyph, GlyphRect, Grapheme, Line, Sdf, TextLayout, TextProperties,
};
#[cfg(feature = "bevy_renderer")]
#[derive(Debug, Clone, TypeUuid, PartialEq)]
......@@ -112,7 +113,9 @@ impl KayakFont {
let norm_glyph_bounds = self.calc_glyph_size(properties.font_size);
// The current line being calculated
let mut line = Line::default();
let mut line = Line::new(0);
let mut glyph_index = 0;
let mut char_index = 0;
// The word index to break a line before
let mut break_index = None;
......@@ -141,8 +144,9 @@ impl KayakFont {
// If the `break_index` is set, see if it applies.
if let Some(idx) = break_index {
if idx == index {
let next_line = Line::new_after(&line);
lines.push(line);
line = Line::new_after(line);
line = next_line;
break_index = None;
}
}
......@@ -154,7 +158,7 @@ impl KayakFont {
}
_ => {
let (next_break, next_skip) =
self.find_next_break(index, line.width, properties, &words);
self.find_next_break(index, line.width(), properties, &words);
break_index = next_break;
skip_until_index = next_skip;
will_break |= break_index.map(|idx| index + 1 == idx).unwrap_or_default();
......@@ -163,11 +167,17 @@ impl KayakFont {
}
// === Iterate Grapheme Clusters === //
for grapheme in word.content.graphemes(true) {
line.grapheme_len += 1;
for c in grapheme.chars() {
line.char_len += 1;
for grapheme_content in word.content.graphemes(true) {
let mut grapheme = Grapheme {
position: (line.width(), properties.line_height * lines.len() as f32),
glyph_index,
char_index,
..Default::default()
};
for c in grapheme_content.chars() {
char_index += 1;
grapheme.char_total += 1;
if utility::is_newline(c) {
// Newlines (hard breaks) are already accounted for by the line break algorithm
......@@ -177,10 +187,10 @@ impl KayakFont {
if utility::is_space(c) {
if !will_break {
// Don't add the space if we're about to break the line
line.width += space_width;
grapheme.size.0 += space_width;
}
} else if utility::is_tab(c) {
line.width += tab_width;
grapheme.size.0 += tab_width;
} else {
let glyph = self.get_glyph(c).or_else(|| {
if let Some(missing) = self.missing_glyph {
......@@ -204,9 +214,10 @@ impl KayakFont {
};
// Calculate position relative to line and normalized glyph bounds
let pos_x = line.width + left * properties.font_size;
let mut pos_y = properties.line_height * lines.len() as f32;
pos_y -= top * properties.font_size;
let pos_x = (grapheme.position.0 + grapheme.size.0)
+ left * properties.font_size;
let pos_y = (grapheme.position.1 + grapheme.size.1)
- top * properties.font_size;
glyph_rects.push(GlyphRect {
position: (pos_x, pos_y),
......@@ -214,13 +225,15 @@ impl KayakFont {
content: glyph.unicode,
});
line.glyph_len += 1;
line.width += glyph.advance * properties.font_size;
glyph_index += 1;
grapheme.glyph_total += 1;
grapheme.size.0 += glyph.advance * properties.font_size;
}
}
size.0 = size.0.max(line.width);
}
line.add_grapheme(grapheme);
size.0 = size.0.max(line.width());
}
}
......@@ -232,12 +245,12 @@ impl KayakFont {
for line in lines.iter() {
let shift_x = match properties.alignment {
Alignment::Start => 0.0,
Alignment::Middle => (properties.max_size.0 - line.width) / 2.0,
Alignment::End => properties.max_size.0 - line.width,
Alignment::Middle => (properties.max_size.0 - line.width()) / 2.0,
Alignment::End => properties.max_size.0 - line.width(),
};
let start = line.glyph_index;
let end = start + line.glyph_len;
let start = line.glyph_index();
let end = line.glyph_index() + line.total_glyphs();
for index in start..end {
let rect = &mut glyph_rects[index];
......
/// Layout information for a renderable glyph.
#[derive(Default, Debug, Clone, Copy, PartialEq)]
pub struct GlyphRect {
pub position: (f32, f32),
pub size: (f32, f32),
pub content: char,
}
use std::cmp::Ordering;
/// A representation of a grapheme cluster, as defined by [Unicode UAX #29].
///
/// [Unicode UAX #29]: https://unicode.org/reports/tr29/
#[derive(Default, Debug, Copy, Clone, PartialEq)]
pub struct Grapheme {
/// The index of the starting char within this grapheme, relative to the entire text content.
pub char_index: usize,
/// The total number of chars in this grapheme.
pub char_total: usize,
/// The index of the starting glyph within this grapheme, relative to the entire text content.
pub glyph_index: usize,
/// The total number of glyphs in this grapheme.
pub glyph_total: usize,
/// The position of this grapheme, relative to the entire text content.
pub position: (f32, f32),
/// The size of this grapheme.
pub size: (f32, f32),
}
impl PartialOrd for Grapheme {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.char_index.partial_cmp(&other.char_index)
}
}
use crate::layout::grapheme::Grapheme;
use std::cmp::Ordering;
use std::ops::Index;
use std::slice::SliceIndex;
/// Contains details for a calculated line of text.
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
grapheme_index: usize,
graphemes: Vec<Grapheme>,
width: f32,
}
/// A reference to the grapheme at a specific row and column of a given line of text.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct RowCol {
/// The row this line belongs to (zero-indexed).
pub row: usize,
/// The column this grapheme belongs to (zero-indexed).
///
/// This is the same as the grapheme index localized to within a line.
pub col: usize,
/// The grapheme at this row and column.
pub grapheme: Grapheme,
}
impl Line {
/// Creates a new [`Line`] starting at the given grapheme cluster index.
pub fn new(grapheme_index: usize) -> Self {
Self {
grapheme_index,
graphemes: Vec::new(),
width: 0.0,
}
}
/// Creates a new [`Line`] following the given one.
///
/// This essentially means that it starts out pointing to the next [grapheme index].
///
/// [grapheme index]: Self::grapheme_index
pub fn new_after(previous: &Self) -> Self {
Self::new(previous.grapheme_index + previous.total_graphemes())
}
/// The total width of this line (in pixels).
pub fn width(&self) -> f32 {
self.width
}
/// Returns the grapheme at the given index within this line, if any.
///
/// If the grapheme does
pub fn get_grapheme<I: SliceIndex<[Grapheme]>>(&self, index: I) -> Option<&I::Output> {
self.graphemes.get(index)
}
/// Returns the grapheme at the given index within this line.
///
/// # Panics
///
/// Will panic if the given index is out of bounds of this line.
pub fn grapheme<I: SliceIndex<[Grapheme]>>(&self, index: I) -> &I::Output {
&self.graphemes[index]
}
/// The list of grapheme clusters in this line.
pub fn graphemes(&self) -> &[Grapheme] {
&self.graphemes
}
/// The index of the starting grapheme cluster within this line, relative to the entire text content.
pub fn grapheme_index(&self) -> usize {
self.grapheme_index
}
/// The total number of graphemes in this line.
pub fn total_graphemes(&self) -> usize {
self.graphemes.len()
}
/// The index of the starting glyph within this line, relative to the entire text content.
pub fn glyph_index(&self) -> usize {
self.graphemes
.first()
.map(|grapheme| grapheme.glyph_index)
.unwrap_or_default()
}
/// The total number of glyphs in this line.
pub fn total_glyphs(&self) -> usize {
let end = self
.graphemes
.last()
.map(|grapheme| grapheme.glyph_index + grapheme.glyph_total);
match end {
Some(index) if index > 0 => index - self.glyph_index(),
_ => 0,
}
}
/// The index of the starting char within this line, relative to the entire text content.
pub fn char_index(&self) -> usize {
self.graphemes
.first()
.map(|grapheme| grapheme.char_index)
.unwrap_or_default()
}
/// The total number of chars in this line.
pub fn total_chars(&self) -> usize {
let end = self
.graphemes
.last()
.map(|grapheme| grapheme.char_index + grapheme.char_total);
match end {
Some(index) if index > 0 => index - self.char_index(),
_ => 0,
}
}
/// Add a new grapheme to this line.
pub fn add_grapheme(&mut self, grapheme: Grapheme) {
self.width += grapheme.size.0;
self.graphemes.push(grapheme)
}
}
impl PartialOrd for Line {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.grapheme_index.partial_cmp(&other.grapheme_index)
}
}
impl<I: SliceIndex<[Grapheme]>> Index<I> for Line {
type Output = I::Output;
fn index(&self, index: I) -> &Self::Output {
self.grapheme(index)
}
}
//! Contains useful types for text layout.
mod glyph;
mod grapheme;
mod line;
mod text;
pub use glyph::*;
pub use grapheme::*;
pub use line::*;
pub use text::*;
use crate::{GlyphRect, Line, RowCol};
use std::cmp::Ordering;
#[derive(Default, Debug, Clone, Copy, PartialEq)]
pub struct GlyphRect {
pub position: (f32, f32),
pub size: (f32, f32),
pub content: char,
}
/// The text alignment.
#[derive(Copy, Clone, Debug, PartialEq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Alignment {
Start,
Middle,
......@@ -42,43 +36,6 @@ impl Default for TextProperties {
}
}
/// Contains details for a calculated line of text.
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct Line {
/// The index of the starting grapheme cluster within the text content.
pub grapheme_index: usize,
/// The index of the starting glyph within the text content.
pub glyph_index: usize,
/// The index of the starting char within the text content.
pub char_index: usize,
/// The total number of grapheme clusters in this line.
pub grapheme_len: usize,
/// The total number of glyphs in this line.
pub glyph_len: usize,
/// The total number of chars in this line.
pub char_len: usize,
/// The total width of this line (in pixels).
pub width: f32,
}
impl Line {
/// Creates a new [`Line`] following the given one.
pub fn new_after(previous: Self) -> Self {
Self {
grapheme_index: previous.grapheme_index + previous.grapheme_len,
glyph_index: previous.glyph_index + previous.glyph_len,
char_index: previous.char_index + previous.char_len,
..Default::default()
}
}
}
impl PartialOrd for Line {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.grapheme_index.partial_cmp(&other.grapheme_index)
}
}
/// Calculated text layout.
///
/// This can be retrieved using [`measure`](crate::KayakFont::measure).
......@@ -92,6 +49,12 @@ pub struct TextLayout {
impl TextLayout {
/// Create a new [`TextLayout`].
///
/// The given lists of [lines] and [glyphs] should be in their appropriate order
/// (i.e. Line 1 should come before Line 2, etc.).
///
/// [lines]: Line
/// [glyphs]: GlyphRect
pub fn new(
glyphs: Vec<GlyphRect>,
lines: Vec<Line>,
......@@ -107,12 +70,12 @@ impl TextLayout {
}
/// Returns the calculated lines for the text content.
pub fn lines(&self) -> &Vec<Line> {
pub fn lines(&self) -> &[Line] {
&self.lines
}
/// Returns the calculated glyph rects for the text content.
pub fn glyphs(&self) -> &Vec<GlyphRect> {
pub fn glyphs(&self) -> &[GlyphRect] {
&self.glyphs
}
......@@ -125,4 +88,57 @@ impl TextLayout {
pub fn properties(&self) -> TextProperties {
self.properties
}
/// The total number of lines.
pub fn total_lines(&self) -> usize {
self.lines.len()
}
/// The total number of graphemes.
pub fn total_graphemes(&self) -> usize {
self.lines
.last()
.map(|line| line.grapheme_index() + line.total_graphemes())
.unwrap_or_default()
}
/// The total number of glyphs.
pub fn total_glyphs(&self) -> usize {
self.glyphs.len()
}
/// The total number of chars.
pub fn total_chars(&self) -> usize {
self.lines
.last()
.map(|line| line.char_index() + line.total_chars())
.unwrap_or_default()
}
/// Performs a binary search to find the grapheme at the given index.
///
/// If the grapheme could not be found, `None` is returned.
pub fn find_grapheme(&self, index: usize) -> Option<RowCol> {
self.lines
.binary_search_by(|line| {
if index < line.grapheme_index() {
// Current line comes after line containing grapheme
Ordering::Greater
} else if index >= line.grapheme_index() + line.total_graphemes() {
// Current line comes before line containing grapheme
Ordering::Less
} else {
// Current line contains grapheme
Ordering::Equal
}
})
.map(|row| {
let line = &self.lines[row];
let col = index - line.grapheme_index();
let grapheme = line[col];
RowCol { row, col, grapheme }
})
.ok()
}
}
......@@ -15,3 +15,122 @@ pub use sdf::*;
#[cfg(feature = "bevy_renderer")]
pub mod bevy;
#[cfg(test)]
mod tests {
use crate::{Alignment, KayakFont, Sdf, TextProperties};
fn make_font() -> KayakFont {
let bytes = std::fs::read("assets/roboto.kayak_font")
.expect("a `roboto.kayak_font` file in the `assets/` directory of this crate");
#[cfg(feature = "bevy_renderer")]
return KayakFont::new(Sdf::from_bytes(&bytes), bevy::asset::Handle::default());
#[cfg(not(feature = "bevy_renderer"))]
return KayakFont::new(Sdf::from_bytes(&bytes));
}
fn make_properties() -> TextProperties {
TextProperties {
line_height: 14.0 * 1.2,
font_size: 14.0,
alignment: Alignment::Start,
max_size: (200.0, 300.0),
tab_size: 4,
}
}
#[test]
fn should_contain_correct_number_of_chars() {
let content = "Hello world!\nHow is everyone on this super-awesome rock doing today?";
let font = make_font();
let properties = make_properties();
let layout = font.measure(content, properties);
assert_eq!(content.len(), layout.total_chars())
}
#[test]
fn should_contain_correct_number_of_glyphs() {
let content = "Hello world!\nHow is everyone on this super-awesome rock doing today?";
let font = make_font();
let properties = make_properties();
let layout = font.measure(content, properties);
// Since this string is pure ascii, we can just get the total characters - total whitespace
let expected = content.split_whitespace().collect::<String>().len();
assert_eq!(expected, layout.total_glyphs())
}
#[test]
fn should_contain_correct_number_of_graphemes() {
let content = "Hello world!\nHow is everyone on this super-awesome rock doing today?";
let font = make_font();
let properties = make_properties();
let layout = font.measure(content, properties);
// Since this string is pure ascii, we can just get the total characters
let expected = content.len();
assert_eq!(expected, layout.total_graphemes())
}
#[test]
fn should_contain_correct_number_of_lines() {
let content = "Hello world!\nHow is everyone on this super-awesome rock doing today?";
let font = make_font();
let properties = make_properties();
let layout = font.measure(content, properties);
assert_eq!(4, layout.total_lines())
}
#[test]
fn should_find_line_containing_grapheme() {
let content = "Hello world!\nHow is everyone on this super-awesome rock doing today?";
let font = make_font();
let properties = make_properties();
let layout = font.measure(content, properties);
let lines = [
(content.find("Hello").unwrap(), content.rfind('\n').unwrap()),
(
content.find("How").unwrap(),
content.rfind("this ").unwrap(),
),
(
content.find("super").unwrap(),
content.rfind("doing ").unwrap(),
),
(content.find("today").unwrap(), content.rfind('?').unwrap()),
];
for (line_index, (line_start, line_end)) in lines.into_iter().enumerate() {
let result = layout.find_grapheme(line_start).unwrap().row;
assert_eq!(line_index, result);
let result = layout.find_grapheme(line_end).unwrap().row;
assert_eq!(line_index, result);
}
}
#[test]
fn grapheme_should_be_correct_position() {
let content = "Hello world!\nHow is everyone on this super-awesome rock doing today?";
let font = make_font();
let properties = make_properties();
let layout = font.measure(content, properties);
for (line_index, line) in layout.lines().iter().enumerate() {
let mut expected_x = 0.0;
let expected_y = properties.line_height * line_index as f32;
for grapheme in line.graphemes() {
assert_eq!(expected_x, grapheme.position.0);
assert_eq!(expected_y, grapheme.position.1);
expected_x += grapheme.size.0;
}
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment