From a70285e64c53b1319491507d636b0a2032259a8f Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 29 Dec 2023 11:36:52 -0500 Subject: [PATCH 1/2] Initial text mark with glyphon No text rotation support --- Cargo.lock | 141 ++++++++++++++- vega-wgpu-renderer/Cargo.toml | 1 + vega-wgpu-renderer/src/renderers/canvas.rs | 109 +++++++++--- vega-wgpu-renderer/src/renderers/mark.rs | 4 +- vega-wgpu-renderer/src/renderers/mod.rs | 1 + vega-wgpu-renderer/src/renderers/text.rs | 180 ++++++++++++++++++++ vega-wgpu-renderer/src/scene/mod.rs | 1 + vega-wgpu-renderer/src/scene/scene_graph.rs | 13 +- vega-wgpu-renderer/src/scene/text.rs | 83 +++++++++ vega-wgpu-renderer/src/specs/mark.rs | 3 +- vega-wgpu-renderer/src/specs/mod.rs | 1 + vega-wgpu-renderer/src/specs/text.rs | 77 +++++++++ 12 files changed, 586 insertions(+), 28 deletions(-) create mode 100644 vega-wgpu-renderer/src/renderers/text.rs create mode 100644 vega-wgpu-renderer/src/scene/text.rs create mode 100644 vega-wgpu-renderer/src/specs/text.rs diff --git a/Cargo.lock b/Cargo.lock index 8aa1411..2329c28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -685,6 +685,27 @@ dependencies = [ "libc", ] +[[package]] +name = "cosmic-text" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75acbfb314aeb4f5210d379af45ed1ec2c98c7f1790bf57b8a4c562ac0c51b71" +dependencies = [ + "fontdb", + "libm", + "log", + "rangemap", + "rustc-hash", + "rustybuzz 0.11.0", + "self_cell", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.11" @@ -1929,6 +1950,25 @@ dependencies = [ "rustversion", ] +[[package]] +name = "etagere" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "306960881d6c46bd0dd6b7f07442a441418c08d0d3e63d8d080b0f64c6343e4e" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f253bc5c813ca05792837a0ff4b3a580336b224512d48f7eda1d7dd9210787" +dependencies = [ + "num-traits", +] + [[package]] name = "exr" version = "1.71.0" @@ -2416,6 +2456,17 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "glyphon" +version = "0.3.0" +source = "git+https://github.com/grovesNL/glyphon.git?rev=941309aed230d7110bfec0d4af502ecb4648cf90#941309aed230d7110bfec0d4af502ecb4648cf90" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "wgpu", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -3246,6 +3297,15 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lru" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a83fb7698b3643a0e34f9ae6f2e8f0178c0fd42f8b59d493aa271ff3a5bf21" +dependencies = [ + "hashbrown 0.14.3", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -4392,6 +4452,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8a99fddc9f0ba0a85884b8d14e3592853e787d581ca1816c91349b10e4eeab" +[[package]] +name = "rangemap" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977b1e897f9d764566891689e642653e5ed90c6895106acd005eb4c1d0203991" + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -4814,6 +4880,23 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "rustybuzz" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa" +dependencies = [ + "bitflags 1.3.2", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.20.0", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.16" @@ -4947,6 +5030,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba" + [[package]] name = "semver" version = "0.9.0" @@ -5350,6 +5439,12 @@ dependencies = [ "usvg", ] +[[package]] +name = "svg_fmt" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb1df15f412ee2e9dfc1c504260fa695c1c3f10fe9f4a6ee2d2184d7d6450e2" + [[package]] name = "svgtypes" version = "0.12.0" @@ -5360,6 +5455,16 @@ dependencies = [ "siphasher 0.3.11", ] +[[package]] +name = "swash" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7c73c813353c347272919aa1af2885068b05e625e5532b43049e4f641ae77f" +dependencies = [ + "yazi", + "zeno", +] + [[package]] name = "swc_atoms" version = "0.6.4" @@ -5812,6 +5917,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "sys-locale" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e801cf239ecd6ccd71f03d270d67dd53d13e90aab208bf4b8fe4ad957ea949b0" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -6290,6 +6404,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.22" @@ -6311,6 +6431,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d817255e1bed6dfd4ca47258685d14d2bdcfbc64fdc9e3819bd5848057b8ecc" +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "unicode-vo" version = "0.1.0" @@ -6412,7 +6538,7 @@ dependencies = [ "fontdb", "kurbo", "log", - "rustybuzz", + "rustybuzz 0.10.0", "unicode-bidi", "unicode-script", "unicode-vo", @@ -6490,6 +6616,7 @@ dependencies = [ "dssim", "env_logger", "futures-intrusive", + "glyphon", "image", "log", "pollster", @@ -7291,6 +7418,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + [[package]] name = "zerocopy" version = "0.7.32" diff --git a/vega-wgpu-renderer/Cargo.toml b/vega-wgpu-renderer/Cargo.toml index 8b391cc..716026b 100644 --- a/vega-wgpu-renderer/Cargo.toml +++ b/vega-wgpu-renderer/Cargo.toml @@ -21,6 +21,7 @@ thiserror = "1.0.52" csscolorparser = "0.6.2" image = "0.24.7" futures-intrusive = "^0.5" +glyphon = { git = "https://github.com/grovesNL/glyphon.git", rev="941309aed230d7110bfec0d4af502ecb4648cf90" } [dev-dependencies] dssim = "3.2.4" diff --git a/vega-wgpu-renderer/src/renderers/canvas.rs b/vega-wgpu-renderer/src/renderers/canvas.rs index a9b5804..6869c6a 100644 --- a/vega-wgpu-renderer/src/renderers/canvas.rs +++ b/vega-wgpu-renderer/src/renderers/canvas.rs @@ -1,12 +1,14 @@ use crate::error::VegaWgpuError; -use crate::renderers::mark::MarkRenderer; +use crate::renderers::mark::GeomMarkRenderer; use crate::renderers::rect::RectShader; use crate::renderers::rule::RuleShader; use crate::renderers::symbol::SymbolShader; +use crate::renderers::text::TextMarkRenderer; use crate::scene::rect::{RectInstance, RectMark}; use crate::scene::rule::RuleMark; use crate::scene::scene_graph::{SceneGraph, SceneGroup, SceneMark}; use crate::scene::symbol::{SymbolInstance, SymbolMark}; +use crate::scene::text::TextMark; use image::imageops::crop_imm; use wgpu::{ Adapter, Buffer, BufferAddress, BufferDescriptor, BufferUsages, CommandBuffer, CommandEncoder, @@ -23,15 +25,20 @@ use winit::window::Window; #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct CanvasUniform { - size: [f32; 2], + pub size: [f32; 2], filler: [f32; 2], // Pad to 16 bytes } +pub enum MarkRenderer { + Geom(GeomMarkRenderer), + Text(TextMarkRenderer), +} + pub trait Canvas { fn add_mark_renderer(&mut self, mark_renderer: MarkRenderer); fn clear_mark_renderer(&mut self); - fn device(&self) -> &Device; + fn queue(&self) -> &Queue; fn uniform(&self) -> &CanvasUniform; fn set_uniform(&mut self, uniform: CanvasUniform); @@ -41,36 +48,47 @@ pub trait Canvas { fn sample_count(&self) -> u32; fn add_symbol_mark(&mut self, mark: &SymbolMark) { - self.add_mark_renderer(MarkRenderer::new( + self.add_mark_renderer(MarkRenderer::Geom(GeomMarkRenderer::new( &self.device(), self.uniform().clone(), self.texture_format(), self.sample_count(), Box::new(SymbolShader::new(mark.shape)), mark.instances.as_slice(), - )); + ))); } fn add_rect_mark(&mut self, mark: &RectMark) { - self.add_mark_renderer(MarkRenderer::new( + self.add_mark_renderer(MarkRenderer::Geom(GeomMarkRenderer::new( &self.device(), self.uniform().clone(), self.texture_format(), self.sample_count(), Box::new(RectShader::new()), mark.instances.as_slice(), - )); + ))); } fn add_rule_mark(&mut self, mark: &RuleMark) { - self.add_mark_renderer(MarkRenderer::new( + self.add_mark_renderer(MarkRenderer::Geom(GeomMarkRenderer::new( &self.device(), self.uniform().clone(), self.texture_format(), self.sample_count(), Box::new(RuleShader::new()), mark.instances.as_slice(), - )); + ))); + } + + fn add_text_mark(&mut self, mark: &TextMark) { + self.add_mark_renderer(MarkRenderer::Text(TextMarkRenderer::new( + &self.device(), + &self.queue(), + self.uniform().clone(), + self.texture_format(), + self.sample_count(), + mark.instances.clone(), + ))); } fn add_group_mark(&mut self, group: &SceneGroup) { @@ -85,6 +103,9 @@ pub trait Canvas { SceneMark::Rule(mark) => { self.add_rule_mark(mark); } + SceneMark::Text(mark) => { + self.add_text_mark(mark); + } SceneMark::Group(group) => { self.add_group_mark(group); } @@ -336,12 +357,29 @@ impl WindowCanvas { }; let mut commands = vec![background_command]; - for mark in &self.marks { - let command = if self.sample_count > 1 { - mark.render(&self.device, &self.multisampled_framebuffer, Some(&view)) - } else { - mark.render(&self.device, &view, None) + for mark in &mut self.marks { + let command = match mark { + MarkRenderer::Geom(mark) => { + if self.sample_count > 1 { + mark.render(&self.device, &self.multisampled_framebuffer, Some(&view)) + } else { + mark.render(&self.device, &view, None) + } + } + MarkRenderer::Text(mark) => { + if self.sample_count > 1 { + mark.render( + &self.device, + &self.queue, + &self.multisampled_framebuffer, + Some(&view), + ) + } else { + mark.render(&self.device, &self.queue, &view, None) + } + } }; + commands.push(command); } @@ -365,6 +403,10 @@ impl Canvas for WindowCanvas { &self.device } + fn queue(&self) -> &Queue { + &self.queue + } + fn uniform(&self) -> &CanvasUniform { &self.uniform } @@ -492,16 +534,33 @@ impl PngCanvas { let mut commands = vec![background_command]; - for mark in &self.marks { - let command = if self.sample_count > 1 { - mark.render( - &self.device, - &self.multisampled_framebuffer, - Some(&self.texture_view), - ) - } else { - mark.render(&self.device, &self.texture_view, None) + for mark in &mut self.marks { + let command = match mark { + MarkRenderer::Geom(mark) => { + if self.sample_count > 1 { + mark.render( + &self.device, + &self.multisampled_framebuffer, + Some(&self.texture_view), + ) + } else { + mark.render(&self.device, &self.texture_view, None) + } + } + MarkRenderer::Text(mark) => { + if self.sample_count > 1 { + mark.render( + &self.device, + &self.queue, + &self.multisampled_framebuffer, + Some(&self.texture_view), + ) + } else { + mark.render(&self.device, &self.queue, &self.texture_view, None) + } + } }; + commands.push(command); } @@ -578,6 +637,10 @@ impl Canvas for PngCanvas { &self.device } + fn queue(&self) -> &Queue { + &self.queue + } + fn uniform(&self) -> &CanvasUniform { &self.uniform } diff --git a/vega-wgpu-renderer/src/renderers/mark.rs b/vega-wgpu-renderer/src/renderers/mark.rs index 1cd5aa2..4a5b51a 100644 --- a/vega-wgpu-renderer/src/renderers/mark.rs +++ b/vega-wgpu-renderer/src/renderers/mark.rs @@ -14,7 +14,7 @@ pub trait MarkShader { fn instance_desc(&self) -> wgpu::VertexBufferLayout<'static>; } -pub struct MarkRenderer { +pub struct GeomMarkRenderer { render_pipeline: wgpu::RenderPipeline, vertex_buffer: wgpu::Buffer, num_vertices: u32, @@ -27,7 +27,7 @@ pub struct MarkRenderer { uniform_bind_group: wgpu::BindGroup, } -impl MarkRenderer { +impl GeomMarkRenderer { pub fn new( device: &Device, uniform: CanvasUniform, diff --git a/vega-wgpu-renderer/src/renderers/mod.rs b/vega-wgpu-renderer/src/renderers/mod.rs index 2f681f2..649cdfe 100644 --- a/vega-wgpu-renderer/src/renderers/mod.rs +++ b/vega-wgpu-renderer/src/renderers/mod.rs @@ -3,4 +3,5 @@ pub mod mark; pub mod rect; pub mod rule; pub mod symbol; +mod text; pub mod vertex; diff --git a/vega-wgpu-renderer/src/renderers/text.rs b/vega-wgpu-renderer/src/renderers/text.rs new file mode 100644 index 0000000..2bf07fe --- /dev/null +++ b/vega-wgpu-renderer/src/renderers/text.rs @@ -0,0 +1,180 @@ +use crate::renderers::canvas::CanvasUniform; +use crate::renderers::mark::MarkShader; +use crate::scene::text::TextInstance; +use crate::specs::text::{TextAlignSpec, TextBaselineSpec}; +use glyphon::cosmic_text::Align; +use glyphon::{ + Attrs, Buffer, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, + TextAtlas, TextBounds, TextRenderer, +}; +use wgpu::{ + CommandBuffer, CommandEncoderDescriptor, Device, MultisampleState, Operations, Queue, + RenderPassColorAttachment, RenderPassDescriptor, TextureFormat, TextureView, +}; + +pub struct TextMarkRenderer { + pub font_system: FontSystem, + pub cache: SwashCache, + pub atlas: TextAtlas, + pub text_renderer: TextRenderer, + pub instances: Vec, + pub uniform: CanvasUniform, +} + +impl TextMarkRenderer { + pub fn new( + device: &Device, + queue: &Queue, + uniform: CanvasUniform, + texture_format: TextureFormat, + sample_count: u32, + instances: Vec, + ) -> Self { + let mut font_system = FontSystem::new(); + let mut cache = SwashCache::new(); + let mut atlas = TextAtlas::new(device, queue, texture_format); + let mut text_renderer = TextRenderer::new( + &mut atlas, + &device, + MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + None, + ); + + Self { + font_system, + cache, + atlas, + text_renderer, + uniform, + instances, + } + } + + pub fn render( + &mut self, + device: &Device, + queue: &Queue, + texture_view: &TextureView, + resolve_target: Option<&TextureView>, + ) -> CommandBuffer { + // Collect buffer into a vector first so that they live as long as the text areas + // that reference them below + let buffers = self + .instances + .iter() + .map(|instance| { + let mut buffer = Buffer::new( + &mut self.font_system, + Metrics::new(instance.font_size, instance.font_size * 1.0), + ); + buffer.set_text( + &mut self.font_system, + &instance.text, + Attrs::new().family(Family::SansSerif), + Shaping::Advanced, + ); + buffer.set_size( + &mut self.font_system, + self.uniform.size[0], + self.uniform.size[1], + ); + buffer.shape_until_scroll(&mut self.font_system); + + buffer + }) + .collect::>(); + + let areas = buffers + .iter() + .zip(&self.instances) + .map(|(buffer, instance)| { + let (width, height) = measure(&buffer); + + let left = match instance.align { + TextAlignSpec::Left => instance.position[0], + TextAlignSpec::Center => instance.position[0] - width / 2.0, + TextAlignSpec::Right => instance.position[0] - width, + }; + + let top = match instance.baseline { + TextBaselineSpec::Alphabetic => instance.position[1] - height, + TextBaselineSpec::Top => instance.position[1], + TextBaselineSpec::Middle => instance.position[1] - height * 0.56, + TextBaselineSpec::Bottom => instance.position[1] - height, + TextBaselineSpec::LineTop => todo!(), + TextBaselineSpec::LineBottom => todo!(), + }; + + TextArea { + buffer: &buffer, + left, + top, + scale: 1.0, + bounds: TextBounds { + left: 0, + top: 0, + right: self.uniform.size[0] as i32, + bottom: self.uniform.size[1] as i32, + }, + default_color: Color::rgb( + (instance.color[0] * 255.0) as u8, + (instance.color[1] * 255.0) as u8, + (instance.color[2] * 255.0) as u8, + ), + } + }) + .collect::>(); + + self.text_renderer + .prepare( + &device, + &queue, + &mut self.font_system, + &mut self.atlas, + Resolution { + width: self.uniform.size[0] as u32, + height: self.uniform.size[1] as u32, + }, + areas, + &mut self.cache, + ) + .unwrap(); + + let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("Text render"), + }); + { + let mut pass = encoder.begin_render_pass(&RenderPassDescriptor { + label: None, + color_attachments: &[Some(RenderPassColorAttachment { + view: &texture_view, + resolve_target, + ops: Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + self.text_renderer.render(&self.atlas, &mut pass).unwrap(); + } + + encoder.finish() + } +} + +pub fn measure(buffer: &Buffer) -> (f32, f32) { + let (width, total_lines) = buffer + .layout_runs() + .fold((0.0, 0usize), |(width, total_lines), run| { + (run.line_w.max(width), total_lines + 1) + }); + (width, (total_lines as f32 * buffer.metrics().line_height)) +} diff --git a/vega-wgpu-renderer/src/scene/mod.rs b/vega-wgpu-renderer/src/scene/mod.rs index a0cf293..e031364 100644 --- a/vega-wgpu-renderer/src/scene/mod.rs +++ b/vega-wgpu-renderer/src/scene/mod.rs @@ -2,3 +2,4 @@ pub mod rect; pub mod rule; pub mod scene_graph; pub mod symbol; +pub mod text; diff --git a/vega-wgpu-renderer/src/scene/scene_graph.rs b/vega-wgpu-renderer/src/scene/scene_graph.rs index ff8c443..b61b929 100644 --- a/vega-wgpu-renderer/src/scene/scene_graph.rs +++ b/vega-wgpu-renderer/src/scene/scene_graph.rs @@ -2,6 +2,7 @@ use crate::error::VegaWgpuError; use crate::scene::rect::RectMark; use crate::scene::rule::RuleMark; use crate::scene::symbol::SymbolMark; +use crate::scene::text::TextMark; use crate::specs::group::GroupItemSpec; use crate::specs::mark::{MarkContainerSpec, MarkSpec}; use crate::specs::rect::RectItemSpec; @@ -20,6 +21,8 @@ pub trait SceneVisitor { fn visit_rect_mark(&mut self, mark: &RectMark, bounds: GroupBounds) -> Result<(), Self::Error>; fn visit_rule_mark(&mut self, mark: &RuleMark, bounds: GroupBounds) -> Result<(), Self::Error>; + + fn visit_text_mark(&mut self, mark: &TextMark, bounds: GroupBounds) -> Result<(), Self::Error>; } #[derive(Debug, Clone)] @@ -74,6 +77,7 @@ impl SceneGroup { SceneMark::Symbol(mark) => visitor.visit_symbol_mark(mark, self.bounds)?, SceneMark::Rect(mark) => visitor.visit_rect_mark(mark, self.bounds)?, SceneMark::Rule(mark) => visitor.visit_rule_mark(mark, self.bounds)?, + SceneMark::Text(mark) => visitor.visit_text_mark(mark, self.bounds)?, SceneMark::Group(group) => visitor.visit_group(group, self.bounds)?, } } @@ -104,7 +108,13 @@ impl SceneGroup { MarkSpec::Symbol(mark) => { vec![SceneMark::Symbol(SymbolMark::from_spec(mark, new_origin)?)] } - _ => unimplemented!(), + MarkSpec::Text(mark) => { + vec![SceneMark::Text(TextMark::from_spec(mark, new_origin)?)] + } + _ => { + println!("Mark type not yet supported: {:?}", item); + continue; + } }; group_marks.extend(item_marks); } @@ -127,6 +137,7 @@ pub enum SceneMark { Symbol(SymbolMark), Rect(RectMark), Rule(RuleMark), + Text(TextMark), Group(SceneGroup), } diff --git a/vega-wgpu-renderer/src/scene/text.rs b/vega-wgpu-renderer/src/scene/text.rs new file mode 100644 index 0000000..eb3856f --- /dev/null +++ b/vega-wgpu-renderer/src/scene/text.rs @@ -0,0 +1,83 @@ +use crate::error::VegaWgpuError; +use crate::specs::mark::MarkContainerSpec; +use crate::specs::symbol::{SymbolItemSpec, SymbolShape}; +use crate::specs::text::{ + FontStyleSpec, FontWeightSpec, TextAlignSpec, TextBaselineSpec, TextItemSpec, +}; + +#[derive(Debug, Clone)] +pub struct TextMark { + pub instances: Vec, + pub clip: bool, +} + +impl TextMark { + pub fn from_spec( + spec: &MarkContainerSpec, + origin: [f32; 2], + ) -> Result { + let instances = TextInstance::from_specs(spec.items.as_slice(), origin)?; + Ok(Self { + instances, + clip: spec.clip, + }) + } +} + +#[derive(Clone, Debug)] +pub struct TextInstance { + pub text: String, + pub position: [f32; 2], + pub color: [f32; 3], + pub opacity: f32, + pub align: TextAlignSpec, + pub angle: f32, + pub baseline: TextBaselineSpec, + pub dx: f32, + pub dy: f32, + pub font: String, + pub font_size: f32, + pub font_weight: FontWeightSpec, + pub font_style: FontStyleSpec, + pub limit: f32, +} + +impl TextInstance { + pub fn from_spec(item_spec: &TextItemSpec, origin: [f32; 2]) -> Result { + let color = if let Some(fill) = &item_spec.fill { + let c = csscolorparser::parse(fill)?; + [c.r as f32, c.g as f32, c.b as f32] + } else { + [0.0, 0.0, 0.0] + }; + Ok(Self { + text: item_spec.text.clone(), + position: [item_spec.x + origin[0], item_spec.y + origin[1]], + color, + align: item_spec.align.unwrap_or_default(), + angle: item_spec.angle.unwrap_or(0.0), + baseline: item_spec.baseline.unwrap_or_default(), + dx: item_spec.dx.unwrap_or(0.0), + dy: item_spec.dy.unwrap_or(0.0), + opacity: item_spec.fill_opacity.unwrap_or(1.0), + font: item_spec + .font + .clone() + .unwrap_or_else(|| "Liberation Sans".to_string()), + font_size: item_spec.fill_opacity.unwrap_or(12.0), + font_weight: item_spec.font_weight.unwrap_or_default(), + font_style: item_spec.font_style.unwrap_or_default(), + limit: item_spec.limit.unwrap_or(0.0), + }) + } + + pub fn from_specs( + item_specs: &[TextItemSpec], + origin: [f32; 2], + ) -> Result, VegaWgpuError> { + item_specs + .iter() + .map(|item| Self::from_spec(item, origin)) + .collect() + } +} diff --git a/vega-wgpu-renderer/src/specs/mark.rs b/vega-wgpu-renderer/src/specs/mark.rs index 460e535..ffa3857 100644 --- a/vega-wgpu-renderer/src/specs/mark.rs +++ b/vega-wgpu-renderer/src/specs/mark.rs @@ -2,6 +2,7 @@ use crate::specs::group::GroupItemSpec; use crate::specs::rect::RectItemSpec; use crate::specs::rule::RuleItemSpec; use crate::specs::symbol::SymbolItemSpec; +use crate::specs::text::TextItemSpec; use serde::{Deserialize, Serialize}; use std::fmt::Debug; @@ -21,7 +22,7 @@ pub enum MarkSpec { Rule(MarkContainerSpec), Shape, Symbol(MarkContainerSpec), - Text, + Text(MarkContainerSpec), Trail, } diff --git a/vega-wgpu-renderer/src/specs/mod.rs b/vega-wgpu-renderer/src/specs/mod.rs index df266d4..4662b06 100644 --- a/vega-wgpu-renderer/src/specs/mod.rs +++ b/vega-wgpu-renderer/src/specs/mod.rs @@ -4,6 +4,7 @@ pub mod mark; pub mod rect; pub mod rule; pub mod symbol; +pub mod text; use crate::specs::group::GroupItemSpec; use crate::specs::mark::MarkContainerSpec; diff --git a/vega-wgpu-renderer/src/specs/text.rs b/vega-wgpu-renderer/src/specs/text.rs new file mode 100644 index 0000000..9b142b3 --- /dev/null +++ b/vega-wgpu-renderer/src/specs/text.rs @@ -0,0 +1,77 @@ +use crate::specs::mark::MarkItemSpec; +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextItemSpec { + // Required + pub x: f32, + pub y: f32, + pub text: String, + + // Optional + pub align: Option, + pub angle: Option, + pub baseline: Option, + pub dx: Option, + pub dy: Option, + pub fill: Option, + pub fill_opacity: Option, + pub font: Option, + pub font_size: Option, + pub font_weight: Option, + pub font_style: Option, + pub limit: Option, +} + +impl MarkItemSpec for TextItemSpec {} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TextAlignSpec { + #[default] + Left, + Center, + Right, +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TextBaselineSpec { + Alphabetic, + Top, + Middle, + #[default] + Bottom, + LineTop, + LineBottom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FontWeightSpec { + Name(FontWeightNameSpec), + Number(f32), +} + +impl Default for FontWeightSpec { + fn default() -> Self { + Self::Name(FontWeightNameSpec::Normal) + } +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FontWeightNameSpec { + #[default] + Normal, + Bold, +} + +#[derive(Default, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FontStyleSpec { + #[default] + Normal, + Italic, +} From 70c6dd7254b886bb6196f83c5901a8b92a658898 Mon Sep 17 00:00:00 2001 From: Jon Mease Date: Fri, 29 Dec 2023 13:22:07 -0500 Subject: [PATCH 2/2] Add font weight support and add test (still no text rotation support) --- .../vega-specs/text/bar_axis_labels.vg.json | 135 +++++ vega-wgpu-renderer/src/renderers/text.rs | 27 +- vega-wgpu-renderer/src/scene/text.rs | 4 +- .../specs/text/bar_axis_labels.dims.json | 6 + .../tests/specs/text/bar_axis_labels.png | Bin 0 -> 13789 bytes .../tests/specs/text/bar_axis_labels.sg.json | 567 ++++++++++++++++++ .../tests/test_image_baselines.rs | 3 +- 7 files changed, 733 insertions(+), 9 deletions(-) create mode 100644 gen-test-data/vega-specs/text/bar_axis_labels.vg.json create mode 100644 vega-wgpu-renderer/tests/specs/text/bar_axis_labels.dims.json create mode 100644 vega-wgpu-renderer/tests/specs/text/bar_axis_labels.png create mode 100644 vega-wgpu-renderer/tests/specs/text/bar_axis_labels.sg.json diff --git a/gen-test-data/vega-specs/text/bar_axis_labels.vg.json b/gen-test-data/vega-specs/text/bar_axis_labels.vg.json new file mode 100644 index 0000000..e3cf559 --- /dev/null +++ b/gen-test-data/vega-specs/text/bar_axis_labels.vg.json @@ -0,0 +1,135 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "description": "A bar graph showing what activities consume what percentage of the day.", + "background": "white", + "padding": 5, + "width": 200, + "style": "cell", + "config": {"style": {"cell": {"stroke": "transparent"}}}, + "data": [ + { + "name": "source_0", + "values": [ + {"Activity": "Sleeping", "Time": 8}, + {"Activity": "Eating", "Time": 2}, + {"Activity": "TV", "Time": 4}, + {"Activity": "Work", "Time": 8}, + {"Activity": "Exercise", "Time": 2} + ] + }, + { + "name": "data_0", + "source": "source_0", + "transform": [ + { + "type": "joinaggregate", + "as": ["TotalTime"], + "ops": ["sum"], + "fields": ["Time"] + }, + { + "type": "formula", + "expr": "datum.Time/datum.TotalTime * 100", + "as": "PercentOfTotal" + }, + { + "type": "stack", + "groupby": ["Activity"], + "field": "PercentOfTotal", + "sort": {"field": [], "order": []}, + "as": ["PercentOfTotal_start", "PercentOfTotal_end"], + "offset": "zero" + }, + { + "type": "filter", + "expr": "isValid(datum[\"PercentOfTotal\"]) && isFinite(+datum[\"PercentOfTotal\"])" + } + ] + } + ], + "signals": [ + {"name": "y_step", "value": 12}, + { + "name": "height", + "update": "bandspace(domain('y').length, 0.1, 0.05) * y_step" + } + ], + "marks": [ + { + "name": "marks", + "type": "rect", + "style": ["bar"], + "from": {"data": "data_0"}, + "encode": { + "update": { + "fill": {"value": "#4c78a8"}, + "ariaRoleDescription": {"value": "bar"}, + "description": { + "signal": "\"% of total Time: \" + (format(datum[\"PercentOfTotal\"], \"\")) + \"; Activity: \" + (isValid(datum[\"Activity\"]) ? datum[\"Activity\"] : \"\"+datum[\"Activity\"])" + }, + "x": {"scale": "x", "field": "PercentOfTotal_end"}, + "x2": {"scale": "x", "field": "PercentOfTotal_start"}, + "y": {"scale": "y", "field": "Activity"}, + "height": {"signal": "max(0.25, bandwidth('y'))"} + } + } + } + ], + "scales": [ + { + "name": "x", + "type": "linear", + "domain": { + "data": "data_0", + "fields": ["PercentOfTotal_start", "PercentOfTotal_end"] + }, + "range": [0, {"signal": "width"}], + "nice": true, + "zero": true + }, + { + "name": "y", + "type": "band", + "domain": {"data": "data_0", "field": "Activity", "sort": true}, + "range": {"step": {"signal": "y_step"}}, + "paddingInner": 0.1, + "paddingOuter": 0.05 + } + ], + "axes": [ + { + "scale": "x", + "orient": "bottom", + "gridScale": "y", + "grid": true, + "tickCount": {"signal": "ceil(width/40)"}, + "domain": false, + "labels": false, + "aria": false, + "maxExtent": 0, + "minExtent": 0, + "ticks": false, + "zindex": 0 + }, + { + "scale": "x", + "orient": "bottom", + "grid": false, + "title": "% of total Time", + "labelFlush": true, + "labelOverlap": true, + "tickCount": {"signal": "ceil(width/40)"}, + "labelFont": "Helvetica", + "titleFont": "Helvetica", + "zindex": 0 + }, + { + "scale": "y", + "orient": "left", + "grid": false, + "labelFont": "Helvetica", + "titleFont": "Helvetica", + "zindex": 0 + } + ] +} diff --git a/vega-wgpu-renderer/src/renderers/text.rs b/vega-wgpu-renderer/src/renderers/text.rs index 2bf07fe..c33f1ed 100644 --- a/vega-wgpu-renderer/src/renderers/text.rs +++ b/vega-wgpu-renderer/src/renderers/text.rs @@ -1,11 +1,11 @@ use crate::renderers::canvas::CanvasUniform; use crate::renderers::mark::MarkShader; use crate::scene::text::TextInstance; -use crate::specs::text::{TextAlignSpec, TextBaselineSpec}; +use crate::specs::text::{FontWeightNameSpec, FontWeightSpec, TextAlignSpec, TextBaselineSpec}; use glyphon::cosmic_text::Align; use glyphon::{ Attrs, Buffer, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, - TextAtlas, TextBounds, TextRenderer, + TextAtlas, TextBounds, TextRenderer, Weight, }; use wgpu::{ CommandBuffer, CommandEncoderDescriptor, Device, MultisampleState, Operations, Queue, @@ -69,12 +69,26 @@ impl TextMarkRenderer { .map(|instance| { let mut buffer = Buffer::new( &mut self.font_system, - Metrics::new(instance.font_size, instance.font_size * 1.0), + Metrics::new(instance.font_size, instance.font_size), ); + let family = match instance.font.to_lowercase().as_str() { + "serif" => Family::Serif, + "sans serif" => Family::SansSerif, + "cursive" => Family::Cursive, + "fantasy" => Family::Fantasy, + "monospace" => Family::Monospace, + _ => Family::Name(instance.font.as_str()), + }; + let weight = match instance.font_weight { + FontWeightSpec::Name(FontWeightNameSpec::Bold) => Weight::BOLD, + FontWeightSpec::Name(FontWeightNameSpec::Normal) => Weight::NORMAL, + FontWeightSpec::Number(w) => Weight(w as u16), + }; + buffer.set_text( &mut self.font_system, &instance.text, - Attrs::new().family(Family::SansSerif), + Attrs::new().family(family).weight(weight), Shaping::Advanced, ); buffer.set_size( @@ -102,8 +116,9 @@ impl TextMarkRenderer { let top = match instance.baseline { TextBaselineSpec::Alphabetic => instance.position[1] - height, - TextBaselineSpec::Top => instance.position[1], - TextBaselineSpec::Middle => instance.position[1] - height * 0.56, + // Add half pixel for top baseline for better match with resvg + TextBaselineSpec::Top => instance.position[1] + 0.5, + TextBaselineSpec::Middle => instance.position[1] - height * 0.5, TextBaselineSpec::Bottom => instance.position[1] - height, TextBaselineSpec::LineTop => todo!(), TextBaselineSpec::LineBottom => todo!(), diff --git a/vega-wgpu-renderer/src/scene/text.rs b/vega-wgpu-renderer/src/scene/text.rs index eb3856f..db77b6c 100644 --- a/vega-wgpu-renderer/src/scene/text.rs +++ b/vega-wgpu-renderer/src/scene/text.rs @@ -63,8 +63,8 @@ impl TextInstance { font: item_spec .font .clone() - .unwrap_or_else(|| "Liberation Sans".to_string()), - font_size: item_spec.fill_opacity.unwrap_or(12.0), + .unwrap_or_else(|| "Sans Serif".to_string()), + font_size: item_spec.font_size.unwrap_or(10.0), font_weight: item_spec.font_weight.unwrap_or_default(), font_style: item_spec.font_style.unwrap_or_default(), limit: item_spec.limit.unwrap_or(0.0), diff --git a/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.dims.json b/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.dims.json new file mode 100644 index 0000000..25e83d0 --- /dev/null +++ b/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.dims.json @@ -0,0 +1,6 @@ +{ + "width": 257, + "height": 102, + "origin_x": 51, + "origin_y": 5 +} \ No newline at end of file diff --git a/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.png b/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.png new file mode 100644 index 0000000000000000000000000000000000000000..bda863534786a17de6d1a2f46cb79db1167e0f5b GIT binary patch literal 13789 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