diff --git a/.typos.toml b/.typos.toml index d43c5c85..e2af5c57 100644 --- a/.typos.toml +++ b/.typos.toml @@ -23,6 +23,7 @@ Maka = "Maka" ot = "ot" ot_a = "ot_a" ot_b = "ot_b" +VAI = "VAI" wdth = "wdth" # Match Inside a Word - Case Insensitive diff --git a/Cargo.lock b/Cargo.lock index b91fe797..7f1a2f25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,7 +1169,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.1", "peniko", - "read-fonts", + "read-fonts 0.29.3", "roxmltree", "smallvec", "unicode-script", @@ -1417,6 +1417,19 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "harfrust" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca4253de099ee464aea026833ee8f3968c08bfe0065bfb4f47c6ac590d533590" +dependencies = [ + "bitflags 2.9.1", + "bytemuck", + "core_maths", + "read-fonts 0.33.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.15.3" @@ -2644,6 +2657,7 @@ dependencies = [ "accesskit", "core_maths", "fontique", + "harfrust", "hashbrown", "peniko", "skrifa", @@ -2883,9 +2897,20 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.29.2" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + +[[package]] +name = "read-fonts" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f96bfbb7df43d34a2b7b8582fcbcb676ba02a763265cb90bc8aabfd62b57d64" +checksum = "149a62cd54cf1ef1ee79d2b987e01e29b29a16f5ae67d1eaf58653479397186a" dependencies = [ "bytemuck", "core_maths", @@ -3115,7 +3140,7 @@ checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" dependencies = [ "bytemuck", "core_maths", - "read-fonts", + "read-fonts 0.29.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 73eaa6f8..4d2c9c04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ repository = "https://github.com/linebender/parley" accesskit = "0.21.0" bytemuck = { version = "1.23.0", default-features = false } fontique = { version = "0.5.0", default-features = false, path = "fontique" } +harfrust = { version = "0.1.2", default-features = false } hashbrown = "0.15.3" parley = { version = "0.5.0", default-features = false, path = "parley" } peniko = { version = "0.4.0", default-features = false } diff --git a/README.md b/README.md index 287eff75..465e46c1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It is backed by [Swash](https://github.com/dfrg/swash). ## The Parley text stack -Currently, Parley directly depends on four crates: Fontique, Swash, Skrifa, and Peniko. +Currently, Parley directly depends on five crates: Fontique, HarfRust, Swash, Skrifa, and Peniko. These crates cover different pieces of the text-rendering process. ### Peniko @@ -39,6 +39,11 @@ This is necessary because fonts typically don't cover the entire Unicode range: But if you have, say arabic text or emoji embedded within latin text, you don't typically specify the font for the arabic text or the emoji, one is chosen for you. Font fallback is the process which makes that choice. +### HarfRust + +HarfRust is a Rust port of HarfBuzz text shaping engine. **Text shaping** means mapping runs of Unicode codepoints to specific glyphs within fonts. +This includes applying ligatures, resolving emoji modifiers, but also much more complex transformations for some scripts. + ### Skrifa Skrifa reads TrueType and OpenType fonts. @@ -50,15 +55,7 @@ Notably it converts the raw glyph representations in font files into scaled, hin ### Swash -Swash implements text shaping and [some miscellaneous Unicode-related features](https://github.com/dfrg/swash#text-analysis). - -**Text shaping** means mapping runs of Unicode codepoints to specific glyphs within fonts. -This includes applying ligatures, resolving emoji modifiers, but also much more complex transformations for some scripts. - -Swash's implementation is faster but less complete and tested than Harfbuzz and Rustybuzz. - -Swash also implements font parsing, scaling, and hinting. -This part of Swash is now superseded by Skrifa: the implementation in Skrifa is directly descended from the one in Swash. +Within the context of Parley, Swash implements [some miscellaneous Unicode-related features](https://github.com/dfrg/swash#text-analysis). ### Parley @@ -106,6 +103,7 @@ at your option. Some files used for tests are under different licenses: +- The font file `Arimo-VariableFont_wght.ttf` in `/parley/tests/assets/arimo_fonts/` is licensed solely as documented in that folder (and is licensed under the Apache License, Version 2.0). - The font file `Roboto-Regular.ttf` in `/parley/tests/assets/roboto_fonts/` is licensed solely as documented in that folder (and is licensed under the Apache License, Version 2.0). - The font file `NotoKufiArabic-Regular.otf` in `/parley/tests/assets/noto_fonts/` is licensed solely as documented in that folder (and is licensed under the SIL Open Font License, Version 1.1). diff --git a/examples/swash_render/src/main.rs b/examples/swash_render/src/main.rs index 82aef015..a37bb62a 100644 --- a/examples/swash_render/src/main.rs +++ b/examples/swash_render/src/main.rs @@ -322,7 +322,7 @@ fn render_glyph( // Apply the fractional offset .offset(offset) // Render the image - .render(scaler, glyph.id) + .render(scaler, glyph.id as u16) .unwrap(); let glyph_width = rendered_glyph.placement.width; diff --git a/examples/vello_editor/src/text.rs b/examples/vello_editor/src/text.rs index a1b89404..98f3d961 100644 --- a/examples/vello_editor/src/text.rs +++ b/examples/vello_editor/src/text.rs @@ -408,7 +408,7 @@ impl Editor { let gy = y - glyph.y; x += glyph.advance; vello::Glyph { - id: glyph.id as _, + id: glyph.id, x: gx, y: gy, } diff --git a/fontique/src/font.rs b/fontique/src/font.rs index bf2ee18f..26a94da0 100644 --- a/fontique/src/font.rs +++ b/fontique/src/font.rs @@ -319,7 +319,7 @@ pub struct AxisInfo { /// as well as [`QueryFont::synthesis`]. /// /// [`QueryFont::synthesis`]: crate::QueryFont::synthesis -#[derive(Copy, Clone, Default)] +#[derive(Copy, Clone, Default, PartialEq)] pub struct Synthesis { vars: [(Tag, f32); 3], len: u8, diff --git a/parley/Cargo.toml b/parley/Cargo.toml index cf336a8c..3ff64c52 100644 --- a/parley/Cargo.toml +++ b/parley/Cargo.toml @@ -18,7 +18,7 @@ workspace = true [features] default = ["system"] -std = ["fontique/std", "peniko/std", "skrifa/std", "swash/std"] +std = ["fontique/std", "harfrust/std", "peniko/std", "skrifa/std", "swash/std"] libm = ["fontique/libm", "peniko/libm", "skrifa/libm", "swash/libm", "dep:core_maths"] # Enables support for system font backends system = ["std", "fontique/system"] @@ -32,6 +32,7 @@ fontique = { workspace = true } core_maths = { version = "0.1.1", optional = true } accesskit = { workspace = true, optional = true } hashbrown = { workspace = true } +harfrust = { workspace = true } [dev-dependencies] tiny-skia = "0.11.4" diff --git a/parley/src/context.rs b/parley/src/context.rs index 2d5c79b8..16f0d796 100644 --- a/parley/src/context.rs +++ b/parley/src/context.rs @@ -13,11 +13,11 @@ use super::builder::RangedBuilder; use super::resolve::{RangedStyle, RangedStyleBuilder, ResolveContext, ResolvedStyle, tree}; use super::style::{Brush, TextStyle}; -use swash::shape::ShapeContext; use swash::text::cluster::CharInfo; use crate::builder::TreeBuilder; use crate::inline_box::InlineBox; +use crate::shape::ShapeContext; /// Shared scratch space used when constructing text layouts. /// diff --git a/parley/src/layout/cluster.rs b/parley/src/layout/cluster.rs index 1558439c..dc79610c 100644 --- a/parley/src/layout/cluster.rs +++ b/parley/src/layout/cluster.rs @@ -1,9 +1,7 @@ // Copyright 2021 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use super::{ - BreakReason, Brush, Cluster, ClusterInfo, Glyph, Layout, Line, LineItem, Range, Run, Style, -}; +use super::{BreakReason, Brush, Cluster, Glyph, Layout, Line, LineItem, Range, Run, Style, data}; use swash::text::cluster::Whitespace; /// Defines the visual side of the cluster for hit testing. @@ -390,8 +388,8 @@ impl<'a, B: Brush> Cluster<'a, B> { Some(offset) } - pub(crate) fn info(&self) -> ClusterInfo { - self.data.info + pub(crate) fn info(&self) -> &data::ClusterInfo { + &self.data.info } } diff --git a/parley/src/layout/data.rs b/parley/src/layout/data.rs index 655378fd..2e6983c3 100644 --- a/parley/src/layout/data.rs +++ b/parley/src/layout/data.rs @@ -6,10 +6,10 @@ use crate::layout::{ContentWidths, Glyph, LineMetrics, RunMetrics, Style}; use crate::style::Brush; use crate::util::nearly_zero; use crate::{Font, OverflowWrap}; +use core::iter; use core::ops::Range; -use swash::Synthesis; -use swash::shape::Shaper; -use swash::text::cluster::{Boundary, ClusterInfo}; + +use swash::text::cluster::{Boundary, Whitespace}; use alloc::vec::Vec; @@ -20,15 +20,21 @@ use core_maths::CoreFloat; #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct ClusterData { pub(crate) info: ClusterInfo, + /// Cluster flags (see impl methods for details). pub(crate) flags: u16, + /// Style index for this cluster. pub(crate) style_index: u16, + /// Number of glyphs in this cluster (0xFF = single glyph stored inline) pub(crate) glyph_len: u8, + /// Number of text bytes in this cluster pub(crate) text_len: u8, /// If `glyph_len == 0xFF`, then `glyph_offset` is a glyph identifier, /// otherwise, it's an offset into the glyph array with the base /// taken from the owning run. - pub(crate) glyph_offset: u16, + pub(crate) glyph_offset: u32, + /// Offset into the text for this cluster pub(crate) text_offset: u16, + /// Advance width for this cluster pub(crate) advance: f32, } @@ -55,22 +61,73 @@ impl ClusterData { } } +#[derive(Copy, Clone, Debug, PartialEq)] +pub(crate) struct ClusterInfo { + boundary: Boundary, + source_char: char, +} + +impl ClusterInfo { + pub(crate) fn new(boundary: Boundary, source_char: char) -> Self { + Self { + boundary, + source_char, + } + } + + // Returns the boundary type of the cluster. + pub(crate) fn boundary(&self) -> Boundary { + self.boundary + } + + // Returns the whitespace type of the cluster. + pub(crate) fn whitespace(&self) -> Whitespace { + to_whitespace(self.source_char) + } + + /// Returns if the cluster is a line boundary. + pub(crate) fn is_boundary(&self) -> bool { + self.boundary != Boundary::None + } + + /// Returns if the cluster is an emoji. + pub(crate) fn is_emoji(&self) -> bool { + // TODO: Defer to ICU4X properties (see: https://docs.rs/icu/latest/icu/properties/props/struct.Emoji.html). + matches!(self.source_char as u32, 0x1F600..=0x1F64F | 0x1F300..=0x1F5FF | 0x1F680..=0x1F6FF | 0x2600..=0x26FF | 0x2700..=0x27BF) + } + + /// Returns if the cluster is any whitespace. + pub(crate) fn is_whitespace(&self) -> bool { + self.source_char.is_whitespace() + } +} + +fn to_whitespace(c: char) -> Whitespace { + match c { + ' ' => Whitespace::Space, + '\t' => Whitespace::Tab, + '\n' => Whitespace::Newline, + '\r' => Whitespace::Newline, + '\u{00A0}' => Whitespace::NoBreakSpace, + _ => Whitespace::None, + } +} + +/// `HarfRust`-based run data #[derive(Clone, Debug, PartialEq)] pub(crate) struct RunData { /// Index of the font for the run. pub(crate) font_index: usize, /// Font size. pub(crate) font_size: f32, - /// Synthesis information for the font. - pub(crate) synthesis: Synthesis, + /// Synthesis for rendering (contains variation settings) + pub(crate) synthesis: fontique::Synthesis, /// Range of normalized coordinates in the layout data. pub(crate) coords_range: Range, /// Range of the source text. pub(crate) text_range: Range, /// Bidi level for the run. pub(crate) bidi_level: u8, - /// True if the run ends with a newline. - pub(crate) ends_with_newline: bool, /// Range of clusters. pub(crate) cluster_range: Range, /// Base for glyph indices. @@ -287,19 +344,25 @@ impl LayoutData { bidi_level, }); } - - #[allow(unused_assignments)] #[allow(clippy::too_many_arguments)] pub(crate) fn push_run( &mut self, font: Font, font_size: f32, - synthesis: Synthesis, - shaper: Shaper<'_>, + synthesis: fontique::Synthesis, + glyph_buffer: &harfrust::GlyphBuffer, bidi_level: u8, word_spacing: f32, letter_spacing: f32, + source_text: &str, + char_infos: &[(swash::text::cluster::CharInfo, u16)], // From text analysis + text_range: Range, // The text range this run covers + coords: &[harfrust::NormalizedCoord], ) { + let coords_start = self.coords.len(); + self.coords.extend(coords.iter().map(|c| c.to_bits())); + let coords_end = self.coords.len(); + let font_index = self .fonts .iter() @@ -309,154 +372,103 @@ impl LayoutData { self.fonts.push(font); index }); - let metrics = shaper.metrics(); + + let metrics = { + let font = &self.fonts[font_index]; + let font_ref = skrifa::FontRef::from_index(font.data.as_ref(), font.index).unwrap(); + skrifa::metrics::Metrics::new(&font_ref, skrifa::prelude::Size::new(font_size), coords) + }; + let units_per_em = metrics.units_per_em as f32; + + let metrics = { + let (underline_offset, underline_size) = if let Some(underline) = metrics.underline { + (underline.offset, underline.thickness) + } else { + // Default values from Harfbuzz: https://github.com/harfbuzz/harfbuzz/blob/00492ec7df0038f41f78d43d477c183e4e4c506e/src/hb-ot-metrics.cc#L334 + let default = units_per_em / 18.0; + (default, default) + }; + let (strikethrough_offset, strikethrough_size) = + if let Some(strikeout) = metrics.strikeout { + (strikeout.offset, strikeout.thickness) + } else { + // Default values from HarfBuzz: https://github.com/harfbuzz/harfbuzz/blob/00492ec7df0038f41f78d43d477c183e4e4c506e/src/hb-ot-metrics.cc#L334-L347 + (metrics.ascent / 2.0, units_per_em / 18.0) + }; + + RunMetrics { + ascent: metrics.ascent, + descent: -metrics.descent, + leading: metrics.leading, + underline_offset, + underline_size, + strikethrough_offset, + strikethrough_size, + } + }; + let cluster_range = self.clusters.len()..self.clusters.len(); - let coords_start = self.coords.len(); - let coords = shaper.normalized_coords(); - if coords.iter().any(|coord| *coord != 0) { - self.coords.extend_from_slice(coords); - } - let coords_end = self.coords.len(); + let mut run = RunData { font_index, font_size, synthesis, coords_range: coords_start..coords_end, - text_range: 0..0, + text_range, bidi_level, - ends_with_newline: false, cluster_range, glyph_start: self.glyphs.len(), - metrics: RunMetrics { - ascent: metrics.ascent, - descent: metrics.descent, - leading: metrics.leading, - underline_offset: metrics.underline_offset, - underline_size: metrics.stroke_size, - strikethrough_offset: metrics.strikeout_offset, - strikethrough_size: metrics.stroke_size, - }, + metrics, word_spacing, letter_spacing, advance: 0., }; - // Track these so that we can flush if they overflow a u16. - let mut glyph_count = 0_usize; - let mut text_offset = 0; - macro_rules! flush_run { - () => { - if !run.cluster_range.is_empty() { - self.runs.push(run.clone()); - self.items.push(LayoutItem { - kind: LayoutItemKind::TextRun, - index: self.runs.len() - 1, - bidi_level: run.bidi_level, - }); - run.text_range = text_offset..text_offset; - run.cluster_range.start = run.cluster_range.end; - run.glyph_start = self.glyphs.len(); - run.advance = 0.; - glyph_count = 0; - } - }; + + // `HarfRust` returns glyphs in visual order, so we need to process them as such while + // maintaining logical ordering of clusters. + + let glyph_infos = glyph_buffer.glyph_infos(); + if glyph_infos.is_empty() { + return; + } + let glyph_positions = glyph_buffer.glyph_positions(); + let scale_factor = font_size / units_per_em; + let cluster_range_start = self.clusters.len(); + let is_rtl = bidi_level & 1 == 1; + if !is_rtl { + run.advance = process_clusters( + &mut self.clusters, + &mut self.glyphs, + scale_factor, + glyph_infos, + glyph_positions, + char_infos, + source_text.char_indices(), + ); + } else { + run.advance = process_clusters( + &mut self.clusters, + &mut self.glyphs, + scale_factor, + glyph_infos, + glyph_positions, + char_infos, + source_text.char_indices().rev(), + ); + // Reverse clusters into logical order for RTL + let clusters_len = self.clusters.len(); + self.clusters[cluster_range_start..clusters_len].reverse(); + } + + run.cluster_range = cluster_range_start..self.clusters.len(); + if !run.cluster_range.is_empty() { + self.runs.push(run); + self.items.push(LayoutItem { + kind: LayoutItemKind::TextRun, + index: self.runs.len() - 1, + bidi_level, + }); } - let mut first = true; - shaper.shape_with(|cluster| { - if cluster.info.boundary() == Boundary::Mandatory { - run.ends_with_newline = true; - flush_run!(); - } - run.ends_with_newline = false; - const MAX_LEN: usize = u16::MAX as usize; - let source_range = cluster.source.to_range(); - if first { - run.text_range = source_range.start..source_range.start; - text_offset = source_range.start; - first = false; - } - let num_components = cluster.components.len() + 1; - if glyph_count > MAX_LEN - || (text_offset - run.text_range.start) > MAX_LEN - || (num_components > 1 - && (cluster.components.last().unwrap().start as usize - run.text_range.start) - > MAX_LEN) - { - flush_run!(); - } - let text_len = source_range.len(); - let glyph_len = cluster.glyphs.len(); - let advance = cluster.advance(); - run.advance += advance; - let mut cluster_data = ClusterData { - info: cluster.info, - flags: 0, - style_index: cluster.data as _, - glyph_len: glyph_len as u8, - text_len: text_len as u8, - advance, - text_offset: (text_offset - run.text_range.start) as u16, - glyph_offset: 0, - }; - if num_components > 1 { - cluster_data.flags = ClusterData::LIGATURE_START; - cluster_data.advance /= cluster.components.len() as f32; - cluster_data.text_len = cluster.components[0].to_range().len() as u8; - } - macro_rules! push_components { - () => { - self.clusters.push(cluster_data); - if num_components > 1 { - cluster_data.glyph_offset = 0; - cluster_data.glyph_len = 0; - for component in &cluster.components[1..] { - let range = component.to_range(); - cluster_data.flags = ClusterData::LIGATURE_COMPONENT; - cluster_data.text_offset = (range.start - run.text_range.start) as u16; - cluster_data.text_len = range.len() as u8; - self.clusters.push(cluster_data); - run.cluster_range.end += 1; - } - cluster_data.flags = 0; - } - }; - } - run.cluster_range.end += 1; - run.text_range.end += text_len; - text_offset += text_len; - if glyph_len == 1 && num_components == 1 { - let g = &cluster.glyphs[0]; - if nearly_zero(g.x) && nearly_zero(g.y) { - // Handle the case with a single glyph with zero'd offset. - cluster_data.glyph_len = 0xFF; - cluster_data.glyph_offset = g.id; - push_components!(); - return; - } - } else if glyph_len == 0 { - // Insert an empty cluster. This occurs for both invisible - // control characters and ligature components. - push_components!(); - return; - } - // Otherwise, encode all of the glyphs. - cluster_data.glyph_offset = (self.glyphs.len() - run.glyph_start) as u16; - self.glyphs.extend(cluster.glyphs.iter().map(|g| { - let style_index = g.data as u16; - if cluster_data.style_index != style_index { - cluster_data.flags |= ClusterData::DIVERGENT_STYLES; - } - Glyph { - id: g.id, - style_index, - x: g.x, - y: g.y, - advance: g.advance, - } - })); - glyph_count += glyph_len; - push_components!(); - }); - flush_run!(); } pub(crate) fn finish(&mut self) { @@ -549,3 +561,305 @@ impl LayoutData { } } } + +/// Processes shaped glyphs from `HarfRust` and converts them into `ClusterData` and `Glyph`. +/// +/// # Parameters +/// +/// ## Output Parameters (mutated by this function): +/// * `clusters` - Vector where new `ClusterData` entries will be pushed. +/// * `glyphs` - Vector where new `Glyph` entries will be pushed. Note: single-glyph clusters +/// with zero offsets may be inlined directly into `ClusterData`. +/// +/// ## Input Parameters: +/// * `scale_factor` - Scaling factor used to convert font units to the target size. +/// * `glyph_infos` - `HarfRust` glyph information in visual order. +/// * `glyph_positions` - `HarfRust` glyph positioning data in visual order. +/// * `char_infos` - Character information from text analysis, indexed by cluster ID. +/// * `char_indices_iter` - Iterator over (`byte_offset`, `char`) pairs from the source text. +/// Should be in logical order (forward for LTR, reverse for RTL). +fn process_clusters>( + clusters: &mut Vec, + glyphs: &mut Vec, + scale_factor: f32, + glyph_infos: &[harfrust::GlyphInfo], + glyph_positions: &[harfrust::GlyphPosition], + char_infos: &[(swash::text::cluster::CharInfo, u16)], + char_indices_iter: I, +) -> f32 { + let mut char_indices_iter = char_indices_iter.peekable(); + let mut cluster_start_char = char_indices_iter.next().unwrap(); + let mut total_glyphs: u32 = 0; + let mut cluster_glyph_offset: u32 = 0; + let start_cluster_id = glyph_infos.first().unwrap().cluster; + let mut cluster_id = start_cluster_id; + let mut char_info = &char_infos[cluster_id as usize]; + let mut run_advance = 0.0; + let mut cluster_advance = 0.0; + // If the current cluster might be a single-glyph, zero-offset cluster, we defer + // pushing the first glyph to `glyphs` because it might be inlined into `ClusterData`. + let mut pending_inline_glyph: Option = None; + + let text_len = |char: (usize, char), chars: &mut iter::Peekable| { + let next = chars + .peek() + .map(|x| x.0) + .unwrap_or(char.0 + char.1.len_utf8()); + char.0.abs_diff(next) as u8 + }; + + for (glyph_info, glyph_pos) in glyph_infos.iter().zip(glyph_positions.iter()) { + // Flush previous cluster if we've reached a new cluster + if cluster_id != glyph_info.cluster { + let num_components = cluster_id.abs_diff(glyph_info.cluster); + run_advance += cluster_advance; + cluster_advance /= num_components as f32; + let is_newline = to_whitespace(cluster_start_char.1) == Whitespace::Newline; + let cluster_type = if num_components > 1 { + debug_assert!(!is_newline); + ClusterType::LigatureStart + } else if is_newline { + ClusterType::Newline + } else { + ClusterType::Regular + }; + + let inline_glyph_id = if matches!(cluster_type, ClusterType::Regular) { + pending_inline_glyph.take().map(|g| g.id) + } else { + // This isn't a regular cluster, so we don't inline the glyph and push + // it to `glyphs`. + if let Some(pending) = pending_inline_glyph.take() { + glyphs.push(pending); + total_glyphs += 1; + } + None + }; + + push_cluster( + clusters, + char_info, + text_len(cluster_start_char, &mut char_indices_iter), + cluster_start_char, + cluster_glyph_offset, + cluster_advance, + total_glyphs, + cluster_type, + inline_glyph_id, + ); + cluster_glyph_offset = total_glyphs; + + if num_components > 1 { + // Skip characters until we reach the current cluster + for i in 0..(num_components - 1) { + cluster_start_char = char_indices_iter.next().unwrap(); + if to_whitespace(cluster_start_char.1) == Whitespace::Space { + break; + } + // Iterate in correct (LTR or RTL) order + let char_info_ = if cluster_id < glyph_info.cluster { + &char_infos[(cluster_id + i) as usize] + } else { + &char_infos[(cluster_id - 1) as usize] + }; + + push_cluster( + clusters, + char_info_, + text_len(cluster_start_char, &mut char_indices_iter), + cluster_start_char, + cluster_glyph_offset, + cluster_advance, + total_glyphs, + ClusterType::LigatureComponent, + None, + ); + } + } + cluster_start_char = char_indices_iter.next().unwrap(); + + cluster_advance = 0.0; + cluster_id = glyph_info.cluster; + char_info = &char_infos[cluster_id as usize]; + pending_inline_glyph = None; + } + + let glyph = Glyph { + id: glyph_info.glyph_id, + style_index: char_info.1, + x: (glyph_pos.x_offset as f32) * scale_factor, + y: (glyph_pos.y_offset as f32) * scale_factor, + advance: (glyph_pos.x_advance as f32) * scale_factor, + }; + cluster_advance += glyph.advance; + // Push any pending glyph. If it was a zero-offset, single glyph cluster, it would + // have been pushed in the first `if` block. + if let Some(pending) = pending_inline_glyph.take() { + glyphs.push(pending); + total_glyphs += 1; + } + if total_glyphs == cluster_glyph_offset && glyph.x == 0.0 && glyph.y == 0.0 { + // Defer this potential zero-offset, single glyph cluster + pending_inline_glyph = Some(glyph); + } else { + glyphs.push(glyph); + total_glyphs += 1; + } + } + + // Push the last cluster + { + // Since this is the last cluster, it covers from cluster_id to the end of char_infos + let remaining_chars = char_infos.len() - cluster_id.abs_diff(start_cluster_id) as usize; + if remaining_chars > 1 { + // This is a ligature - create ligature start + ligature components + + if let Some(pending) = pending_inline_glyph.take() { + glyphs.push(pending); + total_glyphs += 1; + } + let ligature_advance = cluster_advance / remaining_chars as f32; + push_cluster( + clusters, + char_info, + text_len(cluster_start_char, &mut char_indices_iter), + cluster_start_char, + cluster_glyph_offset, + ligature_advance, + total_glyphs, + ClusterType::LigatureStart, + None, + ); + + cluster_glyph_offset = total_glyphs; + + // Create ligature component clusters for the remaining characters + let mut i = 1; + while let Some(char) = char_indices_iter.next() { + if to_whitespace(char.1) == Whitespace::Space { + break; + } + // Iterate in correct (LTR or RTL) order + let component_char_info = if cluster_start_char.0 < char.0 { + &char_infos[(cluster_id + i) as usize] + } else { + &char_infos[(cluster_id - i) as usize] + }; + + push_cluster( + clusters, + component_char_info, + text_len(char, &mut char_indices_iter), + char, + cluster_glyph_offset, + ligature_advance, + total_glyphs, + ClusterType::LigatureComponent, + None, + ); + i += 1; + } + } else { + let is_newline = to_whitespace(cluster_start_char.1) == Whitespace::Newline; + let cluster_type = if is_newline { + ClusterType::Newline + } else { + ClusterType::Regular + }; + let mut inline_glyph_id = None; + match cluster_type { + ClusterType::Regular => { + if total_glyphs == cluster_glyph_offset { + if let Some(pending) = pending_inline_glyph.take() { + inline_glyph_id = Some(pending.id); + } + } + } + _ => { + if let Some(pending) = pending_inline_glyph.take() { + glyphs.push(pending); + total_glyphs += 1; + } + } + } + push_cluster( + clusters, + char_info, + text_len(cluster_start_char, &mut char_indices_iter), + cluster_start_char, + cluster_glyph_offset, + cluster_advance, + total_glyphs, + cluster_type, + inline_glyph_id, + ); + } + } + + run_advance +} + +enum ClusterType { + LigatureStart, + LigatureComponent, + Regular, + Newline, +} + +impl From<&ClusterType> for u16 { + fn from(cluster_type: &ClusterType) -> Self { + match cluster_type { + ClusterType::LigatureStart => ClusterData::LIGATURE_START, + ClusterType::LigatureComponent => ClusterData::LIGATURE_COMPONENT, + ClusterType::Regular | ClusterType::Newline => 0, // No special flags + } + } +} + +fn push_cluster( + clusters: &mut Vec, + char_info: &(swash::text::cluster::CharInfo, u16), + text_len: u8, + cluster_start_char: (usize, char), + glyph_offset: u32, + advance: f32, + total_glyphs: u32, + cluster_type: ClusterType, + inline_glyph_id: Option, +) { + let glyph_len = (total_glyphs - glyph_offset) as u8; + + let (final_glyph_len, final_glyph_offset, final_advance) = match cluster_type { + ClusterType::LigatureComponent => { + // Ligature components have no glyphs, only advance. + debug_assert_eq!(glyph_len, 0); + (0_u8, 0_u32, advance) + } + ClusterType::Newline => { + // Newline clusters are stripped of their glyph contribution. + debug_assert_eq!(glyph_len, 1); + (0_u8, 0_u32, 0.0) + } + _ if inline_glyph_id.is_some() => { + // Inline glyphs are stored inline within `ClusterData` + debug_assert_eq!(glyph_len, 0); + (0xFF_u8, inline_glyph_id.unwrap(), advance) + } + ClusterType::Regular | ClusterType::LigatureStart => { + // Regular and ligature start clusters maintain their glyphs and advance. + debug_assert_ne!(glyph_len, 0); + (glyph_len, glyph_offset, advance) + } + }; + + clusters.push(ClusterData { + info: ClusterInfo::new(char_info.0.boundary(), cluster_start_char.1), + flags: (&cluster_type).into(), + style_index: char_info.1, + glyph_len: final_glyph_len, + text_len, + glyph_offset: final_glyph_offset, + text_offset: cluster_start_char.0 as u16, + advance: final_advance, + }); +} diff --git a/parley/src/layout/mod.rs b/parley/src/layout/mod.rs index 07229ce7..2ae7618e 100644 --- a/parley/src/layout/mod.rs +++ b/parley/src/layout/mod.rs @@ -24,10 +24,10 @@ use alignment::unjustify; use alloc::vec::Vec; use core::{cmp::Ordering, ops::Range}; use data::{ClusterData, LayoutData, LayoutItem, LayoutItemKind, LineData, LineItemData, RunData}; +use fontique::Synthesis; #[cfg(feature = "accesskit")] use hashbrown::{HashMap, HashSet}; -use swash::text::cluster::{Boundary, ClusterInfo}; -use swash::{GlyphId, NormalizedCoord, Synthesis}; +use swash::text::cluster::Boundary; pub use alignment::AlignmentOptions; pub use cluster::{Affinity, ClusterPath, ClusterSide}; @@ -261,7 +261,7 @@ pub struct Cluster<'a, B: Brush> { /// Glyph with an offset and advance. #[derive(Copy, Clone, Default, Debug, PartialEq)] pub struct Glyph { - pub id: GlyphId, + pub id: u32, pub style_index: u16, pub x: f32, pub y: f32, diff --git a/parley/src/layout/run.rs b/parley/src/layout/run.rs index f2fa5376..b4b6bff3 100644 --- a/parley/src/layout/run.rs +++ b/parley/src/layout/run.rs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use super::{ - Brush, Cluster, ClusterPath, Font, Layout, LineItemData, NormalizedCoord, Range, Run, RunData, - Synthesis, + Brush, Cluster, ClusterPath, Font, Layout, LineItemData, Range, Run, RunData, Synthesis, }; impl<'a, B: Brush> Run<'a, B> { @@ -45,7 +44,7 @@ impl<'a, B: Brush> Run<'a, B> { /// Returns the normalized variation coordinates for the font associated /// with the run. - pub fn normalized_coords(&self) -> &[NormalizedCoord] { + pub fn normalized_coords(&self) -> &[i16] { self.layout .data .coords diff --git a/parley/src/shape.rs b/parley/src/shape.rs index 0c350fd8..4300a280 100644 --- a/parley/src/shape.rs +++ b/parley/src/shape.rs @@ -1,21 +1,39 @@ // Copyright 2021 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +//! Text shaping implementation using `harfrust`for shaping +//! and `swash` for text analysis. + +use core::mem; + +use alloc::vec::Vec; + use super::layout::Layout; use super::resolve::{RangedStyle, ResolveContext, Resolved}; use super::style::{Brush, FontFeature, FontVariation}; -use crate::Font; +use crate::inline_box::InlineBox; use crate::util::nearly_eq; -use fontique::QueryFamily; -use fontique::{self, Query, QueryFont}; -use swash::shape::{Direction, ShapeContext, partition}; +use crate::{Font, swash_convert}; + +use fontique::{self, Query, QueryFamily, QueryFont}; use swash::text::cluster::{CharCluster, CharInfo, Token}; use swash::text::{Language, Script}; -use swash::{FontRef, Synthesis}; -use alloc::vec::Vec; +pub(crate) struct ShapeContext { + deferred_boxes: Vec, + unicode_buffer: Option, + features: Vec, +} -use crate::inline_box::InlineBox; +impl Default for ShapeContext { + fn default() -> Self { + Self { + deferred_boxes: Vec::new(), + unicode_buffer: Some(harfrust::UnicodeBuffer::new()), + features: Vec::new(), + } + } +} struct Item { style_index: u16, @@ -78,62 +96,6 @@ pub(crate) fn shape_text<'a, B: Brush>( let mut inline_box_iter = inline_boxes.iter().enumerate(); let mut current_box = inline_box_iter.next(); - let mut deferred_boxes: Vec = Vec::with_capacity(16); - - // Define macro to shape - macro_rules! shape_item { - () => { - let item_text = &text[text_range.clone()]; - let item_infos = &infos[char_range.start..]; - let first_style_index = item_infos[0].1; - let mut fs = FontSelector::new( - &mut fq, - rcx, - styles, - first_style_index, - item.script, - item.locale, - ); - let options = partition::SimpleShapeOptions { - size: item.size, - script: item.script, - language: item.locale, - direction: if item.level & 1 != 0 { - Direction::RightToLeft - } else { - Direction::LeftToRight - }, - variations: rcx.variations(item.variations).unwrap_or(&[]), - features: rcx.features(item.features).unwrap_or(&[]), - insert_dotted_circles: false, - }; - partition::shape( - scx, - &mut fs, - &options, - item_text.char_indices().zip(item_infos).map( - |((offset, ch), (info, style_index))| Token { - ch, - offset: (text_range.start + offset) as u32, - len: ch.len_utf8() as u8, - info: *info, - data: *style_index as _, - }, - ), - |font, shaper| { - layout.data.push_run( - Font::new(font.font.blob.clone(), font.font.index), - item.size, - font.synthesis, - shaper, - item.level, - item.word_spacing, - item.letter_spacing, - ); - }, - ); - }; - } // Iterate over characters in the text for ((char_index, (byte_index, ch)), (info, style_index)) in @@ -169,11 +131,9 @@ pub(crate) fn shape_text<'a, B: Brush>( // - We do this *before* processing the text run because we need to know whether we should // break the run due to the presence of an inline box. while let Some((box_idx, inline_box)) = current_box { - // println!("{} {}", byte_index, inline_box.index); - if inline_box.index == byte_index { break_run = true; - deferred_boxes.push(box_idx); + scx.deferred_boxes.push(box_idx); // Update the current box to the next box current_box = inline_box_iter.next(); } else { @@ -182,7 +142,18 @@ pub(crate) fn shape_text<'a, B: Brush>( } if break_run && !text_range.is_empty() { - shape_item!(); + shape_item( + &mut fq, + rcx, + styles, + &item, + scx, + text, + &text_range, + &char_range, + infos, + layout, + ); item.size = style.font_size; item.level = level; item.script = script; @@ -193,8 +164,7 @@ pub(crate) fn shape_text<'a, B: Brush>( char_range.start = char_range.end; } - for box_idx in deferred_boxes.drain(0..) { - // Push the box to the list of items + for box_idx in scx.deferred_boxes.drain(..) { layout.data.push_inline_box(box_idx); } @@ -203,7 +173,18 @@ pub(crate) fn shape_text<'a, B: Brush>( } if !text_range.is_empty() { - shape_item!(); + shape_item( + &mut fq, + rcx, + styles, + &item, + scx, + text, + &text_range, + &char_range, + infos, + layout, + ); } // Process any remaining inline boxes whose index is greater than the length of the text @@ -215,10 +196,195 @@ pub(crate) fn shape_text<'a, B: Brush>( } } +fn shape_item<'a, B: Brush>( + fq: &mut Query<'a>, + rcx: &'a ResolveContext, + styles: &'a [RangedStyle], + item: &Item, + scx: &mut ShapeContext, + text: &str, + text_range: &core::ops::Range, + char_range: &core::ops::Range, + infos: &[(CharInfo, u16)], + layout: &mut Layout, +) { + let item_text = &text[text_range.clone()]; + let item_infos = &infos[char_range.start..char_range.end]; // Only process current item + let first_style_index = item_infos[0].1; + let mut font_selector = + FontSelector::new(fq, rcx, styles, first_style_index, item.script, item.locale); + + // Parse text into clusters of the current item + let tokens = + item_text + .char_indices() + .zip(item_infos) + .map(|((offset, ch), (info, style_index))| Token { + ch, + offset: (text_range.start + offset) as u32, + len: ch.len_utf8() as u8, + info: *info, + data: *style_index as u32, + }); + + let mut parser = swash::text::cluster::Parser::new(item.script, tokens); + let mut cluster = CharCluster::new(); + + // Reimplement swash's shape_clusters algorithm - but only for current item + if !parser.next(&mut cluster) { + return; // No clusters to process + } + + let mut current_font = font_selector.select_font(&mut cluster); + + // Main segmentation loop (based on swash shape_clusters) - only within current item + while let Some(font) = current_font.take() { + // Collect all clusters for this font segment + let segment_start_offset = cluster.range().start as usize - text_range.start; + let mut segment_end_offset = cluster.range().end as usize - text_range.start; + + loop { + if !parser.next(&mut cluster) { + // End of current item - process final segment + break; + } + + if let Some(next_font) = font_selector.select_font(&mut cluster) { + if next_font != font { + current_font = Some(next_font); + break; + } else { + // Same font - add to current segment + segment_end_offset = cluster.range().end as usize - text_range.start; + } + } else { + // No font found - skip this cluster + if !parser.next(&mut cluster) { + break; + } + } + } + + // Shape this font segment with harfrust + let segment_text = &item_text[segment_start_offset..segment_end_offset]; + // Shape the entire segment text including newlines + // The line breaking algorithm will handle newlines automatically + + // TODO: How do we want to handle errors like this? + let font_ref = + harfrust::FontRef::from_index(font.font.blob.as_ref(), font.font.index).unwrap(); + + // Create harfrust shaper + // TODO: cache this upstream? + let shaper_data = harfrust::ShaperData::new(&font_ref); + let instance = harfrust::ShaperInstance::from_variations( + &font_ref, + variations_iter(&font.font.synthesis, rcx.variations(item.variations)), + ); + // TODO: Don't create a new shaper for each segment. + let harf_shaper = shaper_data + .shaper(&font_ref) + .instance(Some(&instance)) + .point_size(Some(item.size)) + .build(); + + // Prepare harfrust buffer + let mut buffer = mem::take(&mut scx.unicode_buffer).unwrap(); + buffer.clear(); + + // Use the entire segment text including newlines + buffer.reserve(segment_text.len()); + for (i, ch) in segment_text.chars().enumerate() { + // Ensure that each cluster's index matches the index into `infos`. This is required + // for efficient cluster lookup within `data.rs`. + // + // In other words, instead of using `buffer.push_str`, which iterates `segment_text` + // with `char_indices`, push each char individually via `.chars` with a cluster index + // that matches its `infos` counterpart. This allows us to lookup `infos` via cluster + // index in `data.rs`. + buffer.add(ch, i as u32); + } + + let direction = if item.level & 1 != 0 { + harfrust::Direction::RightToLeft + } else { + harfrust::Direction::LeftToRight + }; + buffer.set_direction(direction); + + let script = swash_convert::script_to_harfrust(item.script); + buffer.set_script(script); + + if let Some(lang) = item.locale { + let lang_tag = lang.language(); + if let Ok(harf_lang) = lang_tag.parse::() { + buffer.set_language(harf_lang); + } + } + + scx.features.clear(); + for feature in rcx.features(item.features).unwrap_or(&[]) { + scx.features.push(harfrust::Feature::new( + harfrust::Tag::from_u32(feature.tag), + feature.value as u32, + 0..buffer.len(), + )); + } + + let glyph_buffer = harf_shaper.shape(buffer, &scx.features); + + // Extract relevant CharInfo slice for this segment + let char_start = char_range.start + item_text[..segment_start_offset].chars().count(); + let segment_char_start = char_start - char_range.start; + let segment_char_count = segment_text.chars().count(); + let segment_infos = + &item_infos[segment_char_start..(segment_char_start + segment_char_count)]; + + // Push harfrust-shaped run for the entire segment + layout.data.push_run( + Font::new(font.font.blob.clone(), font.font.index), + item.size, + font.font.synthesis, + &glyph_buffer, + item.level, + item.word_spacing, + item.letter_spacing, + segment_text, + segment_infos, + (text_range.start + segment_start_offset)..(text_range.start + segment_end_offset), + harf_shaper.coords(), + ); + + // Replace buffer to reuse allocation in next iteration. + scx.unicode_buffer = Some(glyph_buffer.clear()); + } +} + fn real_script(script: Script) -> bool { script != Script::Common && script != Script::Unknown && script != Script::Inherited } +fn variations_iter<'a>( + synthesis: &'a fontique::Synthesis, + item: Option<&'a [FontVariation]>, +) -> impl Iterator + 'a { + synthesis + .variation_settings() + .iter() + .map(|(tag, value)| harfrust::Variation { + tag: *tag, + value: *value, + }) + .chain( + item.unwrap_or(&[]) + .iter() + .map(|variation| harfrust::Variation { + tag: harfrust::Tag::from_u32(variation.tag), + value: variation.value, + }), + ) +} + struct FontSelector<'a, 'b, B: Brush> { query: &'b mut Query<'a>, fonts_id: Option, @@ -267,12 +433,8 @@ impl<'a, 'b, B: Brush> FontSelector<'a, 'b, B> { features, } } -} -impl partition::Selector for FontSelector<'_, '_, B> { - type SelectedFont = SelectedFont; - - fn select_font(&mut self, cluster: &mut CharCluster) -> Option { + fn select_font(&mut self, cluster: &mut CharCluster) -> Option { let style_index = cluster.user_data() as u16; let is_emoji = cluster.info().is_emoji(); if style_index != self.style_index || is_emoji || self.fonts_id.is_none() { @@ -348,35 +510,16 @@ impl partition::Selector for FontSelector<'_, '_, B> { struct SelectedFont { font: QueryFont, - synthesis: Synthesis, } impl From<&QueryFont> for SelectedFont { fn from(font: &QueryFont) -> Self { - use crate::swash_convert::synthesis_to_swash; - Self { - font: font.clone(), - synthesis: synthesis_to_swash(font.synthesis), - } + Self { font: font.clone() } } } impl PartialEq for SelectedFont { fn eq(&self, other: &Self) -> bool { - self.font.family == other.font.family && self.synthesis == other.synthesis - } -} - -impl partition::SelectedFont for SelectedFont { - fn font(&self) -> FontRef<'_> { - FontRef::from_index(self.font.blob.as_ref(), self.font.index as _).unwrap() - } - - fn id_override(&self) -> Option<[u64; 2]> { - Some([self.font.blob.id(), self.font.index as _]) - } - - fn synthesis(&self) -> Option { - Some(self.synthesis) + self.font.family == other.font.family && self.font.synthesis == other.font.synthesis } } diff --git a/parley/src/swash_convert.rs b/parley/src/swash_convert.rs index 7cf5519b..db243a82 100644 --- a/parley/src/swash_convert.rs +++ b/parley/src/swash_convert.rs @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT pub(crate) fn script_to_fontique(script: swash::text::Script) -> fontique::Script { - fontique::Script(*SCRIPT_TAGS.get(script as usize).unwrap_or(b"Zzzz")) + fontique::Script(*FONTIQUE_SCRIPT_TAGS.get(script as usize).unwrap_or(b"Zzzz")) +} + +pub(crate) fn script_to_harfrust(script: swash::text::Script) -> harfrust::Script { + harfrust::Script::from_iso15924_tag(harfrust::Tag::new( + FONTIQUE_SCRIPT_TAGS.get(script as usize).unwrap_or(b"Zzzz"), + )) + .unwrap_or(harfrust::script::UNKNOWN) } pub(crate) fn locale_to_fontique(locale: swash::text::Language) -> Option { @@ -31,22 +38,8 @@ pub(crate) fn locale_to_fontique(locale: swash::text::Language) -> Option swash::Synthesis { - swash::Synthesis::new( - synthesis - .variation_settings() - .iter() - .map(|setting| swash::Setting { - tag: swash::tag_from_bytes(&setting.0.to_be_bytes()), - value: setting.1, - }), - synthesis.embolden(), - synthesis.skew().unwrap_or_default(), - ) -} - #[rustfmt::skip] -const SCRIPT_TAGS: [[u8; 4]; 157] = [ +const FONTIQUE_SCRIPT_TAGS: [[u8; 4]; 157] = [ *b"Adlm", *b"Aghb", *b"Ahom", *b"Arab", *b"Armi", *b"Armn", *b"Avst", *b"Bali", *b"Bamu", *b"Bass", *b"Batk", *b"Beng", *b"Bhks", *b"Bopo", *b"Brah", *b"Brai", *b"Bugi", *b"Buhd", *b"Cakm", *b"Cans", *b"Cari", *b"Cham", *b"Cher", *b"Chrs", *b"Copt", *b"Cprt", *b"Cyrl", diff --git a/parley/src/tests/test_basic.rs b/parley/src/tests/test_basic.rs index 7f88f853..0c68d442 100644 --- a/parley/src/tests/test_basic.rs +++ b/parley/src/tests/test_basic.rs @@ -1,14 +1,16 @@ // Copyright 2024 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT +use std::borrow::Cow; + use peniko::{ color::{AlphaColor, Srgb, palette}, kurbo::Size, }; use crate::{ - Alignment, AlignmentOptions, ContentWidths, FontStack, InlineBox, Layout, LineHeight, - StyleProperty, TextStyle, WhiteSpaceCollapse, test_name, + Alignment, AlignmentOptions, ContentWidths, FontFamily, FontSettings, FontStack, InlineBox, + Layout, LineHeight, StyleProperty, TextStyle, WhiteSpaceCollapse, test_name, }; use super::utils::{ColorBrush, FONT_STACK, TestEnv, asserts::assert_eq_layout_data_alignments}; @@ -390,6 +392,126 @@ fn inbox_content_width() { } } +#[test] +fn ligatures() { + let mut env = TestEnv::new(test_name!(), None); + + let text = "fi ".repeat(20); + let builder = env.ranged_builder(&text); + let mut layout = builder.build(&text); + layout.break_all_lines(Some(100.0)); + layout.align(None, Alignment::Start, AlignmentOptions::default()); + + // Check that every cluster is correctly classified as a ligature start, ligature continuation, + // or none with correct glyphs and advances. + for line in layout.lines() { + for item in line.items() { + if let crate::PositionedLayoutItem::GlyphRun(glyph_run) = item { + let mut last_advance = f32::MAX; + glyph_run.run().clusters().enumerate().for_each(|(i, c)| { + match i % 3 { + 0 => { + assert!(c.is_ligature_start()); + assert_eq!(c.glyphs().count(), 1); + assert_eq!(c.text_range().len(), 1); + assert_eq!(c.glyphs().next().unwrap().id, 444); + // The glyph for this ligature lives in the start cluster and should + // contain the whole ligature's advance. + assert_eq!(c.glyphs().next().unwrap().advance, c.advance() * 2.0); + } + 1 => { + assert!(c.is_ligature_continuation()); + // A continuation shares its advance with the previous cluster. + assert_eq!(c.advance(), last_advance); + assert_eq!(c.text_range().len(), 1); + assert_eq!(c.glyphs().count(), 0); + } + 2 => assert!(!c.is_ligature_start() && !c.is_ligature_continuation()), + _ => unreachable!(), + } + last_advance = c.advance(); + }); + } + } + } + + env.check_layout_snapshot(&layout); +} + +#[test] +fn text_range_rtl() { + let mut env = TestEnv::new(test_name!(), None); + + let text = "اللغة العربية"; + let builder = env.ranged_builder(text); + let mut layout = builder.build(text); + layout.break_all_lines(Some(100.0)); + layout.align(None, Alignment::Start, AlignmentOptions::default()); + + for line in layout.lines() { + for item in line.items() { + if let crate::PositionedLayoutItem::GlyphRun(glyph_run) = item { + glyph_run.run().clusters().for_each(|c| { + if !c.is_space_or_nbsp() { + assert_eq!(c.text_range().len(), 2); + } + }); + } + } + } +} + +#[test] +fn font_features() { + let mut env = TestEnv::new(test_name!(), None); + + let text = "fi ".repeat(4); + let mut builder = env.ranged_builder(&text); + builder.push( + StyleProperty::FontFeatures(FontSettings::List(Cow::Borrowed(&[swash::Setting { + tag: swash::tag_from_bytes(b"liga"), + value: 1, + }]))), + 0..5, + ); + builder.push( + StyleProperty::FontFeatures(FontSettings::List(Cow::Borrowed(&[swash::Setting { + tag: swash::tag_from_bytes(b"liga"), + value: 0, + }]))), + 5..10, + ); + let mut layout = builder.build(&text); + layout.break_all_lines(Some(100.0)); + layout.align(None, Alignment::Start, AlignmentOptions::default()); + + env.check_layout_snapshot(&layout); +} + +#[test] +fn variable_fonts() { + let mut env = TestEnv::new(test_name!(), None); + let text = "Hello World"; + + for wght in [100., 500., 1000.] { + let mut builder = env.ranged_builder(text); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named(Cow::Borrowed("Arimo")), + ))); + builder.push_default(StyleProperty::FontVariations(FontSettings::List( + Cow::Borrowed(&[swash::Setting { + tag: swash::tag_from_bytes(b"wght"), + value: wght, + }]), + ))); + let mut layout = builder.build(text); + layout.break_all_lines(Some(100.0)); + layout.align(None, Alignment::Start, AlignmentOptions::default()); + + env.check_layout_snapshot(&layout); + } +} + #[test] /// Layouts can be re-line-breaked and re-aligned. fn realign() { diff --git a/parley/src/tests/test_cursor.rs b/parley/src/tests/test_cursor.rs index 8cccac43..ecb7b39d 100644 --- a/parley/src/tests/test_cursor.rs +++ b/parley/src/tests/test_cursor.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use crate::tests::utils::CursorTest; -use crate::{Cursor, FontContext, LayoutContext}; +use crate::{Cursor, FontContext, LayoutContext, Selection}; #[test] fn cursor_previous_visual() { @@ -29,3 +29,30 @@ fn cursor_next_visual() { layout.assert_cursor_is_after("ipsum d", cursor); } + +#[test] +fn cursor_ligature_selection() { + use crate::tests::utils::ColorBrush; + use crate::{Affinity, Cursor}; + let (mut lcx, mut fcx): (LayoutContext, _) = + (LayoutContext::new(), FontContext::new()); + + // Test with ligature text "fi" using serif font which should support ligatures + let text = "fi"; + let mut builder = lcx.ranged_builder(&mut fcx, text, 1.0, true); + builder.push_default(crate::style::GenericFamily::Serif); + let mut layout = builder.build(text); + layout.break_all_lines(None); + + // Test cursor positioning at the end of the text (byte index 2) + // This should position the cursor at the end, not at the start of the cluster + let cursor_end = Cursor::from_byte_index(&layout, 2, Affinity::Upstream); + + let selection: Selection = cursor_end.into(); + + let focus = selection.focus(); + + let clusters = focus.logical_clusters(&layout); + + assert_eq!(clusters[0].as_ref().map(|c| c.text_range()), Some(1..2)); +} diff --git a/parley/src/tests/utils/env.rs b/parley/src/tests/utils/env.rs index d3325dff..51e71638 100644 --- a/parley/src/tests/utils/env.rs +++ b/parley/src/tests/utils/env.rs @@ -45,6 +45,10 @@ fn snapshot_dir() -> PathBuf { fn font_dirs() -> impl Iterator { [ + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("assets") + .join("arimo_fonts"), Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("assets") diff --git a/parley/src/tests/utils/renderer.rs b/parley/src/tests/utils/renderer.rs index 18645123..3f1f42af 100644 --- a/parley/src/tests/utils/renderer.rs +++ b/parley/src/tests/utils/renderer.rs @@ -173,7 +173,7 @@ fn render_glyph_run(glyph_run: &GlyphRun<'_, ColorBrush>, pen: &mut TinySkiaPen< let glyph_y = run_y - glyph.y + padding as f32; run_x += glyph.advance; - let glyph_id = GlyphId::from(glyph.id); + let glyph_id = GlyphId::from(glyph.id as u16); if let Some(glyph_outline) = outlines.get(glyph_id) { pen.set_origin(glyph_x, glyph_y); pen.set_color(brush.color); diff --git a/parley/tests/assets/arimo_fonts/Arimo-VariableFont_wght.ttf b/parley/tests/assets/arimo_fonts/Arimo-VariableFont_wght.ttf new file mode 100644 index 00000000..b48679b5 Binary files /dev/null and b/parley/tests/assets/arimo_fonts/Arimo-VariableFont_wght.ttf differ diff --git a/parley/tests/assets/arimo_fonts/LICENSE.txt b/parley/tests/assets/arimo_fonts/LICENSE.txt new file mode 100644 index 00000000..75b52484 --- /dev/null +++ b/parley/tests/assets/arimo_fonts/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/parley/tests/snapshots/base_level_alignment_rtl-center.png b/parley/tests/snapshots/base_level_alignment_rtl-center.png index f4c2d0f2..908a5bc5 100644 --- a/parley/tests/snapshots/base_level_alignment_rtl-center.png +++ b/parley/tests/snapshots/base_level_alignment_rtl-center.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abcdfd4a7ee9754a612c491a04ba41475d9ef0ea4b81da124437f2c6869fd15c -size 20250 +oid sha256:1ae21fc2a383631aae55b9fbcc9490e50f4c93a45bbe0e8d5feebee6b6b28a34 +size 20363 diff --git a/parley/tests/snapshots/base_level_alignment_rtl-end.png b/parley/tests/snapshots/base_level_alignment_rtl-end.png index 54fd890d..2ddf26fc 100644 --- a/parley/tests/snapshots/base_level_alignment_rtl-end.png +++ b/parley/tests/snapshots/base_level_alignment_rtl-end.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a53d1f9f1177f5941c51eb593d250f421e79fc69cb0c4c5f8636149e3010a88 -size 20222 +oid sha256:8a39f570209432d032961f5f2dc19859e1633ed7bb59072add9db5b11d72a402 +size 20319 diff --git a/parley/tests/snapshots/base_level_alignment_rtl-justify.png b/parley/tests/snapshots/base_level_alignment_rtl-justify.png index f6c8c48a..3e9f785e 100644 --- a/parley/tests/snapshots/base_level_alignment_rtl-justify.png +++ b/parley/tests/snapshots/base_level_alignment_rtl-justify.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:066576d38344b0b890fb789bc87bddf9e570910e18745fc08295b8307801605b -size 20363 +oid sha256:91bf0b638cd2a4d48f168098778e7f4f993061219b14ed240f68231392d12e31 +size 20458 diff --git a/parley/tests/snapshots/base_level_alignment_rtl-start.png b/parley/tests/snapshots/base_level_alignment_rtl-start.png index 9c3a8def..b8be1cc9 100644 --- a/parley/tests/snapshots/base_level_alignment_rtl-start.png +++ b/parley/tests/snapshots/base_level_alignment_rtl-start.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:374a095e14512dfdba9c1e4db25f44da28425a76febf74981b874cb6ba5f693f -size 20167 +oid sha256:6ba9ddbac219ca6bd10b12c92fdcef45861d2aa700d43feb636a9f967046e1f2 +size 20292 diff --git a/parley/tests/snapshots/font_features-0.png b/parley/tests/snapshots/font_features-0.png new file mode 100644 index 00000000..595e8dac --- /dev/null +++ b/parley/tests/snapshots/font_features-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d74d6e5a3d5be0aaf55805a2e15f59fecbc75bf0babdbaa8486e2993866878d +size 2314 diff --git a/parley/tests/snapshots/ligatures-0.png b/parley/tests/snapshots/ligatures-0.png new file mode 100644 index 00000000..0e0ebdb2 --- /dev/null +++ b/parley/tests/snapshots/ligatures-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ce431a5a753cdc47164cf9abb13760fcade801ec9e44dd66ca7f5b28b69d663 +size 8565 diff --git a/parley/tests/snapshots/lines_fractional_line_height_big_negative_leading-0.png b/parley/tests/snapshots/lines_fractional_line_height_big_negative_leading-0.png index 180b7299..971d8a2b 100644 --- a/parley/tests/snapshots/lines_fractional_line_height_big_negative_leading-0.png +++ b/parley/tests/snapshots/lines_fractional_line_height_big_negative_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8fa519d1a189efdd80799a5043ee1dc090fddcd1b7899a22d77b66ecce0714cc -size 62419 +oid sha256:a03491d161587fa5bbc7cf10fa3bfce3818d3fa036d059353309d5b1c70019e3 +size 7456 diff --git a/parley/tests/snapshots/lines_fractional_line_height_big_positive_leading-0.png b/parley/tests/snapshots/lines_fractional_line_height_big_positive_leading-0.png index 73f9de03..e0a2cb90 100644 --- a/parley/tests/snapshots/lines_fractional_line_height_big_positive_leading-0.png +++ b/parley/tests/snapshots/lines_fractional_line_height_big_positive_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e97a18041c35a59b0b9c9d4fdf58086b20a5519f0b2782336c85cc9faae246d -size 83516 +oid sha256:11e56c0e470f02945dc6ac39659a9c9ebbdd789fa01fd900da5b8ad72e20f698 +size 8404 diff --git a/parley/tests/snapshots/lines_fractional_line_height_negative_leading-0.png b/parley/tests/snapshots/lines_fractional_line_height_negative_leading-0.png index 3287b3c5..34d5418c 100644 --- a/parley/tests/snapshots/lines_fractional_line_height_negative_leading-0.png +++ b/parley/tests/snapshots/lines_fractional_line_height_negative_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2ce09ff1bab99321fad76f6d99a4d79632e70c74c6adaa50be83fb9c1ae671d -size 65687 +oid sha256:fd5e7fc978d010ced1b9337de113cef82a53fd30e5c3d45a5996b6d1b03b0df3 +size 7284 diff --git a/parley/tests/snapshots/lines_fractional_line_height_positive_leading-0.png b/parley/tests/snapshots/lines_fractional_line_height_positive_leading-0.png index 7ff96d15..d3e3c3d8 100644 --- a/parley/tests/snapshots/lines_fractional_line_height_positive_leading-0.png +++ b/parley/tests/snapshots/lines_fractional_line_height_positive_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbac2ef521ab022979da73929a52cee9650f5d3ab6f7993eda553a4fdf8a283d -size 72261 +oid sha256:3d492a40e17753bcd058181dfd16a001ba276886a0a002b0803744ba8d52bc0b +size 7357 diff --git a/parley/tests/snapshots/lines_integral_line_height_ascent_descent_rounding-0.png b/parley/tests/snapshots/lines_integral_line_height_ascent_descent_rounding-0.png index 5d75583f..e9aed43e 100644 --- a/parley/tests/snapshots/lines_integral_line_height_ascent_descent_rounding-0.png +++ b/parley/tests/snapshots/lines_integral_line_height_ascent_descent_rounding-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:460d8af68eb6a74f4e38deda19b0782e49d0381b76539d900a56bb8fd9199484 -size 88963 +oid sha256:012d0859cb3b185e0e7288f5fe69819ddfc9a364832dc17c45f74b858f367c52 +size 8923 diff --git a/parley/tests/snapshots/lines_integral_line_height_minus_one_leading-0.png b/parley/tests/snapshots/lines_integral_line_height_minus_one_leading-0.png index 911a1350..003c6109 100644 --- a/parley/tests/snapshots/lines_integral_line_height_minus_one_leading-0.png +++ b/parley/tests/snapshots/lines_integral_line_height_minus_one_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b5aa46601ae25bb83c662d6368a2b094d30eeb51e79f8f27ee71095cc2aa246 -size 73742 +oid sha256:40d85dc10fd0590e08c172d9abc3074d0a1cc1918f86733911c2d16491ee9121 +size 7802 diff --git a/parley/tests/snapshots/lines_integral_line_height_plus_one_leading-0.png b/parley/tests/snapshots/lines_integral_line_height_plus_one_leading-0.png index e1db433f..34b7e8fe 100644 --- a/parley/tests/snapshots/lines_integral_line_height_plus_one_leading-0.png +++ b/parley/tests/snapshots/lines_integral_line_height_plus_one_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c376c0a4daf3a3af07723eb5919fd8818e45ec99289a7f6cce5bc26dc7022b21 -size 62414 +oid sha256:7bace77eda306345bd1032d8329cd9b7e8c2cf7e676c097a9afdd3f4289e6e77 +size 6703 diff --git a/parley/tests/snapshots/lines_integral_line_height_zero_leading-0.png b/parley/tests/snapshots/lines_integral_line_height_zero_leading-0.png index dddb2c0e..01ad6fc2 100644 --- a/parley/tests/snapshots/lines_integral_line_height_zero_leading-0.png +++ b/parley/tests/snapshots/lines_integral_line_height_zero_leading-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b5f4889230c036dae9333c6a6abee095c701b3048ce3011f50d2de20847fbb8 -size 69761 +oid sha256:5a17d83c010560bcebb55390a04098a0e3c5e7e767e6a9fb4b62393ac74dfecf +size 7165 diff --git a/parley/tests/snapshots/lines_line_height_rounds_down-0.png b/parley/tests/snapshots/lines_line_height_rounds_down-0.png index 0f8f6eeb..b442781f 100644 --- a/parley/tests/snapshots/lines_line_height_rounds_down-0.png +++ b/parley/tests/snapshots/lines_line_height_rounds_down-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a26ca57037a2630ac38dd510f38f7e0ad8b1ba63ce6430315c5b7d31c5d07e5 -size 70340 +oid sha256:7d4cd019adceb0288cda05350c35b6db66b6f60a1662e3d486cc12dcc7a44dcc +size 7223 diff --git a/parley/tests/snapshots/lines_line_height_rounds_up-0.png b/parley/tests/snapshots/lines_line_height_rounds_up-0.png index eed8654a..654c7586 100644 --- a/parley/tests/snapshots/lines_line_height_rounds_up-0.png +++ b/parley/tests/snapshots/lines_line_height_rounds_up-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2967695f1a851327426701e7810b1758123b7e0695e193eff951e30931370f4 -size 70640 +oid sha256:2f5fa42057b7abce2f01b6169198dc4ab8b2bcdb584ab3420ec5ce0b396282f8 +size 7285 diff --git a/parley/tests/snapshots/variable_fonts-0.png b/parley/tests/snapshots/variable_fonts-0.png new file mode 100644 index 00000000..858318e8 --- /dev/null +++ b/parley/tests/snapshots/variable_fonts-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8599022bef2bca5e755a0539e6d4a6a423e97d3922c5ff8193550003e8b83892 +size 3465 diff --git a/parley/tests/snapshots/variable_fonts-1.png b/parley/tests/snapshots/variable_fonts-1.png new file mode 100644 index 00000000..28a98780 --- /dev/null +++ b/parley/tests/snapshots/variable_fonts-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:011e63d02841f3d69cb6cc397c4c02ca6f08e395e445f1847741288180c416a4 +size 3687 diff --git a/parley/tests/snapshots/variable_fonts-2.png b/parley/tests/snapshots/variable_fonts-2.png new file mode 100644 index 00000000..e5b2bb4f --- /dev/null +++ b/parley/tests/snapshots/variable_fonts-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c009d05c6c6b34c582565f93a655439c063fe45e9f48584c1d9af62c6774e73c +size 3876