From bf28b29dc035d87fda6e5cf38223d4f9bbec90e4 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 10:37:48 +0900 Subject: [PATCH 01/34] chore(main): remove unused dependencies --- crates/main/Cargo.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 0bd49c6..5d2f4ce 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -9,14 +9,8 @@ license.workspace = true fvi-core.workspace = true fvi-render.workspace = true -bytemuck.workspace = true -tokio.workspace = true tracing.workspace = true -tracing-subscriber.workspace = true winit.workspace = true -[target.'cfg(not(target_family = "wasm"))'.dependencies] -tokio = { workspace = true, features = ["rt-multi-thread"] } - [lints] workspace = true From be9741c1a95e31b4d9e7c13ead278aa587b048c9 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 10:38:09 +0900 Subject: [PATCH 02/34] chore(render): add harfrust/swash as dep --- Cargo.lock | 156 +++++++++++++++++---------------- Cargo.toml | 3 +- crates/render/Cargo.toml | 2 + crates/render/src/cache/mod.rs | 2 +- 4 files changed, 84 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d6d06f..db5701e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -882,12 +891,9 @@ dependencies = [ name = "fvi" version = "0.1.0" dependencies = [ - "bytemuck", "fvi-core", "fvi-render", - "tokio", "tracing", - "tracing-subscriber", "winit", ] @@ -906,8 +912,10 @@ dependencies = [ "bytemuck", "etagere", "fvi-core", + "harfrust", "hashbrown 0.17.1", "nalgebra", + "swash", "tracing", "wgpu", "wgsl_bindgen", @@ -1038,6 +1046,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1199,12 +1220,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -1436,15 +1451,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num-bigint" version = "0.4.8" @@ -2180,6 +2186,28 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2393,15 +2421,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -2436,6 +2455,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -2546,6 +2575,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.119" @@ -2630,15 +2670,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -2674,15 +2705,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2742,32 +2764,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", ] [[package]] @@ -2818,12 +2814,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" @@ -3623,6 +3613,18 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.57" diff --git a/Cargo.toml b/Cargo.toml index 227e4fc..fb2b587 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,9 @@ fvi-render = { path = "crates/render" } bytemuck = "1.25" etagere = "0.3" hashbrown = "0.17" +harfrust = "0.13" nalgebra = "0.35" -tokio = { version = "1", features = ["rt", "sync"] } +swash = "0.2" tracing = "0.1" tracing-subscriber = "0.3" wgpu = "30.0" diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 08f7afc..2b7307c 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -11,7 +11,9 @@ fvi-core.workspace = true bytemuck.workspace = true etagere.workspace = true hashbrown.workspace = true +harfrust.workspace = true nalgebra.workspace = true +swash.workspace = true tracing.workspace = true wgpu.workspace = true winit.workspace = true diff --git a/crates/render/src/cache/mod.rs b/crates/render/src/cache/mod.rs index 6001afe..792194e 100644 --- a/crates/render/src/cache/mod.rs +++ b/crates/render/src/cache/mod.rs @@ -39,7 +39,7 @@ impl Segment { } } - pub fn is_sweep_required(&self) -> bool { + pub const fn is_sweep_required(&self) -> bool { self.items.len() > self.max_len } } From baa22bdf1a81a545c011b2992fb2c51f1f436e84 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 10:42:47 +0900 Subject: [PATCH 03/34] chore(render): add fontdb as dep to query fonts --- Cargo.lock | 35 +++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + crates/render/Cargo.toml | 1 + 3 files changed, 37 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index db5701e..3c56109 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,6 +836,28 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -911,6 +933,7 @@ version = "0.1.0" dependencies = [ "bytemuck", "etagere", + "fontdb", "fvi-core", "harfrust", "hashbrown 0.17.1", @@ -2270,6 +2293,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2705,6 +2734,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index fb2b587..46207b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ fvi-render = { path = "crates/render" } bytemuck = "1.25" etagere = "0.3" +fontdb = "0.24" hashbrown = "0.17" harfrust = "0.13" nalgebra = "0.35" diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 2b7307c..3976c02 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -10,6 +10,7 @@ fvi-core.workspace = true bytemuck.workspace = true etagere.workspace = true +fontdb.workspace = true hashbrown.workspace = true harfrust.workspace = true nalgebra.workspace = true From ee1dfcd8a7d6e5f84ee32088ef944769629e5d9d Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 14:42:15 +0900 Subject: [PATCH 04/34] feat(render): impl fontmap for font resolving --- crates/render/src/text/font.rs | 168 +++++++++++++++++++++++++++++++-- 1 file changed, 162 insertions(+), 6 deletions(-) diff --git a/crates/render/src/text/font.rs b/crates/render/src/text/font.rs index fb262f9..e00d0dc 100644 --- a/crates/render/src/text/font.rs +++ b/crates/render/src/text/font.rs @@ -1,9 +1,165 @@ -use std::rc::Rc; +use std::fmt::Debug; +use std::ops::Deref; +use std::sync::Arc; -/// A reference of font. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct FontRef(Rc); +pub struct FontId(fontdb::ID); -/// A font. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct Font {} +#[derive(Clone)] +pub struct FontRef<'a> { + harfrust: harfrust::FontRef<'a>, + swash: swash::FontRef<'a>, +} + +impl<'a> FontRef<'a> { + pub fn from_index(data: &'a [u8], index: u32) -> Option { + Some(Self { + harfrust: harfrust::FontRef::from_index(data, index).ok()?, + swash: swash::FontRef::from_index(data, index as usize)?, + }) + } +} + +impl Debug for FontRef<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FontRef") + .field("data", &self.swash.data) + .field("offset", &self.swash.offset) + .finish_non_exhaustive() + } +} + +impl<'a> AsRef> for FontRef<'a> { + fn as_ref(&self) -> &harfrust::FontRef<'a> { + &self.harfrust + } +} + +impl<'a> AsRef> for FontRef<'a> { + fn as_ref(&self) -> &swash::FontRef<'a> { + &self.swash + } +} + +impl<'a> From> for harfrust::FontRef<'a> { + fn from(value: FontRef<'a>) -> Self { + value.harfrust + } +} + +impl<'a> From> for swash::FontRef<'a> { + fn from(value: FontRef<'a>) -> Self { + value.swash + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Fonts { + fonts: Arc<[fontdb::ID]>, +} + +impl Fonts { + pub fn resolve(&self, map: &FontMap, text: &str) -> Option { + for &font in self.fonts.deref() { + if map + .db + .with_face_data(font, |data, index| { + let Some(font) = swash::FontRef::from_index(data, index as usize) else { + return false; + }; + + let charmap = font.charmap(); + text.chars().all(|ch| charmap.map(ch) != 0) + }) + .unwrap_or(false) + { + return Some(FontId(font)); + } + } + + None + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FontStyle { + pub weight: u16, + pub italic: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FontsDescriptor<'a> { + pub names: &'a [&'a str], + pub style: FontStyle, +} + +impl Fonts { + fn new(map: &FontMap, desc: &FontsDescriptor) -> Self { + let mut fonts = Vec::with_capacity(desc.names.len()); + for name in desc.names { + #[rustfmt::skip] + let family = match *name { + "serif" => fontdb::Family::Serif, + "sans-serif" => fontdb::Family::SansSerif, + "monospace" => fontdb::Family::Monospace, + "cursive" => fontdb::Family::Cursive, + "fantasy" => fontdb::Family::Fantasy, + name => fontdb::Family::Name(name), + }; + + let Some(font_id) = map.db.query(&fontdb::Query { + families: &[family], + weight: fontdb::Weight(desc.style.weight), + stretch: fontdb::Stretch::Normal, + style: if desc.style.italic { + fontdb::Style::Italic + } else { + fontdb::Style::Normal + }, + }) else { + continue; + }; + + fonts.push(font_id); + } + + Self { + fonts: fonts.into_boxed_slice().into(), + } + } +} + +#[derive(Debug)] +pub struct FontMap { + db: fontdb::Database, +} + +impl FontMap { + pub fn new() -> Self { + Self { + db: fontdb::Database::new(), + } + } + + pub fn resolve(&self, desc: &FontsDescriptor) -> Fonts { + Fonts::new(self, desc) + } + + pub fn with(&self, id: FontId, f: F) -> T + where + F: FnOnce(&FontRef) -> T, + { + self.db + .with_face_data(id.0, |data, index| { + let font_ref = FontRef::from_index(data, index).unwrap(); + f(&font_ref) + }) + .expect("id is not from self") + } +} + +impl Default for FontMap { + fn default() -> Self { + Self::new() + } +} From 7726b4c911fb39767afc6993480fa0e8e2d517cb Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 14:42:56 +0900 Subject: [PATCH 05/34] feat(render): create textstyle --- crates/render/src/text/style.rs | 108 ++++++-------------------------- crates/render/src/text/unit.rs | 26 ++++++++ 2 files changed, 44 insertions(+), 90 deletions(-) create mode 100644 crates/render/src/text/unit.rs diff --git a/crates/render/src/text/style.rs b/crates/render/src/text/style.rs index 41b6959..b728899 100644 --- a/crates/render/src/text/style.rs +++ b/crates/render/src/text/style.rs @@ -1,95 +1,23 @@ -use crate::Sp; -use crate::text::FontRef; - -/// A builder type of [`TextStyle`]. -#[derive(Debug)] -pub struct TextStyleBuilder { - font: FontRef, - weight: f32, - italic: bool, - size: Sp, +use crate::text::Fonts; +use crate::{Color, Sp}; + +#[derive(Clone, Debug)] +pub struct Style { + pub fonts: Fonts, + pub color: Color, + pub weight: u16, + pub italic: bool, + pub size: Sp, } -impl TextStyleBuilder { - const fn new(font: FontRef) -> Self { - Self { - font, - weight: 400.0, - italic: false, - size: Sp(12.0), - } - } - - /// Sets weight as given (default: 400.0). - #[must_use] - pub const fn weight(mut self, value: f32) -> Self { - self.weight = value; - self +impl PartialEq for Style { + fn eq(&self, other: &Self) -> bool { + self.fonts == other.fonts + && self.color.to_bits() == other.color.to_bits() + && self.weight == other.weight + && self.italic == other.italic + && self.size.0.to_bits() == other.size.0.to_bits() } - - /// Sets italic as given (default: false). - #[must_use] - pub const fn italic(mut self, value: bool) -> Self { - self.italic = value; - self - } - - /// Sets size as given (default: 12.0). - #[must_use] - pub const fn size(mut self, size: Sp) -> Self { - self.size = size; - self - } - - /// Builds [`TextStyle`]. - #[must_use] - pub fn build(self) -> TextStyle { - TextStyle { - font: self.font, - weight: self.weight.to_ne_bytes(), - italic: self.italic, - size: self.size.0.to_ne_bytes(), - } - } -} - -/// A style of glyph. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct TextStyle { - font: FontRef, - weight: [u8; 4], - italic: bool, - size: [u8; 4], } -impl TextStyle { - /// Creates a builder of [`TextStyle`]. - #[must_use] - pub const fn builder(font: FontRef) -> TextStyleBuilder { - TextStyleBuilder::new(font) - } - - /// Returns font set of `self`. - #[must_use] - pub const fn font(&self) -> &FontRef { - &self.font - } - - /// Returns weight value. - #[must_use] - pub const fn weight(&self) -> f32 { - f32::from_ne_bytes(self.weight) - } - - /// Returns whether the italic is set. - #[must_use] - pub const fn italic(&self) -> bool { - self.italic - } - - /// Returns font size. - #[must_use] - pub const fn size(&self) -> Sp { - Sp(f32::from_ne_bytes(self.size)) - } -} +impl Eq for Style {} diff --git a/crates/render/src/text/unit.rs b/crates/render/src/text/unit.rs new file mode 100644 index 0000000..9c79608 --- /dev/null +++ b/crates/render/src/text/unit.rs @@ -0,0 +1,26 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Unit(i32); + +impl Unit { + // 4 unit per 1 physical pixel + const PRECI: i32 = 4; + const PRECF: f32 = 4.0; + + pub fn fract(&self) -> Self { + Self(self.0 % Unit::PRECI) + } +} + +impl From for Unit { + fn from(value: f32) -> Self { + Self((value.fract() * Unit::PRECF) as i32) + } +} + +impl From for f32 { + fn from(value: Unit) -> Self { + let fract = (value.0 % Unit::PRECI) as f32; + let int = (value.0 / Unit::PRECI) as f32; + fract + int + } +} From 78a903a1c2d675eff8abb7021f1112133228f485 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 14:43:12 +0900 Subject: [PATCH 06/34] feat(render): impl rasterizer --- crates/render/src/text/image.rs | 52 +++++++++++++++ crates/render/src/text/raster.rs | 109 +++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 crates/render/src/text/image.rs create mode 100644 crates/render/src/text/raster.rs diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs new file mode 100644 index 0000000..e5c0d78 --- /dev/null +++ b/crates/render/src/text/image.rs @@ -0,0 +1,52 @@ +use swash::scale::image::Content; +use wgpu::*; + +use super::SwashImage; + +#[derive(Clone, Debug)] +pub struct Image<'a> { + format: TextureFormat, + width: u32, + height: u32, + data: &'a [u8], +} + +impl<'a> Image<'a> { + pub fn from(value: &'a SwashImage) -> Self { + Self { + format: match value.content { + Content::Mask => TextureFormat::R8Unorm, + Content::SubpixelMask => TextureFormat::Rgba8Unorm, + Content::Color => TextureFormat::Rgba8Unorm, + }, + width: value.placement.width, + height: value.placement.height, + data: &value.data, + } + } + + pub fn write_to(&self, queue: &Queue, dst: &Texture, origin: Origin3d) { + debug_assert_eq!((self.width * self.height) as usize, self.data.len()); + debug_assert_eq!(self.format, dst.format()); + + queue.write_texture( + TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin, + aspect: TextureAspect::All, + }, + &self.data, + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.width), + rows_per_image: Some(self.height), + }, + Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + } +} diff --git a/crates/render/src/text/raster.rs b/crates/render/src/text/raster.rs new file mode 100644 index 0000000..e0a42c8 --- /dev/null +++ b/crates/render/src/text/raster.rs @@ -0,0 +1,109 @@ +use std::fmt::Debug; +use swash::Usability; +use swash::scale::{Render, ScaleContext, Scaler, Source, StrikeWith}; +use swash::zeno::Vector; + +use super::SwashImage; +use crate::Theme; +use crate::text::FontRef; +use crate::text::image::Image; + +#[derive(Clone, Debug)] +pub struct RasterStyle<'a> { + pub font: FontRef<'a>, + pub size: f32, + pub weight: f32, + pub italic: bool, + pub hint: bool, + pub theme: Option, +} + +pub struct RasterContext { + context: ScaleContext, + buffer: SwashImage, +} + +impl RasterContext { + pub fn new() -> Self { + Self { + context: ScaleContext::new(), + buffer: SwashImage::new(), + } + } + + pub fn open<'a>(&'a mut self, style: RasterStyle<'a>) -> RasterScope<'a> { + RasterScope::new(self, style) + } +} + +impl Debug for RasterContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RasterContext").finish_non_exhaustive() + } +} + +pub struct RasterScope<'a> { + palette: Option, + scaler: Scaler<'a>, + buffer: &'a mut SwashImage, +} + +impl<'a> RasterScope<'a> { + fn new(rcx: &'a mut RasterContext, style: RasterStyle<'a>) -> Self { + Self { + palette: query_palette(&style.font, style.theme), + scaler: rcx + .context + .builder(style.font) + .size(style.size.into()) + .variations(&[("wght", style.weight), ("ital", style.italic.into())]) + .hint(style.hint) + .build(), + buffer: &mut rcx.buffer, + } + } + + pub fn rasterize(&mut self, glyph: u16, x_fract: f32, y_fract: f32) -> Option { + debug_assert_eq!(x_fract.fract(), x_fract); + debug_assert_eq!(y_fract.fract(), y_fract); + + let sources_base: [Source; 4] = [ + Source::ColorOutline(self.palette.unwrap_or(0)), + Source::Outline, + Source::ColorBitmap(StrikeWith::BestFit), + Source::Bitmap(StrikeWith::BestFit), + ]; + let sources = if self.palette.is_some() { + &sources_base + } else { + &sources_base[1..] + }; + + if !Render::new(sources) + .offset(Vector::new(x_fract, y_fract)) + .render_into(&mut self.scaler, glyph, &mut self.buffer) + { + return None; + } + + Some(Image::from(self.buffer)) + } +} + +fn query_palette<'a>(font: &'a FontRef, theme: Option) -> Option { + let font: &swash::FontRef = font.as_ref(); + + let mut best = None; + for palette in font.color_palettes() { + match (palette.usability(), theme) { + (_, None) + | (Some(Usability::Both), _) + | (Some(Usability::Light), Some(Theme::Light)) + | (Some(Usability::Dark), Some(Theme::Dark)) => return Some(palette.index()), + (None, Some(_)) => best = Some(palette.index()), + _ => continue, + } + } + + best +} From 8fd6600be81a5eee6f6da7c9965624618171860f Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 14:43:46 +0900 Subject: [PATCH 07/34] feat(render): update primitive units --- crates/render/src/lib.rs | 2 ++ crates/render/src/theme.rs | 5 +++++ crates/render/src/unit.rs | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 crates/render/src/theme.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4b8fd0e..a76acaa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod frame; mod id; mod raw; pub mod text; +mod theme; mod unit; pub(crate) use cache::*; @@ -17,6 +18,7 @@ pub use canvas::*; pub use error::*; pub use frame::*; pub use id::*; +pub use theme::*; pub use unit::*; #[path = "gfx.g.rs"] diff --git a/crates/render/src/theme.rs b/crates/render/src/theme.rs new file mode 100644 index 0000000..db03929 --- /dev/null +++ b/crates/render/src/theme.rs @@ -0,0 +1,5 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Theme { + Light, + Dark, +} diff --git a/crates/render/src/unit.rs b/crates/render/src/unit.rs index 53118f1..d0265c8 100644 --- a/crates/render/src/unit.rs +++ b/crates/render/src/unit.rs @@ -157,3 +157,44 @@ impl Sp { Dp(dp.copysign(self.0)) } } + +#[repr(C, align(16))] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Zeroable, Pod)] +pub struct Color { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Color { + pub fn to_bits(&self) -> u128 { + let r = (self.r.to_bits() as u128) << 96; + let g = (self.g.to_bits() as u128) << 64; + let b = (self.b.to_bits() as u128) << 32; + let a = (self.a.to_bits() as u128) << 0; + r | g | b | a + } +} + +impl From for Color { + fn from(value: wgpu::Color) -> Self { + Self { + r: value.r as f32, + g: value.g as f32, + b: value.b as f32, + a: value.a as f32, + } + } +} + +impl From for wgpu::Color { + fn from(value: Color) -> Self { + Self { + r: value.r as f64, + g: value.g as f64, + b: value.b as f64, + a: value.a as f64, + } + } +} From cfde95b68583d3ff2c0ea05c7ba01b934e8ae6a0 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 14:50:32 +0900 Subject: [PATCH 08/34] refactor(render): use flag instead of concrete tex format --- crates/render/src/text/image.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs index e5c0d78..b55934a 100644 --- a/crates/render/src/text/image.rs +++ b/crates/render/src/text/image.rs @@ -5,19 +5,19 @@ use super::SwashImage; #[derive(Clone, Debug)] pub struct Image<'a> { - format: TextureFormat, - width: u32, - height: u32, - data: &'a [u8], + pub colored: bool, + pub width: u32, + pub height: u32, + pub data: &'a [u8], } impl<'a> Image<'a> { pub fn from(value: &'a SwashImage) -> Self { Self { - format: match value.content { - Content::Mask => TextureFormat::R8Unorm, - Content::SubpixelMask => TextureFormat::Rgba8Unorm, - Content::Color => TextureFormat::Rgba8Unorm, + colored: match value.content { + Content::Mask => false, + Content::SubpixelMask => true, + Content::Color => true, }, width: value.placement.width, height: value.placement.height, @@ -27,7 +27,14 @@ impl<'a> Image<'a> { pub fn write_to(&self, queue: &Queue, dst: &Texture, origin: Origin3d) { debug_assert_eq!((self.width * self.height) as usize, self.data.len()); - debug_assert_eq!(self.format, dst.format()); + debug_assert_eq!( + if self.colored { + TextureFormat::Rgba8Unorm + } else { + TextureFormat::R8Unorm + }, + dst.format() + ); queue.write_texture( TexelCopyTextureInfo { From bded8957c178e2bd405f5bb1704ab77b59cc1eec Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 16:26:12 +0900 Subject: [PATCH 09/34] feat(render): reimpl atlas --- crates/render/src/text/atlas.rs | 154 ++++++++++++++++++ crates/render/src/text/mod.rs | 273 +------------------------------- 2 files changed, 161 insertions(+), 266 deletions(-) create mode 100644 crates/render/src/text/atlas.rs diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs new file mode 100644 index 0000000..441e030 --- /dev/null +++ b/crates/render/src/text/atlas.rs @@ -0,0 +1,154 @@ +use etagere::euclid::Size2D; +use etagere::{AllocId, AtlasAllocator}; +use std::fmt::Debug; +use tracing::warn; +use wgpu::*; + +use crate::label; +use crate::text::Image; + +const PAGE_SIZE: u32 = 2048; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct AtlasView { + page: usize, + id: AllocId, + pos: [u32; 2], +} + +#[derive(Debug)] +pub struct Atlas { + size: u32, + colored: bool, + pages: Vec, +} + +impl Atlas { + pub fn new(colored: bool) -> Self { + Self::with_size(PAGE_SIZE, colored) + } + + fn with_size(size: u32, colored: bool) -> Self { + Self { + size, + colored, + pages: Vec::new(), + } + } + + pub fn store(&mut self, device: &Device, queue: &Queue, image: &Image) -> AtlasView { + for (i, page) in self.pages.iter_mut().enumerate() { + if let Some(alloc) = page.store(queue, image) { + return AtlasView { + page: i, + id: alloc.id, + pos: alloc.pos, + }; + } + } + + let size = self.size.max(image.width).max(image.height); + if size != self.size { + warn!( + "too large image requested ({}x{}; {}x{} preferred)", + image.width, image.height, self.size, self.size, + ); + } + + let page_id = self.pages.len(); + let page = self.pages.push_mut(Page::new(device, size, self.colored)); + let alloc = page.store(queue, image).unwrap(); + + AtlasView { + page: page_id, + id: alloc.id, + pos: alloc.pos, + } + } + + pub fn remove(&mut self, view: AtlasView) { + self.pages[view.page].remove(view.id); + } +} + +#[derive(Debug, PartialEq, Eq, Hash)] +struct PageAlloc { + id: AllocId, + pos: [u32; 2], +} + +struct Page { + tex: Texture, + view: TextureView, + alloc: AtlasAllocator, +} + +impl Page { + pub fn new(device: &Device, size: u32, colored: bool) -> Self { + let tex = device.create_texture(&TextureDescriptor { + label: label!("tex"), + size: Extent3d { + width: size, + height: size, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: if colored { + TextureFormat::Rgba8Unorm + } else { + TextureFormat::R8Unorm + }, + usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let view = tex.create_view(&TextureViewDescriptor { + label: label!("tex"), + ..Default::default() + }); + + let alloc = AtlasAllocator::new(Size2D::new(size, size).to_i32()); + + Self { tex, view, alloc } + } + + pub fn store(&mut self, queue: &Queue, image: &Image) -> Option { + let alloc = self + .alloc + .allocate(Size2D::new(image.width, image.height).to_i32())?; + + let pos = alloc.rectangle.min.to_u32(); + + image.write_to( + queue, + &self.tex, + Origin3d { + x: pos.x, + y: pos.y, + z: 0, + }, + ); + + Some(PageAlloc { + id: alloc.id, + pos: pos.to_array(), + }) + } + + pub fn remove(&mut self, alloc: AllocId) { + self.alloc.deallocate(alloc); + } +} + +impl Debug for Page { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Atlas") + .field("tex", &self.tex) + .field("view", &self.view) + .field("used", &self.alloc.allocated_space()) + .field("free", &self.alloc.free_space()) + .finish_non_exhaustive() + } +} diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index 6a19821..4087fb5 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -1,276 +1,17 @@ //! A module for text system. -use etagere::{AllocId, AtlasAllocator}; -use std::fmt::Debug; -use tracing::warn; -use wgpu::*; - -use crate::context::{TextureMap, TextureRef}; -use crate::label; -use crate::{Cache, Dp}; - +mod atlas; mod error; mod font; +mod image; +mod raster; mod style; +mod unit; pub use error::*; pub use font::*; +use image::*; pub use style::*; +use unit::*; -/// An identifier of a styled glyph. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct GlyphKey { - id: u16, - style: TextStyle, -} - -/// A glyph. -#[derive(Clone, Debug, PartialEq)] -pub struct Glyph { - /// Atlas texture of glyph. - pub tex: TextureRef, - /// Origin coord on atlas texture, in px. - pub origin: [u32; 2], - /// Size of glyph, in dp. - pub size: [Dp; 2], - /// Offset of glyph from baseline, in dp. - pub off: [Dp; 2], - atlas: AtlasId, - alloc: AllocId, -} - -#[derive(Clone, Debug, PartialEq)] -struct GlyphData { - size: [u32; 2], - off: [f32; 2], - data: Vec, -} - -/// An identifier of an atlas. -#[repr(transparent)] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct AtlasId(u32); - -struct Atlas { - id: AtlasId, - alloc: AtlasAllocator, - tex_ref: TextureRef, - tex: TextureView, -} - -impl Atlas { - pub fn new( - device: &Device, - tex_map: &mut TextureMap, - id: AtlasId, - size: [u32; 2], - ) -> Result { - let alloc = AtlasAllocator::new( - [ - size[0].try_into().expect("too large width"), - size[1].try_into().expect("too large height"), - ] - .into(), - ); - - let tex = device - .create_texture(&TextureDescriptor { - label: label!("tex"), - size: Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: TextureDimension::D2, - format: TextureFormat::Rgba8Unorm, - usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - .create_view(&TextureViewDescriptor::default()); - - let tex_ref = tex_map.insert(id.into(), tex.clone())?; - - Ok(Self { - id, - alloc, - tex_ref, - tex, - }) - } - - fn dealloc(&mut self, alloc: AllocId) { - self.alloc.deallocate(alloc); - } - - fn try_alloc(&mut self, scale: f32, data: &GlyphData) -> Option { - let size_signed = [ - data.size[0].try_into().expect("too large width"), - data.size[1].try_into().expect("too large height"), - ]; - - let alloc = self.alloc.allocate(size_signed.into())?; - let rect = alloc.rectangle.to_u32(); - Some(Glyph { - tex: self.tex_ref, - origin: rect.min.to_array(), - #[expect(clippy::cast_precision_loss, reason = "size may not exceed 2^23")] - size: *data - .size - .map(|px| Dp::from_px(px as f32, scale)) - .as_array::<2>() - .unwrap(), - off: *data - .off - .map(|px| Dp::from_px(px, scale)) - .as_array::<2>() - .unwrap(), - atlas: self.id, - alloc: alloc.id, - }) - } - - fn clear(&mut self) { - self.alloc.clear(); - } -} - -impl Debug for Atlas { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Atlas") - .field("id", &self.id) - .field("tex_ref", &self.tex_ref) - .field("tex", &self.tex) - .finish_non_exhaustive() - } -} - -/// A set of glyph atlases. -#[derive(Debug)] -pub struct AtlasSet { - rasterizer: Rasterizer, - cache: Cache, - atlases: Vec, - atlas_size: [u32; 2], - scale: f32, -} - -impl AtlasSet { - pub(crate) fn new(scale: f32) -> Self { - Self { - rasterizer: Rasterizer::new(), - cache: Cache::new(), - atlases: Vec::new(), - atlas_size: [2048; 2], - scale, - } - } - - pub(crate) fn rescale(&mut self, scale: f32) { - if (scale - self.scale).abs() <= 0.01 { - warn!("atlas rescaling ignored"); - return; - } - - self.scale = scale; - self.cache.clear(); - - for atlas in &mut self.atlases { - atlas.clear(); - } - } - - /// Fetches a glyph from given key. - /// - /// # Panics - /// - /// This function may panic if any of conditions below met. - /// - /// - New texture failed to be allocated on device. - /// - Glyph is too big to be drawn on atlas. - pub fn fetch( - &mut self, - device: &Device, - queue: &Queue, - tex_map: &mut TextureMap, - key: GlyphKey, - ) -> &Glyph { - self.cache.fetch(key, |key| { - let data = self.rasterizer.rasterize(key); - - let mut opt = None; - for atlas in &mut self.atlases { - opt = atlas - .try_alloc(self.scale, &data) - .map(|glyph| (atlas.tex_ref, glyph)); - if opt.is_some() { - break; - } - } - let (tex_ref, glyph) = if let Some(pair) = opt { - pair - } else { - let id = AtlasId(self.atlases.len().try_into().expect("too many atlas")); - - let mut atlas = Atlas::new(device, tex_map, id, self.atlas_size) - .expect("couldn't allocate atlas"); - let tex_ref = atlas.tex_ref; - - let glyph = atlas - .try_alloc(self.scale, &data) - .expect("couldn't allocate glyph"); - self.atlases.push(atlas); - (tex_ref, glyph) - }; - - let tex = tex_map.get(tex_ref); - - queue.write_texture( - TexelCopyTextureInfo { - texture: tex.texture(), - mip_level: 0, - origin: Origin3d { - x: glyph.origin[0], - y: glyph.origin[1], - z: 0, - }, - aspect: TextureAspect::All, - }, - &data.data, - TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(data.size[0]), - rows_per_image: Some(data.size[1]), - }, - Extent3d { - width: data.size[0], - height: data.size[1], - depth_or_array_layers: 1, - }, - ); - - glyph - }) - } - - pub(crate) fn update(&mut self) { - for (_, glyph) in self.cache.sweep() { - self.atlases[glyph.atlas.0 as usize].dealloc(glyph.alloc); - } - self.cache.tick(); - } -} - -#[derive(Debug)] -struct Rasterizer {} - -impl Rasterizer { - pub fn new() -> Self { - todo!() - } - - fn rasterize(&mut self, key: &GlyphKey) -> GlyphData { - todo!() - } -} +use swash::scale::image::Image as SwashImage; From a760a7be9f9ba6bec563c98e36dfabc9f98d0293 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 16:41:12 +0900 Subject: [PATCH 10/34] feat(render): add fallible operations to cache --- crates/render/src/cache/mod.rs | 55 +++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/render/src/cache/mod.rs b/crates/render/src/cache/mod.rs index 792194e..8c69a21 100644 --- a/crates/render/src/cache/mod.rs +++ b/crates/render/src/cache/mod.rs @@ -1,4 +1,4 @@ -use hashbrown::hash_table::OccupiedEntry; +use hashbrown::hash_table::{Entry, OccupiedEntry}; use hashbrown::{DefaultHashBuilder, HashTable}; use std::hash::{BuildHasher, Hash}; @@ -72,6 +72,50 @@ impl Cache { self.table.clear(); } + fn try_fetch_entry( + &mut self, + key: K, + default: impl FnOnce(&K) -> Result, + ) -> Result { + let hash = self.hasher.hash_one(&key); + + let entry = self.table.entry( + hash, + |&other| { + let segment = &self.segments[other.loc.segment as usize]; + segment.items[other.loc.item as usize].key == key + }, + |old| old.hash, + ); + + let &entry = match entry { + Entry::Occupied(entry) => entry, + Entry::Vacant(vacant) => { + let value = default(&key)?; + + let index = self.segments[0].items.len(); + self.segments[0].items.push(Item { + epoch: self.now, + key, + value, + }); + + vacant.insert(TableEntry { + loc: ItemLoc { + segment: 0, + item: index.try_into().expect("insufficient address range"), + }, + hash, + }) + } + } + .get(); + + self.segments[entry.loc.segment as usize].items[entry.loc.item as usize].epoch = self.now; + + Ok(entry) + } + fn fetch_entry(&mut self, key: K, default: impl FnOnce(&K) -> V) -> TableEntry { let hash = self.hasher.hash_one(&key); @@ -148,6 +192,15 @@ impl Cache { (occupied, removed) } + pub fn try_fetch( + &mut self, + key: K, + default: impl FnOnce(&K) -> Result, + ) -> Result<&V, E> { + let entry = self.try_fetch_entry(key, default)?; + Ok(&self.segments[entry.loc.segment as usize].items[entry.loc.item as usize].value) + } + pub fn fetch(&mut self, key: K, default: impl FnOnce(&K) -> V) -> &V { let entry = self.fetch_entry(key, default); &self.segments[entry.loc.segment as usize].items[entry.loc.item as usize].value From 8e85540ad4a51fcb6fd68a931c99764cc3048e49 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 16:52:30 +0900 Subject: [PATCH 11/34] refactor(render): remove duplicated pos info --- crates/render/src/text/atlas.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs index 441e030..bbe610a 100644 --- a/crates/render/src/text/atlas.rs +++ b/crates/render/src/text/atlas.rs @@ -13,7 +13,6 @@ const PAGE_SIZE: u32 = 2048; pub struct AtlasView { page: usize, id: AllocId, - pos: [u32; 2], } #[derive(Debug)] @@ -42,7 +41,6 @@ impl Atlas { return AtlasView { page: i, id: alloc.id, - pos: alloc.pos, }; } } @@ -62,7 +60,6 @@ impl Atlas { AtlasView { page: page_id, id: alloc.id, - pos: alloc.pos, } } @@ -74,7 +71,6 @@ impl Atlas { #[derive(Debug, PartialEq, Eq, Hash)] struct PageAlloc { id: AllocId, - pos: [u32; 2], } struct Page { @@ -131,10 +127,7 @@ impl Page { }, ); - Some(PageAlloc { - id: alloc.id, - pos: pos.to_array(), - }) + Some(PageAlloc { id: alloc.id }) } pub fn remove(&mut self, alloc: AllocId) { From af8cff17f54a1fb179f716ed89b41bced2743b2d Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 17:27:50 +0900 Subject: [PATCH 12/34] feat(render): allow view to be read --- crates/render/src/text/atlas.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs index bbe610a..9cac6b5 100644 --- a/crates/render/src/text/atlas.rs +++ b/crates/render/src/text/atlas.rs @@ -15,6 +15,13 @@ pub struct AtlasView { id: AllocId, } +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct AtlasPart { + pub tex: TextureView, + pub pos: [u32; 2], + pub size: [u32; 2], +} + #[derive(Debug)] pub struct Atlas { size: u32, @@ -35,6 +42,21 @@ impl Atlas { } } + pub fn read(&self, view: &AtlasView) -> AtlasPart { + let rect = self.pages[view.page].alloc.get(view.id); + + AtlasPart { + tex: self.pages[view.page] + .tex + .create_view(&TextureViewDescriptor { + usage: Some(TextureUsages::TEXTURE_BINDING), + ..Default::default() + }), + pos: rect.min.to_u32().to_array(), + size: rect.size().to_u32().to_array(), + } + } + pub fn store(&mut self, device: &Device, queue: &Queue, image: &Image) -> AtlasView { for (i, page) in self.pages.iter_mut().enumerate() { if let Some(alloc) = page.store(queue, image) { From 2a8babda84746b8afe0a86fe0d29c3c072f0984c Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 17:28:35 +0900 Subject: [PATCH 13/34] refactor(render): return result instead of option for fallible operation --- crates/render/src/text/error.rs | 3 +++ crates/render/src/text/raster.rs | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/render/src/text/error.rs b/crates/render/src/text/error.rs index eab36fa..bef8566 100644 --- a/crates/render/src/text/error.rs +++ b/crates/render/src/text/error.rs @@ -3,6 +3,9 @@ use std::fmt::Display; use crate::context::InsertionError; +#[derive(Debug)] +pub struct RasterizationError; + /// An error from atlas. #[derive(Debug)] pub enum AtlasError { diff --git a/crates/render/src/text/raster.rs b/crates/render/src/text/raster.rs index e0a42c8..9ded131 100644 --- a/crates/render/src/text/raster.rs +++ b/crates/render/src/text/raster.rs @@ -5,8 +5,7 @@ use swash::zeno::Vector; use super::SwashImage; use crate::Theme; -use crate::text::FontRef; -use crate::text::image::Image; +use crate::text::{FontRef, Image, RasterizationError}; #[derive(Clone, Debug)] pub struct RasterStyle<'a> { @@ -63,7 +62,12 @@ impl<'a> RasterScope<'a> { } } - pub fn rasterize(&mut self, glyph: u16, x_fract: f32, y_fract: f32) -> Option { + pub fn rasterize( + &mut self, + glyph: u16, + x_fract: f32, + y_fract: f32, + ) -> Result { debug_assert_eq!(x_fract.fract(), x_fract); debug_assert_eq!(y_fract.fract(), y_fract); @@ -83,10 +87,10 @@ impl<'a> RasterScope<'a> { .offset(Vector::new(x_fract, y_fract)) .render_into(&mut self.scaler, glyph, &mut self.buffer) { - return None; + return Err(RasterizationError); } - Some(Image::from(self.buffer)) + Ok(Image::from(self.buffer)) } } From 87735e0a422872c4f2218e86432a1ab3515a12e5 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 17:28:49 +0900 Subject: [PATCH 14/34] refactor(render): make font id copyable --- crates/render/src/text/font.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/text/font.rs b/crates/render/src/text/font.rs index e00d0dc..3ead029 100644 --- a/crates/render/src/text/font.rs +++ b/crates/render/src/text/font.rs @@ -2,7 +2,7 @@ use std::fmt::Debug; use std::ops::Deref; use std::sync::Arc; -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct FontId(fontdb::ID); #[derive(Clone)] From 44cae3cd8cf7423dd1d2d15a4e27f427eac14ed2 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 17:29:14 +0900 Subject: [PATCH 15/34] feat(render): impl glyph cache --- crates/render/src/text/cache.rs | 149 ++++++++++++++++++++++++++++++++ crates/render/src/text/mod.rs | 4 + 2 files changed, 153 insertions(+) create mode 100644 crates/render/src/text/cache.rs diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs new file mode 100644 index 0000000..74f200e --- /dev/null +++ b/crates/render/src/text/cache.rs @@ -0,0 +1,149 @@ +use wgpu::*; + +use crate::text::*; +use crate::{Cache, Theme}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct GlyphStyle { + pub size: Unit, + pub weight: Unit, + pub italic: bool, + pub hint: bool, + pub x_fract: Unit, + pub y_fract: Unit, + pub theme: Option, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +struct GlyphKey { + font: FontId, + glyph: u16, + style: GlyphStyle, +} + +#[derive(Debug)] +struct GlyphData { + colored: bool, + view: AtlasView, +} + +#[derive(Debug)] +pub struct GlyphCache { + cache: Cache, + atlas: Atlas, + colored_atlas: Atlas, + rcx: RasterContext, +} + +impl GlyphCache { + pub fn new() -> Self { + Self { + cache: Cache::new(), + atlas: Atlas::new(false), + colored_atlas: Atlas::new(true), + rcx: RasterContext::new(), + } + } + + pub fn fetch_batch( + &mut self, + device: &Device, + queue: &Queue, + font_map: &FontMap, + font_id: FontId, + style: GlyphStyle, + gids: impl Iterator, + ) -> Result, RasterizationError> { + let mut buf = Vec::new(); + self.fetch_batch_into(device, queue, font_map, font_id, style, &mut buf, gids)?; + Ok(buf) + } + + pub fn fetch_batch_into( + &mut self, + device: &Device, + queue: &Queue, + font_map: &FontMap, + font_id: FontId, + style: GlyphStyle, + buf: &mut Vec, + gids: impl Iterator, + ) -> Result<(), RasterizationError> { + font_map.with(font_id, |font| { + self.fetch_batch_into_unchecked(device, queue, font, font_id, style, buf, gids) + }) + } + + fn fetch_batch_into_unchecked( + &mut self, + device: &Device, + queue: &Queue, + font: &FontRef, + font_id: FontId, + style: GlyphStyle, + buf: &mut Vec, + gids: impl Iterator, + ) -> Result<(), RasterizationError> { + buf.reserve(gids.size_hint().0); + + let mut scope = self.rcx.open(RasterStyle { + font: font.clone(), + // use normalized values + size: style.size.into(), + weight: style.weight.into(), + italic: style.italic, + hint: style.hint, + theme: style.theme, + }); + + for gid in gids { + let data = self.cache.try_fetch( + GlyphKey { + font: font_id, + glyph: gid, + style, + }, + |key| { + let image = scope.rasterize( + key.glyph, + key.style.x_fract.into(), + key.style.y_fract.into(), + )?; + + let view = if image.colored { + self.colored_atlas.store(device, queue, &image) + } else { + self.atlas.store(device, queue, &image) + }; + + Ok(GlyphData { + colored: image.colored, + view, + }) + }, + )?; + + let part = if data.colored { + self.colored_atlas.read(&data.view) + } else { + self.atlas.read(&data.view) + }; + + buf.push(part); + } + + Ok(()) + } + + pub fn update(&mut self) { + for (_, data) in self.cache.sweep() { + if data.colored { + self.colored_atlas.remove(data.view); + } else { + self.atlas.remove(data.view); + } + } + + self.cache.tick(); + } +} diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index 4087fb5..e264b4b 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -1,6 +1,7 @@ //! A module for text system. mod atlas; +mod cache; mod error; mod font; mod image; @@ -8,9 +9,12 @@ mod raster; mod style; mod unit; +use atlas::*; +use cache::*; pub use error::*; pub use font::*; use image::*; +use raster::*; pub use style::*; use unit::*; From 3b2e5bc4ff7b3462ff7fd5423949e7defed366af Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 18:38:34 +0900 Subject: [PATCH 16/34] feat(render): embed glyph cx into render cx --- crates/render/src/context/mod.rs | 19 +++++-------------- crates/render/src/context/texture.rs | 9 --------- crates/render/src/text/mod.rs | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/crates/render/src/context/mod.rs b/crates/render/src/context/mod.rs index 280c69d..cb7155d 100644 --- a/crates/render/src/context/mod.rs +++ b/crates/render/src/context/mod.rs @@ -8,7 +8,7 @@ use winit::dpi::PhysicalSize; use winit::window::Window; use crate::gfx::blit; -use crate::text::AtlasSet; +use crate::text::GlyphContext; use crate::{Frame, InitError, RenderError, label}; use crate::{FrameDescriptor, config::*}; @@ -33,7 +33,7 @@ pub struct RenderContext { surface: Surface<'static>, pub(crate) device: RenderDevice, pub(crate) texture_map: TextureMap, - pub(crate) atlas_set: AtlasSet, + pub(crate) glyph_cx: GlyphContext, format: TextureFormat, pipeline: RenderPipeline, } @@ -103,11 +103,7 @@ impl RenderContext { cache: None, }); - #[expect( - clippy::cast_possible_truncation, - reason = "that much precision is unnecessary for glyph rasterization" - )] - let atlas_set = AtlasSet::new(window.scale_factor() as f32); + let glyph_cx = GlyphContext::new(); let this = Self { config, @@ -118,7 +114,7 @@ impl RenderContext { surface, device, texture_map, - atlas_set, + glyph_cx, format, pipeline, }; @@ -206,11 +202,6 @@ impl RenderContext { self.configure_surface(); } - /// Rescales as given. - pub fn rescale(&mut self, scale: f32) { - self.atlas_set.rescale(scale); - } - /// Creates a new [`Frame`] with its descriptor. pub fn create_frame(&self, desc: &FrameDescriptor) -> Frame { Frame::new(&self.device, desc) @@ -349,6 +340,6 @@ impl RenderContext { /// Advances a tick to manage resources. pub fn update(&mut self) { - self.atlas_set.update(); + self.glyph_cx.update(); } } diff --git a/crates/render/src/context/texture.rs b/crates/render/src/context/texture.rs index 8c2c298..50c7a53 100644 --- a/crates/render/src/context/texture.rs +++ b/crates/render/src/context/texture.rs @@ -7,7 +7,6 @@ use wgpu::util::*; use wgpu::*; use crate::label; -use crate::text::AtlasId; /// A key for internal textures. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -23,8 +22,6 @@ impl InternalTextureId { pub enum TextureId { /// An internal texture. Internal(InternalTextureId), - /// An atlas texture. - Atlas(AtlasId), } impl From for TextureId { @@ -33,12 +30,6 @@ impl From for TextureId { } } -impl From for TextureId { - fn from(value: AtlasId) -> Self { - Self::Atlas(value) - } -} - /// An opaque reference to texture. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct TextureRef(u32); diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index e264b4b..11a2f84 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -19,3 +19,22 @@ pub use style::*; use unit::*; use swash::scale::image::Image as SwashImage; + +#[derive(Debug)] +pub(crate) struct GlyphContext { + cache: GlyphCache, + fonts: FontMap, +} + +impl GlyphContext { + pub fn new() -> Self { + Self { + cache: GlyphCache::new(), + fonts: FontMap::new(), + } + } + + pub fn update(&mut self) { + self.cache.update(); + } +} From 43c58d1b287e655427e330a26181378f5f1bcc12 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 18:42:59 +0900 Subject: [PATCH 17/34] refactor(render): fix clippy warnings --- crates/render/src/theme.rs | 3 +++ crates/render/src/unit.rs | 24 ++++++++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/render/src/theme.rs b/crates/render/src/theme.rs index db03929..e3a0142 100644 --- a/crates/render/src/theme.rs +++ b/crates/render/src/theme.rs @@ -1,5 +1,8 @@ +/// A theme. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Theme { + /// Light mode. Light, + /// Dark mode. Dark, } diff --git a/crates/render/src/unit.rs b/crates/render/src/unit.rs index d0265c8..d8ca46a 100644 --- a/crates/render/src/unit.rs +++ b/crates/render/src/unit.rs @@ -158,26 +158,34 @@ impl Sp { } } +/// A straight RGBA32 color. #[repr(C, align(16))] #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Zeroable, Pod)] pub struct Color { + /// Red channel of the color. pub r: f32, + /// Green channel of the color. pub g: f32, + /// Blue channel of the color. pub b: f32, + /// Alpha channel of the color. pub a: f32, } impl Color { + /// Raw transmutation to `u128`. + #[must_use] pub fn to_bits(&self) -> u128 { - let r = (self.r.to_bits() as u128) << 96; - let g = (self.g.to_bits() as u128) << 64; - let b = (self.b.to_bits() as u128) << 32; - let a = (self.a.to_bits() as u128) << 0; + let r = u128::from(self.r.to_bits()) << 96; + let g = u128::from(self.g.to_bits()) << 64; + let b = u128::from(self.b.to_bits()) << 32; + let a = u128::from(self.a.to_bits()); r | g | b | a } } impl From for Color { + #[expect(clippy::cast_possible_truncation, reason = "intended loss")] fn from(value: wgpu::Color) -> Self { Self { r: value.r as f32, @@ -191,10 +199,10 @@ impl From for Color { impl From for wgpu::Color { fn from(value: Color) -> Self { Self { - r: value.r as f64, - g: value.g as f64, - b: value.b as f64, - a: value.a as f64, + r: value.r.into(), + g: value.g.into(), + b: value.b.into(), + a: value.a.into(), } } } From 039b6056363231c8502c1e69b5cb877876a89794 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 19:40:16 +0900 Subject: [PATCH 18/34] refactor(render): fix clippy warnings --- crates/render/src/text/atlas.rs | 5 +++-- crates/render/src/text/cache.rs | 2 +- crates/render/src/text/image.rs | 3 +-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs index 9cac6b5..e39777e 100644 --- a/crates/render/src/text/atlas.rs +++ b/crates/render/src/text/atlas.rs @@ -30,11 +30,11 @@ pub struct Atlas { } impl Atlas { - pub fn new(colored: bool) -> Self { + pub const fn new(colored: bool) -> Self { Self::with_size(PAGE_SIZE, colored) } - fn with_size(size: u32, colored: bool) -> Self { + const fn with_size(size: u32, colored: bool) -> Self { Self { size, colored, @@ -85,6 +85,7 @@ impl Atlas { } } + #[expect(clippy::needless_pass_by_value, reason = "intended lifetime")] pub fn remove(&mut self, view: AtlasView) { self.pages[view.page].remove(view.id); } diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs index 74f200e..e2f769a 100644 --- a/crates/render/src/text/cache.rs +++ b/crates/render/src/text/cache.rs @@ -4,7 +4,7 @@ use crate::text::*; use crate::{Cache, Theme}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -struct GlyphStyle { +pub struct GlyphStyle { pub size: Unit, pub weight: Unit, pub italic: bool, diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs index b55934a..47f06dc 100644 --- a/crates/render/src/text/image.rs +++ b/crates/render/src/text/image.rs @@ -16,8 +16,7 @@ impl<'a> Image<'a> { Self { colored: match value.content { Content::Mask => false, - Content::SubpixelMask => true, - Content::Color => true, + Content::SubpixelMask | Content::Color => true, }, width: value.placement.width, height: value.placement.height, From 0cda816e6b9138ee35d9e6842ce8688ae25a4c34 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 20:30:36 +0900 Subject: [PATCH 19/34] feat(render): use swash for precise font resolving --- crates/render/src/text/font.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/render/src/text/font.rs b/crates/render/src/text/font.rs index 3ead029..b626b64 100644 --- a/crates/render/src/text/font.rs +++ b/crates/render/src/text/font.rs @@ -1,6 +1,7 @@ use std::fmt::Debug; use std::ops::Deref; use std::sync::Arc; +use swash::text::cluster::{CharCluster, Status}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct FontId(fontdb::ID); @@ -59,25 +60,28 @@ pub struct Fonts { } impl Fonts { - pub fn resolve(&self, map: &FontMap, text: &str) -> Option { + pub fn resolve(&self, map: &FontMap, cluster: &mut CharCluster) -> Option { + let mut best = None; for &font in self.fonts.deref() { - if map + match map .db .with_face_data(font, |data, index| { let Some(font) = swash::FontRef::from_index(data, index as usize) else { - return false; + return Status::Discard; }; let charmap = font.charmap(); - text.chars().all(|ch| charmap.map(ch) != 0) + cluster.map(|ch| charmap.map(ch)) }) - .unwrap_or(false) + .unwrap_or(Status::Discard) { - return Some(FontId(font)); + Status::Complete => return Some(FontId(font)), + Status::Keep => best = Some(FontId(font)), + Status::Discard => {} } } - None + best } } From bd877e3f280c4faccb72b85aca934fa25946403b Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 20:46:23 +0900 Subject: [PATCH 20/34] fix(render): fix int-drop issue of unit --- crates/render/src/text/unit.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/render/src/text/unit.rs b/crates/render/src/text/unit.rs index 9c79608..da62a28 100644 --- a/crates/render/src/text/unit.rs +++ b/crates/render/src/text/unit.rs @@ -5,15 +5,11 @@ impl Unit { // 4 unit per 1 physical pixel const PRECI: i32 = 4; const PRECF: f32 = 4.0; - - pub fn fract(&self) -> Self { - Self(self.0 % Unit::PRECI) - } } impl From for Unit { fn from(value: f32) -> Self { - Self((value.fract() * Unit::PRECF) as i32) + Self((value * Unit::PRECF).round() as i32) } } @@ -21,6 +17,19 @@ impl From for f32 { fn from(value: Unit) -> Self { let fract = (value.0 % Unit::PRECI) as f32; let int = (value.0 / Unit::PRECI) as f32; - fract + int + fract / Unit::PRECF + int + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_samples() { + assert_eq!(Unit::from(0.1), Unit(0)); + assert_eq!(Unit::from(1.1), Unit(4)); + assert_eq!(Unit::from(0.2), Unit(1)); + assert_eq!(Unit::from(4.4), Unit(18)); } } From 010b896b320c93546db5e59bf33df0de8ea77644 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 20:50:50 +0900 Subject: [PATCH 21/34] fix(render): ignore empty image --- crates/render/src/text/atlas.rs | 2 ++ crates/render/src/text/cache.rs | 26 +++++++++++++++++--------- crates/render/src/text/image.rs | 11 ++++++++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs index e39777e..5780f8b 100644 --- a/crates/render/src/text/atlas.rs +++ b/crates/render/src/text/atlas.rs @@ -58,6 +58,8 @@ impl Atlas { } pub fn store(&mut self, device: &Device, queue: &Queue, image: &Image) -> AtlasView { + assert!(!image.is_empty(), "cannot store empty image in atlas"); + for (i, page) in self.pages.iter_mut().enumerate() { if let Some(alloc) = page.store(queue, image) { return AtlasView { diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs index e2f769a..dbb2eff 100644 --- a/crates/render/src/text/cache.rs +++ b/crates/render/src/text/cache.rs @@ -29,7 +29,7 @@ struct GlyphData { #[derive(Debug)] pub struct GlyphCache { - cache: Cache, + cache: Cache>, atlas: Atlas, colored_atlas: Atlas, rcx: RasterContext, @@ -110,26 +110,32 @@ impl GlyphCache { key.style.y_fract.into(), )?; + if image.is_empty() { + return Ok(None); + } + let view = if image.colored { self.colored_atlas.store(device, queue, &image) } else { self.atlas.store(device, queue, &image) }; - Ok(GlyphData { + Ok(Some(GlyphData { colored: image.colored, view, - }) + })) }, )?; - let part = if data.colored { - self.colored_atlas.read(&data.view) - } else { - self.atlas.read(&data.view) - }; + if let Some(data) = data { + let part = if data.colored { + self.colored_atlas.read(&data.view) + } else { + self.atlas.read(&data.view) + }; - buf.push(part); + buf.push(part); + } } Ok(()) @@ -137,6 +143,8 @@ impl GlyphCache { pub fn update(&mut self) { for (_, data) in self.cache.sweep() { + let Some(data) = data else { continue }; + if data.colored { self.colored_atlas.remove(data.view); } else { diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs index 47f06dc..1a8cf28 100644 --- a/crates/render/src/text/image.rs +++ b/crates/render/src/text/image.rs @@ -24,6 +24,11 @@ impl<'a> Image<'a> { } } + #[must_use] + pub const fn is_empty(&self) -> bool { + self.width == 0 || self.height == 0 || self.data.is_empty() + } + pub fn write_to(&self, queue: &Queue, dst: &Texture, origin: Origin3d) { debug_assert_eq!((self.width * self.height) as usize, self.data.len()); debug_assert_eq!( @@ -35,6 +40,10 @@ impl<'a> Image<'a> { dst.format() ); + if self.is_empty() { + return; + } + queue.write_texture( TexelCopyTextureInfo { texture: dst, @@ -42,7 +51,7 @@ impl<'a> Image<'a> { origin, aspect: TextureAspect::All, }, - &self.data, + self.data, TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(self.width), From 6cc966287ee95a8a07467682e60dfbe87dae6a32 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 20:51:54 +0900 Subject: [PATCH 22/34] fix(render): fix image stride --- crates/render/src/text/image.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs index 1a8cf28..639eeda 100644 --- a/crates/render/src/text/image.rs +++ b/crates/render/src/text/image.rs @@ -54,7 +54,7 @@ impl<'a> Image<'a> { self.data, TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(self.width), + bytes_per_row: Some(self.width * if self.colored { 4 } else { 1 }), rows_per_image: Some(self.height), }, Extent3d { From 4e4be1b86c6fcafe734c083bf5451541c975e128 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 20:57:52 +0900 Subject: [PATCH 23/34] refactor(render): fix clippy warnings --- crates/render/src/text/mod.rs | 15 +++++++++++++++ crates/render/src/text/raster.rs | 21 ++++++++++++--------- crates/render/src/text/unit.rs | 14 +++++++++++--- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index 11a2f84..d177696 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -6,6 +6,7 @@ mod error; mod font; mod image; mod raster; +mod shape; mod style; mod unit; @@ -38,3 +39,17 @@ impl GlyphContext { self.cache.update(); } } + +#[derive(Clone, Debug)] +pub struct Text { + string: String, +} + +impl Text { + #[must_use] + pub fn new() -> Self { + Self { + string: String::new(), + } + } +} diff --git a/crates/render/src/text/raster.rs b/crates/render/src/text/raster.rs index 9ded131..447f073 100644 --- a/crates/render/src/text/raster.rs +++ b/crates/render/src/text/raster.rs @@ -5,13 +5,13 @@ use swash::zeno::Vector; use super::SwashImage; use crate::Theme; -use crate::text::{FontRef, Image, RasterizationError}; +use crate::text::{FontRef, Image, RasterizationError, Unit}; #[derive(Clone, Debug)] pub struct RasterStyle<'a> { pub font: FontRef<'a>, - pub size: f32, - pub weight: f32, + pub size: Unit, + pub weight: Unit, pub italic: bool, pub hint: bool, pub theme: Option, @@ -55,7 +55,7 @@ impl<'a> RasterScope<'a> { .context .builder(style.font) .size(style.size.into()) - .variations(&[("wght", style.weight), ("ital", style.italic.into())]) + .variations(&[("wght", style.weight.into()), ("ital", style.italic.into())]) .hint(style.hint) .build(), buffer: &mut rcx.buffer, @@ -67,9 +67,12 @@ impl<'a> RasterScope<'a> { glyph: u16, x_fract: f32, y_fract: f32, - ) -> Result { - debug_assert_eq!(x_fract.fract(), x_fract); - debug_assert_eq!(y_fract.fract(), y_fract); + ) -> Result, RasterizationError> { + { + #![expect(clippy::float_cmp, reason = ".fract() always returns exact result")] + debug_assert_eq!(x_fract.fract(), x_fract); + debug_assert_eq!(y_fract.fract(), y_fract); + } let sources_base: [Source; 4] = [ Source::ColorOutline(self.palette.unwrap_or(0)), @@ -85,7 +88,7 @@ impl<'a> RasterScope<'a> { if !Render::new(sources) .offset(Vector::new(x_fract, y_fract)) - .render_into(&mut self.scaler, glyph, &mut self.buffer) + .render_into(&mut self.scaler, glyph, self.buffer) { return Err(RasterizationError); } @@ -94,7 +97,7 @@ impl<'a> RasterScope<'a> { } } -fn query_palette<'a>(font: &'a FontRef, theme: Option) -> Option { +fn query_palette(font: &FontRef, theme: Option) -> Option { let font: &swash::FontRef = font.as_ref(); let mut best = None; diff --git a/crates/render/src/text/unit.rs b/crates/render/src/text/unit.rs index da62a28..9408d3b 100644 --- a/crates/render/src/text/unit.rs +++ b/crates/render/src/text/unit.rs @@ -9,14 +9,22 @@ impl Unit { impl From for Unit { fn from(value: f32) -> Self { - Self((value * Unit::PRECF).round() as i32) + #[expect(clippy::cast_possible_truncation, reason = "created from i32")] + Self((value * Self::PRECF).round() as i32) } } impl From for f32 { fn from(value: Unit) -> Self { - let fract = (value.0 % Unit::PRECI) as f32; - let int = (value.0 / Unit::PRECI) as f32; + #![expect( + clippy::cast_precision_loss, + reason = r" + precision loss is acceptable for glyph cache keys; + if a property goes larger than 2^23, difference will not be seen by user + " + )] + let fract = (value.0 % Unit::PRECI) as Self; + let int = (value.0 / Unit::PRECI) as Self; fract / Unit::PRECF + int } } From c7cfcf4b79e785631fb29028f5b100d91765710a Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 20:58:34 +0900 Subject: [PATCH 24/34] fix(render): clear buffer before reuse --- crates/render/src/text/raster.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/render/src/text/raster.rs b/crates/render/src/text/raster.rs index 447f073..d3d1413 100644 --- a/crates/render/src/text/raster.rs +++ b/crates/render/src/text/raster.rs @@ -86,6 +86,8 @@ impl<'a> RasterScope<'a> { &sources_base[1..] }; + self.buffer.clear(); + if !Render::new(sources) .offset(Vector::new(x_fract, y_fract)) .render_into(&mut self.scaler, glyph, self.buffer) From 3cd02a374f92f6e41e0e6eb46e5b3c64e333a4cc Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:02:10 +0900 Subject: [PATCH 25/34] fix(render): retain bitmap size to ignore allocation padding --- crates/render/src/text/atlas.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs index 5780f8b..0135466 100644 --- a/crates/render/src/text/atlas.rs +++ b/crates/render/src/text/atlas.rs @@ -13,6 +13,7 @@ const PAGE_SIZE: u32 = 2048; pub struct AtlasView { page: usize, id: AllocId, + size: [u32; 2], } #[derive(Debug, PartialEq, Eq, Hash)] @@ -45,6 +46,11 @@ impl Atlas { pub fn read(&self, view: &AtlasView) -> AtlasPart { let rect = self.pages[view.page].alloc.get(view.id); + let size = rect.size().to_u32().to_array(); + + debug_assert!(size[0] >= view.size[0]); + debug_assert!(size[1] >= view.size[1]); + AtlasPart { tex: self.pages[view.page] .tex @@ -53,7 +59,7 @@ impl Atlas { ..Default::default() }), pos: rect.min.to_u32().to_array(), - size: rect.size().to_u32().to_array(), + size: view.size, } } @@ -65,6 +71,7 @@ impl Atlas { return AtlasView { page: i, id: alloc.id, + size: [image.width, image.height], }; } } @@ -84,6 +91,7 @@ impl Atlas { AtlasView { page: page_id, id: alloc.id, + size: [image.width, image.height], } } From 0690033835f98e403cd2660f14861b834e5b741f Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:04:50 +0900 Subject: [PATCH 26/34] fix(render): retain cache segment --- crates/render/src/cache/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/render/src/cache/mod.rs b/crates/render/src/cache/mod.rs index 8c69a21..0a2e993 100644 --- a/crates/render/src/cache/mod.rs +++ b/crates/render/src/cache/mod.rs @@ -68,7 +68,9 @@ impl Cache { } pub fn clear(&mut self) { - self.segments.clear(); + for segment in &mut self.segments { + segment.items.clear(); + } self.table.clear(); } From c5d26969cb752ba299c572612cdd4c99583783f4 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:10:43 +0900 Subject: [PATCH 27/34] fix(render): fix cell size mismatch in assertion --- crates/render/src/text/image.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs index 639eeda..bf232fe 100644 --- a/crates/render/src/text/image.rs +++ b/crates/render/src/text/image.rs @@ -29,8 +29,15 @@ impl<'a> Image<'a> { self.width == 0 || self.height == 0 || self.data.is_empty() } + const fn px_size(&self) -> u32 { + if self.colored { 4 } else { 1 } + } + pub fn write_to(&self, queue: &Queue, dst: &Texture, origin: Origin3d) { - debug_assert_eq!((self.width * self.height) as usize, self.data.len()); + debug_assert_eq!( + (self.width * self.height * self.px_size()) as usize, + self.data.len() + ); debug_assert_eq!( if self.colored { TextureFormat::Rgba8Unorm @@ -54,7 +61,7 @@ impl<'a> Image<'a> { self.data, TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(self.width * if self.colored { 4 } else { 1 }), + bytes_per_row: Some(self.width * self.px_size()), rows_per_image: Some(self.height), }, Extent3d { From 41658dff456732b8ad669d84bbe6fa0edf343e98 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:12:30 +0900 Subject: [PATCH 28/34] fix(render): tighten adapter limits check --- crates/render/src/context/adapter.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/render/src/context/adapter.rs b/crates/render/src/context/adapter.rs index 0f41aa7..7495d7e 100644 --- a/crates/render/src/context/adapter.rs +++ b/crates/render/src/context/adapter.rs @@ -8,11 +8,12 @@ fn is_compatible(adapter: &Adapter, surface: &Surface, config: &RenderConfig) -> let mut is_compat = true; let limits = adapter.limits(); - let limits_mask = config.limits.clone().or_better_values_from(&limits); - limits.check_limits_with_fail_fn(&limits_mask, false, |limit_name, actual, req| { - is_compat = false; - trace!("{name}: {limit_name}={actual}, {req} required"); - }); + config + .limits + .check_limits_with_fail_fn(&limits, false, |limit_name, req, actual| { + is_compat = false; + trace!("{name}: {limit_name}={actual}, {req} required"); + }); let feat_diff = config.features.difference(adapter.features()); if !feat_diff.is_empty() { From 87ef8e0f09ebea3e173d8beffd811bffc3dfa069 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:18:24 +0900 Subject: [PATCH 29/34] feat(render): use quantized decimal for rasterization --- crates/render/src/text/cache.rs | 11 +++-------- crates/render/src/text/raster.rs | 13 +++++-------- crates/render/src/text/unit.rs | 4 ++++ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs index dbb2eff..3bed76f 100644 --- a/crates/render/src/text/cache.rs +++ b/crates/render/src/text/cache.rs @@ -88,9 +88,8 @@ impl GlyphCache { let mut scope = self.rcx.open(RasterStyle { font: font.clone(), - // use normalized values - size: style.size.into(), - weight: style.weight.into(), + size: style.size, + weight: style.weight, italic: style.italic, hint: style.hint, theme: style.theme, @@ -104,11 +103,7 @@ impl GlyphCache { style, }, |key| { - let image = scope.rasterize( - key.glyph, - key.style.x_fract.into(), - key.style.y_fract.into(), - )?; + let image = scope.rasterize(key.glyph, key.style.x_fract, key.style.y_fract)?; if image.is_empty() { return Ok(None); diff --git a/crates/render/src/text/raster.rs b/crates/render/src/text/raster.rs index d3d1413..80b921a 100644 --- a/crates/render/src/text/raster.rs +++ b/crates/render/src/text/raster.rs @@ -65,14 +65,11 @@ impl<'a> RasterScope<'a> { pub fn rasterize( &mut self, glyph: u16, - x_fract: f32, - y_fract: f32, + x_fract: Unit, + y_fract: Unit, ) -> Result, RasterizationError> { - { - #![expect(clippy::float_cmp, reason = ".fract() always returns exact result")] - debug_assert_eq!(x_fract.fract(), x_fract); - debug_assert_eq!(y_fract.fract(), y_fract); - } + debug_assert_eq!(x_fract.fract(), x_fract); + debug_assert_eq!(y_fract.fract(), y_fract); let sources_base: [Source; 4] = [ Source::ColorOutline(self.palette.unwrap_or(0)), @@ -89,7 +86,7 @@ impl<'a> RasterScope<'a> { self.buffer.clear(); if !Render::new(sources) - .offset(Vector::new(x_fract, y_fract)) + .offset(Vector::new(x_fract.into(), y_fract.into())) .render_into(&mut self.scaler, glyph, self.buffer) { return Err(RasterizationError); diff --git a/crates/render/src/text/unit.rs b/crates/render/src/text/unit.rs index 9408d3b..46c34b2 100644 --- a/crates/render/src/text/unit.rs +++ b/crates/render/src/text/unit.rs @@ -5,6 +5,10 @@ impl Unit { // 4 unit per 1 physical pixel const PRECI: i32 = 4; const PRECF: f32 = 4.0; + + pub const fn fract(self) -> Self { + Self(self.0 - (self.0 % Self::PRECI)) + } } impl From for Unit { From 8e64e543ca6bcbd1bca0499c281948dc9324d243 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:20:27 +0900 Subject: [PATCH 30/34] fix(render): fix fract impl of unit --- crates/render/src/text/unit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/text/unit.rs b/crates/render/src/text/unit.rs index 46c34b2..c1e77f0 100644 --- a/crates/render/src/text/unit.rs +++ b/crates/render/src/text/unit.rs @@ -7,7 +7,7 @@ impl Unit { const PRECF: f32 = 4.0; pub const fn fract(self) -> Self { - Self(self.0 - (self.0 % Self::PRECI)) + Self(self.0 % Self::PRECI) } } From b8a5303fba30fad3a4a179417623c1da9eb2780b Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 21:31:10 +0900 Subject: [PATCH 31/34] refactor(render): document text api --- crates/render/src/text/error.rs | 7 +++++++ crates/render/src/text/font.rs | 31 ++++++++++++++++++++++++------- crates/render/src/text/mod.rs | 2 ++ crates/render/src/text/style.rs | 6 ++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/render/src/text/error.rs b/crates/render/src/text/error.rs index bef8566..489e0d6 100644 --- a/crates/render/src/text/error.rs +++ b/crates/render/src/text/error.rs @@ -3,9 +3,16 @@ use std::fmt::Display; use crate::context::InsertionError; +/// An error during rasterization. #[derive(Debug)] pub struct RasterizationError; +impl Display for RasterizationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("failed to rasterize glyph") + } +} + /// An error from atlas. #[derive(Debug)] pub enum AtlasError { diff --git a/crates/render/src/text/font.rs b/crates/render/src/text/font.rs index b626b64..b2c95ff 100644 --- a/crates/render/src/text/font.rs +++ b/crates/render/src/text/font.rs @@ -1,13 +1,20 @@ use std::fmt::Debug; -use std::ops::Deref; use std::sync::Arc; use swash::text::cluster::{CharCluster, Status}; +#[expect( + clippy::redundant_pub_crate, + reason = "it's crate-scoped although parent is pub" +)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct FontId(fontdb::ID); +pub(crate) struct FontId(fontdb::ID); +#[expect( + clippy::redundant_pub_crate, + reason = "it's crate-scoped although parent is pub" +)] #[derive(Clone)] -pub struct FontRef<'a> { +pub(crate) struct FontRef<'a> { harfrust: harfrust::FontRef<'a>, swash: swash::FontRef<'a>, } @@ -54,15 +61,16 @@ impl<'a> From> for swash::FontRef<'a> { } } +/// A set of fonts. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Fonts { fonts: Arc<[fontdb::ID]>, } impl Fonts { - pub fn resolve(&self, map: &FontMap, cluster: &mut CharCluster) -> Option { + pub(crate) fn resolve(&self, map: &FontMap, cluster: &mut CharCluster) -> Option { let mut best = None; - for &font in self.fonts.deref() { + for &font in &*self.fonts { match map .db .with_face_data(font, |data, index| { @@ -85,15 +93,21 @@ impl Fonts { } } +/// A style of the font. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct FontStyle { + /// Font weight. pub weight: u16, + /// Whether to set italic. pub italic: bool, } +/// A descriptor of [`Fonts`]. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct FontsDescriptor<'a> { + /// Names of fonts. pub names: &'a [&'a str], + /// Font style. pub style: FontStyle, } @@ -133,23 +147,26 @@ impl Fonts { } } +/// A map of available fonts. #[derive(Debug)] pub struct FontMap { db: fontdb::Database, } impl FontMap { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { db: fontdb::Database::new(), } } + /// Resolves a [`FontsDescriptor`] to [`Fonts`]. + #[must_use] pub fn resolve(&self, desc: &FontsDescriptor) -> Fonts { Fonts::new(self, desc) } - pub fn with(&self, id: FontId, f: F) -> T + pub(crate) fn with(&self, id: FontId, f: F) -> T where F: FnOnce(&FontRef) -> T, { diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index d177696..a48cbf5 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -40,12 +40,14 @@ impl GlyphContext { } } +/// A text element. #[derive(Clone, Debug)] pub struct Text { string: String, } impl Text { + /// Creates a new [`Text`]. #[must_use] pub fn new() -> Self { Self { diff --git a/crates/render/src/text/style.rs b/crates/render/src/text/style.rs index b728899..45170f3 100644 --- a/crates/render/src/text/style.rs +++ b/crates/render/src/text/style.rs @@ -1,12 +1,18 @@ use crate::text::Fonts; use crate::{Color, Sp}; +/// A style of text. #[derive(Clone, Debug)] pub struct Style { + /// A set of fonts. pub fonts: Fonts, + /// An overlay color. pub color: Color, + /// Font weight. pub weight: u16, + /// Whether to set italic. pub italic: bool, + /// Font size. pub size: Sp, } From e5cc8cf4c620e36c1e134b93061d0570291663ca Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 22:45:00 +0900 Subject: [PATCH 32/34] feat(render): impl text shaping --- crates/render/src/text/error.rs | 33 +++ crates/render/src/text/layout.rs | 311 ++++++++++++++++++++ crates/render/src/text/mod.rs | 48 +++ crates/render/src/text/shape.rs | 485 +++++++++++++++++++++++++++++++ 4 files changed, 877 insertions(+) create mode 100644 crates/render/src/text/layout.rs create mode 100644 crates/render/src/text/shape.rs diff --git a/crates/render/src/text/error.rs b/crates/render/src/text/error.rs index 489e0d6..be2e603 100644 --- a/crates/render/src/text/error.rs +++ b/crates/render/src/text/error.rs @@ -3,6 +3,39 @@ use std::fmt::Display; use crate::context::InsertionError; +/// An error during text layout. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayoutError { + /// A dimension is negative or non-finite, or the font scale is not positive. + InvalidDimensions, + /// The input exceeds the shaping engine's byte-offset limit. + TextTooLong, + /// Text requires bidirectional resolution, which is not supported yet. + UnsupportedBidi, + /// A style changes inside a shaping cluster at this byte offset. + StyleInsideCluster(usize), + /// None of the style's fonts are available for this original byte range. + MissingFont(std::ops::Range), +} + +impl Display for LayoutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidDimensions => write!(f, "invalid text layout dimensions"), + Self::TextTooLong => write!(f, "text exceeds the shaping byte-offset limit"), + Self::UnsupportedBidi => write!(f, "bidirectional text layout is not supported"), + Self::StyleInsideCluster(offset) => { + write!(f, "style changes inside a cluster at byte {offset}") + } + Self::MissingFont(range) => { + write!(f, "no usable font for bytes {}..{}", range.start, range.end) + } + } + } +} + +impl Error for LayoutError {} + /// An error during rasterization. #[derive(Debug)] pub struct RasterizationError; diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs new file mode 100644 index 0000000..af475ca --- /dev/null +++ b/crates/render/src/text/layout.rs @@ -0,0 +1,311 @@ +use std::ops::Range; + +use swash::text::cluster::Boundary; + +use super::shape::{self, Cluster, Fragment, Kind, PositionedRun}; +use super::{FontMap, LayoutError, Text}; +use crate::Dp; + +/// Additional line breaking within a paragraph. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Wrap { + /// Only mandatory breaks produce new lines. + None, + /// Break at Unicode line opportunities; oversized words may overflow. + Word, + /// Also break oversized words at shaping cluster boundaries. + #[default] + WordOrCluster, +} + +/// Horizontal alignment of each line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Alignment { + /// Align at the left edge. + #[default] + Start, + /// Center within the available width. + Center, + /// Align at the right edge. + End, +} + +/// Options for CPU text layout, independent of rasterization scale. +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutDescriptor { + /// Available width. + pub width: Option, + /// Soft wrapping behavior. + pub wrap: Wrap, + /// Layout alignment. + pub alignment: Alignment, + /// Minimum line height. + pub min_line_height: Option, + /// Accessibility font scale. See also [`Sp::to_dp`](`crate::Sp::to_dp`). + pub font_scale: f32, +} + +impl Default for LayoutDescriptor { + fn default() -> Self { + Self { + width: None, + wrap: Wrap::WordOrCluster, + alignment: Alignment::Start, + min_line_height: None, + font_scale: 1.0, + } + } +} + +/// The layout box. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayoutBounds { + /// Alignment width. + pub width: Dp, + /// Sum of the line heights. + pub height: Dp, +} + +/// Metrics for one line. +#[derive(Clone, Debug, PartialEq)] +pub struct LineMetrics { + /// Original UTF-8 byte range. + pub range: Range, + /// Original byte range of the mandatory break. + pub break_range: Option>, + /// Horizontal alignment offset. + pub x: Dp, + /// Top of the line box. + pub y: Dp, + /// Advance width excluding trailing breakable spaces. + pub width: Dp, + /// Full advance width. + pub advance: Dp, + /// Full line height. + pub height: Dp, + /// Baseline position relative to the layout origin. + pub baseline: Dp, + /// Maximum font ascent among the participating runs. + pub ascent: Dp, + /// Maximum font descent among the participating runs. + pub descent: Dp, + /// Maximum font leading among the participating runs. + pub leading: Dp, +} + +/// An owned layout. +#[derive(Clone, Debug)] +pub struct Layout { + /// Logical bounds. + pub bounds: LayoutBounds, + /// Lines in source order. + pub lines: Vec, + pub(crate) runs: Vec, +} + +pub(super) fn layout( + text: &Text, + fonts: &FontMap, + desc: &LayoutDescriptor, +) -> Result { + validate(text, desc)?; + let clusters = shape::prepare(text, fonts)?; + let mut layout = Layout { + bounds: LayoutBounds { + width: Dp(0.0), + height: Dp(0.0), + }, + lines: Vec::new(), + runs: Vec::new(), + }; + let mut run_ranges = Vec::new(); + let mut start = 0; + + while start < clusters.len() { + let end = clusters[start..] + .iter() + .position(|cluster| cluster.kind == Kind::Break) + .map_or(clusters.len(), |offset| start + offset); + let (mut next, mut fragment) = choose_line(text, fonts, &clusters, start..end, desc)?; + let mut break_range = None; + + if next == end + && let Some(hard_break) = clusters.get(end) + { + break_range = Some(hard_break.range.clone()); + + let metrics = shape::fragment(text, fonts, &clusters[end..=end], desc.font_scale)?; + fragment.ascent = fragment.ascent.max(metrics.ascent); + fragment.descent = fragment.descent.max(metrics.descent); + fragment.leading = fragment.leading.max(metrics.leading); + next += 1; + } + + let content_end = break_range + .as_ref() + .map_or_else(|| clusters[next - 1].range.end, |range| range.start); + run_ranges.push(append_line( + &mut layout, + desc, + fragment, + clusters[start].range.start..content_end, + break_range, + )); + start = next; + } + + if let Some(last) = clusters.last() + && last.kind == Kind::Break + { + let fragment = shape::fragment(text, fonts, std::slice::from_ref(last), desc.font_scale)?; + run_ranges.push(append_line( + &mut layout, + desc, + fragment, + text.string.len()..text.string.len(), + None, + )); + } + + let measured = layout + .lines + .iter() + .map(|line| line.width.0) + .fold(0.0_f32, f32::max); + let width = desc.width.map_or(measured, |width| width.0); + if !layout.lines.is_empty() { + layout.bounds.width = Dp(width.max(measured)); + } + + for (line, runs) in layout.lines.iter_mut().zip(run_ranges) { + let extra = (width - line.width.0).max(0.0); + line.x = Dp(match desc.alignment { + Alignment::Start => 0.0, + Alignment::Center => extra * 0.5, + Alignment::End => extra, + }); + + for run in &mut layout.runs[runs] { + for glyph in &mut run.glyphs { + glyph.x += line.x; + glyph.y += line.baseline; + } + } + } + + Ok(layout) +} + +fn validate(text: &Text, desc: &LayoutDescriptor) -> Result<(), LayoutError> { + let nonnegative = |value: f32| value.is_finite() && value >= 0.0; + if desc.width.is_some_and(|width| !nonnegative(width.0)) + || desc + .min_line_height + .is_some_and(|height| !nonnegative(height.0)) + || !desc.font_scale.is_finite() + || desc.font_scale <= 0.0 + || text.spans.iter().any(|span| { + !nonnegative(span.style.size.0) + || !nonnegative(span.style.size.to_dp(desc.font_scale).0) + }) + { + return Err(LayoutError::InvalidDimensions); + } + + Ok(()) +} + +fn choose_line( + text: &Text, + fonts: &FontMap, + clusters: &[Cluster], + range: Range, + desc: &LayoutDescriptor, +) -> Result<(usize, Fragment), LayoutError> { + let shape = |end| shape::fragment(text, fonts, &clusters[range.start..end], desc.font_scale); + let Some(width) = desc.width.filter(|_| desc.wrap != Wrap::None) else { + return Ok((range.end, shape(range.end)?)); + }; + if range.is_empty() { + return Ok((range.end, Fragment::default())); + } + + let mut best = None; + let breaks = (range.start + 1..range.end) + .filter(|&end| clusters[end].boundary >= Boundary::Line) + .chain(std::iter::once(range.end)); + + for end in breaks { + let fragment = shape(end)?; + if fragment.width <= width.0 { + best = Some((end, fragment)); + continue; + } + if let Some(best) = best { + return Ok(best); + } + if desc.wrap == Wrap::Word { + return Ok((end, fragment)); + } + + let mut fallback = None; + for split in range.start + 1..=end { + let mut candidate = shape(split)?; + if candidate.width > width.0 { + if let Some(fallback) = fallback { + return Ok(fallback); + } + + let mut next = split; + while next < end && clusters[next].space { + next += 1; + } + if next != split { + candidate = shape(next)?; + } + + return Ok((next, candidate)); + } + + fallback = Some((split, candidate)); + } + + return Ok(fallback.unwrap()); + } + + Ok(best.unwrap()) +} + +fn append_line( + layout: &mut Layout, + desc: &LayoutDescriptor, + fragment: Fragment, + range: Range, + break_range: Option>, +) -> Range { + let natural = fragment.ascent + fragment.descent + fragment.leading; + let height = desc + .min_line_height + .map_or(natural, |height| height.0.max(natural)); + let y = layout.bounds.height; + let baseline = (fragment.leading + height - natural).mul_add(0.5, y.0 + fragment.ascent); + + layout.lines.push(LineMetrics { + range, + break_range, + x: Dp(0.0), + y, + width: Dp(fragment.width), + advance: Dp(fragment.advance), + height: Dp(height), + baseline: Dp(baseline), + ascent: Dp(fragment.ascent), + descent: Dp(fragment.descent), + leading: Dp(fragment.leading), + }); + layout.bounds.height += Dp(height); + + let start = layout.runs.len(); + layout.runs.extend(fragment.runs); + start..layout.runs.len() +} diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index a48cbf5..dd48e67 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -5,6 +5,7 @@ mod cache; mod error; mod font; mod image; +mod layout; mod raster; mod shape; mod style; @@ -15,10 +16,12 @@ use cache::*; pub use error::*; pub use font::*; use image::*; +pub use layout::*; use raster::*; pub use style::*; use unit::*; +use std::ops::Range; use swash::scale::image::Image as SwashImage; #[derive(Debug)] @@ -44,6 +47,13 @@ impl GlyphContext { #[derive(Clone, Debug)] pub struct Text { string: String, + spans: Vec, +} + +#[derive(Clone, Debug)] +struct Span { + range: Range, + style: Style, } impl Text { @@ -52,6 +62,44 @@ impl Text { pub fn new() -> Self { Self { string: String::new(), + spans: Vec::new(), } } + + /// Appends text with the given style, merging adjacent equal styles. + pub fn push(&mut self, string: &str, style: &Style) { + if string.is_empty() { + return; + } + + let start = self.string.len(); + self.string.push_str(string); + + if let Some(span) = self.spans.last_mut() + && span.style == *style + { + span.range.end = self.string.len(); + } else { + self.spans.push(Span { + range: start..self.string.len(), + style: style.clone(), + }); + } + } + + /// Removes all text and styles, retaining allocated storage. + pub fn clear(&mut self) { + self.string.clear(); + self.spans.clear(); + } + + /// Measures and positions left-to-right text in density independent pixels. + /// Unsupported characters use a replacement or missing-glyph symbol. + /// + /// # Errors + /// Returns an error for invalid dimensions, unavailable fonts, bidi text, or + /// style changes inside a shaping cluster. + pub fn layout(&self, fonts: &FontMap, desc: &LayoutDescriptor) -> Result { + layout::layout(self, fonts, desc) + } } diff --git a/crates/render/src/text/shape.rs b/crates/render/src/text/shape.rs new file mode 100644 index 0000000..a044fad --- /dev/null +++ b/crates/render/src/text/shape.rs @@ -0,0 +1,485 @@ +use harfrust::{BufferFlags, Direction, ShaperData, ShaperInstance, UnicodeBuffer}; +use std::ops::Range; +use swash::text::cluster::{Boundary, CharCluster, CharInfo, Parser, Token}; +use swash::text::{ClusterBreak, Codepoint, LineBreak, Script, analyze}; + +use super::{FontId, FontMap, FontRef, LayoutError, Style, Text}; +use crate::Dp; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Kind { + Text, + Tab, + Break, +} + +pub(super) struct Cluster { + pub range: Range, + pub style: usize, + pub script: Script, + pub boundary: Boundary, + pub kind: Kind, + pub space: bool, + pub font: Option, + replacement: bool, + chars: CharCluster, +} + +#[derive(Clone, Debug)] +#[expect( + clippy::redundant_pub_crate, + reason = "positioned glyphs remain crate-private" +)] +#[expect(dead_code, reason = "glyph IDs are retained for future rendering")] +pub(crate) struct PositionedGlyph { + pub id: u16, + pub range: Range, + pub x: Dp, + pub y: Dp, + pub advance: Dp, +} + +#[derive(Clone, Debug)] +#[expect( + clippy::redundant_pub_crate, + reason = "positioned runs remain crate-private" +)] +#[expect( + dead_code, + reason = "font handles and styles are retained for future rendering" +)] +pub(crate) struct PositionedRun { + pub font: FontId, + pub style: Style, + pub range: Range, + pub glyphs: Vec, +} + +#[derive(Debug, Default)] +pub(super) struct Fragment { + pub runs: Vec, + pub advance: f32, + pub width: f32, + pub ascent: f32, + pub descent: f32, + pub leading: f32, +} + +impl Fragment { + const fn include_metrics(&mut self, metrics: swash::Metrics) { + self.ascent = self.ascent.max(metrics.ascent); + self.descent = self.descent.max(metrics.descent); + self.leading = self.leading.max(metrics.leading); + } +} + +const fn kind(ch: char) -> Kind { + match ch { + '\r' | '\n' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => Kind::Break, + '\t' => Kind::Tab, + _ => Kind::Text, + } +} + +const fn neutral(script: Script) -> bool { + matches!(script, Script::Common | Script::Inherited | Script::Unknown) +} + +fn analyze_text(text: &Text) -> Result, LayoutError> { + u32::try_from(text.string.len()).map_err(|_| LayoutError::TextTooLong)?; + + let mut analysis = analyze(text.string.chars()); + let mut tokens = Vec::new(); + let mut scripts = Vec::new(); + for ((offset, ch), (properties, boundary)) in text.string.char_indices().zip(&mut analysis) { + tokens.push(Token { + ch, + offset: u32::try_from(offset).map_err(|_| LayoutError::TextTooLong)?, + len: u8::try_from(ch.len_utf8()).unwrap(), + info: CharInfo::new(properties, boundary), + data: 0, + }); + scripts.push(match properties.cluster_break() { + ClusterBreak::EX | ClusterBreak::SM | ClusterBreak::ZWJ => Script::Inherited, + _ => properties.script(), + }); + } + + if analysis.needs_bidi_resolution() { + return Err(LayoutError::UnsupportedBidi); + } + + resolve_scripts(&tokens, &mut scripts); + + let mut clusters: Vec = Vec::new(); + let mut start = 0; + while start < tokens.len() { + let token = tokens[start]; + let token_kind = kind(token.ch); + let mut end = start + 1; + + if token_kind == Kind::Break && token.ch == '\r' { + if tokens.get(end).is_some_and(|next| next.ch == '\n') { + end += 1; + } + } else if token_kind == Kind::Text { + while end < tokens.len() + && kind(tokens[end].ch) == Kind::Text + && scripts[end] == scripts[start] + { + end += 1; + } + } + + let mut parser = Parser::new(scripts[start], tokens[start..end].iter().copied()); + let mut chars = CharCluster::new(); + while parser.next(&mut chars) { + let range = chars.range().to_range(); + let source = &text.string[range.clone()]; + + // swash's fixed-capacity parser overlaps chunks of long clusters. + // its simple parser also emits regional indicators individually. + if let Some(last) = clusters.last_mut() { + let previous = &text.string[last.range.clone()]; + let flag = previous.chars().count() == 1 + && previous.chars().all(regional_indicator) + && source.chars().all(regional_indicator); + + if range.start < last.range.end || flag { + last.range.end = range.end; + continue; + } + } + + clusters.push(Cluster { + range, + style: 0, + script: scripts[start], + boundary: chars.info().boundary(), + kind: token_kind, + space: source.chars().all(|ch| { + ch.is_whitespace() && !matches!(ch.line_break(), LineBreak::GL | LineBreak::WJ) + }), + font: None, + replacement: false, + chars, + }); + } + start = end; + } + + let mut style = 0; + for cluster in &mut clusters { + while text.spans[style].range.end <= cluster.range.start { + style += 1; + } + + cluster.style = style; + if cluster.kind != Kind::Break && text.spans[style].range.end < cluster.range.end { + return Err(LayoutError::StyleInsideCluster(text.spans[style].range.end)); + } + } + + Ok(clusters) +} + +fn resolve_scripts(tokens: &[Token], scripts: &mut [Script]) { + // prefer the preceding script, then the following script within the paragraph. + let mut previous = Script::Common; + for (token, script) in tokens.iter().zip(scripts.iter_mut()) { + if kind(token.ch) == Kind::Break { + previous = Script::Common; + } else if neutral(*script) { + *script = previous; + } else { + previous = *script; + } + } + + let mut following = Script::Common; + for (token, script) in tokens.iter().zip(scripts.iter_mut()).rev() { + if kind(token.ch) == Kind::Break { + following = Script::Common; + } else if neutral(*script) { + *script = following; + } else { + following = *script; + } + } +} + +const fn regional_indicator(ch: char) -> bool { + matches!(ch, '\u{1f1e6}'..='\u{1f1ff}') +} + +fn char_cluster(ch: char) -> CharCluster { + let token = Token { + ch, + len: u8::try_from(ch.len_utf8()).unwrap(), + info: ch.into(), + ..Token::default() + }; + let mut cluster = CharCluster::new(); + Parser::new(Script::Common, std::iter::once(token)).next(&mut cluster); + cluster +} + +fn resolve_font(fonts: &FontMap, style: &Style, chars: &mut CharCluster) -> Option<(FontId, bool)> { + if let Some(font) = style.fonts.resolve(fonts, chars) { + return Some((font, false)); + } + + let font = style + .fonts + .resolve(fonts, &mut char_cluster('\u{fffd}')) + .or_else(|| style.fonts.resolve(fonts, &mut CharCluster::new()))?; + Some((font, true)) +} + +pub(super) fn prepare(text: &Text, fonts: &FontMap) -> Result, LayoutError> { + let mut clusters = analyze_text(text)?; + for cluster in &mut clusters { + let mut chars = if cluster.kind == Kind::Text { + cluster.chars + } else { + char_cluster(' ') + }; + let style = &text.spans[cluster.style].style; + let (font, replacement) = resolve_font(fonts, style, &mut chars) + .ok_or_else(|| LayoutError::MissingFont(cluster.range.clone()))?; + cluster.font = Some(font); + cluster.replacement = replacement; + } + + Ok(clusters) +} + +fn metrics(font: &FontRef, style: &Style, scale: f32) -> swash::Metrics { + let font: &swash::FontRef = font.as_ref(); + let coords: Vec<_> = font + .variations() + .normalized_coords([ + ("wght", f32::from(style.weight)), + ("ital", f32::from(style.italic)), + ]) + .collect(); + font.metrics(&coords).scale(style.size.to_dp(scale).0) +} + +#[expect( + clippy::cast_precision_loss, + reason = "font design units are scaled to f32 layout coordinates" +)] +fn shape_run( + font: &FontRef, + style: &Style, + scale: f32, + source: &str, + range: Range, + context: Range, + script: Script, +) -> Vec { + let face: &harfrust::FontRef = font.as_ref(); + let data = ShaperData::new(face); + let instance = ShaperInstance::from_variations( + face, + [ + (harfrust::Tag::new(b"wght"), f32::from(style.weight)), + (harfrust::Tag::new(b"ital"), f32::from(style.italic)), + ], + ); + let shaper = data.shaper(face).instance(Some(&instance)).build(); + let factor = style.size.to_dp(scale).0 / shaper.units_per_em().max(1) as f32; + + let mut buffer = UnicodeBuffer::new(); + for (offset, ch) in source[range.clone()].char_indices() { + buffer.add(ch, u32::try_from(range.start + offset).unwrap()); + } + + buffer.set_pre_context(&source[context.start..range.start]); + buffer.set_post_context(&source[range.end..context.end]); + buffer.set_direction(Direction::LeftToRight); + buffer.set_script(harfrust_script(script)); + + let mut flags = BufferFlags::empty(); + if range.start == context.start { + flags |= BufferFlags::BEGINNING_OF_TEXT; + } + if range.end == context.end { + flags |= BufferFlags::END_OF_TEXT; + } + buffer.set_flags(flags); + + let buffer = shaper.shape(buffer, harfrust::ShapeOptions::default()); + let infos = buffer.glyph_infos(); + let mut boundaries: Vec<_> = infos.iter().map(|info| info.cluster as usize).collect(); + boundaries.push(range.end); + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut x = 0.0; + let mut y = 0.0; + infos + .iter() + .zip(buffer.glyph_positions()) + .map(|(info, position)| { + let start = info.cluster as usize; + let end = boundaries[boundaries.partition_point(|&offset| offset <= start)]; + let glyph = PositionedGlyph { + id: u16::try_from(info.glyph_id).unwrap(), + range: start..end, + x: Dp((position.x_offset as f32).mul_add(factor, x)), + y: Dp((position.y_offset as f32).mul_add(-factor, y)), + advance: Dp(position.x_advance as f32 * factor), + }; + + x += glyph.advance.0; + y = (position.y_advance as f32).mul_add(-factor, y); + glyph + }) + .collect() +} + +fn harfrust_script(script: Script) -> harfrust::Script { + // these tags differ from their ISO 15924 names. + let tag = match script { + Script::Bengali => *b"Beng", + Script::Devanagari => *b"Deva", + Script::Gujarati => *b"Gujr", + Script::Gurmukhi => *b"Guru", + Script::Kannada => *b"Knda", + Script::Malayalam => *b"Mlym", + Script::Myanmar => *b"Mymr", + Script::Oriya => *b"Orya", + Script::Tamil => *b"Taml", + _ if neutral(script) => return harfrust::script::COMMON, + _ => script.to_opentype().to_be_bytes(), + }; + harfrust::Script::from_iso15924_tag(harfrust::Tag::new(&tag)) + .unwrap_or(harfrust::script::UNKNOWN) +} + +fn space_width(fonts: &FontMap, style: &Style, scale: f32, script: Script) -> Option { + let (id, replacement) = resolve_font(fonts, style, &mut char_cluster(' '))?; + let source = if replacement { "\u{fffd}" } else { " " }; + let range = 0..source.len(); + let glyphs = fonts.with(id, |font| { + shape_run(font, style, scale, source, range.clone(), range, script) + }); + Some(glyphs.iter().map(|glyph| glyph.advance.0).sum()) +} + +pub(super) fn fragment( + text: &Text, + fonts: &FontMap, + clusters: &[Cluster], + scale: f32, +) -> Result { + let mut fragment = Fragment::default(); + let Some(first) = clusters.first() else { + return Ok(fragment); + }; + + let end = clusters + .iter() + .rfind(|cluster| cluster.kind != Kind::Break) + .map_or(first.range.start, |cluster| cluster.range.end); + let context = first.range.start..end; + let visible_end = clusters + .iter() + .rfind(|cluster| !cluster.space && cluster.kind != Kind::Break) + .map_or(context.start, |cluster| cluster.range.end); + let mut tab_width = None; + let mut start = 0; + + while start < clusters.len() { + let cluster = &clusters[start]; + let style = &text.spans[cluster.style].style; + let font = cluster.font.unwrap(); + fragment.include_metrics(fonts.with(font, |font| metrics(font, style, scale))); + + if cluster.kind == Kind::Tab { + let stop = if let Some(width) = tab_width { + width + } else { + let initial_style = &text.spans[first.style].style; + let width = space_width(fonts, initial_style, scale, first.script) + .ok_or_else(|| LayoutError::MissingFont(first.range.clone()))? + * 4.0; + tab_width = Some(width); + width + }; + + if stop > 0.0 { + fragment.advance = (fragment.advance / stop).floor().mul_add(stop, stop); + } + if cluster.range.end <= visible_end { + fragment.width = fragment.advance; + } + start += 1; + continue; + } + + if cluster.kind == Kind::Break { + start += 1; + continue; + } + + let mut end = start + 1; + while !cluster.replacement + && end < clusters.len() + && !clusters[end].replacement + && clusters[end].kind == Kind::Text + && clusters[end].style == cluster.style + && clusters[end].font == cluster.font + && clusters[end].script == cluster.script + { + end += 1; + } + + let range = cluster.range.start..clusters[end - 1].range.end; + let (source, source_range, run_context, script) = if cluster.replacement { + ("\u{fffd}", 0..3, 0..3, Script::Common) + } else { + ( + text.string.as_str(), + range.clone(), + context.clone(), + cluster.script, + ) + }; + let mut glyphs = fonts.with(font, |font| { + shape_run( + font, + style, + scale, + source, + source_range, + run_context, + script, + ) + }); + + let origin = fragment.advance; + for glyph in &mut glyphs { + if cluster.replacement { + glyph.range = range.clone(); + } + glyph.x.0 += origin; + fragment.advance += glyph.advance.0; + if glyph.range.start < visible_end { + fragment.width = fragment.advance; + } + } + + fragment.runs.push(PositionedRun { + font, + style: style.clone(), + range, + glyphs, + }); + start = end; + } + + Ok(fragment) +} From 33793ff198fec9db5fc2a8ed2f5389a1373770ed Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 23:25:46 +0900 Subject: [PATCH 33/34] refactor(render): make fractional offset glyph specific --- crates/render/src/text/cache.rs | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs index 3bed76f..5c363be 100644 --- a/crates/render/src/text/cache.rs +++ b/crates/render/src/text/cache.rs @@ -3,14 +3,19 @@ use wgpu::*; use crate::text::*; use crate::{Cache, Theme}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlyphReq { + pub glyph: u16, + pub x_fract: Unit, + pub y_fract: Unit, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct GlyphStyle { pub size: Unit, pub weight: Unit, pub italic: bool, pub hint: bool, - pub x_fract: Unit, - pub y_fract: Unit, pub theme: Option, } @@ -52,10 +57,10 @@ impl GlyphCache { font_map: &FontMap, font_id: FontId, style: GlyphStyle, - gids: impl Iterator, + reqs: impl Iterator, ) -> Result, RasterizationError> { let mut buf = Vec::new(); - self.fetch_batch_into(device, queue, font_map, font_id, style, &mut buf, gids)?; + self.fetch_batch_into(device, queue, font_map, font_id, style, &mut buf, reqs)?; Ok(buf) } @@ -67,10 +72,10 @@ impl GlyphCache { font_id: FontId, style: GlyphStyle, buf: &mut Vec, - gids: impl Iterator, + reqs: impl Iterator, ) -> Result<(), RasterizationError> { font_map.with(font_id, |font| { - self.fetch_batch_into_unchecked(device, queue, font, font_id, style, buf, gids) + self.fetch_batch_into_unchecked(device, queue, font, font_id, style, buf, reqs) }) } @@ -82,9 +87,9 @@ impl GlyphCache { font_id: FontId, style: GlyphStyle, buf: &mut Vec, - gids: impl Iterator, + reqs: impl Iterator, ) -> Result<(), RasterizationError> { - buf.reserve(gids.size_hint().0); + buf.reserve(reqs.size_hint().0); let mut scope = self.rcx.open(RasterStyle { font: font.clone(), @@ -95,15 +100,15 @@ impl GlyphCache { theme: style.theme, }); - for gid in gids { + for req in reqs { let data = self.cache.try_fetch( GlyphKey { font: font_id, - glyph: gid, + glyph: req.glyph, style, }, |key| { - let image = scope.rasterize(key.glyph, key.style.x_fract, key.style.y_fract)?; + let image = scope.rasterize(key.glyph, req.x_fract, req.y_fract)?; if image.is_empty() { return Ok(None); From 2c0f17728c17139c29628e0147e1f123d5ea41a7 Mon Sep 17 00:00:00 2001 From: sharp0802 Date: Mon, 21 Sep 2026 23:37:04 +0900 Subject: [PATCH 34/34] fix(render): include subpixels to cache key --- crates/render/src/text/cache.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs index 5c363be..4958137 100644 --- a/crates/render/src/text/cache.rs +++ b/crates/render/src/text/cache.rs @@ -22,7 +22,7 @@ pub struct GlyphStyle { #[derive(Debug, PartialEq, Eq, Hash)] struct GlyphKey { font: FontId, - glyph: u16, + req: GlyphReq, style: GlyphStyle, } @@ -104,11 +104,11 @@ impl GlyphCache { let data = self.cache.try_fetch( GlyphKey { font: font_id, - glyph: req.glyph, + req, style, }, |key| { - let image = scope.rasterize(key.glyph, req.x_fract, req.y_fract)?; + let image = scope.rasterize(key.req.glyph, key.req.x_fract, key.req.y_fract)?; if image.is_empty() { return Ok(None);