├── ssb_filter ├── CHANGES.md ├── cbindgen.toml ├── tests │ ├── test.vpy │ ├── platform.irs │ ├── vapoursynth_tests.rs │ └── c_tests.rs ├── README.md ├── src │ ├── lib.rs │ ├── c.rs │ └── vapoursynth.rs ├── Cargo.toml └── build.rs ├── ssb_renderer ├── CHANGES.md ├── src │ ├── lib.rs │ ├── error.rs │ └── rendering.rs ├── tests │ └── math_expr_tests.rs ├── README.md ├── Cargo.toml └── benches │ └── rendering_benches.rs ├── ssb_parser ├── src │ ├── state │ │ ├── mod.rs │ │ ├── ssb_state.rs │ │ └── error.rs │ ├── utils │ │ ├── mod.rs │ │ ├── functions │ │ │ ├── mod.rs │ │ │ ├── option.rs │ │ │ ├── convert.rs │ │ │ ├── macros.rs │ │ │ └── event_iter.rs │ │ └── pattern.rs │ ├── objects │ │ ├── mod.rs │ │ ├── ssb_objects.rs │ │ └── event_objects.rs │ ├── parsers │ │ ├── mod.rs │ │ ├── ssb.rs │ │ └── ssb_render.rs │ └── lib.rs ├── tests │ ├── cute.png │ ├── code_structure_tests.rs │ ├── serialization_tests.rs │ ├── test.ssb │ └── parse_tests.rs ├── README.md ├── Cargo.toml └── CHANGES.md ├── .gitignore ├── .codecov.yml ├── .github ├── ISSUE_TEMPLATE │ ├── enhancement_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── build_workspace.yml ├── .editorconfig ├── Cargo.toml ├── .scripts ├── vapoursynth64-install.ps1 ├── kcov.sh └── vapoursynth-install.sh ├── rustfmt.toml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md └── LICENSE-APACHE-2.0 /ssb_filter/CHANGES.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ssb_renderer/CHANGES.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ssb_parser/src/state/mod.rs: -------------------------------------------------------------------------------- 1 | // Error types. 2 | pub mod error; 3 | // State of SSB processing. 4 | pub mod ssb_state; -------------------------------------------------------------------------------- /ssb_parser/tests/cute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substation-beta/ssb_implementation/HEAD/ssb_parser/tests/cute.png -------------------------------------------------------------------------------- /ssb_parser/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | // Constant pattern for parsing. 2 | pub mod pattern; 3 | // Functions to handle common data. 4 | pub mod functions; -------------------------------------------------------------------------------- /ssb_parser/src/objects/mod.rs: -------------------------------------------------------------------------------- 1 | /// Sub-level objects of SSB for events. 2 | pub mod event_objects; 3 | /// Top-level objects of SSB. 4 | pub mod ssb_objects; -------------------------------------------------------------------------------- /ssb_parser/src/parsers/mod.rs: -------------------------------------------------------------------------------- 1 | // Raw SSB data, close to original text. 2 | pub mod ssb; 3 | // Processed SSB data, formatted for rendering. 4 | pub mod ssb_render; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compile output 2 | /target/ 3 | 4 | # Cargo dependencies lock 5 | Cargo.lock 6 | 7 | # Rust backups 8 | **/*.rs.bk 9 | 10 | # Editor configurations 11 | .idea/ 12 | .vscode/ -------------------------------------------------------------------------------- /ssb_filter/cbindgen.toml: -------------------------------------------------------------------------------- 1 | # 2 | 3 | language = "C" 4 | include_guard = "SSB_FILTER_H" 5 | include_version = true 6 | no_includes = true -------------------------------------------------------------------------------- /ssb_parser/src/utils/functions/mod.rs: -------------------------------------------------------------------------------- 1 | // String conversions. 2 | pub mod convert; 3 | // Option extensions. 4 | pub mod option; 5 | // Iterators over event tokens. 6 | pub mod event_iter; 7 | // Macros evaluation. 8 | pub mod macros; -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # See 2 | 3 | codecov: 4 | ci: ["https://github.com/features/actions"] 5 | require_ci_to_pass: true 6 | notify: 7 | wait_for_ci: true 8 | 9 | coverage: 10 | range: "50...80" 11 | precision: 2 12 | round: down -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Suggest an enhancement for project improvement. 4 | title: "... ." 5 | labels: enhancement 6 | assignees: Youka 7 | 8 | --- 9 | 10 | **Explain the benefit:** 11 | * What's *bad*? 12 | * How to *improve*? 13 | * Practical *example*? 14 | * Possible *alternatives*? -------------------------------------------------------------------------------- /ssb_filter/tests/test.vpy: -------------------------------------------------------------------------------- 1 | # Import vapoursynth core 2 | from vapoursynth import core 3 | 4 | # Load SSB plugin (inserted by post-processor) 5 | core.std.LoadPlugin({:?}) 6 | 7 | # Create empty clip 8 | clip = core.std.BlankClip(None, 100, 100) 9 | # Apply SSB rendering 10 | clip = core.ssb.render_raw(clip, '#EVENTS\n0-1.|||') 11 | 12 | # Trigger some processing 13 | clip.get_frame(0) -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # See 2 | 3 | # Root configuration (don't look further into parents) 4 | root = true 5 | 6 | # All files 7 | [*] 8 | indent_style = space 9 | indent_size = 4 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = false 14 | 15 | [*.ssb] 16 | trim_trailing_whitespace = false 17 | 18 | [*.yml] 19 | indent_size = 2 -------------------------------------------------------------------------------- /ssb_parser/tests/code_structure_tests.rs: -------------------------------------------------------------------------------- 1 | mod code_structure_tests { 2 | // Imports 3 | use std::mem::size_of; 4 | use ssb_parser::objects::event_objects::EventObject; 5 | 6 | #[test] 7 | fn test_sizes() { 8 | let event_object_size = size_of::(); 9 | assert!(event_object_size <= 40, "EventObject is larger than 40 bytes: {}!", event_object_size); 10 | } 11 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "ssb_parser", 4 | "ssb_renderer", 5 | "ssb_filter" 6 | ] 7 | 8 | [profile.release] 9 | # Link-time-optimization for smaller binaries but longer build time 10 | lto = true 11 | # Just one build thread (=no parallel building) but chance for additional optimization 12 | codegen-units = 1 13 | # No expensive stack unwinding, release should be safe and without bloating 14 | panic = "abort" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help fixing bugs. 4 | title: "...!" 5 | labels: bug 6 | assignees: Youka 7 | 8 | --- 9 | 10 | **Provide enough details:** 11 | * What's the *problem*? 12 | * What did you expect? 13 | * What actually happened? 14 | * Your *environment*? 15 | * Operating system? 16 | * Component version? 17 | * Steps to *reproduce*? 18 | * Installation method(s)? 19 | * Failing execution(s)? -------------------------------------------------------------------------------- /ssb_parser/README.md: -------------------------------------------------------------------------------- 1 | # ssb_parser 2 | [![Crate Version](https://img.shields.io/crates/v/ssb_parser.svg?logo=rust)](https://crates.io/crates/ssb_parser) [![Crate Docs Version](https://img.shields.io/crates/v/ssb_parser.svg?logo=rust&label=docs&color=informational)](https://docs.rs/ssb_parser) 3 | 4 | --- 5 | 6 | 1st level component of [ssb_implementation](https://github.com/substation-beta/ssb_implementation). 7 | 8 | [Changes](https://github.com/substation-beta/ssb_implementation/blob/master/ssb_parser/CHANGES.md) -------------------------------------------------------------------------------- /ssb_renderer/src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Renderer component of subtitle format implementation. 3 | 4 | ``` 5 | // TODO 6 | ``` 7 | */ 8 | #![doc( 9 | html_logo_url = "https://substation-beta.github.io/assets/img/logo.png", 10 | html_favicon_url = "https://substation-beta.github.io/assets/img/logo.png", 11 | html_root_url = "https://substation-beta.github.io" 12 | )] 13 | 14 | // Project modules 15 | mod error; 16 | mod rendering; 17 | 18 | // Exports 19 | pub use crate::{error::RenderingError, rendering::*}; 20 | 21 | // Re-exports (interfaces required by public users). 22 | pub use puny2d::raster::image; 23 | pub use ssb_parser; -------------------------------------------------------------------------------- /ssb_filter/README.md: -------------------------------------------------------------------------------- 1 | # ssb_filter 2 | [![Crate Version](https://img.shields.io/crates/v/ssb_filter.svg?logo=rust)](https://crates.io/crates/ssb_filter) [![Crate Docs Version](https://img.shields.io/crates/v/ssb_filter.svg?logo=rust&label=docs&color=informational)](https://docs.rs/ssb_filter) 3 | 4 | --- 5 | 6 | 3rd level component of [ssb_implementation](https://github.com/substation-beta/ssb_implementation). 7 | 8 | ## Planned 9 | * [GStreamer](https://en.wikipedia.org/wiki/GStreamer) 10 | * [MediaFoundation](https://en.wikipedia.org/wiki/Media_Foundation) 11 | 12 | [Changes](https://github.com/substation-beta/ssb_implementation/blob/master/ssb_filter/CHANGES.md) -------------------------------------------------------------------------------- /ssb_filter/src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Filter interfaces for various frameservers to render subtitle format. 3 | 4 | ``` 5 | // TODO 6 | ``` 7 | */ 8 | #![doc( 9 | html_logo_url = "https://substation-beta.github.io/assets/img/logo.png", 10 | html_favicon_url = "https://substation-beta.github.io/assets/img/logo.png", 11 | html_root_url = "https://substation-beta.github.io" 12 | )] 13 | 14 | 15 | /// C API (usable f.e. with [FFI](https://en.wikipedia.org/wiki/Foreign_function_interface)). 16 | pub mod c; 17 | /// [Vapoursynth](www.vapoursynth.com) frameserver. 18 | #[cfg(feature = "vapoursynth-interface")] 19 | #[allow(clippy::missing_safety_doc)] 20 | pub mod vapoursynth; -------------------------------------------------------------------------------- /.scripts/vapoursynth64-install.ps1: -------------------------------------------------------------------------------- 1 | # Get python home 2 | $Env:PYTHON = split-path (Get-Command python).Source 3 | # Load vapoursynth R47.2 4 | wget https://github.com/vapoursynth/vapoursynth/releases/download/R47.2/VapourSynth64-Portable-R47.2.7z -Outfile VapourSynth64-Portable-R47.2.7z 5 | # Extract vapoursynth archive into python 6 | 7z x VapourSynth64-Portable-R47.2.7z -o"$Env:PYTHON" -y 7 | # Show vapoursynth version 8 | python -c "import vapoursynth; print(vapoursynth.core.version())" 9 | # Add compiler access to vapoursynth sdk 10 | echo "VAPOURSYNTH_LIB_DIR=$Env:PYTHON/sdk/lib64" | Out-File -FilePath $Env:GITHUB_ENV -Append -Encoding utf8 # https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-environment-variable -------------------------------------------------------------------------------- /ssb_renderer/tests/math_expr_tests.rs: -------------------------------------------------------------------------------- 1 | mod math_expr_tests { 2 | // Imports 3 | use meval::{eval_str, Expr, Context}; 4 | 5 | #[test] 6 | fn test_simple() { 7 | assert_eq!(eval_str("1 + 2").unwrap(), 3.); 8 | } 9 | 10 | #[test] 11 | fn test_sample() { 12 | // Compile expression 13 | let expr = "phi(-2 * zeta + x)".parse::().unwrap(); 14 | // Build execution context 15 | let mut ctx = Context::new(); 16 | ctx.func("phi", |x| x + 1.) 17 | .var("zeta", -1.); 18 | // Combine expression and context, bind variable and output function 19 | let func = expr.bind_with_context(&ctx, "x").unwrap(); 20 | // Call function and test 21 | assert_eq!(func(2.), 5.); 22 | } 23 | } -------------------------------------------------------------------------------- /ssb_renderer/README.md: -------------------------------------------------------------------------------- 1 | # ssb_renderer 2 | [![Crate Version](https://img.shields.io/crates/v/ssb_renderer.svg?logo=rust)](https://crates.io/crates/ssb_renderer) [![Crate Docs Version](https://img.shields.io/crates/v/ssb_renderer.svg?logo=rust&label=docs&color=informational)](https://docs.rs/ssb_renderer) 3 | 4 | --- 5 | 6 | 2nd level component of [ssb_implementation](https://github.com/substation-beta/ssb_implementation). 7 | 8 | ## Planned 9 | * Platform-independent font handling 10 | * Bidirectional, context-sensitive text? 11 | * Caching repetitive contents 12 | * Multithreading with pool 13 | * SIMD (SSE2, AVX) 14 | * GPGPU (OpenCL, Cuda) option 15 | 16 | [Changes](https://github.com/substation-beta/ssb_implementation/blob/master/ssb_renderer/CHANGES.md) 17 | 18 | _WORK IN PROGRESS_ -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Purpose 2 | _What's the reason for this PR?_ 3 | * _Which problems does it solve and how?_ 4 | * _Which enhancements does it offer and why is it beneficial?_ 5 | * _Are hard changes to consider with after effects?_ 6 | 7 | # Changes 8 | _Please describe changes in detail. Give us a summary of your commits._ 9 | 10 | # References 11 | _Is this PR based on issues or discussions. Don't let us miss any helpful information._ 12 | 13 | # Reviewer requests 14 | _Who should take a look at this PR? Any person especially capable of rating the quality of this PR?_ 15 | 16 | # Checklist 17 | - [ ] Followed contributing guidelines 18 | - [ ] Guaranteed appropriate quality of code 19 | - [ ] Updated documentation 20 | - [ ] Successfully tested 21 | - [ ] Self-reviewed changes -------------------------------------------------------------------------------- /.scripts/kcov.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check root permission 4 | if [ $EUID -ne 0 ]; then 5 | echo Root permission required! Run with sudo or login as root. 6 | exit 1 7 | fi 8 | 9 | # Install kcov requirements 10 | apt-get install -y libcurl4-openssl-dev libelf-dev libdw-dev cmake gcc binutils-dev libiberty-dev 11 | # Download & unpack kcov sources 12 | wget -qO- https://github.com/SimonKagstrom/kcov/archive/master.tar.gz | tar -zxf- 13 | # Move into intermediate kcov build directory 14 | pushd kcov-master && mkdir build && cd build 15 | # Build & install kcov 16 | cmake .. && make install 17 | # Delete kcov sources 18 | popd && rm -rf kcov-master 19 | # Generate code coverage reports 20 | for file in target/debug/*_*-*[^\.d];do 21 | mkdir -p "target/cov/$(basename $file)" 22 | kcov --exclude-pattern=/.cargo,/usr/lib --verify "target/cov/$(basename $file)" "$file" 23 | done 24 | 25 | # Upload code coverage reports (by environment variable CODECOV_TOKEN) 26 | bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /ssb_parser/src/utils/functions/option.rs: -------------------------------------------------------------------------------- 1 | pub trait OptionExt<'a> { 2 | fn map_or_err_str(self, op: F) -> Result 3 | where F: FnOnce(&str) -> Result; 4 | fn map_else_err_str(self, op: F) -> Result 5 | where F: FnOnce(&str) -> Option; 6 | } 7 | impl<'a, T: AsRef + ?Sized> OptionExt<'a> for Option<&'a T> { 8 | fn map_or_err_str(self, op: F) -> Result 9 | where F: FnOnce(&str) -> Result { 10 | self.map_or(Err(""), |value| op(value.as_ref()).map_err(|_| value.as_ref() )) 11 | } 12 | fn map_else_err_str(self, op: F) -> Result 13 | where F: FnOnce(&str) -> Option { 14 | self.map_or(Err(""), |value| op(value.as_ref()).ok_or_else(|| value.as_ref() )) 15 | } 16 | } 17 | 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use super::OptionExt; 22 | 23 | #[test] 24 | fn map_err_str() { 25 | assert_eq!(Some("123").map_or_err_str(|value| value.parse()), Ok(123)); 26 | assert_eq!(Some("987a").map_else_err_str(|value| value.parse::().ok()), Err("987a")); 27 | } 28 | } -------------------------------------------------------------------------------- /ssb_parser/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | # Project information 3 | name = "ssb_parser" 4 | version = "0.4.0" 5 | authors = ["Christoph 'Youka' Spanknebel"] 6 | description = "Parser of text in ssb format." 7 | # Project type 8 | workspace = ".." 9 | edition = "2018" 10 | # Documentation 11 | keywords = ["ssb", "parser", "subtitle", "text"] 12 | categories = ["parsing"] 13 | readme = "README.md" 14 | license = "Apache-2.0" 15 | repository = "https://github.com/substation-beta/ssb_implementation" 16 | 17 | [lib] 18 | # Compile to Rust static library 19 | crate-type = ["rlib"] 20 | # Documentation embedded code doesn't need tests 21 | doctest = false 22 | 23 | [features] 24 | # Serialization 25 | serialization = ["serde"] 26 | 27 | [dependencies] 28 | # Text parsing 29 | regex = "~1.4.3" # https://crates.io/crates/regex 30 | base64 = "~0.13.0" # https://crates.io/crates/base64 31 | # Utilities 32 | lazy_static = "~1.4.0" # https://crates.io/crates/lazy_static 33 | # Serialization 34 | serde = {version = "~1.0.123", features = ["derive"], optional = true} # https://crates.io/crates/serde 35 | 36 | [dev-dependencies] 37 | # Serialization 38 | serde_json = "~1.0.62" # https://crates.io/crates/serde_json -------------------------------------------------------------------------------- /ssb_filter/tests/platform.irs: -------------------------------------------------------------------------------- 1 | #[allow(dead_code)] 2 | mod platform { 3 | // Platform properties 4 | #[cfg(target_os = "windows")] 5 | pub mod constants { 6 | pub const PYTHON_CMD: &str = "python"; 7 | pub const LIB_PREFIX: &str = ""; 8 | pub const LIB_EXTENSION: &str = ".dll"; 9 | } 10 | #[cfg(target_os = "linux")] 11 | pub mod constants { 12 | pub const PYTHON_CMD: &str = "python3"; 13 | pub const LIB_PREFIX: &str = "lib"; 14 | pub const LIB_EXTENSION: &str = ".so"; 15 | } 16 | #[cfg(target_os = "macos")] 17 | pub mod constants { 18 | pub const PYTHON_CMD: &str = "python3"; 19 | pub const LIB_PREFIX: &str = "lib"; 20 | pub const LIB_EXTENSION: &str = ".dylib"; 21 | } 22 | 23 | // Output DLL 24 | pub fn dll_path() -> std::path::PathBuf { 25 | std::path::Path::new(concat!( 26 | env!("CARGO_MANIFEST_DIR"), 27 | "/../target/", 28 | env!("PROFILE") // Set by build script 29 | )).join( 30 | constants::LIB_PREFIX.to_owned() + 31 | &env!("CARGO_PKG_NAME") + 32 | constants::LIB_EXTENSION 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /ssb_parser/src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | Parser component of subtitle format implementation. 3 | 4 | ``` 5 | // Imports 6 | use std::{ 7 | convert::TryFrom, 8 | fs::File, 9 | io::{BufReader,Cursor} 10 | }; 11 | use ssb_parser::{Ssb,SsbRender}; 12 | // Data 13 | let ssb_reader1 = Cursor::new("..."); 14 | let ssb_reader2 = BufReader::new(File::open("/foo/bar.ssb").unwrap()); 15 | // Parsing 16 | let ssb = Ssb::default() 17 | .parse_owned(ssb_reader1).unwrap() 18 | .parse_owned(ssb_reader2).unwrap(); 19 | let ssb_render = SsbRender::try_from(ssb).unwrap(); 20 | // Print 21 | println!("{:#?}", ssb_render); 22 | ``` 23 | */ 24 | #![doc( 25 | html_logo_url = "https://substation-beta.github.io/assets/img/logo.png", 26 | html_favicon_url = "https://substation-beta.github.io/assets/img/logo.png", 27 | html_root_url = "https://substation-beta.github.io" 28 | )] 29 | 30 | 31 | /// Objects in SSB. 32 | pub mod objects; 33 | 34 | // States for SSB processing. 35 | mod state; 36 | pub use state::error::ParseError; 37 | 38 | // Internal utility structures & functions for data processing. 39 | mod utils; 40 | 41 | // Parsers for different levels of SSB data. 42 | mod parsers; 43 | pub use parsers::{ 44 | ssb::Ssb, 45 | ssb_render::SsbRender 46 | }; -------------------------------------------------------------------------------- /ssb_parser/tests/serialization_tests.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "serialization")] 2 | mod serialization_tests { 3 | // Test data 4 | #[derive(serde::Serialize)] 5 | struct Dummy { 6 | a: u8, 7 | b: f64 8 | } 9 | 10 | #[test] 11 | fn test_serde() { 12 | assert_eq!( 13 | serde_json::to_string(&Dummy {a: 8, b: 64.0}).expect("Dummy serialization must work!"), 14 | r#"{"a":8,"b":64.0}"#.to_owned() 15 | ); 16 | } 17 | 18 | #[test] 19 | fn test_ssb() { 20 | use ssb_parser::Ssb; 21 | // Serialize 22 | let ssb_default = Ssb::default(); 23 | let ssb_json = serde_json::to_string(&ssb_default).expect("Ssb serialization must work!"); 24 | assert_eq!( 25 | ssb_json, 26 | r#"{"info_title":null,"info_author":null,"info_description":null,"info_version":null,"info_custom":{},"target_width":null,"target_height":null,"target_depth":1000,"target_view":"Perspective","macros":{},"events":[],"fonts":{},"textures":{}}"#.to_owned() 27 | ); 28 | // Deserialize 29 | assert_eq!( 30 | serde_json::from_str::(&ssb_json).expect("Ssb deserialization must work!"), 31 | ssb_default 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /.scripts/vapoursynth-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check root permission 4 | if [ $EUID -ne 0 ]; then 5 | echo Root permission required! Run with sudo or login as root. 6 | exit 1 7 | fi 8 | 9 | # Helper function for installing 10 | download_and_build() { 11 | if wget -qO- $1 | tar -zxf- && pushd ./$2; then 12 | ./autogen.sh && ./configure && make install 13 | popd && rm -rf ./$2 14 | fi 15 | } 16 | 17 | # Install system dependencies 18 | apt-get install -y build-essential autoconf libtool pkg-config python3-pip && pip3 install cython 19 | # Install zimg (from source) 20 | download_and_build https://github.com/sekrit-twc/zimg/archive/release-2.9.3.tar.gz zimg-release-2.9.3 21 | # Install vapoursynth (from source) 22 | download_and_build https://github.com/vapoursynth/vapoursynth/archive/R47.2.tar.gz vapoursynth-R47.2 23 | # Fix vapoursynth (native) python path 24 | PYTHON3_LOCAL_LIB_PATH=$(echo /usr/local/lib/python3.*) 25 | ln -s $PYTHON3_LOCAL_LIB_PATH/site-packages/vapoursynth.so $PYTHON3_LOCAL_LIB_PATH/dist-packages/vapoursynth.so 26 | # Load vapoursynth into system libraries cache 27 | ldconfig /usr/local/lib 28 | 29 | # Test installation 30 | python3 -c "from vapoursynth import core;print(core.version())" -------------------------------------------------------------------------------- /ssb_parser/CHANGES.md: -------------------------------------------------------------------------------- 1 | # v0.4.1 2 | * updated dependencies 3 | * improved error messages by inheritance 4 | * minor code cleaning 5 | * removed rotate tag with 3 dimensions 6 | 7 | # v0.4.0 8 | * updated dependencies 9 | * error construction private to crate now 10 | * refactoring of public API 11 | * changed minimal rust version to 1.40.0 12 | 13 | # v0.3.1 14 | * updated dependencies 15 | * require rust 1.37+ (updated sources to new rust features) 16 | * disabled serialization feature by default 17 | 18 | # v0.3.0 19 | * updated dependencies 20 | * removed logging 21 | * changed degree type from f32 to f64 (compatible to coordinate type and sse2-based frontends) 22 | 23 | # v0.2.4 24 | * changed sections names to uppercase (according to SSB specification) 25 | 26 | # v0.2.3 27 | * Changed tag 'identity' to 'reset' 28 | 29 | # v0.2.2 30 | * Added logging tests 31 | * Reduced heap allocations 32 | 33 | # v0.2.1 34 | * Fixed doc example 35 | * Added serialization 36 | 37 | # v0.2.0 38 | * Minor performance improvements 39 | * Added clone trait to all structures 40 | * Allow empty texture value 41 | * Save texture url in Ssb structure 42 | * Removed search path from parsing parameters 43 | 44 | # v0.1.1 45 | * Updated documentation 46 | * Log debug outputs 47 | 48 | # v0.1.0 49 | * First functional-complete version -------------------------------------------------------------------------------- /ssb_renderer/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | # Project information 3 | name = "ssb_renderer" 4 | version = "0.0.1" 5 | authors = ["Christoph 'Youka' Spanknebel"] 6 | description = "2d graphics software renderer for ssb format." 7 | # Project type 8 | workspace = ".." 9 | edition = "2018" 10 | # Documentation 11 | keywords = ["ssb", "renderer", "subtitle", "2d", "graphics"] 12 | categories = ["rendering::data-formats"] 13 | readme = "README.md" 14 | license = "Apache-2.0" 15 | repository = "https://github.com/substation-beta/ssb_implementation" 16 | 17 | [lib] 18 | # Compile to Rust static library 19 | crate-type = ["rlib"] 20 | # Documentation embedded code doesn't need tests 21 | doctest = false 22 | 23 | [[bench]] 24 | # File to execute 25 | name = "rendering_benches" 26 | # Disable standard benchmarking harness in favor of microbench 27 | harness = false 28 | 29 | [dependencies] 30 | # Depend on parser module 31 | ssb_parser = {path = "../ssb_parser", version = "0.4.0", default-features = false} 32 | # 2d graphics 33 | puny2d = "~0.0.2" # https://crates.io/crates/puny2d 34 | # Math expressions 35 | meval = "~0.2.0" # https://crates.io/crates/meval 36 | 37 | [dev-dependencies] 38 | # Profiling 39 | microbench = "~0.5.0" # https://crates.io/crates/microbench 40 | # Render target 41 | image = "~0.23.13" # https://crates.io/crates/image -------------------------------------------------------------------------------- /ssb_renderer/benches/rendering_benches.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use std::{ 3 | convert::TryFrom, 4 | time::Duration 5 | }; 6 | use ssb_parser::{ 7 | Ssb, 8 | SsbRender, 9 | objects::ssb_objects::{Event,EventTrigger} 10 | }; 11 | use ssb_renderer::{ 12 | image::{ColorType,ImageView}, 13 | RenderTrigger, 14 | SsbRenderer 15 | }; 16 | use image::RgbImage; 17 | use microbench::{bench,Options}; 18 | 19 | 20 | // Benchmark 21 | fn main() { 22 | // Test data 23 | let mut ssb = Ssb::default(); 24 | ssb.events.push(Event { 25 | trigger: EventTrigger::Id("test".to_owned()), 26 | macro_name: None, 27 | note: None, 28 | data: "".to_owned(), 29 | data_location: (0,0) 30 | }); 31 | let mut renderer = SsbRenderer::new(SsbRender::try_from(ssb).expect("Ssb was certainly valid!")); 32 | // Run test 33 | bench(&Options::default().time(Duration::from_secs(3)), "Basic rendering.", || { 34 | 35 | 36 | // TODO: more complex rendering 37 | let img = RgbImage::new(1920, 1080); 38 | let (width, height, stride, color_type, mut data) = (img.width(), img.height(), img.sample_layout().height_stride, ColorType::RGB24, img.into_raw()); 39 | renderer.render( 40 | ImageView::new(width as u16, height as u16, stride as u32, color_type, vec![&mut data]).expect("ImageView must've valid dimensions!"), 41 | RenderTrigger::Id("test") 42 | ).expect("Image rendering mustn't fail!"); 43 | let _img = RgbImage::from_raw(width, height, data).expect("Image rebuild mustn't fail!"); 44 | 45 | 46 | }); 47 | } -------------------------------------------------------------------------------- /ssb_filter/tests/vapoursynth_tests.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "vapoursynth-interface")] 2 | mod vapoursynth_tests { 3 | // Imports 4 | use vapoursynth::prelude::*; 5 | use std::process::Command; 6 | include!("platform.irs"); // Tests are separated, thus include code 7 | 8 | #[test] 9 | fn test_core_available() { 10 | // Create scripting environment 11 | let environment = Environment::new().expect("Couldn't create a VSScript environment!"); 12 | // Get core functions 13 | let core = environment.get_core().expect("Couldn't create the VapourSynth core!"); 14 | // Output version 15 | println!("Core version: {}", core.info().version_string); 16 | } 17 | 18 | #[test] 19 | fn test_python_available() { 20 | // Get python version 21 | let output = Command::new(platform::constants::PYTHON_CMD) 22 | .arg("--version") 23 | .output().expect("Couldn't find python!"); 24 | // Python 3 at least required 25 | assert!(String::from_utf8_lossy(&output.stdout).to_string().contains("Python 3.")); 26 | } 27 | 28 | #[test] 29 | fn test_plugin_load() { 30 | // Load plugin with vapoursynth by python execution 31 | let output = Command::new(platform::constants::PYTHON_CMD) 32 | .arg("-c") 33 | .arg(format!( 34 | include_str!("test.vpy"), 35 | platform::dll_path() 36 | )) 37 | .output().expect("Couldn't load vapoursynth plugin!"); 38 | // No output on standard error stream -> success! 39 | assert!(output.stderr.is_empty(), "Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); 40 | } 41 | } -------------------------------------------------------------------------------- /ssb_parser/src/utils/pattern.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use lazy_static::lazy_static; 3 | use regex::{Regex,escape}; 4 | 5 | 6 | // Constants 7 | pub const INFO_TITLE_KEY: &str = "Title: "; 8 | pub const INFO_AUTHOR_KEY: &str = "Author: "; 9 | pub const INFO_DESCRIPTION_KEY: &str = "Description: "; 10 | pub const INFO_VERSION_KEY: &str = "Version: "; 11 | pub const KEY_SUFFIX: &str = ": "; 12 | pub const TARGET_WIDTH_KEY: &str = "Width: "; 13 | pub const TARGET_HEIGHT_KEY: &str = "Height: "; 14 | pub const TARGET_DEPTH_KEY: &str = "Depth: "; 15 | pub const TARGET_VIEW_KEY: &str = "View: "; 16 | pub const RESOURCES_FONT_KEY: &str = "Font: "; 17 | pub const RESOURCES_TEXTURE_KEY: &str = "Texture: "; 18 | pub const MACRO_INLINE_START: &str = "${"; 19 | pub const MACRO_INLINE_END: &str = "}"; 20 | pub const VALUE_SEPARATOR: char = ','; 21 | pub const EVENT_SEPARATOR: char = '|'; 22 | pub const TRIGGER_SEPARATOR: char = '-'; 23 | pub const TAG_START: &str = "["; 24 | pub const TAG_START_CHAR: char = '['; 25 | pub const TAG_END: &str = "]"; 26 | pub const TAG_END_CHAR: char = ']'; 27 | pub const TAG_SEPARATOR: char = ';'; 28 | pub const TAG_ASSIGN: char = '='; 29 | 30 | // Statics 31 | lazy_static! { 32 | pub static ref MACRO_PATTERN: Regex = Regex::new(&(escape(MACRO_INLINE_START) + "([a-zA-Z0-9_-]+)" + &escape(MACRO_INLINE_END))).unwrap(); 33 | pub static ref TIMESTAMP_PATTERN: Regex = Regex::new("^(?:(?:(?P\\d{0,2}):(?P[0-5]?\\d?):)|(?:(?P[0-5]?\\d?):))?(?:(?P[0-5]?\\d?)\\.)?(?P\\d{0,3})$").unwrap(); 34 | pub static ref ANIMATE_PATTERN: Regex = Regex::new(&format!("^(?:(?P-?\\d+){0}(?P-?\\d+){0})?(?:(?P.+?){0})?{1}(?P.*?){2}$", escape(&VALUE_SEPARATOR.to_string()), escape(TAG_START), escape(TAG_END))).unwrap(); 35 | } -------------------------------------------------------------------------------- /ssb_renderer/src/error.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use std::{ 3 | error::Error, 4 | fmt::{ 5 | Display, 6 | Formatter, 7 | Result 8 | } 9 | }; 10 | use puny2d::error::GraphicsError; 11 | 12 | 13 | /// SSB rendering specific error type. 14 | #[derive(Debug)] 15 | pub struct RenderingError { 16 | msg: String, 17 | src: Box 18 | } 19 | impl RenderingError { 20 | /// New error with message and source error. 21 | pub(crate) fn new_with_source(msg: &str, src: E) -> Self 22 | where E: Error + 'static { 23 | Self { 24 | msg: msg.to_owned(), 25 | src: Box::new(src) 26 | } 27 | } 28 | } 29 | impl Display for RenderingError { 30 | fn fmt(&self, f: &mut Formatter) -> Result { 31 | write!(f, "{}", self.msg) 32 | .and_then(|_| write!(f, "{}", self.source().map_or(String::new(), |src| format!("\n{}", src)))) 33 | } 34 | } 35 | impl Error for RenderingError { 36 | fn source(&self) -> Option<&(dyn Error + 'static)> { 37 | Some(self.src.as_ref()) 38 | } 39 | } 40 | impl From for RenderingError { 41 | fn from(err: std::io::Error) -> Self { 42 | Self::new_with_source("IO error!", err) 43 | } 44 | } 45 | impl From for RenderingError { 46 | fn from(err: GraphicsError) -> Self { 47 | Self::new_with_source("Graphics error!", err) 48 | } 49 | } 50 | 51 | 52 | // Tests 53 | #[cfg(test)] 54 | mod tests { 55 | use super::RenderingError; 56 | 57 | #[test] 58 | fn rendering_error_from_io() { 59 | use std::io::{Error, ErrorKind}; 60 | assert_eq!(RenderingError::from(Error::new(ErrorKind::PermissionDenied, "No access on filesystem!")).to_string(), "IO error!\nNo access on filesystem!".to_owned()); 61 | } 62 | } -------------------------------------------------------------------------------- /ssb_renderer/src/rendering.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use ssb_parser::{ 3 | SsbRender, 4 | objects::ssb_objects::EventTrigger 5 | }; 6 | use puny2d::raster::image::ImageView; 7 | use crate::error::RenderingError; 8 | 9 | 10 | /// Condition to trigger rendering on specific image. 11 | #[derive(Debug, PartialEq, Clone, Copy)] 12 | pub enum RenderTrigger<'a> { 13 | Id(&'a str), 14 | Time(u32) 15 | } 16 | 17 | /// Renderer for ssb data on images. 18 | #[derive(Debug, PartialEq, Clone)] 19 | pub struct SsbRenderer { 20 | data: SsbRender 21 | } 22 | impl SsbRenderer { 23 | /// Consumes ssb data as rendering blueprint. 24 | pub fn new(data: SsbRender) -> Self { 25 | Self { 26 | data 27 | } 28 | } 29 | /// Renders on image by ssb matching trigger. 30 | pub fn render<'data>(&mut self, mut img: ImageView<'data>, trigger: RenderTrigger) -> Result,RenderingError> { 31 | // Find match of render and ssb trigger 32 | for event in &self.data.events { 33 | if match (&event.trigger, trigger) { 34 | (EventTrigger::Id(event_id), RenderTrigger::Id(render_id)) => event_id == render_id, 35 | (EventTrigger::Time((start_ms, end_ms)), RenderTrigger::Time(current_ms)) => (start_ms..end_ms).contains(&¤t_ms), 36 | _ => false 37 | } { 38 | 39 | 40 | // TODO: whole rendering process 41 | for row in img.plane_rows_mut(0).expect("One plane should always exist!") { 42 | for sample in row { 43 | *sample = std::u8::MAX - *sample; 44 | } 45 | } 46 | 47 | 48 | } 49 | } 50 | // Return still valid image reference 51 | Ok(img) 52 | } 53 | } -------------------------------------------------------------------------------- /ssb_parser/src/state/ssb_state.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use std::convert::TryFrom; 3 | 4 | 5 | // Enums 6 | #[derive(Debug, PartialEq, Clone)] 7 | pub enum Section { 8 | Info, 9 | Target, 10 | Macros, 11 | Events, 12 | Resources 13 | } 14 | impl TryFrom<&str> for Section { 15 | type Error = (); 16 | fn try_from(value: &str) -> Result { 17 | match value { 18 | "#INFO" => Ok(Self::Info), 19 | "#TARGET" => Ok(Self::Target), 20 | "#MACROS" => Ok(Self::Macros), 21 | "#EVENTS" => Ok(Self::Events), 22 | "#RESOURCES" => Ok(Self::Resources), 23 | _ => Err(()) 24 | } 25 | } 26 | } 27 | #[derive(Debug, PartialEq, Clone)] 28 | pub enum Mode { 29 | Text, 30 | Points, 31 | Shape 32 | } 33 | impl Default for Mode { 34 | fn default() -> Self { 35 | Self::Text 36 | } 37 | } 38 | impl TryFrom<&str> for Mode { 39 | type Error = (); 40 | fn try_from(value: &str) -> Result { 41 | match value { 42 | "text" => Ok(Self::Text), 43 | "points" => Ok(Self::Points), 44 | "shape" => Ok(Self::Shape), 45 | _ => Err(()) 46 | } 47 | } 48 | } 49 | 50 | #[derive(Debug, PartialEq, Clone)] 51 | pub enum ShapeSegmentType { 52 | Move, 53 | Line, 54 | Curve, 55 | Arc 56 | } 57 | impl Default for ShapeSegmentType { 58 | fn default() -> Self { 59 | Self::Move 60 | } 61 | } 62 | 63 | 64 | // Tests 65 | #[cfg(test)] 66 | mod tests { 67 | #[test] 68 | fn convert() { 69 | use super::{Section, Mode, TryFrom}; 70 | assert_eq!(Section::try_from("#EVENTS"), Ok(Section::Events)); 71 | assert_eq!(Section::try_from("#EVENT"), Err(())); 72 | assert_eq!(Mode::try_from("shape"), Ok(Mode::Shape)); 73 | assert_eq!(Mode::try_from("lines"), Err(())); 74 | } 75 | } -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # See 2 | 3 | # Stable 4 | use_small_heuristics = "Default" 5 | force_explicit_abi = true 6 | hard_tabs = true 7 | max_width = 160 8 | merge_derives = true 9 | newline_style = "Unix" 10 | remove_nested_parens = true 11 | reorder_imports = true 12 | reorder_modules = true 13 | tab_spaces = 4 14 | use_field_init_shorthand = true 15 | use_try_shorthand = true 16 | edition = "2018" 17 | 18 | # Unstable 19 | unstable_features = true 20 | disable_all_formatting = false 21 | indent_style = "Block" 22 | binop_separator = "Back" 23 | combine_control_expr = true 24 | comment_width = 120 25 | condense_wildcard_suffixes = true 26 | control_brace_style = "AlwaysSameLine" 27 | error_on_line_overflow = false 28 | error_on_unformatted = false 29 | fn_args_density = "Compressed" 30 | brace_style = "SameLineWhere" 31 | empty_item_single_line = true 32 | enum_discrim_align_threshold = 20 33 | fn_single_line = false 34 | where_single_line = true 35 | format_strings = true 36 | format_macro_matchers = true 37 | format_macro_bodies = true 38 | imports_indent = "Block" 39 | imports_layout = "Horizontal" 40 | merge_imports = true 41 | match_block_trailing_comma = false 42 | force_multiline_blocks = true 43 | normalize_comments = true 44 | reorder_impl_items = true 45 | report_todo = "Always" 46 | report_fixme = "Always" 47 | skip_children = false 48 | space_after_colon = true 49 | space_before_colon = false 50 | struct_field_align_threshold = 20 51 | spaces_around_ranges = false 52 | struct_lit_single_line = true 53 | trailing_comma = "Never" 54 | trailing_semicolon = true 55 | type_punctuation_density = "Compressed" 56 | format_doc_comments = false 57 | wrap_comments = true 58 | match_arm_blocks = false 59 | overflow_delimited_expr = true 60 | blank_lines_upper_bound = 2 61 | blank_lines_lower_bound = 0 62 | #required_version = "1.0.1" 63 | hide_parse_errors = false 64 | color = "Auto" 65 | #license_template_path = "" 66 | ignore = [] 67 | version = "One" 68 | normalize_doc_attributes = true -------------------------------------------------------------------------------- /ssb_parser/tests/test.ssb: -------------------------------------------------------------------------------- 1 | // Test script for ssb development 2 | #INFO 3 | // Documentation is important! 4 | Title: test 5 | Author: Youka 6 | Description: Just some test data. 7 | Version: 1.0.0 8 | // Custom entry 9 | Foo: Bar 10 | 11 | #TARGET 12 | Width: 1280 13 | Height: 720 14 | // 2.5D supported 15 | Depth: 800 16 | View: orthogonal 17 | 18 | #MACROS 19 | Default: [bold=y] 20 | Mine: [bold=n;color=FF0000] 21 | Another: [position=100,200,-1;rotate-z=180]${Mine}I'm a 22 | 23 | #EVENTS 24 | //0-2.0|||This line is a comment over 2 seconds! 25 | 2.0-5:0.0|Another|Hello, i'm a note!|red, rotated\ntext over multiple lines. 26 | 5:0.0-2:5:0.0|Mine|Draw sth.|[mode=shape;texture=cute]m 0 0 l 50.5 0 50.5 20.125 0 20.125 b 42 1337 26 0 3.141 2.718 a 0 0 180 c 27 | 10:0.0-10:50:0.0||Lets scale some text to double its size!|[animate=500,1000,[scale=2,2,1]]This text is\ngetting huge 28 | 20:.-21:.|||[mode=points;font=Rabi-Ribi]0 0 100 0 66.6 50${Mine}33.3 50 29 | 'show-something'|Default||This will only be shown when the event id is given 30 | 0-1::.||Let's test it!|[font=Arial;size=20.5;bold=y;italic=n;underline=y;strikeout=n;position=-20,1.5;position=100,100,-50;alignment=5;alignment=1,2.7;margin=1,2,3,4;margin=5;margin-top=-1.23;margin-right=+4.56;margin-bottom=-7.89;margin-left=0;wrap-style=nowrap;direction=rtl;space=9.8,7.6;space=5.5;space-h=4;space-v=3;rotate-x=45;rotate-y=90;rotate-z=-135;scale=0.75,1.25,1;scale-x=0.5;scale-y=1.5;scale-z=2;translate=100,200,0;translate-x=-20.4;translate-y=210;translate-z=50;shear=1,-1;shear-x=1.2;shear-y=0.33;matrix=0.5,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1;reset;border=42;border=20,22;border-h=7.5;border-v=-17.83;join=round;cap=square;texture=cute;texfill=0,0,1,0.5,repeat;color=000000,FFFFFF,FF0000,00FF00,0000FF;bordercolor=FFFF00,00FFFF,FF00FF;alpha=80;borderalpha=A,B,C,D;blur=1.2,1.5;blur=6.66;blur-h=11;blur-v=5;blend=screen;target=frame;mask-mode=normal;mask-clear;animate=[];animate=100,-2000,t^2,[size=42;color=0080FF;translate-x=99.9];k=260;kset=0;kcolor=F8008F]Super styled :) 31 | 32 | #RESOURCES 33 | Font: Rabi-Ribi,bold,UmFiaS1SaWJp 34 | Texture: Jitter,data,Sml0dGVy 35 | Texture: cute,url,tests/cute.png 36 | 37 | 38 | // Successfully parsed till end :) -------------------------------------------------------------------------------- /ssb_filter/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | # Project information 3 | name = "ssb_filter" 4 | version = "0.0.0" 5 | authors = ["Christoph 'Youka' Spanknebel"] 6 | description = "Interfaces to ssb rendering for video frameserving and language bridges." 7 | # Project type 8 | workspace = ".." 9 | edition = "2018" 10 | # Documentation 11 | keywords = ["ssb", "filter", "subtitle", "video", "processing"] 12 | categories = ["rendering::data-formats"] 13 | readme = "README.md" 14 | license = "Apache-2.0" 15 | repository = "https://github.com/substation-beta/ssb_implementation" 16 | # Generate metadata (manifest embedding & C header) 17 | build = "build.rs" 18 | 19 | [lib] 20 | # Compile to C dynamic library 21 | crate-type = ["cdylib"] 22 | # Documentation embedded code doesn't need tests 23 | doctest = false 24 | 25 | [features] 26 | # Add at least one media framework 27 | default = ["vapoursynth-interface"] 28 | # Modern media frameworks 29 | vapoursynth-interface = ["vapoursynth", "failure"] 30 | mediafoundation-interface = ["winapi"] 31 | gstreamer-interface = ["gstreamer"] 32 | 33 | [dependencies] 34 | # Depend on renderer module 35 | ssb_renderer = {path = "../ssb_renderer", version = "0.0.1"} 36 | # Debugging 37 | failure = {version = "~0.1.8", optional = true} # https://crates.io/crates/failure 38 | # Interface frameworks 39 | libc = "~0.2.86" # https://crates.io/crates/libc 40 | vapoursynth = {version = "~0.3.0", features = ["vapoursynth-functions", "vsscript-functions"], optional = true} # https://crates.io/crates/vapoursynth 41 | gstreamer = {version = "~0.16.5", optional = true} # https://crates.io/crates/gstreamer 42 | 43 | [target.'cfg(windows)'.dependencies] 44 | # Interface frameworks 45 | winapi = {version = "~0.3.9", optional = true} # https://crates.io/crates/winapi 46 | 47 | [dev-dependencies] 48 | # DLL loading 49 | libloading = "~0.7.0" # https://crates.io/crates/libloading 50 | 51 | [build-dependencies] 52 | # C header generation 53 | cbindgen = "~0.17.0" # https://crates.io/crates/cbindgen 54 | 55 | [target.'cfg(windows)'.build-dependencies] 56 | # Manifest 57 | embed-resource = "~1.5.1" # https://crates.io/crates/embed-resource 58 | # Date & time 59 | chrono = "~0.4.19" # https://crates.io/crates/chrono -------------------------------------------------------------------------------- /ssb_parser/src/utils/functions/convert.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::pattern::TIMESTAMP_PATTERN; 2 | 3 | 4 | pub fn parse_timestamp(timestamp: &str) -> Result { 5 | // Milliseconds factors 6 | const MS_2_MS: u32 = 1; 7 | const S_2_MS: u32 = MS_2_MS * 1000; 8 | const M_2_MS: u32 = S_2_MS * 60; 9 | const H_2_MS: u32 = M_2_MS * 60; 10 | // Calculate time in milliseconds 11 | let mut ms = 0u32; 12 | let captures = TIMESTAMP_PATTERN.captures(timestamp).ok_or_else(|| ())?; 13 | for (unit, factor) in &[("MS", MS_2_MS), ("S", S_2_MS), ("M", M_2_MS), ("HM", M_2_MS), ("H", H_2_MS)] { 14 | if let Some(unit_value) = captures.name(unit) { 15 | if unit_value.start() != unit_value.end() { // Not empty 16 | ms += unit_value.as_str().parse::().map_err(|_| ())? * factor; 17 | } 18 | } 19 | } 20 | // Return time 21 | Ok(ms) 22 | } 23 | 24 | pub fn bool_from_str(text: &str) -> Result { 25 | match text { 26 | "y" => Ok(true), 27 | "n" => Ok(false), 28 | _ => Err(()) 29 | } 30 | } 31 | 32 | pub fn alpha_from_str(text: &str) -> Result { 33 | match text.len() { 34 | 1..=2 => u8::from_str_radix(text, 16).map_err(|_| () ), 35 | _ => Err(()) 36 | } 37 | } 38 | pub fn rgb_from_str(text: &str) -> Result<[u8;3],()> { 39 | match text.len() { 40 | 1..=6 => u32::from_str_radix(text, 16).map(|value| {let bytes = value.to_le_bytes(); [bytes[2], bytes[1], bytes[0]]} ).map_err(|_| () ), 41 | _ => Err(()), 42 | } 43 | } 44 | 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use super::{ 49 | parse_timestamp, 50 | bool_from_str, 51 | alpha_from_str, 52 | rgb_from_str 53 | }; 54 | 55 | #[test] 56 | fn parse_timestamp_various() { 57 | assert_eq!(parse_timestamp(""), Ok(0)); 58 | assert_eq!(parse_timestamp("1:2.3"), Ok(62_003)); 59 | assert_eq!(parse_timestamp("59:59.999"), Ok(3_599_999)); 60 | assert_eq!(parse_timestamp("1::.1"), Ok(3_600_001)); 61 | } 62 | 63 | #[test] 64 | fn parse_bool() { 65 | assert_eq!(bool_from_str("y"), Ok(true)); 66 | assert_eq!(bool_from_str("n"), Ok(false)); 67 | assert_eq!(bool_from_str("no"), Err(())); 68 | } 69 | 70 | #[test] 71 | fn parse_rgb_alpha() { 72 | assert_eq!(alpha_from_str(""), Err(())); 73 | assert_eq!(alpha_from_str("A"), Ok(10)); 74 | assert_eq!(alpha_from_str("C1"), Ok(193)); 75 | assert_eq!(alpha_from_str("1FF"), Err(())); 76 | assert_eq!(rgb_from_str(""), Err(())); 77 | assert_eq!(rgb_from_str("1FF"), Ok([0, 1, 255])); 78 | assert_eq!(rgb_from_str("808080"), Ok([128, 128, 128])); 79 | assert_eq!(rgb_from_str("FFFF01"), Ok([255, 255, 1])); 80 | assert_eq!(rgb_from_str("1FFFFFF"), Err(())); 81 | } 82 | } -------------------------------------------------------------------------------- /ssb_filter/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | fn main() { 4 | // Add profile to crate 5 | let profile = env::var("PROFILE").expect("Build profile should be known!"); 6 | println!("cargo:rustc-env=PROFILE={}", &profile); 7 | 8 | // Generate C header 9 | cbindgen::generate( 10 | env!("CARGO_MANIFEST_DIR") 11 | ).expect("Generating C header by native crate failed!") 12 | .write_to_file( 13 | format!("{}/../target/{}/{}.h", env!("CARGO_MANIFEST_DIR"), &profile, env!("CARGO_PKG_NAME")) 14 | ); 15 | 16 | // Embed version information to binary 17 | #[cfg(windows)] 18 | { 19 | // Path for temporary manifest file 20 | let file_path = std::path::Path::new(&env::var("OUT_DIR").expect("Build output directory should be known!")).join("manifest.rs"); 21 | // Manifest data 22 | let pkg_name = env!("CARGO_PKG_NAME"); 23 | let pkg_description = env!("CARGO_PKG_DESCRIPTION"); 24 | let major_version = env!("CARGO_PKG_VERSION_MAJOR"); 25 | let minor_version = env!("CARGO_PKG_VERSION_MINOR"); 26 | let patch_version = env!("CARGO_PKG_VERSION_PATCH"); 27 | let version_string = env!("CARGO_PKG_VERSION"); 28 | let authors = env!("CARGO_PKG_AUTHORS"); 29 | use chrono::Datelike; 30 | let date = chrono::Local::today(); 31 | // Write manifest code into file 32 | std::fs::write(&file_path, format!( 33 | r#"// Version informations 34 | 1 VERSIONINFO 35 | FILEVERSION {},{},{},0 36 | PRODUCTVERSION {},{},{},0 37 | BEGIN 38 | BLOCK "StringFileInfo" 39 | BEGIN 40 | BLOCK "040904E4" // Language + codepage in hexadecimal (see further down) 41 | BEGIN 42 | VALUE "CompanyName", "{}" 43 | VALUE "FileDescription", "{}" 44 | VALUE "FileVersion", "{}" 45 | VALUE "InternalName", "{}" 46 | VALUE "LegalCopyright", "{}, {}" 47 | VALUE "OriginalFilename", "{}.dll" 48 | VALUE "ProductName", "{}" 49 | VALUE "ProductVersion", "{}" 50 | END 51 | END 52 | BLOCK "VarFileInfo" 53 | BEGIN 54 | VALUE "Translation", 0x409, 1252 // English language (0x409) with ANSI codepage (1252) 55 | END 56 | END"#, 57 | major_version, minor_version, patch_version, 58 | major_version, minor_version, patch_version, 59 | authors, 60 | pkg_description, 61 | version_string, 62 | pkg_name, 63 | date.year(), authors, 64 | pkg_name, 65 | pkg_name, 66 | version_string 67 | )).expect("Couldn't create temporary output file!"); 68 | // Compile and link manifest 69 | embed_resource::compile(file_path); 70 | } 71 | } -------------------------------------------------------------------------------- /ssb_parser/src/state/error.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use std::{ 3 | error::Error, 4 | fmt::{ 5 | Display, 6 | Formatter, 7 | Result 8 | } 9 | }; 10 | 11 | 12 | /// SSB parsing specific error type. 13 | #[derive(Debug)] 14 | pub struct ParseError { 15 | msg: String, 16 | pos: Option<(usize, usize)>, 17 | src: Option> 18 | } 19 | impl ParseError { 20 | /// New error with message only. 21 | pub(crate) fn new(msg: &str) -> Self { 22 | Self { 23 | msg: msg.to_owned(), 24 | pos: None, 25 | src: None 26 | } 27 | } 28 | /// New error with message and position. 29 | pub(crate) fn new_with_pos(msg: &str, pos: (usize, usize)) -> Self { 30 | Self { 31 | msg: msg.to_owned(), 32 | pos: Some(pos), 33 | src: None 34 | } 35 | } 36 | /// New error with message and source error. 37 | pub(crate) fn new_with_source(msg: &str, src: E) -> Self 38 | where E: Error + 'static { 39 | Self { 40 | msg: msg.to_owned(), 41 | pos: None, 42 | src: Some(Box::new(src)) 43 | } 44 | } 45 | /// New error with message, position and source error. 46 | pub(crate) fn new_with_pos_source(msg: &str, pos: (usize, usize), src: E) -> Self 47 | where E: Error + 'static { 48 | Self { 49 | msg: msg.to_owned(), 50 | pos: Some(pos), 51 | src: Some(Box::new(src)) 52 | } 53 | } 54 | } 55 | impl Display for ParseError { 56 | fn fmt(&self, f: &mut Formatter) -> Result { 57 | self.pos.map(|pos| write!(f, "{} <{}:{}>", self.msg, pos.0, pos.1)) 58 | .unwrap_or_else(|| write!(f, "{}", self.msg)) 59 | .and_then(|_| write!(f, "{}", self.source().map_or(String::new(), |src| format!("\n{}", src)))) 60 | } 61 | } 62 | impl Error for ParseError { 63 | fn source(&self) -> Option<&(dyn Error + 'static)> { 64 | self.src.as_ref().map(AsRef::as_ref) 65 | } 66 | } 67 | impl From for ParseError { 68 | fn from(err: std::io::Error) -> Self { 69 | Self::new_with_source("IO error!", err) 70 | } 71 | } 72 | 73 | 74 | // Tests 75 | #[cfg(test)] 76 | mod tests { 77 | use super::ParseError; 78 | 79 | #[test] 80 | fn parse_error() { 81 | assert_eq!(ParseError::new("simple").to_string(), "simple"); 82 | } 83 | 84 | #[test] 85 | fn parse_error_with_pos() { 86 | assert_eq!(ParseError::new_with_pos("error somewhere", (1,2)).to_string(), "error somewhere <1:2>"); 87 | } 88 | 89 | #[test] 90 | fn parse_error_with_source() { 91 | assert_eq!(ParseError::new_with_source("error on error", ParseError::new("source")).to_string(), "error on error\nsource"); 92 | } 93 | 94 | #[test] 95 | fn parse_error_with_pos_and_source() { 96 | assert_eq!(ParseError::new_with_pos_source("test", (42, 26), ParseError::new("sourcy")).to_string(), "test <42:26>\nsourcy"); 97 | } 98 | 99 | #[test] 100 | fn parse_error_from_io() { 101 | use std::io::{Error, ErrorKind}; 102 | assert_eq!(ParseError::from(Error::new(ErrorKind::NotFound, "Freddy not found!")).to_string(), "IO error!\nFreddy not found!".to_owned()); 103 | } 104 | } -------------------------------------------------------------------------------- /ssb_parser/src/utils/functions/macros.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::pattern::*; 2 | use std::collections::{HashMap,HashSet}; 3 | 4 | 5 | pub fn flatten_macro<'a>(macro_name: &str, history: &mut HashSet<&'a str>, macros: &'a HashMap, flat_macros: &mut HashMap<&'a str, String>) -> Result<(), MacroError> { 6 | // Macro already flattened? 7 | if flat_macros.contains_key(macro_name) { 8 | return Ok(()); 9 | } 10 | // Macro exists? 11 | let (macro_name, mut flat_macro_value) = macros.get_key_value(macro_name) 12 | .map(|(key,value)| (key.as_str(), value.to_owned())) 13 | .ok_or_else(|| MacroError::NotFound(macro_name.to_owned()))?; 14 | // Macro already in history (avoid infinite loop!) 15 | if history.contains(macro_name) { 16 | return Err(MacroError::InfiniteLoop(macro_name.to_owned())); 17 | } else { 18 | history.insert(macro_name); 19 | } 20 | // Process macro value 21 | while let Some(found) = MACRO_PATTERN.find(&flat_macro_value) { 22 | // Insert sub-macro 23 | let sub_macro_name = &flat_macro_value[found.start()+MACRO_INLINE_START.len()..found.end()-MACRO_INLINE_END.len()]; 24 | if !flat_macros.contains_key(sub_macro_name) { 25 | flatten_macro(sub_macro_name, history, macros, flat_macros)?; 26 | } 27 | let sub_macro_location = found.start()..found.end(); 28 | let sub_macro_value = flat_macros.get(sub_macro_name).ok_or_else(|| MacroError::NotFound(sub_macro_name.to_owned()))?; 29 | flat_macro_value.replace_range(sub_macro_location, sub_macro_value); 30 | } 31 | // Register flat macro 32 | flat_macros.insert( 33 | macro_name, 34 | flat_macro_value 35 | ); 36 | // Everything alright 37 | Ok(()) 38 | } 39 | 40 | #[derive(Debug, PartialEq)] 41 | pub enum MacroError { 42 | NotFound(String), 43 | InfiniteLoop(String) 44 | } 45 | 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::{flatten_macro,HashMap,HashSet,MacroError}; 50 | 51 | #[test] 52 | fn flatten_macro_success() { 53 | // Test data 54 | let mut macros = HashMap::new(); 55 | macros.insert("a".to_owned(), "Hello ${b} test!".to_owned()); 56 | macros.insert("b".to_owned(), "fr${c}".to_owned()); 57 | macros.insert("c".to_owned(), "om".to_owned()); 58 | let mut flat_macros = HashMap::new(); 59 | // Test execution 60 | flatten_macro("a", &mut HashSet::new(), ¯os, &mut flat_macros).unwrap(); 61 | assert_eq!(flat_macros.get("a").unwrap(), "Hello from test!"); 62 | } 63 | #[test] 64 | fn flatten_macro_infinite() { 65 | // Test data 66 | let mut macros = HashMap::new(); 67 | macros.insert("a".to_owned(), "foo ${b}".to_owned()); 68 | macros.insert("b".to_owned(), "${a} bar".to_owned()); 69 | // Test execution 70 | assert_eq!(flatten_macro("a", &mut HashSet::new(), ¯os, &mut HashMap::new()).unwrap_err(), MacroError::InfiniteLoop("a".to_owned())); 71 | } 72 | #[test] 73 | fn flatten_macro_notfound() { 74 | assert_eq!(flatten_macro("x", &mut HashSet::new(), &HashMap::new(), &mut HashMap::new()).unwrap_err(), MacroError::NotFound("x".to_owned())); 75 | } 76 | 77 | #[test] 78 | fn compare_macro_errors() { 79 | assert_ne!(MacroError::InfiniteLoop("".to_owned()), MacroError::NotFound("zzz".to_owned())); 80 | } 81 | } -------------------------------------------------------------------------------- /ssb_filter/tests/c_tests.rs: -------------------------------------------------------------------------------- 1 | mod c_tests { 2 | // Imports 3 | use libloading::Library; 4 | use libc::*; 5 | use std::{ 6 | ffi::CStr, 7 | ptr::null_mut 8 | }; 9 | include!("platform.irs"); // Tests are separated, thus include code 10 | 11 | #[test] 12 | fn test_version() { 13 | unsafe { 14 | let lib = Library::new(platform::dll_path()).expect("Couldn't load DLL!"); 15 | let version_fn = lib.get:: *const c_char>(b"ssb_version\0").expect("Couldn't load symbol 'ssb_version' from DLL!"); 16 | assert_eq!( 17 | CStr::from_ptr(version_fn()).to_string_lossy(), 18 | env!("CARGO_PKG_VERSION") 19 | ); 20 | } 21 | } 22 | 23 | #[test] 24 | fn test_renderer() { 25 | // Get DLL functions 26 | unsafe { 27 | let lib = Library::new(platform::dll_path()).expect("Couldn't load DLL!"); 28 | let new_renderer_by_file_fn = lib.get:: *mut c_void>(b"ssb_new_renderer_by_file\0").expect("Couldn't load symbol 'ssb_new_renderer_by_file' from DLL!"); 29 | let new_renderer_by_script_fn = lib.get:: *mut c_void>(b"ssb_new_renderer_by_script\0").expect("Couldn't load symbol 'ssb_new_renderer_by_script' from DLL!"); 30 | let destroy_renderer_fn = lib.get::(b"ssb_destroy_renderer\0").expect("Couldn't load symbol 'ssb_destroy_renderer' from DLL!"); 31 | let render_by_time_fn = lib.get:: c_int>(b"ssb_render_by_time\0").expect("Couldn't load symbol 'ssb_render_by_time' from DLL!"); 32 | let _render_by_id_fn = lib.get:: c_int>(b"ssb_render_by_id\0").expect("Couldn't load symbol 'ssb_render_by_id' from DLL!"); 33 | // Try rendering 34 | let renderer = new_renderer_by_script_fn( 35 | "#EVENTS\n0-1.|||\0".as_ptr() as *const c_char, 36 | null_mut(), 0 37 | ); 38 | assert_ne!(renderer, null_mut()); 39 | assert_eq!( 40 | render_by_time_fn( 41 | renderer, 42 | 640, 480, 640*3, 43 | "RGB24\0".as_ptr() as *const c_char, 44 | vec![vec![0u8;640*480*3]].iter_mut().map(|plane| plane.as_mut_ptr() ).collect::>().as_ptr(), 45 | 1000, 46 | null_mut(), 0 47 | ), 48 | 0 49 | ); 50 | destroy_renderer_fn(renderer); 51 | // Error case 52 | let mut error_message = vec![0 as c_char;128]; 53 | assert_eq!( 54 | new_renderer_by_file_fn( 55 | "NO_FILE\0".as_ptr() as *const c_char, 56 | error_message.as_mut_ptr(), error_message.len() as c_ushort 57 | ), 58 | null_mut() 59 | ); 60 | assert!( 61 | !CStr::from_ptr(error_message.as_ptr()).to_string_lossy().is_empty(), 62 | "Error message expected!" 63 | ); 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team by given channels (see README; f.e. chat). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /ssb_parser/src/objects/ssb_objects.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use std::{ 3 | convert::TryFrom, 4 | fmt 5 | }; 6 | use super::event_objects::EventObject; 7 | 8 | 9 | // Data minor types 10 | #[derive(Debug, PartialEq, Clone)] 11 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 12 | pub struct Event { 13 | pub trigger: EventTrigger, 14 | pub macro_name: Option, 15 | pub note: Option, 16 | pub data: String, 17 | pub data_location: (usize,usize) 18 | } 19 | #[derive(Debug, PartialEq, Clone)] 20 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 21 | pub struct EventRender { 22 | pub trigger: EventTrigger, 23 | pub objects: Vec 24 | } 25 | #[derive(Debug, PartialEq, Clone)] 26 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 27 | pub enum EventTrigger { 28 | Id(String), 29 | Time((u32,u32)) 30 | } 31 | 32 | #[derive(Debug, PartialEq, Clone)] 33 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 34 | pub enum View { 35 | Perspective, 36 | Orthogonal 37 | } 38 | impl TryFrom<&str> for View { 39 | type Error = (); 40 | fn try_from(value: &str) -> Result { 41 | match value { 42 | "perspective" => Ok(Self::Perspective), 43 | "orthogonal" => Ok(Self::Orthogonal), 44 | _ => Err(()) 45 | } 46 | } 47 | } 48 | 49 | #[derive(Debug, PartialEq, Eq, Hash, Clone)] 50 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 51 | pub struct FontFace { 52 | pub family: String, 53 | pub style: FontStyle 54 | } 55 | impl fmt::Display for FontFace { 56 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 57 | write!(f, "{} ({:?})", self.family, self.style) 58 | } 59 | } 60 | #[derive(Debug, PartialEq, Eq, Hash, Clone)] 61 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 62 | pub enum FontStyle { 63 | Regular, 64 | Bold, 65 | Italic, 66 | BoldItalic 67 | } 68 | impl TryFrom<&str> for FontStyle { 69 | type Error = (); 70 | fn try_from(value: &str) -> Result { 71 | match value { 72 | "regular" => Ok(Self::Regular), 73 | "bold" => Ok(Self::Bold), 74 | "italic" => Ok(Self::Italic), 75 | "bold-italic" => Ok(Self::BoldItalic), 76 | _ => Err(()) 77 | } 78 | } 79 | } 80 | pub type FontData = Vec; 81 | pub type TextureId = String; 82 | #[derive(Debug, PartialEq, Clone)] 83 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 84 | pub enum TextureDataVariant { 85 | Raw(TextureData), 86 | Url(String) 87 | } 88 | pub type TextureData = Vec; 89 | 90 | 91 | // Tests 92 | #[cfg(test)] 93 | mod tests { 94 | #[test] 95 | fn convert() { 96 | use super::{View, FontStyle, TryFrom}; 97 | assert_eq!(View::try_from("orthogonal"), Ok(View::Orthogonal)); 98 | assert_eq!(View::try_from("perspective"), Ok(View::Perspective)); 99 | assert_eq!(View::try_from("fuzzy"), Err(())); 100 | assert_eq!(FontStyle::try_from("regular"), Ok(FontStyle::Regular)); 101 | assert_eq!(FontStyle::try_from("bold"), Ok(FontStyle::Bold)); 102 | assert_eq!(FontStyle::try_from("italic"), Ok(FontStyle::Italic)); 103 | assert_eq!(FontStyle::try_from("bold-italic"), Ok(FontStyle::BoldItalic)); 104 | assert_eq!(FontStyle::try_from("ultra-bold"), Err(())); 105 | } 106 | } -------------------------------------------------------------------------------- /.github/workflows/build_workspace.yml: -------------------------------------------------------------------------------- 1 | # Reference: 2 | 3 | 4 | # Workflow label 5 | name: Build workspace 6 | 7 | # Workflow trigger 8 | on: 9 | push: 10 | branches: 11 | - master 12 | pull_request: 13 | branches: 14 | - master 15 | 16 | # Workflow global environment 17 | env: 18 | RUST_BACKTRACE: 1 19 | IS_RELEASE: ${{ startsWith(github.event.head_commit.message, 'release') }} 20 | 21 | # Workflow tasks 22 | jobs: 23 | build: 24 | name: Build 25 | if: startsWith(github.event.head_commit.message, 'minor') != true 26 | strategy: 27 | matrix: 28 | os: 29 | - ubuntu-18.04 30 | - windows-2019 31 | toolchain: 32 | - stable 33 | - nightly 34 | runs-on: ${{ matrix.os }} 35 | env: 36 | IS_WINDOWS: ${{ startsWith(matrix.os, 'windows') }} 37 | IS_UBUNTU: ${{ startsWith(matrix.os, 'ubuntu') }} 38 | timeout-minutes: 30 39 | steps: 40 | - name: Checkout source 41 | uses: actions/checkout@v2 # https://github.com/marketplace/actions/checkout 42 | - name: Cache build 43 | uses: actions/cache@v2 # https://github.com/marketplace/actions/cache 44 | with: 45 | path: target 46 | key: cargo-build-target-${{ matrix.os }}-${{ matrix.toolchain }}-${{ hashFiles('**/Cargo.toml') }} 47 | - name: Select rust toolchain 48 | uses: actions-rs/toolchain@v1 # https://github.com/marketplace/actions/rust-toolchain 49 | with: 50 | toolchain: ${{ matrix.toolchain }} 51 | profile: minimal 52 | default: true 53 | - name: Install vapoursynth (linux) 54 | if: env.IS_UBUNTU == 'true' 55 | run: sudo bash .scripts/vapoursynth-install.sh 56 | - name: Install windows fonts 57 | if: env.IS_UBUNTU == 'true' 58 | run: echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | sudo debconf-set-selections && sudo apt-get install -y ttf-mscorefonts-installer 59 | - name: Install vapoursynth (windows) 60 | if: env.IS_WINDOWS == 'true' 61 | run: powershell .scripts/vapoursynth64-install.ps1 62 | - name: Build 63 | run: cargo build --verbose 64 | - name: Run tests 65 | run: cargo test --verbose 66 | - name: Report test coverage 67 | if: env.IS_UBUNTU == 'true' && matrix.toolchain == 'stable' 68 | run: sudo -E bash .scripts/kcov.sh 69 | env: 70 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 71 | - name: Publish crates 72 | if: env.IS_RELEASE == 'true' && env.IS_UBUNTU == 'true' && matrix.toolchain == 'stable' 73 | run: | 74 | cargo login $CRATES_TOKEN 75 | cargo publish --manifest-path ssb_parser/Cargo.toml || true 76 | cargo publish --manifest-path ssb_renderer/Cargo.toml || true 77 | cargo publish --manifest-path ssb_filter/Cargo.toml || true 78 | env: 79 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 80 | - name: Collect ssb_filter binaries 81 | if: env.IS_RELEASE == 'true' && env.IS_WINDOWS == 'true' && matrix.toolchain == 'stable' 82 | run: | 83 | cargo build --release 84 | Copy-Item -Destination (New-Item -Path bin -ItemType Directory).Name -Path target/release/* -Include ssb_filter.dll,ssb_filter.dll.lib,ssb_filter.h 85 | - name: Upload ssb_filter binaries 86 | if: env.IS_RELEASE == 'true' && env.IS_WINDOWS == 'true' && matrix.toolchain == 'stable' 87 | uses: actions/upload-artifact@v2 # https://github.com/marketplace/actions/upload-a-build-artifact 88 | with: 89 | name: ssb_filter 90 | path: bin 91 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Substation Beta implementation 2 | Welcome to **ssb_implementation**! 3 | 4 | If you intend to contribute to this project, please read following text carefully to avoid bad misunderstandings and wasted work. Contributions are appreciated but just under correct terms. 5 | 6 | ## Table of contents 7 | 1) [Introduction](#introduction) 8 | 2) [Expectations](#expectations) 9 | 3) [Style guideline](#style-guideline) 10 | 4) [What we're looking for](#what-were-looking-for) 11 | 5) [How to contribute](#how-to-contribute) 12 | 6) [Community](#community) 13 | 14 | ## Introduction 15 | Please start by reading our [license](https://github.com/substation-beta/ssb_implementation/blob/master/LICENSE-APACHE-2.0) and [code-of-conduct](https://github.com/substation-beta/ssb_implementation/blob/master/CODE_OF_CONDUCT.md). If you don't agree with them, this isn't your project. 16 | 17 | Before planning to post issues or request pulls, have a look at our templates: 18 | * [Pull request template](https://github.com/substation-beta/ssb_implementation/blob/master/.github/pull_request_template.md) 19 | * [Issue templates](https://github.com/substation-beta/ssb_implementation/tree/master/.github/ISSUE_TEMPLATE) 20 | 21 | For further questions, visit our [discord chat](https://discord.gg/H8HnPSv). 22 | 23 | ## Expectations 24 | * We're **mentoring** but just to a certain degree. An initial effort should have been done, training in programming basics or computer science isn't part of the offer. We want to speed up development, not slowing it down. 25 | * **Quality** has priority, not getting fastly done. Faults by unnecessary hurry or sentences like "at least it works" are the opposite of "being welcome" here. Putting experiments on users is disrespectful for their time investment. 26 | * Don't get emotional, act logical. **Personal taste** has to back off if there's no explanation why _xy_ makes more sense for others too. Let's combine the best of all! 27 | * **Comment & document** your changes. Keep in mind others may work on same project parts too and don't want to spend much time understanding what you've done. 10 seconds you saved costs another contributor 10 minutes. 28 | * Contributing should happen as **teamwork**, not as competition. Join discussions, look through PRs and issues, merge compatible forks. Ignoring the progress of others leads to lost time (and motivation). 29 | 30 | ## Style guideline 31 | * Nothing comes through **untested**! Try to add tests for critical parts and at least 50%. 32 | * **Comment** enough to leave no questions. In a perfect world code explains himself - _spoiler: we don't live in a perfect world!_ 33 | * You remember [KISS](https://en.wikipedia.org/wiki/KISS_principle)? Less code = less maintenance effort. Keep your code clean (no zombies), optimize by a worthy cost-benefit balance and use standards instead of bloating with dependencies! 34 | * Auto-format doesn't always result in most **readable code**! Review and format for humans. 35 | * Continue present **conventions** and follow **best practices**. Don't break our pattern, code needs no multi-culture. 36 | * Trust in **compiler warnings** and **clippy suggestions**. They mostly know better than you how good code looks like. Edit until your tools stop complaining. 37 | 38 | ## What we're looking for 39 | * Bugfixes 40 | * Feature ideas 41 | * Tools (editors, plugins, pre- & post-processors, ...) 42 | * Tutorials 43 | 44 | ## How to contribute 45 | Main contribution ways are to [open issues](https://github.com/substation-beta/ssb_implementation/issues) and [fork with later pull requests](https://github.com/substation-beta/ssb_implementation/network/members). 46 | 47 | Furthermore you can discuss issues, review PRs, develop supportive software, mention this project in public or chat with the community about your experience. 48 | 49 | ## Community 50 | The community has a high value for this project! 51 | 52 | As much as possible should be discussed in public, important decisions made by multiple individuals and people not getting excluded just because of a little dispute. 53 | 54 | Those who actively contribute and follow the spirit of open source development get the chance to become organization members. -------------------------------------------------------------------------------- /ssb_parser/src/utils/functions/event_iter.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::pattern::*; 2 | 3 | 4 | pub struct EscapedText { 5 | text: String, 6 | tag_starts_ends: Vec<(usize,char)> 7 | } 8 | impl EscapedText { 9 | pub fn new(source: &str) -> Self { 10 | let text = source.replace("\\\\", "\x1B").replace(&("\\".to_owned() + TAG_START), "\x02").replace(&("\\".to_owned() + TAG_END), "\x03").replace("\\n", "\n").replace('\x1B', "\\"); 11 | Self { 12 | text: text.replace('\x02', TAG_START).replace('\x03', TAG_END), 13 | tag_starts_ends: text.char_indices().filter(|(_,c)| *c == TAG_START_CHAR || *c == TAG_END_CHAR).collect() 14 | } 15 | } 16 | pub fn iter(&self) -> TagGeometryIterator { 17 | TagGeometryIterator { 18 | source: self, 19 | pos: 0 20 | } 21 | } 22 | } 23 | pub struct TagGeometryIterator<'src> { 24 | source: &'src EscapedText, 25 | pos: usize 26 | } 27 | impl<'src> Iterator for TagGeometryIterator<'src> { 28 | type Item = (bool, &'src str); 29 | fn next(&mut self) -> Option { 30 | // End of source reached? 31 | if self.pos == self.source.text.len() { 32 | return None; 33 | } 34 | // Find next tag start 35 | let tag_start = self.source.tag_starts_ends.iter().find(|(pos,tag)| *pos >= self.pos && *tag == TAG_START_CHAR).map(|(pos,_)| *pos); 36 | // Match tag or geometry 37 | let is_tag; 38 | let text_chunk; 39 | if tag_start.filter(|pos| *pos == self.pos).is_some() { 40 | is_tag = true; 41 | // Till tag end (considers nested tags) 42 | let mut tag_open_count = 0usize; 43 | if let Some(end_pos) = self.source.tag_starts_ends.iter().find(|(pos,tag)| match *tag { 44 | _ if *pos < self.pos + TAG_START.len() => false, 45 | TAG_START_CHAR => {tag_open_count+=1; false} 46 | TAG_END_CHAR => if tag_open_count == 0 {true} else {tag_open_count-=1; false} 47 | _ => false 48 | }).map(|(pos,_)| *pos) { 49 | text_chunk = &self.source.text[self.pos + TAG_START.len()..end_pos]; 50 | self.pos = end_pos + TAG_END.len(); 51 | // Till end 52 | } else { 53 | text_chunk = &self.source.text[self.pos + TAG_START.len()..]; 54 | self.pos = self.source.text.len(); 55 | } 56 | } else { 57 | is_tag = false; 58 | // Till tag start 59 | if let Some(tag_start) = tag_start { 60 | text_chunk = &self.source.text[self.pos..tag_start]; 61 | self.pos = tag_start; 62 | // Till end 63 | } else { 64 | text_chunk = &self.source.text[self.pos..]; 65 | self.pos = self.source.text.len(); 66 | } 67 | } 68 | // Return tag or geometry 69 | Some((is_tag, text_chunk)) 70 | } 71 | } 72 | 73 | pub struct TagsIterator<'src> { 74 | text: &'src str, 75 | pos: usize 76 | } 77 | impl<'src> TagsIterator<'src> { 78 | pub fn new(text: &'src str) -> Self { 79 | Self { 80 | text, 81 | pos: 0 82 | } 83 | } 84 | } 85 | impl<'src> Iterator for TagsIterator<'src> { 86 | type Item = (&'src str, Option<&'src str>); 87 | fn next(&mut self) -> Option { 88 | // End of source reached? 89 | if self.pos == self.text.len() { 90 | return None; 91 | } 92 | // Find next tag separator (considers nested tags) 93 | let mut tag_open_count = 0usize; 94 | let tag_sep = self.text.char_indices().skip(self.pos).find(|(_,c)| match *c { 95 | TAG_START_CHAR => {tag_open_count+=1; false} 96 | TAG_END_CHAR => {if tag_open_count > 0 {tag_open_count-=1} false} 97 | TAG_SEPARATOR if tag_open_count == 0 => true, 98 | _ => false 99 | }).map(|(index,_)| index); 100 | // Match till separator or end 101 | let tag_token; 102 | if let Some(tag_sep) = tag_sep { 103 | tag_token = &self.text[self.pos..tag_sep]; 104 | self.pos = tag_sep + 1 /* TAG_SEPARATOR */; 105 | } else { 106 | tag_token = &self.text[self.pos..]; 107 | self.pos = self.text.len(); 108 | } 109 | // Split into name+value and return 110 | if let Some(tag_assign) = tag_token.find(TAG_ASSIGN) { 111 | Some((&tag_token[..tag_assign], Some(&tag_token[tag_assign + 1 /* TAG_ASSIGN */..]))) 112 | } else { 113 | Some((tag_token, None)) 114 | } 115 | } 116 | } 117 | 118 | 119 | #[cfg(test)] 120 | mod tests { 121 | use super::{EscapedText,TagsIterator}; 122 | 123 | #[test] 124 | fn tag_geometry_iter() { 125 | let text = EscapedText::new("[tag1][tag2=[inner_tag]]geometry1\\[geometry1_continue\\\\[tag3]geometry2\\n[tag4"); 126 | let mut iter = text.iter(); 127 | assert_eq!(iter.next(), Some((true, "tag1"))); 128 | assert_eq!(iter.next(), Some((true, "tag2=[inner_tag]"))); 129 | assert_eq!(iter.next(), Some((false, "geometry1[geometry1_continue\\"))); 130 | assert_eq!(iter.next(), Some((true, "tag3"))); 131 | assert_eq!(iter.next(), Some((false, "geometry2\n"))); 132 | assert_eq!(iter.next(), Some((true, "tag4"))); 133 | assert_eq!(iter.next(), None); 134 | } 135 | 136 | #[test] 137 | fn tags_iter() { 138 | let mut iter = TagsIterator::new("mode=points;reset;animate=0,-500,[position=200,100.5];color=ff00ff;mask-clear"); 139 | assert_eq!(iter.next(), Some(("mode", Some("points")))); 140 | assert_eq!(iter.next(), Some(("reset", None))); 141 | assert_eq!(iter.next(), Some(("animate", Some("0,-500,[position=200,100.5]")))); 142 | assert_eq!(iter.next(), Some(("color", Some("ff00ff")))); 143 | assert_eq!(iter.next(), Some(("mask-clear", None))); 144 | assert_eq!(iter.next(), None); 145 | } 146 | } -------------------------------------------------------------------------------- /ssb_filter/src/c.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use libc::*; 3 | use ssb_renderer::{ 4 | ssb_parser::{Ssb, SsbRender}, 5 | image::{ColorType, ImageView}, 6 | RenderTrigger, 7 | SsbRenderer 8 | }; 9 | use std::{ 10 | convert::TryFrom, 11 | error::Error, 12 | ffi::CStr, 13 | fs::File, 14 | io::{BufRead, BufReader, Cursor}, 15 | ptr::null_mut, 16 | slice::{from_raw_parts, from_raw_parts_mut} 17 | }; 18 | 19 | 20 | // Helpers 21 | fn error_to_c(error: Box, error_message: *mut c_char, error_message_capacity: c_ushort) { 22 | if !error_message.is_null() && error_message_capacity > 0 { 23 | let mut msg = error.to_string(); 24 | msg.truncate((error_message_capacity-1) as usize); 25 | msg.push('\0'); 26 | unsafe {msg.as_ptr().copy_to(error_message as *mut u8, msg.len());} 27 | } 28 | } 29 | fn into_ptr(data: T) -> *mut c_void { 30 | Box::into_raw(Box::new(data)) as *mut c_void 31 | } 32 | fn free_ptr(ptr: *mut c_void) { 33 | if !ptr.is_null() { 34 | unsafe {Box::from_raw(ptr);} 35 | } 36 | } 37 | 38 | /// Get library version as C string. 39 | #[no_mangle] 40 | pub extern fn ssb_version() -> *const c_char { 41 | concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char 42 | } 43 | 44 | /// Create renderer instance by file. 45 | /// 46 | /// **file** mustn't be *null*. 47 | /// 48 | /// **error_message** can be *null*. 49 | /// 50 | /// Returns renderer instance or *null*. 51 | #[no_mangle] 52 | pub extern fn ssb_new_renderer_by_file(file: *const c_char, error_message: *mut c_char, error_message_capacity: c_ushort) -> *mut c_void { 53 | match ssb_new_renderer_by_file_inner(file) { 54 | Ok(renderer) => into_ptr(renderer), 55 | Err(error) => { 56 | error_to_c(error, error_message, error_message_capacity); 57 | null_mut() 58 | } 59 | } 60 | } 61 | fn ssb_new_renderer_by_file_inner(file: *const c_char) -> Result> { 62 | ssb_new_renderer_inner(BufReader::new( 63 | File::open(unsafe{ CStr::from_ptr(file) }.to_str()?)? 64 | )) 65 | } 66 | fn ssb_new_renderer_inner(script: R) -> Result> { 67 | Ok(SsbRenderer::new( 68 | Ssb::default().parse_owned(script) 69 | .and_then(SsbRender::try_from)? 70 | )) 71 | } 72 | 73 | /// Create renderer instance by script content. 74 | /// 75 | /// **script** mustn't be *null*. 76 | /// 77 | /// **error_message** can be *null*. 78 | /// 79 | /// Returns renderer instance or *null*. 80 | #[no_mangle] 81 | pub extern fn ssb_new_renderer_by_script(script: *const c_char, error_message: *mut c_char, error_message_capacity: c_ushort) -> *mut c_void { 82 | match ssb_new_renderer_by_script_inner(script) { 83 | Ok(renderer) => into_ptr(renderer), 84 | Err(error) => { 85 | error_to_c(error, error_message, error_message_capacity); 86 | null_mut() 87 | } 88 | } 89 | } 90 | fn ssb_new_renderer_by_script_inner(script: *const c_char) -> Result> { 91 | ssb_new_renderer_inner( 92 | Cursor::new(unsafe{ CStr::from_ptr(script) }.to_str()?) 93 | ) 94 | } 95 | 96 | /// Destroy renderer instance. 97 | /// 98 | /// **renderer** can be *null*. 99 | #[no_mangle] 100 | pub extern fn ssb_destroy_renderer(renderer: *mut c_void) { 101 | free_ptr(renderer); 102 | } 103 | 104 | /// Render on image by time. 105 | /// 106 | /// **renderer** can be *null*. 107 | /// 108 | /// **color_type** mustn't be *null*. 109 | /// 110 | /// **planes** mustn't be *null* and contains enough pointers with enough data for given **color_type**. 111 | /// 112 | /// **error_message** can be *null*. 113 | /// 114 | /// Returns 0 on success, 1 on error. 115 | #[no_mangle] 116 | pub extern fn ssb_render_by_time( 117 | renderer: *mut c_void, 118 | width: c_ushort, height: c_ushort, stride: c_uint, color_type: *const c_char, planes: *const *mut c_uchar, 119 | time: c_uint, 120 | error_message: *mut c_char, error_message_capacity: c_ushort 121 | ) -> c_int { 122 | match ssb_render_by_time_inner(renderer, width, height, stride, color_type, planes, time) { 123 | Ok(()) => 0, 124 | Err(error) => { 125 | error_to_c(error, error_message, error_message_capacity); 126 | 1 127 | } 128 | } 129 | } 130 | fn ssb_render_by_time_inner( 131 | renderer: *mut c_void, 132 | width: c_ushort, height: c_ushort, stride: c_uint, color_type: *const c_char, planes: *const *mut c_uchar, 133 | time: c_uint 134 | ) -> Result<(), Box> { 135 | ssb_render_inner( 136 | renderer, 137 | width, height, stride, color_type, planes, 138 | RenderTrigger::Time(time) 139 | ) 140 | } 141 | fn ssb_render_inner( 142 | renderer: *mut c_void, 143 | width: c_ushort, height: c_ushort, stride: c_uint, color_type: *const c_char, planes: *const *mut c_uchar, 144 | trigger: RenderTrigger 145 | ) -> Result<(), Box> { 146 | if !renderer.is_null() { 147 | unsafe { 148 | let color_type = ColorType::by_name( CStr::from_ptr(color_type).to_str()? )?; 149 | (*(renderer as *mut SsbRenderer)).render( 150 | ImageView::new( 151 | width, 152 | height, 153 | stride, 154 | color_type, 155 | { 156 | let min_data_size = height as usize * stride as usize; 157 | from_raw_parts(planes, color_type.planes() as usize) 158 | .iter() 159 | .map(|plane| from_raw_parts_mut(*plane, min_data_size) ) 160 | .collect() 161 | } 162 | )?, 163 | trigger 164 | )?; 165 | } 166 | } 167 | Ok(()) 168 | } 169 | 170 | /// Render on image by id. 171 | /// 172 | /// **renderer** can be *null*. 173 | /// 174 | /// **color_type** mustn't be *null*. 175 | /// 176 | /// **planes** mustn't be *null* and contains enough pointers with enough data for given **color_type**. 177 | /// 178 | /// **id** mustn't be *null*. 179 | /// 180 | /// **error_message** can be *null*. 181 | /// 182 | /// Returns 0 on success, 1 on error. 183 | #[no_mangle] 184 | pub extern fn ssb_render_by_id( 185 | renderer: *mut c_void, 186 | width: c_ushort, height: c_ushort, stride: c_uint, color_type: *const c_char, planes: *const *mut c_uchar, 187 | id: *const c_char, 188 | error_message: *mut c_char, error_message_capacity: c_ushort 189 | ) -> c_int { 190 | match ssb_render_by_id_inner(renderer, width, height, stride, color_type, planes, id) { 191 | Ok(()) => 0, 192 | Err(error) => { 193 | error_to_c(error, error_message, error_message_capacity); 194 | 1 195 | } 196 | } 197 | } 198 | fn ssb_render_by_id_inner( 199 | renderer: *mut c_void, 200 | width: c_ushort, height: c_ushort, stride: c_uint, color_type: *const c_char, planes: *const *mut c_uchar, 201 | id: *const c_char 202 | ) -> Result<(), Box> { 203 | ssb_render_inner( 204 | renderer, 205 | width, height, stride, color_type, planes, 206 | RenderTrigger::Id(unsafe{ CStr::from_ptr(id) }.to_str()?) 207 | ) 208 | } -------------------------------------------------------------------------------- /ssb_filter/src/vapoursynth.rs: -------------------------------------------------------------------------------- 1 | // See 2 | 3 | // Imports 4 | use vapoursynth::{ 5 | prelude::*, 6 | plugins::*, 7 | core::CoreRef, 8 | video_info::VideoInfo, 9 | make_filter_function, 10 | export_vapoursynth_plugin 11 | }; 12 | use failure::{Error, err_msg, format_err, bail}; 13 | use ssb_renderer::{ 14 | ssb_parser::{Ssb,SsbRender}, 15 | image::{ColorType,ImageView}, 16 | RenderTrigger, 17 | SsbRenderer 18 | }; 19 | use std::{ 20 | io::{BufRead,BufReader,Cursor}, 21 | fs::File, 22 | convert::TryFrom, 23 | sync::Mutex, 24 | cell::RefCell, 25 | slice::from_raw_parts_mut 26 | }; 27 | 28 | // Register functions to plugin 29 | export_vapoursynth_plugin! { 30 | // Plugin configuration 31 | Metadata { 32 | // Internal unique key 33 | identifier: "de.youka.ssb", 34 | // Namespace/prefix of plugin functions 35 | namespace: "ssb", 36 | // Plugin description 37 | name: "SSB subtitle plugin.", 38 | // Plugin does changes? (optimization) 39 | read_only: false 40 | }, 41 | // Plugin functions 42 | [ 43 | RenderFunction::new(), 44 | RenderRawFunction::new() 45 | ] 46 | } 47 | 48 | // Create vapoursynth functions 49 | make_filter_function! { 50 | // Name rust & vapoursynth function 51 | RenderFunction, "render" 52 | // Vapoursynth function call 53 | fn create_render<'core>( 54 | _api: API, 55 | _core: CoreRef<'core>, 56 | clip: Node<'core>, 57 | script: &[u8] 58 | ) -> Result + 'core>>, Error> { 59 | Ok(Some(Box::new( 60 | build_render_filter(clip, BufReader::new( 61 | File::open( 62 | String::from_utf8( script.to_vec() )? 63 | )? 64 | ))? 65 | ))) 66 | } 67 | } 68 | make_filter_function! { 69 | // Name rust & vapoursynth function 70 | RenderRawFunction, "render_raw" 71 | // Vapoursynth function call 72 | fn create_render_raw<'core>( 73 | _api: API, 74 | _core: CoreRef<'core>, 75 | clip: Node<'core>, 76 | data: &[u8] 77 | ) -> Result + 'core>>, Error> { 78 | Ok(Some(Box::new( 79 | build_render_filter(clip, Cursor::new(data))? 80 | ))) 81 | } 82 | } 83 | 84 | // Build vapoursynth filter instance 85 | fn build_render_filter(clip: Node, reader: R) -> Result 86 | where R: BufRead { 87 | Ok(RenderFilter{ 88 | source: clip, 89 | renderer: Mutex::new(RefCell::new(SsbRenderer::new( 90 | Ssb::default().parse_owned(reader) 91 | .and_then(SsbRender::try_from) 92 | .map_err(|err| err_msg(err.to_string()) )? 93 | ))) 94 | }) 95 | } 96 | 97 | // Filter class 98 | struct RenderFilter<'core> { 99 | source: Node<'core>, 100 | renderer: Mutex> 101 | } 102 | impl<'core> Filter<'core> for RenderFilter<'core> { 103 | // Output video meta information 104 | fn video_info(&self, _api: API, _core: CoreRef<'core>) -> Vec> { 105 | // Just take from local video node 106 | vec![self.source.info()] 107 | } 108 | 109 | // Fetch frame from pipeline for local filter 110 | fn get_frame_initial( 111 | &self, 112 | _api: API, 113 | _core: CoreRef<'core>, 114 | context: FrameContext, 115 | n: usize 116 | ) -> Result>, Error> { 117 | // Just fetch it, nothing more 118 | self.source.request_frame_filter(context, n); 119 | Ok(None) 120 | } 121 | 122 | // Process available frame 123 | fn get_frame( 124 | &self, 125 | _api: API, 126 | core: CoreRef<'core>, 127 | context: FrameContext, 128 | n: usize 129 | ) -> Result, Error> { 130 | // Get frame 131 | let frame = self.source 132 | .get_frame_filter(context, n) 133 | .ok_or_else(|| format_err!("Couldn't get the source frame!"))?; 134 | // Check RGB(A) format 135 | let format = frame.format(); 136 | if format.color_family() == ColorFamily::RGB && (3..4).contains(&format.plane_count()) && format.sample_type() == SampleType::Integer && format.bits_per_sample() == 8 { 137 | // Create lock on renderer 138 | if let Ok(renderer_refcell) = self.renderer.lock() { 139 | // Make frame copy 140 | let mut frame = FrameRefMut::copy_of(core, &frame); 141 | // Edit frame by SSB 142 | renderer_refcell.borrow_mut().render( 143 | ImageView::new( 144 | frame.width(0) as u16, 145 | frame.height(0) as u16, 146 | frame.stride(0) as u32, 147 | if format.plane_count() == 4 {ColorType::R8G8B8A8} else {ColorType::R8G8B8}, 148 | unsafe { 149 | // Serve color planes 150 | let frame_size = frame.height(0) * frame.stride(0); 151 | if format.plane_count() == 4 { 152 | vec![ 153 | from_raw_parts_mut(frame.data_ptr_mut(0), frame_size), 154 | from_raw_parts_mut(frame.data_ptr_mut(1), frame_size), 155 | from_raw_parts_mut(frame.data_ptr_mut(2), frame_size), 156 | from_raw_parts_mut(frame.data_ptr_mut(3), frame_size) 157 | ] 158 | } else { 159 | vec![ 160 | from_raw_parts_mut(frame.data_ptr_mut(0), frame_size), 161 | from_raw_parts_mut(frame.data_ptr_mut(1), frame_size), 162 | from_raw_parts_mut(frame.data_ptr_mut(2), frame_size) 163 | ] 164 | } 165 | } 166 | ).map_err(|err| err_msg(err.to_string()) )?, 167 | RenderTrigger::Time( 168 | // Calculate frame time (in milliseconds) 169 | match self.source.info().framerate { 170 | Property::Constant(framerate) => (framerate.denominator as f64 / framerate.numerator as f64 * 1000.0 * n as f64) as u32, 171 | Property::Variable => { // Reserved frame properties: 172 | let frame_props = frame.props(); 173 | if let (Ok(duration_numerator), Ok(duration_denominator)) = (frame_props.get_int("_DurationNum"), frame_props.get_int("_DurationDen")) { 174 | (duration_numerator as f64 / duration_denominator as f64 * 1000.0) as u32 175 | } else { 176 | bail!("Couldn't get frame time! No constant framerate or variable frame property.") 177 | } 178 | } 179 | } 180 | ) 181 | ).map_err(|err| err_msg(err.to_string()) )?; 182 | // Pass processed frame copy further through the pipeline 183 | Ok(frame.into()) 184 | } else { 185 | bail!("Couldn't lock renderer!") 186 | } 187 | } else { 188 | bail!("Frame format must be RGB24 or RGBA32!") 189 | } 190 | } 191 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 | Crates | [![Crate Version](https://img.shields.io/crates/v/ssb_parser.svg?label=ssb_parser&logo=rust)](https://crates.io/crates/ssb_parser) [![Crate Version](https://img.shields.io/crates/v/ssb_renderer.svg?label=ssb_renderer&logo=rust)](https://crates.io/crates/ssb_renderer) [![Crate Version](https://img.shields.io/crates/v/ssb_filter.svg?label=ssb_filter&logo=rust)](https://crates.io/crates/ssb_filter) 6 | :---|:--- 7 | Code quality | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/substation-beta/ssb_implementation/Build%20workspace?logo=github)](https://github.com/substation-beta/ssb_implementation/actions?query=workflow%3A%22Build+workspace%22) [![Code Coverage](https://img.shields.io/codecov/c/github/substation-beta/ssb_implementation.svg?logo=Codecov)](https://codecov.io/gh/substation-beta/ssb_implementation) [![Deps.rs dependency status for GitHub repo](https://deps.rs/repo/github/substation-beta/ssb_implementation/status.svg)](https://deps.rs/repo/github/substation-beta/ssb_implementation) 8 | Properties | [![License](https://img.shields.io/github/license/substation-beta/ssb_implementation.svg?logo=github)](https://github.com/substation-beta/ssb_implementation/blob/master/LICENSE-APACHE-2.0) [![Minimal rust version](https://img.shields.io/badge/rust-v1.49%2B-blue?logo=rust)](https://github.com/rust-lang/rust/blob/master/RELEASES.md#version-1490-2020-12-31) [![Last commit](https://img.shields.io/github/last-commit/substation-beta/ssb_implementation.svg?logo=github)](https://github.com/substation-beta/ssb_implementation/graphs/commit-activity) 9 | Platforms | [![Windows support](https://img.shields.io/badge/Windows-supported-success.svg?logo=Windows)](https://en.wikipedia.org/wiki/Microsoft_Windows) [![Linux support](https://img.shields.io/badge/Linux-supported-success.svg?logo=Linux)](https://en.wikipedia.org/wiki/Linux) [![Mac support](https://img.shields.io/badge/OSX-not%20willingly-inactive.svg?logo=Apple)](https://en.wikipedia.org/wiki/MacOS) 10 | Contact | [![Discord channel](https://img.shields.io/discord/586927398277087235.svg?logo=discord)](https://discord.gg/H8HnPSv) [![Github issues](https://img.shields.io/github/issues/substation-beta/ssb_implementation.svg?logo=github)](https://github.com/substation-beta/ssb_implementation/issues) 11 | 12 |   13 | 14 | --- 15 | 16 | | Index of contents | 17 | |:---:| 18 | | [Substation Beta](#substation-beta) • [Components](#components) • [Getting started](#getting-started) • [Building](#building) • [Contributing](#contributing) • [License](#license) • [Acknowledgment](#acknowledgment) | 19 | 20 | # SubStation Beta 21 | This project is the reference implementation of subtitle format `substation beta` (short **ssb**). 22 | 23 | Components target desktop application development and evolve with continuation of [ssb_book](https://github.com/substation-beta/ssb_book). 24 | 25 | # Components 26 | Project contents consist of multiple components which build on top of each other: 27 | 28 | **ssb_parser** → **ssb_renderer** → **ssb_filter** 29 | 30 | ## ssb_parser 31 | Parser of text in ssb format. 32 | 33 | * **Reads** from file or byte stream 34 | * **Validates** content 35 | * **Packs** data into ordered structures 36 | * Allows **serialization** in other format (like JSON) 37 | * Relevant for **rust developers** 38 | 39 | See sub-project [ssb_parser](https://github.com/substation-beta/ssb_implementation/tree/master/ssb_parser). 40 | 41 | ## ssb_renderer 42 | 2d graphics software renderer for ssb format. 43 | 44 | * Builds upon **ssb_parser** for input processing 45 | * **Renders** 2-dimensional graphics on system memory buffers 46 | * **High-performance** by efficient hardware workload 47 | * Relevant for **rust developers** 48 | 49 | See sub-project [ssb_renderer](https://github.com/substation-beta/ssb_implementation/tree/master/ssb_renderer). 50 | 51 | ## ssb_filter 52 | Interfaces to ssb rendering for video frameserving and language wrapping. 53 | 54 | * Builds upon **ssb_renderer** for graphics rendering (including **ssb_parser** for input processing) 55 | * **Plugin** binary for immediate use in popular frameservers 56 | * **C API** provides access by [FFI](https://en.wikipedia.org/wiki/Foreign_function_interface) 57 | * Relevant for **c developers** and **frameserver users** 58 | 59 | See sub-project [ssb_filter](https://github.com/substation-beta/ssb_implementation/tree/master/ssb_filter). 60 | 61 | # Getting started 62 | *TODO* 63 | 64 | ## Install 65 | All components get released on [crates.io](https://crates.io/search?q=ssb%20subtitle) therefore are easy to add as **dependency in rust projects** (ssb_parser & ssb_renderer) or **build to a binary** (ssb_filter). 66 | 67 | ssb_filter gets additionally deployed on [github releases](https://github.com/substation-beta/ssb_implementation/releases) for windows and linux distributions. 68 | 69 | For installing ssb_filter as frameserver plugin, see the documentation of your target frameserver. Usually it's just putting the [shared library](https://en.wikipedia.org/wiki/Library_(computing)#Shared_libraries) into an **autoload directory** or calling an **import** command with the filepath as parameter. 70 | 71 | ## First steps 72 | *TODO* 73 | 74 | ## Documentation 75 | *TODO* 76 | 77 | # Building 78 | All components are projects inside a **rust** workspace - the ssb_implementation repository. Build tool cargo (part of rust toolchain) already manages dependencies. Enabling features may require additional software installed on your operating system. 79 | 80 | 1) Install [rust](https://www.rust-lang.org/tools/install) 81 | 2) Get [ssb_implementation](https://github.com/substation-beta/ssb_implementation) 82 | * Git clone: `git clone https://github.com/substation-beta/ssb_implementation.git` 83 | * [HTTPS download](https://github.com/substation-beta/ssb_implementation/archive/master.zip) 84 | 3) Change current directory to new... 85 | * `./ssb_implementation` (git) 86 | * `./ssb_implementation-master` (https) 87 | 4) Install software for [features](https://doc.rust-lang.org/cargo/reference/manifest.html#usage-in-end-products) 88 | 1) [Vapoursynth](http://www.vapoursynth.com/doc/installation.html) for [ssb_filter](https://github.com/substation-beta/ssb_implementation/blob/master/ssb_filter/Cargo.toml) *vapoursynth-interface* (! on by default !) 89 | 5) Build components by [cargo](https://doc.rust-lang.org/cargo/commands/) 90 | 1) Libraries with release profile: `cargo build --release` 91 | 2) Documentation without dependencies: `cargo doc --no-deps` 92 | 6) Build output can be found in default cargo location 93 | 1) Libraries: `./target/release/*.{rlib,dll,so,dylib,lib,a,h}` 94 | 2) Documentation: `./target/doc/**/*` 95 | 96 | For references see [CI](https://en.wikipedia.org/wiki/Continuous_integration) by **Github Actions** [script](https://github.com/substation-beta/ssb_implementation/blob/master/.github/workflows/build_workspace.yml). 97 | 98 | # Contributing 99 | We welcome contributers but insist on working by our rules. The principle **quality > quantity** has to be followed through every part of this project. 100 | 101 | For details, see [CONTRIBUTING](https://github.com/substation-beta/ssb_implementation/blob/master/CONTRIBUTING.md). 102 | 103 | # License 104 | This project and all of its components are licensed under **Apache-2.0**. Distributed on an "AS-IS" basis, there's no warranty, a limited liability and no grant of trademark rights. 105 | 106 | For more, see [LICENSE](https://github.com/substation-beta/ssb_implementation/blob/master/LICENSE-APACHE-2.0). 107 | 108 | # Acknowledgment 109 | * [ASS (Advanced Substation Alpha)](https://en.wikipedia.org/wiki/SubStation_Alpha#Advanced_SubStation_Alpha) 110 | * [Rust community contributions](https://crates.io/) 111 | -------------------------------------------------------------------------------- /LICENSE-APACHE-2.0: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | --- 179 | 180 | Copyright 2019 Christoph 'Youka' Spanknebel 181 | 182 | Licensed under the Apache License, Version 2.0 (the "License"); 183 | you may not use this file except in compliance with the License. 184 | You may obtain a copy of the License at 185 | 186 | http://www.apache.org/licenses/LICENSE-2.0 187 | 188 | Unless required by applicable law or agreed to in writing, software 189 | distributed under the License is distributed on an "AS IS" BASIS, 190 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 191 | See the License for the specific language governing permissions and 192 | limitations under the License. -------------------------------------------------------------------------------- /ssb_parser/src/objects/event_objects.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use std::convert::TryFrom; 3 | 4 | 5 | // General 6 | #[derive(Debug, PartialEq, Clone)] 7 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 8 | pub struct Point2D { 9 | pub x: Coordinate, 10 | pub y: Coordinate 11 | } 12 | #[derive(Debug, PartialEq, Clone)] 13 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 14 | pub struct Point3D { 15 | pub x: Coordinate, 16 | pub y: Coordinate, 17 | pub z: Coordinate 18 | } 19 | pub type Coordinate = f32; 20 | pub type Degree = f32; 21 | pub type Rgb = [u8;3]; 22 | 23 | 24 | // Objects 25 | #[derive(Debug, PartialEq, Clone)] 26 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 27 | pub enum EventObject { 28 | GeometryShape(Vec), 29 | GeometryPoints(Vec), 30 | GeometryText(String), 31 | TagFont(String), 32 | TagSize(f32), 33 | TagBold(bool), 34 | TagItalic(bool), 35 | TagUnderline(bool), 36 | TagStrikeout(bool), 37 | TagPosition(Point3D), 38 | TagAlignment(Alignment), 39 | TagMargin(Margin), 40 | TagWrapStyle(WrapStyle), 41 | TagDirection(Direction), 42 | TagSpace(Space), 43 | TagRotate(Rotate), 44 | TagScale(Scale), 45 | TagTranslate(Translate), 46 | TagShear(Shear), 47 | TagMatrix(Box<[Degree;16]>), 48 | TagReset, 49 | TagBorder(Border), 50 | TagJoin(Join), 51 | TagCap(Cap), 52 | TagTexture(String), 53 | TagTexFill{ 54 | x0: Degree, 55 | y0: Degree, 56 | x1: Degree, 57 | y1: Degree, 58 | wrap: TextureWrapping 59 | }, 60 | TagColor(Color), 61 | TagBorderColor(Color), 62 | TagAlpha(Alpha), 63 | TagBorderAlpha(Alpha), 64 | TagBlur(Blur), 65 | TagBlend(Blend), 66 | TagTarget(Target), 67 | TagMaskMode(MaskMode), 68 | TagMaskClear, 69 | TagAnimate(Box), 70 | TagKaraoke(u32), 71 | TagKaraokeSet(i32), 72 | TagKaraokeColor(Rgb) 73 | } 74 | 75 | 76 | // Object properties 77 | #[derive(Debug, PartialEq, Clone)] 78 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 79 | pub enum ShapeSegment { 80 | MoveTo(Point2D), 81 | LineTo(Point2D), 82 | CurveTo(Point2D, Point2D, Point2D), 83 | ArcBy(Point2D, Degree), 84 | Close 85 | } 86 | #[derive(Debug, PartialEq, Clone)] 87 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 88 | pub enum Alignment { 89 | Numpad(Numpad), 90 | Offset(Point2D) 91 | } 92 | #[derive(Debug, PartialEq, Clone)] 93 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 94 | pub enum Numpad { 95 | TopLeft, TopCenter, TopRight, 96 | MiddleLeft, MiddleCenter, MiddleRight, 97 | BottomLeft, BottomCenter, BottomRight 98 | } 99 | impl TryFrom for Numpad { 100 | type Error = (); 101 | fn try_from(value: u8) -> Result { 102 | match value { 103 | 1 => Ok(Self::BottomLeft), 104 | 2 => Ok(Self::BottomCenter), 105 | 3 => Ok(Self::BottomRight), 106 | 4 => Ok(Self::MiddleLeft), 107 | 5 => Ok(Self::MiddleCenter), 108 | 6 => Ok(Self::MiddleRight), 109 | 7 => Ok(Self::TopLeft), 110 | 8 => Ok(Self::TopCenter), 111 | 9 => Ok(Self::TopRight), 112 | _ => Err(()) 113 | } 114 | } 115 | } 116 | #[derive(Debug, PartialEq, Clone)] 117 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 118 | pub enum Margin { 119 | All(Coordinate, Coordinate, Coordinate, Coordinate), 120 | Top(Coordinate), 121 | Right(Coordinate), 122 | Bottom(Coordinate), 123 | Left(Coordinate) 124 | } 125 | #[derive(Debug, PartialEq, Clone)] 126 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 127 | pub enum WrapStyle { 128 | Space, 129 | Character, 130 | NoWrap 131 | } 132 | impl TryFrom<&str> for WrapStyle { 133 | type Error = (); 134 | fn try_from(value: &str) -> Result { 135 | match value { 136 | "space" => Ok(Self::Space), 137 | "character" => Ok(Self::Character), 138 | "nowrap" => Ok(Self::NoWrap), 139 | _ => Err(()) 140 | } 141 | } 142 | } 143 | #[derive(Debug, PartialEq, Clone)] 144 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 145 | pub enum Direction { 146 | LeftToRight, 147 | RightToLeft, 148 | TopToBottom, 149 | BottomToTop 150 | } 151 | impl TryFrom<&str> for Direction { 152 | type Error = (); 153 | fn try_from(value: &str) -> Result { 154 | match value { 155 | "ltr" => Ok(Self::LeftToRight), 156 | "rtl" => Ok(Self::RightToLeft), 157 | "ttb" => Ok(Self::TopToBottom), 158 | "btt" => Ok(Self::BottomToTop), 159 | _ => Err(()) 160 | } 161 | } 162 | } 163 | #[derive(Debug, PartialEq, Clone)] 164 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 165 | pub enum Space { 166 | All(Coordinate, Coordinate), 167 | Horizontal(Coordinate), 168 | Vertical(Coordinate) 169 | } 170 | #[derive(Debug, PartialEq, Clone)] 171 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 172 | pub enum Rotate { 173 | X(Degree), 174 | Y(Degree), 175 | Z(Degree) 176 | } 177 | #[derive(Debug, PartialEq, Clone)] 178 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 179 | pub enum Scale { 180 | All(Degree, Degree, Degree), 181 | X(Degree), 182 | Y(Degree), 183 | Z(Degree) 184 | } 185 | #[derive(Debug, PartialEq, Clone)] 186 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 187 | pub enum Translate { 188 | All(Coordinate, Coordinate, Coordinate), 189 | X(Coordinate), 190 | Y(Coordinate), 191 | Z(Coordinate) 192 | } 193 | #[derive(Debug, PartialEq, Clone)] 194 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 195 | pub enum Shear { 196 | All(Degree, Degree), 197 | X(Degree), 198 | Y(Degree) 199 | } 200 | #[derive(Debug, PartialEq, Clone)] 201 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 202 | pub enum Border { 203 | All(Coordinate, Coordinate), 204 | Horizontal(Coordinate), 205 | Vertical(Coordinate) 206 | } 207 | #[derive(Debug, PartialEq, Clone)] 208 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 209 | pub enum Join { 210 | Round, 211 | Bevel, 212 | Miter 213 | } 214 | impl TryFrom<&str> for Join { 215 | type Error = (); 216 | fn try_from(value: &str) -> Result { 217 | match value { 218 | "round" => Ok(Self::Round), 219 | "bevel" => Ok(Self::Bevel), 220 | "miter" => Ok(Self::Miter), 221 | _ => Err(()) 222 | } 223 | } 224 | } 225 | #[derive(Debug, PartialEq, Clone)] 226 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 227 | pub enum Cap { 228 | Round, 229 | Butt, 230 | Square 231 | } 232 | impl TryFrom<&str> for Cap { 233 | type Error = (); 234 | fn try_from(value: &str) -> Result { 235 | match value { 236 | "round" => Ok(Self::Round), 237 | "butt" => Ok(Self::Butt), 238 | "square" => Ok(Self::Square), 239 | _ => Err(()) 240 | } 241 | } 242 | } 243 | #[derive(Debug, PartialEq, Clone)] 244 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 245 | pub enum TextureWrapping { 246 | Pad, 247 | Clamp, 248 | Repeat, 249 | Mirror 250 | } 251 | impl TryFrom<&str> for TextureWrapping { 252 | type Error = (); 253 | fn try_from(value: &str) -> Result { 254 | match value { 255 | "pad" => Ok(Self::Pad), 256 | "clamp" => Ok(Self::Clamp), 257 | "repeat" => Ok(Self::Repeat), 258 | "mirror" => Ok(Self::Mirror), 259 | _ => Err(()) 260 | } 261 | } 262 | } 263 | #[derive(Debug, PartialEq, Clone)] 264 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 265 | pub enum Color { 266 | Mono(Rgb), 267 | Linear([Rgb;2]), 268 | LinearWithStop([Rgb;3]), 269 | Corners([Rgb;4]), 270 | CornersWithStop([Rgb;5]) 271 | } 272 | #[derive(Debug, PartialEq, Clone)] 273 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 274 | pub enum Alpha { 275 | Mono(u8), 276 | Linear([u8;2]), 277 | LinearWithStop([u8;3]), 278 | Corners([u8;4]), 279 | CornersWithStop([u8;5]) 280 | } 281 | #[derive(Debug, PartialEq, Clone)] 282 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 283 | pub enum Blur { 284 | All(Coordinate, Coordinate), 285 | Horizontal(Coordinate), 286 | Vertical(Coordinate) 287 | } 288 | #[derive(Debug, PartialEq, Clone)] 289 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 290 | pub enum Blend { 291 | Add, 292 | Subtract, 293 | Multiply, 294 | Invert, 295 | Difference, 296 | Screen 297 | } 298 | impl TryFrom<&str> for Blend { 299 | type Error = (); 300 | fn try_from(value: &str) -> Result { 301 | match value { 302 | "add" => Ok(Self::Add), 303 | "subtract" => Ok(Self::Subtract), 304 | "multiply" => Ok(Self::Multiply), 305 | "invert" => Ok(Self::Invert), 306 | "difference" => Ok(Self::Difference), 307 | "screen" => Ok(Self::Screen), 308 | _ => Err(()) 309 | } 310 | } 311 | } 312 | #[derive(Debug, PartialEq, Clone)] 313 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 314 | pub enum Target { 315 | Frame, 316 | Mask 317 | } 318 | impl TryFrom<&str> for Target { 319 | type Error = (); 320 | fn try_from(value: &str) -> Result { 321 | match value { 322 | "frame" => Ok(Self::Frame), 323 | "mask" => Ok(Self::Mask), 324 | _ => Err(()) 325 | } 326 | } 327 | } 328 | #[derive(Debug, PartialEq, Clone)] 329 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 330 | pub enum MaskMode { 331 | Normal, 332 | Invert 333 | } 334 | impl TryFrom<&str> for MaskMode { 335 | type Error = (); 336 | fn try_from(value: &str) -> Result { 337 | match value { 338 | "normal" => Ok(Self::Normal), 339 | "invert" => Ok(Self::Invert), 340 | _ => Err(()) 341 | } 342 | } 343 | } 344 | #[derive(Debug, PartialEq, Clone)] 345 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 346 | pub struct Animate { 347 | pub time: Option<(i32, i32)>, 348 | pub formula: Option, 349 | pub tags: Vec 350 | } 351 | 352 | 353 | // Tests 354 | #[cfg(test)] 355 | mod tests { 356 | #[test] 357 | fn convert() { 358 | use super::{Numpad, WrapStyle, Direction, Join, Cap, TextureWrapping, Blend, Target, MaskMode, TryFrom}; 359 | assert_eq!(Numpad::try_from(7u8), Ok(Numpad::TopLeft)); 360 | assert_eq!(WrapStyle::try_from("character"), Ok(WrapStyle::Character)); 361 | assert_eq!(Direction::try_from("ttb"), Ok(Direction::TopToBottom)); 362 | assert_eq!(Join::try_from("bevel"), Ok(Join::Bevel)); 363 | assert_eq!(Cap::try_from("butt"), Ok(Cap::Butt)); 364 | assert_eq!(TextureWrapping::try_from("mirror"), Ok(TextureWrapping::Mirror)); 365 | assert_eq!(Blend::try_from("invert"), Ok(Blend::Invert)); 366 | assert_eq!(Target::try_from("mask"), Ok(Target::Mask)); 367 | assert_eq!(MaskMode::try_from("invert"), Ok(MaskMode::Invert)); 368 | } 369 | } -------------------------------------------------------------------------------- /ssb_parser/src/parsers/ssb.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use crate::{ 3 | state::{ 4 | error::ParseError, 5 | ssb_state::Section 6 | }, 7 | objects::ssb_objects::{View,Event,EventTrigger,FontFace,FontStyle,FontData,TextureId,TextureDataVariant}, 8 | utils::{ 9 | pattern::*, 10 | functions::convert::parse_timestamp 11 | } 12 | }; 13 | use std::{ 14 | collections::HashMap, 15 | io::BufRead, 16 | convert::TryFrom 17 | }; 18 | 19 | 20 | /// Raw SSB data, representing original input one-by-one (except empty lines and comments). 21 | #[derive(Debug, PartialEq, Clone)] 22 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 23 | pub struct Ssb { 24 | // Info section 25 | pub info_title: Option, 26 | pub info_author: Option, 27 | pub info_description: Option, 28 | pub info_version: Option, 29 | pub info_custom: HashMap, 30 | // Target section 31 | pub target_width: Option, 32 | pub target_height: Option, 33 | pub target_depth: u16, 34 | pub target_view: View, 35 | // Macros section 36 | pub macros: HashMap, 37 | // Events section 38 | pub events: Vec, 39 | // Resources section 40 | pub fonts: HashMap, 41 | pub textures: HashMap 42 | } 43 | impl Default for Ssb { 44 | fn default() -> Self { 45 | Self { 46 | info_title: None, 47 | info_author: None, 48 | info_description: None, 49 | info_version: None, 50 | info_custom: HashMap::default(), 51 | target_width: None, 52 | target_height: None, 53 | target_depth: 1000, 54 | target_view: View::Perspective, 55 | macros: HashMap::default(), 56 | events: Vec::default(), 57 | fonts: HashMap::default(), 58 | textures: HashMap::default() 59 | } 60 | } 61 | } 62 | impl Ssb { 63 | /// Parse SSB input and fill structure (which it owns and returns modified). 64 | pub fn parse_owned(mut self, reader: R) -> Result 65 | where R: BufRead { 66 | self.parse(reader)?; 67 | Ok(self) 68 | } 69 | /// Parse SSB input and fill structure (which it borrows and returns as reference). 70 | pub fn parse(&mut self, reader: R) -> Result<&mut Self, ParseError> 71 | where R: BufRead { 72 | // Initial state 73 | let mut section: Option
= None; 74 | // Iterate through text lines 75 | for (line_index, line) in reader.lines().enumerate() { 76 | // Check for valid UTF-8 and remove carriage return (leftover of windows-ending) 77 | let mut line = line?; 78 | if line.ends_with('\r') {line.pop();} 79 | // Ignore empty lines & comments 80 | if !(line.is_empty() || line.starts_with("//")) { 81 | // Switch or handle section 82 | if let Ok(parsed_section) = Section::try_from(line.as_ref()) { 83 | section = Some(parsed_section); 84 | } else { 85 | match section { 86 | // Info section 87 | Some(Section::Info) => { 88 | // Title 89 | if line.starts_with(INFO_TITLE_KEY) { 90 | self.info_title = Some(line[INFO_TITLE_KEY.len()..].to_owned()); 91 | } 92 | // Author 93 | else if line.starts_with(INFO_AUTHOR_KEY) { 94 | self.info_author = Some(line[INFO_AUTHOR_KEY.len()..].to_owned()); 95 | } 96 | // Description 97 | else if line.starts_with(INFO_DESCRIPTION_KEY) { 98 | self.info_description = Some(line[INFO_DESCRIPTION_KEY.len()..].to_owned()); 99 | } 100 | // Version 101 | else if line.starts_with(INFO_VERSION_KEY) { 102 | self.info_version = Some(line[INFO_VERSION_KEY.len()..].to_owned()); 103 | } 104 | // Custom 105 | else if let Some(separator_pos) = line.find(KEY_SUFFIX).filter(|pos| *pos > 0) { 106 | self.info_custom.insert( 107 | line[..separator_pos].to_owned(), 108 | line[separator_pos + KEY_SUFFIX.len()..].to_owned() 109 | ); 110 | } 111 | // Invalid entry 112 | else { 113 | return Err(ParseError::new_with_pos("Invalid info entry!", (line_index, 0))); 114 | } 115 | } 116 | // Target section 117 | Some(Section::Target) => { 118 | // Width 119 | if line.starts_with(TARGET_WIDTH_KEY) { 120 | self.target_width = Some( 121 | line[TARGET_WIDTH_KEY.len()..].parse().map_err(|_| ParseError::new_with_pos("Invalid target width value!", (line_index, TARGET_WIDTH_KEY.len())) )? 122 | ); 123 | } 124 | // Height 125 | else if line.starts_with(TARGET_HEIGHT_KEY) { 126 | self.target_height = Some( 127 | line[TARGET_HEIGHT_KEY.len()..].parse().map_err(|_| ParseError::new_with_pos("Invalid target height value!", (line_index, TARGET_HEIGHT_KEY.len())) )? 128 | ); 129 | } 130 | // Depth 131 | else if line.starts_with(TARGET_DEPTH_KEY) { 132 | self.target_depth = line[TARGET_DEPTH_KEY.len()..].parse().map_err(|_| ParseError::new_with_pos("Invalid target depth value!", (line_index, TARGET_DEPTH_KEY.len())) )?; 133 | } 134 | // View 135 | else if line.starts_with(TARGET_VIEW_KEY) { 136 | self.target_view = View::try_from(&line[TARGET_VIEW_KEY.len()..]).map_err(|_| ParseError::new_with_pos("Invalid target view value!", (line_index, TARGET_VIEW_KEY.len())) )?; 137 | } 138 | // Invalid entry 139 | else { 140 | return Err(ParseError::new_with_pos("Invalid target entry!", (line_index, 0))); 141 | } 142 | } 143 | // Macros section 144 | Some(Section::Macros) => { 145 | // Macro 146 | if let Some(separator_pos) = line.find(KEY_SUFFIX).filter(|pos| *pos > 0) { 147 | self.macros.insert( 148 | line[..separator_pos].to_owned(), 149 | line[separator_pos + KEY_SUFFIX.len()..].to_owned() 150 | ); 151 | } 152 | // Invalid entry 153 | else { 154 | return Err(ParseError::new_with_pos("Invalid macros entry!", (line_index, 0))); 155 | } 156 | } 157 | // Events section 158 | Some(Section::Events) => { 159 | let mut event_tokens = line.splitn(4, EVENT_SEPARATOR); 160 | if let (Some(trigger), Some(macro_name), Some(note), Some(data)) = (event_tokens.next(), event_tokens.next(), event_tokens.next(), event_tokens.next()) { 161 | // Save event 162 | self.events.push( 163 | Event { 164 | trigger: { 165 | // Tag 166 | if trigger.starts_with('\'') && trigger.len() >= 2 && trigger.ends_with('\'') { 167 | EventTrigger::Id(trigger[1..trigger.len()-1].to_owned()) 168 | // Time 169 | } else if let Some(seperator_pos) = trigger.find(TRIGGER_SEPARATOR) { 170 | let start_time = parse_timestamp(&trigger[..seperator_pos]).map_err(|_| ParseError::new_with_pos("Start timestamp invalid!", (line_index, 0)) )?; 171 | let end_time = parse_timestamp(&trigger[seperator_pos + 1 /* TRIGGER_SEPARATOR */..]).map_err(|_| ParseError::new_with_pos("End timestamp invalid!", (line_index, seperator_pos + 1 /* TRIGGER_SEPARATOR */) ))?; 172 | if start_time > end_time { 173 | return Err(ParseError::new_with_pos("Start time greater than end time!", (line_index, 0))); 174 | } 175 | EventTrigger::Time((start_time, end_time)) 176 | // Invalid 177 | } else { 178 | return Err(ParseError::new_with_pos("Invalid trigger format!", (line_index, 0))); 179 | } 180 | }, 181 | macro_name: Some(macro_name.to_owned()).filter(|s| !s.is_empty()), 182 | note: Some(note.to_owned()).filter(|s| !s.is_empty()), 183 | data: data.to_owned(), 184 | data_location: (line_index, trigger.len() + macro_name.len() + note.len() + 3 /* 3x EVENT_SEPARATOR */) 185 | } 186 | ); 187 | } 188 | // Invalid entry 189 | else { 190 | return Err(ParseError::new_with_pos("Invalid events entry!", (line_index, 0))); 191 | } 192 | } 193 | // Resources section 194 | Some(Section::Resources) => { 195 | // Font 196 | if line.starts_with(RESOURCES_FONT_KEY) { 197 | // Parse tokens 198 | let mut font_tokens = line[RESOURCES_FONT_KEY.len()..].splitn(3, VALUE_SEPARATOR); 199 | if let (Some(family), Some(style), Some(data)) = (font_tokens.next(), font_tokens.next(), font_tokens.next()) { 200 | // Save font 201 | self.fonts.insert( 202 | FontFace { 203 | family: family.to_owned(), 204 | style: FontStyle::try_from(style).map_err(|_| ParseError::new_with_pos("Font style invalid!", (line_index, RESOURCES_FONT_KEY.len() + family.len() + 1 /* VALUE_SEPARATOR */) ))? 205 | }, 206 | base64::decode(data).map_err(|_| ParseError::new_with_pos("Font data not in base64 format!", (line_index, RESOURCES_FONT_KEY.len() + family.len() + style.len() + (1 /* VALUE_SEPARATOR */ << 1))) )? 207 | ); 208 | } else { 209 | return Err(ParseError::new_with_pos("Font family, style and data expected!", (line_index, RESOURCES_FONT_KEY.len()))); 210 | } 211 | } 212 | // Texture 213 | else if line.starts_with(RESOURCES_TEXTURE_KEY) { 214 | // Parse tokens 215 | let mut texture_tokens = line[RESOURCES_TEXTURE_KEY.len()..].splitn(3, VALUE_SEPARATOR); 216 | if let (Some(id), Some(data_type), Some(data)) = (texture_tokens.next(), texture_tokens.next(), texture_tokens.next()) { 217 | // Save texture 218 | self.textures.insert( 219 | id.to_owned(), 220 | match data_type { 221 | // Raw data 222 | "data" => TextureDataVariant::Raw( 223 | base64::decode(data).map_err(|_| ParseError::new_with_pos("Texture data not in base64 format!", (line_index, RESOURCES_TEXTURE_KEY.len() + id.len() + data_type.len() + (1 /* VALUE_SEPARATOR */ << 1))) )? 224 | ), 225 | // Data by url 226 | "url" => TextureDataVariant::Url( 227 | data.to_owned() 228 | ), 229 | _ => return Err(ParseError::new_with_pos("Texture data type invalid!", (line_index, RESOURCES_TEXTURE_KEY.len() + id.len() + 1 /* VALUE_SEPARATOR */))) 230 | } 231 | ); 232 | } else { 233 | return Err(ParseError::new_with_pos("Texture id, data type and data expected!", (line_index, RESOURCES_TEXTURE_KEY.len()))); 234 | } 235 | } 236 | // Invalid entry 237 | else { 238 | return Err(ParseError::new_with_pos("Invalid resources entry!", (line_index, 0))); 239 | } 240 | } 241 | // Unset section 242 | None => return Err(ParseError::new_with_pos("No section set!", (line_index, 0))) 243 | } 244 | } 245 | } 246 | } 247 | // Return self for chaining calls 248 | Ok(self) 249 | } 250 | } -------------------------------------------------------------------------------- /ssb_parser/tests/parse_tests.rs: -------------------------------------------------------------------------------- 1 | mod parse_tests { 2 | // Imports 3 | use ssb_parser::{ 4 | objects::{ 5 | ssb_objects::*, 6 | event_objects::* 7 | }, 8 | Ssb, 9 | SsbRender 10 | }; 11 | use std::{ 12 | collections::HashMap, 13 | convert::TryFrom, 14 | env::set_current_dir, 15 | io::{BufReader, Cursor}, 16 | fs::File 17 | }; 18 | 19 | 20 | // Tester 21 | #[test] 22 | fn test_ssb_simple() { 23 | // Parse 24 | let ssb = Ssb::default().parse_owned(Cursor::new( 25 | " 26 | #INFO 27 | Author: Youka 28 | 29 | #TARGET 30 | Width: 123 31 | 32 | #MACROS 33 | foo: bar 34 | 35 | #EVENTS 36 | 0-1::.|foo|I'm a note!|[color=123abc]Hello world! 37 | 38 | #RESOURCES 39 | Font: bar,bold,dXNhZ2k= 40 | Texture: Fancy,data,RmFuY3k= 41 | " 42 | )).unwrap(); 43 | // Asserts 44 | assert_eq!(ssb.info_title, None); 45 | assert_eq!(ssb.info_author, Some("Youka".to_owned())); 46 | assert_eq!(ssb.target_width, Some(123)); 47 | assert_eq!(ssb.target_height, None); 48 | assert_eq!(ssb.macros.get("foo"), Some(&"bar".to_owned())); 49 | assert_eq!(ssb.macros.get("abc"), None); 50 | let event = ssb.events.get(0).expect("One event expected!"); 51 | assert_eq!(event.trigger, EventTrigger::Time((0, 3600000))); 52 | assert_eq!(event.data, "[color=123abc]Hello world!"); 53 | assert_eq!(ssb.fonts.get(&FontFace {family: "bar".to_owned(), style: FontStyle::Bold}), Some(&vec![117, 115, 97, 103, 105])); 54 | assert_eq!(ssb.fonts.get(&FontFace {family: "".to_owned(), style: FontStyle::Regular}), None); 55 | assert_eq!(ssb.textures.get("Fancy"), Some(&TextureDataVariant::Raw(vec![70, 97, 110, 99, 121]))); 56 | assert_eq!(ssb.textures.get("Nobody"), None); 57 | } 58 | 59 | #[test] 60 | fn test_ssb_complex() { 61 | // Parse 1st phase 62 | let mut ssb = Ssb::default(); 63 | ssb.parse( 64 | BufReader::new( 65 | File::open(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/test.ssb")) 66 | .expect("Test SSB file must exist!") 67 | ) 68 | ).unwrap_or_else(|exception| panic!("SSB parsing error: {}", exception) ); 69 | //println!("{:#?}", ssb); 70 | // Parse 2nd phase 71 | set_current_dir(env!("CARGO_MANIFEST_DIR")).expect("Working directory couldn't set to manifest location?!"); 72 | assert_eq!( 73 | SsbRender::try_from(ssb).unwrap_or_else(|exception| panic!("SSB render data error: {}", exception) ), 74 | SsbRender { 75 | target_width: Some(1280), 76 | target_height: Some(720), 77 | target_depth: 800, 78 | target_view: View::Orthogonal, 79 | events: vec![ 80 | EventRender { 81 | trigger: EventTrigger::Time((2000,300000)), 82 | objects: vec![ 83 | EventObject::TagPosition(Point3D { 84 | x: 100.0, 85 | y: 200.0, 86 | z: -1.0 87 | }), 88 | EventObject::TagRotate(Rotate::Z( 89 | 180.0 90 | )), 91 | EventObject::TagBold( 92 | false 93 | ), 94 | EventObject::TagColor(Color::Mono([ 95 | 255, 0, 0 96 | ])), 97 | EventObject::GeometryText( 98 | "I\'m a red, rotated\ntext over multiple lines.".to_owned() 99 | ) 100 | ] 101 | }, 102 | EventRender { 103 | trigger: EventTrigger::Time((300000,7500000)), 104 | objects: vec![ 105 | EventObject::TagBold( 106 | false 107 | ), 108 | EventObject::TagColor(Color::Mono([ 109 | 255, 0, 0 110 | ])), 111 | EventObject::TagTexture( 112 | "cute".to_owned() 113 | ), 114 | EventObject::GeometryShape(vec![ 115 | ShapeSegment::MoveTo(Point2D { 116 | x: 0.0, 117 | y: 0.0 118 | }), 119 | ShapeSegment::LineTo(Point2D { 120 | x: 50.5, 121 | y: 0.0 122 | }), 123 | ShapeSegment::LineTo(Point2D { 124 | x: 50.5, 125 | y: 20.125 126 | }), 127 | ShapeSegment::LineTo(Point2D { 128 | x: 0.0, 129 | y: 20.125 130 | }), 131 | ShapeSegment::CurveTo( 132 | Point2D { 133 | x: 42.0, 134 | y: 1337.0 135 | }, 136 | Point2D { 137 | x: 26.0, 138 | y: 0.0 139 | }, 140 | Point2D { 141 | x: 3.141, 142 | y: 2.718 143 | } 144 | ), 145 | ShapeSegment::ArcBy( 146 | Point2D { 147 | x: 0.0, 148 | y: 0.0 149 | }, 150 | 180.0 151 | ), 152 | ShapeSegment::Close 153 | ]) 154 | ] 155 | }, 156 | EventRender { 157 | trigger: EventTrigger::Time((600000,39000000)), 158 | objects: vec![ 159 | EventObject::TagAnimate(Box::new(Animate { 160 | time: Some((500, 1000)), 161 | formula: None, 162 | tags: vec![ 163 | EventObject::TagScale(Scale::All( 164 | 2.0, 2.0, 1.0 165 | )) 166 | ] 167 | })), 168 | EventObject::GeometryText( 169 | "This text is\ngetting huge".to_owned() 170 | ) 171 | ] 172 | }, 173 | EventRender { 174 | trigger: EventTrigger::Time((1200000,1260000)), 175 | objects: vec![ 176 | EventObject::TagFont( 177 | "Rabi-Ribi".to_owned() 178 | ), 179 | EventObject::GeometryPoints(vec![ 180 | Point2D { 181 | x: 0.0, 182 | y: 0.0 183 | }, 184 | Point2D { 185 | x: 100.0, 186 | y: 0.0 187 | }, 188 | Point2D { 189 | x: 66.6, 190 | y: 50.0 191 | } 192 | ]), 193 | EventObject::TagBold( 194 | false 195 | ), 196 | EventObject::TagColor(Color::Mono([ 197 | 255, 0, 0 198 | ])), 199 | EventObject::GeometryPoints(vec![ 200 | Point2D { 201 | x: 33.3, 202 | y: 50.0 203 | } 204 | ]) 205 | ] 206 | }, 207 | EventRender { 208 | trigger: EventTrigger::Id("show-something".to_owned()), 209 | objects: vec![ 210 | EventObject::TagBold( 211 | true 212 | ), 213 | EventObject::GeometryText( 214 | "This will only be shown when the event id is given".to_owned() 215 | ) 216 | ] 217 | }, 218 | EventRender { 219 | trigger: EventTrigger::Time((0,3600000)), 220 | objects: vec![ 221 | EventObject::TagFont( 222 | "Arial".to_owned() 223 | ), 224 | EventObject::TagSize( 225 | 20.5 226 | ), 227 | EventObject::TagBold( 228 | true 229 | ), 230 | EventObject::TagItalic( 231 | false 232 | ), 233 | EventObject::TagUnderline( 234 | true 235 | ), 236 | EventObject::TagStrikeout( 237 | false 238 | ), 239 | EventObject::TagPosition(Point3D { 240 | x: -20.0, 241 | y: 1.5, 242 | z: 0.0 243 | }), 244 | EventObject::TagPosition(Point3D { 245 | x: 100.0, 246 | y: 100.0, 247 | z: -50.0 248 | }), 249 | EventObject::TagAlignment(Alignment::Numpad( 250 | Numpad::MiddleCenter 251 | )), 252 | EventObject::TagAlignment(Alignment::Offset(Point2D { 253 | x: 1.0, 254 | y: 2.7 255 | })), 256 | EventObject::TagMargin(Margin::All( 257 | 1.0, 2.0, 3.0, 4.0 258 | )), 259 | EventObject::TagMargin(Margin::All( 260 | 5.0, 5.0, 5.0, 5.0 261 | )), 262 | EventObject::TagMargin(Margin::Top( 263 | -1.23 264 | )), 265 | EventObject::TagMargin(Margin::Right( 266 | 4.56 267 | )), 268 | EventObject::TagMargin(Margin::Bottom( 269 | -7.89 270 | )), 271 | EventObject::TagMargin(Margin::Left( 272 | 0.0 273 | )), 274 | EventObject::TagWrapStyle( 275 | WrapStyle::NoWrap 276 | ), 277 | EventObject::TagDirection( 278 | Direction::RightToLeft 279 | ), 280 | EventObject::TagSpace(Space::All( 281 | 9.8, 7.6 282 | )), 283 | EventObject::TagSpace(Space::All( 284 | 5.5, 5.5 285 | )), 286 | EventObject::TagSpace(Space::Horizontal( 287 | 4.0 288 | )), 289 | EventObject::TagSpace(Space::Vertical( 290 | 3.0 291 | )), 292 | EventObject::TagRotate(Rotate::X( 293 | 45.0 294 | )), 295 | EventObject::TagRotate(Rotate::Y( 296 | 90.0 297 | )), 298 | EventObject::TagRotate(Rotate::Z( 299 | -135.0 300 | )), 301 | EventObject::TagScale(Scale::All( 302 | 0.75, 1.25, 1.0 303 | )), 304 | EventObject::TagScale(Scale::X( 305 | 0.5 306 | )), 307 | EventObject::TagScale(Scale::Y( 308 | 1.5 309 | )), 310 | EventObject::TagScale(Scale::Z( 311 | 2.0 312 | )), 313 | EventObject::TagTranslate(Translate::All( 314 | 100.0, 200.0, 0.0 315 | )), 316 | EventObject::TagTranslate(Translate::X( 317 | -20.4 318 | )), 319 | EventObject::TagTranslate(Translate::Y( 320 | 210.0 321 | )), 322 | EventObject::TagTranslate(Translate::Z( 323 | 50.0 324 | )), 325 | EventObject::TagShear(Shear::All( 326 | 1.0, -1.0 327 | )), 328 | EventObject::TagShear(Shear::X( 329 | 1.2 330 | )), 331 | EventObject::TagShear(Shear::Y( 332 | 0.33 333 | )), 334 | EventObject::TagMatrix(Box::new([ 335 | 0.5, 0.0, 0.0, 0.0, 336 | 0.0, 1.0, 0.0, 0.0, 337 | 0.0, 0.0, 1.0, 0.0, 338 | 0.0, 0.0, 0.0, 1.0 339 | ])), 340 | EventObject::TagReset, 341 | EventObject::TagBorder(Border::All( 342 | 42.0, 42.0 343 | )), 344 | EventObject::TagBorder(Border::All( 345 | 20.0, 22.0 346 | )), 347 | EventObject::TagBorder(Border::Horizontal( 348 | 7.5 349 | )), 350 | EventObject::TagBorder(Border::Vertical( 351 | -17.83 352 | )), 353 | EventObject::TagJoin( 354 | Join::Round 355 | ), 356 | EventObject::TagCap( 357 | Cap::Square 358 | ), 359 | EventObject::TagTexture( 360 | "cute".to_owned() 361 | ), 362 | EventObject::TagTexFill { 363 | x0: 0.0, 364 | y0: 0.0, 365 | x1: 1.0, 366 | y1: 0.5, 367 | wrap: TextureWrapping::Repeat 368 | }, 369 | EventObject::TagColor(Color::CornersWithStop([ 370 | [0, 0, 0], 371 | [255, 255, 255], 372 | [255, 0, 0], 373 | [0, 255, 0], 374 | [0, 0, 255] 375 | ])), 376 | EventObject::TagBorderColor(Color::LinearWithStop([ 377 | [255, 255, 0], 378 | [0, 255, 255], 379 | [255, 0, 255] 380 | ])), 381 | EventObject::TagAlpha(Alpha::Mono( 382 | 128 383 | )), 384 | EventObject::TagBorderAlpha(Alpha::Corners([ 385 | 10, 386 | 11, 387 | 12, 388 | 13 389 | ])), 390 | EventObject::TagBlur(Blur::All( 391 | 1.2, 1.5 392 | )), 393 | EventObject::TagBlur(Blur::All( 394 | 6.66, 6.66 395 | )), 396 | EventObject::TagBlur(Blur::Horizontal( 397 | 11.0 398 | )), 399 | EventObject::TagBlur(Blur::Vertical( 400 | 5.0 401 | )), 402 | EventObject::TagBlend( 403 | Blend::Screen 404 | ), 405 | EventObject::TagTarget( 406 | Target::Frame 407 | ), 408 | EventObject::TagMaskMode( 409 | MaskMode::Normal 410 | ), 411 | EventObject::TagMaskClear, 412 | EventObject::TagAnimate(Box::new(Animate { 413 | time: None, 414 | formula: None, 415 | tags: vec![] 416 | })), 417 | EventObject::TagAnimate(Box::new(Animate { 418 | time: Some((100, -2000)), 419 | formula: Some("t^2".to_owned()), 420 | tags: vec![ 421 | EventObject::TagSize( 422 | 42.0 423 | ), 424 | EventObject::TagColor(Color::Mono([ 425 | 0, 128, 255 426 | ])), 427 | EventObject::TagTranslate(Translate::X( 428 | 99.9 429 | )) 430 | ] 431 | })), 432 | EventObject::TagKaraoke( 433 | 260 434 | ), 435 | EventObject::TagKaraokeSet( 436 | 0 437 | ), 438 | EventObject::TagKaraokeColor( 439 | [248, 0, 143] 440 | ), 441 | EventObject::GeometryText( 442 | "Super styled :)".to_owned() 443 | ) 444 | ] 445 | } 446 | ], 447 | fonts: { 448 | let mut fonts = HashMap::new(); 449 | fonts.insert( 450 | FontFace { 451 | family: "Rabi-Ribi".to_owned(), 452 | style: FontStyle::Bold 453 | }, 454 | vec![82,97,98,105,45,82,105,98,105] 455 | ); 456 | fonts 457 | }, 458 | textures: { 459 | let mut textures = HashMap::new(); 460 | textures.insert( 461 | "Jitter".to_owned(), 462 | vec![74,105,116,116,101,114] 463 | ); 464 | textures.insert( 465 | "cute".to_owned(), 466 | vec![137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,32,0,0,0,32,8,0,0,0,0,86,17,37,40,0,0,0,9,112,72,89,115,0,0,46,35,0,0,46,35,1,120,165,63,118,0,0,0,7,116,73,77,69,7,227,4,29,2,32,49,204,41,26,248,0,0,1,179,73,68,65,84,56,203,109,146,177,75,35,81,16,135,127,183,173,164,58,176,72,103,101,113,157,205,221,177,112,141,87,137,254,1,183,149,54,146,226,42,173,172,181,213,38,77,46,214,86,41,18,177,58,60,9,108,35,110,17,228,194,181,65,81,34,132,40,172,176,194,237,30,249,174,120,111,95,54,217,76,53,243,155,111,222,204,27,70,0,131,143,59,17,115,22,237,124,30,0,8,160,33,169,145,22,211,105,67,82,3,192,147,164,68,82,237,248,159,156,101,199,53,43,75,0,103,146,164,230,244,129,166,36,233,204,181,184,49,117,87,121,254,202,196,55,14,120,241,36,73,213,7,147,127,88,150,36,121,47,14,224,192,148,124,207,0,178,154,137,14,152,2,145,29,238,23,192,79,27,68,5,32,219,50,154,159,64,242,201,248,91,89,1,160,107,171,206,225,220,186,93,138,64,22,24,117,53,73,86,141,23,100,51,0,125,91,23,134,214,233,51,11,80,55,122,205,126,161,206,60,240,186,169,130,109,190,150,0,122,69,160,71,9,136,252,34,224,71,243,64,91,115,214,158,5,186,42,217,204,30,70,203,101,160,58,194,29,140,46,70,101,96,120,33,119,48,217,138,22,216,74,230,90,60,106,161,61,154,22,127,81,106,149,253,65,75,106,13,246,109,152,74,169,132,255,173,125,237,6,143,165,216,125,233,186,19,248,136,219,163,74,254,230,238,239,83,233,180,191,155,199,149,163,91,4,196,225,246,162,17,182,195,216,237,225,222,203,15,102,60,206,15,198,187,47,44,42,222,179,234,198,120,188,97,221,189,56,7,222,162,195,37,173,91,249,107,158,95,215,210,97,244,6,226,71,85,65,231,105,114,89,41,246,175,92,78,158,58,129,170,77,52,57,105,221,1,48,172,231,131,200,171,15,1,184,107,157,76,222,49,45,123,254,211,235,133,250,178,182,246,225,253,84,252,15,108,126,214,79,66,138,234,197,0,0,0,0,73,69,78,68,174,66,96,130] 467 | ); 468 | textures 469 | } 470 | } 471 | ); 472 | } 473 | 474 | #[test] 475 | fn test_ssb_errors() { 476 | // Section 477 | assert_eq!( 478 | Ssb::default().parse(Cursor::new("x")).map_err(|err| err.to_string()), 479 | Err("No section set! <0:0>".to_owned()) 480 | ); 481 | // Info 482 | assert_eq!( 483 | Ssb::default().parse(Cursor::new("#INFO\nINVALID_ENTRY")).map_err(|err| err.to_string()), 484 | Err("Invalid info entry! <1:0>".to_owned()) 485 | ); 486 | // Target 487 | assert_eq!( 488 | Ssb::default().parse(Cursor::new("#TARGET\nWidth: 4096\nINVALID_ENTRY")).map_err(|err| err.to_string()), 489 | Err("Invalid target entry! <2:0>".to_owned()) 490 | ); 491 | // Macros 492 | assert_eq!( 493 | Ssb::default().parse(Cursor::new("#MACROS\nabc: []\n123: Hi!\nINVALID_ENTRY")).map_err(|err| err.to_string()), 494 | Err("Invalid macros entry! <3:0>".to_owned()) 495 | ); 496 | // Events 497 | assert_eq!( 498 | Ssb::default().parse(Cursor::new("#EVENTS\nINVALID_ENTRY")).map_err(|err| err.to_string()), 499 | Err("Invalid events entry! <1:0>".to_owned()) 500 | ); 501 | assert_eq!( 502 | Ssb::default().parse(Cursor::new("#EVENTS\n1:-0|||")).map_err(|err| err.to_string()), 503 | Err("Start time greater than end time! <1:0>".to_owned()) 504 | ); 505 | assert_eq!( 506 | Ssb::default().parse(Cursor::new("#EVENTS\n?|||")).map_err(|err| err.to_string()), 507 | Err("Invalid trigger format! <1:0>".to_owned()) 508 | ); 509 | // Resources 510 | assert_eq!( 511 | Ssb::default().parse(Cursor::new("#RESOURCES\nINVALID_ENTRY")).map_err(|err| err.to_string()), 512 | Err("Invalid resources entry! <1:0>".to_owned()) 513 | ); 514 | assert_eq!( 515 | Ssb::default().parse(Cursor::new("#RESOURCES\nFont: myfont,Regula")).map_err(|err| err.to_string()), 516 | Err("Font family, style and data expected! <1:6>".to_owned()) 517 | ); 518 | assert_eq!( 519 | Ssb::default().parse(Cursor::new("#RESOURCES\nTexture: ")).map_err(|err| err.to_string()), 520 | Err("Texture id, data type and data expected! <1:9>".to_owned()) 521 | ); 522 | assert_eq!( 523 | Ssb::default().parse(Cursor::new("#RESOURCES\nTexture: Pikachu,data,INVALID_BASE64")).map_err(|err| err.to_string()), 524 | Err("Texture data not in base64 format! <1:22>".to_owned()) 525 | ); 526 | } 527 | } -------------------------------------------------------------------------------- /ssb_parser/src/parsers/ssb_render.rs: -------------------------------------------------------------------------------- 1 | // Imports 2 | use crate::{ 3 | state::{ 4 | error::ParseError, 5 | ssb_state::{Mode,ShapeSegmentType} 6 | }, 7 | utils::{ 8 | pattern::*, 9 | functions::{ 10 | macros::flatten_macro, 11 | event_iter::{EscapedText,TagsIterator}, 12 | convert::{bool_from_str,alpha_from_str,rgb_from_str}, 13 | option::OptionExt 14 | } 15 | }, 16 | objects::{ 17 | ssb_objects::{View,EventRender,FontFace,FontData,TextureId,TextureData,TextureDataVariant}, 18 | event_objects::{Point2D,Point3D,EventObject,ShapeSegment,Alignment,Numpad,Margin,WrapStyle,Direction,Space,Rotate,Scale,Translate,Shear,Border,Join,Cap,TextureWrapping,Color,Alpha,Blur,Blend,Target,MaskMode,Animate} 19 | }, 20 | parsers::ssb::Ssb 21 | }; 22 | use std::{ 23 | collections::{HashMap,HashSet}, 24 | convert::TryFrom 25 | }; 26 | 27 | 28 | /// Processed SSB data, reduced and evaluated for rendering purposes. 29 | #[derive(Debug, PartialEq, Clone)] 30 | #[cfg_attr(feature = "serialization", derive(serde::Serialize,serde::Deserialize))] 31 | pub struct SsbRender { 32 | // Target section 33 | pub target_width: Option, 34 | pub target_height: Option, 35 | pub target_depth: u16, 36 | pub target_view: View, 37 | // Events section 38 | pub events: Vec, 39 | // Resources section 40 | pub fonts: HashMap, 41 | pub textures: HashMap 42 | } 43 | impl TryFrom for SsbRender { 44 | type Error = ParseError; 45 | fn try_from(data: Ssb) -> Result { 46 | Ok(SsbRender { 47 | target_width: data.target_width, 48 | target_height: data.target_height, 49 | target_depth: data.target_depth, 50 | target_view: data.target_view, 51 | events: { 52 | // Flatten macros & detect infinite recursion 53 | let mut flat_macros = HashMap::with_capacity(data.macros.len()); 54 | for macro_name in data.macros.keys() { 55 | flatten_macro(macro_name, &mut HashSet::new(), &data.macros, &mut flat_macros).map_err(|err| ParseError::new(&format!("Flattening macro '{}' caused error: {:?}", macro_name, err)) )?; 56 | } 57 | // Evaluate events 58 | let mut events = Vec::with_capacity(data.events.len()); 59 | for event in data.events { 60 | // Insert base macro 61 | let mut event_data = event.data.clone(); 62 | if let Some(macro_name) = &event.macro_name { 63 | event_data.insert_str(0, flat_macros.get(macro_name.as_str()).ok_or_else(|| ParseError::new_with_pos(&format!("Base macro '{}' not found to insert!", macro_name), (event.data_location.0, 0)) )?); 64 | } 65 | // Insert inline macros 66 | while let Some(found) = MACRO_PATTERN.find(&event_data) { 67 | let macro_name = &event_data[found.start()+MACRO_INLINE_START.len()..found.end()-MACRO_INLINE_END.len()]; 68 | let macro_location = found.start()..found.end(); 69 | let macro_value = flat_macros.get(macro_name).ok_or_else(|| ParseError::new_with_pos(&format!("Inline macro '{}' not found to insert!", macro_name), event.data_location) )?; 70 | event_data.replace_range(macro_location, macro_value); 71 | } 72 | // Parse objects and save event for rendering 73 | events.push( 74 | EventRender { 75 | trigger: event.trigger.clone(), 76 | objects: parse_objects(&event_data).map_err(|err| ParseError::new_with_pos_source("Invalid event data!", event.data_location, err) )? 77 | } 78 | ); 79 | } 80 | events 81 | }, 82 | fonts: data.fonts, 83 | textures: { 84 | let mut textures = HashMap::with_capacity(data.textures.len()); 85 | for (texture_name, texture_data) in data.textures { 86 | textures.insert( 87 | texture_name.clone(), 88 | match texture_data { 89 | TextureDataVariant::Raw(data) => data, 90 | TextureDataVariant::Url(url) => std::fs::read(&url).map_err(|err| { 91 | ParseError::new_with_source( 92 | &format!("Texture data for '{}' not loadable from file '{}'!", texture_name, url), 93 | err 94 | ) 95 | })? 96 | } 97 | ); 98 | } 99 | textures 100 | } 101 | }) 102 | } 103 | } 104 | 105 | 106 | // Objects parsing 107 | fn parse_objects(event_data: &str) -> Result, ParseError> { 108 | let mut objects = vec![]; 109 | let mut mode = Mode::default(); 110 | for (is_tag, data) in EscapedText::new(event_data).iter() { 111 | if is_tag { 112 | parse_tags(data, &mut objects, Some(&mut mode))?; 113 | } else { 114 | parse_geometries(data, &mut objects, &mode)?; 115 | } 116 | } 117 | Ok(objects) 118 | } 119 | fn parse_tags<'a>(data: &str, objects: &'a mut Vec, mut mode: Option<&mut Mode>) -> Result<&'a mut Vec, ParseError> { 120 | for (tag_name, tag_value) in TagsIterator::new(data) { 121 | #[allow(clippy::redundant_closure)] // Remove wrong hint because of missing lifetime on closure reduction 122 | match tag_name { 123 | "font" => objects.push(EventObject::TagFont( 124 | tag_value.map_else_err_str(|value| Some(value.to_owned()) ) 125 | .map_err(|value| ParseError::new(&format!("Invalid font '{}'!", value)) )? 126 | )), 127 | "size" => objects.push(EventObject::TagSize( 128 | tag_value.map_or_err_str(|value| value.parse() ) 129 | .map_err(|value| ParseError::new(&format!("Invalid size '{}'!", value)) )? 130 | )), 131 | "bold" => objects.push(EventObject::TagBold( 132 | tag_value.map_or_err_str(|value| bool_from_str(value) ) 133 | .map_err(|value| ParseError::new(&format!("Invalid bold '{}'!", value)) )? 134 | )), 135 | "italic" => objects.push(EventObject::TagItalic( 136 | tag_value.map_or_err_str(|value| bool_from_str(value) ) 137 | .map_err(|value| ParseError::new(&format!("Invalid italic '{}'!", value)) )? 138 | )), 139 | "underline" => objects.push(EventObject::TagUnderline( 140 | tag_value.map_or_err_str(|value| bool_from_str(value) ) 141 | .map_err(|value| ParseError::new(&format!("Invalid underline '{}'!", value)) )? 142 | )), 143 | "strikeout" => objects.push(EventObject::TagStrikeout( 144 | tag_value.map_or_err_str(|value| bool_from_str(value) ) 145 | .map_err(|value| ParseError::new(&format!("Invalid strikeout '{}'!", value)) )? 146 | )), 147 | "position" => objects.push(EventObject::TagPosition( 148 | tag_value.map_else_err_str(|value| { 149 | let mut tokens = value.splitn(3, VALUE_SEPARATOR); 150 | Some(Point3D { 151 | x: tokens.next()?.parse().ok()?, 152 | y: tokens.next()?.parse().ok()?, 153 | z: tokens.next().or(Some("0")).and_then(|value| value.parse().ok())? 154 | }) 155 | } ) 156 | .map_err(|value| ParseError::new(&format!("Invalid position '{}'!", value)) )? 157 | )), 158 | "alignment" => objects.push(EventObject::TagAlignment( 159 | tag_value.map_else_err_str(|value| { 160 | Some( 161 | if let Some(sep) = value.find(VALUE_SEPARATOR) { 162 | Alignment::Offset(Point2D { 163 | x: value[..sep].parse().ok()?, 164 | y: value[sep + 1 /* VALUE_SEPARATOR */..].parse().ok()?, 165 | }) 166 | } else { 167 | Alignment::Numpad(Numpad::try_from(value.parse::().ok()?).ok()?) 168 | } 169 | ) 170 | } ) 171 | .map_err(|value| ParseError::new(&format!("Invalid alignment '{}'!", value)) )? 172 | )), 173 | "margin" => objects.push(EventObject::TagMargin( 174 | tag_value.map_else_err_str(|value| { 175 | let mut tokens = value.splitn(4, VALUE_SEPARATOR); 176 | Some( 177 | if let (Some(top), Some(right), Some(bottom), Some(left)) = (tokens.next(), tokens.next(), tokens.next(), tokens.next()) { 178 | Margin::All( 179 | top.parse().ok()?, 180 | right.parse().ok()?, 181 | bottom.parse().ok()?, 182 | left.parse().ok()? 183 | ) 184 | } else { 185 | let margin = value.parse().ok()?; 186 | Margin::All( 187 | margin, 188 | margin, 189 | margin, 190 | margin 191 | ) 192 | } 193 | ) 194 | } ) 195 | .map_err(|value| ParseError::new(&format!("Invalid margin '{}'!", value)) )? 196 | )), 197 | "margin-top" => objects.push(EventObject::TagMargin( 198 | tag_value.map_else_err_str(|value| Some(Margin::Top(value.parse().ok()?)) ) 199 | .map_err(|value| ParseError::new(&format!("Invalid margin top '{}'!", value)) )? 200 | )), 201 | "margin-right" => objects.push(EventObject::TagMargin( 202 | tag_value.map_else_err_str(|value| Some(Margin::Right(value.parse().ok()?)) ) 203 | .map_err(|value| ParseError::new(&format!("Invalid margin right '{}'!", value)) )? 204 | )), 205 | "margin-bottom" => objects.push(EventObject::TagMargin( 206 | tag_value.map_else_err_str(|value| Some(Margin::Bottom(value.parse().ok()?)) ) 207 | .map_err(|value| ParseError::new(&format!("Invalid margin bottom '{}'!", value)) )? 208 | )), 209 | "margin-left" => objects.push(EventObject::TagMargin( 210 | tag_value.map_else_err_str(|value| Some(Margin::Left(value.parse().ok()?)) ) 211 | .map_err(|value| ParseError::new(&format!("Invalid margin left '{}'!", value)) )? 212 | )), 213 | "wrap-style" => objects.push(EventObject::TagWrapStyle( 214 | tag_value.map_or_err_str(|value| WrapStyle::try_from(value) ) 215 | .map_err(|value| ParseError::new(&format!("Invalid wrap style '{}'!", value)) )? 216 | )), 217 | "direction" => objects.push(EventObject::TagDirection( 218 | tag_value.map_or_err_str(|value| Direction::try_from(value) ) 219 | .map_err(|value| ParseError::new(&format!("Invalid direction '{}'!", value)) )? 220 | )), 221 | "space" => objects.push(EventObject::TagSpace( 222 | tag_value.map_else_err_str(|value| { 223 | Some( 224 | if let Some(sep) = value.find(VALUE_SEPARATOR) { 225 | Space::All( 226 | value[..sep].parse().ok()?, 227 | value[sep + 1 /* VALUE_SEPARATOR */..].parse().ok()? 228 | ) 229 | } else { 230 | let space = value.parse().ok()?; 231 | Space::All(space, space) 232 | } 233 | ) 234 | } ) 235 | .map_err(|value| ParseError::new(&format!("Invalid space '{}'!", value)) )? 236 | )), 237 | "space-h" => objects.push(EventObject::TagSpace( 238 | tag_value.map_else_err_str(|value| Some(Space::Horizontal(value.parse().ok()?)) ) 239 | .map_err(|value| ParseError::new(&format!("Invalid space horizontal '{}'!", value)) )? 240 | )), 241 | "space-v" => objects.push(EventObject::TagSpace( 242 | tag_value.map_else_err_str(|value| Some(Space::Vertical(value.parse().ok()?)) ) 243 | .map_err(|value| ParseError::new(&format!("Invalid space vertical '{}'!", value)) )? 244 | )), 245 | "rotate-x" => objects.push(EventObject::TagRotate( 246 | tag_value.map_else_err_str(|value| Some(Rotate::X(value.parse().ok()?)) ) 247 | .map_err(|value| ParseError::new(&format!("Invalid rotate x '{}'!", value)) )? 248 | )), 249 | "rotate-y" => objects.push(EventObject::TagRotate( 250 | tag_value.map_else_err_str(|value| Some(Rotate::Y(value.parse().ok()?)) ) 251 | .map_err(|value| ParseError::new(&format!("Invalid rotate y '{}'!", value)) )? 252 | )), 253 | "rotate-z" => objects.push(EventObject::TagRotate( 254 | tag_value.map_else_err_str(|value| Some(Rotate::Z(value.parse().ok()?)) ) 255 | .map_err(|value| ParseError::new(&format!("Invalid rotate z '{}'!", value)) )? 256 | )), 257 | "scale" => objects.push(EventObject::TagScale( 258 | tag_value.map_else_err_str(|value| { 259 | let mut tokens = value.splitn(3, VALUE_SEPARATOR); 260 | Some(Scale::All( 261 | tokens.next()?.parse().ok()?, 262 | tokens.next()?.parse().ok()?, 263 | tokens.next()?.parse().ok()? 264 | )) 265 | } ) 266 | .map_err(|value| ParseError::new(&format!("Invalid scale '{}'!", value)) )? 267 | )), 268 | "scale-x" => objects.push(EventObject::TagScale( 269 | tag_value.map_else_err_str(|value| Some(Scale::X(value.parse().ok()?)) ) 270 | .map_err(|value| ParseError::new(&format!("Invalid scale x '{}'!", value)) )? 271 | )), 272 | "scale-y" => objects.push(EventObject::TagScale( 273 | tag_value.map_else_err_str(|value| Some(Scale::Y(value.parse().ok()?)) ) 274 | .map_err(|value| ParseError::new(&format!("Invalid scale y '{}'!", value)) )? 275 | )), 276 | "scale-z" => objects.push(EventObject::TagScale( 277 | tag_value.map_else_err_str(|value| Some(Scale::Z(value.parse().ok()?)) ) 278 | .map_err(|value| ParseError::new(&format!("Invalid scale z '{}'!", value)) )? 279 | )), 280 | "translate" => objects.push(EventObject::TagTranslate( 281 | tag_value.map_else_err_str(|value| { 282 | let mut tokens = value.splitn(3, VALUE_SEPARATOR); 283 | Some(Translate::All( 284 | tokens.next()?.parse().ok()?, 285 | tokens.next()?.parse().ok()?, 286 | tokens.next()?.parse().ok()? 287 | )) 288 | } ) 289 | .map_err(|value| ParseError::new(&format!("Invalid translate '{}'!", value)) )? 290 | )), 291 | "translate-x" => objects.push(EventObject::TagTranslate( 292 | tag_value.map_else_err_str(|value| Some(Translate::X(value.parse().ok()?)) ) 293 | .map_err(|value| ParseError::new(&format!("Invalid translate x '{}'!", value)) )? 294 | )), 295 | "translate-y" => objects.push(EventObject::TagTranslate( 296 | tag_value.map_else_err_str(|value| Some(Translate::Y(value.parse().ok()?)) ) 297 | .map_err(|value| ParseError::new(&format!("Invalid translate y '{}'!", value)) )? 298 | )), 299 | "translate-z" => objects.push(EventObject::TagTranslate( 300 | tag_value.map_else_err_str(|value| Some(Translate::Z(value.parse().ok()?)) ) 301 | .map_err(|value| ParseError::new(&format!("Invalid translate z '{}'!", value)) )? 302 | )), 303 | "shear" => objects.push(EventObject::TagShear( 304 | tag_value.map_else_err_str(|value| { 305 | let sep = value.find(VALUE_SEPARATOR)?; 306 | Some(Shear::All( 307 | value[..sep].parse().ok()?, 308 | value[sep + 1 /* VALUE_SEPARATOR */..].parse().ok()? 309 | )) 310 | } ) 311 | .map_err(|value| ParseError::new(&format!("Invalid shear '{}'!", value)) )? 312 | )), 313 | "shear-x" => objects.push(EventObject::TagShear( 314 | tag_value.map_else_err_str(|value| Some(Shear::X(value.parse().ok()?)) ) 315 | .map_err(|value| ParseError::new(&format!("Invalid shear x '{}'!", value)) )? 316 | )), 317 | "shear-y" => objects.push(EventObject::TagShear( 318 | tag_value.map_else_err_str(|value| Some(Shear::Y(value.parse().ok()?)) ) 319 | .map_err(|value| ParseError::new(&format!("Invalid shear y '{}'!", value)) )? 320 | )), 321 | "matrix" => objects.push(EventObject::TagMatrix( 322 | tag_value.map_else_err_str(|value| { 323 | let mut tokens = value.splitn(16, VALUE_SEPARATOR).filter_map(|value| value.parse().ok() ); 324 | Some(Box::new([ 325 | tokens.next()?, tokens.next()?, tokens.next()?, tokens.next()?, 326 | tokens.next()?, tokens.next()?, tokens.next()?, tokens.next()?, 327 | tokens.next()?, tokens.next()?, tokens.next()?, tokens.next()?, 328 | tokens.next()?, tokens.next()?, tokens.next()?, tokens.next()? 329 | ])) 330 | } ) 331 | .map_err(|value| ParseError::new(&format!("Invalid matrix '{}'!", value)) )? 332 | )), 333 | "reset" => objects.push(Some(EventObject::TagReset) 334 | .filter(|_| tag_value.is_none() ) 335 | .ok_or_else(|| ParseError::new("Reset must have no value!") )? 336 | ), 337 | "mode" if mode.is_some() => **mode.as_mut().expect("Impossible :O Checked right before!") = 338 | tag_value.map_or_err_str(|value| Mode::try_from(value) ) 339 | .map_err(|value| ParseError::new(&format!("Invalid mode '{}'!", value)) )?, 340 | "border" => objects.push(EventObject::TagBorder( 341 | tag_value.map_else_err_str(|value| { 342 | Some( 343 | if let Some(sep) = value.find(VALUE_SEPARATOR) { 344 | Border::All( 345 | value[..sep].parse().ok()?, 346 | value[sep + 1 /* VALUE_SEPARATOR */..].parse().ok()? 347 | ) 348 | } else { 349 | let border = value.parse().ok()?; 350 | Border::All(border, border) 351 | } 352 | ) 353 | } ) 354 | .map_err(|value| ParseError::new(&format!("Invalid border '{}'!", value)) )? 355 | )), 356 | "border-h" => objects.push(EventObject::TagBorder( 357 | tag_value.map_else_err_str(|value| Some(Border::Horizontal(value.parse().ok()?)) ) 358 | .map_err(|value| ParseError::new(&format!("Invalid border horizontal '{}'!", value)) )? 359 | )), 360 | "border-v" => objects.push(EventObject::TagBorder( 361 | tag_value.map_else_err_str(|value| Some(Border::Vertical(value.parse().ok()?)) ) 362 | .map_err(|value| ParseError::new(&format!("Invalid border vertical '{}'!", value)) )? 363 | )), 364 | "join" => objects.push(EventObject::TagJoin( 365 | tag_value.map_or_err_str(|value| Join::try_from(value) ) 366 | .map_err(|value| ParseError::new(&format!("Invalid join '{}'!", value)) )? 367 | )), 368 | "cap" => objects.push(EventObject::TagCap( 369 | tag_value.map_or_err_str(|value| Cap::try_from(value) ) 370 | .map_err(|value| ParseError::new(&format!("Invalid cap '{}'!", value)) )? 371 | )), 372 | "texture" => objects.push(EventObject::TagTexture( 373 | tag_value.map(ToOwned::to_owned).unwrap_or_else(|| "".to_owned() ) 374 | )), 375 | "texfill" => objects.push( 376 | tag_value.map_else_err_str(|value| { 377 | let mut tokens = value.splitn(5, VALUE_SEPARATOR); 378 | Some(EventObject::TagTexFill { 379 | x0: tokens.next()?.parse().ok()?, 380 | y0: tokens.next()?.parse().ok()?, 381 | x1: tokens.next()?.parse().ok()?, 382 | y1: tokens.next()?.parse().ok()?, 383 | wrap: TextureWrapping::try_from(tokens.next()?).ok()? 384 | }) 385 | } ) 386 | .map_err(|value| ParseError::new(&format!("Invalid texture filling '{}'!", value)) )? 387 | ), 388 | "color" | "bordercolor" => objects.push({ 389 | let color = tag_value.map_or_err_str(|value| { 390 | let mut tokens = value.splitn(5, VALUE_SEPARATOR); 391 | Ok(match (tokens.next(), tokens.next(), tokens.next(), tokens.next(), tokens.next()) { 392 | (Some(color1), Some(color2), Some(color3), Some(color4), Some(color5)) => 393 | Color::CornersWithStop([ 394 | rgb_from_str(color1)?, 395 | rgb_from_str(color2)?, 396 | rgb_from_str(color3)?, 397 | rgb_from_str(color4)?, 398 | rgb_from_str(color5)? 399 | ]), 400 | (Some(color1), Some(color2), Some(color3), Some(color4), None) => 401 | Color::Corners([ 402 | rgb_from_str(color1)?, 403 | rgb_from_str(color2)?, 404 | rgb_from_str(color3)?, 405 | rgb_from_str(color4)? 406 | ]), 407 | (Some(color1), Some(color2), Some(color3), None, None) => 408 | Color::LinearWithStop([ 409 | rgb_from_str(color1)?, 410 | rgb_from_str(color2)?, 411 | rgb_from_str(color3)? 412 | ]), 413 | (Some(color1), Some(color2), None, None, None) => 414 | Color::Linear([ 415 | rgb_from_str(color1)?, 416 | rgb_from_str(color2)? 417 | ]), 418 | (Some(color1), None, None, None, None) => 419 | Color::Mono( 420 | rgb_from_str(color1)? 421 | ), 422 | _ => return Err(()) 423 | }) 424 | } ); 425 | if tag_name == "color" { 426 | EventObject::TagColor( 427 | color.map_err(|value| ParseError::new(&format!("Invalid color '{}'!", value)) )? 428 | ) 429 | } else { 430 | EventObject::TagBorderColor( 431 | color.map_err(|value| ParseError::new(&format!("Invalid border color '{}'!", value)) )? 432 | ) 433 | } 434 | }), 435 | "alpha" | "borderalpha" => objects.push({ 436 | let alpha = tag_value.map_or_err_str(|value| { 437 | let mut tokens = value.splitn(5, VALUE_SEPARATOR); 438 | Ok(match (tokens.next(), tokens.next(), tokens.next(), tokens.next(), tokens.next()) { 439 | (Some(alpha1), Some(alpha2), Some(alpha3), Some(alpha4), Some(alpha5)) => 440 | Alpha::CornersWithStop([ 441 | alpha_from_str(alpha1)?, 442 | alpha_from_str(alpha2)?, 443 | alpha_from_str(alpha3)?, 444 | alpha_from_str(alpha4)?, 445 | alpha_from_str(alpha5)? 446 | ]), 447 | (Some(alpha1), Some(alpha2), Some(alpha3), Some(alpha4), None) => 448 | Alpha::Corners([ 449 | alpha_from_str(alpha1)?, 450 | alpha_from_str(alpha2)?, 451 | alpha_from_str(alpha3)?, 452 | alpha_from_str(alpha4)? 453 | ]), 454 | (Some(alpha1), Some(alpha2), Some(alpha3), None, None) => 455 | Alpha::LinearWithStop([ 456 | alpha_from_str(alpha1)?, 457 | alpha_from_str(alpha2)?, 458 | alpha_from_str(alpha3)? 459 | ]), 460 | (Some(alpha1), Some(alpha2), None, None, None) => 461 | Alpha::Linear([ 462 | alpha_from_str(alpha1)?, 463 | alpha_from_str(alpha2)? 464 | ]), 465 | (Some(alpha1), None, None, None, None) => 466 | Alpha::Mono( 467 | alpha_from_str(alpha1)? 468 | ), 469 | _ => return Err(()) 470 | }) 471 | } ); 472 | if tag_name == "alpha" { 473 | EventObject::TagAlpha( 474 | alpha.map_err(|value| ParseError::new(&format!("Invalid alpha '{}'!", value)) )? 475 | ) 476 | } else { 477 | EventObject::TagBorderAlpha( 478 | alpha.map_err(|value| ParseError::new(&format!("Invalid border coloalphar '{}'!", value)) )? 479 | ) 480 | } 481 | }), 482 | "blur" => objects.push(EventObject::TagBlur( 483 | tag_value.map_else_err_str(|value| { 484 | Some( 485 | if let Some(sep) = value.find(VALUE_SEPARATOR) { 486 | Blur::All( 487 | value[..sep].parse().ok()?, 488 | value[sep + 1 /* VALUE_SEPARATOR */..].parse().ok()? 489 | ) 490 | } else { 491 | let blur = value.parse().ok()?; 492 | Blur::All(blur, blur) 493 | } 494 | ) 495 | } ) 496 | .map_err(|value| ParseError::new(&format!("Invalid blur '{}'!", value)) )? 497 | )), 498 | "blur-h" => objects.push(EventObject::TagBlur( 499 | tag_value.map_else_err_str(|value| Some(Blur::Horizontal(value.parse().ok()?)) ) 500 | .map_err(|value| ParseError::new(&format!("Invalid blur horizontal '{}'!", value)) )? 501 | )), 502 | "blur-v" => objects.push(EventObject::TagBlur( 503 | tag_value.map_else_err_str(|value| Some(Blur::Vertical(value.parse().ok()?)) ) 504 | .map_err(|value| ParseError::new(&format!("Invalid blur vertical '{}'!", value)) )? 505 | )), 506 | "blend" => objects.push(EventObject::TagBlend( 507 | tag_value.map_or_err_str(|value| Blend::try_from(value) ) 508 | .map_err(|value| ParseError::new(&format!("Invalid blend '{}'!", value)) )? 509 | )), 510 | "target" => objects.push(EventObject::TagTarget( 511 | tag_value.map_or_err_str(|value| Target::try_from(value) ) 512 | .map_err(|value| ParseError::new(&format!("Invalid target '{}'!", value)) )? 513 | )), 514 | "mask-mode" => objects.push(EventObject::TagMaskMode( 515 | tag_value.map_or_err_str(|value| MaskMode::try_from(value) ) 516 | .map_err(|value| ParseError::new(&format!("Invalid mask mode '{}'!", value)) )? 517 | )), 518 | "mask-clear" => objects.push(Some(EventObject::TagMaskClear) 519 | .filter(|_| tag_value.is_none() ) 520 | .ok_or_else(|| ParseError::new("Mask clear must have no value!") )? 521 | ), 522 | "animate" if mode.is_some() => objects.push(EventObject::TagAnimate( 523 | tag_value.map_or(Err(("", None)), |value| { 524 | let captures = ANIMATE_PATTERN.captures(value).ok_or_else(|| (value, None) )?; 525 | Ok(Box::new(Animate { 526 | time: match (captures.name("S"), captures.name("E")) { 527 | (Some(start_time), Some(end_time)) => Some(( 528 | start_time.as_str().parse().map_err(|_| (value, None) )?, 529 | end_time.as_str().parse().map_err(|_| (value, None) )? 530 | )), 531 | _ => None 532 | }, 533 | formula: captures.name("F").map(|value| value.as_str().to_owned() ), 534 | tags: { 535 | let mut tags = vec![]; 536 | parse_tags(captures.name("T").ok_or_else(|| (value, None) )?.as_str(), &mut tags, None).map_err(|err| (value, Some(err)) )?; 537 | tags 538 | } 539 | })) 540 | }) 541 | .map_err(|(value,err)| { 542 | let value = format!("Invalid animate '{}'!", value); 543 | err.map(|err| ParseError::new_with_source(&value, err) ).unwrap_or_else(|| ParseError::new(&value) ) 544 | } )? 545 | )), 546 | "k" => objects.push(EventObject::TagKaraoke( 547 | tag_value.map_or_err_str(|value| value.parse() ) 548 | .map_err(|value| ParseError::new(&format!("Invalid karaoke '{}'!", value)) )? 549 | )), 550 | "kset" => objects.push(EventObject::TagKaraokeSet( 551 | tag_value.map_or_err_str(|value| value.parse() ) 552 | .map_err(|value| ParseError::new(&format!("Invalid karaoke set '{}'!", value)) )? 553 | )), 554 | "kcolor" => objects.push(EventObject::TagKaraokeColor( 555 | tag_value.map_or_err_str(|value| rgb_from_str(value) ) 556 | .map_err(|value| ParseError::new(&format!("Invalid karaoke color '{}'!", value)) )? 557 | )), 558 | _ => return Err(ParseError::new(&format!("Invalid tag '{}'!", tag_name))) 559 | } 560 | } 561 | Ok(objects) 562 | } 563 | fn parse_geometries<'a>(data: &str, objects: &'a mut Vec, mode: &Mode) -> Result<&'a mut Vec, ParseError> { 564 | match mode { 565 | Mode::Text => objects.push(EventObject::GeometryText(data.to_owned())), 566 | Mode::Points => { 567 | // Find points 568 | let tokens = data.split_ascii_whitespace().collect::>(); 569 | let mut points = Vec::with_capacity(tokens.len() >> 1); 570 | let mut tokens = tokens.iter(); 571 | // Collect points 572 | loop { 573 | match (tokens.next(), tokens.next()) { 574 | (Some(x), Some(y)) => points.push(Point2D { 575 | x: x.parse().map_err(|_| ParseError::new(&format!("Invalid X coordinate of point '{}'!", x)) )?, 576 | y: y.parse().map_err(|_| ParseError::new(&format!("Invalid Y coordinate of point '{}'!", y)) )? 577 | }), 578 | (Some(leftover), None) => return Err(ParseError::new(&format!("Points incomplete (leftover: '{}')!", leftover))), 579 | _ => break 580 | } 581 | } 582 | // Save points 583 | objects.push(EventObject::GeometryPoints(points)); 584 | } 585 | Mode::Shape => { 586 | // Find segments 587 | let tokens = data.split_ascii_whitespace().collect::>(); 588 | let mut segments = Vec::with_capacity(tokens.len() >> 2 /* Vague estimation, shrinking later */); 589 | let mut tokens = tokens.iter(); 590 | // Collect segments 591 | let mut segment_type = ShapeSegmentType::default(); 592 | while let Some(token) = tokens.next() { 593 | match *token { 594 | "m" => segment_type = ShapeSegmentType::Move, 595 | "l" => segment_type = ShapeSegmentType::Line, 596 | "b" => segment_type = ShapeSegmentType::Curve, 597 | "a" => segment_type = ShapeSegmentType::Arc, 598 | "c" => {segments.push(ShapeSegment::Close); segment_type = ShapeSegmentType::Move;} 599 | _ => match segment_type { 600 | ShapeSegmentType::Move => segments.push(ShapeSegment::MoveTo(Point2D { 601 | x: token.parse().map_err(|_| ParseError::new(&format!("Invalid X coordinate of move '{}'!", token)) )?, 602 | y: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid Y coordinate of move '{}'!", token)) )? 603 | })), 604 | ShapeSegmentType::Line => segments.push(ShapeSegment::LineTo(Point2D { 605 | x: token.parse().map_err(|_| ParseError::new(&format!("Invalid X coordinate of line '{}'!", token)) )?, 606 | y: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid Y coordinate of line '{}'!", token)) )? 607 | })), 608 | ShapeSegmentType::Curve => segments.push(ShapeSegment::CurveTo( 609 | Point2D { 610 | x: token.parse().map_err(|_| ParseError::new(&format!("Invalid X coordinate of curve first point '{}'!", token)) )?, 611 | y: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid Y coordinate of curve first point '{}'!", token)) )? 612 | }, 613 | Point2D { 614 | x: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid X coordinate of curve second point '{}'!", token)) )?, 615 | y: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid Y coordinate of curve second point '{}'!", token)) )? 616 | }, 617 | Point2D { 618 | x: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid X coordinate of curve third point '{}'!", token)) )?, 619 | y: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid Y coordinate of curve third point '{}'!", token)) )? 620 | } 621 | )), 622 | ShapeSegmentType::Arc => segments.push(ShapeSegment::ArcBy( 623 | Point2D { 624 | x: token.parse().map_err(|_| ParseError::new(&format!("Invalid X coordinate of arc '{}'!", token)) )?, 625 | y: tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid Y coordinate of arc '{}'!", token)) )? 626 | }, 627 | tokens.next().map_or_err_str(|token| token.parse()).map_err(|token| ParseError::new(&format!("Invalid degree of arc '{}'!", token)) )? 628 | )), 629 | } 630 | } 631 | } 632 | // Save segments 633 | segments.shrink_to_fit(); 634 | objects.push(EventObject::GeometryShape(segments)); 635 | } 636 | } 637 | Ok(objects) 638 | } 639 | 640 | 641 | // Tests 642 | #[cfg(test)] 643 | mod tests { 644 | use super::{parse_tags, Mode, parse_geometries}; 645 | 646 | #[test] 647 | fn invalid_tag() { 648 | assert_eq!( 649 | parse_tags("font=Arial;dummy", &mut vec![], None).map_err(|err| err.to_string() ), 650 | Err("Invalid tag 'dummy'!".to_owned()) 651 | ); 652 | assert_eq!( 653 | parse_tags("animate=500,-500,t,[abc]", &mut vec![], Some(&mut Mode::default())).map_err(|err| err.to_string() ), 654 | Err("Invalid animate '500,-500,t,[abc]'!\nInvalid tag 'abc'!".to_owned()) 655 | ); 656 | } 657 | 658 | #[test] 659 | fn invalid_geometry() { 660 | assert_eq!( 661 | parse_geometries("0 0 -1 2.5 3", &mut vec![], &Mode::Points).map_err(|err| err.to_string() ), 662 | Err("Points incomplete (leftover: '3')!".to_owned()) 663 | ); 664 | } 665 | } --------------------------------------------------------------------------------