| author | |
| committer | |
| log | 939081be8a62e17d4e2ec4abb68c052f8e3c506d |
| tree | 41bced825034984b492780c823359f7039912725 |
| parent | 1b0d6e5c862e65471df75c1239a1a111b9c11302 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Page and application chrome will paint through one renderer, atlas and
image cache, while an iOS host embeds the canvas without the desktop UI
kit. draw paints layers (transform, device clip, primitives); text
arrives as glyph runs through the Glyphs trait, so canvas keeps its
Windows-metric line boxes and the renderer never measures text. Tag
artwork becomes a generic SVG icon primitive. canvas without the gpu
feature stays free of wgpu for the fuzz targets.
All 23 corpus pages render pixel-identical to the previous renderer.
Assisted-by: claude-opus-5.519 files changed, 2471 insertions(+), 1883 deletions(-)
Cargo.lock+16-4| ... | ... | @@ -395,17 +395,14 @@ version = "0.1.0" |
| 395 | 395 | dependencies = [ |
| 396 | 396 | "accesskit", |
| 397 | 397 | "accesskit_consumer", |
| 398 | "bytemuck", | |
| 399 | "image", | |
| 398 | "draw", | |
| 400 | 399 | "onestore", |
| 401 | 400 | "parley", |
| 402 | 401 | "png", |
| 403 | 402 | "pollster", |
| 404 | "roxmltree", | |
| 405 | 403 | "serde", |
| 406 | 404 | "serde_json", |
| 407 | 405 | "skrifa", |
| 408 | "swash", | |
| 409 | 406 | "wgpu", |
| 410 | 407 | ] |
| 411 | 408 | |
| ... | ... | @@ -724,6 +721,20 @@ version = "0.1.2" |
| 724 | 721 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 725 | 722 | checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" |
| 726 | 723 | |
| 724 | [[package]] | |
| 725 | name = "draw" | |
| 726 | version = "0.1.0" | |
| 727 | dependencies = [ | |
| 728 | "bytemuck", | |
| 729 | "image", | |
| 730 | "parley", | |
| 731 | "png", | |
| 732 | "pollster", | |
| 733 | "roxmltree", | |
| 734 | "swash", | |
| 735 | "wgpu", | |
| 736 | ] | |
| 737 | ||
| 727 | 738 | [[package]] |
| 728 | 739 | name = "equivalent" |
| 729 | 740 | version = "1.0.2" |
| ... | ... | @@ -2632,6 +2643,7 @@ dependencies = [ |
| 2632 | 2643 | "accesskit_winit", |
| 2633 | 2644 | "arboard", |
| 2634 | 2645 | "canvas", |
| 2646 | "draw", | |
| 2635 | 2647 | "notebook", |
| 2636 | 2648 | "objc2 0.5.2", |
| 2637 | 2649 | "objc2-app-kit 0.2.2", |
crates/canvas/Cargo.toml+3-6| ... | ... | @@ -5,7 +5,7 @@ edition = "2024" |
| 5 | 5 | publish = false |
| 6 | 6 | |
| 7 | 7 | [features] |
| 8 | gpu = ["dep:bytemuck", "dep:swash", "dep:wgpu", "dep:image", "dep:roxmltree"] | |
| 8 | gpu = ["dep:draw"] | |
| 9 | 9 | # How a page responds to input and presents itself to assistive technology. |
| 10 | 10 | interaction = ["gpu", "dep:accesskit"] |
| 11 | 11 | |
| ... | ... | @@ -16,11 +16,7 @@ serde = { version = "1.0.229", features = ["derive"] } |
| 16 | 16 | serde_json = "1.0.151" |
| 17 | 17 | skrifa = "0.44.0" |
| 18 | 18 | |
| 19 | bytemuck = { version = "1.25", features = ["derive"], optional = true } | |
| 20 | swash = { version = "0.2.10", optional = true } | |
| 21 | wgpu = { workspace = true, optional = true } | |
| 22 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } | |
| 23 | roxmltree = { version = "0.21", optional = true } | |
| 19 | draw = { path = "../draw", optional = true } | |
| 24 | 20 | accesskit = { version = "0.25.0", optional = true } |
| 25 | 21 | |
| 26 | 22 | [[bin]] |
| ... | ... | @@ -35,6 +31,7 @@ path = "src/bin/page_probe.rs" |
| 35 | 31 | accesskit_consumer = "0.39.0" |
| 36 | 32 | pollster = "0.4" |
| 37 | 33 | png = "0.18" |
| 34 | wgpu.workspace = true | |
| 38 | 35 | |
| 39 | 36 | [[example]] |
| 40 | 37 | name = "render_page" |
crates/canvas/examples/render_page.rs+13-10| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | use canvas::gpu::{Renderer, Viewport, page::PageScene}; | |
| 1 | use canvas::gpu::{Viewport, page::PageScene}; | |
| 2 | 2 | use canvas::layout::TextEngine; |
| 3 | use draw::Renderer; | |
| 3 | 4 | use onestore::page::Page; |
| 4 | 5 | use onestore::{RevisionIndex, Store, document::Document}; |
| 5 | 6 | use std::{env, fs, sync::Arc, time::Duration}; |
| ... | ... | @@ -67,18 +68,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 67 | 68 | mapped_at_creation: false, |
| 68 | 69 | }); |
| 69 | 70 | let mut renderer = Renderer::new(device, queue, format); |
| 71 | let viewport = Viewport { | |
| 72 | size, | |
| 73 | scale: 96.0 / 72.0, | |
| 74 | origin: [ | |
| 75 | (36.0 - margin_origin[0]) * (96.0 / 72.0), | |
| 76 | (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, | |
| 77 | ], | |
| 78 | }; | |
| 70 | 79 | renderer |
| 71 | 80 | .draw( |
| 72 | 81 | &target.create_view(&Default::default()), |
| 73 | Viewport { | |
| 74 | size, | |
| 75 | scale: 96.0 / 72.0, | |
| 76 | origin: [ | |
| 77 | (36.0 - margin_origin[0]) * (96.0 / 72.0), | |
| 78 | (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, | |
| 79 | ], | |
| 80 | }, | |
| 81 | &primitives, | |
| 82 | size, | |
| 83 | [1.0; 4], | |
| 84 | &[viewport.layer(&primitives)], | |
| 82 | 85 | ) |
| 83 | 86 | .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; |
| 84 | 87 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); |
crates/canvas/src/gpu/canvas.wgsl deleted-87| ... | ... | @@ -1,87 +0,0 @@ |
| 1 | struct Vertex { | |
| 2 | @builtin(position) position: vec4<f32>, | |
| 3 | @location(0) uv: vec2<f32>, | |
| 4 | @location(1) color: vec4<f32>, | |
| 5 | @location(2) local: vec2<f32>, | |
| 6 | @location(3) @interpolate(flat) shape: vec4<f32>, | |
| 7 | @location(4) @interpolate(flat) stroke: f32, | |
| 8 | } | |
| 9 | ||
| 10 | @group(0) @binding(0) var atlas: texture_2d<f32>; | |
| 11 | @group(0) @binding(1) var atlas_sampler: sampler; | |
| 12 | ||
| 13 | @vertex | |
| 14 | fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>, | |
| 15 | @location(2) color: vec4<f32>, @location(3) local: vec2<f32>, | |
| 16 | @location(4) shape: vec4<f32>, @location(5) stroke: f32) -> Vertex { | |
| 17 | return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke); | |
| 18 | } | |
| 19 | ||
| 20 | fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 { | |
| 21 | if radius.x == radius.y { | |
| 22 | return angle * radius.x; | |
| 23 | } | |
| 24 | // Four-point Gauss-Legendre quadrature. | |
| 25 | let nodes = vec4<f32>(-0.86113631, -0.33998104, 0.33998104, 0.86113631); | |
| 26 | let weights = vec4<f32>(0.34785485, 0.65214515, 0.65214515, 0.34785485); | |
| 27 | let half = angle * 0.5; | |
| 28 | let scale = max(radius.x, radius.y); | |
| 29 | var sum = 0.0; | |
| 30 | for (var i = 0u; i < 4u; i += 1u) { | |
| 31 | let t = half * (nodes[i] + 1.0); | |
| 32 | sum += weights[i] * length((radius / scale) * vec2<f32>(sin(t), cos(t))); | |
| 33 | } | |
| 34 | return half * sum * scale; | |
| 35 | } | |
| 36 | ||
| 37 | @fragment | |
| 38 | fn fragment(input: Vertex) -> @location(0) vec4<f32> { | |
| 39 | var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; | |
| 40 | if input.shape.x > 0.0 && input.shape.y > 0.0 { | |
| 41 | let q = abs(input.local) - input.shape.xy + input.shape.zw; | |
| 42 | var distance: f32; | |
| 43 | if input.shape.z == input.shape.w { | |
| 44 | distance = length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - input.shape.z; | |
| 45 | } else if all(q > vec2<f32>(0.0)) { | |
| 46 | let radius = max(input.shape.zw, vec2<f32>(0.0001)); | |
| 47 | let normalized = q / radius; | |
| 48 | let k0 = length(normalized); | |
| 49 | distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); | |
| 50 | } else { | |
| 51 | let edge = abs(input.local) - input.shape.xy; | |
| 52 | distance = max(edge.x, edge.y); | |
| 53 | } | |
| 54 | var coverage = clamp(0.5 - distance, 0.0, 1.0); | |
| 55 | let width = abs(input.stroke); | |
| 56 | if width > 0.0 { | |
| 57 | coverage *= clamp(distance + width + 0.5, 0.0, 1.0); | |
| 58 | } | |
| 59 | if input.stroke < 0.0 { | |
| 60 | var radius = max(input.shape.zw - width * 0.5, vec2<f32>(0.0)); | |
| 61 | if any(radius == vec2<f32>(0.0)) { | |
| 62 | radius = vec2<f32>(0.0); | |
| 63 | } | |
| 64 | let straight = input.shape.xy - width * 0.5 - radius; | |
| 65 | let p = abs(input.local); | |
| 66 | let quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); | |
| 67 | var along: f32; | |
| 68 | if p.y <= straight.y { | |
| 69 | along = p.y; | |
| 70 | } else if p.x <= straight.x || radius.x == 0.0 { | |
| 71 | along = quarter - p.x; | |
| 72 | } else { | |
| 73 | let angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); | |
| 74 | along = straight.y + ellipse_arc(angle, radius); | |
| 75 | } | |
| 76 | if input.local.x < 0.0 { | |
| 77 | along = select(2.0 * quarter - along, 2.0 * quarter + along, input.local.y < 0.0); | |
| 78 | } else if input.local.y < 0.0 { | |
| 79 | along = 4.0 * quarter - along; | |
| 80 | } | |
| 81 | let phase = fract(along / (4.0 * width)) * (4.0 * width); | |
| 82 | coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0); | |
| 83 | } | |
| 84 | color *= coverage; | |
| 85 | } | |
| 86 | return color; | |
| 87 | } |
crates/canvas/src/gpu/mod.rs+144-1562| ... | ... | @@ -1,150 +1,13 @@ |
| 1 | 1 | pub mod page; |
| 2 | 2 | #[cfg(test)] |
| 3 | 3 | mod profile; |
| 4 | mod tags; | |
| 5 | 4 | |
| 6 | 5 | use crate::{ |
| 7 | layout::TextLayout, | |
| 8 | outline::{ParagraphTag, TagIcon}, | |
| 6 | layout::{TextBrush, TextLayout}, | |
| 7 | outline::TagIcon, | |
| 9 | 8 | }; |
| 10 | use bytemuck::{Pod, Zeroable}; | |
| 11 | use image::ImageDecoder; | |
| 12 | use parley::{PositionedLayoutItem, fontique::Blob}; | |
| 13 | use std::{ | |
| 14 | collections::{HashMap, HashSet}, | |
| 15 | ops::Range, | |
| 16 | }; | |
| 17 | use swash::{ | |
| 18 | FontRef, | |
| 19 | scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, | |
| 20 | zeno::{Angle, Format, Transform, Vector}, | |
| 21 | }; | |
| 22 | ||
| 23 | const ATLAS_SIZE: u32 = 2048; | |
| 24 | const MAX_GLYPHS: usize = 8192; | |
| 25 | const MAX_VERTICES: usize = 65_536; | |
| 26 | const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; | |
| 27 | const MAX_IMAGES: usize = 256; | |
| 28 | ||
| 29 | #[derive(Clone)] | |
| 30 | pub struct RasterImage { | |
| 31 | size: [u32; 2], | |
| 32 | pixels: Blob<u8>, | |
| 33 | } | |
| 34 | ||
| 35 | impl RasterImage { | |
| 36 | pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> { | |
| 37 | if encoded.len() as u64 > MAX_IMAGE_BYTES { | |
| 38 | return Err(RenderError::ImageBudget); | |
| 39 | } | |
| 40 | let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; | |
| 41 | let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); | |
| 42 | let mut limits = image::Limits::default(); | |
| 43 | limits.max_image_width = Some(16_384); | |
| 44 | limits.max_image_height = Some(16_384); | |
| 45 | limits.max_alloc = Some(MAX_IMAGE_BYTES); | |
| 46 | reader.limits(limits.clone()); | |
| 47 | let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; | |
| 48 | let (width, height) = decoder.dimensions(); | |
| 49 | if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES | |
| 50 | || decoder.total_bytes() > MAX_IMAGE_BYTES | |
| 51 | { | |
| 52 | return Err(RenderError::ImageBudget); | |
| 53 | } | |
| 54 | limits | |
| 55 | .reserve(decoder.total_bytes()) | |
| 56 | .map_err(RenderError::ImageDecode)?; | |
| 57 | decoder | |
| 58 | .set_limits(limits) | |
| 59 | .map_err(RenderError::ImageDecode)?; | |
| 60 | let pixels = image::DynamicImage::from_decoder(decoder) | |
| 61 | .map_err(RenderError::ImageDecode)? | |
| 62 | .into_rgba8(); | |
| 63 | Self::new([width, height], pixels.into_raw()) | |
| 64 | } | |
| 65 | ||
| 66 | /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. | |
| 67 | pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> { | |
| 68 | let bytes = u64::from(size[0]) | |
| 69 | .checked_mul(u64::from(size[1])) | |
| 70 | .and_then(|v| v.checked_mul(4)) | |
| 71 | .ok_or(RenderError::InvalidImage)?; | |
| 72 | if size.contains(&0) || bytes != pixels.len() as u64 { | |
| 73 | return Err(RenderError::InvalidImage); | |
| 74 | } | |
| 75 | if bytes > MAX_IMAGE_BYTES { | |
| 76 | return Err(RenderError::ImageBudget); | |
| 77 | } | |
| 78 | // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. | |
| 79 | for pixel in pixels.chunks_exact_mut(4) { | |
| 80 | let alpha = pixel[3]; | |
| 81 | if alpha == 255 { | |
| 82 | continue; | |
| 83 | } | |
| 84 | let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap())); | |
| 85 | for (byte, value) in pixel[..3].iter_mut().zip(linear) { | |
| 86 | let value = value * f32::from(alpha) / 255.0; | |
| 87 | *byte = srgb_byte(value); | |
| 88 | } | |
| 89 | } | |
| 90 | Ok(Self { | |
| 91 | size, | |
| 92 | pixels: pixels.into(), | |
| 93 | }) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | struct CachedImage { | |
| 98 | binding: wgpu::BindGroup, | |
| 99 | bytes: u64, | |
| 100 | } | |
| 101 | ||
| 102 | struct Batch { | |
| 103 | vertices: Range<u32>, | |
| 104 | image: Option<u64>, | |
| 105 | scissor: [u32; 4], | |
| 106 | } | |
| 107 | ||
| 108 | #[repr(C)] | |
| 109 | #[derive(Clone, Copy, Pod, Zeroable)] | |
| 110 | struct Vertex { | |
| 111 | position: [f32; 2], | |
| 112 | uv: [f32; 2], | |
| 113 | color: [f32; 4], | |
| 114 | local: [f32; 2], | |
| 115 | shape: [f32; 4], | |
| 116 | stroke: f32, | |
| 117 | } | |
| 118 | ||
| 119 | #[derive(Hash, PartialEq, Eq)] | |
| 120 | enum AtlasKey { | |
| 121 | Text { | |
| 122 | font: u64, | |
| 123 | index: u32, | |
| 124 | glyph: u16, | |
| 125 | size: u32, | |
| 126 | coords: Vec<i16>, | |
| 127 | phase: [u8; 2], | |
| 128 | embolden: bool, | |
| 129 | skew: u32, | |
| 130 | }, | |
| 131 | Tag { | |
| 132 | icon: TagIcon, | |
| 133 | size: u32, | |
| 134 | phase: [u8; 2], | |
| 135 | }, | |
| 136 | } | |
| 137 | ||
| 138 | #[derive(Clone, Copy)] | |
| 139 | struct AtlasGlyph { | |
| 140 | x: u32, | |
| 141 | y: u32, | |
| 142 | width: u32, | |
| 143 | height: u32, | |
| 144 | left: i32, | |
| 145 | top: i32, | |
| 146 | color: bool, | |
| 147 | } | |
| 9 | use draw::{GlyphRun, Glyphs, Layer, Primitive, RenderError}; | |
| 10 | use parley::PositionedLayoutItem; | |
| 148 | 11 | |
| 149 | 12 | #[derive(Clone, Copy)] |
| 150 | 13 | pub struct Viewport { |
| ... | ... | @@ -163,1492 +26,211 @@ impl Viewport { |
| 163 | 26 | ] |
| 164 | 27 | } |
| 165 | 28 | |
| 166 | fn visible_image_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> { | |
| 167 | let rect = [ | |
| 168 | rect[0] * self.scale + self.origin[0], | |
| 169 | rect[1] * self.scale + self.origin[1], | |
| 170 | rect[2] * self.scale + self.origin[0], | |
| 171 | rect[3] * self.scale + self.origin[1], | |
| 172 | ]; | |
| 173 | if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { | |
| 174 | return Err(RenderError::InvalidPrimitive); | |
| 29 | /// Document-point primitives drawn through this viewport. | |
| 30 | pub fn layer<'a>(&self, primitives: &'a [Primitive<'a>]) -> Layer<'a> { | |
| 31 | Layer { | |
| 32 | scale: self.scale, | |
| 33 | origin: self.origin, | |
| 34 | clip: None, | |
| 35 | primitives, | |
| 175 | 36 | } |
| 176 | Ok((rect[0] < self.size[0] as f32 | |
| 177 | && rect[1] < self.size[1] as f32 | |
| 178 | && rect[2] > 0.0 | |
| 179 | && rect[3] > 0.0 | |
| 180 | && rect[0] < rect[2] | |
| 181 | && rect[1] < rect[3]) | |
| 182 | .then_some(rect)) | |
| 183 | 37 | } |
| 184 | 38 | } |
| 185 | 39 | |
| 186 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 187 | pub enum Stroke { | |
| 188 | Solid(f32), | |
| 189 | /// Dash and gap lengths are twice the stroke width. | |
| 190 | Dashed(f32), | |
| 191 | } | |
| 192 | ||
| 193 | /// Coordinates are document points; colors are linear RGBA. | |
| 194 | pub enum Primitive<'a> { | |
| 195 | Tag { | |
| 196 | tag: &'a ParagraphTag, | |
| 197 | origin: [f32; 2], | |
| 198 | }, | |
| 199 | Text { | |
| 200 | layout: &'a TextLayout, | |
| 201 | origin: [f32; 2], | |
| 202 | /// Document-space paint bounds preserve the full shaping and caret advances. | |
| 203 | clip: Option<[f32; 4]>, | |
| 204 | }, | |
| 205 | Rect { | |
| 206 | rect: [f32; 4], | |
| 207 | color: [f32; 4], | |
| 208 | }, | |
| 209 | RoundedRect { | |
| 210 | rect: [f32; 4], | |
| 211 | radius: [f32; 2], | |
| 212 | /// None fills the shape; stroke widths are positive document points. | |
| 213 | stroke: Option<Stroke>, | |
| 214 | color: [f32; 4], | |
| 215 | }, | |
| 216 | Image { | |
| 217 | image: &'a RasterImage, | |
| 218 | rect: [f32; 4], | |
| 219 | }, | |
| 220 | /// A pen stroke between two points, `width` points across. | |
| 221 | Segment { | |
| 222 | from: [f32; 2], | |
| 223 | to: [f32; 2], | |
| 224 | width: f32, | |
| 225 | /// A round pen tip caps the ends; otherwise they are square. | |
| 226 | round: bool, | |
| 227 | color: [f32; 4], | |
| 228 | }, | |
| 229 | } | |
| 230 | ||
| 231 | #[derive(Debug)] | |
| 232 | pub enum RenderError { | |
| 233 | AtlasFull, | |
| 234 | FrameTooLarge, | |
| 235 | UnreadableFont, | |
| 236 | UnsupportedGlyph, | |
| 237 | InvalidViewport, | |
| 238 | InvalidPrimitive, | |
| 239 | InvalidImage, | |
| 240 | ImageBudget, | |
| 241 | ImageTooLarge, | |
| 242 | ImageDecode(image::ImageError), | |
| 243 | } | |
| 244 | ||
| 245 | pub struct Renderer { | |
| 246 | pub device: wgpu::Device, | |
| 247 | pub queue: wgpu::Queue, | |
| 248 | pipeline: wgpu::RenderPipeline, | |
| 249 | image_pipeline: wgpu::RenderPipeline, | |
| 250 | atlas: wgpu::Texture, | |
| 251 | bind_group: wgpu::BindGroup, | |
| 252 | image_sampler: wgpu::Sampler, | |
| 253 | vertex_buffer: wgpu::Buffer, | |
| 254 | vertices: Vec<Vertex>, | |
| 255 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, | |
| 256 | images: HashMap<u64, CachedImage>, | |
| 257 | batches: Vec<Batch>, | |
| 258 | scaler: ScaleContext, | |
| 259 | pen: [u32; 2], | |
| 260 | row_height: u32, | |
| 261 | } | |
| 262 | ||
| 263 | impl Renderer { | |
| 264 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | |
| 265 | let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl")); | |
| 266 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | |
| 267 | label: Some("Canvas texture"), | |
| 268 | entries: &[ | |
| 269 | wgpu::BindGroupLayoutEntry { | |
| 270 | binding: 0, | |
| 271 | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 272 | ty: wgpu::BindingType::Texture { | |
| 273 | sample_type: wgpu::TextureSampleType::Float { filterable: true }, | |
| 274 | view_dimension: wgpu::TextureViewDimension::D2, | |
| 275 | multisampled: false, | |
| 276 | }, | |
| 277 | count: None, | |
| 278 | }, | |
| 279 | wgpu::BindGroupLayoutEntry { | |
| 280 | binding: 1, | |
| 281 | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 282 | ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), | |
| 283 | count: None, | |
| 284 | }, | |
| 285 | ], | |
| 286 | }); | |
| 287 | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { | |
| 288 | label: Some("Canvas"), | |
| 289 | bind_group_layouts: &[Some(&binding_layout)], | |
| 290 | ..Default::default() | |
| 291 | }); | |
| 292 | let make_pipeline = |blend| { | |
| 293 | device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { | |
| 294 | label: Some("Canvas"), | |
| 295 | layout: Some(&pipeline_layout), | |
| 296 | vertex: wgpu::VertexState { | |
| 297 | module: &shader, | |
| 298 | entry_point: Some("vertex"), | |
| 299 | compilation_options: Default::default(), | |
| 300 | buffers: &[Some(wgpu::VertexBufferLayout { | |
| 301 | array_stride: size_of::<Vertex>() as u64, | |
| 302 | step_mode: wgpu::VertexStepMode::Vertex, | |
| 303 | attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], | |
| 304 | })], | |
| 305 | }, | |
| 306 | fragment: Some(wgpu::FragmentState { | |
| 307 | module: &shader, | |
| 308 | entry_point: Some("fragment"), | |
| 309 | compilation_options: Default::default(), | |
| 310 | targets: &[Some(wgpu::ColorTargetState { | |
| 311 | format, | |
| 312 | blend: Some(blend), | |
| 313 | write_mask: wgpu::ColorWrites::ALL, | |
| 314 | })], | |
| 315 | }), | |
| 316 | primitive: Default::default(), | |
| 317 | depth_stencil: None, | |
| 318 | multisample: Default::default(), | |
| 319 | multiview_mask: None, | |
| 320 | cache: None, | |
| 321 | }) | |
| 322 | }; | |
| 323 | let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); | |
| 324 | let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); | |
| 325 | let atlas = device.create_texture(&wgpu::TextureDescriptor { | |
| 326 | label: Some("Glyph atlas"), | |
| 327 | size: wgpu::Extent3d { | |
| 328 | width: ATLAS_SIZE, | |
| 329 | height: ATLAS_SIZE, | |
| 330 | depth_or_array_layers: 1, | |
| 331 | }, | |
| 332 | mip_level_count: 1, | |
| 333 | sample_count: 1, | |
| 334 | dimension: wgpu::TextureDimension::D2, | |
| 335 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 336 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 337 | view_formats: &[], | |
| 338 | }); | |
| 339 | let view = atlas.create_view(&Default::default()); | |
| 340 | let sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 341 | mag_filter: wgpu::FilterMode::Nearest, | |
| 342 | min_filter: wgpu::FilterMode::Nearest, | |
| 343 | ..Default::default() | |
| 344 | }); | |
| 345 | let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 346 | label: Some("Glyph atlas"), | |
| 347 | layout: &pipeline.get_bind_group_layout(0), | |
| 348 | entries: &[ | |
| 349 | wgpu::BindGroupEntry { | |
| 350 | binding: 0, | |
| 351 | resource: wgpu::BindingResource::TextureView(&view), | |
| 352 | }, | |
| 353 | wgpu::BindGroupEntry { | |
| 354 | binding: 1, | |
| 355 | resource: wgpu::BindingResource::Sampler(&sampler), | |
| 356 | }, | |
| 357 | ], | |
| 358 | }); | |
| 359 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { | |
| 360 | label: Some("Canvas vertices"), | |
| 361 | size: (MAX_VERTICES * size_of::<Vertex>()) as u64, | |
| 362 | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, | |
| 363 | mapped_at_creation: false, | |
| 364 | }); | |
| 365 | let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 366 | label: Some("Canvas image filtering"), | |
| 367 | mag_filter: wgpu::FilterMode::Linear, | |
| 368 | min_filter: wgpu::FilterMode::Linear, | |
| 369 | ..Default::default() | |
| 370 | }); | |
| 371 | queue.write_texture( | |
| 372 | atlas.as_image_copy(), | |
| 373 | &[255; 4], | |
| 374 | wgpu::TexelCopyBufferLayout { | |
| 375 | offset: 0, | |
| 376 | bytes_per_row: Some(4), | |
| 377 | rows_per_image: Some(1), | |
| 378 | }, | |
| 379 | wgpu::Extent3d { | |
| 380 | width: 1, | |
| 381 | height: 1, | |
| 382 | depth_or_array_layers: 1, | |
| 383 | }, | |
| 384 | ); | |
| 385 | Self { | |
| 386 | device, | |
| 387 | queue, | |
| 388 | pipeline, | |
| 389 | image_pipeline, | |
| 390 | atlas, | |
| 391 | bind_group, | |
| 392 | image_sampler, | |
| 393 | vertex_buffer, | |
| 394 | vertices: Vec::new(), | |
| 395 | glyphs: HashMap::new(), | |
| 396 | images: HashMap::new(), | |
| 397 | batches: Vec::new(), | |
| 398 | scaler: ScaleContext::with_max_entries(32), | |
| 399 | pen: [1, 0], | |
| 400 | row_height: 1, | |
| 401 | } | |
| 402 | } | |
| 403 | ||
| 404 | pub fn clear_glyph_cache(&mut self) { | |
| 405 | self.glyphs.clear(); | |
| 406 | self.pen = [1, 0]; | |
| 407 | self.row_height = 1; | |
| 408 | } | |
| 409 | ||
| 410 | pub fn draw( | |
| 411 | &mut self, | |
| 412 | target: &wgpu::TextureView, | |
| 413 | viewport: Viewport, | |
| 414 | primitives: &[Primitive<'_>], | |
| 40 | impl Glyphs for TextLayout { | |
| 41 | fn runs( | |
| 42 | &self, | |
| 43 | paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, | |
| 415 | 44 | ) -> Result<(), RenderError> { |
| 416 | if viewport.size.contains(&0) | |
| 417 | || !viewport.scale.is_finite() | |
| 418 | || viewport.scale <= 0.0 | |
| 419 | || viewport.origin.iter().any(|v| !v.is_finite()) | |
| 420 | { | |
| 421 | return Err(RenderError::InvalidViewport); | |
| 422 | } | |
| 423 | let mut active_images = HashSet::new(); | |
| 424 | let mut image_bytes = 0; | |
| 425 | for primitive in primitives { | |
| 426 | if let Primitive::Image { image, rect } = primitive | |
| 427 | && viewport.visible_image_rect(*rect)?.is_some() | |
| 428 | && active_images.insert(image.pixels.id()) | |
| 429 | { | |
| 430 | image_bytes += image.pixels.as_ref().len() as u64; | |
| 431 | if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { | |
| 432 | return Err(RenderError::ImageBudget); | |
| 433 | } | |
| 434 | } | |
| 435 | } | |
| 436 | for attempt in 0..2 { | |
| 437 | self.vertices.clear(); | |
| 438 | self.batches.clear(); | |
| 439 | match self.prepare(viewport, primitives, &active_images) { | |
| 440 | Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), | |
| 441 | result => { | |
| 442 | result?; | |
| 443 | break; | |
| 444 | } | |
| 445 | } | |
| 446 | } | |
| 447 | self.queue | |
| 448 | .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); | |
| 449 | let mut encoder = self.device.create_command_encoder(&Default::default()); | |
| 450 | { | |
| 451 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 452 | label: Some("Canvas"), | |
| 453 | color_attachments: &[Some(wgpu::RenderPassColorAttachment { | |
| 454 | view: target, | |
| 455 | depth_slice: None, | |
| 456 | resolve_target: None, | |
| 457 | ops: wgpu::Operations { | |
| 458 | load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), | |
| 459 | store: wgpu::StoreOp::Store, | |
| 460 | }, | |
| 461 | })], | |
| 462 | ..Default::default() | |
| 463 | }); | |
| 464 | pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); | |
| 465 | for batch in &self.batches { | |
| 466 | let [x, y, width, height] = batch.scissor; | |
| 467 | pass.set_scissor_rect(x, y, width, height); | |
| 468 | pass.set_pipeline(if batch.image.is_some() { | |
| 469 | &self.image_pipeline | |
| 470 | } else { | |
| 471 | &self.pipeline | |
| 472 | }); | |
| 473 | let binding = batch | |
| 474 | .image | |
| 475 | .map(|id| &self.images[&id].binding) | |
| 476 | .unwrap_or(&self.bind_group); | |
| 477 | pass.set_bind_group(0, binding, &[]); | |
| 478 | pass.draw(batch.vertices.clone(), 0..1); | |
| 479 | } | |
| 480 | } | |
| 481 | self.queue.submit([encoder.finish()]); | |
| 482 | Ok(()) | |
| 483 | } | |
| 484 | ||
| 485 | fn prepare( | |
| 486 | &mut self, | |
| 487 | viewport: Viewport, | |
| 488 | primitives: &[Primitive<'_>], | |
| 489 | active_images: &HashSet<u64>, | |
| 490 | ) -> Result<(), RenderError> { | |
| 491 | for primitive in primitives { | |
| 492 | let start = self.vertices.len() as u32; | |
| 493 | let mut image_id = None; | |
| 494 | let mut scissor = [0, 0, viewport.size[0], viewport.size[1]]; | |
| 495 | match primitive { | |
| 496 | Primitive::Tag { tag, origin } => { | |
| 497 | let origin = [ | |
| 498 | viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale, | |
| 499 | viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale, | |
| 500 | ]; | |
| 501 | self.tag(Viewport { origin, ..viewport }, tag)?; | |
| 502 | } | |
| 503 | Primitive::Text { | |
| 504 | layout, | |
| 505 | origin, | |
| 506 | clip, | |
| 507 | } => { | |
| 508 | let origin = [ | |
| 509 | viewport.origin[0] + origin[0] * viewport.scale, | |
| 510 | viewport.origin[1] + origin[1] * viewport.scale, | |
| 511 | ]; | |
| 512 | if origin.iter().any(|v| !v.is_finite()) { | |
| 513 | return Err(RenderError::InvalidPrimitive); | |
| 514 | } | |
| 515 | if let Some(clip) = clip { | |
| 516 | let Some(rect) = viewport.visible_image_rect(*clip)? else { | |
| 517 | continue; | |
| 518 | }; | |
| 519 | let left = rect[0].max(0.0).floor() as u32; | |
| 520 | let top = rect[1].max(0.0).floor() as u32; | |
| 521 | let right = rect[2].min(viewport.size[0] as f32).ceil() as u32; | |
| 522 | let bottom = rect[3].min(viewport.size[1] as f32).ceil() as u32; | |
| 523 | scissor = [left, top, right - left, bottom - top]; | |
| 524 | } | |
| 525 | self.text(Viewport { origin, ..viewport }, layout)?; | |
| 526 | } | |
| 527 | Primitive::Rect { rect, color } => { | |
| 528 | self.quad( | |
| 529 | viewport, | |
| 530 | [ | |
| 531 | rect[0] * viewport.scale + viewport.origin[0], | |
| 532 | rect[1] * viewport.scale + viewport.origin[1], | |
| 533 | rect[2] * viewport.scale + viewport.origin[0], | |
| 534 | rect[3] * viewport.scale + viewport.origin[1], | |
| 535 | ], | |
| 536 | [0.5 / ATLAS_SIZE as f32; 4], | |
| 537 | *color, | |
| 538 | )?; | |
| 539 | } | |
| 540 | Primitive::RoundedRect { | |
| 541 | rect, | |
| 542 | radius, | |
| 543 | stroke, | |
| 544 | color, | |
| 545 | } => { | |
| 546 | if radius | |
| 547 | .iter() | |
| 548 | .any(|value| !value.is_finite() || *value < 0.0) | |
| 549 | { | |
| 550 | return Err(RenderError::InvalidPrimitive); | |
| 551 | } | |
| 552 | let width = match stroke { | |
| 553 | None => 0.0, | |
| 554 | Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { | |
| 555 | if !width.is_finite() || *width <= 0.0 || width * viewport.scale == 0.0 | |
| 556 | { | |
| 557 | return Err(RenderError::InvalidPrimitive); | |
| 558 | } | |
| 559 | *width | |
| 560 | } | |
| 561 | }; | |
| 562 | let rect = [ | |
| 563 | rect[0] * viewport.scale + viewport.origin[0], | |
| 564 | rect[1] * viewport.scale + viewport.origin[1], | |
| 565 | rect[2] * viewport.scale + viewport.origin[0], | |
| 566 | rect[3] * viewport.scale + viewport.origin[1], | |
| 567 | ]; | |
| 568 | self.quad(viewport, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?; | |
| 569 | let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; | |
| 570 | if !(4.0 * (half[0] + half[1])).is_finite() { | |
| 571 | return Err(RenderError::InvalidPrimitive); | |
| 572 | } | |
| 573 | let radius = if radius.contains(&0.0) { | |
| 574 | [0.0; 2] | |
| 575 | } else { | |
| 576 | std::array::from_fn(|axis| (radius[axis] * viewport.scale).min(half[axis])) | |
| 577 | }; | |
| 578 | let width = (width * viewport.scale).min(half[0].min(half[1])); | |
| 579 | // The stroke sign selects its dash pattern at the shader boundary. | |
| 580 | let width = if matches!(stroke, Some(Stroke::Dashed(_))) { | |
| 581 | -width | |
| 582 | } else { | |
| 583 | width | |
| 584 | }; | |
| 585 | for vertex in &mut self.vertices[start as usize..] { | |
| 586 | vertex.shape = [half[0], half[1], radius[0], radius[1]]; | |
| 587 | vertex.stroke = width; | |
| 588 | } | |
| 589 | } | |
| 590 | Primitive::Segment { | |
| 591 | from, | |
| 592 | to, | |
| 593 | width, | |
| 594 | round, | |
| 595 | color, | |
| 596 | } => self.segment(viewport, *from, *to, *width, *round, *color)?, | |
| 597 | Primitive::Image { image, rect } => { | |
| 598 | if let Some(rect) = viewport.visible_image_rect(*rect)? { | |
| 599 | self.image(image, active_images)?; | |
| 600 | image_id = Some(image.pixels.id()); | |
| 601 | self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; | |
| 602 | } | |
| 603 | } | |
| 604 | } | |
| 605 | let end = self.vertices.len() as u32; | |
| 606 | if start != end { | |
| 607 | if let Some(last) = self.batches.last_mut() | |
| 608 | && last.image == image_id | |
| 609 | && last.scissor == scissor | |
| 610 | { | |
| 611 | last.vertices.end = end; | |
| 612 | } else { | |
| 613 | self.batches.push(Batch { | |
| 614 | vertices: start..end, | |
| 615 | image: image_id, | |
| 616 | scissor, | |
| 617 | }); | |
| 618 | } | |
| 619 | } | |
| 620 | } | |
| 621 | Ok(()) | |
| 622 | } | |
| 623 | ||
| 624 | fn image(&mut self, image: &RasterImage, active: &HashSet<u64>) -> Result<(), RenderError> { | |
| 625 | if self.images.contains_key(&image.pixels.id()) { | |
| 626 | return Ok(()); | |
| 627 | } | |
| 628 | if image | |
| 629 | .size | |
| 630 | .iter() | |
| 631 | .any(|v| *v > self.device.limits().max_texture_dimension_2d) | |
| 632 | { | |
| 633 | return Err(RenderError::ImageTooLarge); | |
| 634 | } | |
| 635 | let bytes = image.pixels.as_ref().len() as u64; | |
| 636 | while self.images.values().map(|i| i.bytes).sum::<u64>() + bytes > MAX_IMAGE_BYTES | |
| 637 | || self.images.len() >= MAX_IMAGES | |
| 638 | { | |
| 639 | let id = self | |
| 640 | .images | |
| 641 | .keys() | |
| 642 | .find(|id| !active.contains(id)) | |
| 643 | .copied() | |
| 644 | .ok_or(RenderError::ImageBudget)?; | |
| 645 | self.images.remove(&id); | |
| 646 | } | |
| 647 | let size = wgpu::Extent3d { | |
| 648 | width: image.size[0], | |
| 649 | height: image.size[1], | |
| 650 | depth_or_array_layers: 1, | |
| 651 | }; | |
| 652 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 653 | label: Some("Canvas image"), | |
| 654 | size, | |
| 655 | mip_level_count: 1, | |
| 656 | sample_count: 1, | |
| 657 | dimension: wgpu::TextureDimension::D2, | |
| 658 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 659 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 660 | view_formats: &[], | |
| 661 | }); | |
| 662 | self.queue.write_texture( | |
| 663 | texture.as_image_copy(), | |
| 664 | image.pixels.as_ref(), | |
| 665 | wgpu::TexelCopyBufferLayout { | |
| 666 | offset: 0, | |
| 667 | bytes_per_row: Some(image.size[0] * 4), | |
| 668 | rows_per_image: Some(image.size[1]), | |
| 669 | }, | |
| 670 | size, | |
| 671 | ); | |
| 672 | let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 673 | label: Some("Canvas image"), | |
| 674 | layout: &self.pipeline.get_bind_group_layout(0), | |
| 675 | entries: &[ | |
| 676 | wgpu::BindGroupEntry { | |
| 677 | binding: 0, | |
| 678 | resource: wgpu::BindingResource::TextureView( | |
| 679 | &texture.create_view(&Default::default()), | |
| 680 | ), | |
| 681 | }, | |
| 682 | wgpu::BindGroupEntry { | |
| 683 | binding: 1, | |
| 684 | resource: wgpu::BindingResource::Sampler(&self.image_sampler), | |
| 685 | }, | |
| 686 | ], | |
| 687 | }); | |
| 688 | self.images | |
| 689 | .insert(image.pixels.id(), CachedImage { binding, bytes }); | |
| 690 | Ok(()) | |
| 691 | } | |
| 692 | ||
| 693 | fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> { | |
| 694 | let scale = viewport.scale; | |
| 695 | let origin = viewport.origin; | |
| 696 | for (line, line_box) in layout.lines() { | |
| 697 | let top = line_box.top * scale + origin[1]; | |
| 698 | if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 { | |
| 699 | continue; | |
| 700 | } | |
| 45 | for (line, bounds) in self.lines() { | |
| 701 | 46 | for item in line.items() { |
| 702 | let PositionedLayoutItem::GlyphRun(glyph_run) = item else { | |
| 47 | let PositionedLayoutItem::GlyphRun(run) = item else { | |
| 703 | 48 | continue; |
| 704 | 49 | }; |
| 705 | let run = glyph_run.run(); | |
| 706 | let data = &run.font().font; | |
| 707 | let size = run.font_size() * scale; | |
| 708 | if !size.is_finite() || size > ATLAS_SIZE as f32 { | |
| 709 | return Err(RenderError::AtlasFull); | |
| 710 | } | |
| 711 | let synthesis = run.synthesis(); | |
| 712 | let coords: Vec<i16> = run | |
| 713 | .normalized_coords() | |
| 714 | .iter() | |
| 715 | .map(|c| c.to_bits()) | |
| 716 | .collect(); | |
| 717 | let color = glyph_run.style().brush.color; | |
| 718 | let rise = glyph_run.style().brush.rise; | |
| 719 | for glyph in glyph_run.positioned_glyphs() { | |
| 720 | let x = glyph.x * scale + origin[0]; | |
| 721 | let y = (glyph.y + line_box.baseline - line.metrics().baseline - rise) * scale | |
| 722 | + origin[1]; | |
| 723 | // Quantization affects raster coverage only, never advances or line breaks. | |
| 724 | let x = (x * 4.0).round() * 0.25; | |
| 725 | let y = (y * 4.0).round() * 0.25; | |
| 726 | let glyph_id = glyph | |
| 727 | .id | |
| 728 | .try_into() | |
| 729 | .map_err(|_| RenderError::UnsupportedGlyph)?; | |
| 730 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 731 | let key = AtlasKey::Text { | |
| 732 | font: data.data.id(), | |
| 733 | index: data.index, | |
| 734 | glyph: glyph_id, | |
| 735 | size: size.to_bits(), | |
| 736 | coords: coords.clone(), | |
| 737 | phase, | |
| 738 | embolden: synthesis.embolden(), | |
| 739 | skew: synthesis.skew().unwrap_or(0.0).to_bits(), | |
| 740 | }; | |
| 741 | let cached = if let Some(cached) = self.glyphs.get(&key) { | |
| 742 | *cached | |
| 743 | } else { | |
| 744 | if self.glyphs.len() >= MAX_GLYPHS { | |
| 745 | return Err(RenderError::AtlasFull); | |
| 746 | } | |
| 747 | let font = FontRef::from_index(data.data.as_ref(), data.index as usize) | |
| 748 | .ok_or(RenderError::UnreadableFont)?; | |
| 749 | let mut scaler = self | |
| 750 | .scaler | |
| 751 | .builder_with_id(font, [data.data.id(), u64::from(data.index)]) | |
| 752 | .size(size) | |
| 753 | .hint(false) | |
| 754 | .normalized_coords(coords.iter().copied()) | |
| 755 | .build(); | |
| 756 | let mut render = Render::new(&[ | |
| 757 | Source::ColorOutline(0), | |
| 758 | Source::ColorBitmap(StrikeWith::BestFit), | |
| 759 | Source::Outline, | |
| 760 | ]); | |
| 761 | // Swash rasterizes outlines in Y-up coordinates. | |
| 762 | render.format(Format::Alpha).offset(Vector::new( | |
| 763 | f32::from(phase[0]) * 0.25, | |
| 764 | -f32::from(phase[1]) * 0.25, | |
| 765 | )); | |
| 766 | if synthesis.embolden() { | |
| 767 | render.embolden(size / 24.0); | |
| 768 | } | |
| 769 | if let Some(skew) = synthesis.skew() { | |
| 770 | render.transform(Some(Transform::skew( | |
| 771 | Angle::from_degrees(skew), | |
| 772 | Angle::from_degrees(0.0), | |
| 773 | ))); | |
| 774 | } | |
| 775 | let image = render.render(&mut scaler, glyph_id); | |
| 776 | let cached = if let Some(image) = | |
| 777 | image.filter(|i| i.placement.width > 0 && i.placement.height > 0) | |
| 778 | { | |
| 779 | Some(self.upload(image)?) | |
| 780 | } else { | |
| 781 | None | |
| 782 | }; | |
| 783 | self.glyphs.insert(key, cached); | |
| 784 | cached | |
| 785 | }; | |
| 786 | if let Some(glyph) = cached { | |
| 787 | let mut left = x.floor() + glyph.left as f32; | |
| 788 | let mut top = y.floor() - glyph.top as f32; | |
| 789 | let mut width = glyph.width as f32; | |
| 790 | let mut height = glyph.height as f32; | |
| 791 | if glyph.color { | |
| 792 | let line_top = line_box.top * scale + origin[1]; | |
| 793 | let line_height = line_box.height * scale; | |
| 794 | let ratio = (line_height / height).min(1.0); | |
| 795 | left += width * (1.0 - ratio) * 0.5; | |
| 796 | width *= ratio; | |
| 797 | height *= ratio; | |
| 798 | top = top | |
| 799 | .clamp(line_top, (line_top + line_height - height).max(line_top)); | |
| 800 | } | |
| 801 | let atlas_size = ATLAS_SIZE as f32; | |
| 802 | self.quad( | |
| 803 | viewport, | |
| 804 | [left, top, left + width, top + height], | |
| 805 | [ | |
| 806 | glyph.x as f32 / atlas_size, | |
| 807 | glyph.y as f32 / atlas_size, | |
| 808 | (glyph.x + glyph.width) as f32 / atlas_size, | |
| 809 | (glyph.y + glyph.height) as f32 / atlas_size, | |
| 810 | ], | |
| 811 | if glyph.color { | |
| 812 | [1.0; 4] | |
| 813 | } else { | |
| 814 | colorref(color) | |
| 815 | }, | |
| 816 | )?; | |
| 817 | } | |
| 818 | } | |
| 819 | let metrics = run.font_metrics(); | |
| 820 | for (decoration, offset, thickness) in [ | |
| 821 | ( | |
| 822 | &glyph_run.style().underline, | |
| 823 | metrics.underline_offset, | |
| 824 | metrics.underline_size, | |
| 825 | ), | |
| 826 | ( | |
| 827 | &glyph_run.style().strikethrough, | |
| 828 | metrics.strikethrough_offset, | |
| 829 | metrics.strikethrough_size, | |
| 830 | ), | |
| 831 | ] { | |
| 832 | if let Some(decoration) = decoration { | |
| 833 | let x = glyph_run.offset() * scale + origin[0]; | |
| 834 | let y = (line_box.baseline - rise - decoration.offset.unwrap_or(offset)) | |
| 835 | * scale | |
| 836 | + origin[1]; | |
| 837 | self.quad( | |
| 838 | viewport, | |
| 839 | [ | |
| 840 | x, | |
| 841 | y, | |
| 842 | x + glyph_run.advance() * scale, | |
| 843 | y + (decoration.size.unwrap_or(thickness) * scale).max(1.0), | |
| 844 | ], | |
| 845 | [0.5 / ATLAS_SIZE as f32; 4], | |
| 846 | colorref(decoration.brush.color), | |
| 847 | )?; | |
| 848 | } | |
| 849 | } | |
| 50 | draw::paint_parley_run( | |
| 51 | &run, | |
| 52 | bounds.baseline, | |
| 53 | run.style().brush.rise, | |
| 54 | [bounds.top, bounds.height], | |
| 55 | |brush: &TextBrush| colorref(brush.color), | |
| 56 | paint, | |
| 57 | )?; | |
| 850 | 58 | } |
| 851 | 59 | } |
| 852 | 60 | Ok(()) |
| 853 | 61 | } |
| 854 | ||
| 855 | fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> { | |
| 856 | let p = image.placement; | |
| 857 | if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { | |
| 858 | return Err(RenderError::AtlasFull); | |
| 859 | } | |
| 860 | if self.pen[0] + p.width + 1 > ATLAS_SIZE { | |
| 861 | self.pen[0] = 0; | |
| 862 | self.pen[1] += self.row_height + 1; | |
| 863 | self.row_height = 0; | |
| 864 | } | |
| 865 | if self.pen[1] + p.height + 1 > ATLAS_SIZE { | |
| 866 | return Err(RenderError::AtlasFull); | |
| 867 | } | |
| 868 | let cached = AtlasGlyph { | |
| 869 | x: self.pen[0], | |
| 870 | y: self.pen[1], | |
| 871 | width: p.width, | |
| 872 | height: p.height, | |
| 873 | left: p.left, | |
| 874 | top: p.top, | |
| 875 | color: image.content == Content::Color, | |
| 876 | }; | |
| 877 | let rgba = match image.content { | |
| 878 | Content::Mask => image | |
| 879 | .data | |
| 880 | .iter() | |
| 881 | .flat_map(|a| [255, 255, 255, *a]) | |
| 882 | .collect(), | |
| 883 | _ => image.data, | |
| 884 | }; | |
| 885 | self.queue.write_texture( | |
| 886 | wgpu::TexelCopyTextureInfo { | |
| 887 | origin: wgpu::Origin3d { | |
| 888 | x: cached.x, | |
| 889 | y: cached.y, | |
| 890 | z: 0, | |
| 891 | }, | |
| 892 | ..self.atlas.as_image_copy() | |
| 893 | }, | |
| 894 | &rgba, | |
| 895 | wgpu::TexelCopyBufferLayout { | |
| 896 | offset: 0, | |
| 897 | bytes_per_row: Some(p.width * 4), | |
| 898 | rows_per_image: Some(p.height), | |
| 899 | }, | |
| 900 | wgpu::Extent3d { | |
| 901 | width: p.width, | |
| 902 | height: p.height, | |
| 903 | depth_or_array_layers: 1, | |
| 904 | }, | |
| 905 | ); | |
| 906 | self.pen[0] += p.width + 1; | |
| 907 | self.row_height = self.row_height.max(p.height); | |
| 908 | Ok(cached) | |
| 909 | } | |
| 910 | ||
| 911 | fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> { | |
| 912 | let size = ParagraphTag::SIZE * viewport.scale; | |
| 913 | let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25); | |
| 914 | if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { | |
| 915 | return Err(RenderError::InvalidPrimitive); | |
| 916 | } | |
| 917 | if x + size + 1.0 < 0.0 | |
| 918 | || y + size + 1.0 < 0.0 | |
| 919 | || x - 1.0 > viewport.size[0] as f32 | |
| 920 | || y - 1.0 > viewport.size[1] as f32 | |
| 921 | { | |
| 922 | return Ok(()); | |
| 923 | } | |
| 924 | if size + 3.0 > ATLAS_SIZE as f32 { | |
| 925 | return Err(RenderError::AtlasFull); | |
| 926 | } | |
| 927 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 928 | let key = AtlasKey::Tag { | |
| 929 | icon: tag.icon, | |
| 930 | size: size.to_bits(), | |
| 931 | phase, | |
| 932 | }; | |
| 933 | let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { | |
| 934 | *glyph | |
| 935 | } else { | |
| 936 | if self.glyphs.len() >= MAX_GLYPHS { | |
| 937 | return Err(RenderError::AtlasFull); | |
| 938 | } | |
| 939 | let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?; | |
| 940 | self.glyphs.insert(key, Some(glyph)); | |
| 941 | glyph | |
| 942 | }; | |
| 943 | let left = x.floor(); | |
| 944 | let top = y.floor(); | |
| 945 | let atlas = ATLAS_SIZE as f32; | |
| 946 | self.quad( | |
| 947 | viewport, | |
| 948 | [ | |
| 949 | left, | |
| 950 | top, | |
| 951 | left + glyph.width as f32, | |
| 952 | top + glyph.height as f32, | |
| 953 | ], | |
| 954 | [ | |
| 955 | glyph.x as f32 / atlas, | |
| 956 | glyph.y as f32 / atlas, | |
| 957 | (glyph.x + glyph.width) as f32 / atlas, | |
| 958 | (glyph.y + glyph.height) as f32 / atlas, | |
| 959 | ], | |
| 960 | [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], | |
| 961 | ) | |
| 962 | } | |
| 963 | ||
| 964 | /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. | |
| 965 | fn segment( | |
| 966 | &mut self, | |
| 967 | viewport: Viewport, | |
| 968 | from: [f32; 2], | |
| 969 | to: [f32; 2], | |
| 970 | width: f32, | |
| 971 | round: bool, | |
| 972 | color: [f32; 4], | |
| 973 | ) -> Result<(), RenderError> { | |
| 974 | let pixel = |p: [f32; 2]| { | |
| 975 | [ | |
| 976 | p[0] * viewport.scale + viewport.origin[0], | |
| 977 | p[1] * viewport.scale + viewport.origin[1], | |
| 978 | ] | |
| 979 | }; | |
| 980 | let [from, to] = [pixel(from), pixel(to)]; | |
| 981 | // Hairlines stay one device pixel wide, as OneNote draws its thinnest pen. | |
| 982 | let radius = (width * viewport.scale).max(1.0) * 0.5; | |
| 983 | if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { | |
| 984 | return Err(RenderError::InvalidPrimitive); | |
| 985 | } | |
| 986 | let pad = radius + 1.0; | |
| 987 | if from[0].max(to[0]) + pad < 0.0 | |
| 988 | || from[1].max(to[1]) + pad < 0.0 | |
| 989 | || from[0].min(to[0]) - pad > viewport.size[0] as f32 | |
| 990 | || from[1].min(to[1]) - pad > viewport.size[1] as f32 | |
| 991 | { | |
| 992 | return Ok(()); | |
| 993 | } | |
| 994 | if self.vertices.len() + 6 > MAX_VERTICES { | |
| 995 | return Err(RenderError::FrameTooLarge); | |
| 996 | } | |
| 997 | let delta = [to[0] - from[0], to[1] - from[1]]; | |
| 998 | let length = delta[0].hypot(delta[1]); | |
| 999 | let along = if length > 0.0 { | |
| 1000 | [delta[0] / length, delta[1] / length] | |
| 1001 | } else { | |
| 1002 | [1.0, 0.0] | |
| 1003 | }; | |
| 1004 | let across = [-along[1], along[0]]; | |
| 1005 | let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; | |
| 1006 | let half = [length * 0.5 + radius, radius]; | |
| 1007 | let corner = if round { radius } else { 0.0 }; | |
| 1008 | let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; | |
| 1009 | for local in [ | |
| 1010 | [-hx, -hy], | |
| 1011 | [-hx, hy], | |
| 1012 | [hx, hy], | |
| 1013 | [-hx, -hy], | |
| 1014 | [hx, hy], | |
| 1015 | [hx, -hy], | |
| 1016 | ] { | |
| 1017 | let x = center[0] + along[0] * local[0] + across[0] * local[1]; | |
| 1018 | let y = center[1] + along[1] * local[0] + across[1] * local[1]; | |
| 1019 | self.vertices.push(Vertex { | |
| 1020 | position: [ | |
| 1021 | x * 2.0 / viewport.size[0] as f32 - 1.0, | |
| 1022 | 1.0 - y * 2.0 / viewport.size[1] as f32, | |
| 1023 | ], | |
| 1024 | uv: [0.5 / ATLAS_SIZE as f32; 2], | |
| 1025 | color, | |
| 1026 | local, | |
| 1027 | shape: [half[0], half[1], corner, corner], | |
| 1028 | stroke: 0.0, | |
| 1029 | }); | |
| 1030 | } | |
| 1031 | Ok(()) | |
| 1032 | } | |
| 1033 | ||
| 1034 | fn quad( | |
| 1035 | &mut self, | |
| 1036 | viewport: Viewport, | |
| 1037 | rect: [f32; 4], | |
| 1038 | uv: [f32; 4], | |
| 1039 | color: [f32; 4], | |
| 1040 | ) -> Result<(), RenderError> { | |
| 1041 | if rect.iter().chain(&color).any(|v| !v.is_finite()) | |
| 1042 | || rect[2] < rect[0] | |
| 1043 | || rect[3] < rect[1] | |
| 1044 | { | |
| 1045 | return Err(RenderError::InvalidPrimitive); | |
| 1046 | } | |
| 1047 | if rect[2] < 0.0 | |
| 1048 | || rect[3] < 0.0 | |
| 1049 | || rect[0] > viewport.size[0] as f32 | |
| 1050 | || rect[1] > viewport.size[1] as f32 | |
| 1051 | { | |
| 1052 | return Ok(()); | |
| 1053 | } | |
| 1054 | if self.vertices.len() + 6 > MAX_VERTICES { | |
| 1055 | return Err(RenderError::FrameTooLarge); | |
| 1056 | } | |
| 1057 | let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0; | |
| 1058 | let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0; | |
| 1059 | let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32; | |
| 1060 | let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32; | |
| 1061 | if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { | |
| 1062 | return Err(RenderError::InvalidPrimitive); | |
| 1063 | } | |
| 1064 | let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; | |
| 1065 | for (position, uv, local) in [ | |
| 1066 | ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), | |
| 1067 | ([x0, y1], [uv[0], uv[3]], [-hx, hy]), | |
| 1068 | ([x1, y1], [uv[2], uv[3]], [hx, hy]), | |
| 1069 | ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), | |
| 1070 | ([x1, y1], [uv[2], uv[3]], [hx, hy]), | |
| 1071 | ([x1, y0], [uv[2], uv[1]], [hx, -hy]), | |
| 1072 | ] { | |
| 1073 | self.vertices.push(Vertex { | |
| 1074 | position, | |
| 1075 | uv, | |
| 1076 | color, | |
| 1077 | local, | |
| 1078 | shape: [0.0; 4], | |
| 1079 | stroke: 0.0, | |
| 1080 | }); | |
| 1081 | } | |
| 1082 | Ok(()) | |
| 1083 | } | |
| 1084 | } | |
| 1085 | ||
| 1086 | fn srgb_byte(value: f32) -> u8 { | |
| 1087 | let srgb = if value <= 0.0031308 { | |
| 1088 | value * 12.92 | |
| 1089 | } else { | |
| 1090 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | |
| 1091 | }; | |
| 1092 | (srgb * 255.0).round() as u8 | |
| 1093 | 62 | } |
| 1094 | 63 | |
| 64 | /// The layout a text primitive paints. | |
| 65 | #[cfg(test)] | |
| 66 | pub(crate) fn painted_layout(text: &dyn Glyphs) -> &TextLayout { | |
| 67 | (text as &dyn std::any::Any) | |
| 68 | .downcast_ref() | |
| 69 | .expect("Page text is a TextLayout") | |
| 70 | } | |
| 71 | ||
| 72 | const CHECKBOX: &str = include_str!("../../assets/tags/checkbox.svg"); | |
| 73 | const CHECKMARK: &str = include_str!("../../assets/tags/checkmark.svg"); | |
| 74 | const QUESTION: &str = include_str!("../../assets/tags/question.svg"); | |
| 75 | const MUSIC: &str = include_str!("../../assets/tags/music.svg"); | |
| 76 | const EXCLAMATION: &str = include_str!("../../assets/tags/exclamation.svg"); | |
| 77 | const RED_SQUARE: &str = include_str!("../../assets/tags/red-square.svg"); | |
| 78 | const YELLOW_SQUARE: &str = include_str!("../../assets/tags/yellow-square.svg"); | |
| 79 | const BLUE_SQUARE: &str = include_str!("../../assets/tags/blue-square.svg"); | |
| 80 | ||
| 81 | /// The tag's artwork, drawn in order. | |
| 82 | pub fn tag_sources(icon: TagIcon) -> &'static [&'static str] { | |
| 83 | match icon { | |
| 84 | TagIcon::CheckBox { checked: false } => &[CHECKBOX], | |
| 85 | TagIcon::CheckBox { checked: true } => &[CHECKBOX, CHECKMARK], | |
| 86 | TagIcon::Question => &[QUESTION], | |
| 87 | TagIcon::Music => &[MUSIC], | |
| 88 | TagIcon::Exclamation => &[EXCLAMATION], | |
| 89 | TagIcon::RedSquare => &[RED_SQUARE], | |
| 90 | TagIcon::YellowSquare => &[YELLOW_SQUARE], | |
| 91 | TagIcon::BlueSquare => &[BLUE_SQUARE], | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | /// A COLORREF (0x00BBGGRR) as linear RGBA. | |
| 1095 | 96 | pub fn colorref(color: u32) -> [f32; 4] { |
| 1096 | let linear = |byte: u32| { | |
| 1097 | let value = (byte & 255) as f32 / 255.0; | |
| 1098 | if value <= 0.04045 { | |
| 1099 | value / 12.92 | |
| 1100 | } else { | |
| 1101 | ((value + 0.055) / 1.055).powf(2.4) | |
| 1102 | } | |
| 1103 | }; | |
| 1104 | [linear(color), linear(color >> 8), linear(color >> 16), 1.0] | |
| 97 | let [red, green, blue, _] = color.to_le_bytes(); | |
| 98 | draw::srgb(red, green, blue) | |
| 1105 | 99 | } |
| 1106 | 100 | |
| 1107 | 101 | #[cfg(test)] |
| 1108 | 102 | mod tests { |
| 1109 | 103 | use super::*; |
| 1110 | 104 | use crate::layout::TextEngine; |
| 1111 | use onestore::document::Format as TextFormat; | |
| 105 | use onestore::document::Format; | |
| 1112 | 106 | use onestore::page::text::Paragraph; |
| 1113 | 107 | use std::time::Duration; |
| 1114 | 108 | |
| 1115 | #[test] | |
| 1116 | fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { | |
| 1117 | let mut encoded = Vec::new(); | |
| 1118 | let mut encoder = png::Encoder::new(&mut encoded, 2, 1); | |
| 1119 | encoder.set_color(png::ColorType::Rgba); | |
| 1120 | encoder.set_depth(png::BitDepth::Eight); | |
| 1121 | let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; | |
| 1122 | encoder | |
| 1123 | .write_header() | |
| 1124 | .unwrap() | |
| 1125 | .write_image_data(&rgba) | |
| 1126 | .unwrap(); | |
| 1127 | let decoded = RasterImage::decode(&encoded).unwrap(); | |
| 1128 | assert_eq!(decoded.size, [2, 1]); | |
| 1129 | assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]); | |
| 1130 | for end in 0..encoded.len() - 12 { | |
| 1131 | assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); | |
| 1132 | } | |
| 1133 | ||
| 1134 | let mut jpeg = Vec::new(); | |
| 1135 | image::codecs::jpeg::JpegEncoder::new(&mut jpeg) | |
| 1136 | .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) | |
| 1137 | .unwrap(); | |
| 1138 | let decoded = RasterImage::decode(&jpeg).unwrap(); | |
| 1139 | assert_eq!(decoded.size, [4, 4]); | |
| 1140 | assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]); | |
| 1141 | ||
| 1142 | let mut gif = Vec::new(); | |
| 1143 | image::codecs::gif::GifEncoder::new(&mut gif) | |
| 1144 | .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) | |
| 1145 | .unwrap(); | |
| 1146 | let decoded = RasterImage::decode(&gif).unwrap(); | |
| 1147 | assert_eq!(decoded.size, [2, 2]); | |
| 1148 | assert_eq!(decoded.pixels.as_ref(), [255; 2 * 2 * 4]); | |
| 1149 | ||
| 1150 | for size in [[8192, 8192], [16_385, 1]] { | |
| 1151 | let mut header = Vec::new(); | |
| 1152 | let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); | |
| 1153 | encoder.set_color(png::ColorType::Rgba); | |
| 1154 | encoder.set_depth(png::BitDepth::Eight); | |
| 1155 | let mut writer = encoder.write_header().unwrap(); | |
| 1156 | writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); | |
| 1157 | drop(writer); | |
| 1158 | let error = RasterImage::decode(&header).err().unwrap(); | |
| 1159 | if size[0] == 8192 { | |
| 1160 | assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); | |
| 1161 | } else { | |
| 1162 | assert!( | |
| 1163 | matches!( | |
| 1164 | error, | |
| 1165 | RenderError::ImageDecode(image::ImageError::Limits(_)) | |
| 1166 | ), | |
| 1167 | "{error:?}" | |
| 1168 | ); | |
| 1169 | } | |
| 1170 | } | |
| 1171 | } | |
| 1172 | ||
| 1173 | #[test] | |
| 1174 | fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { | |
| 1175 | assert!(matches!( | |
| 1176 | RasterImage::new([0, 1], Vec::new()), | |
| 1177 | Err(RenderError::InvalidImage) | |
| 1178 | )); | |
| 1179 | assert!(matches!( | |
| 1180 | RasterImage::new([2, 2], vec![255; 15]), | |
| 1181 | Err(RenderError::InvalidImage) | |
| 1182 | )); | |
| 1183 | assert!(matches!( | |
| 1184 | RasterImage::new([u32::MAX; 2], Vec::new()), | |
| 1185 | Err(RenderError::InvalidImage) | |
| 1186 | )); | |
| 1187 | let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); | |
| 1188 | assert_eq!(image.pixels.id(), image.clone().pixels.id()); | |
| 1189 | } | |
| 1190 | ||
| 1191 | 109 | #[test] |
| 1192 | 110 | #[ignore = "requires a native GPU adapter"] |
| 1193 | fn eviction_and_repaint_reproduce_pixels() { | |
| 111 | fn tags_and_page_text_paint_through_the_renderer() { | |
| 1194 | 112 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); |
| 1195 | 113 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); |
| 1196 | 114 | let (device, queue) = |
| 1197 | 115 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); |
| 1198 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; | |
| 116 | let size = [256, 64]; | |
| 1199 | 117 | let target = device.create_texture(&wgpu::TextureDescriptor { |
| 1200 | label: Some("Canvas readback test"), | |
| 118 | label: Some("Tag readback test"), | |
| 1201 | 119 | size: wgpu::Extent3d { |
| 1202 | width: 512, | |
| 1203 | height: 256, | |
| 120 | width: size[0], | |
| 121 | height: size[1], | |
| 1204 | 122 | depth_or_array_layers: 1, |
| 1205 | 123 | }, |
| 1206 | 124 | mip_level_count: 1, |
| 1207 | 125 | sample_count: 1, |
| 1208 | 126 | dimension: wgpu::TextureDimension::D2, |
| 1209 | format, | |
| 127 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 1210 | 128 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, |
| 1211 | 129 | view_formats: &[], |
| 1212 | 130 | }); |
| 1213 | 131 | let readback = device.create_buffer(&wgpu::BufferDescriptor { |
| 1214 | label: Some("Canvas readback"), | |
| 1215 | size: 512 * 256 * 4, | |
| 132 | label: Some("Tag readback"), | |
| 133 | size: u64::from(size[0] * size[1] * 4), | |
| 1216 | 134 | usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, |
| 1217 | 135 | mapped_at_creation: false, |
| 1218 | 136 | }); |
| 1219 | let mut renderer = Renderer::new(device, queue, format); | |
| 1220 | let mut engine = TextEngine::default(); | |
| 1221 | let layout = engine | |
| 1222 | .layout( | |
| 1223 | &Paragraph::new( | |
| 1224 | "Hello 🌳\nCafé e\u{301} שלום".into(), | |
| 1225 | TextFormat { | |
| 1226 | underline: Some(true), | |
| 1227 | ..TextFormat::default() | |
| 1228 | }, | |
| 1229 | ), | |
| 1230 | 220.0, | |
| 1231 | ) | |
| 1232 | .unwrap(); | |
| 1233 | let viewport = Viewport { | |
| 1234 | size: [512, 256], | |
| 1235 | scale: 2.0, | |
| 1236 | origin: [24.0; 2], | |
| 1237 | }; | |
| 1238 | let mut captures = Vec::new(); | |
| 1239 | let image = RasterImage::new( | |
| 1240 | [2, 2], | |
| 1241 | vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], | |
| 1242 | ) | |
| 1243 | .unwrap(); | |
| 1244 | let transparent_edge = | |
| 1245 | RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); | |
| 1246 | let tags = [ | |
| 137 | let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); | |
| 138 | let icons = [ | |
| 1247 | 139 | TagIcon::CheckBox { checked: false }, |
| 1248 | 140 | TagIcon::CheckBox { checked: true }, |
| 1249 | 141 | TagIcon::Question, |
| 1250 | 142 | TagIcon::Music, |
| 1251 | ] | |
| 1252 | .map(|icon| ParagraphTag { | |
| 1253 | icon, | |
| 1254 | origin: [0.0; 2], | |
| 1255 | label: String::new(), | |
| 1256 | disabled: false, | |
| 1257 | }); | |
| 1258 | let phase_layout = engine | |
| 143 | TagIcon::Exclamation, | |
| 144 | TagIcon::RedSquare, | |
| 145 | TagIcon::YellowSquare, | |
| 146 | TagIcon::BlueSquare, | |
| 147 | ]; | |
| 148 | let text = TextEngine::default() | |
| 1259 | 149 | .layout( |
| 1260 | 150 | &Paragraph::new( |
| 1261 | "H".into(), | |
| 1262 | TextFormat { | |
| 1263 | font: Some("Arial".into()), | |
| 1264 | font_size: Some(11.0), | |
| 1265 | ..TextFormat::default() | |
| 151 | "Tagged".into(), | |
| 152 | Format { | |
| 153 | color: Some(0x000000ff), | |
| 154 | underline: Some(true), | |
| 155 | ..Format::default() | |
| 1266 | 156 | }, |
| 1267 | 157 | ), |
| 1268 | 20.0, | |
| 158 | 100.0, | |
| 1269 | 159 | ) |
| 1270 | 160 | .unwrap(); |
| 1271 | let mut primitives = vec![ | |
| 1272 | Primitive::Rect { | |
| 1273 | rect: [0.0, 0.0, 70.0, 14.0], | |
| 1274 | color: [0.55, 0.73, 1.0, 0.5], | |
| 1275 | }, | |
| 1276 | Primitive::Text { | |
| 1277 | clip: None, | |
| 1278 | layout: &layout, | |
| 1279 | origin: [0.0; 2], | |
| 1280 | }, | |
| 1281 | Primitive::Text { | |
| 1282 | clip: None, | |
| 1283 | layout: &layout, | |
| 1284 | origin: [8.0, 64.0], | |
| 1285 | }, | |
| 1286 | Primitive::Image { | |
| 1287 | image: &image, | |
| 1288 | rect: [128.0, 8.0, 160.0, 40.0], | |
| 1289 | }, | |
| 1290 | Primitive::Rect { | |
| 1291 | rect: [128.0, 8.0, 132.0, 12.0], | |
| 1292 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1293 | }, | |
| 1294 | Primitive::Image { | |
| 1295 | image: &transparent_edge, | |
| 1296 | rect: [176.0, 8.0, 208.0, 40.0], | |
| 1297 | }, | |
| 1298 | Primitive::RoundedRect { | |
| 1299 | rect: [128.0, 48.0, 148.0, 68.0], | |
| 1300 | radius: [5.0, 10.0], | |
| 1301 | stroke: None, | |
| 1302 | color: [1.0, 0.0, 0.0, 1.0], | |
| 1303 | }, | |
| 1304 | Primitive::RoundedRect { | |
| 1305 | rect: [164.0, 48.0, 184.0, 68.0], | |
| 1306 | radius: [5.0; 2], | |
| 1307 | stroke: Some(Stroke::Solid(1.0)), | |
| 1308 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1309 | }, | |
| 1310 | Primitive::RoundedRect { | |
| 1311 | rect: [200.0, 48.0, 240.0, 68.0], | |
| 1312 | radius: [3.0, 10.0], | |
| 1313 | stroke: Some(Stroke::Dashed(1.0)), | |
| 1314 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1315 | }, | |
| 1316 | Primitive::Tag { | |
| 1317 | tag: &tags[0], | |
| 1318 | origin: [128.125, 80.25], | |
| 1319 | }, | |
| 1320 | Primitive::Tag { | |
| 1321 | tag: &tags[1], | |
| 1322 | origin: [148.125, 80.25], | |
| 1323 | }, | |
| 1324 | Primitive::Tag { | |
| 1325 | tag: &tags[2], | |
| 1326 | origin: [168.125, 80.25], | |
| 1327 | }, | |
| 1328 | Primitive::Tag { | |
| 1329 | tag: &tags[3], | |
| 1330 | origin: [188.125, 80.25], | |
| 1331 | }, | |
| 1332 | Primitive::Segment { | |
| 1333 | from: [226.0, 104.0], | |
| 1334 | to: [240.0, 104.0], | |
| 1335 | width: 4.0, | |
| 1336 | round: true, | |
| 1337 | color: [1.0, 0.0, 0.0, 1.0], | |
| 1338 | }, | |
| 1339 | ]; | |
| 1340 | primitives.extend((0..9).map(|step| Primitive::Text { | |
| 1341 | clip: None, | |
| 1342 | layout: &phase_layout, | |
| 1343 | origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], | |
| 1344 | })); | |
| 1345 | for pass in 0..6 { | |
| 1346 | if pass == 2 { | |
| 1347 | renderer.clear_glyph_cache(); | |
| 1348 | renderer.images.clear(); | |
| 1349 | } else if pass == 3 { | |
| 1350 | renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); | |
| 1351 | } | |
| 1352 | let clipping = [ | |
| 1353 | Primitive::Text { | |
| 1354 | layout: &layout, | |
| 1355 | origin: [0.0; 2], | |
| 1356 | clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), | |
| 1357 | }, | |
| 1358 | Primitive::Rect { | |
| 1359 | rect: [40.0, 40.0, 70.0, 50.0], | |
| 1360 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1361 | }, | |
| 1362 | ]; | |
| 1363 | renderer | |
| 1364 | .draw( | |
| 1365 | &target.create_view(&Default::default()), | |
| 1366 | viewport, | |
| 1367 | if pass < 4 { &primitives } else { &clipping }, | |
| 1368 | ) | |
| 1369 | .unwrap(); | |
| 1370 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 1371 | encoder.copy_texture_to_buffer( | |
| 1372 | target.as_image_copy(), | |
| 1373 | wgpu::TexelCopyBufferInfo { | |
| 1374 | buffer: &readback, | |
| 1375 | layout: wgpu::TexelCopyBufferLayout { | |
| 1376 | offset: 0, | |
| 1377 | bytes_per_row: Some(512 * 4), | |
| 1378 | rows_per_image: Some(256), | |
| 1379 | }, | |
| 1380 | }, | |
| 1381 | wgpu::Extent3d { | |
| 1382 | width: 512, | |
| 1383 | height: 256, | |
| 1384 | depth_or_array_layers: 1, | |
| 1385 | }, | |
| 1386 | ); | |
| 1387 | renderer.queue.submit([encoder.finish()]); | |
| 1388 | let (sender, receiver) = std::sync::mpsc::channel(); | |
| 1389 | readback.map_async(wgpu::MapMode::Read, .., move |result| { | |
| 1390 | sender.send(result).unwrap(); | |
| 1391 | }); | |
| 1392 | renderer | |
| 1393 | .device | |
| 1394 | .poll(wgpu::PollType::Wait { | |
| 1395 | submission_index: None, | |
| 1396 | timeout: Some(Duration::from_secs(5)), | |
| 1397 | }) | |
| 1398 | .unwrap(); | |
| 1399 | receiver | |
| 1400 | .recv_timeout(Duration::from_secs(5)) | |
| 1401 | .unwrap() | |
| 1402 | .unwrap(); | |
| 1403 | captures.push(readback.get_mapped_range(..).unwrap().to_vec()); | |
| 1404 | readback.unmap(); | |
| 1405 | } | |
| 1406 | let centroids: Vec<_> = (0..9) | |
| 1407 | .map(|step| { | |
| 1408 | let mut weight = 0.0_f64; | |
| 1409 | let mut moment = 0.0_f64; | |
| 1410 | for y in 212..252 { | |
| 1411 | for x in (40 + 48 * step)..(72 + 48 * step) { | |
| 1412 | let value = captures[0][(y * 512 + x) * 4]; | |
| 1413 | let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]); | |
| 1414 | weight += coverage; | |
| 1415 | moment += coverage * y as f64; | |
| 1416 | } | |
| 1417 | } | |
| 1418 | assert!(weight > 10.0, "phase sample {step} was not painted"); | |
| 1419 | moment / weight | |
| 161 | let mut primitives: Vec<_> = icons | |
| 162 | .iter() | |
| 163 | .enumerate() | |
| 164 | .map(|(index, icon)| Primitive::Icon { | |
| 165 | sources: tag_sources(*icon), | |
| 166 | origin: [2.0 + 16.0 * index as f32, 2.0], | |
| 167 | size: crate::outline::ParagraphTag::SIZE, | |
| 168 | opacity: 1.0, | |
| 1420 | 169 | }) |
| 1421 | 170 | .collect(); |
| 1422 | let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; | |
| 1423 | assert_eq!(pixel(490, 232), [255, 0, 0, 255]); | |
| 1424 | assert_eq!(pixel(473, 232), [255, 0, 0, 255]); | |
| 1425 | assert_eq!(pixel(490, 238), [255; 4]); | |
| 1426 | assert_eq!(pixel(472, 228), [255; 4]); | |
| 1427 | assert_eq!(pixel(300, 140), [255, 0, 0, 255]); | |
| 1428 | assert_eq!(pixel(280, 120), [255; 4]); | |
| 1429 | assert_eq!(pixel(282, 124), [255; 4]); | |
| 1430 | assert_eq!(pixel(281, 140), [255, 0, 0, 255]); | |
| 1431 | assert_eq!(pixel(372, 140), [255; 4]); | |
| 1432 | assert_eq!(pixel(372, 120), [0, 0, 255, 255]); | |
| 1433 | assert_eq!(pixel(352, 120), [255; 4]); | |
| 1434 | assert_eq!(pixel(424, 120), [255; 4]); | |
| 1435 | assert_eq!(pixel(464, 140), [255; 4]); | |
| 1436 | assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); | |
| 1437 | assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); | |
| 1438 | for x in 440..472 { | |
| 1439 | assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); | |
| 1440 | } | |
| 1441 | for (step, centroid) in centroids.iter().enumerate() { | |
| 1442 | let movement = centroid - centroids[0]; | |
| 1443 | assert!( | |
| 1444 | (movement - step as f64 * 0.25).abs() < 0.06, | |
| 1445 | "quarter-pixel step {step} moved the glyph by {movement} px" | |
| 1446 | ); | |
| 1447 | } | |
| 1448 | assert!(!renderer.glyphs.is_empty()); | |
| 1449 | assert!(renderer.glyphs.len() <= MAX_GLYPHS); | |
| 1450 | assert_eq!( | |
| 1451 | renderer | |
| 1452 | .glyphs | |
| 1453 | .keys() | |
| 1454 | .filter(|key| matches!(key, AtlasKey::Tag { .. })) | |
| 1455 | .count(), | |
| 1456 | 4 | |
| 1457 | ); | |
| 1458 | for x in [280, 320, 360, 400] { | |
| 1459 | let colored = (184..210) | |
| 1460 | .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) | |
| 1461 | .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) | |
| 1462 | .count(); | |
| 1463 | assert!(colored > 10, "tag at {x} was not painted"); | |
| 1464 | } | |
| 1465 | let count = renderer.glyphs.len(); | |
| 171 | primitives.push(Primitive::Text { | |
| 172 | text: &text, | |
| 173 | origin: [2.0, 20.0], | |
| 174 | clip: None, | |
| 175 | }); | |
| 176 | let viewport = Viewport { | |
| 177 | size, | |
| 178 | scale: 2.0, | |
| 179 | origin: [0.0; 2], | |
| 180 | }; | |
| 1466 | 181 | renderer |
| 1467 | 182 | .draw( |
| 1468 | 183 | &target.create_view(&Default::default()), |
| 1469 | viewport, | |
| 1470 | &[Primitive::Tag { | |
| 1471 | tag: &tags[0], | |
| 1472 | origin: [10000.0; 2], | |
| 1473 | }], | |
| 184 | size, | |
| 185 | [1.0; 4], | |
| 186 | &[viewport.layer(&primitives)], | |
| 1474 | 187 | ) |
| 1475 | 188 | .unwrap(); |
| 1476 | assert_eq!(renderer.glyphs.len(), count); | |
| 1477 | assert!( | |
| 1478 | captures[0] | |
| 1479 | .chunks_exact(4) | |
| 1480 | .filter(|pixel| pixel[0] < 200) | |
| 1481 | .count() | |
| 1482 | > 100 | |
| 1483 | ); | |
| 1484 | assert_eq!(captures[0], captures[1]); | |
| 1485 | assert_eq!(captures[0], captures[2]); | |
| 1486 | assert_eq!(captures[0], captures[3]); | |
| 1487 | assert_ne!(captures[4], captures[5]); | |
| 1488 | for y in 0..256 { | |
| 1489 | for x in 0..512 { | |
| 1490 | let offset = (y * 512 + x) * 4; | |
| 1491 | let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) | |
| 1492 | || ((104..164).contains(&x) && (104..124).contains(&y)) | |
| 1493 | { | |
| 1494 | &captures[4][offset..offset + 4] | |
| 1495 | } else { | |
| 1496 | &[255; 4] | |
| 1497 | }; | |
| 1498 | assert_eq!( | |
| 1499 | &captures[5][offset..offset + 4], | |
| 1500 | expected, | |
| 1501 | "clipping at {x},{y}" | |
| 1502 | ); | |
| 1503 | } | |
| 1504 | } | |
| 1505 | for (x, y, color) in [ | |
| 1506 | (288, 48, [255, 0, 0, 255]), | |
| 1507 | (336, 96, [255; 4]), | |
| 1508 | (284, 44, [0, 0, 255, 255]), | |
| 1509 | ] { | |
| 1510 | let offset = (y * 512 + x) * 4; | |
| 1511 | assert_eq!(captures[0][offset..offset + 4], color); | |
| 1512 | } | |
| 1513 | let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; | |
| 1514 | assert_eq!(edge[0], 255, "{edge:?}"); | |
| 1515 | assert_eq!(edge[1], edge[2], "{edge:?}"); | |
| 1516 | assert!((180..=200).contains(&edge[1]), "{edge:?}"); | |
| 1517 | assert_eq!(renderer.images.len(), 2); | |
| 1518 | for primitive in [ | |
| 1519 | Primitive::RoundedRect { | |
| 1520 | rect: [-1e38, 0.0, 1e38, 1e38], | |
| 1521 | radius: [3.0; 2], | |
| 1522 | stroke: Some(Stroke::Dashed(1.0)), | |
| 1523 | color: [1.0; 4], | |
| 1524 | }, | |
| 1525 | Primitive::RoundedRect { | |
| 1526 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1527 | radius: [f32::NAN, 2.0], | |
| 1528 | stroke: None, | |
| 1529 | color: [1.0; 4], | |
| 1530 | }, | |
| 1531 | Primitive::RoundedRect { | |
| 1532 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1533 | radius: [2.0; 2], | |
| 1534 | stroke: Some(Stroke::Solid(0.0)), | |
| 1535 | color: [1.0; 4], | |
| 1536 | }, | |
| 1537 | Primitive::RoundedRect { | |
| 1538 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1539 | radius: [2.0; 2], | |
| 1540 | stroke: Some(Stroke::Dashed(f32::NAN)), | |
| 1541 | color: [1.0; 4], | |
| 1542 | }, | |
| 1543 | Primitive::Rect { | |
| 1544 | rect: [0.0, 0.0, f32::NAN, 1.0], | |
| 1545 | color: [1.0; 4], | |
| 1546 | }, | |
| 1547 | Primitive::Rect { | |
| 1548 | rect: [2.0, 0.0, 1.0, 1.0], | |
| 1549 | color: [1.0; 4], | |
| 1550 | }, | |
| 1551 | Primitive::Text { | |
| 1552 | clip: None, | |
| 1553 | layout: &layout, | |
| 1554 | origin: [f32::MAX; 2], | |
| 1555 | }, | |
| 1556 | Primitive::Text { | |
| 1557 | clip: Some([0.0, 0.0, f32::NAN, 1.0]), | |
| 1558 | layout: &layout, | |
| 1559 | origin: [0.0; 2], | |
| 189 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 190 | encoder.copy_texture_to_buffer( | |
| 191 | target.as_image_copy(), | |
| 192 | wgpu::TexelCopyBufferInfo { | |
| 193 | buffer: &readback, | |
| 194 | layout: wgpu::TexelCopyBufferLayout { | |
| 195 | offset: 0, | |
| 196 | bytes_per_row: Some(size[0] * 4), | |
| 197 | rows_per_image: Some(size[1]), | |
| 198 | }, | |
| 1560 | 199 | }, |
| 1561 | Primitive::Text { | |
| 1562 | clip: Some([2.0, 0.0, 1.0, 1.0]), | |
| 1563 | layout: &layout, | |
| 1564 | origin: [0.0; 2], | |
| 200 | wgpu::Extent3d { | |
| 201 | width: size[0], | |
| 202 | height: size[1], | |
| 203 | depth_or_array_layers: 1, | |
| 1565 | 204 | }, |
| 1566 | ] { | |
| 1567 | assert!(matches!( | |
| 1568 | renderer.draw( | |
| 1569 | &target.create_view(&Default::default()), | |
| 1570 | viewport, | |
| 1571 | &[primitive] | |
| 1572 | ), | |
| 1573 | Err(RenderError::InvalidPrimitive) | |
| 1574 | )); | |
| 1575 | } | |
| 1576 | let too_many: Vec<_> = (0..=MAX_IMAGES) | |
| 1577 | .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) | |
| 1578 | .collect(); | |
| 1579 | let primitives: Vec<_> = too_many | |
| 1580 | .iter() | |
| 1581 | .map(|image| Primitive::Image { | |
| 1582 | image, | |
| 1583 | rect: [0.0, 0.0, 1.0, 1.0], | |
| 1584 | }) | |
| 1585 | .collect(); | |
| 1586 | assert!(matches!( | |
| 1587 | renderer.draw( | |
| 1588 | &target.create_view(&Default::default()), | |
| 1589 | viewport, | |
| 1590 | &primitives | |
| 1591 | ), | |
| 1592 | Err(RenderError::ImageBudget) | |
| 1593 | )); | |
| 1594 | let primitives: Vec<_> = too_many | |
| 1595 | .iter() | |
| 1596 | .map(|image| Primitive::Image { | |
| 1597 | image, | |
| 1598 | rect: [-20.0, -20.0, -19.0, -19.0], | |
| 1599 | }) | |
| 1600 | .collect(); | |
| 205 | ); | |
| 206 | renderer.queue.submit([encoder.finish()]); | |
| 207 | readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); | |
| 1601 | 208 | renderer |
| 1602 | .draw( | |
| 1603 | &target.create_view(&Default::default()), | |
| 1604 | viewport, | |
| 1605 | &primitives, | |
| 1606 | ) | |
| 209 | .device | |
| 210 | .poll(wgpu::PollType::Wait { | |
| 211 | submission_index: None, | |
| 212 | timeout: Some(Duration::from_secs(5)), | |
| 213 | }) | |
| 1607 | 214 | .unwrap(); |
| 1608 | assert_eq!(renderer.images.len(), 2); | |
| 1609 | let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]); | |
| 1610 | let mut previous = None; | |
| 1611 | for _ in 0..5 { | |
| 1612 | let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); | |
| 1613 | seen.insert(large.pixels.id()); | |
| 1614 | let mut primitives = vec![Primitive::Image { | |
| 1615 | image: &large, | |
| 1616 | rect: [0.0, 0.0, 16.0, 16.0], | |
| 1617 | }]; | |
| 1618 | if let Some(image) = &previous { | |
| 1619 | primitives.push(Primitive::Image { | |
| 1620 | image, | |
| 1621 | rect: [16.0, 0.0, 32.0, 16.0], | |
| 1622 | }); | |
| 1623 | } | |
| 1624 | renderer | |
| 1625 | .draw( | |
| 1626 | &target.create_view(&Default::default()), | |
| 1627 | viewport, | |
| 1628 | &primitives, | |
| 1629 | ) | |
| 1630 | .unwrap(); | |
| 1631 | assert!(renderer.images.contains_key(&large.pixels.id())); | |
| 1632 | if let Some(image) = &previous { | |
| 1633 | assert!(renderer.images.contains_key(&image.pixels.id())); | |
| 1634 | } | |
| 1635 | assert!(renderer.images.values().map(|i| i.bytes).sum::<u64>() <= MAX_IMAGE_BYTES); | |
| 1636 | previous = Some(large); | |
| 215 | let pixels = readback.get_mapped_range(..).unwrap().to_vec(); | |
| 216 | let pixel = |x: u32, y: u32| { | |
| 217 | let offset = ((y * size[0] + x) * 4) as usize; | |
| 218 | [pixels[offset], pixels[offset + 1], pixels[offset + 2]] | |
| 219 | }; | |
| 220 | for index in 0..icons.len() as u32 { | |
| 221 | let painted = (4..28) | |
| 222 | .flat_map(|y| (4 + 32 * index..28 + 32 * index).map(move |x| (x, y))) | |
| 223 | .filter(|(x, y)| pixel(*x, *y).iter().any(|v| *v < 200)) | |
| 224 | .count(); | |
| 225 | assert!(painted > 20, "tag {index} was not painted"); | |
| 1637 | 226 | } |
| 1638 | assert!(renderer.images.len() <= MAX_IMAGES); | |
| 1639 | assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); | |
| 1640 | renderer.images.clear(); | |
| 1641 | renderer | |
| 1642 | .draw( | |
| 1643 | &target.create_view(&Default::default()), | |
| 1644 | viewport, | |
| 1645 | &[Primitive::Image { | |
| 1646 | image: &image, | |
| 1647 | rect: [128.0, 8.0, 160.0, 40.0], | |
| 1648 | }], | |
| 1649 | ) | |
| 1650 | .unwrap(); | |
| 1651 | assert_eq!(renderer.images.len(), 1); | |
| 1652 | assert!(renderer.images.contains_key(&image.pixels.id())); | |
| 227 | let red = (40..64) | |
| 228 | .flat_map(|y| (4..120).map(move |x| (x, y))) | |
| 229 | .filter(|(x, y)| { | |
| 230 | let [r, g, b] = pixel(*x, *y); | |
| 231 | r > 200 && g < 80 && b < 80 | |
| 232 | }) | |
| 233 | .count(); | |
| 234 | assert!(red > 50, "text colour or underline was not painted"); | |
| 1653 | 235 | } |
| 1654 | 236 | } |
crates/canvas/src/gpu/page.rs+74-31| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | use super::{Primitive, RasterImage, RenderError, colorref}; | |
| 1 | use super::colorref; | |
| 2 | 2 | use crate::editor::ReadOnlyObject; |
| 3 | 3 | use crate::editor::page::Content; |
| 4 | 4 | use crate::{ |
| ... | ... | @@ -6,6 +6,7 @@ use crate::{ |
| 6 | 6 | editor::{CanvasEditor, EditorError}, |
| 7 | 7 | layout::{LayoutError, TextEngine}, |
| 8 | 8 | }; |
| 9 | use draw::{Primitive, RasterImage, RenderError}; | |
| 9 | 10 | use onestore::page::Page; |
| 10 | 11 | use std::fmt; |
| 11 | 12 | |
| ... | ... | @@ -197,8 +198,8 @@ impl PageScene { |
| 197 | 198 | for (id, encoded) in payloads { |
| 198 | 199 | let image = RasterImage::decode(encoded.ok_or(SceneError::MissingImage)?) |
| 199 | 200 | .map_err(SceneError::Image)?; |
| 200 | bytes += image.pixels.as_ref().len() as u64; | |
| 201 | if bytes > super::MAX_IMAGE_BYTES { | |
| 201 | bytes += image.pixels().len() as u64; | |
| 202 | if bytes > draw::MAX_IMAGE_BYTES { | |
| 202 | 203 | return Err(SceneError::Image(RenderError::ImageBudget)); |
| 203 | 204 | } |
| 204 | 205 | if images.insert(id, image).is_some() { |
| ... | ... | @@ -253,7 +254,7 @@ impl PageScene { |
| 253 | 254 | } |
| 254 | 255 | if let Some(label) = object.label() { |
| 255 | 256 | primitives.push(Primitive::Text { |
| 256 | layout: &label.text, | |
| 257 | text: &label.text, | |
| 257 | 258 | origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], |
| 258 | 259 | clip: None, |
| 259 | 260 | }); |
| ... | ... | @@ -508,7 +509,7 @@ impl PageScene { |
| 508 | 509 | }); |
| 509 | 510 | primitives.push(Primitive::Text { |
| 510 | 511 | clip: None, |
| 511 | layout: &object.label, | |
| 512 | text: &object.label, | |
| 512 | 513 | origin: [rect[0] + 8.0, rect[1] + 8.0], |
| 513 | 514 | }); |
| 514 | 515 | continue; |
| ... | ... | @@ -632,7 +633,7 @@ impl crate::outline::OutlineLayout { |
| 632 | 633 | primitives.push(match item { |
| 633 | 634 | crate::math::MathItem::Text { layout, origin } => Primitive::Text { |
| 634 | 635 | clip, |
| 635 | layout, | |
| 636 | text: layout, | |
| 636 | 637 | origin: [x + origin[0], y + origin[1]], |
| 637 | 638 | }, |
| 638 | 639 | // As pen strokes, so hairline rules keep a device pixel. |
| ... | ... | @@ -665,21 +666,26 @@ impl crate::outline::OutlineLayout { |
| 665 | 666 | } |
| 666 | 667 | None => primitives.push(Primitive::Text { |
| 667 | 668 | clip, |
| 668 | layout: &paragraph.text, | |
| 669 | text: &paragraph.text, | |
| 669 | 670 | origin: [x, y], |
| 670 | 671 | }), |
| 671 | 672 | } |
| 672 | 673 | for (layout, marker) in &paragraph.markers { |
| 673 | 674 | primitives.push(Primitive::Text { |
| 674 | 675 | clip, |
| 675 | layout, | |
| 676 | text: layout, | |
| 676 | 677 | origin: [origin[0] + marker[0], y + marker[1]], |
| 677 | 678 | }); |
| 678 | 679 | } |
| 679 | 680 | for tag in &paragraph.tags { |
| 680 | primitives.push(Primitive::Tag { | |
| 681 | tag, | |
| 682 | origin: [origin[0] + self.tag_column_offset(), y], | |
| 681 | primitives.push(Primitive::Icon { | |
| 682 | sources: super::tag_sources(tag.icon), | |
| 683 | origin: [ | |
| 684 | origin[0] + self.tag_column_offset() + tag.origin[0], | |
| 685 | y + tag.origin[1], | |
| 686 | ], | |
| 687 | size: crate::outline::ParagraphTag::SIZE, | |
| 688 | opacity: if tag.disabled { 0.45 } else { 1.0 }, | |
| 683 | 689 | }); |
| 684 | 690 | } |
| 685 | 691 | } |
| ... | ... | @@ -702,7 +708,7 @@ impl crate::outline::OutlineLayout { |
| 702 | 708 | bounds[3] + origin[1] + 0.375, |
| 703 | 709 | ], |
| 704 | 710 | radius: [3.6; 2], |
| 705 | stroke: Some(super::Stroke::Solid(0.75)), | |
| 711 | stroke: Some(draw::Stroke::Solid(0.75)), | |
| 706 | 712 | color, |
| 707 | 713 | }); |
| 708 | 714 | for cell in &table.cells { |
| ... | ... | @@ -738,6 +744,7 @@ impl crate::outline::OutlineLayout { |
| 738 | 744 | mod tests { |
| 739 | 745 | use super::*; |
| 740 | 746 | use crate::editor::TextOutline; |
| 747 | use crate::gpu::painted_layout; | |
| 741 | 748 | use onestore::page::text::Paragraph; |
| 742 | 749 | use onestore::page::{Image, PageObject}; |
| 743 | 750 | use onestore::{ExGuid, document::Layout}; |
| ... | ... | @@ -1154,7 +1161,7 @@ mod tests { |
| 1154 | 1161 | primitives |
| 1155 | 1162 | .into_iter() |
| 1156 | 1163 | .filter_map(|primitive| match primitive { |
| 1157 | Primitive::Image { image, rect } => Some((image.pixels.id(), rect)), | |
| 1164 | Primitive::Image { image, rect } => Some((image.id(), rect)), | |
| 1158 | 1165 | _ => None, |
| 1159 | 1166 | }) |
| 1160 | 1167 | .collect::<Vec<_>>() |
| ... | ... | @@ -1555,7 +1562,7 @@ mod tests { |
| 1555 | 1562 | for (_, paragraph) in outline.layouts() { |
| 1556 | 1563 | primitives.push(Primitive::Text { |
| 1557 | 1564 | clip: None, |
| 1558 | layout: &paragraph.text, | |
| 1565 | text: &paragraph.text, | |
| 1559 | 1566 | origin: [ |
| 1560 | 1567 | outline.origin()[0] + paragraph.origin[0] + offset[0], |
| 1561 | 1568 | outline.origin()[1] + paragraph.origin[1] + offset[1], |
| ... | ... | @@ -1647,19 +1654,31 @@ mod tests { |
| 1647 | 1654 | ( |
| 1648 | 1655 | Primitive::Text { |
| 1649 | 1656 | clip: None, |
| 1650 | layout: a, | |
| 1657 | text: a, | |
| 1651 | 1658 | origin: x, |
| 1652 | 1659 | }, |
| 1653 | 1660 | Primitive::Text { |
| 1654 | 1661 | clip: None, |
| 1655 | layout: b, | |
| 1662 | text: b, | |
| 1656 | 1663 | origin: y, |
| 1657 | 1664 | }, |
| 1658 | 1665 | ) => { |
| 1659 | 1666 | assert_eq!(x, y); |
| 1660 | 1667 | assert_eq!( |
| 1661 | a.lines().next().unwrap().0.metrics().advance, | |
| 1662 | b.lines().next().unwrap().0.metrics().advance | |
| 1668 | painted_layout(*a) | |
| 1669 | .lines() | |
| 1670 | .next() | |
| 1671 | .unwrap() | |
| 1672 | .0 | |
| 1673 | .metrics() | |
| 1674 | .advance, | |
| 1675 | painted_layout(*b) | |
| 1676 | .lines() | |
| 1677 | .next() | |
| 1678 | .unwrap() | |
| 1679 | .0 | |
| 1680 | .metrics() | |
| 1681 | .advance | |
| 1663 | 1682 | ); |
| 1664 | 1683 | } |
| 1665 | 1684 | ( |
| ... | ... | @@ -1667,7 +1686,7 @@ mod tests { |
| 1667 | 1686 | Primitive::Image { image: b, rect: y }, |
| 1668 | 1687 | ) => { |
| 1669 | 1688 | assert_eq!(x, y); |
| 1670 | assert_eq!(a.pixels.as_ref(), b.pixels.as_ref()); | |
| 1689 | assert_eq!(a.pixels(), b.pixels()); | |
| 1671 | 1690 | } |
| 1672 | 1691 | _ => panic!("paint order changed"), |
| 1673 | 1692 | } |
| ... | ... | @@ -1675,39 +1694,63 @@ mod tests { |
| 1675 | 1694 | let Primitive::Image { image, .. } = &actual[1] else { |
| 1676 | 1695 | panic!() |
| 1677 | 1696 | }; |
| 1678 | image_id = image.pixels.id(); | |
| 1697 | image_id = image.id(); | |
| 1679 | 1698 | } |
| 1680 | 1699 | let original = editor.active_outline().document().clone(); |
| 1681 | 1700 | editor.insert(&mut engine, "new words ").unwrap(); |
| 1682 | 1701 | { |
| 1683 | 1702 | let actual = paint_editor(&scene, &editor); |
| 1684 | let Primitive::Text { layout, .. } = &actual[0] else { | |
| 1703 | let Primitive::Text { text, .. } = &actual[0] else { | |
| 1685 | 1704 | panic!() |
| 1686 | 1705 | }; |
| 1687 | let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { | |
| 1706 | let Primitive::Text { text: before, .. } = &reference_primitives[0] else { | |
| 1688 | 1707 | panic!() |
| 1689 | 1708 | }; |
| 1690 | 1709 | assert!( |
| 1691 | layout.lines().next().unwrap().0.metrics().advance | |
| 1692 | > before.lines().next().unwrap().0.metrics().advance | |
| 1710 | painted_layout(*text) | |
| 1711 | .lines() | |
| 1712 | .next() | |
| 1713 | .unwrap() | |
| 1714 | .0 | |
| 1715 | .metrics() | |
| 1716 | .advance | |
| 1717 | > painted_layout(*before) | |
| 1718 | .lines() | |
| 1719 | .next() | |
| 1720 | .unwrap() | |
| 1721 | .0 | |
| 1722 | .metrics() | |
| 1723 | .advance | |
| 1693 | 1724 | ); |
| 1694 | 1725 | let Primitive::Image { image, .. } = &actual[1] else { |
| 1695 | 1726 | panic!() |
| 1696 | 1727 | }; |
| 1697 | assert_eq!(image.pixels.id(), image_id); | |
| 1728 | assert_eq!(image.id(), image_id); | |
| 1698 | 1729 | } |
| 1699 | 1730 | editor.undo(&mut engine).unwrap(); |
| 1700 | 1731 | assert_eq!(editor.active_outline().document(), &original); |
| 1701 | 1732 | let actual = paint_editor(&scene, &editor); |
| 1702 | let Primitive::Text { layout, .. } = &actual[0] else { | |
| 1733 | let Primitive::Text { text, .. } = &actual[0] else { | |
| 1703 | 1734 | panic!() |
| 1704 | 1735 | }; |
| 1705 | let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { | |
| 1736 | let Primitive::Text { text: before, .. } = &reference_primitives[0] else { | |
| 1706 | 1737 | panic!() |
| 1707 | 1738 | }; |
| 1708 | 1739 | assert_eq!( |
| 1709 | layout.lines().next().unwrap().0.metrics().advance, | |
| 1710 | before.lines().next().unwrap().0.metrics().advance | |
| 1740 | painted_layout(*text) | |
| 1741 | .lines() | |
| 1742 | .next() | |
| 1743 | .unwrap() | |
| 1744 | .0 | |
| 1745 | .metrics() | |
| 1746 | .advance, | |
| 1747 | painted_layout(*before) | |
| 1748 | .lines() | |
| 1749 | .next() | |
| 1750 | .unwrap() | |
| 1751 | .0 | |
| 1752 | .metrics() | |
| 1753 | .advance | |
| 1711 | 1754 | ); |
| 1712 | 1755 | assert!(matches!( |
| 1713 | 1756 | scene.append_primitives(&mut Vec::new(), [0.0; 2]), |
| ... | ... | @@ -1846,8 +1889,8 @@ mod tests { |
| 1846 | 1889 | }; |
| 1847 | 1890 | assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]); |
| 1848 | 1891 | assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]); |
| 1849 | assert_eq!(first.pixels.id(), second.pixels.id()); | |
| 1850 | assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]); | |
| 1892 | assert_eq!(first.id(), second.id()); | |
| 1893 | assert_eq!(first.pixels(), [255, 0, 0, 255]); | |
| 1851 | 1894 | } |
| 1852 | 1895 | |
| 1853 | 1896 | #[test] |
crates/canvas/src/gpu/profile.rs+25-26| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | use super::*; |
| 2 | 2 | use crate::layout::TextEngine; |
| 3 | use draw::Renderer; | |
| 3 | 4 | use onestore::page::Page; |
| 4 | 5 | use onestore::page::text::Paragraph; |
| 6 | use parley::fontique::Blob; | |
| 5 | 7 | use std::{ |
| 6 | 8 | sync::Arc, |
| 7 | 9 | time::{Duration, Instant}, |
| ... | ... | @@ -54,10 +56,11 @@ fn renderer_cost() { |
| 54 | 56 | }); |
| 55 | 57 | let view = target.create_view(&Default::default()); |
| 56 | 58 | let mut renderer = Renderer::new(device, queue, format); |
| 59 | let occupancy = renderer.occupancy(); | |
| 57 | 60 | eprintln!( |
| 58 | 61 | "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}", |
| 59 | u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4, | |
| 60 | renderer.vertex_buffer.size(), | |
| 62 | occupancy.atlas_bytes, | |
| 63 | occupancy.vertex_buffer_bytes, | |
| 61 | 64 | u64::from(size[0]) * u64::from(size[1]) * 4 |
| 62 | 65 | ); |
| 63 | 66 | for phase in [ |
| ... | ... | @@ -74,12 +77,12 @@ fn renderer_cost() { |
| 74 | 77 | } |
| 75 | 78 | if phase.starts_with("offscreen") { |
| 76 | 79 | renderer.clear_glyph_cache(); |
| 77 | renderer.images.clear(); | |
| 80 | renderer.clear_images(); | |
| 78 | 81 | } |
| 79 | 82 | for sample in 0..32 { |
| 80 | 83 | if phase == "evict" { |
| 81 | 84 | renderer.clear_glyph_cache(); |
| 82 | renderer.images.clear(); | |
| 85 | renderer.clear_images(); | |
| 83 | 86 | } |
| 84 | 87 | let mut viewport = Viewport { |
| 85 | 88 | size, |
| ... | ... | @@ -100,14 +103,16 @@ fn renderer_cost() { |
| 100 | 103 | if let Some(scene) = &scene { |
| 101 | 104 | scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); |
| 102 | 105 | } else { |
| 103 | primitives.push(Primitive::Text { | |
| 106 | primitives.push(draw::Primitive::Text { | |
| 104 | 107 | clip: None, |
| 105 | layout: layout.as_ref().unwrap(), | |
| 108 | text: layout.as_ref().unwrap(), | |
| 106 | 109 | origin: [36.0, 90.0], |
| 107 | 110 | }); |
| 108 | 111 | } |
| 109 | 112 | let start = Instant::now(); |
| 110 | renderer.draw(&view, viewport, &primitives).unwrap(); | |
| 113 | renderer | |
| 114 | .draw(&view, size, [1.0; 4], &[viewport.layer(&primitives)]) | |
| 115 | .unwrap(); | |
| 111 | 116 | let submitted = start.elapsed().as_nanos(); |
| 112 | 117 | renderer |
| 113 | 118 | .device |
| ... | ... | @@ -117,26 +122,20 @@ fn renderer_cost() { |
| 117 | 122 | }) |
| 118 | 123 | .unwrap(); |
| 119 | 124 | let completed = start.elapsed().as_nanos(); |
| 120 | let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum(); | |
| 121 | let atlas_texels: u64 = renderer | |
| 122 | .glyphs | |
| 123 | .values() | |
| 124 | .flatten() | |
| 125 | .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) | |
| 126 | .sum(); | |
| 127 | assert!(renderer.glyphs.len() <= MAX_GLYPHS); | |
| 128 | assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES); | |
| 129 | assert!(renderer.vertices.len() <= MAX_VERTICES); | |
| 125 | let occupancy = renderer.occupancy(); | |
| 126 | assert!(occupancy.within_budget); | |
| 130 | 127 | eprintln!( |
| 131 | "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{atlas_texels}\t{}\t{image_bytes}\t{}\t{}\t{}\t{}\t{}\t{}", | |
| 132 | renderer.glyphs.len(), | |
| 133 | renderer.images.len(), | |
| 134 | renderer.vertices.len(), | |
| 135 | renderer.batches.len(), | |
| 136 | renderer.vertices.capacity() * size_of::<Vertex>(), | |
| 137 | renderer.batches.capacity() * size_of::<Batch>(), | |
| 138 | renderer.glyphs.capacity(), | |
| 139 | renderer.images.capacity() | |
| 128 | "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", | |
| 129 | occupancy.glyphs, | |
| 130 | occupancy.atlas_texels, | |
| 131 | occupancy.images, | |
| 132 | occupancy.image_bytes, | |
| 133 | occupancy.vertices, | |
| 134 | occupancy.batches, | |
| 135 | occupancy.vertex_capacity_bytes, | |
| 136 | occupancy.batch_capacity_bytes, | |
| 137 | occupancy.glyph_capacity, | |
| 138 | occupancy.image_capacity | |
| 140 | 139 | ); |
| 141 | 140 | } |
| 142 | 141 | } |
crates/canvas/src/gpu/tags.rs deleted-140| ... | ... | @@ -1,140 +0,0 @@ |
| 1 | use crate::outline::TagIcon; | |
| 2 | use swash::{ | |
| 3 | scale::image::{Content, Image}, | |
| 4 | zeno::{Mask, Placement, Transform, Vector}, | |
| 5 | }; | |
| 6 | ||
| 7 | pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { | |
| 8 | let (source, overlay) = match icon { | |
| 9 | TagIcon::CheckBox { checked } => ( | |
| 10 | include_str!("../../assets/tags/checkbox.svg"), | |
| 11 | checked.then_some(include_str!("../../assets/tags/checkmark.svg")), | |
| 12 | ), | |
| 13 | TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None), | |
| 14 | TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None), | |
| 15 | TagIcon::Exclamation => (include_str!("../../assets/tags/exclamation.svg"), None), | |
| 16 | TagIcon::RedSquare => (include_str!("../../assets/tags/red-square.svg"), None), | |
| 17 | TagIcon::YellowSquare => (include_str!("../../assets/tags/yellow-square.svg"), None), | |
| 18 | TagIcon::BlueSquare => (include_str!("../../assets/tags/blue-square.svg"), None), | |
| 19 | }; | |
| 20 | let width = (size + 1.0).ceil() as u32; | |
| 21 | let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; | |
| 22 | for source in std::iter::once(source).chain(overlay) { | |
| 23 | let svg = roxmltree::Document::parse(source).expect("Bundled tag SVG must be valid"); | |
| 24 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); | |
| 25 | for path in svg.descendants().filter(|node| node.has_tag_name("path")) { | |
| 26 | let gradient = path | |
| 27 | .attribute("fill") | |
| 28 | .unwrap() | |
| 29 | .strip_prefix("url(#") | |
| 30 | .unwrap() | |
| 31 | .strip_suffix(')') | |
| 32 | .unwrap(); | |
| 33 | let gradient = svg | |
| 34 | .descendants() | |
| 35 | .find(|node| node.attribute("id") == Some(gradient)) | |
| 36 | .unwrap(); | |
| 37 | let mut stops = gradient | |
| 38 | .children() | |
| 39 | .filter(|node| node.has_tag_name("stop")) | |
| 40 | .map(|node| { | |
| 41 | let color = u32::from_str_radix( | |
| 42 | node.attribute("stop-color") | |
| 43 | .unwrap() | |
| 44 | .strip_prefix('#') | |
| 45 | .unwrap(), | |
| 46 | 16, | |
| 47 | ) | |
| 48 | .unwrap(); | |
| 49 | let [_, r, g, b] = color.to_be_bytes(); | |
| 50 | super::colorref(u32::from_le_bytes([r, g, b, 0])) | |
| 51 | }); | |
| 52 | let top = stops.next().unwrap(); | |
| 53 | let bottom = stops.next().unwrap(); | |
| 54 | assert!(stops.next().is_none()); | |
| 55 | let (mask, _) = Mask::new(path.attribute("d").unwrap()) | |
| 56 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) | |
| 57 | .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) | |
| 58 | .size(width, width) | |
| 59 | .render(); | |
| 60 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { | |
| 61 | let alpha = f32::from(coverage) / 255.0; | |
| 62 | let y = ((index / width as usize) as f32 / size).min(1.0); | |
| 63 | for channel in 0..3 { | |
| 64 | let color = top[channel] + (bottom[channel] - top[channel]) * y; | |
| 65 | pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); | |
| 66 | } | |
| 67 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | let data = pixels | |
| 72 | .into_iter() | |
| 73 | .flat_map(|pixel| { | |
| 74 | let alpha = pixel[3]; | |
| 75 | let channel = |value: f32| { | |
| 76 | let value = if alpha > 0.0 { value / alpha } else { 0.0 }; | |
| 77 | super::srgb_byte(value) | |
| 78 | }; | |
| 79 | [ | |
| 80 | channel(pixel[0]), | |
| 81 | channel(pixel[1]), | |
| 82 | channel(pixel[2]), | |
| 83 | (alpha * 255.0).round() as u8, | |
| 84 | ] | |
| 85 | }) | |
| 86 | .collect(); | |
| 87 | Image { | |
| 88 | content: Content::Color, | |
| 89 | placement: Placement { | |
| 90 | left: 0, | |
| 91 | top: 0, | |
| 92 | width, | |
| 93 | height: width, | |
| 94 | }, | |
| 95 | data, | |
| 96 | ..Image::default() | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | #[cfg(test)] | |
| 101 | mod tests { | |
| 102 | use super::*; | |
| 103 | ||
| 104 | #[test] | |
| 105 | fn tag_masks_keep_transparency_and_checked_state_across_scales() { | |
| 106 | for size in [8.0, 16.0, 32.0, 57.5] { | |
| 107 | for phase in [[0, 0], [1, 2], [3, 3]] { | |
| 108 | let mut masks = Vec::new(); | |
| 109 | for icon in [ | |
| 110 | TagIcon::CheckBox { checked: false }, | |
| 111 | TagIcon::CheckBox { checked: true }, | |
| 112 | TagIcon::Question, | |
| 113 | TagIcon::Music, | |
| 114 | TagIcon::Exclamation, | |
| 115 | TagIcon::RedSquare, | |
| 116 | TagIcon::YellowSquare, | |
| 117 | TagIcon::BlueSquare, | |
| 118 | ] { | |
| 119 | let image = rasterize(icon, size, phase); | |
| 120 | assert_eq!( | |
| 121 | image.data.len(), | |
| 122 | (image.placement.width * image.placement.height * 4) as usize | |
| 123 | ); | |
| 124 | assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); | |
| 125 | assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); | |
| 126 | assert!( | |
| 127 | image | |
| 128 | .data | |
| 129 | .chunks_exact(4) | |
| 130 | .filter(|p| p[3] == 0) | |
| 131 | .all(|p| p[..3] == [0; 3]) | |
| 132 | ); | |
| 133 | masks.push(image.data); | |
| 134 | } | |
| 135 | assert_ne!(masks[0], masks[1]); | |
| 136 | assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | } |
crates/canvas/src/interaction/mod.rs+2-1| ... | ... | @@ -9,7 +9,7 @@ mod scroll; |
| 9 | 9 | #[cfg(test)] |
| 10 | 10 | mod tests; |
| 11 | 11 | |
| 12 | use crate::gpu::{Primitive, Stroke, Viewport, page::PageScene}; | |
| 12 | use crate::gpu::{Viewport, page::PageScene}; | |
| 13 | 13 | use crate::{ |
| 14 | 14 | date::DateField, |
| 15 | 15 | editor::{ |
| ... | ... | @@ -17,6 +17,7 @@ use crate::{ |
| 17 | 17 | }, |
| 18 | 18 | layout::TextEngine, |
| 19 | 19 | }; |
| 20 | use draw::{Primitive, Stroke}; | |
| 20 | 21 | use std::{ |
| 21 | 22 | error::Error, |
| 22 | 23 | time::{Duration, Instant}, |
crates/canvas/src/interaction/scroll.rs+2-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | use crate::gpu::{Primitive, Viewport}; | |
| 1 | use crate::gpu::Viewport; | |
| 2 | use draw::Primitive; | |
| 2 | 3 | |
| 3 | 4 | pub struct Scroll { |
| 4 | 5 | pub min: [f32; 2], |
crates/canvas/src/interaction/tests.rs+13-9| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | use super::*; |
| 2 | 2 | use crate::document::TextDocument; |
| 3 | use crate::gpu::painted_layout; | |
| 3 | 4 | use onestore::document::Format; |
| 4 | 5 | use onestore::page::Page; |
| 5 | 6 | use onestore::page::text::Paragraph; |
| ... | ... | @@ -385,7 +386,12 @@ fn imported_page_widgets_and_accessibility_follow_edits() { |
| 385 | 386 | .unwrap() |
| 386 | 387 | .into_iter() |
| 387 | 388 | .filter_map(|primitive| match primitive { |
| 388 | Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), | |
| 389 | Primitive::Icon { | |
| 390 | sources, | |
| 391 | origin, | |
| 392 | opacity, | |
| 393 | .. | |
| 394 | } => Some(((sources, opacity), origin)), | |
| 389 | 395 | _ => None, |
| 390 | 396 | }) |
| 391 | 397 | .collect::<Vec<_>>() |
| ... | ... | @@ -427,9 +433,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { |
| 427 | 433 | let edited_tags = tags(&editor); |
| 428 | 434 | assert_eq!(edited_tags.len(), original_tags.len()); |
| 429 | 435 | for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { |
| 430 | assert_eq!(edited.icon, original.icon); | |
| 431 | assert_eq!(edited.label, original.label); | |
| 432 | assert_eq!(edited.disabled, original.disabled); | |
| 436 | assert_eq!(edited, original); | |
| 433 | 437 | } |
| 434 | 438 | } |
| 435 | 439 | editor.undo(&mut engine).unwrap(); |
| ... | ... | @@ -891,14 +895,14 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { |
| 891 | 895 | .unwrap(); |
| 892 | 896 | let color = crate::gpu::colorref(0x00a3a3a3); |
| 893 | 897 | assert!(matches!(primitives[0], Primitive::RoundedRect { |
| 894 | stroke: Some(crate::gpu::Stroke::Solid(0.75)), color: actual, .. | |
| 898 | stroke: Some(draw::Stroke::Solid(0.75)), color: actual, .. | |
| 895 | 899 | } if actual == color)); |
| 896 | 900 | assert!(matches!(primitives[1], Primitive::Rect { color: actual, .. } if actual == color)); |
| 897 | 901 | assert!(matches!(primitives[2], Primitive::Rect { color, .. } if color == COLORS.selection)); |
| 898 | 902 | let painted = primitives |
| 899 | 903 | .iter() |
| 900 | 904 | .filter_map(|primitive| match primitive { |
| 901 | Primitive::Text { layout, origin, .. } => Some((layout.id(), *origin)), | |
| 905 | Primitive::Text { text, origin, .. } => Some((painted_layout(*text).id(), *origin)), | |
| 902 | 906 | _ => None, |
| 903 | 907 | }) |
| 904 | 908 | .collect::<Vec<_>>(); |
| ... | ... | @@ -948,7 +952,7 @@ fn resize_preview_paints_reflowed_text_without_changing_the_editor() { |
| 948 | 952 | let painted = primitives |
| 949 | 953 | .iter() |
| 950 | 954 | .find_map(|primitive| match primitive { |
| 951 | Primitive::Text { layout, .. } => Some(*layout), | |
| 955 | Primitive::Text { text, .. } => Some(painted_layout(*text)), | |
| 952 | 956 | _ => None, |
| 953 | 957 | }) |
| 954 | 958 | .unwrap(); |
| ... | ... | @@ -1161,7 +1165,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { |
| 1161 | 1165 | .unwrap() |
| 1162 | 1166 | .into_iter() |
| 1163 | 1167 | .filter_map(|p| match p { |
| 1164 | Primitive::Text { layout, .. } => Some(layout.id()), | |
| 1168 | Primitive::Text { text, .. } => Some(painted_layout(text).id()), | |
| 1165 | 1169 | _ => None, |
| 1166 | 1170 | }) |
| 1167 | 1171 | .collect(); |
| ... | ... | @@ -1213,7 +1217,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { |
| 1213 | 1217 | .unwrap() |
| 1214 | 1218 | .into_iter() |
| 1215 | 1219 | .filter_map(|p| match p { |
| 1216 | Primitive::Text { layout, .. } => Some(layout.id()), | |
| 1220 | Primitive::Text { text, .. } => Some(painted_layout(text).id()), | |
| 1217 | 1221 | _ => None, |
| 1218 | 1222 | }) |
| 1219 | 1223 | .collect(); |
crates/draw/Cargo.toml created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | [package] | |
| 2 | name = "draw" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2024" | |
| 5 | publish = false | |
| 6 | ||
| 7 | [dependencies] | |
| 8 | bytemuck = { version = "1.25", features = ["derive"] } | |
| 9 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] } | |
| 10 | parley.workspace = true | |
| 11 | roxmltree = "0.21" | |
| 12 | swash = "0.2.10" | |
| 13 | wgpu.workspace = true | |
| 14 | ||
| 15 | [dev-dependencies] | |
| 16 | pollster = "0.4" | |
| 17 | png = "0.18" |
crates/draw/src/draw.wgsl created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | struct Vertex { | |
| 2 | @builtin(position) position: vec4<f32>, | |
| 3 | @location(0) uv: vec2<f32>, | |
| 4 | @location(1) color: vec4<f32>, | |
| 5 | @location(2) local: vec2<f32>, | |
| 6 | @location(3) @interpolate(flat) shape: vec4<f32>, | |
| 7 | @location(4) @interpolate(flat) stroke: f32, | |
| 8 | } | |
| 9 | ||
| 10 | @group(0) @binding(0) var atlas: texture_2d<f32>; | |
| 11 | @group(0) @binding(1) var atlas_sampler: sampler; | |
| 12 | ||
| 13 | @vertex | |
| 14 | fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>, | |
| 15 | @location(2) color: vec4<f32>, @location(3) local: vec2<f32>, | |
| 16 | @location(4) shape: vec4<f32>, @location(5) stroke: f32) -> Vertex { | |
| 17 | return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color, local, shape, stroke); | |
| 18 | } | |
| 19 | ||
| 20 | fn ellipse_arc(angle: f32, radius: vec2<f32>) -> f32 { | |
| 21 | if radius.x == radius.y { | |
| 22 | return angle * radius.x; | |
| 23 | } | |
| 24 | // Four-point Gauss-Legendre quadrature. | |
| 25 | let nodes = vec4<f32>(-0.86113631, -0.33998104, 0.33998104, 0.86113631); | |
| 26 | let weights = vec4<f32>(0.34785485, 0.65214515, 0.65214515, 0.34785485); | |
| 27 | let half = angle * 0.5; | |
| 28 | let scale = max(radius.x, radius.y); | |
| 29 | var sum = 0.0; | |
| 30 | for (var i = 0u; i < 4u; i += 1u) { | |
| 31 | let t = half * (nodes[i] + 1.0); | |
| 32 | sum += weights[i] * length((radius / scale) * vec2<f32>(sin(t), cos(t))); | |
| 33 | } | |
| 34 | return half * sum * scale; | |
| 35 | } | |
| 36 | ||
| 37 | @fragment | |
| 38 | fn fragment(input: Vertex) -> @location(0) vec4<f32> { | |
| 39 | var color = textureSample(atlas, atlas_sampler, input.uv) * input.color; | |
| 40 | if input.shape.x > 0.0 && input.shape.y > 0.0 { | |
| 41 | let q = abs(input.local) - input.shape.xy + input.shape.zw; | |
| 42 | var distance: f32; | |
| 43 | if input.shape.z == input.shape.w { | |
| 44 | distance = length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - input.shape.z; | |
| 45 | } else if all(q > vec2<f32>(0.0)) { | |
| 46 | let radius = max(input.shape.zw, vec2<f32>(0.0001)); | |
| 47 | let normalized = q / radius; | |
| 48 | let k0 = length(normalized); | |
| 49 | distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); | |
| 50 | } else { | |
| 51 | let edge = abs(input.local) - input.shape.xy; | |
| 52 | distance = max(edge.x, edge.y); | |
| 53 | } | |
| 54 | var coverage = clamp(0.5 - distance, 0.0, 1.0); | |
| 55 | let width = abs(input.stroke); | |
| 56 | if width > 0.0 { | |
| 57 | coverage *= clamp(distance + width + 0.5, 0.0, 1.0); | |
| 58 | } | |
| 59 | if input.stroke < 0.0 { | |
| 60 | var radius = max(input.shape.zw - width * 0.5, vec2<f32>(0.0)); | |
| 61 | if any(radius == vec2<f32>(0.0)) { | |
| 62 | radius = vec2<f32>(0.0); | |
| 63 | } | |
| 64 | let straight = input.shape.xy - width * 0.5 - radius; | |
| 65 | let p = abs(input.local); | |
| 66 | let quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); | |
| 67 | var along: f32; | |
| 68 | if p.y <= straight.y { | |
| 69 | along = p.y; | |
| 70 | } else if p.x <= straight.x || radius.x == 0.0 { | |
| 71 | along = quarter - p.x; | |
| 72 | } else { | |
| 73 | let angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); | |
| 74 | along = straight.y + ellipse_arc(angle, radius); | |
| 75 | } | |
| 76 | if input.local.x < 0.0 { | |
| 77 | along = select(2.0 * quarter - along, 2.0 * quarter + along, input.local.y < 0.0); | |
| 78 | } else if input.local.y < 0.0 { | |
| 79 | along = 4.0 * quarter - along; | |
| 80 | } | |
| 81 | let phase = fract(along / (4.0 * width)) * (4.0 * width); | |
| 82 | coverage *= clamp(width + 0.5 - abs(phase - 2.0 * width), 0.0, 1.0); | |
| 83 | } | |
| 84 | color *= coverage; | |
| 85 | } | |
| 86 | return color; | |
| 87 | } |
crates/draw/src/icon.rs created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | use swash::{ | |
| 2 | scale::image::{Content, Image}, | |
| 3 | zeno::{Mask, Placement, Transform, Vector}, | |
| 4 | }; | |
| 5 | ||
| 6 | /// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by | |
| 7 | /// `phase` quarter pixels. Paths are filled with two-stop vertical gradients. | |
| 8 | pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { | |
| 9 | let width = (size + 1.0).ceil() as u32; | |
| 10 | let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; | |
| 11 | for source in sources { | |
| 12 | let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); | |
| 13 | assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); | |
| 14 | for path in svg.descendants().filter(|node| node.has_tag_name("path")) { | |
| 15 | let gradient = path | |
| 16 | .attribute("fill") | |
| 17 | .unwrap() | |
| 18 | .strip_prefix("url(#") | |
| 19 | .unwrap() | |
| 20 | .strip_suffix(')') | |
| 21 | .unwrap(); | |
| 22 | let gradient = svg | |
| 23 | .descendants() | |
| 24 | .find(|node| node.attribute("id") == Some(gradient)) | |
| 25 | .unwrap(); | |
| 26 | let mut stops = gradient | |
| 27 | .children() | |
| 28 | .filter(|node| node.has_tag_name("stop")) | |
| 29 | .map(|node| { | |
| 30 | let color = u32::from_str_radix( | |
| 31 | node.attribute("stop-color") | |
| 32 | .unwrap() | |
| 33 | .strip_prefix('#') | |
| 34 | .unwrap(), | |
| 35 | 16, | |
| 36 | ) | |
| 37 | .unwrap(); | |
| 38 | let [_, r, g, b] = color.to_be_bytes(); | |
| 39 | super::srgb(r, g, b) | |
| 40 | }); | |
| 41 | let top = stops.next().unwrap(); | |
| 42 | let bottom = stops.next().unwrap(); | |
| 43 | assert!(stops.next().is_none()); | |
| 44 | let (mask, _) = Mask::new(path.attribute("d").unwrap()) | |
| 45 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) | |
| 46 | .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) | |
| 47 | .size(width, width) | |
| 48 | .render(); | |
| 49 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { | |
| 50 | let alpha = f32::from(coverage) / 255.0; | |
| 51 | let y = ((index / width as usize) as f32 / size).min(1.0); | |
| 52 | for channel in 0..3 { | |
| 53 | let color = top[channel] + (bottom[channel] - top[channel]) * y; | |
| 54 | pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); | |
| 55 | } | |
| 56 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | let data = pixels | |
| 61 | .into_iter() | |
| 62 | .flat_map(|pixel| { | |
| 63 | let alpha = pixel[3]; | |
| 64 | let channel = |value: f32| { | |
| 65 | let value = if alpha > 0.0 { value / alpha } else { 0.0 }; | |
| 66 | super::srgb_byte(value) | |
| 67 | }; | |
| 68 | [ | |
| 69 | channel(pixel[0]), | |
| 70 | channel(pixel[1]), | |
| 71 | channel(pixel[2]), | |
| 72 | (alpha * 255.0).round() as u8, | |
| 73 | ] | |
| 74 | }) | |
| 75 | .collect(); | |
| 76 | Image { | |
| 77 | content: Content::Color, | |
| 78 | placement: Placement { | |
| 79 | left: 0, | |
| 80 | top: 0, | |
| 81 | width, | |
| 82 | height: width, | |
| 83 | }, | |
| 84 | data, | |
| 85 | ..Image::default() | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | #[cfg(test)] | |
| 90 | mod tests { | |
| 91 | use super::*; | |
| 92 | ||
| 93 | const BOX: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#8090a0"/></linearGradient></defs><path fill="url(#a)" d="M2 2h12v12H2z"/></svg>"##; | |
| 94 | const MARK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ff2000"/><stop offset="1" stop-color="#a00000"/></linearGradient></defs><path fill="url(#b)" d="M4 8l3 3 5-7-1-1-4 5-2-2z"/></svg>"##; | |
| 95 | ||
| 96 | #[test] | |
| 97 | fn icons_keep_transparency_and_overlays_across_scales() { | |
| 98 | for size in [8.0, 16.0, 32.0, 57.5] { | |
| 99 | for phase in [[0, 0], [1, 2], [3, 3]] { | |
| 100 | let plain = rasterize(&[BOX], size, phase); | |
| 101 | let marked = rasterize(&[BOX, MARK], size, phase); | |
| 102 | for image in [&plain, &marked] { | |
| 103 | assert_eq!( | |
| 104 | image.data.len(), | |
| 105 | (image.placement.width * image.placement.height * 4) as usize | |
| 106 | ); | |
| 107 | assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); | |
| 108 | assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); | |
| 109 | assert!( | |
| 110 | image | |
| 111 | .data | |
| 112 | .chunks_exact(4) | |
| 113 | .filter(|p| p[3] == 0) | |
| 114 | .all(|p| p[..3] == [0; 3]) | |
| 115 | ); | |
| 116 | } | |
| 117 | assert_ne!(plain.data, marked.data); | |
| 118 | assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
| 122 | } |
crates/draw/src/lib.rs created+1828| ... | ... | @@ -0,0 +1,1828 @@ |
| 1 | //! Paints layers of primitives with wgpu: glyphs and icons through one atlas, images, | |
| 2 | //! rounded rectangles and pen strokes. Each layer has its own transform and clip. | |
| 3 | ||
| 4 | mod icon; | |
| 5 | mod text; | |
| 6 | ||
| 7 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; | |
| 8 | ||
| 9 | use bytemuck::{Pod, Zeroable}; | |
| 10 | use image::ImageDecoder; | |
| 11 | use parley::fontique::Blob; | |
| 12 | use std::{ | |
| 13 | collections::{HashMap, HashSet}, | |
| 14 | ops::Range, | |
| 15 | }; | |
| 16 | use swash::{ | |
| 17 | FontRef, | |
| 18 | scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, | |
| 19 | zeno::{Angle, Format, Transform, Vector}, | |
| 20 | }; | |
| 21 | ||
| 22 | const ATLAS_SIZE: u32 = 2048; | |
| 23 | const MAX_GLYPHS: usize = 8192; | |
| 24 | const MAX_VERTICES: usize = 65_536; | |
| 25 | /// Decoded bytes of all images one frame may paint. | |
| 26 | pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; | |
| 27 | const MAX_IMAGES: usize = 256; | |
| 28 | ||
| 29 | #[derive(Clone)] | |
| 30 | pub struct RasterImage { | |
| 31 | size: [u32; 2], | |
| 32 | pixels: Blob<u8>, | |
| 33 | } | |
| 34 | ||
| 35 | impl RasterImage { | |
| 36 | pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> { | |
| 37 | if encoded.len() as u64 > MAX_IMAGE_BYTES { | |
| 38 | return Err(RenderError::ImageBudget); | |
| 39 | } | |
| 40 | let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; | |
| 41 | let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); | |
| 42 | let mut limits = image::Limits::default(); | |
| 43 | limits.max_image_width = Some(16_384); | |
| 44 | limits.max_image_height = Some(16_384); | |
| 45 | limits.max_alloc = Some(MAX_IMAGE_BYTES); | |
| 46 | reader.limits(limits.clone()); | |
| 47 | let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; | |
| 48 | let (width, height) = decoder.dimensions(); | |
| 49 | if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES | |
| 50 | || decoder.total_bytes() > MAX_IMAGE_BYTES | |
| 51 | { | |
| 52 | return Err(RenderError::ImageBudget); | |
| 53 | } | |
| 54 | limits | |
| 55 | .reserve(decoder.total_bytes()) | |
| 56 | .map_err(RenderError::ImageDecode)?; | |
| 57 | decoder | |
| 58 | .set_limits(limits) | |
| 59 | .map_err(RenderError::ImageDecode)?; | |
| 60 | let pixels = image::DynamicImage::from_decoder(decoder) | |
| 61 | .map_err(RenderError::ImageDecode)? | |
| 62 | .into_rgba8(); | |
| 63 | Self::new([width, height], pixels.into_raw()) | |
| 64 | } | |
| 65 | ||
| 66 | /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. | |
| 67 | pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> { | |
| 68 | let bytes = u64::from(size[0]) | |
| 69 | .checked_mul(u64::from(size[1])) | |
| 70 | .and_then(|v| v.checked_mul(4)) | |
| 71 | .ok_or(RenderError::InvalidImage)?; | |
| 72 | if size.contains(&0) || bytes != pixels.len() as u64 { | |
| 73 | return Err(RenderError::InvalidImage); | |
| 74 | } | |
| 75 | if bytes > MAX_IMAGE_BYTES { | |
| 76 | return Err(RenderError::ImageBudget); | |
| 77 | } | |
| 78 | // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. | |
| 79 | for pixel in pixels.chunks_exact_mut(4) { | |
| 80 | let alpha = pixel[3]; | |
| 81 | if alpha == 255 { | |
| 82 | continue; | |
| 83 | } | |
| 84 | let linear = srgb(pixel[0], pixel[1], pixel[2]); | |
| 85 | for (byte, value) in pixel[..3].iter_mut().zip(linear) { | |
| 86 | let value = value * f32::from(alpha) / 255.0; | |
| 87 | *byte = srgb_byte(value); | |
| 88 | } | |
| 89 | } | |
| 90 | Ok(Self { | |
| 91 | size, | |
| 92 | pixels: pixels.into(), | |
| 93 | }) | |
| 94 | } | |
| 95 | ||
| 96 | /// The identity caches and uploads share across clones. | |
| 97 | pub fn id(&self) -> u64 { | |
| 98 | self.pixels.id() | |
| 99 | } | |
| 100 | ||
| 101 | /// Premultiplied sRGB RGBA rows. | |
| 102 | pub fn pixels(&self) -> &[u8] { | |
| 103 | self.pixels.as_ref() | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | struct CachedImage { | |
| 108 | binding: wgpu::BindGroup, | |
| 109 | bytes: u64, | |
| 110 | } | |
| 111 | ||
| 112 | struct Batch { | |
| 113 | vertices: Range<u32>, | |
| 114 | image: Option<u64>, | |
| 115 | scissor: [u32; 4], | |
| 116 | } | |
| 117 | ||
| 118 | #[repr(C)] | |
| 119 | #[derive(Clone, Copy, Pod, Zeroable)] | |
| 120 | struct Vertex { | |
| 121 | position: [f32; 2], | |
| 122 | uv: [f32; 2], | |
| 123 | color: [f32; 4], | |
| 124 | local: [f32; 2], | |
| 125 | shape: [f32; 4], | |
| 126 | stroke: f32, | |
| 127 | } | |
| 128 | ||
| 129 | #[derive(Hash, PartialEq, Eq)] | |
| 130 | enum AtlasKey { | |
| 131 | Text { | |
| 132 | font: u64, | |
| 133 | index: u32, | |
| 134 | glyph: u16, | |
| 135 | size: u32, | |
| 136 | coords: Vec<i16>, | |
| 137 | phase: [u8; 2], | |
| 138 | embolden: bool, | |
| 139 | skew: u32, | |
| 140 | }, | |
| 141 | Icon { | |
| 142 | sources: &'static [&'static str], | |
| 143 | size: u32, | |
| 144 | phase: [u8; 2], | |
| 145 | }, | |
| 146 | } | |
| 147 | ||
| 148 | #[derive(Clone, Copy)] | |
| 149 | struct AtlasGlyph { | |
| 150 | x: u32, | |
| 151 | y: u32, | |
| 152 | width: u32, | |
| 153 | height: u32, | |
| 154 | left: i32, | |
| 155 | top: i32, | |
| 156 | color: bool, | |
| 157 | } | |
| 158 | ||
| 159 | /// Primitives sharing one transform and clip. | |
| 160 | pub struct Layer<'a> { | |
| 161 | /// Device pixels per layer unit. | |
| 162 | pub scale: f32, | |
| 163 | /// Device position of the layer origin. | |
| 164 | pub origin: [f32; 2], | |
| 165 | /// Device bounds `[left, top, right, bottom]` the layer paints within. | |
| 166 | pub clip: Option<[f32; 4]>, | |
| 167 | pub primitives: &'a [Primitive<'a>], | |
| 168 | } | |
| 169 | ||
| 170 | /// A layer's transform onto the target. | |
| 171 | #[derive(Clone, Copy)] | |
| 172 | struct Space { | |
| 173 | size: [u32; 2], | |
| 174 | scale: f32, | |
| 175 | origin: [f32; 2], | |
| 176 | } | |
| 177 | ||
| 178 | impl Space { | |
| 179 | fn visible_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> { | |
| 180 | let rect = [ | |
| 181 | rect[0] * self.scale + self.origin[0], | |
| 182 | rect[1] * self.scale + self.origin[1], | |
| 183 | rect[2] * self.scale + self.origin[0], | |
| 184 | rect[3] * self.scale + self.origin[1], | |
| 185 | ]; | |
| 186 | if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { | |
| 187 | return Err(RenderError::InvalidPrimitive); | |
| 188 | } | |
| 189 | Ok((rect[0] < self.size[0] as f32 | |
| 190 | && rect[1] < self.size[1] as f32 | |
| 191 | && rect[2] > 0.0 | |
| 192 | && rect[3] > 0.0 | |
| 193 | && rect[0] < rect[2] | |
| 194 | && rect[1] < rect[3]) | |
| 195 | .then_some(rect)) | |
| 196 | } | |
| 197 | ||
| 198 | /// Whole device pixels covering `rect` within the target, as a scissor rectangle. | |
| 199 | fn scissor(&self, rect: [f32; 4]) -> [u32; 4] { | |
| 200 | let left = rect[0].max(0.0).floor() as u32; | |
| 201 | let top = rect[1].max(0.0).floor() as u32; | |
| 202 | let right = (rect[2].min(self.size[0] as f32).ceil() as u32).max(left); | |
| 203 | let bottom = (rect[3].min(self.size[1] as f32).ceil() as u32).max(top); | |
| 204 | [left, top, right - left, bottom - top] | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | fn intersect(a: [u32; 4], b: [u32; 4]) -> [u32; 4] { | |
| 209 | let left = a[0].max(b[0]); | |
| 210 | let top = a[1].max(b[1]); | |
| 211 | let right = (a[0] + a[2]).min(b[0] + b[2]).max(left); | |
| 212 | let bottom = (a[1] + a[3]).min(b[1] + b[3]).max(top); | |
| 213 | [left, top, right - left, bottom - top] | |
| 214 | } | |
| 215 | ||
| 216 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 217 | pub enum Stroke { | |
| 218 | Solid(f32), | |
| 219 | /// Dash and gap lengths are twice the stroke width. | |
| 220 | Dashed(f32), | |
| 221 | } | |
| 222 | ||
| 223 | /// Coordinates are layer units; colors are linear RGBA. | |
| 224 | pub enum Primitive<'a> { | |
| 225 | Text { | |
| 226 | text: &'a dyn Glyphs, | |
| 227 | origin: [f32; 2], | |
| 228 | /// Layer bounds of the painted glyphs, which keep their full shaping and advances. | |
| 229 | clip: Option<[f32; 4]>, | |
| 230 | }, | |
| 231 | /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones. | |
| 232 | Icon { | |
| 233 | sources: &'static [&'static str], | |
| 234 | origin: [f32; 2], | |
| 235 | size: f32, | |
| 236 | opacity: f32, | |
| 237 | }, | |
| 238 | Rect { | |
| 239 | rect: [f32; 4], | |
| 240 | color: [f32; 4], | |
| 241 | }, | |
| 242 | RoundedRect { | |
| 243 | rect: [f32; 4], | |
| 244 | radius: [f32; 2], | |
| 245 | /// None fills the shape; stroke widths are positive layer units. | |
| 246 | stroke: Option<Stroke>, | |
| 247 | color: [f32; 4], | |
| 248 | }, | |
| 249 | Image { | |
| 250 | image: &'a RasterImage, | |
| 251 | rect: [f32; 4], | |
| 252 | }, | |
| 253 | /// A pen stroke between two points, `width` units across. | |
| 254 | Segment { | |
| 255 | from: [f32; 2], | |
| 256 | to: [f32; 2], | |
| 257 | width: f32, | |
| 258 | /// A round pen tip caps the ends; otherwise they are square. | |
| 259 | round: bool, | |
| 260 | color: [f32; 4], | |
| 261 | }, | |
| 262 | } | |
| 263 | ||
| 264 | #[derive(Debug)] | |
| 265 | pub enum RenderError { | |
| 266 | AtlasFull, | |
| 267 | FrameTooLarge, | |
| 268 | UnreadableFont, | |
| 269 | UnsupportedGlyph, | |
| 270 | InvalidLayer, | |
| 271 | InvalidPrimitive, | |
| 272 | InvalidImage, | |
| 273 | ImageBudget, | |
| 274 | ImageTooLarge, | |
| 275 | ImageDecode(image::ImageError), | |
| 276 | } | |
| 277 | ||
| 278 | /// Cache and buffer use after the last frame, for resource probes. Texture and buffer | |
| 279 | /// sizes are as requested, without driver overhead. | |
| 280 | #[derive(Debug)] | |
| 281 | pub struct Occupancy { | |
| 282 | /// Glyph and icon atlas entries, including glyphs with no pixels. | |
| 283 | pub glyphs: usize, | |
| 284 | pub atlas_texels: u64, | |
| 285 | pub images: usize, | |
| 286 | pub image_bytes: u64, | |
| 287 | pub vertices: usize, | |
| 288 | pub batches: usize, | |
| 289 | pub vertex_capacity_bytes: usize, | |
| 290 | pub batch_capacity_bytes: usize, | |
| 291 | pub glyph_capacity: usize, | |
| 292 | pub image_capacity: usize, | |
| 293 | pub atlas_bytes: u64, | |
| 294 | pub vertex_buffer_bytes: u64, | |
| 295 | pub within_budget: bool, | |
| 296 | } | |
| 297 | ||
| 298 | pub struct Renderer { | |
| 299 | pub device: wgpu::Device, | |
| 300 | pub queue: wgpu::Queue, | |
| 301 | pipeline: wgpu::RenderPipeline, | |
| 302 | image_pipeline: wgpu::RenderPipeline, | |
| 303 | atlas: wgpu::Texture, | |
| 304 | bind_group: wgpu::BindGroup, | |
| 305 | image_sampler: wgpu::Sampler, | |
| 306 | vertex_buffer: wgpu::Buffer, | |
| 307 | vertices: Vec<Vertex>, | |
| 308 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, | |
| 309 | images: HashMap<u64, CachedImage>, | |
| 310 | batches: Vec<Batch>, | |
| 311 | scaler: ScaleContext, | |
| 312 | pen: [u32; 2], | |
| 313 | row_height: u32, | |
| 314 | } | |
| 315 | ||
| 316 | impl Renderer { | |
| 317 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | |
| 318 | let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl")); | |
| 319 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | |
| 320 | label: Some("Draw texture"), | |
| 321 | entries: &[ | |
| 322 | wgpu::BindGroupLayoutEntry { | |
| 323 | binding: 0, | |
| 324 | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 325 | ty: wgpu::BindingType::Texture { | |
| 326 | sample_type: wgpu::TextureSampleType::Float { filterable: true }, | |
| 327 | view_dimension: wgpu::TextureViewDimension::D2, | |
| 328 | multisampled: false, | |
| 329 | }, | |
| 330 | count: None, | |
| 331 | }, | |
| 332 | wgpu::BindGroupLayoutEntry { | |
| 333 | binding: 1, | |
| 334 | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 335 | ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), | |
| 336 | count: None, | |
| 337 | }, | |
| 338 | ], | |
| 339 | }); | |
| 340 | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { | |
| 341 | label: Some("Draw"), | |
| 342 | bind_group_layouts: &[Some(&binding_layout)], | |
| 343 | ..Default::default() | |
| 344 | }); | |
| 345 | let make_pipeline = |blend| { | |
| 346 | device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { | |
| 347 | label: Some("Draw"), | |
| 348 | layout: Some(&pipeline_layout), | |
| 349 | vertex: wgpu::VertexState { | |
| 350 | module: &shader, | |
| 351 | entry_point: Some("vertex"), | |
| 352 | compilation_options: Default::default(), | |
| 353 | buffers: &[Some(wgpu::VertexBufferLayout { | |
| 354 | array_stride: size_of::<Vertex>() as u64, | |
| 355 | step_mode: wgpu::VertexStepMode::Vertex, | |
| 356 | attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], | |
| 357 | })], | |
| 358 | }, | |
| 359 | fragment: Some(wgpu::FragmentState { | |
| 360 | module: &shader, | |
| 361 | entry_point: Some("fragment"), | |
| 362 | compilation_options: Default::default(), | |
| 363 | targets: &[Some(wgpu::ColorTargetState { | |
| 364 | format, | |
| 365 | blend: Some(blend), | |
| 366 | write_mask: wgpu::ColorWrites::ALL, | |
| 367 | })], | |
| 368 | }), | |
| 369 | primitive: Default::default(), | |
| 370 | depth_stencil: None, | |
| 371 | multisample: Default::default(), | |
| 372 | multiview_mask: None, | |
| 373 | cache: None, | |
| 374 | }) | |
| 375 | }; | |
| 376 | let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); | |
| 377 | let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); | |
| 378 | let atlas = device.create_texture(&wgpu::TextureDescriptor { | |
| 379 | label: Some("Glyph atlas"), | |
| 380 | size: wgpu::Extent3d { | |
| 381 | width: ATLAS_SIZE, | |
| 382 | height: ATLAS_SIZE, | |
| 383 | depth_or_array_layers: 1, | |
| 384 | }, | |
| 385 | mip_level_count: 1, | |
| 386 | sample_count: 1, | |
| 387 | dimension: wgpu::TextureDimension::D2, | |
| 388 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 389 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 390 | view_formats: &[], | |
| 391 | }); | |
| 392 | let view = atlas.create_view(&Default::default()); | |
| 393 | let sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 394 | mag_filter: wgpu::FilterMode::Nearest, | |
| 395 | min_filter: wgpu::FilterMode::Nearest, | |
| 396 | ..Default::default() | |
| 397 | }); | |
| 398 | let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 399 | label: Some("Glyph atlas"), | |
| 400 | layout: &pipeline.get_bind_group_layout(0), | |
| 401 | entries: &[ | |
| 402 | wgpu::BindGroupEntry { | |
| 403 | binding: 0, | |
| 404 | resource: wgpu::BindingResource::TextureView(&view), | |
| 405 | }, | |
| 406 | wgpu::BindGroupEntry { | |
| 407 | binding: 1, | |
| 408 | resource: wgpu::BindingResource::Sampler(&sampler), | |
| 409 | }, | |
| 410 | ], | |
| 411 | }); | |
| 412 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { | |
| 413 | label: Some("Draw vertices"), | |
| 414 | size: (MAX_VERTICES * size_of::<Vertex>()) as u64, | |
| 415 | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, | |
| 416 | mapped_at_creation: false, | |
| 417 | }); | |
| 418 | let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 419 | label: Some("Draw image filtering"), | |
| 420 | mag_filter: wgpu::FilterMode::Linear, | |
| 421 | min_filter: wgpu::FilterMode::Linear, | |
| 422 | ..Default::default() | |
| 423 | }); | |
| 424 | queue.write_texture( | |
| 425 | atlas.as_image_copy(), | |
| 426 | &[255; 4], | |
| 427 | wgpu::TexelCopyBufferLayout { | |
| 428 | offset: 0, | |
| 429 | bytes_per_row: Some(4), | |
| 430 | rows_per_image: Some(1), | |
| 431 | }, | |
| 432 | wgpu::Extent3d { | |
| 433 | width: 1, | |
| 434 | height: 1, | |
| 435 | depth_or_array_layers: 1, | |
| 436 | }, | |
| 437 | ); | |
| 438 | Self { | |
| 439 | device, | |
| 440 | queue, | |
| 441 | pipeline, | |
| 442 | image_pipeline, | |
| 443 | atlas, | |
| 444 | bind_group, | |
| 445 | image_sampler, | |
| 446 | vertex_buffer, | |
| 447 | vertices: Vec::new(), | |
| 448 | glyphs: HashMap::new(), | |
| 449 | images: HashMap::new(), | |
| 450 | batches: Vec::new(), | |
| 451 | scaler: ScaleContext::with_max_entries(32), | |
| 452 | pen: [1, 0], | |
| 453 | row_height: 1, | |
| 454 | } | |
| 455 | } | |
| 456 | ||
| 457 | pub fn clear_glyph_cache(&mut self) { | |
| 458 | self.glyphs.clear(); | |
| 459 | self.pen = [1, 0]; | |
| 460 | self.row_height = 1; | |
| 461 | } | |
| 462 | ||
| 463 | /// Forgets uploaded images; they upload again when next painted. | |
| 464 | pub fn clear_images(&mut self) { | |
| 465 | self.images.clear(); | |
| 466 | } | |
| 467 | ||
| 468 | pub fn occupancy(&self) -> Occupancy { | |
| 469 | let image_bytes = self.images.values().map(|image| image.bytes).sum(); | |
| 470 | Occupancy { | |
| 471 | glyphs: self.glyphs.len(), | |
| 472 | atlas_texels: self | |
| 473 | .glyphs | |
| 474 | .values() | |
| 475 | .flatten() | |
| 476 | .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) | |
| 477 | .sum(), | |
| 478 | images: self.images.len(), | |
| 479 | image_bytes, | |
| 480 | vertices: self.vertices.len(), | |
| 481 | batches: self.batches.len(), | |
| 482 | vertex_capacity_bytes: self.vertices.capacity() * size_of::<Vertex>(), | |
| 483 | batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(), | |
| 484 | glyph_capacity: self.glyphs.capacity(), | |
| 485 | image_capacity: self.images.capacity(), | |
| 486 | atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4, | |
| 487 | vertex_buffer_bytes: self.vertex_buffer.size(), | |
| 488 | within_budget: self.glyphs.len() <= MAX_GLYPHS | |
| 489 | && self.images.len() <= MAX_IMAGES | |
| 490 | && image_bytes <= MAX_IMAGE_BYTES | |
| 491 | && self.vertices.len() <= MAX_VERTICES, | |
| 492 | } | |
| 493 | } | |
| 494 | ||
| 495 | /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order. | |
| 496 | pub fn draw( | |
| 497 | &mut self, | |
| 498 | target: &wgpu::TextureView, | |
| 499 | size: [u32; 2], | |
| 500 | clear: [f32; 4], | |
| 501 | layers: &[Layer<'_>], | |
| 502 | ) -> Result<(), RenderError> { | |
| 503 | if size.contains(&0) | |
| 504 | || clear.iter().any(|v| !v.is_finite()) | |
| 505 | || layers.iter().any(|layer| { | |
| 506 | !layer.scale.is_finite() | |
| 507 | || layer.scale <= 0.0 | |
| 508 | || layer.origin.iter().any(|v| !v.is_finite()) | |
| 509 | || layer | |
| 510 | .clip | |
| 511 | .is_some_and(|clip| clip.iter().any(|v| !v.is_finite())) | |
| 512 | }) | |
| 513 | { | |
| 514 | return Err(RenderError::InvalidLayer); | |
| 515 | } | |
| 516 | let mut active_images = HashSet::new(); | |
| 517 | let mut image_bytes = 0; | |
| 518 | for layer in layers { | |
| 519 | let space = Space { | |
| 520 | size, | |
| 521 | scale: layer.scale, | |
| 522 | origin: layer.origin, | |
| 523 | }; | |
| 524 | for primitive in layer.primitives { | |
| 525 | if let Primitive::Image { image, rect } = primitive | |
| 526 | && space.visible_rect(*rect)?.is_some() | |
| 527 | && active_images.insert(image.id()) | |
| 528 | { | |
| 529 | image_bytes += image.pixels().len() as u64; | |
| 530 | if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { | |
| 531 | return Err(RenderError::ImageBudget); | |
| 532 | } | |
| 533 | } | |
| 534 | } | |
| 535 | } | |
| 536 | for attempt in 0..2 { | |
| 537 | self.vertices.clear(); | |
| 538 | self.batches.clear(); | |
| 539 | match self.prepare(size, layers, &active_images) { | |
| 540 | Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), | |
| 541 | result => { | |
| 542 | result?; | |
| 543 | break; | |
| 544 | } | |
| 545 | } | |
| 546 | } | |
| 547 | self.queue | |
| 548 | .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); | |
| 549 | let mut encoder = self.device.create_command_encoder(&Default::default()); | |
| 550 | { | |
| 551 | let [r, g, b, a] = clear.map(f64::from); | |
| 552 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 553 | label: Some("Draw"), | |
| 554 | color_attachments: &[Some(wgpu::RenderPassColorAttachment { | |
| 555 | view: target, | |
| 556 | depth_slice: None, | |
| 557 | resolve_target: None, | |
| 558 | ops: wgpu::Operations { | |
| 559 | load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), | |
| 560 | store: wgpu::StoreOp::Store, | |
| 561 | }, | |
| 562 | })], | |
| 563 | ..Default::default() | |
| 564 | }); | |
| 565 | pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); | |
| 566 | for batch in &self.batches { | |
| 567 | let [x, y, width, height] = batch.scissor; | |
| 568 | pass.set_scissor_rect(x, y, width, height); | |
| 569 | pass.set_pipeline(if batch.image.is_some() { | |
| 570 | &self.image_pipeline | |
| 571 | } else { | |
| 572 | &self.pipeline | |
| 573 | }); | |
| 574 | let binding = batch | |
| 575 | .image | |
| 576 | .map(|id| &self.images[&id].binding) | |
| 577 | .unwrap_or(&self.bind_group); | |
| 578 | pass.set_bind_group(0, binding, &[]); | |
| 579 | pass.draw(batch.vertices.clone(), 0..1); | |
| 580 | } | |
| 581 | } | |
| 582 | self.queue.submit([encoder.finish()]); | |
| 583 | Ok(()) | |
| 584 | } | |
| 585 | ||
| 586 | fn prepare( | |
| 587 | &mut self, | |
| 588 | size: [u32; 2], | |
| 589 | layers: &[Layer<'_>], | |
| 590 | active_images: &HashSet<u64>, | |
| 591 | ) -> Result<(), RenderError> { | |
| 592 | for layer in layers { | |
| 593 | let space = Space { | |
| 594 | size, | |
| 595 | scale: layer.scale, | |
| 596 | origin: layer.origin, | |
| 597 | }; | |
| 598 | let bounds = match layer.clip { | |
| 599 | Some(clip) => space.scissor(clip), | |
| 600 | None => [0, 0, size[0], size[1]], | |
| 601 | }; | |
| 602 | if bounds[2] == 0 || bounds[3] == 0 { | |
| 603 | continue; | |
| 604 | } | |
| 605 | for primitive in layer.primitives { | |
| 606 | self.primitive(space, bounds, primitive, active_images)?; | |
| 607 | } | |
| 608 | } | |
| 609 | Ok(()) | |
| 610 | } | |
| 611 | ||
| 612 | fn primitive( | |
| 613 | &mut self, | |
| 614 | space: Space, | |
| 615 | bounds: [u32; 4], | |
| 616 | primitive: &Primitive<'_>, | |
| 617 | active_images: &HashSet<u64>, | |
| 618 | ) -> Result<(), RenderError> { | |
| 619 | let start = self.vertices.len() as u32; | |
| 620 | let mut image_id = None; | |
| 621 | let mut scissor = bounds; | |
| 622 | match primitive { | |
| 623 | Primitive::Icon { | |
| 624 | sources, | |
| 625 | origin, | |
| 626 | size, | |
| 627 | opacity, | |
| 628 | } => { | |
| 629 | let origin = [ | |
| 630 | space.origin[0] + origin[0] * space.scale, | |
| 631 | space.origin[1] + origin[1] * space.scale, | |
| 632 | ]; | |
| 633 | self.icon(Space { origin, ..space }, sources, *size, *opacity)?; | |
| 634 | } | |
| 635 | Primitive::Text { text, origin, clip } => { | |
| 636 | let origin = [ | |
| 637 | space.origin[0] + origin[0] * space.scale, | |
| 638 | space.origin[1] + origin[1] * space.scale, | |
| 639 | ]; | |
| 640 | if origin.iter().any(|v| !v.is_finite()) { | |
| 641 | return Err(RenderError::InvalidPrimitive); | |
| 642 | } | |
| 643 | if let Some(clip) = clip { | |
| 644 | let Some(rect) = space.visible_rect(*clip)? else { | |
| 645 | return Ok(()); | |
| 646 | }; | |
| 647 | scissor = intersect(bounds, space.scissor(rect)); | |
| 648 | if scissor[2] == 0 || scissor[3] == 0 { | |
| 649 | return Ok(()); | |
| 650 | } | |
| 651 | } | |
| 652 | let space = Space { origin, ..space }; | |
| 653 | text.runs(&mut |run| self.glyph_run(space, run))?; | |
| 654 | } | |
| 655 | Primitive::Rect { rect, color } => { | |
| 656 | self.quad( | |
| 657 | space, | |
| 658 | [ | |
| 659 | rect[0] * space.scale + space.origin[0], | |
| 660 | rect[1] * space.scale + space.origin[1], | |
| 661 | rect[2] * space.scale + space.origin[0], | |
| 662 | rect[3] * space.scale + space.origin[1], | |
| 663 | ], | |
| 664 | [0.5 / ATLAS_SIZE as f32; 4], | |
| 665 | *color, | |
| 666 | )?; | |
| 667 | } | |
| 668 | Primitive::RoundedRect { | |
| 669 | rect, | |
| 670 | radius, | |
| 671 | stroke, | |
| 672 | color, | |
| 673 | } => { | |
| 674 | if radius | |
| 675 | .iter() | |
| 676 | .any(|value| !value.is_finite() || *value < 0.0) | |
| 677 | { | |
| 678 | return Err(RenderError::InvalidPrimitive); | |
| 679 | } | |
| 680 | let width = match stroke { | |
| 681 | None => 0.0, | |
| 682 | Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { | |
| 683 | if !width.is_finite() || *width <= 0.0 || width * space.scale == 0.0 { | |
| 684 | return Err(RenderError::InvalidPrimitive); | |
| 685 | } | |
| 686 | *width | |
| 687 | } | |
| 688 | }; | |
| 689 | let rect = [ | |
| 690 | rect[0] * space.scale + space.origin[0], | |
| 691 | rect[1] * space.scale + space.origin[1], | |
| 692 | rect[2] * space.scale + space.origin[0], | |
| 693 | rect[3] * space.scale + space.origin[1], | |
| 694 | ]; | |
| 695 | self.quad(space, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?; | |
| 696 | let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; | |
| 697 | if !(4.0 * (half[0] + half[1])).is_finite() { | |
| 698 | return Err(RenderError::InvalidPrimitive); | |
| 699 | } | |
| 700 | let radius = if radius.contains(&0.0) { | |
| 701 | [0.0; 2] | |
| 702 | } else { | |
| 703 | std::array::from_fn(|axis| (radius[axis] * space.scale).min(half[axis])) | |
| 704 | }; | |
| 705 | let width = (width * space.scale).min(half[0].min(half[1])); | |
| 706 | // The stroke sign selects its dash pattern at the shader boundary. | |
| 707 | let width = if matches!(stroke, Some(Stroke::Dashed(_))) { | |
| 708 | -width | |
| 709 | } else { | |
| 710 | width | |
| 711 | }; | |
| 712 | for vertex in &mut self.vertices[start as usize..] { | |
| 713 | vertex.shape = [half[0], half[1], radius[0], radius[1]]; | |
| 714 | vertex.stroke = width; | |
| 715 | } | |
| 716 | } | |
| 717 | Primitive::Segment { | |
| 718 | from, | |
| 719 | to, | |
| 720 | width, | |
| 721 | round, | |
| 722 | color, | |
| 723 | } => self.segment(space, *from, *to, *width, *round, *color)?, | |
| 724 | Primitive::Image { image, rect } => { | |
| 725 | if let Some(rect) = space.visible_rect(*rect)? { | |
| 726 | self.image(image, active_images)?; | |
| 727 | image_id = Some(image.id()); | |
| 728 | self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; | |
| 729 | } | |
| 730 | } | |
| 731 | } | |
| 732 | let end = self.vertices.len() as u32; | |
| 733 | if start != end { | |
| 734 | if let Some(last) = self.batches.last_mut() | |
| 735 | && last.image == image_id | |
| 736 | && last.scissor == scissor | |
| 737 | { | |
| 738 | last.vertices.end = end; | |
| 739 | } else { | |
| 740 | self.batches.push(Batch { | |
| 741 | vertices: start..end, | |
| 742 | image: image_id, | |
| 743 | scissor, | |
| 744 | }); | |
| 745 | } | |
| 746 | } | |
| 747 | Ok(()) | |
| 748 | } | |
| 749 | ||
| 750 | fn image(&mut self, image: &RasterImage, active: &HashSet<u64>) -> Result<(), RenderError> { | |
| 751 | if self.images.contains_key(&image.id()) { | |
| 752 | return Ok(()); | |
| 753 | } | |
| 754 | if image | |
| 755 | .size | |
| 756 | .iter() | |
| 757 | .any(|v| *v > self.device.limits().max_texture_dimension_2d) | |
| 758 | { | |
| 759 | return Err(RenderError::ImageTooLarge); | |
| 760 | } | |
| 761 | let bytes = image.pixels().len() as u64; | |
| 762 | while self.images.values().map(|i| i.bytes).sum::<u64>() + bytes > MAX_IMAGE_BYTES | |
| 763 | || self.images.len() >= MAX_IMAGES | |
| 764 | { | |
| 765 | let id = self | |
| 766 | .images | |
| 767 | .keys() | |
| 768 | .find(|id| !active.contains(id)) | |
| 769 | .copied() | |
| 770 | .ok_or(RenderError::ImageBudget)?; | |
| 771 | self.images.remove(&id); | |
| 772 | } | |
| 773 | let size = wgpu::Extent3d { | |
| 774 | width: image.size[0], | |
| 775 | height: image.size[1], | |
| 776 | depth_or_array_layers: 1, | |
| 777 | }; | |
| 778 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 779 | label: Some("Draw image"), | |
| 780 | size, | |
| 781 | mip_level_count: 1, | |
| 782 | sample_count: 1, | |
| 783 | dimension: wgpu::TextureDimension::D2, | |
| 784 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 785 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 786 | view_formats: &[], | |
| 787 | }); | |
| 788 | self.queue.write_texture( | |
| 789 | texture.as_image_copy(), | |
| 790 | image.pixels(), | |
| 791 | wgpu::TexelCopyBufferLayout { | |
| 792 | offset: 0, | |
| 793 | bytes_per_row: Some(image.size[0] * 4), | |
| 794 | rows_per_image: Some(image.size[1]), | |
| 795 | }, | |
| 796 | size, | |
| 797 | ); | |
| 798 | let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 799 | label: Some("Draw image"), | |
| 800 | layout: &self.pipeline.get_bind_group_layout(0), | |
| 801 | entries: &[ | |
| 802 | wgpu::BindGroupEntry { | |
| 803 | binding: 0, | |
| 804 | resource: wgpu::BindingResource::TextureView( | |
| 805 | &texture.create_view(&Default::default()), | |
| 806 | ), | |
| 807 | }, | |
| 808 | wgpu::BindGroupEntry { | |
| 809 | binding: 1, | |
| 810 | resource: wgpu::BindingResource::Sampler(&self.image_sampler), | |
| 811 | }, | |
| 812 | ], | |
| 813 | }); | |
| 814 | self.images | |
| 815 | .insert(image.id(), CachedImage { binding, bytes }); | |
| 816 | Ok(()) | |
| 817 | } | |
| 818 | ||
| 819 | fn glyph_run(&mut self, space: Space, run: GlyphRun<'_>) -> Result<(), RenderError> { | |
| 820 | let scale = space.scale; | |
| 821 | let origin = space.origin; | |
| 822 | let line_top = run.line[0] * scale + origin[1]; | |
| 823 | let line_height = run.line[1] * scale; | |
| 824 | if line_top + line_height < 0.0 || line_top > space.size[1] as f32 { | |
| 825 | return Ok(()); | |
| 826 | } | |
| 827 | let size = run.size * scale; | |
| 828 | if !size.is_finite() || size > ATLAS_SIZE as f32 { | |
| 829 | return Err(RenderError::AtlasFull); | |
| 830 | } | |
| 831 | for glyph in run.glyphs { | |
| 832 | let x = glyph.x * scale + origin[0]; | |
| 833 | let y = glyph.y * scale + origin[1]; | |
| 834 | // Quantization affects raster coverage only, never advances or line breaks. | |
| 835 | let x = (x * 4.0).round() * 0.25; | |
| 836 | let y = (y * 4.0).round() * 0.25; | |
| 837 | let glyph_id = glyph | |
| 838 | .id | |
| 839 | .try_into() | |
| 840 | .map_err(|_| RenderError::UnsupportedGlyph)?; | |
| 841 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 842 | let key = AtlasKey::Text { | |
| 843 | font: run.font.id(), | |
| 844 | index: run.index, | |
| 845 | glyph: glyph_id, | |
| 846 | size: size.to_bits(), | |
| 847 | coords: run.coords.to_vec(), | |
| 848 | phase, | |
| 849 | embolden: run.embolden, | |
| 850 | skew: run.skew.unwrap_or(0.0).to_bits(), | |
| 851 | }; | |
| 852 | let cached = if let Some(cached) = self.glyphs.get(&key) { | |
| 853 | *cached | |
| 854 | } else { | |
| 855 | if self.glyphs.len() >= MAX_GLYPHS { | |
| 856 | return Err(RenderError::AtlasFull); | |
| 857 | } | |
| 858 | let font = FontRef::from_index(run.font.as_ref(), run.index as usize) | |
| 859 | .ok_or(RenderError::UnreadableFont)?; | |
| 860 | let mut scaler = self | |
| 861 | .scaler | |
| 862 | .builder_with_id(font, [run.font.id(), u64::from(run.index)]) | |
| 863 | .size(size) | |
| 864 | .hint(false) | |
| 865 | .normalized_coords(run.coords.iter().copied()) | |
| 866 | .build(); | |
| 867 | let mut render = Render::new(&[ | |
| 868 | Source::ColorOutline(0), | |
| 869 | Source::ColorBitmap(StrikeWith::BestFit), | |
| 870 | Source::Outline, | |
| 871 | ]); | |
| 872 | // Swash rasterizes outlines in Y-up coordinates. | |
| 873 | render.format(Format::Alpha).offset(Vector::new( | |
| 874 | f32::from(phase[0]) * 0.25, | |
| 875 | -f32::from(phase[1]) * 0.25, | |
| 876 | )); | |
| 877 | if run.embolden { | |
| 878 | render.embolden(size / 24.0); | |
| 879 | } | |
| 880 | if let Some(skew) = run.skew { | |
| 881 | render.transform(Some(Transform::skew( | |
| 882 | Angle::from_degrees(skew), | |
| 883 | Angle::from_degrees(0.0), | |
| 884 | ))); | |
| 885 | } | |
| 886 | let image = render.render(&mut scaler, glyph_id); | |
| 887 | let cached = if let Some(image) = | |
| 888 | image.filter(|i| i.placement.width > 0 && i.placement.height > 0) | |
| 889 | { | |
| 890 | Some(self.upload(image)?) | |
| 891 | } else { | |
| 892 | None | |
| 893 | }; | |
| 894 | self.glyphs.insert(key, cached); | |
| 895 | cached | |
| 896 | }; | |
| 897 | if let Some(glyph) = cached { | |
| 898 | let mut left = x.floor() + glyph.left as f32; | |
| 899 | let mut top = y.floor() - glyph.top as f32; | |
| 900 | let mut width = glyph.width as f32; | |
| 901 | let mut height = glyph.height as f32; | |
| 902 | if glyph.color { | |
| 903 | let ratio = (line_height / height).min(1.0); | |
| 904 | left += width * (1.0 - ratio) * 0.5; | |
| 905 | width *= ratio; | |
| 906 | height *= ratio; | |
| 907 | top = top.clamp(line_top, (line_top + line_height - height).max(line_top)); | |
| 908 | } | |
| 909 | let atlas_size = ATLAS_SIZE as f32; | |
| 910 | self.quad( | |
| 911 | space, | |
| 912 | [left, top, left + width, top + height], | |
| 913 | [ | |
| 914 | glyph.x as f32 / atlas_size, | |
| 915 | glyph.y as f32 / atlas_size, | |
| 916 | (glyph.x + glyph.width) as f32 / atlas_size, | |
| 917 | (glyph.y + glyph.height) as f32 / atlas_size, | |
| 918 | ], | |
| 919 | if glyph.color { [1.0; 4] } else { run.color }, | |
| 920 | )?; | |
| 921 | } | |
| 922 | } | |
| 923 | for decoration in run.decorations { | |
| 924 | let x = decoration.x * scale + origin[0]; | |
| 925 | let y = decoration.y * scale + origin[1]; | |
| 926 | self.quad( | |
| 927 | space, | |
| 928 | [ | |
| 929 | x, | |
| 930 | y, | |
| 931 | x + decoration.width * scale, | |
| 932 | y + (decoration.thickness * scale).max(1.0), | |
| 933 | ], | |
| 934 | [0.5 / ATLAS_SIZE as f32; 4], | |
| 935 | decoration.color, | |
| 936 | )?; | |
| 937 | } | |
| 938 | Ok(()) | |
| 939 | } | |
| 940 | ||
| 941 | fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> { | |
| 942 | let p = image.placement; | |
| 943 | if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { | |
| 944 | return Err(RenderError::AtlasFull); | |
| 945 | } | |
| 946 | if self.pen[0] + p.width + 1 > ATLAS_SIZE { | |
| 947 | self.pen[0] = 0; | |
| 948 | self.pen[1] += self.row_height + 1; | |
| 949 | self.row_height = 0; | |
| 950 | } | |
| 951 | if self.pen[1] + p.height + 1 > ATLAS_SIZE { | |
| 952 | return Err(RenderError::AtlasFull); | |
| 953 | } | |
| 954 | let cached = AtlasGlyph { | |
| 955 | x: self.pen[0], | |
| 956 | y: self.pen[1], | |
| 957 | width: p.width, | |
| 958 | height: p.height, | |
| 959 | left: p.left, | |
| 960 | top: p.top, | |
| 961 | color: image.content == Content::Color, | |
| 962 | }; | |
| 963 | let rgba = match image.content { | |
| 964 | Content::Mask => image | |
| 965 | .data | |
| 966 | .iter() | |
| 967 | .flat_map(|a| [255, 255, 255, *a]) | |
| 968 | .collect(), | |
| 969 | _ => image.data, | |
| 970 | }; | |
| 971 | self.queue.write_texture( | |
| 972 | wgpu::TexelCopyTextureInfo { | |
| 973 | origin: wgpu::Origin3d { | |
| 974 | x: cached.x, | |
| 975 | y: cached.y, | |
| 976 | z: 0, | |
| 977 | }, | |
| 978 | ..self.atlas.as_image_copy() | |
| 979 | }, | |
| 980 | &rgba, | |
| 981 | wgpu::TexelCopyBufferLayout { | |
| 982 | offset: 0, | |
| 983 | bytes_per_row: Some(p.width * 4), | |
| 984 | rows_per_image: Some(p.height), | |
| 985 | }, | |
| 986 | wgpu::Extent3d { | |
| 987 | width: p.width, | |
| 988 | height: p.height, | |
| 989 | depth_or_array_layers: 1, | |
| 990 | }, | |
| 991 | ); | |
| 992 | self.pen[0] += p.width + 1; | |
| 993 | self.row_height = self.row_height.max(p.height); | |
| 994 | Ok(cached) | |
| 995 | } | |
| 996 | ||
| 997 | fn icon( | |
| 998 | &mut self, | |
| 999 | space: Space, | |
| 1000 | sources: &'static [&'static str], | |
| 1001 | size: f32, | |
| 1002 | opacity: f32, | |
| 1003 | ) -> Result<(), RenderError> { | |
| 1004 | let size = size * space.scale; | |
| 1005 | let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); | |
| 1006 | if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { | |
| 1007 | return Err(RenderError::InvalidPrimitive); | |
| 1008 | } | |
| 1009 | if x + size + 1.0 < 0.0 | |
| 1010 | || y + size + 1.0 < 0.0 | |
| 1011 | || x - 1.0 > space.size[0] as f32 | |
| 1012 | || y - 1.0 > space.size[1] as f32 | |
| 1013 | { | |
| 1014 | return Ok(()); | |
| 1015 | } | |
| 1016 | if size + 3.0 > ATLAS_SIZE as f32 { | |
| 1017 | return Err(RenderError::AtlasFull); | |
| 1018 | } | |
| 1019 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 1020 | let key = AtlasKey::Icon { | |
| 1021 | sources, | |
| 1022 | size: size.to_bits(), | |
| 1023 | phase, | |
| 1024 | }; | |
| 1025 | let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { | |
| 1026 | *glyph | |
| 1027 | } else { | |
| 1028 | if self.glyphs.len() >= MAX_GLYPHS { | |
| 1029 | return Err(RenderError::AtlasFull); | |
| 1030 | } | |
| 1031 | let glyph = self.upload(icon::rasterize(sources, size, phase))?; | |
| 1032 | self.glyphs.insert(key, Some(glyph)); | |
| 1033 | glyph | |
| 1034 | }; | |
| 1035 | let left = x.floor(); | |
| 1036 | let top = y.floor(); | |
| 1037 | let atlas = ATLAS_SIZE as f32; | |
| 1038 | self.quad( | |
| 1039 | space, | |
| 1040 | [ | |
| 1041 | left, | |
| 1042 | top, | |
| 1043 | left + glyph.width as f32, | |
| 1044 | top + glyph.height as f32, | |
| 1045 | ], | |
| 1046 | [ | |
| 1047 | glyph.x as f32 / atlas, | |
| 1048 | glyph.y as f32 / atlas, | |
| 1049 | (glyph.x + glyph.width) as f32 / atlas, | |
| 1050 | (glyph.y + glyph.height) as f32 / atlas, | |
| 1051 | ], | |
| 1052 | [1.0, 1.0, 1.0, opacity], | |
| 1053 | ) | |
| 1054 | } | |
| 1055 | ||
| 1056 | /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. | |
| 1057 | fn segment( | |
| 1058 | &mut self, | |
| 1059 | space: Space, | |
| 1060 | from: [f32; 2], | |
| 1061 | to: [f32; 2], | |
| 1062 | width: f32, | |
| 1063 | round: bool, | |
| 1064 | color: [f32; 4], | |
| 1065 | ) -> Result<(), RenderError> { | |
| 1066 | let pixel = |p: [f32; 2]| { | |
| 1067 | [ | |
| 1068 | p[0] * space.scale + space.origin[0], | |
| 1069 | p[1] * space.scale + space.origin[1], | |
| 1070 | ] | |
| 1071 | }; | |
| 1072 | let [from, to] = [pixel(from), pixel(to)]; | |
| 1073 | // Hairlines stay one device pixel wide. | |
| 1074 | let radius = (width * space.scale).max(1.0) * 0.5; | |
| 1075 | if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { | |
| 1076 | return Err(RenderError::InvalidPrimitive); | |
| 1077 | } | |
| 1078 | let pad = radius + 1.0; | |
| 1079 | if from[0].max(to[0]) + pad < 0.0 | |
| 1080 | || from[1].max(to[1]) + pad < 0.0 | |
| 1081 | || from[0].min(to[0]) - pad > space.size[0] as f32 | |
| 1082 | || from[1].min(to[1]) - pad > space.size[1] as f32 | |
| 1083 | { | |
| 1084 | return Ok(()); | |
| 1085 | } | |
| 1086 | if self.vertices.len() + 6 > MAX_VERTICES { | |
| 1087 | return Err(RenderError::FrameTooLarge); | |
| 1088 | } | |
| 1089 | let delta = [to[0] - from[0], to[1] - from[1]]; | |
| 1090 | let length = delta[0].hypot(delta[1]); | |
| 1091 | let along = if length > 0.0 { | |
| 1092 | [delta[0] / length, delta[1] / length] | |
| 1093 | } else { | |
| 1094 | [1.0, 0.0] | |
| 1095 | }; | |
| 1096 | let across = [-along[1], along[0]]; | |
| 1097 | let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; | |
| 1098 | let half = [length * 0.5 + radius, radius]; | |
| 1099 | let corner = if round { radius } else { 0.0 }; | |
| 1100 | let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; | |
| 1101 | for local in [ | |
| 1102 | [-hx, -hy], | |
| 1103 | [-hx, hy], | |
| 1104 | [hx, hy], | |
| 1105 | [-hx, -hy], | |
| 1106 | [hx, hy], | |
| 1107 | [hx, -hy], | |
| 1108 | ] { | |
| 1109 | let x = center[0] + along[0] * local[0] + across[0] * local[1]; | |
| 1110 | let y = center[1] + along[1] * local[0] + across[1] * local[1]; | |
| 1111 | self.vertices.push(Vertex { | |
| 1112 | position: [ | |
| 1113 | x * 2.0 / space.size[0] as f32 - 1.0, | |
| 1114 | 1.0 - y * 2.0 / space.size[1] as f32, | |
| 1115 | ], | |
| 1116 | uv: [0.5 / ATLAS_SIZE as f32; 2], | |
| 1117 | color, | |
| 1118 | local, | |
| 1119 | shape: [half[0], half[1], corner, corner], | |
| 1120 | stroke: 0.0, | |
| 1121 | }); | |
| 1122 | } | |
| 1123 | Ok(()) | |
| 1124 | } | |
| 1125 | ||
| 1126 | fn quad( | |
| 1127 | &mut self, | |
| 1128 | space: Space, | |
| 1129 | rect: [f32; 4], | |
| 1130 | uv: [f32; 4], | |
| 1131 | color: [f32; 4], | |
| 1132 | ) -> Result<(), RenderError> { | |
| 1133 | if rect.iter().chain(&color).any(|v| !v.is_finite()) | |
| 1134 | || rect[2] < rect[0] | |
| 1135 | || rect[3] < rect[1] | |
| 1136 | { | |
| 1137 | return Err(RenderError::InvalidPrimitive); | |
| 1138 | } | |
| 1139 | if rect[2] < 0.0 | |
| 1140 | || rect[3] < 0.0 | |
| 1141 | || rect[0] > space.size[0] as f32 | |
| 1142 | || rect[1] > space.size[1] as f32 | |
| 1143 | { | |
| 1144 | return Ok(()); | |
| 1145 | } | |
| 1146 | if self.vertices.len() + 6 > MAX_VERTICES { | |
| 1147 | return Err(RenderError::FrameTooLarge); | |
| 1148 | } | |
| 1149 | let x0 = rect[0] * 2.0 / space.size[0] as f32 - 1.0; | |
| 1150 | let x1 = rect[2] * 2.0 / space.size[0] as f32 - 1.0; | |
| 1151 | let y0 = 1.0 - rect[1] * 2.0 / space.size[1] as f32; | |
| 1152 | let y1 = 1.0 - rect[3] * 2.0 / space.size[1] as f32; | |
| 1153 | if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { | |
| 1154 | return Err(RenderError::InvalidPrimitive); | |
| 1155 | } | |
| 1156 | let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; | |
| 1157 | for (position, uv, local) in [ | |
| 1158 | ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), | |
| 1159 | ([x0, y1], [uv[0], uv[3]], [-hx, hy]), | |
| 1160 | ([x1, y1], [uv[2], uv[3]], [hx, hy]), | |
| 1161 | ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), | |
| 1162 | ([x1, y1], [uv[2], uv[3]], [hx, hy]), | |
| 1163 | ([x1, y0], [uv[2], uv[1]], [hx, -hy]), | |
| 1164 | ] { | |
| 1165 | self.vertices.push(Vertex { | |
| 1166 | position, | |
| 1167 | uv, | |
| 1168 | color, | |
| 1169 | local, | |
| 1170 | shape: [0.0; 4], | |
| 1171 | stroke: 0.0, | |
| 1172 | }); | |
| 1173 | } | |
| 1174 | Ok(()) | |
| 1175 | } | |
| 1176 | } | |
| 1177 | ||
| 1178 | fn srgb_byte(value: f32) -> u8 { | |
| 1179 | let srgb = if value <= 0.0031308 { | |
| 1180 | value * 12.92 | |
| 1181 | } else { | |
| 1182 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | |
| 1183 | }; | |
| 1184 | (srgb * 255.0).round() as u8 | |
| 1185 | } | |
| 1186 | ||
| 1187 | /// Linear RGBA of an opaque sRGB colour. | |
| 1188 | pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { | |
| 1189 | let linear = |byte: u8| { | |
| 1190 | let value = f32::from(byte) / 255.0; | |
| 1191 | if value <= 0.04045 { | |
| 1192 | value / 12.92 | |
| 1193 | } else { | |
| 1194 | ((value + 0.055) / 1.055).powf(2.4) | |
| 1195 | } | |
| 1196 | }; | |
| 1197 | [linear(red), linear(green), linear(blue), 1.0] | |
| 1198 | } | |
| 1199 | ||
| 1200 | #[cfg(test)] | |
| 1201 | mod tests { | |
| 1202 | use super::*; | |
| 1203 | use parley::{ | |
| 1204 | FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext, | |
| 1205 | PositionedLayoutItem, StyleProperty, | |
| 1206 | }; | |
| 1207 | use std::time::Duration; | |
| 1208 | ||
| 1209 | const CHECKBOX: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#7090b0"/></linearGradient></defs><path fill="url(#a)" d="M2 2h12v12H2zM4 4v8h8V4z"/></svg>"##; | |
| 1210 | const MARK: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#20c040"/><stop offset="1" stop-color="#107020"/></linearGradient></defs><path fill="url(#b)" d="M4 8l3 3 5-7-1-1-4 5-2-2z"/></svg>"##; | |
| 1211 | const DOT: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><linearGradient id="c" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffd000"/><stop offset="1" stop-color="#c08000"/></linearGradient></defs><path fill="url(#c)" d="M8 2a6 6 0 1 0 0.01 0z"/></svg>"##; | |
| 1212 | ||
| 1213 | struct Text(Layout<[u8; 4]>); | |
| 1214 | ||
| 1215 | impl Glyphs for Text { | |
| 1216 | fn runs( | |
| 1217 | &self, | |
| 1218 | paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, | |
| 1219 | ) -> Result<(), RenderError> { | |
| 1220 | for line in self.0.lines() { | |
| 1221 | let metrics = line.metrics(); | |
| 1222 | for item in line.items() { | |
| 1223 | if let PositionedLayoutItem::GlyphRun(run) = item { | |
| 1224 | paint_parley_run( | |
| 1225 | &run, | |
| 1226 | metrics.baseline, | |
| 1227 | 0.0, | |
| 1228 | [metrics.block_min_coord, metrics.line_height], | |
| 1229 | |_| [0.0, 0.0, 0.0, 1.0], | |
| 1230 | paint, | |
| 1231 | )?; | |
| 1232 | } | |
| 1233 | } | |
| 1234 | } | |
| 1235 | Ok(()) | |
| 1236 | } | |
| 1237 | } | |
| 1238 | ||
| 1239 | fn shape(text: &str, family: &str, size: f32, underline: bool, width: f32) -> Text { | |
| 1240 | let mut fonts = FontContext::default(); | |
| 1241 | let mut context = LayoutContext::default(); | |
| 1242 | let mut builder = context.ranged_builder(&mut fonts, text, 1.0, false); | |
| 1243 | builder.push_default(StyleProperty::FontFamily(FontFamily::List( | |
| 1244 | vec![ | |
| 1245 | FontFamilyName::named(family), | |
| 1246 | GenericFamily::SansSerif.into(), | |
| 1247 | ] | |
| 1248 | .into(), | |
| 1249 | ))); | |
| 1250 | builder.push_default(StyleProperty::FontSize(size)); | |
| 1251 | builder.push_default(StyleProperty::Underline(underline)); | |
| 1252 | let mut layout = builder.build(text); | |
| 1253 | layout.break_all_lines(Some(width)); | |
| 1254 | Text(layout) | |
| 1255 | } | |
| 1256 | ||
| 1257 | #[test] | |
| 1258 | fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { | |
| 1259 | let mut encoded = Vec::new(); | |
| 1260 | let mut encoder = png::Encoder::new(&mut encoded, 2, 1); | |
| 1261 | encoder.set_color(png::ColorType::Rgba); | |
| 1262 | encoder.set_depth(png::BitDepth::Eight); | |
| 1263 | let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; | |
| 1264 | encoder | |
| 1265 | .write_header() | |
| 1266 | .unwrap() | |
| 1267 | .write_image_data(&rgba) | |
| 1268 | .unwrap(); | |
| 1269 | let decoded = RasterImage::decode(&encoded).unwrap(); | |
| 1270 | assert_eq!(decoded.size, [2, 1]); | |
| 1271 | assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); | |
| 1272 | for end in 0..encoded.len() - 12 { | |
| 1273 | assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); | |
| 1274 | } | |
| 1275 | ||
| 1276 | let mut jpeg = Vec::new(); | |
| 1277 | image::codecs::jpeg::JpegEncoder::new(&mut jpeg) | |
| 1278 | .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) | |
| 1279 | .unwrap(); | |
| 1280 | let decoded = RasterImage::decode(&jpeg).unwrap(); | |
| 1281 | assert_eq!(decoded.size, [4, 4]); | |
| 1282 | assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); | |
| 1283 | ||
| 1284 | let mut gif = Vec::new(); | |
| 1285 | image::codecs::gif::GifEncoder::new(&mut gif) | |
| 1286 | .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) | |
| 1287 | .unwrap(); | |
| 1288 | let decoded = RasterImage::decode(&gif).unwrap(); | |
| 1289 | assert_eq!(decoded.size, [2, 2]); | |
| 1290 | assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); | |
| 1291 | ||
| 1292 | for size in [[8192, 8192], [16_385, 1]] { | |
| 1293 | let mut header = Vec::new(); | |
| 1294 | let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); | |
| 1295 | encoder.set_color(png::ColorType::Rgba); | |
| 1296 | encoder.set_depth(png::BitDepth::Eight); | |
| 1297 | let mut writer = encoder.write_header().unwrap(); | |
| 1298 | writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); | |
| 1299 | drop(writer); | |
| 1300 | let error = RasterImage::decode(&header).err().unwrap(); | |
| 1301 | if size[0] == 8192 { | |
| 1302 | assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); | |
| 1303 | } else { | |
| 1304 | assert!( | |
| 1305 | matches!( | |
| 1306 | error, | |
| 1307 | RenderError::ImageDecode(image::ImageError::Limits(_)) | |
| 1308 | ), | |
| 1309 | "{error:?}" | |
| 1310 | ); | |
| 1311 | } | |
| 1312 | } | |
| 1313 | } | |
| 1314 | ||
| 1315 | #[test] | |
| 1316 | fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { | |
| 1317 | assert!(matches!( | |
| 1318 | RasterImage::new([0, 1], Vec::new()), | |
| 1319 | Err(RenderError::InvalidImage) | |
| 1320 | )); | |
| 1321 | assert!(matches!( | |
| 1322 | RasterImage::new([2, 2], vec![255; 15]), | |
| 1323 | Err(RenderError::InvalidImage) | |
| 1324 | )); | |
| 1325 | assert!(matches!( | |
| 1326 | RasterImage::new([u32::MAX; 2], Vec::new()), | |
| 1327 | Err(RenderError::InvalidImage) | |
| 1328 | )); | |
| 1329 | let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); | |
| 1330 | assert_eq!(image.id(), image.clone().id()); | |
| 1331 | } | |
| 1332 | ||
| 1333 | struct Target { | |
| 1334 | texture: wgpu::Texture, | |
| 1335 | readback: wgpu::Buffer, | |
| 1336 | } | |
| 1337 | ||
| 1338 | impl Target { | |
| 1339 | fn new(device: &wgpu::Device) -> Self { | |
| 1340 | Self { | |
| 1341 | texture: device.create_texture(&wgpu::TextureDescriptor { | |
| 1342 | label: Some("Draw readback test"), | |
| 1343 | size: wgpu::Extent3d { | |
| 1344 | width: 512, | |
| 1345 | height: 256, | |
| 1346 | depth_or_array_layers: 1, | |
| 1347 | }, | |
| 1348 | mip_level_count: 1, | |
| 1349 | sample_count: 1, | |
| 1350 | dimension: wgpu::TextureDimension::D2, | |
| 1351 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 1352 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, | |
| 1353 | view_formats: &[], | |
| 1354 | }), | |
| 1355 | readback: device.create_buffer(&wgpu::BufferDescriptor { | |
| 1356 | label: Some("Draw readback"), | |
| 1357 | size: 512 * 256 * 4, | |
| 1358 | usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, | |
| 1359 | mapped_at_creation: false, | |
| 1360 | }), | |
| 1361 | } | |
| 1362 | } | |
| 1363 | ||
| 1364 | fn draw(&self, renderer: &mut Renderer, layers: &[Layer<'_>]) -> Result<(), RenderError> { | |
| 1365 | renderer.draw( | |
| 1366 | &self.texture.create_view(&Default::default()), | |
| 1367 | [512, 256], | |
| 1368 | [1.0; 4], | |
| 1369 | layers, | |
| 1370 | ) | |
| 1371 | } | |
| 1372 | ||
| 1373 | fn capture(&self, renderer: &Renderer) -> Vec<u8> { | |
| 1374 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 1375 | encoder.copy_texture_to_buffer( | |
| 1376 | self.texture.as_image_copy(), | |
| 1377 | wgpu::TexelCopyBufferInfo { | |
| 1378 | buffer: &self.readback, | |
| 1379 | layout: wgpu::TexelCopyBufferLayout { | |
| 1380 | offset: 0, | |
| 1381 | bytes_per_row: Some(512 * 4), | |
| 1382 | rows_per_image: Some(256), | |
| 1383 | }, | |
| 1384 | }, | |
| 1385 | wgpu::Extent3d { | |
| 1386 | width: 512, | |
| 1387 | height: 256, | |
| 1388 | depth_or_array_layers: 1, | |
| 1389 | }, | |
| 1390 | ); | |
| 1391 | renderer.queue.submit([encoder.finish()]); | |
| 1392 | let (sender, receiver) = std::sync::mpsc::channel(); | |
| 1393 | self.readback | |
| 1394 | .map_async(wgpu::MapMode::Read, .., move |result| { | |
| 1395 | sender.send(result).unwrap(); | |
| 1396 | }); | |
| 1397 | renderer | |
| 1398 | .device | |
| 1399 | .poll(wgpu::PollType::Wait { | |
| 1400 | submission_index: None, | |
| 1401 | timeout: Some(Duration::from_secs(5)), | |
| 1402 | }) | |
| 1403 | .unwrap(); | |
| 1404 | receiver | |
| 1405 | .recv_timeout(Duration::from_secs(5)) | |
| 1406 | .unwrap() | |
| 1407 | .unwrap(); | |
| 1408 | let capture = self.readback.get_mapped_range(..).unwrap().to_vec(); | |
| 1409 | self.readback.unmap(); | |
| 1410 | capture | |
| 1411 | } | |
| 1412 | } | |
| 1413 | ||
| 1414 | fn page<'a>(primitives: &'a [Primitive<'a>]) -> [Layer<'a>; 1] { | |
| 1415 | [Layer { | |
| 1416 | scale: 2.0, | |
| 1417 | origin: [24.0; 2], | |
| 1418 | clip: None, | |
| 1419 | primitives, | |
| 1420 | }] | |
| 1421 | } | |
| 1422 | ||
| 1423 | #[test] | |
| 1424 | #[ignore = "requires a native GPU adapter"] | |
| 1425 | fn eviction_and_repaint_reproduce_pixels() { | |
| 1426 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 1427 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); | |
| 1428 | let (device, queue) = | |
| 1429 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); | |
| 1430 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; | |
| 1431 | let target = Target::new(&device); | |
| 1432 | let mut renderer = Renderer::new(device, queue, format); | |
| 1433 | let layout = shape("Hello 🌳\nCafé e\u{301} שלום", "Arial", 11.0, true, 220.0); | |
| 1434 | let mut captures = Vec::new(); | |
| 1435 | let image = RasterImage::new( | |
| 1436 | [2, 2], | |
| 1437 | vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], | |
| 1438 | ) | |
| 1439 | .unwrap(); | |
| 1440 | let transparent_edge = | |
| 1441 | RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); | |
| 1442 | let icons: [&'static [&'static str]; 4] = [&[CHECKBOX], &[CHECKBOX, MARK], &[DOT], &[MARK]]; | |
| 1443 | let phase_layout = shape("H", "Arial", 11.0, false, 20.0); | |
| 1444 | let mut primitives = vec![ | |
| 1445 | Primitive::Rect { | |
| 1446 | rect: [0.0, 0.0, 70.0, 14.0], | |
| 1447 | color: [0.55, 0.73, 1.0, 0.5], | |
| 1448 | }, | |
| 1449 | Primitive::Text { | |
| 1450 | clip: None, | |
| 1451 | text: &layout, | |
| 1452 | origin: [0.0; 2], | |
| 1453 | }, | |
| 1454 | Primitive::Text { | |
| 1455 | clip: None, | |
| 1456 | text: &layout, | |
| 1457 | origin: [8.0, 64.0], | |
| 1458 | }, | |
| 1459 | Primitive::Image { | |
| 1460 | image: &image, | |
| 1461 | rect: [128.0, 8.0, 160.0, 40.0], | |
| 1462 | }, | |
| 1463 | Primitive::Rect { | |
| 1464 | rect: [128.0, 8.0, 132.0, 12.0], | |
| 1465 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1466 | }, | |
| 1467 | Primitive::Image { | |
| 1468 | image: &transparent_edge, | |
| 1469 | rect: [176.0, 8.0, 208.0, 40.0], | |
| 1470 | }, | |
| 1471 | Primitive::RoundedRect { | |
| 1472 | rect: [128.0, 48.0, 148.0, 68.0], | |
| 1473 | radius: [5.0, 10.0], | |
| 1474 | stroke: None, | |
| 1475 | color: [1.0, 0.0, 0.0, 1.0], | |
| 1476 | }, | |
| 1477 | Primitive::RoundedRect { | |
| 1478 | rect: [164.0, 48.0, 184.0, 68.0], | |
| 1479 | radius: [5.0; 2], | |
| 1480 | stroke: Some(Stroke::Solid(1.0)), | |
| 1481 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1482 | }, | |
| 1483 | Primitive::RoundedRect { | |
| 1484 | rect: [200.0, 48.0, 240.0, 68.0], | |
| 1485 | radius: [3.0, 10.0], | |
| 1486 | stroke: Some(Stroke::Dashed(1.0)), | |
| 1487 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1488 | }, | |
| 1489 | Primitive::Segment { | |
| 1490 | from: [226.0, 104.0], | |
| 1491 | to: [240.0, 104.0], | |
| 1492 | width: 4.0, | |
| 1493 | round: true, | |
| 1494 | color: [1.0, 0.0, 0.0, 1.0], | |
| 1495 | }, | |
| 1496 | ]; | |
| 1497 | for (index, sources) in icons.iter().enumerate() { | |
| 1498 | primitives.push(Primitive::Icon { | |
| 1499 | sources, | |
| 1500 | origin: [128.125 + 20.0 * index as f32, 80.25], | |
| 1501 | size: 12.0, | |
| 1502 | opacity: 1.0, | |
| 1503 | }); | |
| 1504 | } | |
| 1505 | primitives.extend((0..9).map(|step| Primitive::Text { | |
| 1506 | clip: None, | |
| 1507 | text: &phase_layout, | |
| 1508 | origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], | |
| 1509 | })); | |
| 1510 | for pass in 0..6 { | |
| 1511 | if pass == 2 { | |
| 1512 | renderer.clear_glyph_cache(); | |
| 1513 | renderer.images.clear(); | |
| 1514 | } else if pass == 3 { | |
| 1515 | renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); | |
| 1516 | } | |
| 1517 | let clipping = [ | |
| 1518 | Primitive::Text { | |
| 1519 | text: &layout, | |
| 1520 | origin: [0.0; 2], | |
| 1521 | clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), | |
| 1522 | }, | |
| 1523 | Primitive::Rect { | |
| 1524 | rect: [40.0, 40.0, 70.0, 50.0], | |
| 1525 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1526 | }, | |
| 1527 | ]; | |
| 1528 | target | |
| 1529 | .draw( | |
| 1530 | &mut renderer, | |
| 1531 | &page(if pass < 4 { &primitives } else { &clipping }), | |
| 1532 | ) | |
| 1533 | .unwrap(); | |
| 1534 | captures.push(target.capture(&renderer)); | |
| 1535 | } | |
| 1536 | let centroids: Vec<_> = (0..9) | |
| 1537 | .map(|step| { | |
| 1538 | let mut weight = 0.0_f64; | |
| 1539 | let mut moment = 0.0_f64; | |
| 1540 | for y in 212..252 { | |
| 1541 | for x in (40 + 48 * step)..(72 + 48 * step) { | |
| 1542 | let value = captures[0][(y * 512 + x) * 4]; | |
| 1543 | let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]); | |
| 1544 | weight += coverage; | |
| 1545 | moment += coverage * y as f64; | |
| 1546 | } | |
| 1547 | } | |
| 1548 | assert!(weight > 10.0, "phase sample {step} was not painted"); | |
| 1549 | moment / weight | |
| 1550 | }) | |
| 1551 | .collect(); | |
| 1552 | let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; | |
| 1553 | assert_eq!(pixel(490, 232), [255, 0, 0, 255]); | |
| 1554 | assert_eq!(pixel(473, 232), [255, 0, 0, 255]); | |
| 1555 | assert_eq!(pixel(490, 238), [255; 4]); | |
| 1556 | assert_eq!(pixel(472, 228), [255; 4]); | |
| 1557 | assert_eq!(pixel(300, 140), [255, 0, 0, 255]); | |
| 1558 | assert_eq!(pixel(280, 120), [255; 4]); | |
| 1559 | assert_eq!(pixel(282, 124), [255; 4]); | |
| 1560 | assert_eq!(pixel(281, 140), [255, 0, 0, 255]); | |
| 1561 | assert_eq!(pixel(372, 140), [255; 4]); | |
| 1562 | assert_eq!(pixel(372, 120), [0, 0, 255, 255]); | |
| 1563 | assert_eq!(pixel(352, 120), [255; 4]); | |
| 1564 | assert_eq!(pixel(424, 120), [255; 4]); | |
| 1565 | assert_eq!(pixel(464, 140), [255; 4]); | |
| 1566 | assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); | |
| 1567 | assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); | |
| 1568 | for x in 440..472 { | |
| 1569 | assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); | |
| 1570 | } | |
| 1571 | for (step, centroid) in centroids.iter().enumerate() { | |
| 1572 | let movement = centroid - centroids[0]; | |
| 1573 | assert!( | |
| 1574 | (movement - step as f64 * 0.25).abs() < 0.06, | |
| 1575 | "quarter-pixel step {step} moved the glyph by {movement} px" | |
| 1576 | ); | |
| 1577 | } | |
| 1578 | assert!(!renderer.glyphs.is_empty()); | |
| 1579 | assert!(renderer.glyphs.len() <= MAX_GLYPHS); | |
| 1580 | assert_eq!( | |
| 1581 | renderer | |
| 1582 | .glyphs | |
| 1583 | .keys() | |
| 1584 | .filter(|key| matches!(key, AtlasKey::Icon { .. })) | |
| 1585 | .count(), | |
| 1586 | 4 | |
| 1587 | ); | |
| 1588 | for x in [280, 320, 360, 400] { | |
| 1589 | let colored = (184..210) | |
| 1590 | .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) | |
| 1591 | .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) | |
| 1592 | .count(); | |
| 1593 | assert!(colored > 10, "icon at {x} was not painted"); | |
| 1594 | } | |
| 1595 | let count = renderer.glyphs.len(); | |
| 1596 | target | |
| 1597 | .draw( | |
| 1598 | &mut renderer, | |
| 1599 | &page(&[Primitive::Icon { | |
| 1600 | sources: icons[0], | |
| 1601 | origin: [10000.0; 2], | |
| 1602 | size: 12.0, | |
| 1603 | opacity: 1.0, | |
| 1604 | }]), | |
| 1605 | ) | |
| 1606 | .unwrap(); | |
| 1607 | assert_eq!(renderer.glyphs.len(), count); | |
| 1608 | assert!( | |
| 1609 | captures[0] | |
| 1610 | .chunks_exact(4) | |
| 1611 | .filter(|pixel| pixel[0] < 200) | |
| 1612 | .count() | |
| 1613 | > 100 | |
| 1614 | ); | |
| 1615 | assert_eq!(captures[0], captures[1]); | |
| 1616 | assert_eq!(captures[0], captures[2]); | |
| 1617 | assert_eq!(captures[0], captures[3]); | |
| 1618 | assert_ne!(captures[4], captures[5]); | |
| 1619 | for y in 0..256 { | |
| 1620 | for x in 0..512 { | |
| 1621 | let offset = (y * 512 + x) * 4; | |
| 1622 | let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) | |
| 1623 | || ((104..164).contains(&x) && (104..124).contains(&y)) | |
| 1624 | { | |
| 1625 | &captures[4][offset..offset + 4] | |
| 1626 | } else { | |
| 1627 | &[255; 4] | |
| 1628 | }; | |
| 1629 | assert_eq!( | |
| 1630 | &captures[5][offset..offset + 4], | |
| 1631 | expected, | |
| 1632 | "clipping at {x},{y}" | |
| 1633 | ); | |
| 1634 | } | |
| 1635 | } | |
| 1636 | for (x, y, color) in [ | |
| 1637 | (288, 48, [255, 0, 0, 255]), | |
| 1638 | (336, 96, [255; 4]), | |
| 1639 | (284, 44, [0, 0, 255, 255]), | |
| 1640 | ] { | |
| 1641 | let offset = (y * 512 + x) * 4; | |
| 1642 | assert_eq!(captures[0][offset..offset + 4], color); | |
| 1643 | } | |
| 1644 | let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; | |
| 1645 | assert_eq!(edge[0], 255, "{edge:?}"); | |
| 1646 | assert_eq!(edge[1], edge[2], "{edge:?}"); | |
| 1647 | assert!((180..=200).contains(&edge[1]), "{edge:?}"); | |
| 1648 | assert_eq!(renderer.images.len(), 2); | |
| 1649 | for primitive in [ | |
| 1650 | Primitive::RoundedRect { | |
| 1651 | rect: [-1e38, 0.0, 1e38, 1e38], | |
| 1652 | radius: [3.0; 2], | |
| 1653 | stroke: Some(Stroke::Dashed(1.0)), | |
| 1654 | color: [1.0; 4], | |
| 1655 | }, | |
| 1656 | Primitive::RoundedRect { | |
| 1657 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1658 | radius: [f32::NAN, 2.0], | |
| 1659 | stroke: None, | |
| 1660 | color: [1.0; 4], | |
| 1661 | }, | |
| 1662 | Primitive::RoundedRect { | |
| 1663 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1664 | radius: [2.0; 2], | |
| 1665 | stroke: Some(Stroke::Solid(0.0)), | |
| 1666 | color: [1.0; 4], | |
| 1667 | }, | |
| 1668 | Primitive::RoundedRect { | |
| 1669 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1670 | radius: [2.0; 2], | |
| 1671 | stroke: Some(Stroke::Dashed(f32::NAN)), | |
| 1672 | color: [1.0; 4], | |
| 1673 | }, | |
| 1674 | Primitive::Rect { | |
| 1675 | rect: [0.0, 0.0, f32::NAN, 1.0], | |
| 1676 | color: [1.0; 4], | |
| 1677 | }, | |
| 1678 | Primitive::Rect { | |
| 1679 | rect: [2.0, 0.0, 1.0, 1.0], | |
| 1680 | color: [1.0; 4], | |
| 1681 | }, | |
| 1682 | Primitive::Text { | |
| 1683 | clip: None, | |
| 1684 | text: &layout, | |
| 1685 | origin: [f32::MAX; 2], | |
| 1686 | }, | |
| 1687 | Primitive::Text { | |
| 1688 | clip: Some([0.0, 0.0, f32::NAN, 1.0]), | |
| 1689 | text: &layout, | |
| 1690 | origin: [0.0; 2], | |
| 1691 | }, | |
| 1692 | Primitive::Text { | |
| 1693 | clip: Some([2.0, 0.0, 1.0, 1.0]), | |
| 1694 | text: &layout, | |
| 1695 | origin: [0.0; 2], | |
| 1696 | }, | |
| 1697 | ] { | |
| 1698 | assert!(matches!( | |
| 1699 | target.draw(&mut renderer, &page(&[primitive])), | |
| 1700 | Err(RenderError::InvalidPrimitive) | |
| 1701 | )); | |
| 1702 | } | |
| 1703 | let too_many: Vec<_> = (0..=MAX_IMAGES) | |
| 1704 | .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) | |
| 1705 | .collect(); | |
| 1706 | let primitives: Vec<_> = too_many | |
| 1707 | .iter() | |
| 1708 | .map(|image| Primitive::Image { | |
| 1709 | image, | |
| 1710 | rect: [0.0, 0.0, 1.0, 1.0], | |
| 1711 | }) | |
| 1712 | .collect(); | |
| 1713 | assert!(matches!( | |
| 1714 | target.draw(&mut renderer, &page(&primitives)), | |
| 1715 | Err(RenderError::ImageBudget) | |
| 1716 | )); | |
| 1717 | let primitives: Vec<_> = too_many | |
| 1718 | .iter() | |
| 1719 | .map(|image| Primitive::Image { | |
| 1720 | image, | |
| 1721 | rect: [-20.0, -20.0, -19.0, -19.0], | |
| 1722 | }) | |
| 1723 | .collect(); | |
| 1724 | target.draw(&mut renderer, &page(&primitives)).unwrap(); | |
| 1725 | assert_eq!(renderer.images.len(), 2); | |
| 1726 | let mut seen = HashSet::from([image.id(), transparent_edge.id()]); | |
| 1727 | let mut previous = None; | |
| 1728 | for _ in 0..5 { | |
| 1729 | let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); | |
| 1730 | seen.insert(large.id()); | |
| 1731 | let mut primitives = vec![Primitive::Image { | |
| 1732 | image: &large, | |
| 1733 | rect: [0.0, 0.0, 16.0, 16.0], | |
| 1734 | }]; | |
| 1735 | if let Some(image) = &previous { | |
| 1736 | primitives.push(Primitive::Image { | |
| 1737 | image, | |
| 1738 | rect: [16.0, 0.0, 32.0, 16.0], | |
| 1739 | }); | |
| 1740 | } | |
| 1741 | target.draw(&mut renderer, &page(&primitives)).unwrap(); | |
| 1742 | assert!(renderer.images.contains_key(&large.id())); | |
| 1743 | if let Some(image) = &previous { | |
| 1744 | assert!(renderer.images.contains_key(&image.id())); | |
| 1745 | } | |
| 1746 | assert!(renderer.occupancy().within_budget); | |
| 1747 | previous = Some(large); | |
| 1748 | } | |
| 1749 | assert!(renderer.images.len() <= MAX_IMAGES); | |
| 1750 | assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); | |
| 1751 | renderer.images.clear(); | |
| 1752 | target | |
| 1753 | .draw( | |
| 1754 | &mut renderer, | |
| 1755 | &page(&[Primitive::Image { | |
| 1756 | image: &image, | |
| 1757 | rect: [128.0, 8.0, 160.0, 40.0], | |
| 1758 | }]), | |
| 1759 | ) | |
| 1760 | .unwrap(); | |
| 1761 | assert_eq!(renderer.images.len(), 1); | |
| 1762 | assert!(renderer.images.contains_key(&image.id())); | |
| 1763 | } | |
| 1764 | ||
| 1765 | #[test] | |
| 1766 | #[ignore = "requires a native GPU adapter"] | |
| 1767 | fn layers_transform_and_clip_independently() { | |
| 1768 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 1769 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); | |
| 1770 | let (device, queue) = | |
| 1771 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); | |
| 1772 | let target = Target::new(&device); | |
| 1773 | let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); | |
| 1774 | let fill = [Primitive::Rect { | |
| 1775 | rect: [0.0, 0.0, 300.0, 100.0], | |
| 1776 | color: [1.0, 0.0, 0.0, 1.0], | |
| 1777 | }]; | |
| 1778 | let chrome = [Primitive::Rect { | |
| 1779 | rect: [0.0, 0.0, 10.0, 10.0], | |
| 1780 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1781 | }]; | |
| 1782 | target | |
| 1783 | .draw( | |
| 1784 | &mut renderer, | |
| 1785 | &[ | |
| 1786 | Layer { | |
| 1787 | scale: 1.0, | |
| 1788 | origin: [0.0; 2], | |
| 1789 | clip: Some([100.0, 50.0, 200.5, 80.0]), | |
| 1790 | primitives: &fill, | |
| 1791 | }, | |
| 1792 | Layer { | |
| 1793 | scale: 2.0, | |
| 1794 | origin: [300.0, 10.0], | |
| 1795 | clip: None, | |
| 1796 | primitives: &chrome, | |
| 1797 | }, | |
| 1798 | ], | |
| 1799 | ) | |
| 1800 | .unwrap(); | |
| 1801 | let capture = target.capture(&renderer); | |
| 1802 | let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; | |
| 1803 | assert_eq!(pixel(10, 10), [255; 4], "clipped away"); | |
| 1804 | assert_eq!(pixel(100, 50), [255, 0, 0, 255]); | |
| 1805 | assert_eq!( | |
| 1806 | pixel(200, 79), | |
| 1807 | [255, 0, 0, 255], | |
| 1808 | "partial pixels round outward" | |
| 1809 | ); | |
| 1810 | assert_eq!(pixel(99, 60), [255; 4]); | |
| 1811 | assert_eq!(pixel(201, 60), [255; 4]); | |
| 1812 | assert_eq!(pixel(150, 80), [255; 4]); | |
| 1813 | assert_eq!(pixel(319, 29), [0, 0, 255, 255]); | |
| 1814 | assert_eq!(pixel(320, 30), [255; 4]); | |
| 1815 | assert!(matches!( | |
| 1816 | target.draw( | |
| 1817 | &mut renderer, | |
| 1818 | &[Layer { | |
| 1819 | scale: 0.0, | |
| 1820 | origin: [0.0; 2], | |
| 1821 | clip: None, | |
| 1822 | primitives: &fill, | |
| 1823 | }] | |
| 1824 | ), | |
| 1825 | Err(RenderError::InvalidLayer) | |
| 1826 | )); | |
| 1827 | } | |
| 1828 | } |
crates/draw/src/text.rs created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | use crate::RenderError; | |
| 2 | use parley::fontique::Blob; | |
| 3 | ||
| 4 | /// Text the renderer can paint, as glyph runs in layer units. Owners recover their | |
| 5 | /// concrete text from a primitive through `Any`. | |
| 6 | pub trait Glyphs: std::any::Any { | |
| 7 | fn runs( | |
| 8 | &self, | |
| 9 | paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, | |
| 10 | ) -> Result<(), RenderError>; | |
| 11 | } | |
| 12 | ||
| 13 | /// Glyphs sharing one font, size and colour. | |
| 14 | pub struct GlyphRun<'a> { | |
| 15 | pub font: &'a Blob<u8>, | |
| 16 | /// The face within a font collection. | |
| 17 | pub index: u32, | |
| 18 | pub size: f32, | |
| 19 | /// Variation coordinates in F2Dot14 bits. | |
| 20 | pub coords: &'a [i16], | |
| 21 | pub embolden: bool, | |
| 22 | /// Synthesized oblique angle in degrees. | |
| 23 | pub skew: Option<f32>, | |
| 24 | pub color: [f32; 4], | |
| 25 | /// Top and height of the run's line: lines outside the target are skipped, and | |
| 26 | /// colour glyphs shrink to fit. | |
| 27 | pub line: [f32; 2], | |
| 28 | pub glyphs: &'a mut dyn Iterator<Item = Glyph>, | |
| 29 | /// Underline and strikethrough, painted after the glyphs. | |
| 30 | pub decorations: &'a [Decoration], | |
| 31 | } | |
| 32 | ||
| 33 | /// A glyph's baseline position. | |
| 34 | #[derive(Clone, Copy)] | |
| 35 | pub struct Glyph { | |
| 36 | pub id: u32, | |
| 37 | pub x: f32, | |
| 38 | pub y: f32, | |
| 39 | } | |
| 40 | ||
| 41 | /// A decoration line whose top edge is `y`; it stays at least one device pixel thick. | |
| 42 | #[derive(Clone, Copy)] | |
| 43 | pub struct Decoration { | |
| 44 | pub x: f32, | |
| 45 | pub y: f32, | |
| 46 | pub width: f32, | |
| 47 | pub thickness: f32, | |
| 48 | pub color: [f32; 4], | |
| 49 | } | |
| 50 | ||
| 51 | /// Paints one parley glyph run with its line's baseline moved to `baseline` and its | |
| 52 | /// glyphs raised by `rise`. | |
| 53 | pub fn paint_parley_run<B: parley::Brush>( | |
| 54 | run: &parley::GlyphRun<'_, B>, | |
| 55 | baseline: f32, | |
| 56 | rise: f32, | |
| 57 | line: [f32; 2], | |
| 58 | color: impl Fn(&B) -> [f32; 4], | |
| 59 | paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, | |
| 60 | ) -> Result<(), RenderError> { | |
| 61 | let shaped = run.run(); | |
| 62 | let font = &shaped.font().font; | |
| 63 | let coords: Vec<i16> = shaped | |
| 64 | .normalized_coords() | |
| 65 | .iter() | |
| 66 | .map(|c| c.to_bits()) | |
| 67 | .collect(); | |
| 68 | let synthesis = shaped.synthesis(); | |
| 69 | let style = run.style(); | |
| 70 | let metrics = shaped.font_metrics(); | |
| 71 | let decorations: Vec<_> = [ | |
| 72 | ( | |
| 73 | &style.underline, | |
| 74 | metrics.underline_offset, | |
| 75 | metrics.underline_size, | |
| 76 | ), | |
| 77 | ( | |
| 78 | &style.strikethrough, | |
| 79 | metrics.strikethrough_offset, | |
| 80 | metrics.strikethrough_size, | |
| 81 | ), | |
| 82 | ] | |
| 83 | .into_iter() | |
| 84 | .filter_map(|(decoration, offset, thickness)| { | |
| 85 | let decoration = decoration.as_ref()?; | |
| 86 | Some(Decoration { | |
| 87 | x: run.offset(), | |
| 88 | y: baseline - rise - decoration.offset.unwrap_or(offset), | |
| 89 | width: run.advance(), | |
| 90 | thickness: decoration.size.unwrap_or(thickness), | |
| 91 | color: color(&decoration.brush), | |
| 92 | }) | |
| 93 | }) | |
| 94 | .collect(); | |
| 95 | let line_baseline = run.baseline(); | |
| 96 | let mut glyphs = run.positioned_glyphs().map(|glyph| Glyph { | |
| 97 | id: glyph.id, | |
| 98 | x: glyph.x, | |
| 99 | y: glyph.y + baseline - line_baseline - rise, | |
| 100 | }); | |
| 101 | paint(GlyphRun { | |
| 102 | font: &font.data, | |
| 103 | index: font.index, | |
| 104 | size: shaped.font_size(), | |
| 105 | coords: &coords, | |
| 106 | embolden: synthesis.embolden(), | |
| 107 | skew: synthesis.skew(), | |
| 108 | color: color(&style.brush), | |
| 109 | line, | |
| 110 | glyphs: &mut glyphs, | |
| 111 | decorations: &decorations, | |
| 112 | }) | |
| 113 | } |
crates/snowbound/Cargo.toml+1| ... | ... | @@ -6,6 +6,7 @@ publish = false |
| 6 | 6 | |
| 7 | 7 | [dependencies] |
| 8 | 8 | canvas = { path = "../canvas", features = ["interaction"] } |
| 9 | draw = { path = "../draw" } | |
| 9 | 10 | onestore = { path = "../onestore" } |
| 10 | 11 | notebook = { path = "../notebook" } |
| 11 | 12 | pollster = "0.4" |
crates/snowbound/src/main.rs+5-3| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | mod macos; |
| 2 | 2 | |
| 3 | use canvas::gpu::{Renderer, page::PageScene}; | |
| 3 | use canvas::gpu::page::PageScene; | |
| 4 | 4 | use canvas::interaction::{ |
| 5 | 5 | Cursor, Key as PageKey, Modifiers, NamedKey as PageNamedKey, PageView, Request, Response, |
| 6 | 6 | TextColors, accessibility, |
| ... | ... | @@ -11,6 +11,7 @@ use canvas::{ |
| 11 | 11 | editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, |
| 12 | 12 | layout::TextEngine, |
| 13 | 13 | }; |
| 14 | use draw::Renderer; | |
| 14 | 15 | use onestore::document::Format; |
| 15 | 16 | use onestore::page::Page; |
| 16 | 17 | use onestore::page::text::Paragraph; |
| ... | ... | @@ -604,8 +605,9 @@ impl State { |
| 604 | 605 | self.renderer |
| 605 | 606 | .draw( |
| 606 | 607 | &frame.texture.create_view(&Default::default()), |
| 607 | viewport, | |
| 608 | &primitives, | |
| 608 | viewport.size, | |
| 609 | [1.0; 4], | |
| 610 | &[viewport.layer(&primitives)], | |
| 609 | 611 | ) |
| 610 | 612 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; |
| 611 | 613 | self.window.pre_present_notify(); |
tools/canvas/README.md+6-3| ... | ... | @@ -10,6 +10,7 @@ The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. |
| 10 | 10 | python3 tools/canvas/build_macos.py |
| 11 | 11 | "target/Snowbound.app/Contents/MacOS/Snowbound" |
| 12 | 12 | cargo test -p canvas --features interaction |
| 13 | cargo test -p draw -- --ignored | |
| 13 | 14 | cargo test -p canvas --features gpu gpu:: -- --ignored |
| 14 | 15 | ``` |
| 15 | 16 | |
| ... | ... | @@ -30,7 +31,9 @@ onestore::Format |
| 30 | 31 | ↓ |
| 31 | 32 | canvas text edits · inverse history · composition · paragraph layout · structural edits |
| 32 | 33 | ↓ |
| 33 | canvas::gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches | |
| 34 | canvas::gpu page scene · text runs, tag icons, pictures and ink as draw primitives | |
| 35 | ↓ | |
| 36 | draw Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches · layers | |
| 34 | 37 | ↓ |
| 35 | 38 | canvas::interaction hit layers · drags · placement grid · picture handles · chrome · key routing |
| 36 | 39 | · scrolling and zoom · caret blink · AccessKit tree |
| ... | ... | @@ -40,9 +43,9 @@ snowbound Winit window and events · AppKit pickers and quit guard · clipboar |
| 40 | 43 | |
| 41 | 44 | The renderer allocates a 2048² RGBA glyph atlas and a 3.5 MiB vertex buffer, limits combined text-glyph and tag-icon entries to 8192, and rebuilds a frame after atlas eviction. Font scaling uses 32 cache entries. These limits bound those caches; they are not a process-memory or worst-case rasterization budget. Drawing and hit-testing share Windows-metric line boxes. First-baseline fidelity and substitute-font metric selection remain experimental; the `windows_*` probe values expose that policy independently from Parley's original metrics. |
| 42 | 45 | |
| 43 | Drawing accepts a flat sequence of positioned text layouts, tag icons, rectangles and immutable raster images. Consecutive primitives with the same texture share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache. | |
| 46 | The `draw` crate owns the renderer so the page and application chrome share one pass, atlas and image cache. It paints layers, each a transform and device clip over a flat sequence of glyph runs, SVG icons, rectangles, pen strokes and immutable raster images. Text reaches it through the `Glyphs` trait, which visits positioned glyph runs, so the page's Windows-metric line boxes stay in `canvas` and the renderer never measures text. Consecutive primitives with the same texture and clip share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache. | |
| 44 | 47 | |
| 45 | Glyph coverage uses quarter-pixel phases with the vertical sign converted to Swash's Y-up coordinates. The GPU readback test checks nine successive quarter-pixel translations by their linear-light ink centroids, then requires identical pixels on repaint, cache eviction and renderer reconstruction on the same device. The native caret experiment exposed the previous inverted vertical phase; correcting raster placement does not change line metrics or wrapping. | |
| 48 | Glyph coverage uses quarter-pixel phases with the vertical sign converted to Swash's Y-up coordinates. The `draw` GPU readback test checks nine successive quarter-pixel translations by their linear-light ink centroids, then requires identical pixels on repaint, cache eviction and renderer reconstruction on the same device. The native caret experiment exposed the previous inverted vertical phase; correcting raster placement does not change line metrics or wrapping. | |
| 46 | 49 | |
| 47 | 50 | Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not upload or consume the active-frame budget; a frame whose visible images exceed that budget returns an error. Under pressure, eviction preserves every image needed by the current frame. PNG/JPEG decoding checks encoded size, dimensions and decoded output size, with a best-effort codec allocation limit. These limits do not bound all CPU buffers or driver allocations. Images use linear filtering of premultiplied, linear-light colors; glyphs retain their own sampling/blending policy. Decoded color values are currently interpreted as sRGB; ICC and orientation metadata are not transformed by this experiment. |
| 48 | 51 |