example: SVG viewer based on usvg (#260)

This commit is contained in:
David Himmelstrup 2023-01-21 19:20:56 +01:00 committed by GitHub
parent b83642bf0c
commit 9ba85075d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 491 additions and 1 deletions

View file

@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["examples/with_winit", "examples/with_bevy", "examples/run_wasm"]
members = ["examples/with_winit", "examples/with_bevy", "examples/run_wasm", "examples/usvg_viewer"]
[workspace.package]
edition = "2021"

View file

@ -0,0 +1,22 @@
[package]
name = "usvg_viewer"
version = "0.1.0"
license = "MIT/Apache-2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0"
byte-unit = "4.0"
clap = { version = "4.1.0", features = ["derive"] }
dialoguer = "0.10"
generic-array = "0.14"
hex-literal = "0.3"
pollster = "0.2.5"
sha2 = "0.10"
ureq = "2.6"
usvg = "0.28"
vello = { path = "../../" }
wgpu = "0.14"
winit = "0.27.5"

View file

@ -0,0 +1,41 @@
# Vello SVG viewer
This example program parses SVG files with [usvg](https://crates.io/crates/usvg) and renders them with Vello.
The rendering is extremely simplistic and does not yet support:
- group opacity
- mix-blend-modes
- clipping
- masking
- filter effects
- group background
- path visibility
- path paint order
- path shape-rendering
- embedded images
- text
- gradients
- patterns
## Usage
Running the viewer without any arguments will render a built-in set of public-domain SVG images:
```bash
$ cargo run -p usvg_viewer --release
```
Optionally, you can pass in paths to SVG files that you want to render:
```bash
$ cargo run -p usvg_viewer --release -- [SVG FILES]
```
## Controls
- Mouse drag-and-drop will translate the image.
- Mouse scroll wheel will zoom.
- Arrow keys switch between SVG images in the current set.
- Space resets the position and zoom of the image.
- Escape exits the program.

View file

@ -0,0 +1,104 @@
use anyhow::{bail, Result};
use generic_array::GenericArray;
use hex_literal::hex;
use sha2::{Digest, Sha256};
use std::env::temp_dir;
use std::io::Read;
use std::path::PathBuf;
pub struct SvgAsset {
_source: &'static str,
url: &'static str,
sha256sum: [u8; 32],
pub license: &'static str,
pub size: u128,
}
impl SvgAsset {
pub fn local_path(&self) -> PathBuf {
let arr = GenericArray::from(self.sha256sum);
temp_dir()
.join(format!("vello-asset-{:x}", arr))
.with_extension("svg")
}
pub fn fetched(&self) -> bool {
let resource_local_path = self.local_path();
if let Ok(contents) = std::fs::read_to_string(&resource_local_path) {
if Sha256::digest(contents)[..] == self.sha256sum {
return true;
}
}
false
}
pub fn fetch(&self) -> Result<()> {
// ureq::into_string() has a limit of 10MiB so let's use the reader directly:
let mut buf: Vec<u8> = Vec::with_capacity(self.size as usize);
ureq::get(self.url)
.call()?
.into_reader()
.take(self.size as u64)
.read_to_end(&mut buf)?;
let body: String = String::from_utf8_lossy(&buf).to_string();
if Sha256::digest(&body)[..] != self.sha256sum {
bail!(format!("Invalid sha256 hash for resource: {}", self.url))
}
std::fs::write(self.local_path(), &body)?;
Ok(())
}
}
pub const ASSETS: &[SvgAsset] = &[
// DANGER: Zooming in on this image crashes my computer. @lemmih 2023-01-20.
// SvgAsset {
// _source: "https://commons.wikimedia.org/wiki/File:American_Legion_Seal_SVG.svg",
// url: "https://upload.wikimedia.org/wikipedia/commons/c/cf/American_Legion_Seal_SVG.svg",
// sha256sum: hex!("b990f047a274b463a75433ddb9c917e90067615bba5ad8373a3f77753c6bb5e1"),
// license: "Public Domain",
// size: 10849279,
// },
SvgAsset {
_source: "https://commons.wikimedia.org/wiki/File:CIA_WorldFactBook-Political_world.svg",
url: "https://upload.wikimedia.org/wikipedia/commons/7/72/Political_Map_of_the_World_%28august_2013%29.svg",
sha256sum: hex!("57956f10ed0ad3b1bea1e6c74cc7b386e42c99d87720a87c323d07f18c15d349"),
license: "Public Domain",
size: 12771150,
},
SvgAsset {
_source: "https://commons.wikimedia.org/wiki/File:World_-_time_zones_map_(2014).svg",
url: "https://upload.wikimedia.org/wikipedia/commons/c/c6/World_-_time_zones_map_%282014%29.svg",
sha256sum: hex!("0cfecd5cdeadc51eb06f60c75207a769feb5b63abe20e4cd6c0d9fea30e07563"),
license: "Public Domain",
size: 5235172,
},
SvgAsset {
_source: "https://commons.wikimedia.org/wiki/File:Coat_of_arms_of_Poland-official.svg",
url: "https://upload.wikimedia.org/wikipedia/commons/3/3e/Coat_of_arms_of_Poland-official.svg",
sha256sum: hex!("59b4d0e29adcd7ec6a7ab50af5796f1d13afc0334a6d4bd4d4099a345b0e3066"),
license: "Public Domain",
size: 10747708,
},
SvgAsset {
_source: "https://commons.wikimedia.org/wiki/File:Coat_of_arms_of_the_Kingdom_of_Yugoslavia.svg",
url: "https://upload.wikimedia.org/wikipedia/commons/5/58/Coat_of_arms_of_the_Kingdom_of_Yugoslavia.svg",
sha256sum: hex!("2b1084dee535985eb241b14c9a5260129efe4c415c66dafa548b81117842d3e3"),
license: "Public Domain",
size: 12795806,
},
// This SVG renders poorly
// SvgAsset {
// _source: "https://commons.wikimedia.org/wiki/File:Map_of_the_World_Oceans_-_January_2015.svg",
// url: "https://upload.wikimedia.org/wikipedia/commons/d/db/Map_of_the_World_Oceans_-_January_2015.svg",
// sha256sum: hex!("c8b0b13a577092bafa38b48b2fed28a1a26a91d237f4808444fa4bfee423c330"),
// license: "Public Domain",
// size: 10804504,
// },
];

View file

@ -0,0 +1,215 @@
mod asset;
mod render;
use anyhow::Result;
use asset::ASSETS;
use byte_unit::Byte;
use clap::Parser;
use dialoguer::Confirm;
use render::render_svg;
use std::path::PathBuf;
use std::time::Instant;
use vello::{
kurbo::{Affine, Vec2},
util::RenderContext,
Renderer, Scene, SceneBuilder,
};
use winit::{
dpi::LogicalSize,
event_loop::EventLoop,
window::{Window, WindowBuilder},
};
#[derive(Parser, Debug)]
#[command(about, long_about = None)]
struct Args {
/// Input files for rendering. Will use builtin SVGs if empty.
files: Vec<PathBuf>,
}
// Check if all the known assets have been downloaded.
// If some haven't been downloaded (or if the checksums don't match), find
// their combined size and licenses. Ask the user if they want to download
// the SVG files.
// If yes, download the files and return normally.
// If no, exit with status code -1
fn fetch_missing_assets() -> Result<()> {
let missing_assets = ASSETS
.iter()
.filter(|asset| !asset.fetched())
.collect::<Vec<_>>();
if !missing_assets.is_empty() {
let total_size = Byte::from_bytes(missing_assets.iter().map(|asset| asset.size).sum())
.get_appropriate_unit(true);
let mut licenses: Vec<_> = missing_assets.iter().map(|asset| asset.license).collect();
licenses.sort();
licenses.dedup();
println!("Some SVG assets are missing. Let me download them for you.");
println!(
"They'll take up {total_size} and are available under these licenses: {}",
licenses.join(", ")
);
if Confirm::new()
.with_prompt("Do you want to continue?")
.interact()?
{
println!("Looks like you want to continue.");
for missing in missing_assets {
missing.fetch()?
}
} else {
println!("Nevermind then.");
std::process::exit(1)
}
}
Ok(())
}
async fn run(event_loop: EventLoop<()>, window: Window, svg_files: Vec<PathBuf>) {
use winit::{event::*, event_loop::ControlFlow};
let mut render_cx = RenderContext::new().unwrap();
let size = window.inner_size();
let mut surface = render_cx
.create_surface(&window, size.width, size.height)
.await;
let device_handle = &render_cx.devices[surface.dev_id];
let mut renderer = Renderer::new(&device_handle.device).unwrap();
let mut current_frame = 0usize;
let mut scene = Scene::new();
let mut cached_svg_scene = vec![];
cached_svg_scene.resize_with(svg_files.len(), || None);
let mut transform = Affine::IDENTITY;
let mut mouse_down = false;
let mut prior_position = Vec2::default();
let mut last_title_update = Instant::now();
// We allow looping left and right through the svgs, so use a signed index
let mut svg_ix: i32 = 0;
// These are set after choosing the svg, as they overwrite the defaults specified there
event_loop.run(move |event, _, control_flow| match event {
Event::WindowEvent {
ref event,
window_id,
} if window_id == window.id() => match event {
WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit,
WindowEvent::KeyboardInput { input, .. } => {
if input.state == ElementState::Pressed {
match input.virtual_keycode {
Some(VirtualKeyCode::Left) => {
svg_ix = svg_ix.saturating_sub(1);
transform = Affine::IDENTITY
}
Some(VirtualKeyCode::Right) => {
svg_ix = svg_ix.saturating_add(1);
transform = Affine::IDENTITY
}
Some(VirtualKeyCode::Space) => transform = Affine::IDENTITY,
Some(VirtualKeyCode::Escape) => {
*control_flow = ControlFlow::Exit;
}
_ => {}
}
}
}
WindowEvent::Resized(size) => {
render_cx.resize_surface(&mut surface, size.width, size.height);
window.request_redraw();
}
WindowEvent::MouseInput { state, button, .. } => {
if button == &MouseButton::Left {
mouse_down = state == &ElementState::Pressed;
}
}
WindowEvent::MouseWheel { delta, .. } => {
const BASE: f64 = 1.05;
const PIXELS_PER_LINE: f64 = 20.0;
let exponent = if let MouseScrollDelta::PixelDelta(delta) = delta {
delta.y / PIXELS_PER_LINE
} else if let MouseScrollDelta::LineDelta(_, y) = delta {
*y as f64
} else {
0.0
};
transform = Affine::translate(prior_position)
* Affine::scale(BASE.powf(exponent))
* Affine::translate(-prior_position)
* transform;
}
WindowEvent::CursorMoved { position, .. } => {
let position = Vec2::new(position.x, position.y);
if mouse_down {
transform = Affine::translate(position - prior_position) * transform;
}
prior_position = position;
}
_ => {}
},
Event::MainEventsCleared => {
window.request_redraw();
}
Event::RedrawRequested(_) => {
current_frame += 1;
let width = surface.config.width;
let height = surface.config.height;
let device_handle = &render_cx.devices[surface.dev_id];
let mut builder = SceneBuilder::for_scene(&mut scene);
// Allow looping forever
let svg_ix = svg_ix.rem_euclid(svg_files.len() as i32) as usize;
render_svg(
&mut builder,
&mut cached_svg_scene[svg_ix],
transform,
&svg_files[svg_ix],
);
builder.finish();
let surface_texture = surface
.surface
.get_current_texture()
.expect("failed to get surface texture");
renderer
.render_to_surface(
&device_handle.device,
&device_handle.queue,
&scene,
&surface_texture,
width,
height,
)
.expect("failed to render to surface");
surface_texture.present();
if current_frame % 60 == 0 {
let now = Instant::now();
let duration = now.duration_since(last_title_update);
let fps = 60.0 / duration.as_secs_f64();
window.set_title(&format!("usvg viewer - fps: {:.1}", fps));
last_title_update = now;
}
device_handle.device.poll(wgpu::Maintain::Wait);
}
_ => {}
})
}
fn main() -> Result<()> {
let args = Args::parse();
let paths = if args.files.is_empty() {
fetch_missing_assets()?;
ASSETS.iter().map(|asset| asset.local_path()).collect()
} else {
args.files
};
let event_loop = EventLoop::new();
let window = WindowBuilder::new()
.with_inner_size(LogicalSize::new(1044, 800))
.with_resizable(true)
.with_title("Vello usvg viewer")
.build(&event_loop)?;
pollster::block_on(run(event_loop, window, paths));
Ok(())
}

View file

@ -0,0 +1,108 @@
use std::path::PathBuf;
use usvg::NodeExt;
use vello::kurbo::{Affine, BezPath, Rect};
use vello::peniko::{Brush, Color, Fill, Stroke};
use vello::{SceneBuilder, SceneFragment};
pub fn render_svg(
sb: &mut SceneBuilder,
scene: &mut Option<SceneFragment>,
xform: Affine,
path: &PathBuf,
) {
let scene_frag = scene.get_or_insert_with(|| {
let contents = std::fs::read_to_string(path).expect("failed to read svg file");
let svg = usvg::Tree::from_str(&contents, &usvg::Options::default())
.expect("failed to parse svg file");
let mut new_scene = SceneFragment::new();
let mut builder = SceneBuilder::for_fragment(&mut new_scene);
render_tree(&mut builder, svg);
new_scene
});
sb.append(scene_frag, Some(xform));
}
fn render_tree(sb: &mut SceneBuilder, svg: usvg::Tree) {
for elt in svg.root.descendants() {
let transform = elt.abs_transform();
match &*elt.borrow() {
usvg::NodeKind::Group(_) => {}
usvg::NodeKind::Path(path) => {
let mut local_path = BezPath::new();
for elt in usvg::TransformedPath::new(&path.data, transform) {
match elt {
usvg::PathSegment::MoveTo { x, y } => local_path.move_to((x, y)),
usvg::PathSegment::LineTo { x, y } => local_path.line_to((x, y)),
usvg::PathSegment::CurveTo {
x1,
y1,
x2,
y2,
x,
y,
} => local_path.curve_to((x1, y1), (x2, y2), (x, y)),
usvg::PathSegment::ClosePath => local_path.close_path(),
}
}
// FIXME: let path.paint_order determine the fill/stroke order.
if let Some(fill) = &path.fill {
if let Some(brush) = paint_to_brush(&fill.paint, fill.opacity) {
// FIXME: Set the fill rule
sb.fill(Fill::NonZero, Affine::IDENTITY, &brush, None, &local_path);
} else {
unimplemented(sb, &elt);
}
}
if let Some(stroke) = &path.stroke {
if let Some(brush) = paint_to_brush(&stroke.paint, stroke.opacity) {
// FIXME: handle stroke options such as linecap, linejoin, etc.
sb.stroke(
&Stroke::new(stroke.width.get() as f32),
Affine::IDENTITY,
&brush,
None,
&local_path,
);
} else {
unimplemented(sb, &elt);
}
}
}
usvg::NodeKind::Image(_) => {
unimplemented(sb, &elt);
}
usvg::NodeKind::Text(_) => {
unimplemented(sb, &elt);
}
}
}
}
// Draw a red box over unsupported SVG features.
fn unimplemented(sb: &mut SceneBuilder, node: &usvg::Node) {
if let Some(bb) = node.calculate_bbox() {
let rect = Rect {
x0: bb.left(),
y0: bb.top(),
x1: bb.right(),
y1: bb.bottom(),
};
sb.fill(Fill::NonZero, Affine::IDENTITY, Color::RED, None, &rect);
}
}
fn paint_to_brush(paint: &usvg::Paint, opacity: usvg::Opacity) -> Option<Brush> {
match paint {
usvg::Paint::Color(color) => Some(Brush::Solid(Color::rgba8(
color.red,
color.green,
color.blue,
opacity.to_u8(),
))),
usvg::Paint::LinearGradient(_) => None,
usvg::Paint::RadialGradient(_) => None,
usvg::Paint::Pattern(_) => None,
}
}