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/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/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..c33f1ed --- /dev/null +++ b/vega-wgpu-renderer/src/renderers/text.rs @@ -0,0 +1,195 @@ +use crate::renderers::canvas::CanvasUniform; +use crate::renderers::mark::MarkShader; +use crate::scene::text::TextInstance; +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, Weight, +}; +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), + ); + 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).weight(weight), + 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, + // 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!(), + }; + + 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..db77b6c --- /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(|| "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), + }) + } + + 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, +} 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 0000000..bda8635 Binary files /dev/null and b/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.png differ diff --git a/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.sg.json b/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.sg.json new file mode 100644 index 0000000..9556005 --- /dev/null +++ b/vega-wgpu-renderer/tests/specs/text/bar_axis_labels.sg.json @@ -0,0 +1,567 @@ +{ + "marktype": "group", + "name": "root", + "role": "frame", + "interactive": true, + "clip": false, + "items": [ + { + "items": [ + { + "marktype": "group", + "role": "axis", + "interactive": false, + "clip": false, + "items": [ + { + "items": [ + { + "marktype": "rule", + "role": "axis-grid", + "interactive": false, + "clip": false, + "items": [ + { + "x": 0, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 29, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 57, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 86, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 114, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 143, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 171, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + }, + { + "x": 200, + "y": 0, + "opacity": 1, + "stroke": "#ddd", + "strokeWidth": 1, + "y2": -60 + } + ], + "zindex": 0 + } + ], + "x": 0.5, + "y": 60.5, + "orient": "bottom" + } + ], + "zindex": 0, + "aria": false + }, + { + "marktype": "group", + "role": "axis", + "interactive": false, + "clip": false, + "items": [ + { + "items": [ + { + "marktype": "rule", + "role": "axis-tick", + "interactive": false, + "clip": false, + "items": [ + { + "x": 0, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 29, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 57, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 86, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 114, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 143, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 171, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + }, + { + "x": 200, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 5 + } + ], + "zindex": 0 + }, + { + "marktype": "text", + "role": "axis-label", + "interactive": false, + "clip": false, + "items": [ + { + "x": 0, + "y": 7, + "align": "left", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "0", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 28.57142857142857, + "y": 7, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "5", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 57.14285714285714, + "y": 7, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "10", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 85.71428571428571, + "y": 7, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "15", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 114.28571428571428, + "y": 7, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "20", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 142.85714285714286, + "y": 7, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "25", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 171.42857142857142, + "y": 7, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "30", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": 200, + "y": 7, + "align": "right", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "35", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + } + ], + "zindex": 0 + }, + { + "marktype": "rule", + "role": "axis-domain", + "interactive": false, + "clip": false, + "items": [ + { + "x": 0, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "x2": 200 + } + ], + "zindex": 0 + }, + { + "marktype": "text", + "role": "axis-title", + "interactive": false, + "clip": false, + "items": [ + { + "x": 100, + "y": 21, + "align": "center", + "baseline": "top", + "fill": "#000", + "opacity": 1, + "text": "% of total Time", + "angle": 0, + "font": "Helvetica", + "fontSize": 11, + "fontWeight": "bold" + } + ], + "zindex": 0 + } + ], + "x": 0.5, + "y": 60.5, + "orient": "bottom" + } + ], + "zindex": 0 + }, + { + "marktype": "group", + "role": "axis", + "interactive": false, + "clip": false, + "items": [ + { + "items": [ + { + "marktype": "rule", + "role": "axis-tick", + "interactive": false, + "clip": false, + "items": [ + { + "x": 0, + "y": 5, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "x2": -5 + }, + { + "x": 0, + "y": 18, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "x2": -5 + }, + { + "x": 0, + "y": 30, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "x2": -5 + }, + { + "x": 0, + "y": 41, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "x2": -5 + }, + { + "x": 0, + "y": 53, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "x2": -5 + } + ], + "zindex": 0 + }, + { + "marktype": "text", + "role": "axis-label", + "interactive": false, + "clip": false, + "items": [ + { + "x": -7, + "y": 5.499999999999998, + "align": "right", + "baseline": "middle", + "fill": "#000", + "opacity": 1, + "text": "Eating", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": -7, + "y": 17.5, + "align": "right", + "baseline": "middle", + "fill": "#000", + "opacity": 1, + "text": "Exercise", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": -7, + "y": 29.5, + "align": "right", + "baseline": "middle", + "fill": "#000", + "opacity": 1, + "text": "Sleeping", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": -7, + "y": 41.49999999999999, + "align": "right", + "baseline": "middle", + "fill": "#000", + "opacity": 1, + "text": "TV", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + }, + { + "x": -7, + "y": 53.49999999999999, + "align": "right", + "baseline": "middle", + "fill": "#000", + "opacity": 1, + "text": "Work", + "angle": 0, + "limit": 180, + "font": "Helvetica", + "fontSize": 10 + } + ], + "zindex": 0 + }, + { + "marktype": "rule", + "role": "axis-domain", + "interactive": false, + "clip": false, + "items": [ + { + "x": 0, + "y": 0, + "opacity": 1, + "stroke": "#888", + "strokeWidth": 1, + "y2": 60 + } + ], + "zindex": 0 + } + ], + "x": 0.5, + "y": 0.5, + "orient": "left" + } + ], + "zindex": 0 + }, + { + "marktype": "rect", + "name": "marks", + "role": "mark", + "interactive": true, + "clip": false, + "items": [ + { + "x": 0, + "y": 24.599999999999998, + "width": 190.47619047619045, + "height": 10.8, + "fill": "#4c78a8", + "x2": 190.47619047619045, + "description": "% of total Time: 33.3333333333; Activity: Sleeping", + "ariaRoleDescription": "bar" + }, + { + "x": 0, + "y": 0.5999999999999979, + "width": 47.61904761904761, + "height": 10.8, + "fill": "#4c78a8", + "x2": 47.61904761904761, + "description": "% of total Time: 8.33333333333; Activity: Eating", + "ariaRoleDescription": "bar" + }, + { + "x": 0, + "y": 36.599999999999994, + "width": 95.23809523809523, + "height": 10.8, + "fill": "#4c78a8", + "x2": 95.23809523809523, + "description": "% of total Time: 16.6666666667; Activity: TV", + "ariaRoleDescription": "bar" + }, + { + "x": 0, + "y": 48.599999999999994, + "width": 190.47619047619045, + "height": 10.8, + "fill": "#4c78a8", + "x2": 190.47619047619045, + "description": "% of total Time: 33.3333333333; Activity: Work", + "ariaRoleDescription": "bar" + }, + { + "x": 0, + "y": 12.599999999999998, + "width": 47.61904761904761, + "height": 10.8, + "fill": "#4c78a8", + "x2": 47.61904761904761, + "description": "% of total Time: 8.33333333333; Activity: Exercise", + "ariaRoleDescription": "bar" + } + ], + "zindex": 0 + } + ], + "x": 0, + "y": 0, + "width": 200, + "height": 60, + "fill": "transparent", + "stroke": "transparent" + } + ], + "zindex": 0 +} \ No newline at end of file diff --git a/vega-wgpu-renderer/tests/test_image_baselines.rs b/vega-wgpu-renderer/tests/test_image_baselines.rs index cb5d239..48b8635 100644 --- a/vega-wgpu-renderer/tests/test_image_baselines.rs +++ b/vega-wgpu-renderer/tests/test_image_baselines.rs @@ -28,7 +28,8 @@ mod test_image_baselines { case("symbol", "binned_scatter_arrow", 0.001), case("symbol", "binned_scatter_cross", 0.001), case("symbol", "binned_scatter_circle", 0.001), - case("rule", "wide_rule_axes", 0.0001) + case("rule", "wide_rule_axes", 0.0001), + case("text", "bar_axis_labels", 0.01) )] fn test_image_baseline(category: &str, spec_name: &str, tolerance: f64) { let specs_dir = format!("{}/tests/specs/{category}", env!("CARGO_MANIFEST_DIR"));