diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fbb500c2..433e730c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,7 +54,8 @@ jobs: - Linux i686 musl - Linux x86_64 - Linux x86_64 musl - - Linux x86_64 gnux32 + # FIXME: We can't link anything without cross. + # - Linux x86_64 gnux32 - Linux arm - Linux arm musl - Linux arm Hardware Float @@ -314,14 +315,12 @@ jobs: target: x86_64-unknown-linux-musl dylib: skip - - label: Linux x86_64 gnux32 - target: x86_64-unknown-linux-gnux32 - cross: skip - install_target: true - # FIXME: The tests don't run because without cross we can't - # successfully link anything. - tests: skip - dylib: skip + # FIXME: We can't link anything without cross. + # - label: Linux x86_64 gnux32 + # target: x86_64-unknown-linux-gnux32 + # cross: skip + # install_target: true + # dylib: skip - label: Linux arm target: arm-unknown-linux-gnueabi diff --git a/Cargo.toml b/Cargo.toml index 0d1458d0..c86147eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ snafu = { version = "0.8.0", default-features = false } unicase = "2.6.0" # std -image = { version = "0.24.0", features = [ +image = { version = "0.25.0", features = [ "png", ], default-features = false, optional = true } @@ -84,6 +84,9 @@ tiny-skia = { version = "0.11.1", default-features = false, features = [ ], optional = true } tiny-skia-path = { version = "0.11.1", default-features = false, optional = true } +# SVG Rendering +ahash = { version = "0.8.11", default-features = false, optional = true } + # Networking splits-io-api = { version = "0.4.0", optional = true } @@ -147,7 +150,7 @@ std = [ ] more-image-formats = [ "image?/bmp", - "image?/farbfeld", + "image?/ff", "image?/hdr", "image?/ico", "image?/jpeg", @@ -161,6 +164,7 @@ rendering = ["more-image-formats", "image?/gif"] default-text-engine = ["rendering", "cosmic-text"] font-loading = ["std", "default-text-engine"] software-rendering = ["default-text-engine", "tiny-skia", "tiny-skia-path"] +svg-rendering = ["default-text-engine", "ahash"] wasm-web = [ "std", "cosmic-text?/wasm-web", @@ -195,6 +199,10 @@ harness = false name = "software_rendering" harness = false +[[bench]] +name = "svg_rendering" +harness = false + [profile.max-opt] inherits = "release" lto = true diff --git a/benches/scene_management.rs b/benches/scene_management.rs index be011731..da985da8 100644 --- a/benches/scene_management.rs +++ b/benches/scene_management.rs @@ -91,7 +91,7 @@ cfg_if::cfg_if! { let mut manager = SceneManager::new(Dummy); c.bench_function("Scene Management (Default)", move |b| { - b.iter(|| manager.update_scene(Dummy, (300.0, 500.0), &state, &image_cache)) + b.iter(|| manager.update_scene(Dummy, [300.0, 500.0], &state, &image_cache)) }); } @@ -113,7 +113,7 @@ cfg_if::cfg_if! { let mut manager = SceneManager::new(Dummy); c.bench_function("Scene Management (Subsplits Layout)", move |b| { - b.iter(|| manager.update_scene(Dummy, (300.0, 800.0), &state, &image_cache)) + b.iter(|| manager.update_scene(Dummy, [300.0, 800.0], &state, &image_cache)) }); } diff --git a/benches/svg_rendering.rs b/benches/svg_rendering.rs new file mode 100644 index 00000000..d6dfbb7c --- /dev/null +++ b/benches/svg_rendering.rs @@ -0,0 +1,104 @@ +cfg_if::cfg_if! { + if #[cfg(feature = "svg-rendering")] { + use { + criterion::{criterion_group, criterion_main, Criterion}, + livesplit_core::{ + layout::{self, Layout}, + rendering::svg::Renderer, + run::parser::livesplit, + settings::ImageCache, + Run, Segment, TimeSpan, Timer, TimingMethod, + }, + std::fs, + }; + + criterion_main!(benches); + criterion_group!(benches, default, subsplits_layout); + + fn default(c: &mut Criterion) { + let mut run = create_run(&["A", "B", "C", "D"]); + run.set_game_name("Some Game Name"); + run.set_category_name("Some Category Name"); + run.set_attempt_count(1337); + let mut timer = Timer::new(run).unwrap(); + let mut layout = Layout::default_layout(); + let mut image_cache = ImageCache::new(); + + start_run(&mut timer); + make_progress_run_with_splits_opt(&mut timer, &[Some(5.0), None, Some(10.0)]); + + let state = layout.state(&mut image_cache, &timer.snapshot()); + let mut renderer = Renderer::new(); + let mut buf = String::new(); + + c.bench_function("SVG Rendering (Default)", move |b| { + b.iter(|| { + buf.clear(); + renderer.render(&mut buf, &state, &image_cache, [300.0, 500.0]).unwrap(); + }) + }); + } + + fn subsplits_layout(c: &mut Criterion) { + let run = lss("tests/run_files/Celeste - Any% (1.2.1.5).lss"); + let mut timer = Timer::new(run).unwrap(); + let mut layout = lsl("tests/layout_files/subsplits.lsl"); + let mut image_cache = ImageCache::new(); + + start_run(&mut timer); + make_progress_run_with_splits_opt(&mut timer, &[Some(10.0), None, Some(20.0), Some(55.0)]); + + let state = layout.state(&mut image_cache, &timer.snapshot()); + let mut renderer = Renderer::new(); + let mut buf = String::new(); + + c.bench_function("SVG Rendering (Subsplits Layout)", move |b| { + b.iter(|| { + buf.clear(); + renderer.render(&mut buf, &state, &image_cache, [300.0, 800.0]).unwrap(); + }) + }); + } + + fn file(path: &str) -> String { + fs::read_to_string(path).unwrap() + } + + fn lss(path: &str) -> Run { + livesplit::parse(&file(path)).unwrap() + } + + fn lsl(path: &str) -> Layout { + layout::parser::parse(&file(path)).unwrap() + } + + fn create_run(names: &[&str]) -> Run { + let mut run = Run::new(); + for &name in names { + run.push_segment(Segment::new(name)); + } + run + } + + fn start_run(timer: &mut Timer) { + timer.set_current_timing_method(TimingMethod::GameTime); + timer.start(); + timer.initialize_game_time(); + timer.pause_game_time(); + timer.set_game_time(TimeSpan::zero()); + } + + fn make_progress_run_with_splits_opt(timer: &mut Timer, splits: &[Option]) { + for &split in splits { + if let Some(split) = split { + timer.set_game_time(TimeSpan::from_seconds(split)); + timer.split(); + } else { + timer.skip_split(); + } + } + } + } else { + fn main() {} + } +} diff --git a/src/component/graph.rs b/src/component/graph.rs index 0d93e554..b15f7098 100644 --- a/src/component/graph.rs +++ b/src/component/graph.rs @@ -182,7 +182,7 @@ struct DrawInfo { #[derive(Default)] struct GridLines { /// The offset of the first grid line followed by the grid line distance. - horizontal: Option<(f32, f32)>, + horizontal: Option<[f32; 2]>, vertical: Option, } @@ -553,10 +553,10 @@ fn calculate_grid_lines(draw_info: &DrawInfo, x_axis: f32) -> GridLines { // The x-axis should always be on a grid line. let offset = x_axis % distance; - ret.horizontal = Some((offset, distance)); + ret.horizontal = Some([offset, distance]); } else { // Show just one grid line, the x-axis. - ret.horizontal = Some((DEFAULT_X_AXIS, f32::INFINITY)); + ret.horizontal = Some([DEFAULT_X_AXIS, f32::INFINITY]); } if let Some(scale_factor_x) = draw_info.scale_factor_x { @@ -574,7 +574,7 @@ fn calculate_grid_lines(draw_info: &DrawInfo, x_axis: f32) -> GridLines { /// Copies the information from `grid_lines` into `Vec`s. fn update_grid_line_vecs(state: &mut State, grid_lines: GridLines) { state.horizontal_grid_lines.clear(); - if let Some((offset, distance)) = grid_lines.horizontal { + if let Some([offset, distance]) = grid_lines.horizontal { let mut y = offset; while y < HEIGHT { state.horizontal_grid_lines.push(y); diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index 38f3f6bc..e371476b 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -83,6 +83,8 @@ pub mod default_text_engine; #[cfg(feature = "software-rendering")] pub mod software; +#[cfg(feature = "svg-rendering")] +pub mod svg; use self::{ consts::{ @@ -178,17 +180,10 @@ pub struct SceneManager { impl SceneManager { /// Creates a new scene manager. pub fn new( - mut allocator: impl ResourceAllocator, + allocator: impl ResourceAllocator, ) -> Self { - let mut builder = allocator.path_builder(); - builder.move_to(0.0, 0.0); - builder.line_to(0.0, 1.0); - builder.line_to(1.0, 1.0); - builder.line_to(1.0, 0.0); - builder.close(); - let rectangle = Handle::new(0, builder.finish()); - - let mut handles = Handles::new(1, allocator); + let mut handles = Handles::new(0, allocator); + let rectangle = handles.build_square(); let fonts = FontCache::new(&mut handles); Self { @@ -217,10 +212,10 @@ impl SceneManager pub fn update_scene>( &mut self, allocator: A, - resolution: (f32, f32), + resolution: [f32; 2], state: &LayoutState, image_cache: &ImageCache, - ) -> Option<(f32, f32)> { + ) -> Option<[f32; 2]> { self.scene.clear(); // Ensure we have exactly as many cached components as the layout state. @@ -250,10 +245,10 @@ impl SceneManager fn render_vertical( &mut self, allocator: impl ResourceAllocator, - resolution: (f32, f32), + resolution @ [width, height]: [f32; 2], state: &LayoutState, image_cache: &ImageCache, - ) -> Option<(f32, f32)> { + ) -> Option<[f32; 2]> { let total_height = component::layout_height(state); let cached_total_size = self @@ -264,27 +259,24 @@ impl SceneManager match cached_total_size { CachedSize::Vertical(cached_total_height) => { if cached_total_height.to_bits() != total_height.to_bits() { - new_resolution = Some(( - resolution.0, - resolution.1 / *cached_total_height * total_height, - )); + new_resolution = Some([width, height / *cached_total_height * total_height]); *cached_total_height = total_height; } } CachedSize::Horizontal(_) => { - let to_pixels = resolution.1 / TWO_ROW_HEIGHT; + let to_pixels = height / TWO_ROW_HEIGHT; let new_height = total_height * to_pixels; let new_width = DEFAULT_VERTICAL_WIDTH * to_pixels; - new_resolution = Some((new_width, new_height)); + new_resolution = Some([new_width, new_height]); *cached_total_size = CachedSize::Vertical(total_height); } } - let aspect_ratio = resolution.0 / resolution.1; + let aspect_ratio = width / height; let mut context = RenderContext { handles: Handles::new(self.next_id, allocator), - transform: Transform::scale(resolution.0, resolution.1), + transform: Transform::scale(width, height), scene: &mut self.scene, fonts: &mut self.fonts, images: &mut self.images, @@ -327,10 +319,10 @@ impl SceneManager fn render_horizontal( &mut self, allocator: impl ResourceAllocator, - resolution: (f32, f32), + resolution @ [width, height]: [f32; 2], state: &LayoutState, image_cache: &ImageCache, - ) -> Option<(f32, f32)> { + ) -> Option<[f32; 2]> { let total_width = component::layout_width(state); let cached_total_size = self @@ -340,27 +332,24 @@ impl SceneManager match cached_total_size { CachedSize::Vertical(cached_total_height) => { - let new_height = resolution.1 * TWO_ROW_HEIGHT / *cached_total_height; + let new_height = height * TWO_ROW_HEIGHT / *cached_total_height; let new_width = total_width * new_height / TWO_ROW_HEIGHT; - new_resolution = Some((new_width, new_height)); + new_resolution = Some([new_width, new_height]); *cached_total_size = CachedSize::Horizontal(total_width); } CachedSize::Horizontal(cached_total_width) => { if cached_total_width.to_bits() != total_width.to_bits() { - new_resolution = Some(( - resolution.0 / *cached_total_width * total_width, - resolution.1, - )); + new_resolution = Some([width / *cached_total_width * total_width, height]); *cached_total_width = total_width; } } } - let aspect_ratio = resolution.0 / resolution.1; + let aspect_ratio = width / height; let mut context = RenderContext { handles: Handles::new(self.next_id, allocator), - transform: Transform::scale(resolution.0, resolution.1), + transform: Transform::scale(width, height), scene: &mut self.scene, fonts: &mut self.fonts, images: &mut self.images, @@ -789,7 +778,7 @@ impl RenderContext<'_, A> { fn decode_layout_background( &mut self, background: &LayoutBackground, - (mut width, mut height): (f32, f32), + [mut width, mut height]: [f32; 2], ) -> Option> { Some(match background { LayoutBackground::Gradient(gradient) => Background::Shader(decode_gradient(gradient)?), diff --git a/src/rendering/resource/allocation.rs b/src/rendering/resource/allocation.rs index 5f4ed6ee..08bb27a6 100644 --- a/src/rendering/resource/allocation.rs +++ b/src/rendering/resource/allocation.rs @@ -65,6 +65,21 @@ pub trait ResourceAllocator { builder.finish() } + /// Builds a new square. The square is defined by the points `(0, 0)`, `(0, + /// 1)`, `(1, 1)`, and `(1, 0)`. The square is transformed into various + /// rectangles that make up the different parts of the timer. If you want to + /// detect that the square for this purpose is being created you can change + /// this implementation and draw actual rectangles instead of a path. + fn build_square(&mut self) -> Self::Path { + let mut builder = self.path_builder(); + builder.move_to(0.0, 0.0); + builder.line_to(0.0, 1.0); + builder.line_to(1.0, 1.0); + builder.line_to(1.0, 0.0); + builder.close(); + builder.finish() + } + /// Creates an image out of the image data provided. The data represents the /// image in its original file format. It needs to be parsed in order to be /// visualized. The parsed image as well as the aspect ratio (width / @@ -195,6 +210,14 @@ impl ResourceAllocator for &mut A { MutPathBuilder((*self).path_builder()) } + fn build_circle(&mut self, x: f32, y: f32, r: f32) -> Self::Path { + (*self).build_circle(x, y, r) + } + + fn build_square(&mut self) -> Self::Path { + (*self).build_square() + } + fn create_image(&mut self, data: &[u8]) -> Option<(Self::Image, f32)> { (*self).create_image(data) } diff --git a/src/rendering/resource/handles.rs b/src/rendering/resource/handles.rs index 1fdfd5f8..8ac84d17 100644 --- a/src/rendering/resource/handles.rs +++ b/src/rendering/resource/handles.rs @@ -78,6 +78,11 @@ impl ResourceAllocator for Handles { self.next(circle) } + fn build_square(&mut self) -> Self::Path { + let square = self.allocator.build_square(); + self.next(square) + } + fn create_image(&mut self, data: &[u8]) -> Option<(Self::Image, f32)> { let (image, aspect_ratio) = self.allocator.create_image(data)?; Some((self.next(image), aspect_ratio)) diff --git a/src/rendering/software.rs b/src/rendering/software.rs index 2154f440..b5acc5b1 100644 --- a/src/rendering/software.rs +++ b/src/rendering/software.rs @@ -201,7 +201,11 @@ impl SharedOwnership for UnsafeRc { // SceneManager it's harder to prove. However as long as the trait bounds for // the ResourceAllocator's Image and Path types do not require Sync or Send, // then the SceneManager simply can't share any of the allocated resources -// across any threads at all. +// across any threads at all. FIXME: However the Send bound may not actually +// hold, because Rc may not actually be allowed to be sent across threads at +// all, as it may for example use a thread local heap allocator. So deallocating +// from a different thread would be unsound. Upstream issue: +// https://github.com/rust-lang/rust/issues/122452 unsafe impl Send for UnsafeRc {} // Safety: The BorrowedSoftwareRenderer only has a render method which requires @@ -254,7 +258,7 @@ impl BorrowedRenderer { [width, height]: [u32; 2], stride: u32, force_redraw: bool, - ) -> Option<(f32, f32)> { + ) -> Option<[f32; 2]> { let mut frame_buffer = PixmapMut::from_bytes(image, stride, height).unwrap(); if stride != self.background.width() || height != self.background.height() { @@ -263,7 +267,7 @@ impl BorrowedRenderer { let new_resolution = self.scene_manager.update_scene( &mut self.allocator, - (width as _, height as _), + [width as _, height as _], state, image_cache, ); @@ -291,7 +295,7 @@ impl BorrowedRenderer { let top_layer = scene.top_layer(); - let (min_y, max_y) = calculate_bounds(top_layer); + let [min_y, max_y] = calculate_bounds(top_layer); let min_y = mem::replace(&mut self.min_y, min_y).min(min_y); let max_y = mem::replace(&mut self.max_y, max_y).max(max_y); @@ -347,7 +351,7 @@ impl Renderer { state: &LayoutState, image_cache: &ImageCache, [width, height]: [u32; 2], - ) -> Option<(f32, f32)> { + ) -> Option<[f32; 2]> { if width != self.frame_buffer.width() || height != self.frame_buffer.height() { self.frame_buffer = Pixmap::new(width, height).unwrap(); } @@ -411,11 +415,11 @@ fn render_layer( path, |path| { let bounds = path.bounds(); - (bounds.top(), bounds.bottom()) + [bounds.top(), bounds.bottom()] }, |path| { let bounds = path.bounds(); - (bounds.left(), bounds.right()) + [bounds.left(), bounds.right()] }, ); @@ -485,9 +489,9 @@ fn render_layer( } } if bottom < top { - (0.0, 0.0) + [0.0, 0.0] } else { - (top, bottom) + [top, bottom] } }, |label| { @@ -500,9 +504,9 @@ fn render_layer( } } if right < left { - (0.0, 0.0) + [0.0, 0.0] } else { - (left, right) + [left, right] } }, ); @@ -541,13 +545,13 @@ fn render_layer( fn convert_shader( shader: &FillShader, has_bounds: &T, - calculate_top_bottom: impl FnOnce(&T) -> (f32, f32), - calculate_left_right: impl FnOnce(&T) -> (f32, f32), + calculate_top_bottom: impl FnOnce(&T) -> [f32; 2], + calculate_left_right: impl FnOnce(&T) -> [f32; 2], ) -> Paint<'static> { let shader = match shader { FillShader::SolidColor(col) => Shader::SolidColor(convert_color(col)), FillShader::VerticalGradient(top, bottom) => { - let (bound_top, bound_bottom) = calculate_top_bottom(has_bounds); + let [bound_top, bound_bottom] = calculate_top_bottom(has_bounds); LinearGradient::new( Point::from_xy(0.0, bound_top), Point::from_xy(0.0, bound_bottom), @@ -561,7 +565,7 @@ fn convert_shader( .unwrap() } FillShader::HorizontalGradient(left, right) => { - let (bound_left, bound_right) = calculate_left_right(has_bounds); + let [bound_left, bound_right] = calculate_left_right(has_bounds); LinearGradient::new( Point::from_xy(bound_left, 0.0), Point::from_xy(bound_right, 0.0), @@ -780,7 +784,7 @@ fn update_blurred_background_image( } } -fn calculate_bounds(layer: &[Entity]) -> (f32, f32) { +fn calculate_bounds(layer: &[Entity]) -> [f32; 2] { let (mut min_y, mut max_y) = (f32::INFINITY, f32::NEG_INFINITY); for entity in layer.iter() { match entity { @@ -827,5 +831,5 @@ fn calculate_bounds(layer: &[Entity]) -> (f32, f } } } - (min_y, max_y) + [min_y, max_y] } diff --git a/src/rendering/svg.rs b/src/rendering/svg.rs new file mode 100644 index 00000000..fe7956f1 --- /dev/null +++ b/src/rendering/svg.rs @@ -0,0 +1,999 @@ +//! Provides a renderer that emits vector images in the SVG format. + +use core::{ + cell::{Cell, RefCell}, + fmt::{self, Write}, + hash::{BuildHasher, BuildHasherDefault}, +}; + +use ahash::AHasher; +use alloc::rc::Rc; +use hashbrown::{HashSet, HashTable}; + +use crate::{ + layout::LayoutState, + platform::prelude::*, + settings::{Font, ImageCache, BLUR_FACTOR}, + util::xml::{AttributeWriter, DisplayAlreadyEscaped, Text, Value, Writer}, +}; + +use super::{ + default_text_engine::{self, TextEngine}, + Background, Entity, FillShader, FontKind, ResourceAllocator, SceneManager, SharedOwnership, + Transform, +}; + +type SvgImage = Rc; +type SvgFont = default_text_engine::Font; +type SvgLabel = default_text_engine::Label; + +/// The SVG renderer allows rendering layouts to vector images in the SVG +/// format. +pub struct Renderer { + allocator: SvgAllocator, + scene_manager: SceneManager, +} + +impl Default for Renderer { + fn default() -> Self { + Self::new() + } +} + +impl Renderer { + /// Creates a new SVG renderer. + pub fn new() -> Self { + let mut allocator = SvgAllocator { + text_engine: TextEngine::new(), + defs: Rc::new(RefCell::new(Defs { + ptr_lookup: HashSet::new(), + gradients_lookup: HashTable::new(), + })), + }; + let scene_manager = SceneManager::new(&mut allocator); + Self { + allocator, + scene_manager, + } + } + + /// Renders the layout state with the chosen dimensions to the writer + /// provided. It may detect that the layout got resized. In that case it + /// returns the new ideal size. This is just a hint and can be ignored + /// entirely. The image is always rendered with the dimensions provided. + pub fn render( + &mut self, + writer: W, + layout_state: &LayoutState, + image_cache: &ImageCache, + [width, height]: [f32; 2], + ) -> Result, fmt::Error> { + let new_dims = self.scene_manager.update_scene( + &mut self.allocator, + [width, height], + layout_state, + image_cache, + ); + + let writer = &mut Writer::new_with_default_header(writer)?; + + writer.tag_with_content( + "svg", + [ + ( + "viewBox", + DisplayAlreadyEscaped(format_args!("0 0 {width} {height}")), + ), + ( + "xmlns", + DisplayAlreadyEscaped(format_args!("http://www.w3.org/2000/svg")), + ), + ], + |writer| { + let background_filter_id = writer.tag("defs", |writer| { + writer.content(|writer| self.write_defs(writer)) + })?; + self.write_scene(writer, width, height, background_filter_id)?; + + Ok(()) + }, + )?; + + Ok(new_dims) + } + + fn write_defs( + &mut self, + writer: &mut Writer, + ) -> Result, fmt::Error> { + let current_id = &mut 0; + let mut background_filter_id = None; + + let scene = self.scene_manager.scene(); + let defs = &mut *self.allocator.defs.borrow_mut(); + + defs.ptr_lookup.clear(); + + if let Some(background) = scene.background() { + match background { + Background::Shader(shader) => visit_shader(current_id, defs, writer, shader)?, + Background::Image(image, transform) => { + visit_image(current_id, defs, writer, &image.image)?; + + let needs_blur = image.blur != 0.0; + let needs_matrix = image.brightness != 1.0 || image.opacity != 1.0; + let needs_filter = needs_blur || needs_matrix; + + if needs_filter { + let id = *background_filter_id.insert(*current_id); + *current_id += 1; + + writer.tag_with_content( + "filter", + [("id", DisplayAlreadyEscaped(id))], + |writer| { + if needs_blur { + writer.empty_tag( + "feGaussianBlur", + [( + "stdDeviation", + DisplayAlreadyEscaped( + BLUR_FACTOR + * image.blur + * transform.scale_x.max(transform.scale_y), + ), + )], + )?; + } + + if needs_matrix { + writer.empty_tag( + "feColorMatrix", + [( + "values", + DisplayAlreadyEscaped(format_args!( + "{b} 0 0 0 0 \ + 0 {b} 0 0 0 \ + 0 0 {b} 0 0 \ + 0 0 0 {o} 0", + b = image.brightness, + o = image.opacity, + )), + )], + )?; + } + + Ok(()) + }, + )?; + } + } + } + } + + for entity in scene.bottom_layer().iter().chain(scene.top_layer()) { + match entity { + Entity::FillPath(path, shader, _) => { + visit_path(current_id, defs, writer, path)?; + visit_shader(current_id, defs, writer, shader)?; + } + Entity::StrokePath(path, _, _, _) => visit_path(current_id, defs, writer, path)?, + Entity::Image(image, _) => visit_image(current_id, defs, writer, image)?, + Entity::Label(label, shader, _) => { + for glyph in label.read().unwrap().glyphs() { + if glyph.color.is_none() { + visit_shader(current_id, defs, writer, shader)?; + } + + visit_path(current_id, defs, writer, &glyph.path)?; + } + } + } + } + + Ok(background_filter_id) + } + + fn write_scene( + &mut self, + writer: &mut Writer, + width: f32, + height: f32, + background_filter_id: Option, + ) -> Result<(), fmt::Error> { + let scene = self.scene_manager.scene(); + + if let Some(background) = scene.background() { + match background { + Background::Shader(shader) => { + if let Some((fill, opacity)) = convert_shader(shader, &self.allocator.defs) { + writer.tag("rect", |mut writer| { + writer.attribute("width", DisplayAlreadyEscaped(width))?; + writer.attribute("height", DisplayAlreadyEscaped(height))?; + writer.attribute("fill", fill)?; + if let Some(opacity) = opacity { + writer.attribute("fill-opacity", DisplayAlreadyEscaped(opacity))?; + } + Ok(()) + })?; + } + } + Background::Image(image, transform) => { + writer.tag("use", |mut writer| { + writer.attribute( + "href", + DisplayAlreadyEscaped(format_args!("#{}", (*image.image).id.get())), + )?; + writer.attribute( + "transform", + TransformValue( + &transform.pre_scale(image.image.scale_x, image.image.scale_y), + ), + )?; + + if let Some(id) = background_filter_id { + writer.attribute( + "filter", + DisplayAlreadyEscaped(format_args!("url(#{})", id)), + )?; + } + + Ok(()) + })?; + } + } + } + + for entity in scene.bottom_layer().iter().chain(scene.top_layer()) { + match entity { + Entity::FillPath(path, shader, transform) => { + let Some((fill, opacity)) = convert_shader(shader, &self.allocator.defs) else { + continue; + }; + path_with_transform( + writer, + path, + transform, + [("fill", fill.into()), ("fill-opacity", opacity.into())], + )?; + } + Entity::StrokePath(path, stroke_width, color, transform) => { + let Some((color, opacity)) = convert_color(color) else { + continue; + }; + path_with_transform( + writer, + path, + transform, + [ + ("stroke", Fill::Rgb(color).into()), + ("stroke-width", (*stroke_width * transform.scale_y).into()), + ("stroke-opacity", opacity.into()), + ], + )?; + } + Entity::Image(image, transform) => { + writer.empty_tag( + "use", + [ + ( + "href", + DisplayAlreadyEscaped(format_args!("#{}", (**image).id.get())), + ), + ( + "transform", + DisplayAlreadyEscaped(format_args!( + "{}", + TransformValue( + &transform.pre_scale(image.scale_x, image.scale_y) + ) + )), + ), + ], + )?; + } + Entity::Label(label, shader, transform) => { + for glyph in label.read().unwrap().glyphs() { + let (fill, opacity) = if let Some(color) = &glyph.color { + let Some((color, opacity)) = convert_color(color) else { + continue; + }; + (Fill::Rgb(color), opacity) + } else { + let Some((fill, opacity)) = + convert_shader(shader, &self.allocator.defs) + else { + continue; + }; + (fill, opacity) + }; + + path_with_transform( + writer, + &glyph.path, + &transform + .pre_translate(glyph.x, glyph.y) + .pre_scale(glyph.scale, glyph.scale), + [("fill", fill.into()), ("fill-opacity", opacity.into())], + )?; + } + } + } + } + + Ok(()) + } +} + +fn visit_path( + current_id: &mut usize, + defs: &mut Defs, + writer: &mut Writer, + path: &SvgPath, +) -> fmt::Result { + if let SvgPath::Path(path) = path { + if defs.ptr_lookup.insert(Rc::as_ptr(path) as usize) { + path.id.set(*current_id); + *current_id += 1; + + let (tag, attr) = match path.kind { + PathKind::Polygon => ("polygon", "points"), + PathKind::Polyline => ("polyline", "points"), + PathKind::Path => ("path", "d"), + }; + + writer.empty_tag( + tag, + [ + ( + "id", + DisplayAlreadyEscaped(format_args!("{}", path.id.get())), + ), + (attr, DisplayAlreadyEscaped(format_args!("{}", path.data))), + ], + )?; + } + } + Ok(()) +} + +fn visit_image( + current_id: &mut usize, + defs: &mut Defs, + writer: &mut Writer, + image: &SvgImage, +) -> fmt::Result { + if defs.ptr_lookup.insert(Rc::as_ptr(image) as usize) { + image.id.set(*current_id); + *current_id += 1; + + writer.empty_tag( + "image", + [ + ( + "id", + DisplayAlreadyEscaped(format_args!("{}", image.id.get())), + ), + ( + "href", + DisplayAlreadyEscaped(format_args!("{}", image.data)), + ), + ], + )?; + } + Ok(()) +} + +fn visit_shader( + current_id: &mut usize, + defs: &mut Defs, + writer: &mut Writer, + shader: &FillShader, +) -> fmt::Result { + let (vertical, start, end) = match shader { + FillShader::SolidColor(_) => return Ok(()), + FillShader::VerticalGradient(top, bottom) => (true, top, bottom), + FillShader::HorizontalGradient(left, right) => (false, left, right), + }; + + let gradient = defs.add_gradient(vertical, start, end); + + if defs.ptr_lookup.insert(Rc::as_ptr(&gradient) as usize) { + gradient.id.set(*current_id); + *current_id += 1; + + writer.tag_with_content( + "linearGradient", + [ + ( + "id", + DisplayAlreadyEscaped(format_args!("{}", gradient.id.get())), + ), + ( + "x2", + DisplayAlreadyEscaped(format_args!("{}", if vertical { "0" } else { "1" })), + ), + ( + "y2", + DisplayAlreadyEscaped(format_args!("{}", if vertical { "1" } else { "0" })), + ), + ], + |writer| { + writer.tag("stop", |mut writer| { + let (start_rgb, start_a) = convert_color_or_transparent(start); + writer.attribute("stop-color", start_rgb)?; + if let Some(a) = start_a { + writer.attribute("stop-opacity", DisplayAlreadyEscaped(a))?; + } + Ok(()) + })?; + writer.tag("stop", |mut writer| { + let (end_rgb, end_a) = convert_color_or_transparent(end); + writer.attribute("offset", Text::new_escaped("1"))?; + writer.attribute("stop-color", end_rgb)?; + if let Some(a) = end_a { + writer.attribute("stop-opacity", DisplayAlreadyEscaped(a))?; + } + Ok(()) + }) + }, + )?; + } + + Ok(()) +} + +struct SvgAllocator { + text_engine: TextEngine, + defs: Rc>, +} + +struct Gradient { + id: Cell, + vertical: bool, + start: [f32; 4], + end: [f32; 4], +} + +impl core::hash::Hash for Gradient { + fn hash(&self, state: &mut H) { + self.vertical.hash(state); + self.start.map(f32::to_bits).hash(state); + self.end.map(f32::to_bits).hash(state); + } +} + +impl PartialEq for Gradient { + fn eq(&self, other: &Self) -> bool { + self.vertical == other.vertical + && self.start.map(f32::to_bits) == other.start.map(f32::to_bits) + && self.end.map(f32::to_bits) == other.end.map(f32::to_bits) + } +} + +impl Eq for Gradient {} + +struct Defs { + ptr_lookup: HashSet, + gradients_lookup: HashTable>, +} + +impl Defs { + fn add_gradient(&mut self, vertical: bool, start: &[f32; 4], end: &[f32; 4]) -> Rc { + let hasher = BuildHasherDefault::::default(); + let hasher = |val: &Gradient| hasher.hash_one(val); + let gradient = Gradient { + id: Cell::new(0), + vertical, + start: *start, + end: *end, + }; + self.gradients_lookup + .entry(hasher(&gradient), |g| gradient == **g, |g| hasher(g)) + .or_insert_with(|| Rc::new(gradient)) + .get() + .clone() + } +} + +struct PathBuilder { + segments: Vec, +} + +#[derive(Debug, Clone)] +enum SvgPath { + Rectangle, + Circle(f32, f32, f32), + Line(Point, Point), + Path(Rc), +} + +#[derive(Debug, Copy, Clone)] +enum PathKind { + Polygon, + Polyline, + Path, +} + +#[derive(Debug)] +struct PathData { + id: Cell, + kind: PathKind, + data: String, +} + +#[derive(Clone)] +struct Image { + id: Cell, + data: String, + scale_x: f32, + scale_y: f32, +} + +impl SharedOwnership for SvgPath { + fn share(&self) -> Self { + self.clone() + } +} + +impl SharedOwnership for Image { + fn share(&self) -> Self { + self.clone() + } +} + +#[derive(Debug, Copy, Clone)] +struct Point { + x: f32, + y: f32, +} + +enum PathSegment { + MoveTo(Point), + LineTo(Point), + QuadTo(Point, Point), + CurveTo(Point, Point, Point), + Close, +} + +impl super::PathBuilder for PathBuilder { + type Path = SvgPath; + + fn move_to(&mut self, x: f32, y: f32) { + self.segments.push(PathSegment::MoveTo(Point { x, y })); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.segments.push(PathSegment::LineTo(Point { x, y })); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.segments + .push(PathSegment::QuadTo(Point { x: x1, y: y1 }, Point { x, y })); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.segments.push(PathSegment::CurveTo( + Point { x: x1, y: y1 }, + Point { x: x2, y: y2 }, + Point { x, y }, + )); + } + + fn close(&mut self) { + self.segments.push(PathSegment::Close); + } + + fn finish(self) -> Self::Path { + if let [outer_rem @ .., PathSegment::Close] = &*self.segments { + if let [PathSegment::MoveTo(_), rem @ ..] = outer_rem { + if rem + .iter() + .all(|segment| matches!(segment, PathSegment::LineTo(_))) + { + let point_iter = outer_rem.iter().map(|segment| match segment { + PathSegment::MoveTo(point) | PathSegment::LineTo(point) => *point, + _ => unreachable!(), + }); + + let mut data = String::new(); + for point in point_iter { + if !data.is_empty() { + data.push(' '); + } + let _ = write!(data, "{},{}", point.x, point.y); + } + return SvgPath::Path(Rc::new(PathData { + id: Cell::new(0), + kind: PathKind::Polygon, + data, + })); + } + } + } + if let [PathSegment::MoveTo(start), rem @ ..] = &*self.segments { + if let [PathSegment::LineTo(end)] = rem { + return SvgPath::Line(*start, *end); + } + + if rem + .iter() + .all(|segment| matches!(segment, PathSegment::LineTo(_))) + { + let point_iter = rem.iter().map(|segment| match segment { + PathSegment::MoveTo(point) | PathSegment::LineTo(point) => *point, + _ => unreachable!(), + }); + + let mut data = String::new(); + for point in point_iter { + if !data.is_empty() { + data.push(' '); + } + let _ = write!(data, "{},{}", point.x, point.y); + } + return SvgPath::Path(Rc::new(PathData { + id: Cell::new(0), + kind: PathKind::Polyline, + data, + })); + } + } + + let mut data = String::new(); + + for segment in self.segments { + if !data.is_empty() { + data.push(' '); + } + match segment { + PathSegment::MoveTo(point) => { + let _ = write!(data, "M{},{}", point.x, point.y); + } + PathSegment::LineTo(point) => { + let _ = write!(data, "L{},{}", point.x, point.y); + } + PathSegment::QuadTo(point1, point2) => { + let _ = write!(data, "Q{},{} {},{}", point1.x, point1.y, point2.x, point2.y); + } + PathSegment::CurveTo(point1, point2, point3) => { + let _ = write!( + data, + "C{},{} {},{} {},{}", + point1.x, point1.y, point2.x, point2.y, point3.x, point3.y + ); + } + PathSegment::Close => { + data.push('Z'); + } + } + } + + SvgPath::Path(Rc::new(PathData { + id: Cell::new(0), + kind: PathKind::Path, + data, + })) + } +} + +impl ResourceAllocator for SvgAllocator { + type PathBuilder = PathBuilder; + type Path = SvgPath; + type Image = SvgImage; + type Font = SvgFont; + type Label = SvgLabel; + + fn path_builder(&mut self) -> Self::PathBuilder { + PathBuilder { + segments: Vec::new(), + } + } + + fn create_image(&mut self, _data: &[u8]) -> Option<(Self::Image, f32)> { + #[cfg(feature = "image")] + { + let format = image::guess_format(_data).ok()?; + + let (width, height) = crate::util::image::get_dimensions(format, _data)?; + let (width, height) = (width as f32, height as f32); + let (rwidth, rheight) = (width.recip(), height.recip()); + + let mut buf = String::new(); + buf.push_str("data:;base64,"); + + // SAFETY: We encode Base64 to the end of the string, which is + // always valid UTF-8. Once we've written it, we simply increase + // the length of the buffer by the amount of bytes written. + unsafe { + let buf = buf.as_mut_vec(); + let encoded_len = base64_simd::STANDARD.encoded_length(_data.len()); + buf.reserve_exact(encoded_len); + let additional_len = base64_simd::STANDARD + .encode( + _data, + base64_simd::Out::from_uninit_slice(buf.spare_capacity_mut()), + ) + .len(); + buf.set_len(buf.len() + additional_len); + } + + Some(( + Rc::new(Image { + id: Cell::new(0), + data: buf, + scale_x: rwidth, + scale_y: rheight, + }), + width * rheight, + )) + } + #[cfg(not(feature = "image"))] + { + None + } + } + + fn create_font(&mut self, font: Option<&Font>, kind: FontKind) -> Self::Font { + self.text_engine.create_font(font, kind) + } + + fn create_label( + &mut self, + text: &str, + font: &mut Self::Font, + max_width: Option, + ) -> Self::Label { + self.text_engine.create_label( + || PathBuilder { + segments: Vec::new(), + }, + text, + font, + max_width, + ) + } + + fn update_label( + &mut self, + label: &mut Self::Label, + text: &str, + font: &mut Self::Font, + max_width: Option, + ) { + self.text_engine.update_label( + || PathBuilder { + segments: Vec::new(), + }, + label, + text, + font, + max_width, + ) + } + + fn build_circle(&mut self, x: f32, y: f32, r: f32) -> Self::Path { + SvgPath::Circle(x, y, r) + } + + fn build_square(&mut self) -> Self::Path { + SvgPath::Rectangle + } +} + +enum AttrValue { + Fill(Fill), + F32(f32), + OptionF32(Option), +} + +impl From for AttrValue { + fn from(v: Fill) -> Self { + Self::Fill(v) + } +} + +impl From for AttrValue { + fn from(v: f32) -> Self { + Self::F32(v) + } +} + +impl From> for AttrValue { + fn from(v: Option) -> Self { + Self::OptionF32(v) + } +} + +fn path_with_transform<'a, W: fmt::Write>( + writer: &mut Writer, + path: &SvgPath, + transform: &Transform, + attrs: impl IntoIterator, +) -> fmt::Result { + let add_attrs = |mut writer: AttributeWriter<'_, W>| { + for (key, value) in attrs { + match value { + AttrValue::Fill(fill) => writer.attribute(key, fill)?, + AttrValue::F32(value) => writer.attribute(key, DisplayAlreadyEscaped(value))?, + AttrValue::OptionF32(value) => { + if let Some(value) = value { + writer.attribute(key, DisplayAlreadyEscaped(value))?; + } + } + } + } + Ok(()) + }; + + match path { + SvgPath::Circle(x, y, r) => { + let [x, y] = Point { x: *x, y: *y }.transform(transform); + let width = r * transform.scale_x; + let height = r * transform.scale_y; + if width == height { + writer.tag("circle", |mut writer| { + writer.attribute("cx", DisplayAlreadyEscaped(x))?; + writer.attribute("cy", DisplayAlreadyEscaped(y))?; + writer.attribute("r", DisplayAlreadyEscaped(width))?; + add_attrs(writer) + })?; + } else { + writer.tag("ellipse", |mut writer| { + writer.attribute("cx", DisplayAlreadyEscaped(x))?; + writer.attribute("cy", DisplayAlreadyEscaped(y))?; + writer.attribute("rx", DisplayAlreadyEscaped(width))?; + writer.attribute("ry", DisplayAlreadyEscaped(height))?; + add_attrs(writer) + })?; + } + } + SvgPath::Line(start, end) => { + let [x1, y1] = start.transform(transform); + let [x2, y2] = end.transform(transform); + writer.tag("line", |mut writer| { + writer.attribute("x1", DisplayAlreadyEscaped(x1))?; + writer.attribute("y1", DisplayAlreadyEscaped(y1))?; + writer.attribute("x2", DisplayAlreadyEscaped(x2))?; + writer.attribute("y2", DisplayAlreadyEscaped(y2))?; + add_attrs(writer) + })? + } + SvgPath::Rectangle => writer.tag("rect", |mut writer| { + if transform.x != 0.0 { + writer.attribute("x", DisplayAlreadyEscaped(transform.x))?; + } + if transform.y != 0.0 { + writer.attribute("y", DisplayAlreadyEscaped(transform.y))?; + } + writer.attribute("width", DisplayAlreadyEscaped(transform.scale_x))?; + writer.attribute("height", DisplayAlreadyEscaped(transform.scale_y))?; + add_attrs(writer) + })?, + SvgPath::Path(path) => writer.tag("use", |mut writer| { + writer.attribute( + "href", + DisplayAlreadyEscaped(format_args!("#{}", path.id.get())), + )?; + writer.attribute("transform", TransformValue(transform))?; + add_attrs(writer) + })?, + }; + + Ok(()) +} + +impl Point { + fn transform(&self, transform: &Transform) -> [f32; 2] { + [ + self.x * transform.scale_x + transform.x, + self.y * transform.scale_y + transform.y, + ] + } +} + +enum Fill { + Rgb(Rgb), + Url(usize), +} + +impl Value for Fill { + fn write_escaped(self, sink: &mut T) -> fmt::Result { + match self { + Fill::Rgb(rgb) => rgb.write_escaped(sink), + Fill::Url(id) => write!(sink, "url(#{id})"), + } + } + + fn is_empty(&self) -> bool { + false + } +} + +fn convert_shader(shader: &FillShader, defs: &Rc>) -> Option<(Fill, Option)> { + Some(match shader { + FillShader::SolidColor(c) => { + let (rgb, a) = convert_color(c)?; + (Fill::Rgb(rgb), a) + } + FillShader::VerticalGradient(top, bottom) => { + let gradient = defs.borrow_mut().add_gradient(true, top, bottom); + (Fill::Url(gradient.id.get()), None) + } + FillShader::HorizontalGradient(left, right) => { + let gradient = defs.borrow_mut().add_gradient(false, left, right); + (Fill::Url(gradient.id.get()), None) + } + }) +} + +fn convert_color_or_transparent(&[r, g, b, a]: &[f32; 4]) -> (Rgb, Option) { + convert_color(&[r, g, b, a]).unwrap_or(( + Rgb { + r: 0xFF, + b: 0xFF, + g: 0xFF, + }, + Some(0.0), + )) +} + +struct Rgb { + r: u8, + g: u8, + b: u8, +} + +impl Value for Rgb { + fn write_escaped(self, sink: &mut T) -> fmt::Result { + if self.r == 0xFF && self.g == 0xFF && self.b == 0xFF { + sink.write_str("white") + } else if self.r == 0x00 && self.g == 0x00 && self.b == 0x00 { + sink.write_str("black") + } else { + write!(sink, "#{:02X}{:02X}{:02X}", self.r, self.g, self.b) + } + } + + fn is_empty(&self) -> bool { + false + } +} + +fn convert_color(&[r, g, b, a]: &[f32; 4]) -> Option<(Rgb, Option)> { + if a == 0.0 { + return None; + } + let a = if a != 1.0 { Some(a) } else { None }; + Some(( + Rgb { + r: (255.0 * r) as u8, + g: (255.0 * g) as u8, + b: (255.0 * b) as u8, + }, + a, + )) +} + +#[derive(Copy, Clone)] +struct TransformValue<'a>(&'a Transform); + +impl fmt::Display for TransformValue<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.write_escaped(f) + } +} + +impl Value for TransformValue<'_> { + fn write_escaped(self, sink: &mut T) -> fmt::Result { + write!( + sink, + "matrix({},0,0,{},{},{})", + self.0.scale_x, self.0.scale_y, self.0.x, self.0.y + ) + } + + fn is_empty(&self) -> bool { + false + } +} diff --git a/src/run/parser/llanfair.rs b/src/run/parser/llanfair.rs index f78f62c5..a6a268cf 100644 --- a/src/run/parser/llanfair.rs +++ b/src/run/parser/llanfair.rs @@ -9,7 +9,7 @@ use crate::{ }; use core::{result::Result as StdResult, str}; #[cfg(feature = "std")] -use image::{codecs::png, ColorType, ImageBuffer, ImageEncoder, Rgba}; +use image::{codecs::png, ExtendedColorType, ImageBuffer, ImageEncoder, Rgba}; use snafu::{OptionExt, ResultExt}; /// The Error type for splits files that couldn't be parsed by the Llanfair @@ -188,7 +188,7 @@ pub fn parse(source: &[u8]) -> Result { if let Some(image) = ImageBuffer::, _>::from_raw(width, height, _image_data) { buf.clear(); if png::PngEncoder::new(&mut buf) - .write_image(image.as_ref(), width, height, ColorType::Rgba8) + .write_image(image.as_ref(), width, height, ExtendedColorType::Rgba8) .is_ok() { icon = Some(crate::settings::Image::new( diff --git a/src/run/parser/llanfair_gered.rs b/src/run/parser/llanfair_gered.rs index 574d3ed0..c11f881d 100644 --- a/src/run/parser/llanfair_gered.rs +++ b/src/run/parser/llanfair_gered.rs @@ -18,7 +18,7 @@ use crate::{ RealTime, Run, Segment, Time, TimeSpan, }; #[cfg(feature = "std")] -use image::{codecs::png, ColorType, ImageEncoder}; +use image::{codecs::png, ExtendedColorType, ImageEncoder}; #[cfg(feature = "std")] use snafu::OptionExt; @@ -124,7 +124,7 @@ where png_buf.clear(); png::PngEncoder::new(&mut *png_buf) - .write_image(image, width, height, ColorType::Rgba8) + .write_image(image, width, height, ExtendedColorType::Rgba8) .map_err(|_| Error::Image)?; f(png_buf); diff --git a/src/run/saver/livesplit.rs b/src/run/saver/livesplit.rs index 8f2ff1b0..b58ff01d 100644 --- a/src/run/saver/livesplit.rs +++ b/src/run/saver/livesplit.rs @@ -29,7 +29,7 @@ use crate::{ run::LinkedLayout, settings::Image, timing::formatter::{Complete, TimeFormatter}, - util::xml::{AttributeWriter, DisplayValue, Text, Writer, NO_ATTRIBUTES}, + util::xml::{AttributeWriter, DisplayAlreadyEscaped, Text, Writer, NO_ATTRIBUTES}, DateTime, Run, Time, Timer, TimerPhase, }; use alloc::borrow::Cow; @@ -108,7 +108,9 @@ fn date( writer.attribute( key, - format_args!("{month:02}/{day:02}/{year:04} {hour:02}:{minute:02}:{second:02}"), + DisplayAlreadyEscaped(format_args!( + "{month:02}/{day:02}/{year:04} {hour:02}:{minute:02}:{second:02}" + )), ) } @@ -117,7 +119,7 @@ fn time_inner(writer: &mut Writer, time: Time) -> fmt::Result writer.tag_with_text_content( "RealTime", NO_ATTRIBUTES, - DisplayValue(Complete.format(time)), + DisplayAlreadyEscaped(Complete.format(time)), )?; } @@ -125,7 +127,7 @@ fn time_inner(writer: &mut Writer, time: Time) -> fmt::Result writer.tag_with_text_content( "GameTime", NO_ATTRIBUTES, - DisplayValue(Complete.format(time)), + DisplayAlreadyEscaped(Complete.format(time)), )?; } @@ -221,12 +223,12 @@ pub fn save_run(run: &Run, writer: W) -> fmt::Result { writer.tag_with_text_content( "Offset", NO_ATTRIBUTES, - DisplayValue(Complete.format(run.offset())), + DisplayAlreadyEscaped(Complete.format(run.offset())), )?; writer.tag_with_text_content( "AttemptCount", NO_ATTRIBUTES, - DisplayValue(run.attempt_count()), + DisplayAlreadyEscaped(run.attempt_count()), )?; scoped_iter( @@ -235,7 +237,7 @@ pub fn save_run(run: &Run, writer: W) -> fmt::Result { run.attempt_history(), |writer, attempt| { writer.tag("Attempt", |mut tag| { - tag.attribute("id", DisplayValue(attempt.index()))?; + tag.attribute("id", DisplayAlreadyEscaped(attempt.index()))?; if let Some(started) = attempt.started() { date(&mut tag, "started", started.time)?; @@ -258,7 +260,7 @@ pub fn save_run(run: &Run, writer: W) -> fmt::Result { writer.tag_with_text_content( "PauseTime", NO_ATTRIBUTES, - DisplayValue(Complete.format(pause_time)), + DisplayAlreadyEscaped(Complete.format(pause_time)), )?; } @@ -298,7 +300,7 @@ pub fn save_run(run: &Run, writer: W) -> fmt::Result { segment.segment_history(), |writer, &(index, history_time)| { writer.tag("Time", |mut tag| { - tag.attribute("id", DisplayValue(index))?; + tag.attribute("id", DisplayAlreadyEscaped(index))?; time(tag, history_time) }) }, diff --git a/src/settings/image/shrinking.rs b/src/settings/image/shrinking.rs index f983b079..a64ec678 100644 --- a/src/settings/image/shrinking.rs +++ b/src/settings/image/shrinking.rs @@ -1,70 +1,24 @@ use alloc::borrow::Cow; -use bytemuck_derive::{Pod, Zeroable}; -use image::{ - codecs::{bmp, farbfeld, hdr, ico, jpeg, pnm, tga, tiff, webp}, - guess_format, load_from_memory_with_format, ImageDecoder, ImageEncoder, ImageFormat, -}; -use std::io::Cursor; +use image::{guess_format, load_from_memory_with_format, ImageEncoder, ImageFormat}; -use crate::util::byte_parsing::{big_endian::U32, strip_pod}; +use crate::util::image::get_dimensions; fn shrink_inner(data: &[u8], max_dim: u32) -> Option> { let format = guess_format(data).ok()?; - let (width, height) = match format { - ImageFormat::Png => { - // We encounter a lot of PNG images in splits files and decoding - // them with image's PNG decoder seems to decode way more than - // necessary. We really just need to find the width and height. The - // specification is here: - // https://www.w3.org/TR/2003/REC-PNG-20031110/ - // - // And it says the following: - // - // "A valid PNG datastream shall begin with a PNG signature, - // immediately followed by an IHDR chunk". - // - // Each chunk is encoded as a length and type and then its chunk - // encoding. An IHDR chunk immediately starts with the width and - // height. This means we can model the beginning of a PNG file as - // follows: - #[derive(Copy, Clone, Pod, Zeroable)] - #[repr(C)] - struct BeginningOfPng { - // 5.2 PNG signature - png_signature: [u8; 8], - // 5.3 Chunk layout - chunk_len: [u8; 4], - // 11.2.2 IHDR Image header - chunk_type: [u8; 4], - width: U32, - height: U32, - } - - // This improves parsing speed of entire splits files by up to 30%. - - let beginning_of_png: &BeginningOfPng = strip_pod(&mut &*data)?; - (beginning_of_png.width.get(), beginning_of_png.height.get()) - } - ImageFormat::Jpeg => jpeg::JpegDecoder::new(data).ok()?.dimensions(), - ImageFormat::WebP => webp::WebPDecoder::new(data).ok()?.dimensions(), - ImageFormat::Pnm => pnm::PnmDecoder::new(data).ok()?.dimensions(), - ImageFormat::Tiff => tiff::TiffDecoder::new(Cursor::new(data)).ok()?.dimensions(), - ImageFormat::Tga => tga::TgaDecoder::new(Cursor::new(data)).ok()?.dimensions(), - ImageFormat::Bmp => bmp::BmpDecoder::new(Cursor::new(data)).ok()?.dimensions(), - ImageFormat::Ico => ico::IcoDecoder::new(Cursor::new(data)).ok()?.dimensions(), - ImageFormat::Hdr => hdr::HdrAdapter::new(data).ok()?.dimensions(), - ImageFormat::Farbfeld => farbfeld::FarbfeldDecoder::new(data).ok()?.dimensions(), + let dims = if format == ImageFormat::Gif { // FIXME: For GIF we would need to properly shrink the whole animation. // The image crate can't properly handle this at this point in time. // Some properties are not translated over properly it seems. We could // shrink GIFs that are a single frame, but we also can't tell how many // frames there are. - // DDS isn't a format we really care for. - // AVIF uses C bindings, so it's not portable. - // The OpenEXR code in the image crate doesn't compile on Big Endian targets. - // And the image format is non-exhaustive. - _ => return Some(data.into()), + None + } else { + get_dimensions(format, data) + }; + + let Some((width, height)) = dims else { + return Some(data.into()); }; let is_too_large = width > max_dim || height > max_dim; @@ -79,7 +33,7 @@ fn shrink_inner(data: &[u8], max_dim: u32) -> Option> { image.as_bytes(), image.width(), image.height(), - image.color(), + image.color().into(), ) .ok()?; data.into() diff --git a/src/util/image.rs b/src/util/image.rs new file mode 100644 index 00000000..0656f5cb --- /dev/null +++ b/src/util/image.rs @@ -0,0 +1,66 @@ +use std::io::Cursor; + +use bytemuck_derive::{Pod, Zeroable}; +use image::{ + codecs::{bmp, farbfeld, hdr, ico, jpeg, pnm, tga, tiff, webp}, + ImageDecoder, ImageFormat, +}; + +use crate::util::byte_parsing::{big_endian::U32, strip_pod}; + +pub fn get_dimensions(format: ImageFormat, data: &[u8]) -> Option<(u32, u32)> { + Some(match format { + ImageFormat::Png => { + // We encounter a lot of PNG images in splits files and decoding + // them with image's PNG decoder seems to decode way more than + // necessary. We really just need to find the width and height. The + // specification is here: + // https://www.w3.org/TR/2003/REC-PNG-20031110/ + // + // And it says the following: + // + // "A valid PNG datastream shall begin with a PNG signature, + // immediately followed by an IHDR chunk". + // + // Each chunk is encoded as a length and type and then its chunk + // encoding. An IHDR chunk immediately starts with the width and + // height. This means we can model the beginning of a PNG file as + // follows: + #[derive(Copy, Clone, Pod, Zeroable)] + #[repr(C)] + struct BeginningOfPng { + // 5.2 PNG signature + png_signature: [u8; 8], + // 5.3 Chunk layout + chunk_len: [u8; 4], + // 11.2.2 IHDR Image header + chunk_type: [u8; 4], + width: U32, + height: U32, + } + + // This improves parsing speed of entire splits files by up to 30%. + + let beginning_of_png: &BeginningOfPng = strip_pod(&mut &*data)?; + (beginning_of_png.width.get(), beginning_of_png.height.get()) + } + ImageFormat::Jpeg => jpeg::JpegDecoder::new(Cursor::new(data)).ok()?.dimensions(), + ImageFormat::WebP => webp::WebPDecoder::new(Cursor::new(data)).ok()?.dimensions(), + ImageFormat::Pnm => pnm::PnmDecoder::new(data).ok()?.dimensions(), + ImageFormat::Tiff => tiff::TiffDecoder::new(Cursor::new(data)).ok()?.dimensions(), + ImageFormat::Tga => tga::TgaDecoder::new(Cursor::new(data)).ok()?.dimensions(), + ImageFormat::Bmp => bmp::BmpDecoder::new(Cursor::new(data)).ok()?.dimensions(), + ImageFormat::Ico => ico::IcoDecoder::new(Cursor::new(data)).ok()?.dimensions(), + ImageFormat::Hdr => hdr::HdrDecoder::new(data).ok()?.dimensions(), + ImageFormat::Farbfeld => farbfeld::FarbfeldDecoder::new(data).ok()?.dimensions(), + #[cfg(feature = "rendering")] + ImageFormat::Gif => image::codecs::gif::GifDecoder::new(Cursor::new(data)) + .ok()? + .dimensions(), + // DDS isn't a format we really care for. + // AVIF uses C bindings, so it's not portable. + // The OpenEXR code in the image crate doesn't compile on Big Endian targets. + // And the image format is non-exhaustive. + _ => return None, + }) +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 63f81cac..75ba6c4c 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -4,6 +4,8 @@ pub(crate) mod ascii_char; pub(crate) mod ascii_set; pub(crate) mod byte_parsing; mod clear_vec; +#[cfg(any(feature = "image-shrinking", feature = "svg-rendering"))] +pub(crate) mod image; pub(crate) mod not_nan; pub mod ordered_map; mod populate_string; diff --git a/src/util/xml/mod.rs b/src/util/xml/mod.rs index b497d1d7..098b8797 100644 --- a/src/util/xml/mod.rs +++ b/src/util/xml/mod.rs @@ -11,7 +11,7 @@ mod writer; pub use self::{ reader::{Event, Reader}, - writer::{AttributeWriter, DisplayValue, Value, Writer, NO_ATTRIBUTES}, + writer::{AttributeWriter, DisplayAlreadyEscaped, Value, Writer, NO_ATTRIBUTES}, }; use super::{ascii_char::AsciiChar, ascii_set::AsciiSet}; diff --git a/src/util/xml/writer.rs b/src/util/xml/writer.rs index 4ec10105..cd8e42de 100644 --- a/src/util/xml/writer.rs +++ b/src/util/xml/writer.rs @@ -204,11 +204,11 @@ impl Value for Text<'_> { } } -pub struct DisplayValue(pub T); +pub struct DisplayAlreadyEscaped(pub T); -impl Value for DisplayValue { +impl Value for DisplayAlreadyEscaped { fn write_escaped(self, sink: &mut T) -> fmt::Result { - write!(EscapeSink(sink), "{}", self.0) + write!(sink, "{}", self.0) } fn is_empty(&self) -> bool { @@ -218,16 +218,6 @@ impl Value for DisplayValue { } } -impl Value for fmt::Arguments<'_> { - fn write_escaped(self, sink: &mut T) -> fmt::Result { - DisplayValue(self).write_escaped(sink) - } - - fn is_empty(&self) -> bool { - DisplayValue(self).is_empty() - } -} - struct IsEmptySink(bool); impl fmt::Write for IsEmptySink { @@ -237,12 +227,4 @@ impl fmt::Write for IsEmptySink { } } -struct EscapeSink<'a, T>(&'a mut T); - -impl fmt::Write for EscapeSink<'_, T> { - fn write_str(&mut self, s: &str) -> fmt::Result { - s.write_escaped(self.0) - } -} - pub const NO_ATTRIBUTES: [(&str, Text<'static>); 0] = []; diff --git a/tests/rendering.rs b/tests/rendering.rs index e519d1dd..cf4d3266 100644 --- a/tests/rendering.rs +++ b/tests/rendering.rs @@ -1,5 +1,5 @@ #![cfg(all( - feature = "software-rendering", + any(feature = "software-rendering", feature = "svg-rendering"), not(all(target_arch = "x86", not(target_feature = "sse"))), ))] @@ -8,11 +8,10 @@ mod run_files; #[path = "../src/util/tests_helper.rs"] mod tests_helper; -use image::Rgba; use livesplit_core::{ component::{self, timer}, layout::{self, Component, ComponentState, Layout, LayoutDirection, LayoutState}, - rendering::software::Renderer, + rendering, run::parser::{livesplit, llanfair, wsplit}, settings::ImageCache, Run, Segment, TimeSpan, Timer, TimingMethod, @@ -42,7 +41,13 @@ fn default() { let mut image_cache = ImageCache::new(); let state = layout.state(&mut image_cache, &timer.snapshot()); - check(&state, &image_cache, "670e0e09bf3dbfed", "default"); + check( + &state, + &image_cache, + "670e0e09bf3dbfed", + "ff7a86855648dd1b", + "default", + ); } // Font fallback inherently requires fonts from the operating system to @@ -55,6 +60,17 @@ fn default() { #[cfg(all(feature = "font-loading", windows))] #[test] fn font_fallback() { + let build_number: u64 = sysinfo::System::kernel_version().unwrap().parse().unwrap(); + + if build_number < 22000 { + // The hash is different before Windows 11. + println!( + "Skipping font fallback test on Windows with build number {}.", + build_number + ); + return; + } + // This list is based on the most commonly used writing systems in the // world: // https://en.wikipedia.org/wiki/List_of_writing_systems#List_of_writing_systems_by_adoption @@ -101,17 +117,15 @@ fn font_fallback() { tests_helper::make_progress_run_with_splits_opt(&mut timer, &[Some(5.0), None, Some(10.0)]); let mut image_cache = ImageCache::new(); - let _state = layout.state(&mut image_cache, &timer.snapshot()); + let state = layout.state(&mut image_cache, &timer.snapshot()); - let build_number: u64 = sysinfo::System::kernel_version().unwrap().parse().unwrap(); - let expected_hash = if build_number >= 22000 { - // Windows 11 - "5a0e8df5e424a5cf" - } else { - // Windows 10 - "dd5663f95a5b43ec" - }; - check(&_state, &image_cache, expected_hash, "font_fallback"); + check( + &state, + &image_cache, + "5a0e8df5e424a5cf", + "23e71509050b368f", + "font_fallback", + ); } #[test] @@ -125,6 +139,7 @@ fn actual_split_file() { &layout.state(&mut image_cache, &timer.snapshot()), &image_cache, "cd9735cf9575f503", + "442e5df389ce2add", "actual_split_file", ); } @@ -141,6 +156,7 @@ fn wsplit() { &image_cache, [250, 300], "9c69454a9258e768", + "d1eebea6860d57c3", "wsplit", ); } @@ -160,6 +176,7 @@ fn timer_delta_background() { &image_cache, [250, 300], "fc8e7890593f9da6", + "0140697763078566", "timer_delta_background_ahead", ); @@ -170,6 +187,7 @@ fn timer_delta_background() { &image_cache, [250, 300], "c56d1f6715627391", + "75b3c2a49c0f0b93", "timer_delta_background_stopped", ); } @@ -194,6 +212,7 @@ fn all_components() { &image_cache, [300, 800], "a4a9f27478717418", + "2294184b8afcea27", "all_components", ); @@ -202,6 +221,7 @@ fn all_components() { &image_cache, [150, 800], "0ecd0bad25453ff6", + "b4186e90d4a93b4a", "all_components_thin", ); } @@ -229,6 +249,7 @@ fn score_split() { &image_cache, [300, 400], "6ec6913f5ace6ab6", + "1acd4eb5a81f4665", "score_split", ); } @@ -245,6 +266,7 @@ fn dark_layout() { &layout.state(&mut image_cache, &timer.snapshot()), &image_cache, "a47c590792c1bab5", + "3f8dfb2da2d43648", "dark_layout", ); } @@ -268,6 +290,7 @@ fn subsplits_layout() { &image_cache, [300, 800], "57165de23ce37b9c", + "0984cf3a14c0edef", "subsplits_layout", ); } @@ -294,6 +317,7 @@ fn display_two_rows() { &image_cache, [200, 100], "d174c2f9a0c54d66", + "1cf9537c6fe5ed76", "display_two_rows", ); } @@ -320,6 +344,7 @@ fn single_line_title() { &image_cache, [300, 60], "5f0a41091c33ecad", + "229c2e381e03328a", "single_line_title", ); } @@ -357,24 +382,47 @@ fn horizontal() { &image_cache, [1500, 40], "987157e649936cbb", + "ca63a8972570fac6", "horizontal", ); } #[track_caller] -fn check(state: &LayoutState, image_cache: &ImageCache, expected_hash_data: &str, name: &str) { - check_dims(state, image_cache, [300, 500], expected_hash_data, name); +fn check( + state: &LayoutState, + image_cache: &ImageCache, + png_hash: &str, + svg_hash: &str, + name: &str, +) { + check_dims(state, image_cache, [300, 500], png_hash, svg_hash, name); } #[track_caller] fn check_dims( + state: &LayoutState, + image_cache: &ImageCache, + dims: [u32; 2], + _png_hash: &str, + _svg_hash: &str, + name: &str, +) { + #[cfg(feature = "software-rendering")] + check_software(state, image_cache, dims, _png_hash, name); + #[cfg(feature = "svg-rendering")] + check_svg(state, image_cache, dims, _svg_hash, name); +} + +#[cfg(feature = "software-rendering")] +#[track_caller] +fn check_software( state: &LayoutState, image_cache: &ImageCache, dims: [u32; 2], expected_hash: &str, name: &str, ) { - let mut renderer = Renderer::new(); + let mut renderer = rendering::software::Renderer::new(); renderer.render(state, image_cache, dims); let hash_image = renderer.image(); @@ -397,7 +445,7 @@ fn check_dims( expected_path.push(format!("{name}_{expected_hash}.png")); let diff_path = if let Ok(expected_image) = image::open(&expected_path) { let mut expected_image = expected_image.to_rgba8(); - for (x, y, Rgba([r, g, b, a])) in expected_image.enumerate_pixels_mut() { + for (x, y, image::Rgba([r, g, b, a])) in expected_image.enumerate_pixels_mut() { if x < hash_image.width() && y < hash_image.height() { let image::Rgba([r2, g2, b2, a2]) = *hash_image.get_pixel(x, y); *r = r.abs_diff(r2); @@ -417,7 +465,7 @@ fn check_dims( }; panic!( - "Render mismatch for {name} + "Software render mismatch for {name} expected: {expected_hash} {} actual: {calculated_hash} {} diff: {}", @@ -427,3 +475,46 @@ diff: {}", ); } } + +#[cfg(feature = "svg-rendering")] +#[track_caller] +fn check_svg( + state: &LayoutState, + image_cache: &ImageCache, + dims: [u32; 2], + expected_hash: &str, + name: &str, +) { + let mut hash_image = String::new(); + let mut renderer = rendering::svg::Renderer::new(); + renderer + .render(&mut hash_image, state, image_cache, dims.map(|v| v as f32)) + .unwrap(); + + let calculated_hash = seahash::hash(hash_image.as_bytes()); + let calculated_hash = format!("{calculated_hash:016x}"); + + let mut path = PathBuf::from_iter(["target", "renders"]); + fs::create_dir_all(&path).ok(); + + let mut actual_path = path.clone(); + actual_path.push(format!("{name}_{calculated_hash}.svg")); + fs::write(&actual_path, hash_image).ok(); + + if calculated_hash != expected_hash { + path.push("diff"); + fs::create_dir_all(&path).ok(); + path.pop(); + + let mut expected_path = path.clone(); + expected_path.push(format!("{name}_{expected_hash}.svg")); + + panic!( + "SVG render mismatch for {name} +expected: {expected_hash} {} +actual: {calculated_hash} {}", + expected_path.display(), + actual_path.display(), + ); + } +}